mirror of
https://github.com/hashicorp/vault.git
synced 2025-09-20 13:21:14 +02:00
LDAP/AD Secrets Engine (#20790)
* adds ldap ember engine (#20786) * adds ldap as mountable and supported secrets engine (#20793) * removes active directory as mountable secrets engine (#20798) * LDAP Config Ember Data Setup (#20863) * adds secret-engine-path adapter * adds model, adapater and serializer for ldap config * adds test for ldap config adapter * addresses PR feedback * updates remaining instances of getURL in secrets-engine-path adapter * adds underscore to getURL method in kubernetes/config adapter * adds check config vars test for kubernetes/config adapter * adds comment regarding primaryKey in secrets-engine-path adapter * adds tab-page-header component for ldap secrets engine (#20941) * LDAP Config Route (#21059) * converts secret-mount-path service to ts and moves kubernetes fetch-config decorator to core addon and converts to ts * adds ldap config route * fixes withConfig import path in kubernetes roles route * updates types in ldap config route * adds unit tests for fetch-secret-config decorator * updates comments in fetch-secret-config decorator * renames fetch-secret-config decorator * LDAP Configure Page Component (#21384) * adds ldap page configure component * removes pauseTest and updates radio card selector in ldap config test * LDAP Configuration (#21430) * adds ldap configuration route * adds secrets-engine-mount-config component to core addon * adds ldap config-cta component * adds display fields to ldap configuration page and test * fixes ldap config-cta test * adds yield to secrets-engine-mount-config component * fixes tests * LDAP Overview Route and Page Component (#21579) * adds ldap overview route and page component * changes toolbar link action type for create role on overview page * LDAP Role Model, Adapter and Serializer (#21655) * adds model, adapter and serializer for ldap roles * addresses review feedback * changes ldap role type from tracked prop to attr and sets in adapter for query methods * adds assertions to verify that frontend only props are returned from query methods in ldap role adapter * LDAP Library Model, Adapter and Serializer (#21728) * adds model, adapter and serializer for ldap library * updates capitalization and punction for ldap role and library form fields * LDAP Roles Create and Edit (#21818) * moves stringify and jsonify helpers to core addon * adds validation error for ttl picker in form field component * adds ldap roles create and edit routes and page component * adds ldap mirage handler and factory for roles * adds example workflow to json editor component * adds tests for ldap page create and edit component * addresses feedback * LDAP Role Details (#22036) * adds ldap role route to pass down model to child routes * adds ldap role details route and page component * updates ldap role model capabilities checks * adds periods to error messages * removes modelFor from ldap roles edit and details routes * adds flash message on ldap role delete success * LDAP Roles (#22070) * adds ldap roles route and page component * update ldap role adapter tests and adds adapter options to query for partialErrorInfo * updates ldap role adapter based on PR feedback * adds filter-input component to core addon * updates ldap roles page to use filter-input component * updates ldap role adapter tests * LDAP Role Credentials (#22142) * adds ldap roles route and page component * update ldap role adapter tests and adds adapter options to query for partialErrorInfo * adds credentials actions to ldap roles list menu and fixes rotate action in details view * adds ldap role credentials route and page component * adds tests for ldap role credentials * LDAP Library Create and Edit (#22171) * adds ldap library create/edit routes and page component * adds ldap library create-and-edit tests and library mirage factory * updates form-field component to display validation errors and warnings for all fields * updates ldap library edit route class name * updates ldap library model interface name * adds missing period in flash message * LDAP Libraries (#22184) * updates interface and class names in ldap roles route * adds ldap libraries route and page component * fixes lint error * LDAP Library Details (#22200) * updates interface and class names in ldap roles route * adds ldap libraries route and page component * fixes lint error * adds ldap library details route and page component * LDAP Library Details Configuration (#22201) * updates interface and class names in ldap roles route * adds ldap libraries route and page component * fixes lint error * adds ldap library details route and page component * adds ldap library details configuration route and page component * updates ldap library check-in enforcement value mapping * fixes issue in code mirror modifier after merging upgrade * fixes failing database secrets test * LDAP Library Account Details (#22287) * adds route and page component for ldap library accounts * adds ldap component for checked out accounts * updates ldap library adapter tests * LDAP Library Check-out (#22289) * adds route and page component for ldap library accounts * adds ldap component for checked out accounts * adds route and page component for ldap library checkout * addresses PR feedback * LDAP Overview Cards (#22325) * adds overview cards to ldap overview route * adds create library toolbar action to ldap overview route * adds acceptance tests for ldap workflows (#22375) * Fetch Secrets Engine Config Decorator Docs (#22416) * removes uneccesary asyncs from ldap route model hooks * updates ldap overview route class name * adds documentation for fetch-secrets-engine-config decorator * add changelog * adding back external links, missed due to merge. * changelog * fix test after merging in dashboard work * Update 20790.txt --------- Co-authored-by: Angel Garbarino <angel@hashicorp.com> Co-authored-by: Angel Garbarino <Monkeychip@users.noreply.github.com>
This commit is contained in:
parent
2d0d5c79ed
commit
a8b593614e
3
changelog/20790.txt
Normal file
3
changelog/20790.txt
Normal file
@ -0,0 +1,3 @@
|
||||
```release-note:feature
|
||||
**UI LDAP secrets engine**: Add LDAP secrets engine to the UI.
|
||||
```
|
@ -3,41 +3,12 @@
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
import ApplicationAdapter from 'vault/adapters/application';
|
||||
import { encodePath } from 'vault/utils/path-encoding-helpers';
|
||||
import SecretsEnginePathAdapter from 'vault/adapters/secrets-engine-path';
|
||||
|
||||
export default class KubernetesConfigAdapter extends ApplicationAdapter {
|
||||
namespace = 'v1';
|
||||
export default class KubernetesConfigAdapter extends SecretsEnginePathAdapter {
|
||||
path = 'config';
|
||||
|
||||
getURL(backend, path = 'config') {
|
||||
return `${this.buildURL()}/${encodePath(backend)}/${path}`;
|
||||
}
|
||||
urlForUpdateRecord(name, modelName, snapshot) {
|
||||
return this.getURL(snapshot.attr('backend'));
|
||||
}
|
||||
urlForDeleteRecord(backend) {
|
||||
return this.getURL(backend);
|
||||
}
|
||||
|
||||
queryRecord(store, type, query) {
|
||||
const { backend } = query;
|
||||
return this.ajax(this.getURL(backend), 'GET').then((resp) => {
|
||||
resp.backend = backend;
|
||||
return resp;
|
||||
});
|
||||
}
|
||||
createRecord() {
|
||||
return this._saveRecord(...arguments);
|
||||
}
|
||||
updateRecord() {
|
||||
return this._saveRecord(...arguments);
|
||||
}
|
||||
_saveRecord(store, { modelName }, snapshot) {
|
||||
const data = store.serializerFor(modelName).serialize(snapshot);
|
||||
const url = this.getURL(snapshot.attr('backend'));
|
||||
return this.ajax(url, 'POST', { data }).then(() => data);
|
||||
}
|
||||
checkConfigVars(backend) {
|
||||
return this.ajax(`${this.getURL(backend, 'check')}`, 'GET');
|
||||
return this.ajax(`${this._getURL(backend, 'check')}`, 'GET');
|
||||
}
|
||||
}
|
||||
|
14
ui/app/adapters/ldap/config.js
Normal file
14
ui/app/adapters/ldap/config.js
Normal file
@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: MPL-2.0
|
||||
*/
|
||||
|
||||
import SecretsEnginePathAdapter from 'vault/adapters/secrets-engine-path';
|
||||
|
||||
export default class LdapConfigAdapter extends SecretsEnginePathAdapter {
|
||||
path = 'config';
|
||||
|
||||
async rotateRoot(backend) {
|
||||
return this.ajax(this._getURL(backend, 'rotate-root'), 'POST');
|
||||
}
|
||||
}
|
67
ui/app/adapters/ldap/library.js
Normal file
67
ui/app/adapters/ldap/library.js
Normal file
@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: MPL-2.0
|
||||
*/
|
||||
|
||||
import NamedPathAdapter from 'vault/adapters/named-path';
|
||||
import { encodePath } from 'vault/utils/path-encoding-helpers';
|
||||
|
||||
export default class LdapLibraryAdapter extends NamedPathAdapter {
|
||||
getURL(backend, name) {
|
||||
const base = `${this.buildURL()}/${encodePath(backend)}/library`;
|
||||
return name ? `${base}/${name}` : base;
|
||||
}
|
||||
|
||||
urlForUpdateRecord(name, modelName, snapshot) {
|
||||
return this.getURL(snapshot.attr('backend'), name);
|
||||
}
|
||||
urlForDeleteRecord(name, modelName, snapshot) {
|
||||
return this.getURL(snapshot.attr('backend'), name);
|
||||
}
|
||||
|
||||
query(store, type, query) {
|
||||
const { backend } = query;
|
||||
return this.ajax(this.getURL(backend), 'GET', { data: { list: true } })
|
||||
.then((resp) => {
|
||||
return resp.data.keys.map((name) => ({ name, backend }));
|
||||
})
|
||||
.catch((error) => {
|
||||
if (error.httpStatus === 404) {
|
||||
return [];
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
queryRecord(store, type, query) {
|
||||
const { backend, name } = query;
|
||||
return this.ajax(this.getURL(backend, name), 'GET').then((resp) => ({ ...resp.data, backend, name }));
|
||||
}
|
||||
|
||||
fetchStatus(backend, name) {
|
||||
const url = `${this.getURL(backend, name)}/status`;
|
||||
return this.ajax(url, 'GET').then((resp) => {
|
||||
const statuses = [];
|
||||
for (const key in resp.data) {
|
||||
const status = {
|
||||
...resp.data[key],
|
||||
account: key,
|
||||
library: name,
|
||||
};
|
||||
statuses.push(status);
|
||||
}
|
||||
return statuses;
|
||||
});
|
||||
}
|
||||
checkOutAccount(backend, name, ttl) {
|
||||
const url = `${this.getURL(backend, name)}/check-out`;
|
||||
return this.ajax(url, 'POST', { data: { ttl } }).then((resp) => {
|
||||
const { lease_id, lease_duration, renewable } = resp;
|
||||
const { service_account_name: account, password } = resp.data;
|
||||
return { account, password, lease_id, lease_duration, renewable };
|
||||
});
|
||||
}
|
||||
checkInAccount(backend, name, service_account_names) {
|
||||
const url = `${this.getURL(backend, name)}/check-in`;
|
||||
return this.ajax(url, 'POST', { data: { service_account_names } }).then((resp) => resp.data);
|
||||
}
|
||||
}
|
92
ui/app/adapters/ldap/role.js
Normal file
92
ui/app/adapters/ldap/role.js
Normal file
@ -0,0 +1,92 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: MPL-2.0
|
||||
*/
|
||||
|
||||
import NamedPathAdapter from 'vault/adapters/named-path';
|
||||
import { encodePath } from 'vault/utils/path-encoding-helpers';
|
||||
import { inject as service } from '@ember/service';
|
||||
|
||||
export default class LdapRoleAdapter extends NamedPathAdapter {
|
||||
@service flashMessages;
|
||||
|
||||
getURL(backend, path, name) {
|
||||
const base = `${this.buildURL()}/${encodePath(backend)}/${path}`;
|
||||
return name ? `${base}/${name}` : base;
|
||||
}
|
||||
pathForRoleType(type, isCred) {
|
||||
const staticPath = isCred ? 'static-cred' : 'static-role';
|
||||
const dynamicPath = isCred ? 'creds' : 'role';
|
||||
return type === 'static' ? staticPath : dynamicPath;
|
||||
}
|
||||
|
||||
urlForUpdateRecord(name, modelName, snapshot) {
|
||||
const { backend, type } = snapshot.record;
|
||||
return this.getURL(backend, this.pathForRoleType(type), name);
|
||||
}
|
||||
urlForDeleteRecord(name, modelName, snapshot) {
|
||||
const { backend, type } = snapshot.record;
|
||||
return this.getURL(backend, this.pathForRoleType(type), name);
|
||||
}
|
||||
|
||||
async query(store, type, query, recordArray, options) {
|
||||
const { showPartialError } = options.adapterOptions || {};
|
||||
const { backend } = query;
|
||||
const roles = [];
|
||||
const errors = [];
|
||||
|
||||
for (const roleType of ['static', 'dynamic']) {
|
||||
const url = this.getURL(backend, this.pathForRoleType(roleType));
|
||||
try {
|
||||
const models = await this.ajax(url, 'GET', { data: { list: true } }).then((resp) => {
|
||||
return resp.data.keys.map((name) => ({ name, backend, type: roleType }));
|
||||
});
|
||||
roles.addObjects(models);
|
||||
} catch (error) {
|
||||
if (error.httpStatus !== 404) {
|
||||
errors.push(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length) {
|
||||
const errorMessages = errors.reduce((errors, e) => {
|
||||
e.errors.forEach((error) => {
|
||||
errors.push(`${e.path}: ${error}`);
|
||||
});
|
||||
return errors;
|
||||
}, []);
|
||||
if (errors.length === 2) {
|
||||
// throw error as normal if both requests fail
|
||||
// ignore status code and concat errors to be displayed in Page::Error component with generic message
|
||||
throw { message: 'Error fetching roles:', errors: errorMessages };
|
||||
} else if (showPartialError) {
|
||||
// if only one request fails, surface the error to the user an info level flash message
|
||||
// this may help for permissions errors where a users policy may be incorrect
|
||||
this.flashMessages.info(`Error fetching roles from ${errorMessages.join(', ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
return roles.sortBy('name');
|
||||
}
|
||||
queryRecord(store, type, query) {
|
||||
const { backend, name, type: roleType } = query;
|
||||
const url = this.getURL(backend, this.pathForRoleType(roleType), name);
|
||||
return this.ajax(url, 'GET').then((resp) => ({ ...resp.data, backend, name, type: roleType }));
|
||||
}
|
||||
|
||||
fetchCredentials(backend, type, name) {
|
||||
const url = this.getURL(backend, this.pathForRoleType(type, true), name);
|
||||
return this.ajax(url, 'GET').then((resp) => {
|
||||
if (type === 'dynamic') {
|
||||
const { lease_id, lease_duration, renewable } = resp;
|
||||
return { ...resp.data, lease_id, lease_duration, renewable, type };
|
||||
}
|
||||
return { ...resp.data, type };
|
||||
});
|
||||
}
|
||||
rotateStaticPassword(backend, name) {
|
||||
const url = this.getURL(backend, 'rotate-role', name);
|
||||
return this.ajax(url, 'POST');
|
||||
}
|
||||
}
|
48
ui/app/adapters/secrets-engine-path.js
Normal file
48
ui/app/adapters/secrets-engine-path.js
Normal file
@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: MPL-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* General use adapter to access specified paths on secrets engines
|
||||
* For example /:backend/config is a typical use case for this adapter
|
||||
* These types of records do not have an id and use the backend value of the secrets engine as the primaryKey in the serializer
|
||||
*/
|
||||
|
||||
import ApplicationAdapter from 'vault/adapters/application';
|
||||
import { encodePath } from 'vault/utils/path-encoding-helpers';
|
||||
|
||||
export default class SecretsEnginePathAdapter extends ApplicationAdapter {
|
||||
namespace = 'v1';
|
||||
|
||||
// define path value in extending class or pass into method directly
|
||||
_getURL(backend, path) {
|
||||
return `${this.buildURL()}/${encodePath(backend)}/${path || this.path}`;
|
||||
}
|
||||
urlForUpdateRecord(name, modelName, snapshot) {
|
||||
return this._getURL(snapshot.attr('backend'));
|
||||
}
|
||||
// primaryKey must be set to backend in serializer
|
||||
urlForDeleteRecord(backend) {
|
||||
return this._getURL(backend);
|
||||
}
|
||||
|
||||
queryRecord(store, type, query) {
|
||||
const { backend } = query;
|
||||
return this.ajax(this._getURL(backend), 'GET').then((resp) => {
|
||||
resp.backend = backend;
|
||||
return resp;
|
||||
});
|
||||
}
|
||||
createRecord() {
|
||||
return this._saveRecord(...arguments);
|
||||
}
|
||||
updateRecord() {
|
||||
return this._saveRecord(...arguments);
|
||||
}
|
||||
_saveRecord(store, { modelName }, snapshot) {
|
||||
const data = store.serializerFor(modelName).serialize(snapshot);
|
||||
const url = this._getURL(snapshot.attr('backend'));
|
||||
return this.ajax(url, 'POST', { data }).then(() => data);
|
||||
}
|
||||
}
|
@ -52,6 +52,14 @@ export default class App extends Application {
|
||||
},
|
||||
},
|
||||
},
|
||||
ldap: {
|
||||
dependencies: {
|
||||
services: ['router', 'store', 'secret-mount-path', 'flash-messages', 'auth'],
|
||||
externalRoutes: {
|
||||
secrets: 'vault.cluster.secrets.backends',
|
||||
},
|
||||
},
|
||||
},
|
||||
kv: {
|
||||
dependencies: {
|
||||
services: ['download', 'namespace', 'router', 'store', 'secret-mount-path', 'flash-messages'],
|
||||
|
@ -30,11 +30,6 @@ const ENTERPRISE_SECRET_ENGINES = [
|
||||
];
|
||||
|
||||
const MOUNTABLE_SECRET_ENGINES = [
|
||||
{
|
||||
displayName: 'Active Directory',
|
||||
type: 'ad',
|
||||
category: 'cloud',
|
||||
},
|
||||
{
|
||||
displayName: 'AliCloud',
|
||||
type: 'alicloud',
|
||||
@ -110,9 +105,15 @@ const MOUNTABLE_SECRET_ENGINES = [
|
||||
type: 'totp',
|
||||
category: 'generic',
|
||||
},
|
||||
{
|
||||
displayName: 'LDAP',
|
||||
type: 'ldap',
|
||||
engineRoute: 'ldap.overview',
|
||||
category: 'generic',
|
||||
glyph: 'folder-users',
|
||||
},
|
||||
{
|
||||
displayName: 'Kubernetes',
|
||||
value: 'kubernetes',
|
||||
type: 'kubernetes',
|
||||
engineRoute: 'kubernetes.overview',
|
||||
category: 'generic',
|
||||
|
@ -18,6 +18,7 @@ const SUPPORTED_SECRET_BACKENDS = [
|
||||
'transform',
|
||||
'keymgmt',
|
||||
'kubernetes',
|
||||
'ldap',
|
||||
];
|
||||
|
||||
export function supportedSecretBackends() {
|
||||
|
129
ui/app/models/ldap/config.js
Normal file
129
ui/app/models/ldap/config.js
Normal file
@ -0,0 +1,129 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: MPL-2.0
|
||||
*/
|
||||
|
||||
import Model, { attr } from '@ember-data/model';
|
||||
import { withFormFields } from 'vault/decorators/model-form-fields';
|
||||
import { withModelValidations } from 'vault/decorators/model-validations';
|
||||
|
||||
const validations = {
|
||||
binddn: [{ type: 'presence', message: 'Administrator distinguished name is required.' }],
|
||||
bindpass: [{ type: 'presence', message: 'Administrator password is required.' }],
|
||||
};
|
||||
const formGroups = [
|
||||
{ default: ['binddn', 'bindpass', 'url', 'password_policy'] },
|
||||
{ 'TLS options': ['starttls', 'insecure_tls', 'certificate', 'client_tls_cert', 'client_tls_key'] },
|
||||
{ 'More options': ['userdn', 'userattr', 'upndomain', 'connection_timeout', 'request_timeout'] },
|
||||
];
|
||||
|
||||
@withModelValidations(validations)
|
||||
@withFormFields(null, formGroups)
|
||||
export default class LdapConfigModel extends Model {
|
||||
@attr('string') backend; // dynamic path of secret -- set on response from value passed to queryRecord
|
||||
|
||||
@attr('string', {
|
||||
label: 'Administrator Distinguished Name',
|
||||
subText:
|
||||
'Distinguished name of the administrator to bind (Bind DN) when performing user and group search. Example: cn=vault,ou=Users,dc=example,dc=com.',
|
||||
})
|
||||
binddn;
|
||||
|
||||
@attr('string', {
|
||||
label: 'Administrator Password',
|
||||
subText: 'Password to use along with Bind DN when performing user search.',
|
||||
})
|
||||
bindpass;
|
||||
|
||||
@attr('string', {
|
||||
label: 'URL',
|
||||
subText: 'The directory server to connect to.',
|
||||
})
|
||||
url;
|
||||
|
||||
@attr('string', {
|
||||
editType: 'optionalText',
|
||||
label: 'Use custom password policy',
|
||||
subText: 'Specify the name of an existing password policy.',
|
||||
defaultSubText: 'Unless a custom policy is specified, Vault will use a default.',
|
||||
defaultShown: 'Default',
|
||||
docLink: '/vault/docs/concepts/password-policies',
|
||||
})
|
||||
password_policy;
|
||||
|
||||
@attr('string') schema;
|
||||
|
||||
@attr('boolean', {
|
||||
label: 'Start TLS',
|
||||
subText: 'If checked, or address contains “ldaps://”, creates an encrypted connection with LDAP.',
|
||||
})
|
||||
starttls;
|
||||
|
||||
@attr('boolean', {
|
||||
label: 'Insecure TLS',
|
||||
subText: 'If checked, skips LDAP server SSL certificate verification - insecure, use with caution!',
|
||||
})
|
||||
insecure_tls;
|
||||
|
||||
@attr('string', {
|
||||
editType: 'file',
|
||||
label: 'CA Certificate',
|
||||
helpText: 'CA certificate to use when verifying LDAP server certificate, must be x509 PEM encoded.',
|
||||
})
|
||||
certificate;
|
||||
|
||||
@attr('string', {
|
||||
editType: 'file',
|
||||
label: 'Client TLS Certificate',
|
||||
helpText: 'Client certificate to provide to the LDAP server, must be x509 PEM encoded.',
|
||||
})
|
||||
client_tls_cert;
|
||||
|
||||
@attr('string', {
|
||||
editType: 'file',
|
||||
label: 'Client TLS Key',
|
||||
helpText: 'Client key to provide to the LDAP server, must be x509 PEM encoded.',
|
||||
})
|
||||
client_tls_key;
|
||||
|
||||
@attr('string', {
|
||||
label: 'Userdn',
|
||||
helpText: 'The base DN under which to perform user search in library management and static roles.',
|
||||
})
|
||||
userdn;
|
||||
|
||||
@attr('string', {
|
||||
label: 'Userattr',
|
||||
subText: 'The attribute field name used to perform user search in library management and static roles.',
|
||||
})
|
||||
userattr;
|
||||
|
||||
@attr('string', {
|
||||
label: 'Upndomain',
|
||||
subText: 'The domain (userPrincipalDomain) used to construct a UPN string for authentication.',
|
||||
})
|
||||
upndomain;
|
||||
|
||||
@attr('number', {
|
||||
editType: 'optionalText',
|
||||
label: 'Connection Timeout',
|
||||
subText: 'Specify the connection timeout length in seconds.',
|
||||
defaultSubText: 'Vault will use the default of 30 seconds.',
|
||||
defaultShown: 'Default 30 seconds.',
|
||||
})
|
||||
connection_timeout;
|
||||
|
||||
@attr('number', {
|
||||
editType: 'optionalText',
|
||||
label: 'Request Timeout',
|
||||
subText: 'Specify the connection timeout length in seconds.',
|
||||
defaultSubText: 'Vault will use the default of 90 seconds.',
|
||||
defaultShown: 'Default 90 seconds.',
|
||||
})
|
||||
request_timeout;
|
||||
|
||||
async rotateRoot() {
|
||||
const adapter = this.store.adapterFor('ldap/config');
|
||||
return adapter.rotateRoot(this.backend);
|
||||
}
|
||||
}
|
106
ui/app/models/ldap/library.js
Normal file
106
ui/app/models/ldap/library.js
Normal file
@ -0,0 +1,106 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: MPL-2.0
|
||||
*/
|
||||
|
||||
import Model, { attr } from '@ember-data/model';
|
||||
import { withFormFields } from 'vault/decorators/model-form-fields';
|
||||
import { withModelValidations } from 'vault/decorators/model-validations';
|
||||
import lazyCapabilities, { apiPath } from 'vault/macros/lazy-capabilities';
|
||||
|
||||
const validations = {
|
||||
name: [{ type: 'presence', message: 'Library name is required.' }],
|
||||
service_account_names: [{ type: 'presence', message: 'At least one service account is required.' }],
|
||||
};
|
||||
const formFields = ['name', 'service_account_names', 'ttl', 'max_ttl', 'disable_check_in_enforcement'];
|
||||
|
||||
@withModelValidations(validations)
|
||||
@withFormFields(formFields)
|
||||
export default class LdapLibraryModel extends Model {
|
||||
@attr('string') backend; // dynamic path of secret -- set on response from value passed to queryRecord
|
||||
|
||||
@attr('string', { label: 'Library name' }) name;
|
||||
|
||||
@attr('string', {
|
||||
editType: 'stringArray',
|
||||
label: 'Accounts',
|
||||
subText:
|
||||
'The names of all the accounts that can be checked out from this set. These accounts must only be used by Vault, and may only be in one set.',
|
||||
})
|
||||
service_account_names;
|
||||
|
||||
@attr({
|
||||
editType: 'ttl',
|
||||
label: 'Default lease TTL',
|
||||
detailsLabel: 'TTL',
|
||||
helperTextDisabled: 'Vault will use the default lease duration.',
|
||||
defaultValue: '24h',
|
||||
defaultShown: 'Engine default',
|
||||
})
|
||||
ttl;
|
||||
|
||||
@attr({
|
||||
editType: 'ttl',
|
||||
label: 'Max lease TTL',
|
||||
detailsLabel: 'Max TTL',
|
||||
helperTextDisabled: 'Vault will use the default lease duration.',
|
||||
defaultValue: '24h',
|
||||
defaultShown: 'Engine default',
|
||||
})
|
||||
max_ttl;
|
||||
|
||||
// this is a boolean from the server but is transformed in the serializer to display as Disabled or Enabled
|
||||
@attr('string', {
|
||||
editType: 'radio',
|
||||
label: 'Check-in enforcement',
|
||||
subText:
|
||||
'When enabled, accounts must be checked in by the entity or client token that checked them out. If disabled, anyone with the right permission can check the account back in.',
|
||||
possibleValues: ['Disabled', 'Enabled'],
|
||||
defaultValue: 'Enabled',
|
||||
})
|
||||
disable_check_in_enforcement;
|
||||
|
||||
get displayFields() {
|
||||
return this.formFields.filter((field) => field.name !== 'service_account_names');
|
||||
}
|
||||
|
||||
@lazyCapabilities(apiPath`${'backend'}/library/${'name'}`, 'backend', 'name') libraryPath;
|
||||
@lazyCapabilities(apiPath`${'backend'}/library/${'name'}/status`, 'backend', 'name') statusPath;
|
||||
@lazyCapabilities(apiPath`${'backend'}/library/${'name'}/check-out`, 'backend', 'name') checkOutPath;
|
||||
@lazyCapabilities(apiPath`${'backend'}/library/${'name'}/check-in`, 'backend', 'name') checkInPath;
|
||||
|
||||
get canCreate() {
|
||||
return this.libraryPath.get('canCreate') !== false;
|
||||
}
|
||||
get canDelete() {
|
||||
return this.libraryPath.get('canDelete') !== false;
|
||||
}
|
||||
get canEdit() {
|
||||
return this.libraryPath.get('canUpdate') !== false;
|
||||
}
|
||||
get canRead() {
|
||||
return this.libraryPath.get('canRead') !== false;
|
||||
}
|
||||
get canList() {
|
||||
return this.libraryPath.get('canList') !== false;
|
||||
}
|
||||
get canReadStatus() {
|
||||
return this.statusPath.get('canRead') !== false;
|
||||
}
|
||||
get canCheckOut() {
|
||||
return this.checkOutPath.get('canUpdate') !== false;
|
||||
}
|
||||
get canCheckIn() {
|
||||
return this.checkInPath.get('canUpdate') !== false;
|
||||
}
|
||||
|
||||
fetchStatus() {
|
||||
return this.store.adapterFor('ldap/library').fetchStatus(this.backend, this.name);
|
||||
}
|
||||
checkOutAccount(ttl) {
|
||||
return this.store.adapterFor('ldap/library').checkOutAccount(this.backend, this.name, ttl);
|
||||
}
|
||||
checkInAccount(account) {
|
||||
return this.store.adapterFor('ldap/library').checkInAccount(this.backend, this.name, [account]);
|
||||
}
|
||||
}
|
230
ui/app/models/ldap/role.js
Normal file
230
ui/app/models/ldap/role.js
Normal file
@ -0,0 +1,230 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: MPL-2.0
|
||||
*/
|
||||
|
||||
import Model, { attr } from '@ember-data/model';
|
||||
import { withFormFields } from 'vault/decorators/model-form-fields';
|
||||
import { withModelValidations } from 'vault/decorators/model-validations';
|
||||
import lazyCapabilities, { apiPath } from 'vault/macros/lazy-capabilities';
|
||||
|
||||
const creationLdifExample = `# The example below is treated as a comment and will not be submitted
|
||||
# dn: cn={{.Username}},ou=users,dc=learn,dc=example
|
||||
# objectClass: person
|
||||
# objectClass: top
|
||||
`;
|
||||
const deletionLdifExample = `# The example below is treated as a comment and will not be submitted
|
||||
# dn: cn={{.Username}},ou=users,dc=learn,dc=example
|
||||
# changetype: delete
|
||||
`;
|
||||
const rollbackLdifExample = `# The example below is treated as a comment and will not be submitted
|
||||
# dn: cn={{.Username}},ou=users,dc=learn,dc=example
|
||||
# changetype: delete
|
||||
`;
|
||||
|
||||
const validations = {
|
||||
name: [{ type: 'presence', message: 'Name is required' }],
|
||||
username: [
|
||||
{
|
||||
validator: (model) => (model.isStatic && !model.username ? false : true),
|
||||
message: 'Username is required.',
|
||||
},
|
||||
],
|
||||
rotation_period: [
|
||||
{
|
||||
validator: (model) => (model.isStatic && !model.rotation_period ? false : true),
|
||||
message: 'Rotation Period is required.',
|
||||
},
|
||||
],
|
||||
creation_ldif: [
|
||||
{
|
||||
validator: (model) => (model.isDynamic && !model.creation_ldif ? false : true),
|
||||
message: 'Creation LDIF is required.',
|
||||
},
|
||||
],
|
||||
deletion_ldif: [
|
||||
{
|
||||
validator: (model) => (model.isDynamic && !model.creation_ldif ? false : true),
|
||||
message: 'Deletion LDIF is required.',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const staticRoleFields = ['username', 'dn', 'rotation_period'];
|
||||
export const dynamicRoleFields = [
|
||||
'default_ttl',
|
||||
'max_ttl',
|
||||
'username_template',
|
||||
'creation_ldif',
|
||||
'deletion_ldif',
|
||||
'rollback_ldif',
|
||||
];
|
||||
|
||||
@withModelValidations(validations)
|
||||
@withFormFields()
|
||||
export default class LdapRoleModel extends Model {
|
||||
@attr('string') backend; // dynamic path of secret -- set on response from value passed to queryRecord
|
||||
|
||||
@attr('string', {
|
||||
defaultValue: 'static',
|
||||
})
|
||||
type; // this must be set to either static or dynamic in order for the adapter to build the correct url and for the correct form fields to display
|
||||
|
||||
@attr('string', {
|
||||
label: 'Role name',
|
||||
subText: 'The name of the role that will be used in Vault.',
|
||||
})
|
||||
name;
|
||||
|
||||
// static role properties
|
||||
@attr('string', {
|
||||
label: 'Distinguished name',
|
||||
subText: 'Distinguished name (DN) of entry Vault should manage.',
|
||||
})
|
||||
dn;
|
||||
|
||||
@attr('string', {
|
||||
label: 'Username',
|
||||
subText:
|
||||
"The name of the user to be used when logging in. This is useful when DN isn't used for login purposes.",
|
||||
})
|
||||
username;
|
||||
|
||||
@attr({
|
||||
editType: 'ttl',
|
||||
label: 'Rotation period',
|
||||
helperTextEnabled:
|
||||
'Specifies the amount of time Vault should wait before rotating the password. The minimum is 5 seconds.',
|
||||
hideToggle: true,
|
||||
})
|
||||
rotation_period;
|
||||
|
||||
// dynamic role properties
|
||||
@attr({
|
||||
editType: 'ttl',
|
||||
label: 'Generated credential’s time-to-live (TTL)',
|
||||
detailsLabel: 'TTL',
|
||||
helperTextDisabled: 'Vault will use the default of 1 hour.',
|
||||
defaultValue: '1h',
|
||||
defaultShown: 'Engine default',
|
||||
})
|
||||
default_ttl;
|
||||
|
||||
@attr({
|
||||
editType: 'ttl',
|
||||
label: 'Generated credential’s maximum time-to-live (Max TTL)',
|
||||
detailsLabel: 'Max TTL',
|
||||
helperTextDisabled: 'Vault will use the engine default of 24 hours.',
|
||||
defaultValue: '24h',
|
||||
defaultShown: 'Engine default',
|
||||
})
|
||||
max_ttl;
|
||||
|
||||
@attr('string', {
|
||||
editType: 'optionalText',
|
||||
label: 'Username template',
|
||||
subText: 'Enter the custom username template to use.',
|
||||
defaultSubText:
|
||||
'Template describing how dynamic usernames are generated. Vault will use the default for this plugin.',
|
||||
docLink: '/vault/docs/concepts/username-templating',
|
||||
defaultShown: 'Default',
|
||||
})
|
||||
username_template;
|
||||
|
||||
@attr('string', {
|
||||
editType: 'json',
|
||||
label: 'Creation LDIF',
|
||||
helpText: 'Specifies the LDIF statements executed to create a user. May optionally be base64 encoded.',
|
||||
example: creationLdifExample,
|
||||
mode: 'ruby',
|
||||
sectionHeading: 'LDIF Statements', // render section heading before form field
|
||||
})
|
||||
creation_ldif;
|
||||
|
||||
@attr('string', {
|
||||
editType: 'json',
|
||||
label: 'Deletion LDIF',
|
||||
helpText:
|
||||
'Specifies the LDIF statements executed to delete a user once its TTL has expired. May optionally be base64 encoded.',
|
||||
example: deletionLdifExample,
|
||||
mode: 'ruby',
|
||||
})
|
||||
deletion_ldif;
|
||||
|
||||
@attr('string', {
|
||||
editType: 'json',
|
||||
label: 'Rollback LDIF',
|
||||
helpText:
|
||||
'Specifies the LDIF statement to attempt to rollback any changes if the creation results in an error. May optionally be base64 encoded.',
|
||||
example: rollbackLdifExample,
|
||||
mode: 'ruby',
|
||||
})
|
||||
rollback_ldif;
|
||||
|
||||
get isStatic() {
|
||||
return this.type === 'static';
|
||||
}
|
||||
get isDynamic() {
|
||||
return this.type === 'dynamic';
|
||||
}
|
||||
// this is used to build the form fields as well as serialize the correct payload based on type
|
||||
// if a new attr is added be sure to add it to the appropriate array
|
||||
get fieldsForType() {
|
||||
return this.isStatic
|
||||
? ['username', 'dn', 'rotation_period']
|
||||
: ['default_ttl', 'max_ttl', 'username_template', 'creation_ldif', 'deletion_ldif', 'rollback_ldif'];
|
||||
}
|
||||
get formFields() {
|
||||
// filter all fields and return only those relevant to type
|
||||
return this.allFields.filter((field) => {
|
||||
// name is the only common field
|
||||
return field.name === 'name' || this.fieldsForType.includes(field.name);
|
||||
});
|
||||
}
|
||||
|
||||
get displayFields() {
|
||||
// insert type after role name
|
||||
const [name, ...rest] = this.formFields;
|
||||
const typeField = { name: 'type', options: { label: 'Role type' } };
|
||||
return [name, typeField, ...rest];
|
||||
}
|
||||
|
||||
get roleUri() {
|
||||
return this.isStatic ? 'static-role' : 'role';
|
||||
}
|
||||
get credsUri() {
|
||||
return this.isStatic ? 'static-cred' : 'creds';
|
||||
}
|
||||
@lazyCapabilities(apiPath`${'backend'}/${'roleUri'}/${'name'}`, 'backend', 'roleUri', 'name') rolePath;
|
||||
@lazyCapabilities(apiPath`${'backend'}/${'credsUri'}/${'name'}`, 'backend', 'credsUri', 'name') credsPath;
|
||||
@lazyCapabilities(apiPath`${'backend'}/rotate-role/${'name'}`, 'backend', 'name') staticRotateCredsPath;
|
||||
|
||||
get canCreate() {
|
||||
return this.rolePath.get('canCreate') !== false;
|
||||
}
|
||||
get canDelete() {
|
||||
return this.rolePath.get('canDelete') !== false;
|
||||
}
|
||||
get canEdit() {
|
||||
return this.rolePath.get('canUpdate') !== false;
|
||||
}
|
||||
get canRead() {
|
||||
return this.rolePath.get('canRead') !== false;
|
||||
}
|
||||
get canList() {
|
||||
return this.rolePath.get('canList') !== false;
|
||||
}
|
||||
get canReadCreds() {
|
||||
return this.credsPath.get('canRead') !== false;
|
||||
}
|
||||
get canRotateStaticCreds() {
|
||||
return this.isStatic && this.staticRotateCredsPath.get('canCreate') !== false;
|
||||
}
|
||||
|
||||
fetchCredentials() {
|
||||
return this.store.adapterFor('ldap/role').fetchCredentials(this.backend, this.type, this.name);
|
||||
}
|
||||
rotateStaticPassword() {
|
||||
return this.store.adapterFor('ldap/role').rotateStaticPassword(this.backend, this.name);
|
||||
}
|
||||
}
|
@ -129,13 +129,14 @@ export default class SecretEngineModel extends Model {
|
||||
}
|
||||
|
||||
get icon() {
|
||||
if (!this.engineType || this.engineType === 'kmip') {
|
||||
return 'secrets';
|
||||
}
|
||||
if (this.engineType === 'keymgmt') {
|
||||
return 'key';
|
||||
}
|
||||
return this.engineType;
|
||||
const defaultIcon = this.engineType || 'secrets';
|
||||
return (
|
||||
{
|
||||
keymgmt: 'key',
|
||||
kmip: 'secrets',
|
||||
ldap: 'folder-users',
|
||||
}[this.engineType] || defaultIcon
|
||||
);
|
||||
}
|
||||
|
||||
get engineType() {
|
||||
|
@ -161,6 +161,7 @@ Router.map(function () {
|
||||
this.mount('kmip');
|
||||
this.mount('kubernetes');
|
||||
this.mount('kv');
|
||||
this.mount('ldap');
|
||||
this.mount('pki');
|
||||
this.route('index', { path: '/' });
|
||||
this.route('configuration');
|
||||
|
@ -5,13 +5,10 @@
|
||||
|
||||
import ApplicationSerializer from '../application';
|
||||
|
||||
export default class KubernetesConfigSerializer extends ApplicationSerializer {
|
||||
export default class KubernetesRoleSerializer extends ApplicationSerializer {
|
||||
primaryKey = 'name';
|
||||
|
||||
serialize() {
|
||||
const json = super.serialize(...arguments);
|
||||
// remove backend value from payload
|
||||
delete json.backend;
|
||||
return json;
|
||||
}
|
||||
attrs = {
|
||||
backend: { serialize: false },
|
||||
};
|
||||
}
|
||||
|
10
ui/app/serializers/ldap/config.js
Normal file
10
ui/app/serializers/ldap/config.js
Normal file
@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: MPL-2.0
|
||||
*/
|
||||
|
||||
import ApplicationSerializer from '../application';
|
||||
|
||||
export default class LdapConfigSerializer extends ApplicationSerializer {
|
||||
primaryKey = 'backend';
|
||||
}
|
27
ui/app/serializers/ldap/library.js
Normal file
27
ui/app/serializers/ldap/library.js
Normal file
@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: MPL-2.0
|
||||
*/
|
||||
|
||||
import ApplicationSerializer from '../application';
|
||||
|
||||
export default class LdapLibrarySerializer extends ApplicationSerializer {
|
||||
primaryKey = 'name';
|
||||
|
||||
attrs = {
|
||||
backend: { serialize: false },
|
||||
name: { serialize: false },
|
||||
};
|
||||
|
||||
// disable_check_in_enforcement is a boolean but needs to be presented as Disabled or Enabled
|
||||
normalize(modelClass, data) {
|
||||
data.disable_check_in_enforcement = data.disable_check_in_enforcement ? 'Disabled' : 'Enabled';
|
||||
return super.normalize(modelClass, data);
|
||||
}
|
||||
|
||||
serialize() {
|
||||
const json = super.serialize(...arguments);
|
||||
json.disable_check_in_enforcement = json.disable_check_in_enforcement === 'Enabled' ? false : true;
|
||||
return json;
|
||||
}
|
||||
}
|
22
ui/app/serializers/ldap/role.js
Normal file
22
ui/app/serializers/ldap/role.js
Normal file
@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: MPL-2.0
|
||||
*/
|
||||
|
||||
import ApplicationSerializer from '../application';
|
||||
|
||||
export default class LdapRoleSerializer extends ApplicationSerializer {
|
||||
primaryKey = 'name';
|
||||
|
||||
serialize(snapshot) {
|
||||
// remove all fields that are not relevant to specified role type
|
||||
const { fieldsForType } = snapshot.record;
|
||||
const json = super.serialize(...arguments);
|
||||
Object.keys(json).forEach((key) => {
|
||||
if (!fieldsForType.includes(key)) {
|
||||
delete json[key];
|
||||
}
|
||||
});
|
||||
return json;
|
||||
}
|
||||
}
|
@ -10,10 +10,8 @@ import Service from '@ember/service';
|
||||
// are not accessible
|
||||
export default class SecretMountPath extends Service {
|
||||
currentPath = '';
|
||||
update(path) {
|
||||
|
||||
update(path: string) {
|
||||
this.currentPath = path;
|
||||
}
|
||||
get() {
|
||||
return this.currentPath;
|
||||
}
|
||||
}
|
@ -5,7 +5,6 @@
|
||||
|
||||
.overview-card {
|
||||
padding: $spacing-l;
|
||||
display: initial;
|
||||
line-height: initial;
|
||||
|
||||
.title-number {
|
||||
|
@ -326,4 +326,9 @@ a.button.disabled {
|
||||
font-size: inherit;
|
||||
font-weight: inherit;
|
||||
cursor: pointer;
|
||||
|
||||
&:disabled {
|
||||
color: $grey-light;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
@ -18,6 +18,10 @@
|
||||
background-color: $ui-gray-200;
|
||||
}
|
||||
|
||||
.has-background-gray-900 {
|
||||
background-color: $ui-gray-900;
|
||||
}
|
||||
|
||||
.background-color-black {
|
||||
background-color: black;
|
||||
}
|
||||
@ -34,7 +38,9 @@
|
||||
}
|
||||
|
||||
.has-error-border,
|
||||
select.has-error-border {
|
||||
select.has-error-border,
|
||||
.ttl-picker-form-field-error input,
|
||||
.string-list-form-field-error .field:first-of-type textarea {
|
||||
border: 1px solid $red-500;
|
||||
}
|
||||
|
||||
@ -75,6 +81,10 @@ select.has-error-border {
|
||||
.has-text-info {
|
||||
color: $blue-500 !important;
|
||||
}
|
||||
// same without the !important
|
||||
.has-text-primary {
|
||||
color: $blue-500;
|
||||
}
|
||||
|
||||
.has-text-success {
|
||||
color: $green-500 !important;
|
||||
@ -87,3 +97,7 @@ select.has-error-border {
|
||||
.has-text-danger {
|
||||
color: $red-500 !important;
|
||||
}
|
||||
|
||||
.has-text-primary {
|
||||
color: $blue;
|
||||
}
|
||||
|
@ -41,6 +41,11 @@
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.is-flex-align-start {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.is-flex-align-baseline {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
|
@ -116,6 +116,10 @@
|
||||
border-radius: $radius;
|
||||
}
|
||||
|
||||
.border-radius-4 {
|
||||
border-radius: $radius-large;
|
||||
}
|
||||
|
||||
// border-spacing
|
||||
.is-border-spacing-revert {
|
||||
border-spacing: revert;
|
||||
|
@ -26,6 +26,10 @@
|
||||
padding-right: $spacing-s;
|
||||
}
|
||||
|
||||
.has-padding-s {
|
||||
padding: $spacing-s;
|
||||
}
|
||||
|
||||
.has-padding-xxs {
|
||||
padding: $spacing-xxs;
|
||||
}
|
||||
@ -37,6 +41,10 @@
|
||||
padding: $spacing-l;
|
||||
}
|
||||
|
||||
.has-padding-l {
|
||||
padding: $spacing-l;
|
||||
}
|
||||
|
||||
.has-bottom-padding-s {
|
||||
padding-bottom: $spacing-s;
|
||||
}
|
||||
@ -95,15 +103,22 @@
|
||||
margin-bottom: -$spacing-m;
|
||||
}
|
||||
|
||||
.has-top-margin-negative-xxl {
|
||||
margin-top: -$spacing-xxl;
|
||||
}
|
||||
|
||||
.has-top-margin-xxs {
|
||||
margin: $spacing-xxs 0;
|
||||
}
|
||||
|
||||
.has-right-margin-xxs {
|
||||
margin-right: $spacing-xxs;
|
||||
}
|
||||
|
||||
.has-left-margin-xxs {
|
||||
margin-left: $spacing-xxs;
|
||||
}
|
||||
|
||||
.has-bottom-margin-xxs {
|
||||
margin-bottom: $spacing-xxs !important;
|
||||
}
|
||||
|
@ -1,16 +0,0 @@
|
||||
{{!
|
||||
Copyright (c) HashiCorp, Inc.
|
||||
SPDX-License-Identifier: BUSL-1.1
|
||||
~}}
|
||||
|
||||
<WizardSection
|
||||
@headerText="Active Directory"
|
||||
@headerIcon="azure-color"
|
||||
@docText="Docs: Active Directory Secrets"
|
||||
@docPath="/docs/secrets/ad/index.html"
|
||||
>
|
||||
<p>
|
||||
The AD Secrets Engine rotates AD passwords dynamically, and is designed for a high-load environment where many instances
|
||||
may be accessing a shared password simultaneously.
|
||||
</p>
|
||||
</WizardSection>
|
@ -49,7 +49,7 @@
|
||||
<LinkedBlock
|
||||
@params={{array backend.backendLink backend.id}}
|
||||
class="list-item-row linked-block-item is-no-underline"
|
||||
data-test-auth-backend-link={{backend.id}}
|
||||
data-test-secrets-backend-link={{backend.id}}
|
||||
@disabled={{if backend.isSupportedBackend false true}}
|
||||
>
|
||||
<div>
|
||||
|
92
ui/docs/fetch-secrets-engine-config.md
Normal file
92
ui/docs/fetch-secrets-engine-config.md
Normal file
@ -0,0 +1,92 @@
|
||||
# Fetch Secrets Engine Configuration Decorator
|
||||
|
||||
The `fetch-secrets-engine-config` decorator is available in the core addon and can be used on a route that needs to be aware of the configuration details of a secrets engine prior to model hook execution. This is useful for conditionally displaying a call to action for the user to complete the configuration.
|
||||
|
||||
## API
|
||||
|
||||
The decorator accepts a single argument with the name of the Ember Data model to be fetched.
|
||||
|
||||
- **modelName** [string] - name of the Ember Data model to fetch which is passed to the `queryRecord` method.
|
||||
|
||||
With the provided model name, the decorator fetches the record using the store `queryRecord` method in the `beforeModel` route hook. Several properties are set on the route class based on the status of the request:
|
||||
|
||||
- **configModel** [Model | null] - set on success with resolved Ember Data model.
|
||||
|
||||
- **configError** [AdapterError | null] - set if the request errors with any status other than 404.
|
||||
|
||||
- **promptConfig** [boolean] - set to `true` if the request returns a 404, otherwise set to `false`. This is for convenience since checking for `(!this.configModel && !this.configError)` would result in the same value.
|
||||
|
||||
## Usage
|
||||
|
||||
### Configure route
|
||||
|
||||
```js
|
||||
@withConfig('foo/config')
|
||||
export default class FooConfigureRoute extends Route {
|
||||
@service store;
|
||||
@service secretMountPath;
|
||||
|
||||
model() {
|
||||
const backend = this.secretMountPath.currentPath;
|
||||
return this.configModel || this.store.createRecord('foo/config', { backend });
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
In the scenario of creating/updating the configuration, the model is used to populate the form if available, otherwise the form is presented in an empty state. Fetch errors are not a concern, nor is prompting the user to configure so only the `configModel` property is used.
|
||||
|
||||
### Configuration route
|
||||
|
||||
```js
|
||||
@withConfig('foo/config')
|
||||
export default class FooConfigurationRoute extends Route {
|
||||
@service store;
|
||||
@service secretMountPath;
|
||||
|
||||
model() {
|
||||
// the error could also be thrown to display the error template
|
||||
// in this example a component is used to display the error
|
||||
return {
|
||||
configModel: this.configModel,
|
||||
configError: this.configError,
|
||||
};
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
For configuration routes, the model and error properties may be used to determine what should be displayed to the user:
|
||||
|
||||
`configuration.hbs`
|
||||
|
||||
```hbs
|
||||
{{#if @configModel}}
|
||||
{{#each @configModel.fields as |field|}}
|
||||
<InfoTableRow @label={{field.label}} @value={{field.value}} />
|
||||
{{/each}}
|
||||
{{else if @configError}}
|
||||
<Page::Error @error={{@configError}} />
|
||||
{{else}}
|
||||
<ConfigCta />
|
||||
{{/if}}
|
||||
```
|
||||
|
||||
### Other routes (overview etc.)
|
||||
|
||||
This is the most basic usage where a route only needs to be aware of whether or not to show the config prompt:
|
||||
|
||||
```js
|
||||
@withConfig('foo/config')
|
||||
export default class FooOverviewRoute extends Route {
|
||||
@service store;
|
||||
@service secretMountPath;
|
||||
|
||||
model() {
|
||||
const backend = this.secretMountPath.currentPath;
|
||||
return hash({
|
||||
promptConfig: this.promptConfig,
|
||||
roles: this.store.query('foo/role', { backend }).catch(() => []),
|
||||
libraries: this.store.query('foo/library', { backend }).catch(() => []),
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
12
ui/lib/core/addon/components/filter-input.hbs
Normal file
12
ui/lib/core/addon/components/filter-input.hbs
Normal file
@ -0,0 +1,12 @@
|
||||
<div class="field">
|
||||
<p class="control has-icons-left">
|
||||
<Input
|
||||
class="filter input"
|
||||
placeholder={{this.placeholder}}
|
||||
data-test-filter-input
|
||||
@value={{@value}}
|
||||
{{on "input" this.onInput}}
|
||||
/>
|
||||
<Icon @name="search" class="search-icon has-text-grey-light" />
|
||||
</p>
|
||||
</div>
|
31
ui/lib/core/addon/components/filter-input.ts
Normal file
31
ui/lib/core/addon/components/filter-input.ts
Normal file
@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: MPL-2.0
|
||||
*/
|
||||
|
||||
import Component from '@glimmer/component';
|
||||
import { action } from '@ember/object';
|
||||
import { debounce } from '@ember/runloop';
|
||||
|
||||
import type { HTMLElementEvent } from 'vault/forms';
|
||||
|
||||
interface Args {
|
||||
placeholder?: string; // defaults to Type to filter results
|
||||
wait?: number; // defaults to 200
|
||||
onInput(value: string): void;
|
||||
}
|
||||
|
||||
export default class FilterInputComponent extends Component<Args> {
|
||||
get placeholder() {
|
||||
return this.args.placeholder || 'Type to filter results';
|
||||
}
|
||||
|
||||
@action onInput(event: HTMLElementEvent<HTMLInputElement>) {
|
||||
const callback = () => {
|
||||
this.args.onInput(event.target.value);
|
||||
};
|
||||
const wait = this.args.wait || 200;
|
||||
// ts complains when trying to pass object of optional args to callback as 3rd arg to debounce
|
||||
debounce(this, callback, wait);
|
||||
}
|
||||
}
|
@ -57,14 +57,6 @@
|
||||
{{/each}}
|
||||
</select>
|
||||
</div>
|
||||
{{#if this.validationError}}
|
||||
<AlertInline
|
||||
@type="danger"
|
||||
@message={{this.validationError}}
|
||||
@paddingTop={{true}}
|
||||
data-test-field-validation={{@attr.name}}
|
||||
/>
|
||||
{{/if}}
|
||||
</div>
|
||||
{{/if}}
|
||||
{{else if (eq @attr.options.editType "searchSelect")}}
|
||||
@ -86,9 +78,6 @@
|
||||
class={{if this.validationError "dropdown-has-error-border"}}
|
||||
/>
|
||||
</div>
|
||||
{{#if this.validationError}}
|
||||
<AlertInline @type="danger" @message={{this.validationError}} @paddingTop={{true}} />
|
||||
{{/if}}
|
||||
{{else if (eq @attr.options.editType "mountAccessor")}}
|
||||
<MountAccessorSelect
|
||||
@name={{@attr.name}}
|
||||
@ -126,6 +115,7 @@
|
||||
{{#let (or (get @model this.valuePath) @attr.options.setDefault) as |initialValue|}}
|
||||
<TtlPicker
|
||||
data-test-input={{@attr.name}}
|
||||
class={{if this.validationError "ttl-picker-form-field-error"}}
|
||||
@onChange={{this.setAndBroadcastTtl}}
|
||||
@label={{this.labelString}}
|
||||
@helperTextDisabled={{or @attr.options.helperTextDisabled "Vault will use the default lease duration."}}
|
||||
@ -194,6 +184,7 @@
|
||||
</Toggle>
|
||||
{{else if (eq @attr.options.editType "stringArray")}}
|
||||
<StringList
|
||||
class={{if this.validationError "string-list-form-field-error"}}
|
||||
data-test-input={{@attr.name}}
|
||||
@label={{this.labelString}}
|
||||
@helpText={{if this.showHelpText @attr.options.helpText}}
|
||||
@ -210,9 +201,6 @@
|
||||
@onChange={{this.setAndBroadcast}}
|
||||
@onKeyUp={{@onKeyUp}}
|
||||
/>
|
||||
{{#if this.validationError}}
|
||||
<AlertInline @type="danger" @message={{this.validationError}} @paddingTop={{true}} />
|
||||
{{/if}}
|
||||
{{else if (or (eq @attr.type "number") (eq @attr.type "string"))}}
|
||||
<div class="control">
|
||||
{{#if (eq @attr.options.editType "textarea")}}
|
||||
@ -224,9 +212,6 @@
|
||||
oninput={{this.onChangeWithEvent}}
|
||||
class="textarea {{if this.validationError 'has-error-border'}}"
|
||||
></textarea>
|
||||
{{#if this.validationError}}
|
||||
<AlertInline @type="danger" @message={{this.validationError}} @paddingTop={{true}} />
|
||||
{{/if}}
|
||||
{{else if (eq @attr.options.editType "password")}}
|
||||
<Input
|
||||
data-test-input={{@attr.name}}
|
||||
@ -253,6 +238,7 @@
|
||||
@theme={{or @attr.options.theme "hashi"}}
|
||||
@helpText={{@attr.options.helpText}}
|
||||
@mode={{@attr.options.mode}}
|
||||
@example={{@attr.options.example}}
|
||||
>
|
||||
{{#if @attr.options.allowReset}}
|
||||
<button
|
||||
@ -295,22 +281,12 @@
|
||||
class="input {{if this.validationError 'has-error-border'}}"
|
||||
maxLength={{@attr.options.characterLimit}}
|
||||
/>
|
||||
{{! TODO: explore removing in favor of new model validations pattern since it is only used on the namespace model }}
|
||||
{{#if @attr.options.validationAttr}}
|
||||
{{#if (and (get @model this.valuePath) (not (get @model @attr.options.validationAttr)))}}
|
||||
<AlertInline @type="danger" @message={{@attr.options.invalidMessage}} />
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
{{#if this.validationError}}
|
||||
<AlertInline @type="danger" @message={{this.validationError}} @paddingTop={{true}} />
|
||||
{{/if}}
|
||||
{{#if this.validationWarning}}
|
||||
<AlertInline
|
||||
@type="warning"
|
||||
@message={{this.validationWarning}}
|
||||
@paddingTop={{true}}
|
||||
data-test-validation-warning
|
||||
/>
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
</div>
|
||||
{{else if (or (eq @attr.type "boolean") (eq @attr.options.editType "boolean"))}}
|
||||
@ -347,8 +323,27 @@
|
||||
@value={{if (get @model this.valuePath) (stringify (get @model this.valuePath)) this.emptyData}}
|
||||
@valueUpdated={{fn this.codemirrorUpdated false}}
|
||||
@helpText={{@attr.options.helpText}}
|
||||
@example={{@attr.options.example}}
|
||||
/>
|
||||
{{else if (eq @attr.options.editType "yield")}}
|
||||
{{yield}}
|
||||
{{/if}}
|
||||
{{#if this.validationError}}
|
||||
<AlertInline
|
||||
@type="danger"
|
||||
@message={{this.validationError}}
|
||||
@paddingTop={{not-eq @attr.options.editType "ttl"}}
|
||||
data-test-field-validation={{@attr.name}}
|
||||
class={{if (eq @attr.options.editType "stringArray") "has-top-margin-negative-xxl"}}
|
||||
/>
|
||||
{{/if}}
|
||||
{{#if this.validationWarning}}
|
||||
<AlertInline
|
||||
@type="warning"
|
||||
@message={{this.validationWarning}}
|
||||
@paddingTop={{if (and (not this.validationError) (eq @attr.options.editType "ttl")) false true}}
|
||||
data-test-validation-warning={{@attr.name}}
|
||||
class={{if (and (not this.validationError) (eq @attr.options.editType "stringArray")) "has-top-margin-negative-xxl"}}
|
||||
/>
|
||||
{{/if}}
|
||||
</div>
|
@ -15,6 +15,18 @@
|
||||
</label>
|
||||
<ToolbarActions>
|
||||
{{yield}}
|
||||
{{#if @example}}
|
||||
<button
|
||||
type="button"
|
||||
class="toolbar-link"
|
||||
disabled={{not @value}}
|
||||
{{on "click" this.restoreExample}}
|
||||
data-test-restore-example
|
||||
>
|
||||
Restore example
|
||||
<Icon @name="reload" />
|
||||
</button>
|
||||
{{/if}}
|
||||
<div class="toolbar-separator"></div>
|
||||
<CopyButton
|
||||
class="button is-transparent"
|
||||
@ -30,7 +42,7 @@
|
||||
{{/if}}
|
||||
<div
|
||||
{{code-mirror
|
||||
content=@value
|
||||
content=(or @value @example)
|
||||
extraKeys=@extraKeys
|
||||
gutters=@gutters
|
||||
lineNumbers=(if @readOnly false true)
|
||||
@ -38,6 +50,7 @@
|
||||
readOnly=@readOnly
|
||||
theme=@theme
|
||||
viewportMarg=@viewportMargin
|
||||
onSetup=this.onSetup
|
||||
onUpdate=this.onUpdate
|
||||
onFocus=this.onFocus
|
||||
}}
|
||||
|
@ -26,6 +26,7 @@ import { action } from '@ember/object';
|
||||
* @param {String} [theme] - Specify or customize the look via a named "theme" class in scss.
|
||||
* @param {String} [value] - Value within the display. Generally, a json string.
|
||||
* @param {String} [viewportMargin] - Size of viewport. Often set to "Infinity" to load/show all text regardless of length.
|
||||
* @param {string} [example] - Example to show when value is null -- when example is provided a restore action will render in the toolbar to clear the current value and show the example after input
|
||||
*/
|
||||
|
||||
export default class JsonEditorComponent extends Component {
|
||||
@ -33,6 +34,12 @@ export default class JsonEditorComponent extends Component {
|
||||
return this.args.showToolbar === false ? false : true;
|
||||
}
|
||||
|
||||
@action
|
||||
onSetup(editor) {
|
||||
// store reference to codemirror editor so that it can be passed to valueUpdated when restoring example
|
||||
this._codemirrorEditor = editor;
|
||||
}
|
||||
|
||||
@action
|
||||
onUpdate(...args) {
|
||||
if (!this.args.readOnly) {
|
||||
@ -47,4 +54,10 @@ export default class JsonEditorComponent extends Component {
|
||||
this.args.onFocusOut(...args);
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
restoreExample() {
|
||||
// set value to null which will cause the example value to be passed into the editor
|
||||
this.args.valueUpdated(null, this._codemirrorEditor);
|
||||
}
|
||||
}
|
||||
|
@ -8,6 +8,7 @@
|
||||
@hasBorder={{true}}
|
||||
class="overview-card border-radius-2"
|
||||
data-test-overview-card-container={{@cardTitle}}
|
||||
...attributes
|
||||
>
|
||||
<div class="is-flex-between" data-test-overview-card={{@cardTitle}}>
|
||||
<h3 class="title is-5">{{@cardTitle}}</h3>
|
||||
|
@ -9,14 +9,16 @@
|
||||
<li data-test-crumb="{{idx}}">
|
||||
<span class="sep">/</span>
|
||||
{{#if breadcrumb.linkExternal}}
|
||||
<LinkToExternal @route={{breadcrumb.route}}>{{breadcrumb.label}}</LinkToExternal>
|
||||
<LinkToExternal @route={{breadcrumb.route}} data-test-breadcrumb={{breadcrumb.label}}>
|
||||
{{breadcrumb.label}}
|
||||
</LinkToExternal>
|
||||
{{else if breadcrumb.route}}
|
||||
{{#if breadcrumb.model}}
|
||||
<LinkTo @route={{breadcrumb.route}} @model={{breadcrumb.model}}>
|
||||
<LinkTo @route={{breadcrumb.route}} @model={{breadcrumb.model}} data-test-breadcrumb={{breadcrumb.label}}>
|
||||
{{breadcrumb.label}}
|
||||
</LinkTo>
|
||||
{{else}}
|
||||
<LinkTo @route={{breadcrumb.route}}>
|
||||
<LinkTo @route={{breadcrumb.route}} data-test-breadcrumb={{breadcrumb.label}}>
|
||||
{{breadcrumb.label}}
|
||||
</LinkTo>
|
||||
{{/if}}
|
||||
|
17
ui/lib/core/addon/components/secrets-engine-mount-config.hbs
Normal file
17
ui/lib/core/addon/components/secrets-engine-mount-config.hbs
Normal file
@ -0,0 +1,17 @@
|
||||
<div ...attributes>
|
||||
<ToggleButton
|
||||
@isOpen={{this.showConfig}}
|
||||
@openLabel="Hide mount configuration"
|
||||
@closedLabel="Show mount configuration"
|
||||
@onClick={{fn (mut this.showConfig) (not this.showConfig)}}
|
||||
class="is-block"
|
||||
data-test-mount-config-toggle
|
||||
/>
|
||||
{{#if this.showConfig}}
|
||||
{{#each this.fields as |field|}}
|
||||
<InfoTableRow @label={{field.label}} @value={{field.value}} data-test-mount-config-field={{field.label}} />
|
||||
{{/each}}
|
||||
{{! block for additional fields that may be engine specific }}
|
||||
{{yield}}
|
||||
{{/if}}
|
||||
</div>
|
29
ui/lib/core/addon/components/secrets-engine-mount-config.ts
Normal file
29
ui/lib/core/addon/components/secrets-engine-mount-config.ts
Normal file
@ -0,0 +1,29 @@
|
||||
import Component from '@glimmer/component';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
|
||||
import type SecretEngineModel from 'vault/models/secret-engine';
|
||||
|
||||
interface Args {
|
||||
model: SecretEngineModel;
|
||||
}
|
||||
interface Field {
|
||||
label: string;
|
||||
value: string | boolean;
|
||||
}
|
||||
|
||||
export default class SecretsEngineMountConfigComponent extends Component<Args> {
|
||||
@tracked showConfig = false;
|
||||
|
||||
get fields(): Array<Field> {
|
||||
const { model } = this.args;
|
||||
return [
|
||||
{ label: 'Secret Engine Type', value: model.engineType },
|
||||
{ label: 'Path', value: model.path },
|
||||
{ label: 'Accessor', value: model.accessor },
|
||||
{ label: 'Local', value: model.local },
|
||||
{ label: 'Seal Wrap', value: model.sealWrap },
|
||||
{ label: 'Default Lease TTL', value: model.config.defaultLeaseTtl },
|
||||
{ label: 'Max Lease TTL', value: model.config.maxLeaseTtl },
|
||||
];
|
||||
}
|
||||
}
|
@ -5,35 +5,48 @@
|
||||
|
||||
import Route from '@ember/routing/route';
|
||||
|
||||
import type Store from '@ember-data/store';
|
||||
import type SecretMountPath from 'vault/services/secret-mount-path';
|
||||
import type Transition from '@ember/routing/transition';
|
||||
import type Model from '@ember-data/model';
|
||||
import type AdapterError from 'ember-data/adapter'; // eslint-disable-line ember/use-ember-data-rfc-395-imports
|
||||
|
||||
/**
|
||||
* the overview, configure, configuration and roles routes all need to be aware of the config for the engine
|
||||
* for use in routes that need to be aware of the config for a secrets engine
|
||||
* if the user has not configured they are prompted to do so in each of the routes
|
||||
* decorate the necessary routes to perform the check in the beforeModel hook since that may change what is returned for the model
|
||||
*/
|
||||
|
||||
export function withConfig() {
|
||||
return function decorator(SuperClass) {
|
||||
interface BaseRoute extends Route {
|
||||
store: Store;
|
||||
secretMountPath: SecretMountPath;
|
||||
}
|
||||
|
||||
export function withConfig(modelName: string) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return function <RouteClass extends new (...args: any[]) => BaseRoute>(SuperClass: RouteClass) {
|
||||
if (!Object.prototype.isPrototypeOf.call(Route, SuperClass)) {
|
||||
// eslint-disable-next-line
|
||||
console.error(
|
||||
'withConfig decorator must be used on an instance of ember Route class. Decorator not applied to returned class'
|
||||
'withConfig decorator must be used on an instance of Ember Route class. Decorator not applied to returned class'
|
||||
);
|
||||
return SuperClass;
|
||||
}
|
||||
return class FetchConfig extends SuperClass {
|
||||
configModel = null;
|
||||
configError = null;
|
||||
|
||||
return class FetchSecretsEngineConfig extends SuperClass {
|
||||
configModel: Model | null = null;
|
||||
configError: AdapterError | null = null;
|
||||
promptConfig = false;
|
||||
|
||||
async beforeModel() {
|
||||
super.beforeModel(...arguments);
|
||||
async beforeModel(transition: Transition) {
|
||||
super.beforeModel(transition);
|
||||
|
||||
const backend = this.secretMountPath.get();
|
||||
const backend = this.secretMountPath.currentPath;
|
||||
// check the store for record first
|
||||
this.configModel = this.store.peekRecord('kubernetes/config', backend);
|
||||
this.configModel = this.store.peekRecord(modelName, backend);
|
||||
if (!this.configModel) {
|
||||
return this.store
|
||||
.queryRecord('kubernetes/config', { backend })
|
||||
.queryRecord(modelName, { backend })
|
||||
.then((record) => {
|
||||
this.configModel = record;
|
||||
this.promptConfig = false;
|
@ -68,5 +68,9 @@ export default class CodeMirrorModifier extends Modifier {
|
||||
editor.on('focus', bind(this, this._onFocus, namedArgs));
|
||||
|
||||
this._editor = editor;
|
||||
|
||||
if (namedArgs.onSetup) {
|
||||
namedArgs.onSetup(editor);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
6
ui/lib/core/app/components/filter-input.js
Normal file
6
ui/lib/core/app/components/filter-input.js
Normal file
@ -0,0 +1,6 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: MPL-2.0
|
||||
*/
|
||||
|
||||
export { default } from 'core/components/filter-input';
|
@ -0,0 +1,6 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: MPL-2.0
|
||||
*/
|
||||
|
||||
export { default } from 'core/components/secrets-engine-mount-config';
|
6
ui/lib/core/app/helpers/jsonify.js
Normal file
6
ui/lib/core/app/helpers/jsonify.js
Normal file
@ -0,0 +1,6 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: MPL-2.0
|
||||
*/
|
||||
|
||||
export { default, jsonify } from 'core/helpers/jsonify';
|
@ -3,4 +3,4 @@
|
||||
* SPDX-License-Identifier: MPL-2.0
|
||||
*/
|
||||
|
||||
export { default } from 'core/helpers/stringify';
|
||||
export { default, stringify } from 'core/helpers/stringify';
|
||||
|
@ -5,9 +5,9 @@
|
||||
|
||||
import Route from '@ember/routing/route';
|
||||
import { inject as service } from '@ember/service';
|
||||
import { withConfig } from '../decorators/fetch-config';
|
||||
import { withConfig } from 'core/decorators/fetch-secrets-engine-config';
|
||||
|
||||
@withConfig()
|
||||
@withConfig('kubernetes/config')
|
||||
export default class KubernetesConfigureRoute extends Route {
|
||||
@service store;
|
||||
@service secretMountPath;
|
||||
|
@ -5,15 +5,15 @@
|
||||
|
||||
import Route from '@ember/routing/route';
|
||||
import { inject as service } from '@ember/service';
|
||||
import { withConfig } from '../decorators/fetch-config';
|
||||
import { withConfig } from 'core/decorators/fetch-secrets-engine-config';
|
||||
|
||||
@withConfig()
|
||||
@withConfig('kubernetes/config')
|
||||
export default class KubernetesConfigureRoute extends Route {
|
||||
@service store;
|
||||
@service secretMountPath;
|
||||
|
||||
async model() {
|
||||
const backend = this.secretMountPath.get();
|
||||
const backend = this.secretMountPath.currentPath;
|
||||
return this.configModel || this.store.createRecord('kubernetes/config', { backend });
|
||||
}
|
||||
|
||||
|
@ -5,16 +5,16 @@
|
||||
|
||||
import Route from '@ember/routing/route';
|
||||
import { inject as service } from '@ember/service';
|
||||
import { withConfig } from 'kubernetes/decorators/fetch-config';
|
||||
import { withConfig } from 'core/decorators/fetch-secrets-engine-config';
|
||||
import { hash } from 'rsvp';
|
||||
|
||||
@withConfig()
|
||||
@withConfig('kubernetes/config')
|
||||
export default class KubernetesOverviewRoute extends Route {
|
||||
@service store;
|
||||
@service secretMountPath;
|
||||
|
||||
async model() {
|
||||
const backend = this.secretMountPath.get();
|
||||
const backend = this.secretMountPath.currentPath;
|
||||
return hash({
|
||||
promptConfig: this.promptConfig,
|
||||
backend: this.modelFor('application'),
|
||||
|
@ -11,7 +11,7 @@ export default class KubernetesRolesCreateRoute extends Route {
|
||||
@service secretMountPath;
|
||||
|
||||
model() {
|
||||
const backend = this.secretMountPath.get();
|
||||
const backend = this.secretMountPath.currentPath;
|
||||
return this.store.createRecord('kubernetes/role', { backend });
|
||||
}
|
||||
|
||||
|
@ -5,10 +5,10 @@
|
||||
|
||||
import Route from '@ember/routing/route';
|
||||
import { inject as service } from '@ember/service';
|
||||
import { withConfig } from 'kubernetes/decorators/fetch-config';
|
||||
import { withConfig } from 'core/decorators/fetch-secrets-engine-config';
|
||||
import { hash } from 'rsvp';
|
||||
|
||||
@withConfig()
|
||||
@withConfig('kubernetes/config')
|
||||
export default class KubernetesRolesRoute extends Route {
|
||||
@service store;
|
||||
@service secretMountPath;
|
||||
@ -17,7 +17,7 @@ export default class KubernetesRolesRoute extends Route {
|
||||
// filter roles based on pageFilter value
|
||||
const { pageFilter } = transition.to.queryParams;
|
||||
const roles = this.store
|
||||
.query('kubernetes/role', { backend: this.secretMountPath.get() })
|
||||
.query('kubernetes/role', { backend: this.secretMountPath.currentPath })
|
||||
.then((models) =>
|
||||
pageFilter
|
||||
? models.filter((model) => model.name.toLowerCase().includes(pageFilter.toLowerCase()))
|
||||
|
@ -11,7 +11,7 @@ export default class KubernetesRoleCredentialsRoute extends Route {
|
||||
model() {
|
||||
return {
|
||||
roleName: this.paramsFor('roles.role').name,
|
||||
backend: this.secretMountPath.get(),
|
||||
backend: this.secretMountPath.currentPath,
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -11,7 +11,7 @@ export default class KubernetesRoleDetailsRoute extends Route {
|
||||
@service secretMountPath;
|
||||
|
||||
model() {
|
||||
const backend = this.secretMountPath.get();
|
||||
const backend = this.secretMountPath.currentPath;
|
||||
const { name } = this.paramsFor('roles.role');
|
||||
return this.store.queryRecord('kubernetes/role', { backend, name });
|
||||
}
|
||||
|
@ -11,7 +11,7 @@ export default class KubernetesRoleEditRoute extends Route {
|
||||
@service secretMountPath;
|
||||
|
||||
model() {
|
||||
const backend = this.secretMountPath.get();
|
||||
const backend = this.secretMountPath.currentPath;
|
||||
const { name } = this.paramsFor('roles.role');
|
||||
return this.store.queryRecord('kubernetes/role', { backend, name });
|
||||
}
|
||||
|
75
ui/lib/ldap/addon/components/accounts-checked-out.hbs
Normal file
75
ui/lib/ldap/addon/components/accounts-checked-out.hbs
Normal file
@ -0,0 +1,75 @@
|
||||
<OverviewCard
|
||||
@cardTitle="Accounts checked-out"
|
||||
@subText="The accounts that are currently on lease with this token or exist in a library set with check-in enforcement disabled."
|
||||
class="has-padding-l"
|
||||
...attributes
|
||||
>
|
||||
<hr class="has-background-gray-200" />
|
||||
|
||||
{{#if this.filteredAccounts}}
|
||||
<Hds::Table @model={{this.filteredAccounts}} @columns={{this.columns}}>
|
||||
<:body as |Body|>
|
||||
<Body.Tr>
|
||||
<Body.Td data-test-checked-out-account={{Body.data.account}}>{{Body.data.account}}</Body.Td>
|
||||
{{#if @showLibraryColumn}}
|
||||
<Body.Td data-test-checked-out-library={{Body.data.account}}>{{Body.data.library}}</Body.Td>
|
||||
{{/if}}
|
||||
<Body.Td>
|
||||
<button
|
||||
type="button"
|
||||
class="text-button has-text-primary has-text-weight-semibold"
|
||||
disabled={{this.disableCheckIn Body.data.library}}
|
||||
data-test-checked-out-account-action={{Body.data.account}}
|
||||
{{on "click" (fn (mut this.selectedStatus) Body.data)}}
|
||||
>
|
||||
<Icon @name="queue" />
|
||||
Check-in
|
||||
</button>
|
||||
</Body.Td>
|
||||
</Body.Tr>
|
||||
</:body>
|
||||
</Hds::Table>
|
||||
{{else}}
|
||||
<EmptyState
|
||||
@title="No accounts checked out yet"
|
||||
@message="There is no account that is currently in use."
|
||||
class="is-shadowless"
|
||||
/>
|
||||
{{/if}}
|
||||
</OverviewCard>
|
||||
|
||||
{{#if this.selectedStatus}}
|
||||
<Modal
|
||||
@title="Account Check-in"
|
||||
@isActive={{this.selectedStatus}}
|
||||
@showCloseButton={{true}}
|
||||
@onClose={{fn (mut this.selectedStatus) undefined}}
|
||||
>
|
||||
<section class="modal-card-body">
|
||||
<p>
|
||||
This action will check-in account
|
||||
{{this.selectedStatus.account}}
|
||||
back to the library. Do you want to proceed?
|
||||
</p>
|
||||
</section>
|
||||
<footer class="modal-card-foot modal-card-foot-outlined">
|
||||
<button
|
||||
type="button"
|
||||
class="button is-primary {{if this.save.isRunning 'is-loading'}}"
|
||||
disabled={{this.checkIn.isRunning}}
|
||||
data-test-check-in-confirm
|
||||
{{on "click" (perform this.checkIn)}}
|
||||
>
|
||||
Confirm
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="button"
|
||||
disabled={{this.checkIn.isRunning}}
|
||||
{{on "click" (fn (mut this.selectedStatus) "")}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</footer>
|
||||
</Modal>
|
||||
{{/if}}
|
72
ui/lib/ldap/addon/components/accounts-checked-out.ts
Normal file
72
ui/lib/ldap/addon/components/accounts-checked-out.ts
Normal file
@ -0,0 +1,72 @@
|
||||
import Component from '@glimmer/component';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import { inject as service } from '@ember/service';
|
||||
import { task } from 'ember-concurrency';
|
||||
import { waitFor } from '@ember/test-waiters';
|
||||
import errorMessage from 'vault/utils/error-message';
|
||||
|
||||
import type FlashMessageService from 'vault/services/flash-messages';
|
||||
import type RouterService from '@ember/routing/router-service';
|
||||
import type AuthService from 'vault/services/auth';
|
||||
import type LdapLibraryModel from 'vault/models/ldap/library';
|
||||
import type { LdapLibraryAccountStatus } from 'vault/adapters/ldap/library';
|
||||
|
||||
interface Args {
|
||||
libraries: Array<LdapLibraryModel>;
|
||||
statuses: Array<LdapLibraryAccountStatus>;
|
||||
showLibraryColumn: boolean;
|
||||
}
|
||||
|
||||
export default class LdapAccountsCheckedOutComponent extends Component<Args> {
|
||||
@service declare readonly flashMessages: FlashMessageService;
|
||||
@service declare readonly router: RouterService;
|
||||
@service declare readonly auth: AuthService;
|
||||
|
||||
@tracked selectedStatus: LdapLibraryAccountStatus | undefined;
|
||||
|
||||
get columns() {
|
||||
const columns = [{ label: 'Account' }, { label: 'Action' }];
|
||||
if (this.args.showLibraryColumn) {
|
||||
columns.splice(1, 0, { label: 'Library' });
|
||||
}
|
||||
return columns;
|
||||
}
|
||||
|
||||
get filteredAccounts() {
|
||||
// filter status to only show checked out accounts associated to the current user
|
||||
// if disable_check_in_enforcement is true on the library set then all checked out accounts are displayed
|
||||
return this.args.statuses.filter((status) => {
|
||||
const authEntityId = this.auth.authData?.entity_id;
|
||||
const isRoot = !status.borrower_entity_id && !authEntityId; // root user will not have an entity id and it won't be populated on status
|
||||
const isEntity = status.borrower_entity_id === authEntityId;
|
||||
const library = this.findLibrary(status.library);
|
||||
const enforcementDisabled = library.disable_check_in_enforcement === 'Disabled';
|
||||
|
||||
return !status.available && (enforcementDisabled || isEntity || isRoot);
|
||||
});
|
||||
}
|
||||
|
||||
disableCheckIn = (name: string) => {
|
||||
return !this.findLibrary(name).canCheckIn;
|
||||
};
|
||||
|
||||
findLibrary(name: string): LdapLibraryModel {
|
||||
return this.args.libraries.find((library) => library.name === name) as LdapLibraryModel;
|
||||
}
|
||||
|
||||
@task
|
||||
@waitFor
|
||||
*checkIn() {
|
||||
const { library, account } = this.selectedStatus as LdapLibraryAccountStatus;
|
||||
try {
|
||||
const libraryModel = this.findLibrary(library);
|
||||
yield libraryModel.checkInAccount(account);
|
||||
this.flashMessages.success(`Successfully checked in the account ${account}.`);
|
||||
// transitioning to the current route to trigger the model hook so we can fetch the updated status
|
||||
this.router.transitionTo('vault.cluster.secrets.backend.ldap.libraries.library.details.accounts');
|
||||
} catch (error) {
|
||||
this.selectedStatus = undefined;
|
||||
this.flashMessages.danger(`Error checking in the account ${account}. \n ${errorMessage(error)}`);
|
||||
}
|
||||
}
|
||||
}
|
9
ui/lib/ldap/addon/components/config-cta.hbs
Normal file
9
ui/lib/ldap/addon/components/config-cta.hbs
Normal file
@ -0,0 +1,9 @@
|
||||
<EmptyState
|
||||
data-test-config-cta
|
||||
@title="LDAP not configured"
|
||||
@message="Get started by setting up the connection with your existing LDAP system."
|
||||
>
|
||||
<LinkTo class="has-top-margin-xs" @route="configure">
|
||||
Configure LDAP
|
||||
</LinkTo>
|
||||
</EmptyState>
|
39
ui/lib/ldap/addon/components/page/configuration.hbs
Normal file
39
ui/lib/ldap/addon/components/page/configuration.hbs
Normal file
@ -0,0 +1,39 @@
|
||||
<TabPageHeader @model={{@backendModel}} @breadcrumbs={{@breadcrumbs}}>
|
||||
<:toolbarActions>
|
||||
{{#if @configModel}}
|
||||
<ConfirmAction
|
||||
@buttonClasses="toolbar-link"
|
||||
@onConfirmAction={{perform this.rotateRoot}}
|
||||
@confirmTitle="Rotate root?"
|
||||
@confirmMessage="After rotation, Vault will generate a new root password in your directory server."
|
||||
@confirmButtonText="Rotate"
|
||||
@disabled={{this.rotateRoot.isRunning}}
|
||||
data-test-toolbar-rotate-action
|
||||
>
|
||||
Rotate root
|
||||
</ConfirmAction>
|
||||
{{/if}}
|
||||
<ToolbarLink @route="configure" data-test-toolbar-config-action>
|
||||
{{if @configModel "Edit configuration" "Configure LDAP"}}
|
||||
</ToolbarLink>
|
||||
</:toolbarActions>
|
||||
</TabPageHeader>
|
||||
|
||||
{{#if @configModel}}
|
||||
{{#each this.defaultFields as |field|}}
|
||||
<InfoTableRow @label={{field.label}} @value={{field.value}} @formatTtl={{field.formatTtl}} @alwaysRender={{true}} />
|
||||
{{/each}}
|
||||
|
||||
<h2 class="title is-4 has-top-margin-xl">TLS Connection</h2>
|
||||
<hr class="is-marginless" />
|
||||
|
||||
{{#each this.connectionFields as |field|}}
|
||||
<InfoTableRow @label={{field.label}} @value={{field.value}} @alwaysRender={{true}} />
|
||||
{{/each}}
|
||||
{{else if @configError}}
|
||||
<Page::Error @error={{@configError}} />
|
||||
{{else}}
|
||||
<ConfigCta />
|
||||
{{/if}}
|
||||
|
||||
<SecretsEngineMountConfig @model={{@backendModel}} class="has-top-margin-xl has-bottom-margin-xl" data-test-mount-config />
|
82
ui/lib/ldap/addon/components/page/configuration.ts
Normal file
82
ui/lib/ldap/addon/components/page/configuration.ts
Normal file
@ -0,0 +1,82 @@
|
||||
import Component from '@glimmer/component';
|
||||
import { inject as service } from '@ember/service';
|
||||
import { task } from 'ember-concurrency';
|
||||
import { waitFor } from '@ember/test-waiters';
|
||||
import errorMessage from 'vault/utils/error-message';
|
||||
|
||||
import type LdapConfigModel from 'vault/models/ldap/config';
|
||||
import type SecretEngineModel from 'vault/models/secret-engine';
|
||||
import type AdapterError from 'ember-data/adapter'; // eslint-disable-line ember/use-ember-data-rfc-395-imports
|
||||
import type { Breadcrumb } from 'vault/vault/app-types';
|
||||
import type FlashMessageService from 'vault/services/flash-messages';
|
||||
|
||||
interface Args {
|
||||
configModel: LdapConfigModel;
|
||||
configError: AdapterError;
|
||||
backendModel: SecretEngineModel;
|
||||
breadcrumbs: Array<Breadcrumb>;
|
||||
}
|
||||
|
||||
interface Field {
|
||||
label: string;
|
||||
value: any; // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
formatTtl?: boolean;
|
||||
}
|
||||
|
||||
export default class LdapConfigurationPageComponent extends Component<Args> {
|
||||
@service declare readonly flashMessages: FlashMessageService;
|
||||
|
||||
get defaultFields(): Array<Field> {
|
||||
const model = this.args.configModel;
|
||||
const keys = [
|
||||
'binddn',
|
||||
'url',
|
||||
'schema',
|
||||
'password_policy',
|
||||
'userdn',
|
||||
'userattr',
|
||||
'connection_timeout',
|
||||
'request_timeout',
|
||||
];
|
||||
return model.allFields.reduce<Array<Field>>((filtered, field) => {
|
||||
if (keys.includes(field.name)) {
|
||||
const label =
|
||||
{
|
||||
schema: 'Schema',
|
||||
password_policy: 'Password Policy',
|
||||
}[field.name] || field.options.label;
|
||||
filtered.splice(keys.indexOf(field.name), 0, {
|
||||
label,
|
||||
value: model[field.name as keyof typeof model],
|
||||
formatTtl: field.name.includes('timeout'),
|
||||
});
|
||||
}
|
||||
return filtered;
|
||||
}, []);
|
||||
}
|
||||
|
||||
get connectionFields(): Array<Field> {
|
||||
const model = this.args.configModel;
|
||||
const keys = ['certificate', 'starttls', 'insecure_tls', 'client_tls_cert', 'client_tls_key'];
|
||||
return model.allFields.reduce<Array<Field>>((filtered, field) => {
|
||||
if (keys.includes(field.name)) {
|
||||
filtered.splice(keys.indexOf(field.name), 0, {
|
||||
label: field.options.label,
|
||||
value: model[field.name as keyof typeof model],
|
||||
});
|
||||
}
|
||||
return filtered;
|
||||
}, []);
|
||||
}
|
||||
|
||||
@task
|
||||
@waitFor
|
||||
*rotateRoot() {
|
||||
try {
|
||||
yield this.args.configModel.rotateRoot();
|
||||
this.flashMessages.success('Root password successfully rotated.');
|
||||
} catch (error) {
|
||||
this.flashMessages.danger(`Error rotating root password \n ${errorMessage(error)}`);
|
||||
}
|
||||
}
|
||||
}
|
113
ui/lib/ldap/addon/components/page/configure.hbs
Normal file
113
ui/lib/ldap/addon/components/page/configure.hbs
Normal file
@ -0,0 +1,113 @@
|
||||
<PageHeader as |p|>
|
||||
<p.top>
|
||||
<Page::Breadcrumbs @breadcrumbs={{@breadcrumbs}} />
|
||||
</p.top>
|
||||
<p.levelLeft>
|
||||
<h1 class="title is-3">Configure LDAP</h1>
|
||||
</p.levelLeft>
|
||||
</PageHeader>
|
||||
|
||||
<hr class="is-marginless has-background-gray-200" />
|
||||
|
||||
<form class="has-top-margin-l" {{on "submit" (perform this.save)}}>
|
||||
<Hds::Form::RadioCard::Group @name="schema options" as |RadioGroup|>
|
||||
{{#each this.schemaOptions as |option|}}
|
||||
<RadioGroup.RadioCard
|
||||
@checked={{eq option.value @model.schema}}
|
||||
{{on "change" (fn (mut @model.schema) option.value)}}
|
||||
data-test-radio-card={{option.title}}
|
||||
as |Card|
|
||||
>
|
||||
<Card.Icon @name={{option.icon}} />
|
||||
<Card.Label>{{option.title}}</Card.Label>
|
||||
<Card.Description>{{option.description}}</Card.Description>
|
||||
</RadioGroup.RadioCard>
|
||||
{{/each}}
|
||||
</Hds::Form::RadioCard::Group>
|
||||
|
||||
<div class="has-top-margin-xl">
|
||||
<MessageError @errorMessage={{this.error}} />
|
||||
|
||||
<h2 class="title is-4">Schema Options</h2>
|
||||
<hr class="has-background-gray-200" />
|
||||
|
||||
{{#if @model.schema}}
|
||||
<div class="has-top-margin-l">
|
||||
<FormFieldGroups @model={{@model}} @groupName="formFieldGroups" @modelValidations={{this.modelValidations}} />
|
||||
</div>
|
||||
{{else}}
|
||||
<EmptyState
|
||||
class="is-shadowless has-top-margin-l"
|
||||
@title="Choose an option"
|
||||
@message="Pick an option above to see available configuration options"
|
||||
/>
|
||||
{{/if}}
|
||||
</div>
|
||||
|
||||
<hr class="has-background-gray-200 has-top-margin-l" />
|
||||
|
||||
<div class="has-top-margin-l has-bottom-margin-l is-flex">
|
||||
<button
|
||||
data-test-config-save
|
||||
class="button is-primary"
|
||||
type="submit"
|
||||
disabled={{or this.save.isRunning (not @model.schema)}}
|
||||
{{on "click" (perform this.save)}}
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
data-test-config-cancel
|
||||
class="button has-left-margin-xs"
|
||||
type="button"
|
||||
disabled={{or this.save.isRunning this.fetchInferred.isRunning}}
|
||||
{{on "click" this.cancel}}
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
{{#if this.invalidFormMessage}}
|
||||
<AlertInline
|
||||
@type="danger"
|
||||
@paddingTop={{true}}
|
||||
@message={{this.invalidFormMessage}}
|
||||
@mimicRefresh={{true}}
|
||||
data-test-invalid-form-message
|
||||
/>
|
||||
{{/if}}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{{#if this.showRotatePrompt}}
|
||||
<Modal
|
||||
@title="Rotate your root password?"
|
||||
@type="info"
|
||||
@isActive={{this.showRotatePrompt}}
|
||||
@showCloseButton={{true}}
|
||||
@onClose={{fn (mut this.showRotatePrompt) false}}
|
||||
>
|
||||
<section class="modal-card-body">
|
||||
<p>
|
||||
It’s best practice to rotate the administrator (root) password immediately after the initial configuration of the
|
||||
LDAP engine. The rotation will update the password both in Vault and your directory server. Once rotated,
|
||||
<span class="has-text-weight-semibold">only Vault knows the new root password.</span>
|
||||
</p>
|
||||
<br />
|
||||
<p>
|
||||
Would you like to rotate your new credentials? You can also do this later.
|
||||
</p>
|
||||
</section>
|
||||
<footer class="modal-card-foot modal-card-foot-outlined">
|
||||
<button
|
||||
data-test-save-with-rotate
|
||||
type="button"
|
||||
class="button is-primary"
|
||||
{{on "click" (fn (perform this.save) null true)}}
|
||||
>
|
||||
Save and rotate
|
||||
</button>
|
||||
<button data-test-save-without-rotate type="button" class="button" {{on "click" (fn (perform this.save) null false)}}>
|
||||
Save without rotating
|
||||
</button>
|
||||
</footer>
|
||||
</Modal>
|
||||
{{/if}}
|
113
ui/lib/ldap/addon/components/page/configure.ts
Normal file
113
ui/lib/ldap/addon/components/page/configure.ts
Normal file
@ -0,0 +1,113 @@
|
||||
import Component from '@glimmer/component';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import { action } from '@ember/object';
|
||||
import { inject as service } from '@ember/service';
|
||||
import { task } from 'ember-concurrency';
|
||||
import { waitFor } from '@ember/test-waiters';
|
||||
import errorMessage from 'vault/utils/error-message';
|
||||
|
||||
import type LdapConfigModel from 'vault/models/ldap/config';
|
||||
import { Breadcrumb, ValidationMap } from 'vault/vault/app-types';
|
||||
import type FlashMessageService from 'vault/services/flash-messages';
|
||||
import type RouterService from '@ember/routing/router-service';
|
||||
|
||||
interface Args {
|
||||
model: LdapConfigModel;
|
||||
breadcrumbs: Array<Breadcrumb>;
|
||||
}
|
||||
interface SchemaOption {
|
||||
title: string;
|
||||
icon: string;
|
||||
description: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export default class LdapConfigurePageComponent extends Component<Args> {
|
||||
@service declare readonly flashMessages: FlashMessageService;
|
||||
@service declare readonly router: RouterService;
|
||||
|
||||
@tracked showRotatePrompt = false;
|
||||
@tracked modelValidations: ValidationMap | null = null;
|
||||
@tracked invalidFormMessage = '';
|
||||
@tracked error = '';
|
||||
|
||||
get schemaOptions(): Array<SchemaOption> {
|
||||
return [
|
||||
{
|
||||
title: 'OpenLDAP',
|
||||
icon: 'folder',
|
||||
description:
|
||||
'OpenLDAP is one of the most popular open source directory service developed by the OpenLDAP Project.',
|
||||
value: 'openldap',
|
||||
},
|
||||
{
|
||||
title: 'AD',
|
||||
icon: 'microsoft',
|
||||
description:
|
||||
'Active Directory is a directory service developed by Microsoft for Windows domain networks.',
|
||||
value: 'ad',
|
||||
},
|
||||
{
|
||||
title: 'RACF',
|
||||
icon: 'users',
|
||||
description:
|
||||
"For managing IBM's Resource Access Control Facility (RACF) security system, the generated passwords must be 8 characters or less.",
|
||||
value: 'racf',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
leave(route: string) {
|
||||
this.router.transitionTo(`vault.cluster.secrets.backend.ldap.${route}`);
|
||||
}
|
||||
|
||||
validate() {
|
||||
const { isValid, state, invalidFormMessage } = this.args.model.validate();
|
||||
this.modelValidations = isValid ? null : state;
|
||||
this.invalidFormMessage = isValid ? '' : invalidFormMessage;
|
||||
return isValid;
|
||||
}
|
||||
|
||||
async rotateRoot() {
|
||||
try {
|
||||
await this.args.model.rotateRoot();
|
||||
} catch (error) {
|
||||
// since config save was successful at this point we only want to show the error in a flash message
|
||||
this.flashMessages.danger(`Error rotating root password \n ${errorMessage(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
@task
|
||||
@waitFor
|
||||
*save(event: Event | null, rotate: boolean) {
|
||||
if (event) {
|
||||
event.preventDefault();
|
||||
}
|
||||
const isValid = this.validate();
|
||||
// show rotate creds prompt for new models when form state is valid
|
||||
this.showRotatePrompt = isValid && this.args.model.isNew && !this.showRotatePrompt;
|
||||
|
||||
if (isValid && !this.showRotatePrompt) {
|
||||
try {
|
||||
yield this.args.model.save();
|
||||
// if save was triggered from confirm action in rotate password prompt we need to make an additional request
|
||||
if (rotate) {
|
||||
yield this.rotateRoot();
|
||||
}
|
||||
this.flashMessages.success('Successfully configured LDAP engine');
|
||||
this.leave('configuration');
|
||||
} catch (error) {
|
||||
this.error = errorMessage(error, 'Error saving configuration. Please try again or contact support.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
cancel() {
|
||||
const { model } = this.args;
|
||||
const transitionRoute = model.isNew ? 'overview' : 'configuration';
|
||||
const cleanupMethod = model.isNew ? 'unloadRecord' : 'rollbackAttributes';
|
||||
model[cleanupMethod]();
|
||||
this.leave(transitionRoute);
|
||||
}
|
||||
}
|
91
ui/lib/ldap/addon/components/page/libraries.hbs
Normal file
91
ui/lib/ldap/addon/components/page/libraries.hbs
Normal file
@ -0,0 +1,91 @@
|
||||
<TabPageHeader @model={{@backendModel}} @breadcrumbs={{@breadcrumbs}}>
|
||||
<:toolbarFilters>
|
||||
{{#if (and (not @promptConfig) @libraries)}}
|
||||
<FilterInput @placeholder="Filter libraries" @onInput={{fn (mut this.filterValue)}} />
|
||||
{{/if}}
|
||||
</:toolbarFilters>
|
||||
<:toolbarActions>
|
||||
{{#if @promptConfig}}
|
||||
<ToolbarLink @route="configure" data-test-toolbar-action="config">
|
||||
Configure LDAP
|
||||
</ToolbarLink>
|
||||
{{else}}
|
||||
<ToolbarLink @route="libraries.create" @type="add" data-test-toolbar-action="library">
|
||||
Create library
|
||||
</ToolbarLink>
|
||||
{{/if}}
|
||||
</:toolbarActions>
|
||||
</TabPageHeader>
|
||||
|
||||
{{#if @promptConfig}}
|
||||
<ConfigCta />
|
||||
{{else if (not this.filteredLibraries)}}
|
||||
{{#if this.filterValue}}
|
||||
<EmptyState @title="There are no libraries matching "{{this.filterValue}}"" />
|
||||
{{else}}
|
||||
<EmptyState
|
||||
data-test-config-cta
|
||||
@title="No libraries created yet"
|
||||
@message="Use libraries to manage a set of highly privileged accounts that can be shared among a team."
|
||||
>
|
||||
<LinkTo class="has-top-margin-xs" @route="libraries.create">
|
||||
Create library
|
||||
</LinkTo>
|
||||
</EmptyState>
|
||||
{{/if}}
|
||||
{{else}}
|
||||
<div class="has-bottom-margin-s">
|
||||
{{#each this.filteredLibraries as |library|}}
|
||||
<ListItem @linkPrefix={{this.mountPoint}} @linkParams={{array "libraries.library.details" library.name}} as |Item|>
|
||||
<Item.content>
|
||||
<Icon @name="folder" />
|
||||
<span data-test-library={{library.name}}>{{library.name}}</span>
|
||||
</Item.content>
|
||||
<Item.menu as |Menu|>
|
||||
{{#if library.libraryPath.isLoading}}
|
||||
<li class="action">
|
||||
<button disabled type="button" class="link button is-loading is-transparent">
|
||||
loading
|
||||
</button>
|
||||
</li>
|
||||
{{else}}
|
||||
<li class="action">
|
||||
<LinkTo
|
||||
class="has-text-black has-text-weight-semibold"
|
||||
data-test-edit
|
||||
@route="libraries.library.edit"
|
||||
@model={{library}}
|
||||
@disabled={{not library.canEdit}}
|
||||
>
|
||||
Edit
|
||||
</LinkTo>
|
||||
</li>
|
||||
<li class="action">
|
||||
<LinkTo
|
||||
class="has-text-black has-text-weight-semibold"
|
||||
data-test-details
|
||||
@route="libraries.library.details"
|
||||
@model={{library}}
|
||||
@disabled={{not library.canRead}}
|
||||
>
|
||||
Details
|
||||
</LinkTo>
|
||||
</li>
|
||||
{{#if library.canDelete}}
|
||||
<li class="action">
|
||||
<Menu.Message
|
||||
data-test-delete
|
||||
@id={{library.id}}
|
||||
@triggerText="Delete"
|
||||
@title="Are you sure?"
|
||||
@message="This library and associated accounts will be permanently deleted. You will not be able to recover it."
|
||||
@onConfirm={{fn this.onDelete library}}
|
||||
/>
|
||||
</li>
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
</Item.menu>
|
||||
</ListItem>
|
||||
{{/each}}
|
||||
</div>
|
||||
{{/if}}
|
53
ui/lib/ldap/addon/components/page/libraries.ts
Normal file
53
ui/lib/ldap/addon/components/page/libraries.ts
Normal file
@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: MPL-2.0
|
||||
*/
|
||||
|
||||
import Component from '@glimmer/component';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import { inject as service } from '@ember/service';
|
||||
import { action } from '@ember/object';
|
||||
import { getOwner } from '@ember/application';
|
||||
import errorMessage from 'vault/utils/error-message';
|
||||
|
||||
import type LdapLibraryModel from 'vault/models/ldap/library';
|
||||
import type SecretEngineModel from 'vault/models/secret-engine';
|
||||
import type FlashMessageService from 'vault/services/flash-messages';
|
||||
import type { Breadcrumb, EngineOwner } from 'vault/vault/app-types';
|
||||
|
||||
interface Args {
|
||||
libraries: Array<LdapLibraryModel>;
|
||||
promptConfig: boolean;
|
||||
backendModel: SecretEngineModel;
|
||||
breadcrumbs: Array<Breadcrumb>;
|
||||
}
|
||||
|
||||
export default class LdapLibrariesPageComponent extends Component<Args> {
|
||||
@service declare readonly flashMessages: FlashMessageService;
|
||||
|
||||
@tracked filterValue = '';
|
||||
|
||||
get mountPoint(): string {
|
||||
const owner = getOwner(this) as EngineOwner;
|
||||
return owner.mountPoint;
|
||||
}
|
||||
|
||||
get filteredLibraries() {
|
||||
const { libraries } = this.args;
|
||||
return this.filterValue
|
||||
? libraries.filter((library) => library.name.toLowerCase().includes(this.filterValue.toLowerCase()))
|
||||
: libraries;
|
||||
}
|
||||
|
||||
@action
|
||||
async onDelete(model: LdapLibraryModel) {
|
||||
try {
|
||||
const message = `Successfully deleted library ${model.name}.`;
|
||||
await model.destroyRecord();
|
||||
this.args.libraries.removeObject(model);
|
||||
this.flashMessages.success(message);
|
||||
} catch (error) {
|
||||
this.flashMessages.danger(`Error deleting library \n ${errorMessage(error)}`);
|
||||
}
|
||||
}
|
||||
}
|
50
ui/lib/ldap/addon/components/page/library/check-out.hbs
Normal file
50
ui/lib/ldap/addon/components/page/library/check-out.hbs
Normal file
@ -0,0 +1,50 @@
|
||||
<PageHeader as |p|>
|
||||
<p.top>
|
||||
<Page::Breadcrumbs @breadcrumbs={{@breadcrumbs}} />
|
||||
</p.top>
|
||||
<p.levelLeft>
|
||||
<h1 class="title is-3" data-test-header-title>
|
||||
Check-out
|
||||
</h1>
|
||||
</p.levelLeft>
|
||||
</PageHeader>
|
||||
|
||||
<hr class="is-marginless has-background-gray-200" />
|
||||
|
||||
<Hds::Alert @type="inline" @color="warning" class="has-top-margin-m" as |Alert|>
|
||||
<Alert.Title>Warning</Alert.Title>
|
||||
<Alert.Description data-test-alert-description>
|
||||
You won’t be able to access these credentials later, so please copy them now.
|
||||
</Alert.Description>
|
||||
</Hds::Alert>
|
||||
|
||||
<div class="has-top-margin-m">
|
||||
<InfoTableRow @label="Account name" @value={{@credentials.account}} />
|
||||
<InfoTableRow @label="Password">
|
||||
<MaskedInput @value={{@credentials.password}} @displayOnly={{true}} @allowCopy={{true}} />
|
||||
</InfoTableRow>
|
||||
<InfoTableRow @label="Lease ID" @value={{@credentials.lease_id}} />
|
||||
<InfoTableRow @label="Lease duration" @value={{@credentials.lease_duration}} @formatTtl={{true}} />
|
||||
<InfoTableRow @label="Lease renewable">
|
||||
<div class="is-flex-v-centered">
|
||||
<Icon
|
||||
@name={{if @credentials.renewable "check-circle" "x-circle"}}
|
||||
class="is-marginless {{if @credentials.renewable 'has-text-success' 'has-text-danger'}}"
|
||||
/>
|
||||
<span class="has-left-margin-xs">
|
||||
{{if @credentials.renewable "True" "False"}}
|
||||
</span>
|
||||
</div>
|
||||
</InfoTableRow>
|
||||
</div>
|
||||
|
||||
<div class="has-top-margin-xl has-bottom-margin-l">
|
||||
<button
|
||||
data-test-done
|
||||
class="button is-primary"
|
||||
type="button"
|
||||
{{on "click" (transition-to "vault.cluster.secrets.backend.ldap.libraries.library.details.accounts")}}
|
||||
>
|
||||
Done
|
||||
</button>
|
||||
</div>
|
@ -0,0 +1,46 @@
|
||||
<PageHeader as |p|>
|
||||
<p.top>
|
||||
<Page::Breadcrumbs @breadcrumbs={{@breadcrumbs}} />
|
||||
</p.top>
|
||||
<p.levelLeft>
|
||||
<h1 class="title is-3">
|
||||
{{if @model.isNew "Create Library" "Edit Library"}}
|
||||
</h1>
|
||||
</p.levelLeft>
|
||||
</PageHeader>
|
||||
|
||||
<hr class="is-marginless has-background-gray-200" />
|
||||
|
||||
<form {{on "submit" (perform this.save)}} class="has-top-margin-m">
|
||||
<MessageError @errorMessage={{this.error}} />
|
||||
|
||||
{{#each @model.formFields as |field|}}
|
||||
<FormField @attr={{field}} @model={{@model}} @modelValidations={{this.modelValidations}} />
|
||||
{{/each}}
|
||||
|
||||
<hr class="has-background-gray-200 has-top-margin-l" />
|
||||
|
||||
<div class="has-top-margin-l has-bottom-margin-l is-flex">
|
||||
<button data-test-save class="button is-primary" type="submit" disabled={{this.save.isRunning}}>
|
||||
{{if @model.isNew "Create library" "Save"}}
|
||||
</button>
|
||||
<button
|
||||
data-test-cancel
|
||||
class="button has-left-margin-xs"
|
||||
type="button"
|
||||
disabled={{this.save.isRunning}}
|
||||
{{on "click" this.cancel}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
{{#if this.invalidFormMessage}}
|
||||
<AlertInline
|
||||
@type="danger"
|
||||
@paddingTop={{true}}
|
||||
@message={{this.invalidFormMessage}}
|
||||
@mimicRefresh={{true}}
|
||||
data-test-invalid-form-message
|
||||
/>
|
||||
{{/if}}
|
||||
</div>
|
||||
</form>
|
55
ui/lib/ldap/addon/components/page/library/create-and-edit.ts
Normal file
55
ui/lib/ldap/addon/components/page/library/create-and-edit.ts
Normal file
@ -0,0 +1,55 @@
|
||||
import Component from '@glimmer/component';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import { action } from '@ember/object';
|
||||
import { inject as service } from '@ember/service';
|
||||
import { task } from 'ember-concurrency';
|
||||
import { waitFor } from '@ember/test-waiters';
|
||||
import errorMessage from 'vault/utils/error-message';
|
||||
|
||||
import type LdapLibraryModel from 'vault/models/ldap/library';
|
||||
import { Breadcrumb, ValidationMap } from 'vault/vault/app-types';
|
||||
import type FlashMessageService from 'vault/services/flash-messages';
|
||||
import type RouterService from '@ember/routing/router-service';
|
||||
|
||||
interface Args {
|
||||
model: LdapLibraryModel;
|
||||
breadcrumbs: Array<Breadcrumb>;
|
||||
}
|
||||
|
||||
export default class LdapCreateAndEditLibraryPageComponent extends Component<Args> {
|
||||
@service declare readonly flashMessages: FlashMessageService;
|
||||
@service declare readonly router: RouterService;
|
||||
|
||||
@tracked modelValidations: ValidationMap | null = null;
|
||||
@tracked invalidFormMessage = '';
|
||||
@tracked error = '';
|
||||
|
||||
@task
|
||||
@waitFor
|
||||
*save(event: Event) {
|
||||
event.preventDefault();
|
||||
|
||||
const { model } = this.args;
|
||||
const { isValid, state, invalidFormMessage } = model.validate();
|
||||
|
||||
this.modelValidations = isValid ? null : state;
|
||||
this.invalidFormMessage = isValid ? '' : invalidFormMessage;
|
||||
|
||||
if (isValid) {
|
||||
try {
|
||||
const action = model.isNew ? 'created' : 'updated';
|
||||
yield model.save();
|
||||
this.flashMessages.success(`Successfully ${action} the library ${model.name}.`);
|
||||
this.router.transitionTo('vault.cluster.secrets.backend.ldap.libraries.library.details', model.name);
|
||||
} catch (error) {
|
||||
this.error = errorMessage(error, 'Error saving library. Please try again or contact support.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
cancel() {
|
||||
this.args.model.rollbackAttributes();
|
||||
this.router.transitionTo('vault.cluster.secrets.backend.ldap.libraries');
|
||||
}
|
||||
}
|
37
ui/lib/ldap/addon/components/page/library/details.hbs
Normal file
37
ui/lib/ldap/addon/components/page/library/details.hbs
Normal file
@ -0,0 +1,37 @@
|
||||
<PageHeader as |p|>
|
||||
<p.top>
|
||||
<Page::Breadcrumbs @breadcrumbs={{@breadcrumbs}} />
|
||||
</p.top>
|
||||
<p.levelLeft>
|
||||
<h1 class="title is-3" data-test-header-title>
|
||||
{{@model.name}}
|
||||
</h1>
|
||||
</p.levelLeft>
|
||||
</PageHeader>
|
||||
|
||||
<div class="tabs-container box is-bottomless is-marginless is-paddingless">
|
||||
<nav class="tabs" aria-label="ldap tabs">
|
||||
<ul>
|
||||
<LinkTo @route="libraries.library.details.accounts" data-test-tab="accounts">Accounts</LinkTo>
|
||||
<LinkTo @route="libraries.library.details.configuration" data-test-tab="config">Configuration</LinkTo>
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<Toolbar>
|
||||
<ToolbarActions>
|
||||
{{#if @model.canDelete}}
|
||||
<ConfirmAction @buttonClasses="toolbar-link" @onConfirmAction={{this.delete}} data-test-delete>
|
||||
Delete library
|
||||
</ConfirmAction>
|
||||
{{#if @model.canEdit}}
|
||||
<div class="toolbar-separator"></div>
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
{{#if @model.canEdit}}
|
||||
<ToolbarLink @route="libraries.library.edit" data-test-edit>
|
||||
Edit library
|
||||
</ToolbarLink>
|
||||
{{/if}}
|
||||
</ToolbarActions>
|
||||
</Toolbar>
|
31
ui/lib/ldap/addon/components/page/library/details.ts
Normal file
31
ui/lib/ldap/addon/components/page/library/details.ts
Normal file
@ -0,0 +1,31 @@
|
||||
import Component from '@glimmer/component';
|
||||
import { action } from '@ember/object';
|
||||
import { inject as service } from '@ember/service';
|
||||
import errorMessage from 'vault/utils/error-message';
|
||||
|
||||
import type LdapLibraryModel from 'vault/models/ldap/library';
|
||||
import { Breadcrumb } from 'vault/vault/app-types';
|
||||
import type FlashMessageService from 'vault/services/flash-messages';
|
||||
import type RouterService from '@ember/routing/router-service';
|
||||
|
||||
interface Args {
|
||||
model: LdapLibraryModel;
|
||||
breadcrumbs: Array<Breadcrumb>;
|
||||
}
|
||||
|
||||
export default class LdapLibraryDetailsPageComponent extends Component<Args> {
|
||||
@service declare readonly flashMessages: FlashMessageService;
|
||||
@service declare readonly router: RouterService;
|
||||
|
||||
@action
|
||||
async delete() {
|
||||
try {
|
||||
await this.args.model.destroyRecord();
|
||||
this.flashMessages.success('Library deleted successfully.');
|
||||
this.router.transitionTo('vault.cluster.secrets.backend.ldap.libraries');
|
||||
} catch (error) {
|
||||
const message = errorMessage(error, 'Unable to delete library. Please try again or contact support.');
|
||||
this.flashMessages.danger(message);
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,90 @@
|
||||
<div class="has-top-margin-l is-flex-align-start">
|
||||
<Hds::Card::Container @level="mid" @hasBorder={{true}} class="has-padding-l is-flex-half">
|
||||
<div class="is-flex-between">
|
||||
<h3 class="is-size-5 has-text-weight-semibold">All accounts</h3>
|
||||
{{#if @library.canCheckOut}}
|
||||
<button
|
||||
type="button"
|
||||
class="button is-link"
|
||||
data-test-check-out
|
||||
{{on "click" (fn (mut this.showCheckOutPrompt) true)}}
|
||||
>
|
||||
Check-out
|
||||
</button>
|
||||
{{/if}}
|
||||
</div>
|
||||
|
||||
<p class="has-text-grey is-size-8">The accounts within this library</p>
|
||||
<hr class="has-background-gray-200" />
|
||||
|
||||
<Hds::Table @model={{@statuses}} @columns={{array (hash label="Accounts") (hash label="Status")}}>
|
||||
<:body as |Body|>
|
||||
<Body.Tr>
|
||||
<Body.Td data-test-account-name={{Body.data.account}}>{{Body.data.account}}</Body.Td>
|
||||
<Body.Td>
|
||||
<Hds::Badge
|
||||
@text={{if Body.data.available "Available" "Unavailable"}}
|
||||
@color={{if Body.data.available "success" "neutral"}}
|
||||
data-test-account-status={{Body.data.account}}
|
||||
/>
|
||||
</Body.Td>
|
||||
</Body.Tr>
|
||||
</:body>
|
||||
</Hds::Table>
|
||||
</Hds::Card::Container>
|
||||
|
||||
<div class="has-left-margin-l is-flex-half">
|
||||
<AccountsCheckedOut @libraries={{array @library}} @statuses={{@statuses}} data-test-checked-out-card />
|
||||
|
||||
<OverviewCard
|
||||
@cardTitle="To renew a checked-out account"
|
||||
@subText="Use the CLI command below:"
|
||||
class="has-padding-l has-top-margin-l"
|
||||
>
|
||||
<div class="has-padding-s has-background-gray-900 border-radius-4 is-flex-between has-top-margin-s">
|
||||
<code class="has-text-white is-size-7" data-test-cli-command>{{this.cliCommand}}</code>
|
||||
<CopyButton
|
||||
class="button is-compact is-transparent has-text-grey-light"
|
||||
data-test-cli-command-copy
|
||||
@clipboardText={{this.cliCommand}}
|
||||
@buttonType="button"
|
||||
@success={{action (set-flash-message "Renew command copied!")}}
|
||||
>
|
||||
Copy
|
||||
<Icon @name="clipboard-copy" aria-label="Copy" />
|
||||
</CopyButton>
|
||||
</div>
|
||||
</OverviewCard>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{#if this.showCheckOutPrompt}}
|
||||
<Modal
|
||||
@title="Account Check-out"
|
||||
@isActive={{this.showCheckOutPrompt}}
|
||||
@showCloseButton={{true}}
|
||||
@onClose={{fn (mut this.showCheckOutPrompt) false}}
|
||||
>
|
||||
<section class="modal-card-body">
|
||||
<p>
|
||||
Current generated credential’s time-to-live is set at
|
||||
{{format-duration @library.ttl}}. You can set a different limit if you’d like:
|
||||
</p>
|
||||
<br />
|
||||
<TtlPicker @label="TTL" @hideToggle={{true}} @initialValue={{@library.ttl}} @onChange={{this.setTtl}} />
|
||||
</section>
|
||||
<footer class="modal-card-foot modal-card-foot-outlined">
|
||||
<button data-test-check-out="save" type="button" class="button is-primary" {{on "click" this.checkOut}}>
|
||||
Check-out
|
||||
</button>
|
||||
<button
|
||||
data-test-check-out="cancel"
|
||||
type="button"
|
||||
class="button"
|
||||
{{on "click" (fn (mut this.showCheckOutPrompt) false)}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</footer>
|
||||
</Modal>
|
||||
{{/if}}
|
@ -0,0 +1,37 @@
|
||||
import Component from '@glimmer/component';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import { inject as service } from '@ember/service';
|
||||
import { action } from '@ember/object';
|
||||
|
||||
import type FlashMessageService from 'vault/services/flash-messages';
|
||||
import type RouterService from '@ember/routing/router-service';
|
||||
import type LdapLibraryModel from 'vault/models/ldap/library';
|
||||
import type { LdapLibraryAccountStatus } from 'vault/vault/adapters/ldap/library';
|
||||
import { TtlEvent } from 'vault/vault/app-types';
|
||||
|
||||
interface Args {
|
||||
library: LdapLibraryModel;
|
||||
statuses: Array<LdapLibraryAccountStatus>;
|
||||
}
|
||||
|
||||
export default class LdapLibraryDetailsAccountsPageComponent extends Component<Args> {
|
||||
@service declare readonly flashMessages: FlashMessageService;
|
||||
@service declare readonly router: RouterService;
|
||||
|
||||
@tracked showCheckOutPrompt = false;
|
||||
@tracked checkOutTtl: string | null = null;
|
||||
|
||||
get cliCommand() {
|
||||
return `vault lease renew ad/library/${this.args.library.name}/check-out/:lease_id`;
|
||||
}
|
||||
@action
|
||||
setTtl(data: TtlEvent) {
|
||||
this.checkOutTtl = data.timeString;
|
||||
}
|
||||
@action
|
||||
checkOut() {
|
||||
this.router.transitionTo('vault.cluster.secrets.backend.ldap.libraries.library.check-out', {
|
||||
queryParams: { ttl: this.checkOutTtl },
|
||||
});
|
||||
}
|
||||
}
|
@ -0,0 +1,21 @@
|
||||
{{#each @model.displayFields as |field|}}
|
||||
{{#let (get @model field.name) as |value|}}
|
||||
{{#if (eq field.name "disable_check_in_enforcement")}}
|
||||
<InfoTableRow @label={{field.options.label}}>
|
||||
<Icon
|
||||
class="is-flex-v-centered {{if (eq value 'Enabled') 'icon-true' 'icon-false'}}"
|
||||
@name={{if (eq value "Enabled") "check-circle" "x-square"}}
|
||||
data-test-check-in-icon
|
||||
/>
|
||||
<span>{{value}}</span>
|
||||
</InfoTableRow>
|
||||
{{else}}
|
||||
<InfoTableRow
|
||||
data-test-filtered-field
|
||||
@label={{or field.options.detailsLabel field.options.label}}
|
||||
@value={{value}}
|
||||
@formatTtl={{eq field.options.editType "ttl"}}
|
||||
/>
|
||||
{{/if}}
|
||||
{{/let}}
|
||||
{{/each}}
|
69
ui/lib/ldap/addon/components/page/overview.hbs
Normal file
69
ui/lib/ldap/addon/components/page/overview.hbs
Normal file
@ -0,0 +1,69 @@
|
||||
<TabPageHeader @model={{@backendModel}} @breadcrumbs={{@breadcrumbs}}>
|
||||
<:toolbarActions>
|
||||
{{#if @promptConfig}}
|
||||
<ToolbarLink @route="configure" data-test-toolbar-action="config">
|
||||
Configure LDAP
|
||||
</ToolbarLink>
|
||||
{{/if}}
|
||||
</:toolbarActions>
|
||||
</TabPageHeader>
|
||||
|
||||
{{#if @promptConfig}}
|
||||
<ConfigCta />
|
||||
{{else}}
|
||||
<div class="is-grid has-top-margin-l grid-2-columns grid-gap-2">
|
||||
<OverviewCard
|
||||
@cardTitle="Roles"
|
||||
@subText="The total number of roles that have been set up in this secret engine in order to generate credentials."
|
||||
@actionText="Create new"
|
||||
@actionTo="roles.create"
|
||||
>
|
||||
<h2 class="title is-2 has-font-weight-normal has-top-margin-m" data-test-roles-count>
|
||||
{{or @roles.length "None"}}
|
||||
</h2>
|
||||
</OverviewCard>
|
||||
<OverviewCard
|
||||
@cardTitle="Libraries"
|
||||
@subText="The total number of libraries that have been created for service account management."
|
||||
@actionText="Create new"
|
||||
@actionTo="libraries.create"
|
||||
>
|
||||
<h2 class="title is-2 has-font-weight-normal has-top-margin-m" data-test-libraries-count>
|
||||
{{or @libraries.length "None"}}
|
||||
</h2>
|
||||
</OverviewCard>
|
||||
</div>
|
||||
<div class="is-flex-align-start has-top-margin-l">
|
||||
<AccountsCheckedOut
|
||||
@libraries={{@libraries}}
|
||||
@statuses={{@librariesStatus}}
|
||||
@showLibraryColumn={{true}}
|
||||
class="is-flex-half"
|
||||
/>
|
||||
|
||||
<div class="has-left-margin-l is-flex-half">
|
||||
<OverviewCard @cardTitle="Generate credentials" @subText="Quickly generate credentials by typing the role name.">
|
||||
<div class="has-top-margin-m is-flex">
|
||||
<SearchSelect
|
||||
class="is-flex-1"
|
||||
@placeholder="Select a role"
|
||||
@disallowNewItems={{true}}
|
||||
@options={{@roles}}
|
||||
@selectLimit="1"
|
||||
@fallbackComponent="input-search"
|
||||
@onChange={{this.selectRole}}
|
||||
/>
|
||||
<button
|
||||
class="button has-left-margin-s"
|
||||
type="button"
|
||||
disabled={{not this.selectedRole}}
|
||||
{{on "click" this.generateCredentials}}
|
||||
data-test-generate-credential-button
|
||||
>
|
||||
Get credentials
|
||||
</button>
|
||||
</div>
|
||||
</OverviewCard>
|
||||
</div>
|
||||
</div>
|
||||
{{/if}}
|
43
ui/lib/ldap/addon/components/page/overview.ts
Normal file
43
ui/lib/ldap/addon/components/page/overview.ts
Normal file
@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: MPL-2.0
|
||||
*/
|
||||
|
||||
import Component from '@glimmer/component';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import { inject as service } from '@ember/service';
|
||||
import { action } from '@ember/object';
|
||||
|
||||
import type LdapLibraryModel from 'vault/models/ldap/library';
|
||||
import type SecretEngineModel from 'vault/models/secret-engine';
|
||||
import type RouterService from '@ember/routing/router-service';
|
||||
import type { Breadcrumb } from 'vault/vault/app-types';
|
||||
import LdapRoleModel from 'vault/models/ldap/role';
|
||||
import { LdapLibraryAccountStatus } from 'vault/vault/adapters/ldap/library';
|
||||
|
||||
interface Args {
|
||||
roles: Array<LdapRoleModel>;
|
||||
libraries: Array<LdapLibraryModel>;
|
||||
librariesStatus: Array<LdapLibraryAccountStatus>;
|
||||
promptConfig: boolean;
|
||||
backendModel: SecretEngineModel;
|
||||
breadcrumbs: Array<Breadcrumb>;
|
||||
}
|
||||
|
||||
export default class LdapLibrariesPageComponent extends Component<Args> {
|
||||
@service declare readonly router: RouterService;
|
||||
|
||||
@tracked selectedRole: LdapRoleModel | undefined;
|
||||
|
||||
@action
|
||||
selectRole([roleName]: Array<string>) {
|
||||
const model = this.args.roles.find((role) => role.name === roleName);
|
||||
this.selectedRole = model;
|
||||
}
|
||||
|
||||
@action
|
||||
generateCredentials() {
|
||||
const { type, name } = this.selectedRole as LdapRoleModel;
|
||||
this.router.transitionTo('vault.cluster.secrets.backend.ldap.roles.role.credentials', type, name);
|
||||
}
|
||||
}
|
70
ui/lib/ldap/addon/components/page/role/create-and-edit.hbs
Normal file
70
ui/lib/ldap/addon/components/page/role/create-and-edit.hbs
Normal file
@ -0,0 +1,70 @@
|
||||
<PageHeader as |p|>
|
||||
<p.top>
|
||||
<Page::Breadcrumbs @breadcrumbs={{@breadcrumbs}} />
|
||||
</p.top>
|
||||
<p.levelLeft>
|
||||
<h1 class="title is-3">
|
||||
{{if @model.isNew "Create Role" "Edit Role"}}
|
||||
</h1>
|
||||
</p.levelLeft>
|
||||
</PageHeader>
|
||||
|
||||
<hr class="is-marginless has-background-gray-200" />
|
||||
|
||||
<form {{on "submit" (perform this.save)}} class="has-top-margin-m">
|
||||
<MessageError @errorMessage={{this.error}} />
|
||||
|
||||
<label class="is-label">
|
||||
Role type
|
||||
</label>
|
||||
<Hds::Form::RadioCard::Group @name="role type options" class="has-bottom-margin-m" as |RadioGroup|>
|
||||
{{#each this.roleTypeOptions as |option|}}
|
||||
<RadioGroup.RadioCard
|
||||
@checked={{eq option.value @model.type}}
|
||||
@disabled={{not @model.isNew}}
|
||||
{{on "change" (fn (mut @model.type) option.value)}}
|
||||
data-test-radio-card={{option.value}}
|
||||
as |Card|
|
||||
>
|
||||
<Card.Icon @name={{option.icon}} />
|
||||
<Card.Label>{{option.title}}</Card.Label>
|
||||
<Card.Description>{{option.description}}</Card.Description>
|
||||
</RadioGroup.RadioCard>
|
||||
{{/each}}
|
||||
</Hds::Form::RadioCard::Group>
|
||||
|
||||
{{#each @model.formFields as |field|}}
|
||||
{{! display section heading ahead of ldif fields }}
|
||||
{{#if field.options.sectionHeading}}
|
||||
<hr class="has-background-gray-200" />
|
||||
<h2 class="title is-4 has-top-margin-xl">{{field.options.sectionHeading}}</h2>
|
||||
{{/if}}
|
||||
<FormField @attr={{field}} @model={{@model}} @modelValidations={{this.modelValidations}} />
|
||||
{{/each}}
|
||||
|
||||
<hr class="has-background-gray-200 has-top-margin-l" />
|
||||
|
||||
<div class="has-top-margin-l has-bottom-margin-l is-flex">
|
||||
<button data-test-save class="button is-primary" type="submit" disabled={{this.save.isRunning}}>
|
||||
{{if @model.isNew "Create role" "Save"}}
|
||||
</button>
|
||||
<button
|
||||
data-test-cancel
|
||||
class="button has-left-margin-xs"
|
||||
type="button"
|
||||
disabled={{this.save.isRunning}}
|
||||
{{on "click" this.cancel}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
{{#if this.invalidFormMessage}}
|
||||
<AlertInline
|
||||
@type="danger"
|
||||
@paddingTop={{true}}
|
||||
@message={{this.invalidFormMessage}}
|
||||
@mimicRefresh={{true}}
|
||||
data-test-invalid-form-message
|
||||
/>
|
||||
{{/if}}
|
||||
</div>
|
||||
</form>
|
82
ui/lib/ldap/addon/components/page/role/create-and-edit.ts
Normal file
82
ui/lib/ldap/addon/components/page/role/create-and-edit.ts
Normal file
@ -0,0 +1,82 @@
|
||||
import Component from '@glimmer/component';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import { action } from '@ember/object';
|
||||
import { inject as service } from '@ember/service';
|
||||
import { task } from 'ember-concurrency';
|
||||
import { waitFor } from '@ember/test-waiters';
|
||||
import errorMessage from 'vault/utils/error-message';
|
||||
|
||||
import type LdapRoleModel from 'vault/models/ldap/role';
|
||||
import { Breadcrumb, ValidationMap } from 'vault/vault/app-types';
|
||||
import type FlashMessageService from 'vault/services/flash-messages';
|
||||
import type RouterService from '@ember/routing/router-service';
|
||||
|
||||
interface Args {
|
||||
model: LdapRoleModel;
|
||||
breadcrumbs: Array<Breadcrumb>;
|
||||
}
|
||||
interface RoleTypeOption {
|
||||
title: string;
|
||||
icon: string;
|
||||
description: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export default class LdapCreateAndEditRolePageComponent extends Component<Args> {
|
||||
@service declare readonly flashMessages: FlashMessageService;
|
||||
@service declare readonly router: RouterService;
|
||||
|
||||
@tracked modelValidations: ValidationMap | null = null;
|
||||
@tracked invalidFormMessage = '';
|
||||
@tracked error = '';
|
||||
|
||||
get roleTypeOptions(): Array<RoleTypeOption> {
|
||||
return [
|
||||
{
|
||||
title: 'Static role',
|
||||
icon: 'user',
|
||||
description: 'Static roles map to existing users in an LDAP system.',
|
||||
value: 'static',
|
||||
},
|
||||
{
|
||||
title: 'Dynamic role',
|
||||
icon: 'folder-users',
|
||||
description: 'Dynamic roles allow Vault to create and delete a user in an LDAP system.',
|
||||
value: 'dynamic',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@task
|
||||
@waitFor
|
||||
*save(event: Event) {
|
||||
event.preventDefault();
|
||||
|
||||
const { model } = this.args;
|
||||
const { isValid, state, invalidFormMessage } = model.validate();
|
||||
|
||||
this.modelValidations = isValid ? null : state;
|
||||
this.invalidFormMessage = isValid ? '' : invalidFormMessage;
|
||||
|
||||
if (isValid) {
|
||||
try {
|
||||
const action = model.isNew ? 'created' : 'updated';
|
||||
yield model.save();
|
||||
this.flashMessages.success(`Successfully ${action} the role ${model.name}`);
|
||||
this.router.transitionTo(
|
||||
'vault.cluster.secrets.backend.ldap.roles.role.details',
|
||||
model.type,
|
||||
model.name
|
||||
);
|
||||
} catch (error) {
|
||||
this.error = errorMessage(error, 'Error saving role. Please try again or contact support.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
cancel() {
|
||||
this.args.model.rollbackAttributes();
|
||||
this.router.transitionTo('vault.cluster.secrets.backend.ldap.roles');
|
||||
}
|
||||
}
|
64
ui/lib/ldap/addon/components/page/role/credentials.hbs
Normal file
64
ui/lib/ldap/addon/components/page/role/credentials.hbs
Normal file
@ -0,0 +1,64 @@
|
||||
<PageHeader as |p|>
|
||||
<p.top>
|
||||
<Page::Breadcrumbs @breadcrumbs={{@breadcrumbs}} />
|
||||
</p.top>
|
||||
<p.levelLeft>
|
||||
<h1 class="title is-3" data-test-header-title>
|
||||
Credentials
|
||||
</h1>
|
||||
</p.levelLeft>
|
||||
</PageHeader>
|
||||
|
||||
<hr class="is-marginless has-background-gray-200" />
|
||||
|
||||
{{#if (eq @credentials.type "dynamic")}}
|
||||
<Hds::Alert @type="inline" @color="warning" class="has-top-margin-m" as |Alert|>
|
||||
<Alert.Title>Warning</Alert.Title>
|
||||
<Alert.Description data-test-alert-description>
|
||||
You won’t be able to access these credentials later, so please copy them now.
|
||||
</Alert.Description>
|
||||
</Hds::Alert>
|
||||
{{/if}}
|
||||
|
||||
<div class="has-top-margin-m">
|
||||
{{#each this.fields as |field|}}
|
||||
{{#let (get @credentials field.key) as |value|}}
|
||||
{{#if field.hasBlock}}
|
||||
<InfoTableRow @label={{field.label}}>
|
||||
{{#if (eq field.hasBlock "masked")}}
|
||||
<MaskedInput @value={{value}} @displayOnly={{true}} @allowCopy={{true}} />
|
||||
{{else if (eq field.hasBlock "check")}}
|
||||
<div class="is-flex-v-centered">
|
||||
<Icon
|
||||
@name={{if value "check-circle" "x-circle"}}
|
||||
class="is-marginless {{if value 'has-text-success' 'has-text-danger'}}"
|
||||
/>
|
||||
<span class="has-left-margin-xs">
|
||||
{{if value "True" "False"}}
|
||||
</span>
|
||||
</div>
|
||||
{{/if}}
|
||||
</InfoTableRow>
|
||||
{{else}}
|
||||
<InfoTableRow
|
||||
@label={{field.label}}
|
||||
@value={{value}}
|
||||
@formatDate={{field.formatDate}}
|
||||
@formatTtl={{field.formatTtl}}
|
||||
@type={{field.type}}
|
||||
/>
|
||||
{{/if}}
|
||||
{{/let}}
|
||||
{{/each}}
|
||||
</div>
|
||||
|
||||
<div class="has-top-margin-xl has-bottom-margin-l">
|
||||
<button
|
||||
data-test-done
|
||||
class="button is-primary"
|
||||
type="button"
|
||||
{{on "click" (transition-to "vault.cluster.secrets.backend.ldap.roles.role.details")}}
|
||||
>
|
||||
Done
|
||||
</button>
|
||||
</div>
|
33
ui/lib/ldap/addon/components/page/role/credentials.ts
Normal file
33
ui/lib/ldap/addon/components/page/role/credentials.ts
Normal file
@ -0,0 +1,33 @@
|
||||
import Component from '@glimmer/component';
|
||||
|
||||
import type {
|
||||
LdapStaticRoleCredentials,
|
||||
LdapDynamicRoleCredentials,
|
||||
} from 'ldap/routes/roles/role/credentials';
|
||||
import { Breadcrumb } from 'vault/vault/app-types';
|
||||
|
||||
interface Args {
|
||||
credentials: LdapStaticRoleCredentials | LdapDynamicRoleCredentials;
|
||||
breadcrumbs: Array<Breadcrumb>;
|
||||
}
|
||||
|
||||
export default class LdapRoleCredentialsPageComponent extends Component<Args> {
|
||||
staticFields = [
|
||||
{ label: 'Last Vault rotation', key: 'last_vault_rotation', formatDate: 'MMM d yyyy, h:mm:ss aaa' },
|
||||
{ label: 'Password', key: 'password', hasBlock: 'masked' },
|
||||
{ label: 'Username', key: 'username' },
|
||||
{ label: 'Rotation period', key: 'rotation_period', formatTtl: true },
|
||||
{ label: 'Time remaining', key: 'ttl', formatTtl: true },
|
||||
];
|
||||
dynamicFields = [
|
||||
{ label: 'Distinguished Name', key: 'distinguished_names' },
|
||||
{ label: 'Username', key: 'username', hasBlock: 'masked' },
|
||||
{ label: 'Password', key: 'password', hasBlock: 'masked' },
|
||||
{ label: 'Lease ID', key: 'lease_id' },
|
||||
{ label: 'Lease duration', key: 'lease_duration', formatTtl: true },
|
||||
{ label: 'Lease renewable', key: 'renewable', hasBlock: 'check' },
|
||||
];
|
||||
get fields() {
|
||||
return this.args.credentials.type === 'dynamic' ? this.dynamicFields : this.staticFields;
|
||||
}
|
||||
}
|
54
ui/lib/ldap/addon/components/page/role/details.hbs
Normal file
54
ui/lib/ldap/addon/components/page/role/details.hbs
Normal file
@ -0,0 +1,54 @@
|
||||
<PageHeader as |p|>
|
||||
<p.top>
|
||||
<Page::Breadcrumbs @breadcrumbs={{@breadcrumbs}} />
|
||||
</p.top>
|
||||
<p.levelLeft>
|
||||
<h1 class="title is-3" data-test-header-title>
|
||||
{{@model.name}}
|
||||
</h1>
|
||||
</p.levelLeft>
|
||||
</PageHeader>
|
||||
|
||||
<Toolbar>
|
||||
<ToolbarActions>
|
||||
{{#if @model.canDelete}}
|
||||
<ConfirmAction @buttonClasses="toolbar-link" @onConfirmAction={{this.delete}} data-test-delete>
|
||||
Delete role
|
||||
</ConfirmAction>
|
||||
<div class="toolbar-separator"></div>
|
||||
{{/if}}
|
||||
{{#if @model.canReadCreds}}
|
||||
<ToolbarLink @route="roles.role.credentials" data-test-get-credentials>
|
||||
Get credentials
|
||||
</ToolbarLink>
|
||||
{{/if}}
|
||||
{{#if @model.canRotateStaticCreds}}
|
||||
<ConfirmAction
|
||||
@buttonClasses="toolbar-link"
|
||||
@confirmTitle="Rotate credentials?"
|
||||
@confirmMessage="When manually rotating credentials, the rotation period will start over."
|
||||
@confirmButtonText="Rotate"
|
||||
@disabled={{this.rotateCredentials.isRunning}}
|
||||
@onConfirmAction={{perform this.rotateCredentials}}
|
||||
data-test-rotate-credentials
|
||||
>
|
||||
Rotate credentials
|
||||
</ConfirmAction>
|
||||
{{/if}}
|
||||
{{#if @model.canEdit}}
|
||||
<ToolbarLink @route="roles.role.edit" data-test-edit>
|
||||
Edit role
|
||||
</ToolbarLink>
|
||||
{{/if}}
|
||||
</ToolbarActions>
|
||||
</Toolbar>
|
||||
|
||||
{{#each @model.displayFields as |field|}}
|
||||
{{#let (get @model field.name) as |value|}}
|
||||
<InfoTableRow
|
||||
data-test-filtered-field
|
||||
@label={{or field.options.detailsLabel field.options.label}}
|
||||
@value={{if (eq field.options.editType "ttl") (format-duration value) value}}
|
||||
/>
|
||||
{{/let}}
|
||||
{{/each}}
|
44
ui/lib/ldap/addon/components/page/role/details.ts
Normal file
44
ui/lib/ldap/addon/components/page/role/details.ts
Normal file
@ -0,0 +1,44 @@
|
||||
import Component from '@glimmer/component';
|
||||
import { action } from '@ember/object';
|
||||
import { inject as service } from '@ember/service';
|
||||
import errorMessage from 'vault/utils/error-message';
|
||||
import { task } from 'ember-concurrency';
|
||||
import { waitFor } from '@ember/test-waiters';
|
||||
|
||||
import type LdapRoleModel from 'vault/models/ldap/role';
|
||||
import { Breadcrumb } from 'vault/vault/app-types';
|
||||
import type FlashMessageService from 'vault/services/flash-messages';
|
||||
import type RouterService from '@ember/routing/router-service';
|
||||
|
||||
interface Args {
|
||||
model: LdapRoleModel;
|
||||
breadcrumbs: Array<Breadcrumb>;
|
||||
}
|
||||
|
||||
export default class LdapRoleDetailsPageComponent extends Component<Args> {
|
||||
@service declare readonly flashMessages: FlashMessageService;
|
||||
@service declare readonly router: RouterService;
|
||||
|
||||
@action
|
||||
async delete() {
|
||||
try {
|
||||
await this.args.model.destroyRecord();
|
||||
this.flashMessages.success('Role deleted successfully.');
|
||||
this.router.transitionTo('vault.cluster.secrets.backend.ldap.roles');
|
||||
} catch (error) {
|
||||
const message = errorMessage(error, 'Unable to delete role. Please try again or contact support.');
|
||||
this.flashMessages.danger(message);
|
||||
}
|
||||
}
|
||||
|
||||
@task
|
||||
@waitFor
|
||||
*rotateCredentials() {
|
||||
try {
|
||||
yield this.args.model.rotateStaticPassword();
|
||||
this.flashMessages.success('Credentials successfully rotated.');
|
||||
} catch (error) {
|
||||
this.flashMessages.danger(`Error rotating credentials \n ${errorMessage(error)}`);
|
||||
}
|
||||
}
|
||||
}
|
117
ui/lib/ldap/addon/components/page/roles.hbs
Normal file
117
ui/lib/ldap/addon/components/page/roles.hbs
Normal file
@ -0,0 +1,117 @@
|
||||
<TabPageHeader @model={{@backendModel}} @breadcrumbs={{@breadcrumbs}}>
|
||||
<:toolbarFilters>
|
||||
{{#if (and (not @promptConfig) @roles)}}
|
||||
<FilterInput @placeholder="Filter roles" @onInput={{fn (mut this.filterValue)}} />
|
||||
{{/if}}
|
||||
</:toolbarFilters>
|
||||
<:toolbarActions>
|
||||
{{#if @promptConfig}}
|
||||
<ToolbarLink @route="configure" data-test-toolbar-action="config">
|
||||
Configure LDAP
|
||||
</ToolbarLink>
|
||||
{{else}}
|
||||
<ToolbarLink @route="roles.create" @type="add" data-test-toolbar-action="role">
|
||||
Create role
|
||||
</ToolbarLink>
|
||||
{{/if}}
|
||||
</:toolbarActions>
|
||||
</TabPageHeader>
|
||||
|
||||
{{#if @promptConfig}}
|
||||
<ConfigCta />
|
||||
{{else if (not this.filteredRoles)}}
|
||||
{{#if this.filterValue}}
|
||||
<EmptyState @title="There are no roles matching "{{this.filterValue}}"" />
|
||||
{{else}}
|
||||
<EmptyState
|
||||
data-test-config-cta
|
||||
@title="No roles created yet"
|
||||
@message="Roles in Vault will allow you to manage LDAP credentials. Create a role to get started."
|
||||
>
|
||||
<LinkTo class="has-top-margin-xs" @route="roles.create">
|
||||
Create role
|
||||
</LinkTo>
|
||||
</EmptyState>
|
||||
{{/if}}
|
||||
{{else}}
|
||||
<div class="has-bottom-margin-s">
|
||||
{{#each this.filteredRoles as |role|}}
|
||||
<ListItem @linkPrefix={{this.mountPoint}} @linkParams={{array "roles.role.details" role.type role.name}} as |Item|>
|
||||
<Item.content>
|
||||
<Icon @name="user" />
|
||||
<span data-test-role={{role.name}}>{{role.name}}</span>
|
||||
<Hds::Badge @text={{role.type}} data-test-role-type-badge={{role.name}} />
|
||||
</Item.content>
|
||||
<Item.menu as |Menu|>
|
||||
{{#if role.rolePath.isLoading}}
|
||||
<li class="action">
|
||||
<button disabled type="button" class="link button is-loading is-transparent">
|
||||
loading
|
||||
</button>
|
||||
</li>
|
||||
{{else}}
|
||||
<li class="action">
|
||||
<LinkTo
|
||||
class="has-text-black has-text-weight-semibold"
|
||||
data-test-edit
|
||||
@route="roles.role.edit"
|
||||
@models={{array role.type role.name}}
|
||||
@disabled={{not role.canEdit}}
|
||||
>
|
||||
Edit
|
||||
</LinkTo>
|
||||
</li>
|
||||
<li class="action">
|
||||
<LinkTo
|
||||
class="has-text-black has-text-weight-semibold"
|
||||
data-test-get-creds
|
||||
@route="roles.role.credentials"
|
||||
@models={{array role.type role.name}}
|
||||
@disabled={{not role.canReadCreds}}
|
||||
>
|
||||
Get credentials
|
||||
</LinkTo>
|
||||
</li>
|
||||
{{#if role.canRotateStaticCreds}}
|
||||
<li class="action">
|
||||
<Menu.Message
|
||||
data-test-rotate-creds
|
||||
@id={{concat "rotate-" role.id}}
|
||||
@triggerText="Rotate credentials"
|
||||
@title="Are you sure?"
|
||||
@message="When manually rotating credentials, the rotation period will start over."
|
||||
@confirmButtonText="Rotate"
|
||||
@onConfirm={{fn this.onRotate role}}
|
||||
/>
|
||||
</li>
|
||||
{{/if}}
|
||||
<li class="action">
|
||||
<LinkTo
|
||||
class="has-text-black has-text-weight-semibold"
|
||||
data-test-details
|
||||
@route="roles.role.details"
|
||||
{{! this will force the roles.role model hook to fire since we may only have a partial model loaded in the list view }}
|
||||
@models={{array role.type role.name}}
|
||||
@disabled={{not role.canRead}}
|
||||
>
|
||||
Details
|
||||
</LinkTo>
|
||||
</li>
|
||||
{{#if role.canDelete}}
|
||||
<li class="action">
|
||||
<Menu.Message
|
||||
data-test-delete
|
||||
@id={{concat "delete-" role.id}}
|
||||
@triggerText="Delete"
|
||||
@title="Are you sure?"
|
||||
@message="Deleting this role means that you’ll need to recreate it in order to generate credentials again."
|
||||
@onConfirm={{fn this.onDelete role}}
|
||||
/>
|
||||
</li>
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
</Item.menu>
|
||||
</ListItem>
|
||||
{{/each}}
|
||||
</div>
|
||||
{{/if}}
|
64
ui/lib/ldap/addon/components/page/roles.ts
Normal file
64
ui/lib/ldap/addon/components/page/roles.ts
Normal file
@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: MPL-2.0
|
||||
*/
|
||||
|
||||
import Component from '@glimmer/component';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import { inject as service } from '@ember/service';
|
||||
import { action } from '@ember/object';
|
||||
import { getOwner } from '@ember/application';
|
||||
import errorMessage from 'vault/utils/error-message';
|
||||
|
||||
import type LdapRoleModel from 'vault/models/ldap/role';
|
||||
import type SecretEngineModel from 'vault/models/secret-engine';
|
||||
import type FlashMessageService from 'vault/services/flash-messages';
|
||||
import type { Breadcrumb, EngineOwner } from 'vault/vault/app-types';
|
||||
|
||||
interface Args {
|
||||
roles: Array<LdapRoleModel>;
|
||||
promptConfig: boolean;
|
||||
backendModel: SecretEngineModel;
|
||||
breadcrumbs: Array<Breadcrumb>;
|
||||
}
|
||||
|
||||
export default class LdapRolesPageComponent extends Component<Args> {
|
||||
@service declare readonly flashMessages: FlashMessageService;
|
||||
|
||||
@tracked filterValue = '';
|
||||
|
||||
get mountPoint(): string {
|
||||
const owner = getOwner(this) as EngineOwner;
|
||||
return owner.mountPoint;
|
||||
}
|
||||
|
||||
get filteredRoles() {
|
||||
const { roles } = this.args;
|
||||
return this.filterValue
|
||||
? roles.filter((role) => role.name.toLowerCase().includes(this.filterValue.toLowerCase()))
|
||||
: roles;
|
||||
}
|
||||
|
||||
@action
|
||||
async onRotate(model: LdapRoleModel) {
|
||||
try {
|
||||
const message = `Successfully rotated credentials for ${model.name}.`;
|
||||
await model.rotateStaticPassword();
|
||||
this.flashMessages.success(message);
|
||||
} catch (error) {
|
||||
this.flashMessages.danger(`Error rotating credentials \n ${errorMessage(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
async onDelete(model: LdapRoleModel) {
|
||||
try {
|
||||
const message = `Successfully deleted role ${model.name}.`;
|
||||
await model.destroyRecord();
|
||||
this.args.roles.removeObject(model);
|
||||
this.flashMessages.success(message);
|
||||
} catch (error) {
|
||||
this.flashMessages.danger(`Error deleting role \n ${errorMessage(error)}`);
|
||||
}
|
||||
}
|
||||
}
|
31
ui/lib/ldap/addon/components/tab-page-header.hbs
Normal file
31
ui/lib/ldap/addon/components/tab-page-header.hbs
Normal file
@ -0,0 +1,31 @@
|
||||
<PageHeader as |p|>
|
||||
<p.top>
|
||||
<Page::Breadcrumbs @breadcrumbs={{@breadcrumbs}} />
|
||||
</p.top>
|
||||
<p.levelLeft>
|
||||
<h1 class="title is-3" data-test-header-title>
|
||||
<Icon @name={{@model.icon}} @size="24" class="has-text-grey-light" />
|
||||
{{@model.id}}
|
||||
</h1>
|
||||
</p.levelLeft>
|
||||
</PageHeader>
|
||||
|
||||
<div class="tabs-container box is-bottomless is-marginless is-paddingless">
|
||||
<nav class="tabs" aria-label="ldap tabs">
|
||||
<ul>
|
||||
<LinkTo @route="overview" data-test-tab="overview">Overview</LinkTo>
|
||||
<LinkTo @route="roles" data-test-tab="roles">Roles</LinkTo>
|
||||
<LinkTo @route="libraries" data-test-tab="libraries">Libraries</LinkTo>
|
||||
<LinkTo @route="configuration" data-test-tab="config">Configuration</LinkTo>
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<Toolbar>
|
||||
<ToolbarFilters>
|
||||
{{yield to="toolbarFilters"}}
|
||||
</ToolbarFilters>
|
||||
<ToolbarActions>
|
||||
{{yield to="toolbarActions"}}
|
||||
</ToolbarActions>
|
||||
</Toolbar>
|
22
ui/lib/ldap/addon/engine.js
Normal file
22
ui/lib/ldap/addon/engine.js
Normal file
@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: MPL-2.0
|
||||
*/
|
||||
|
||||
import Engine from 'ember-engines/engine';
|
||||
import loadInitializers from 'ember-load-initializers';
|
||||
import Resolver from 'ember-resolver';
|
||||
import config from './config/environment';
|
||||
|
||||
const { modulePrefix } = config;
|
||||
|
||||
export default class LdapEngine extends Engine {
|
||||
modulePrefix = modulePrefix;
|
||||
Resolver = Resolver;
|
||||
dependencies = {
|
||||
services: ['router', 'store', 'secret-mount-path', 'flash-messages', 'auth'],
|
||||
externalRoutes: ['secrets'],
|
||||
};
|
||||
}
|
||||
|
||||
loadInitializers(LdapEngine, modulePrefix);
|
31
ui/lib/ldap/addon/routes.js
Normal file
31
ui/lib/ldap/addon/routes.js
Normal file
@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: MPL-2.0
|
||||
*/
|
||||
|
||||
import buildRoutes from 'ember-engines/routes';
|
||||
|
||||
export default buildRoutes(function () {
|
||||
this.route('overview');
|
||||
this.route('roles', function () {
|
||||
this.route('create');
|
||||
this.route('role', { path: '/:type/:name' }, function () {
|
||||
this.route('details');
|
||||
this.route('edit');
|
||||
this.route('credentials');
|
||||
});
|
||||
});
|
||||
this.route('libraries', function () {
|
||||
this.route('create');
|
||||
this.route('library', { path: '/:name' }, function () {
|
||||
this.route('details', function () {
|
||||
this.route('accounts');
|
||||
this.route('configuration');
|
||||
});
|
||||
this.route('edit');
|
||||
this.route('check-out');
|
||||
});
|
||||
});
|
||||
this.route('configure');
|
||||
this.route('configuration');
|
||||
});
|
57
ui/lib/ldap/addon/routes/configuration.ts
Normal file
57
ui/lib/ldap/addon/routes/configuration.ts
Normal file
@ -0,0 +1,57 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: MPL-2.0
|
||||
*/
|
||||
|
||||
import Route from '@ember/routing/route';
|
||||
import { inject as service } from '@ember/service';
|
||||
import { withConfig } from 'core/decorators/fetch-secrets-engine-config';
|
||||
|
||||
import type Store from '@ember-data/store';
|
||||
import type SecretMountPath from 'vault/services/secret-mount-path';
|
||||
import type Transition from '@ember/routing/transition';
|
||||
import type LdapConfigModel from 'vault/models/ldap/config';
|
||||
import type SecretEngineModel from 'vault/models/secret-engine';
|
||||
import type Controller from '@ember/controller';
|
||||
import type { Breadcrumb } from 'vault/vault/app-types';
|
||||
import type AdapterError from 'ember-data/adapter'; // eslint-disable-line ember/use-ember-data-rfc-395-imports
|
||||
|
||||
interface LdapConfigurationRouteModel {
|
||||
backendModel: SecretEngineModel;
|
||||
configModel: LdapConfigModel;
|
||||
configError: AdapterError;
|
||||
}
|
||||
interface LdapConfigurationController extends Controller {
|
||||
breadcrumbs: Array<Breadcrumb>;
|
||||
model: LdapConfigurationRouteModel;
|
||||
}
|
||||
|
||||
@withConfig('ldap/config')
|
||||
export default class LdapConfigurationRoute extends Route {
|
||||
@service declare readonly store: Store;
|
||||
@service declare readonly secretMountPath: SecretMountPath;
|
||||
|
||||
declare configModel: LdapConfigModel;
|
||||
declare configError: AdapterError;
|
||||
|
||||
model() {
|
||||
return {
|
||||
backendModel: this.modelFor('application'),
|
||||
configModel: this.configModel,
|
||||
configError: this.configError,
|
||||
};
|
||||
}
|
||||
|
||||
setupController(
|
||||
controller: LdapConfigurationController,
|
||||
resolvedModel: LdapConfigurationRouteModel,
|
||||
transition: Transition
|
||||
) {
|
||||
super.setupController(controller, resolvedModel, transition);
|
||||
|
||||
controller.breadcrumbs = [
|
||||
{ label: 'secrets', route: 'secrets', linkExternal: true },
|
||||
{ label: resolvedModel.backendModel.id },
|
||||
];
|
||||
}
|
||||
}
|
46
ui/lib/ldap/addon/routes/configure.ts
Normal file
46
ui/lib/ldap/addon/routes/configure.ts
Normal file
@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: MPL-2.0
|
||||
*/
|
||||
|
||||
import Route from '@ember/routing/route';
|
||||
import { inject as service } from '@ember/service';
|
||||
import { withConfig } from 'core/decorators/fetch-secrets-engine-config';
|
||||
|
||||
import type Store from '@ember-data/store';
|
||||
import type SecretMountPath from 'vault/services/secret-mount-path';
|
||||
import type Transition from '@ember/routing/transition';
|
||||
import type LdapConfigModel from 'vault/models/ldap/config';
|
||||
import type Controller from '@ember/controller';
|
||||
import type { Breadcrumb } from 'vault/vault/app-types';
|
||||
|
||||
interface LdapConfigureController extends Controller {
|
||||
breadcrumbs: Array<Breadcrumb>;
|
||||
}
|
||||
|
||||
@withConfig('ldap/config')
|
||||
export default class LdapConfigureRoute extends Route {
|
||||
@service declare readonly store: Store;
|
||||
@service declare readonly secretMountPath: SecretMountPath;
|
||||
|
||||
declare configModel: LdapConfigModel;
|
||||
|
||||
model() {
|
||||
const backend = this.secretMountPath.currentPath;
|
||||
return this.configModel || this.store.createRecord('ldap/config', { backend });
|
||||
}
|
||||
|
||||
setupController(
|
||||
controller: LdapConfigureController,
|
||||
resolvedModel: LdapConfigModel,
|
||||
transition: Transition
|
||||
) {
|
||||
super.setupController(controller, resolvedModel, transition);
|
||||
|
||||
controller.breadcrumbs = [
|
||||
{ label: 'Secrets', route: 'secrets', linkExternal: true },
|
||||
{ label: resolvedModel.backend, route: 'overview' },
|
||||
{ label: 'Configure' },
|
||||
];
|
||||
}
|
||||
}
|
32
ui/lib/ldap/addon/routes/error.ts
Normal file
32
ui/lib/ldap/addon/routes/error.ts
Normal file
@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: MPL-2.0
|
||||
*/
|
||||
|
||||
import Route from '@ember/routing/route';
|
||||
import { inject as service } from '@ember/service';
|
||||
|
||||
import type Transition from '@ember/routing/transition';
|
||||
import type AdapterError from 'ember-data/adapter'; // eslint-disable-line ember/use-ember-data-rfc-395-imports
|
||||
import type SecretEngineModel from 'vault/models/secret-engine';
|
||||
import type { Breadcrumb } from 'vault/vault/app-types';
|
||||
import type Controller from '@ember/controller';
|
||||
import type SecretMountPath from 'vault/services/secret-mount-path';
|
||||
|
||||
interface LdapErrorController extends Controller {
|
||||
breadcrumbs: Array<Breadcrumb>;
|
||||
backend: SecretEngineModel;
|
||||
}
|
||||
|
||||
export default class LdapErrorRoute extends Route {
|
||||
@service declare readonly secretMountPath: SecretMountPath;
|
||||
|
||||
setupController(controller: LdapErrorController, resolvedModel: AdapterError, transition: Transition) {
|
||||
super.setupController(controller, resolvedModel, transition);
|
||||
controller.breadcrumbs = [
|
||||
{ label: 'secrets', route: 'secrets', linkExternal: true },
|
||||
{ label: this.secretMountPath.currentPath, route: 'overview' },
|
||||
];
|
||||
controller.backend = this.modelFor('application') as SecretEngineModel;
|
||||
}
|
||||
}
|
43
ui/lib/ldap/addon/routes/libraries/create.ts
Normal file
43
ui/lib/ldap/addon/routes/libraries/create.ts
Normal file
@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: MPL-2.0
|
||||
*/
|
||||
|
||||
import Route from '@ember/routing/route';
|
||||
import { inject as service } from '@ember/service';
|
||||
|
||||
import type Store from '@ember-data/store';
|
||||
import type SecretMountPath from 'vault/services/secret-mount-path';
|
||||
import type LdapLibraryModel from 'vault/models/ldap/library';
|
||||
import type Controller from '@ember/controller';
|
||||
import type Transition from '@ember/routing/transition';
|
||||
import type { Breadcrumb } from 'vault/vault/app-types';
|
||||
|
||||
interface LdapLibrariesCreateController extends Controller {
|
||||
breadcrumbs: Array<Breadcrumb>;
|
||||
model: LdapLibraryModel;
|
||||
}
|
||||
|
||||
export default class LdapLibrariesCreateRoute extends Route {
|
||||
@service declare readonly store: Store;
|
||||
@service declare readonly secretMountPath: SecretMountPath;
|
||||
|
||||
model() {
|
||||
const backend = this.secretMountPath.currentPath;
|
||||
return this.store.createRecord('ldap/library', { backend });
|
||||
}
|
||||
|
||||
setupController(
|
||||
controller: LdapLibrariesCreateController,
|
||||
resolvedModel: LdapLibraryModel,
|
||||
transition: Transition
|
||||
) {
|
||||
super.setupController(controller, resolvedModel, transition);
|
||||
|
||||
controller.breadcrumbs = [
|
||||
{ label: resolvedModel.backend, route: 'overview' },
|
||||
{ label: 'libraries', route: 'libraries' },
|
||||
{ label: 'create' },
|
||||
];
|
||||
}
|
||||
}
|
57
ui/lib/ldap/addon/routes/libraries/index.ts
Normal file
57
ui/lib/ldap/addon/routes/libraries/index.ts
Normal file
@ -0,0 +1,57 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: MPL-2.0
|
||||
*/
|
||||
|
||||
import Route from '@ember/routing/route';
|
||||
import { inject as service } from '@ember/service';
|
||||
import { withConfig } from 'core/decorators/fetch-secrets-engine-config';
|
||||
import { hash } from 'rsvp';
|
||||
|
||||
import type Store from '@ember-data/store';
|
||||
import type SecretMountPath from 'vault/services/secret-mount-path';
|
||||
import type Transition from '@ember/routing/transition';
|
||||
import type LdapLibraryModel from 'vault/models/ldap/library';
|
||||
import type SecretEngineModel from 'vault/models/secret-engine';
|
||||
import type Controller from '@ember/controller';
|
||||
import type { Breadcrumb } from 'vault/vault/app-types';
|
||||
|
||||
interface LdapLibrariesRouteModel {
|
||||
backendModel: SecretEngineModel;
|
||||
promptConfig: boolean;
|
||||
libraries: Array<LdapLibraryModel>;
|
||||
}
|
||||
interface LdapLibrariesController extends Controller {
|
||||
breadcrumbs: Array<Breadcrumb>;
|
||||
model: LdapLibrariesRouteModel;
|
||||
}
|
||||
|
||||
@withConfig('ldap/config')
|
||||
export default class LdapLibrariesRoute extends Route {
|
||||
@service declare readonly store: Store;
|
||||
@service declare readonly secretMountPath: SecretMountPath;
|
||||
|
||||
declare promptConfig: boolean;
|
||||
|
||||
model() {
|
||||
const backendModel = this.modelFor('application') as SecretEngineModel;
|
||||
return hash({
|
||||
backendModel,
|
||||
promptConfig: this.promptConfig,
|
||||
libraries: this.store.query('ldap/library', { backend: backendModel.id }),
|
||||
});
|
||||
}
|
||||
|
||||
setupController(
|
||||
controller: LdapLibrariesController,
|
||||
resolvedModel: LdapLibrariesRouteModel,
|
||||
transition: Transition
|
||||
) {
|
||||
super.setupController(controller, resolvedModel, transition);
|
||||
|
||||
controller.breadcrumbs = [
|
||||
{ label: 'secrets', route: 'secrets', linkExternal: true },
|
||||
{ label: resolvedModel.backendModel.id },
|
||||
];
|
||||
}
|
||||
}
|
25
ui/lib/ldap/addon/routes/libraries/library.ts
Normal file
25
ui/lib/ldap/addon/routes/libraries/library.ts
Normal file
@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: MPL-2.0
|
||||
*/
|
||||
|
||||
import Route from '@ember/routing/route';
|
||||
import { inject as service } from '@ember/service';
|
||||
|
||||
import type Store from '@ember-data/store';
|
||||
import type SecretMountPath from 'vault/services/secret-mount-path';
|
||||
|
||||
interface LdapLibraryRouteParams {
|
||||
name: string;
|
||||
}
|
||||
|
||||
export default class LdapLibraryRoute extends Route {
|
||||
@service declare readonly store: Store;
|
||||
@service declare readonly secretMountPath: SecretMountPath;
|
||||
|
||||
model(params: LdapLibraryRouteParams) {
|
||||
const backend = this.secretMountPath.currentPath;
|
||||
const { name } = params;
|
||||
return this.store.queryRecord('ldap/library', { backend, name });
|
||||
}
|
||||
}
|
65
ui/lib/ldap/addon/routes/libraries/library/check-out.ts
Normal file
65
ui/lib/ldap/addon/routes/libraries/library/check-out.ts
Normal file
@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: MPL-2.0
|
||||
*/
|
||||
|
||||
import Route from '@ember/routing/route';
|
||||
import { inject as service } from '@ember/service';
|
||||
import { action } from '@ember/object';
|
||||
import errorMessage from 'vault/utils/error-message';
|
||||
|
||||
import type FlashMessageService from 'vault/services/flash-messages';
|
||||
import type RouterService from '@ember/routing/router-service';
|
||||
import type LdapLibraryModel from 'vault/models/ldap/library';
|
||||
import type Controller from '@ember/controller';
|
||||
import type Transition from '@ember/routing/transition';
|
||||
import type { Breadcrumb } from 'vault/vault/app-types';
|
||||
import { LdapLibraryCheckOutCredentials } from 'vault/vault/adapters/ldap/library';
|
||||
import type AdapterError from 'ember-data/adapter'; // eslint-disable-line ember/use-ember-data-rfc-395-imports
|
||||
|
||||
interface LdapLibraryCheckOutController extends Controller {
|
||||
breadcrumbs: Array<Breadcrumb>;
|
||||
model: LdapLibraryCheckOutCredentials;
|
||||
}
|
||||
|
||||
export default class LdapLibraryCheckOutRoute extends Route {
|
||||
@service declare readonly flashMessages: FlashMessageService;
|
||||
@service declare readonly router: RouterService;
|
||||
|
||||
accountsRoute = 'vault.cluster.secrets.backend.ldap.libraries.library.details.accounts';
|
||||
|
||||
beforeModel(transition: Transition) {
|
||||
// transition must be from the details.accounts route to ensure it was initiated by the check-out action
|
||||
if (transition.from?.name !== this.accountsRoute) {
|
||||
this.router.replaceWith(this.accountsRoute);
|
||||
}
|
||||
}
|
||||
model(_params: object, transition: Transition) {
|
||||
const { ttl } = transition.to.queryParams;
|
||||
const library = this.modelFor('libraries.library') as LdapLibraryModel;
|
||||
return library.checkOutAccount(ttl);
|
||||
}
|
||||
setupController(
|
||||
controller: LdapLibraryCheckOutController,
|
||||
resolvedModel: LdapLibraryCheckOutCredentials,
|
||||
transition: Transition
|
||||
) {
|
||||
super.setupController(controller, resolvedModel, transition);
|
||||
|
||||
const library = this.modelFor('libraries.library') as LdapLibraryModel;
|
||||
controller.breadcrumbs = [
|
||||
{ label: library.backend, route: 'overview' },
|
||||
{ label: 'libraries', route: 'libraries' },
|
||||
{ label: library.name, route: 'libraries.library' },
|
||||
{ label: 'check-out' },
|
||||
];
|
||||
}
|
||||
|
||||
@action
|
||||
error(error: AdapterError) {
|
||||
// if check-out fails, return to library details route
|
||||
const message = errorMessage(error, 'Error checking out account. Please try again or contact support.');
|
||||
this.flashMessages.danger(message);
|
||||
this.router.replaceWith(this.accountsRoute);
|
||||
}
|
||||
}
|
32
ui/lib/ldap/addon/routes/libraries/library/details.ts
Normal file
32
ui/lib/ldap/addon/routes/libraries/library/details.ts
Normal file
@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: MPL-2.0
|
||||
*/
|
||||
|
||||
import Route from '@ember/routing/route';
|
||||
|
||||
import type LdapLibraryModel from 'vault/models/ldap/library';
|
||||
import type Controller from '@ember/controller';
|
||||
import type Transition from '@ember/routing/transition';
|
||||
import type { Breadcrumb } from 'vault/vault/app-types';
|
||||
|
||||
interface LdapLibraryDetailsController extends Controller {
|
||||
breadcrumbs: Array<Breadcrumb>;
|
||||
model: LdapLibraryModel;
|
||||
}
|
||||
|
||||
export default class LdapLibraryDetailsRoute extends Route {
|
||||
setupController(
|
||||
controller: LdapLibraryDetailsController,
|
||||
resolvedModel: LdapLibraryModel,
|
||||
transition: Transition
|
||||
) {
|
||||
super.setupController(controller, resolvedModel, transition);
|
||||
|
||||
controller.breadcrumbs = [
|
||||
{ label: resolvedModel.backend, route: 'overview' },
|
||||
{ label: 'libraries', route: 'libraries' },
|
||||
{ label: resolvedModel.name },
|
||||
];
|
||||
}
|
||||
}
|
@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: MPL-2.0
|
||||
*/
|
||||
|
||||
import Route from '@ember/routing/route';
|
||||
import { hash } from 'rsvp';
|
||||
|
||||
import type LdapLibraryModel from 'vault/models/ldap/library';
|
||||
|
||||
export default class LdapLibraryRoute extends Route {
|
||||
model() {
|
||||
const model = this.modelFor('libraries.library') as LdapLibraryModel;
|
||||
return hash({
|
||||
library: model,
|
||||
statuses: model.fetchStatus(),
|
||||
});
|
||||
}
|
||||
}
|
17
ui/lib/ldap/addon/routes/libraries/library/details/index.ts
Normal file
17
ui/lib/ldap/addon/routes/libraries/library/details/index.ts
Normal file
@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: MPL-2.0
|
||||
*/
|
||||
|
||||
import Route from '@ember/routing/route';
|
||||
import { inject as service } from '@ember/service';
|
||||
|
||||
import type RouterService from '@ember/routing/router-service';
|
||||
|
||||
export default class LdapLibraryRoute extends Route {
|
||||
@service declare readonly router: RouterService;
|
||||
|
||||
redirect() {
|
||||
this.router.transitionTo('vault.cluster.secrets.backend.ldap.libraries.library.details.accounts');
|
||||
}
|
||||
}
|
33
ui/lib/ldap/addon/routes/libraries/library/edit.ts
Normal file
33
ui/lib/ldap/addon/routes/libraries/library/edit.ts
Normal file
@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: MPL-2.0
|
||||
*/
|
||||
|
||||
import Route from '@ember/routing/route';
|
||||
|
||||
import type LdapLibraryModel from 'vault/models/ldap/library';
|
||||
import type Controller from '@ember/controller';
|
||||
import type Transition from '@ember/routing/transition';
|
||||
import type { Breadcrumb } from 'vault/vault/app-types';
|
||||
|
||||
interface LdapLibraryEditController extends Controller {
|
||||
breadcrumbs: Array<Breadcrumb>;
|
||||
model: LdapLibraryModel;
|
||||
}
|
||||
|
||||
export default class LdapLibraryEditRoute extends Route {
|
||||
setupController(
|
||||
controller: LdapLibraryEditController,
|
||||
resolvedModel: LdapLibraryModel,
|
||||
transition: Transition
|
||||
) {
|
||||
super.setupController(controller, resolvedModel, transition);
|
||||
|
||||
controller.breadcrumbs = [
|
||||
{ label: resolvedModel.backend, route: 'overview' },
|
||||
{ label: 'libraries', route: 'libraries' },
|
||||
{ label: resolvedModel.name, route: 'libraries.library.details' },
|
||||
{ label: 'edit' },
|
||||
];
|
||||
}
|
||||
}
|
17
ui/lib/ldap/addon/routes/libraries/library/index.ts
Normal file
17
ui/lib/ldap/addon/routes/libraries/library/index.ts
Normal file
@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: MPL-2.0
|
||||
*/
|
||||
|
||||
import Route from '@ember/routing/route';
|
||||
import { inject as service } from '@ember/service';
|
||||
|
||||
import type RouterService from '@ember/routing/router-service';
|
||||
|
||||
export default class LdapLibraryRoute extends Route {
|
||||
@service declare readonly router: RouterService;
|
||||
|
||||
redirect() {
|
||||
this.router.transitionTo('vault.cluster.secrets.backend.ldap.libraries.library.details');
|
||||
}
|
||||
}
|
81
ui/lib/ldap/addon/routes/overview.ts
Normal file
81
ui/lib/ldap/addon/routes/overview.ts
Normal file
@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: MPL-2.0
|
||||
*/
|
||||
|
||||
import Route from '@ember/routing/route';
|
||||
import { inject as service } from '@ember/service';
|
||||
import { withConfig } from 'core/decorators/fetch-secrets-engine-config';
|
||||
import { hash } from 'rsvp';
|
||||
|
||||
import type Store from '@ember-data/store';
|
||||
import type SecretMountPath from 'vault/services/secret-mount-path';
|
||||
import type Transition from '@ember/routing/transition';
|
||||
import type SecretEngineModel from 'vault/models/secret-engine';
|
||||
import type LdapRoleModel from 'vault/models/ldap/role';
|
||||
import type LdapLibraryModel from 'vault/models/ldap/library';
|
||||
import type Controller from '@ember/controller';
|
||||
import type { Breadcrumb } from 'vault/vault/app-types';
|
||||
import { LdapLibraryAccountStatus } from 'vault/vault/adapters/ldap/library';
|
||||
|
||||
interface LdapOverviewController extends Controller {
|
||||
breadcrumbs: Array<Breadcrumb>;
|
||||
}
|
||||
interface LdapOverviewRouteModel {
|
||||
backendModel: SecretEngineModel;
|
||||
promptConfig: boolean;
|
||||
roles: Array<LdapRoleModel>;
|
||||
libraries: Array<LdapLibraryModel>;
|
||||
librariesStatus: Array<LdapLibraryAccountStatus>;
|
||||
}
|
||||
|
||||
@withConfig('ldap/config')
|
||||
export default class LdapOverviewRoute extends Route {
|
||||
@service declare readonly store: Store;
|
||||
@service declare readonly secretMountPath: SecretMountPath;
|
||||
|
||||
declare promptConfig: boolean;
|
||||
|
||||
async fetchLibrariesStatus(libraries: Array<LdapLibraryModel>): Promise<Array<LdapLibraryAccountStatus>> {
|
||||
const allStatuses: Array<LdapLibraryAccountStatus> = [];
|
||||
|
||||
for (const library of libraries) {
|
||||
try {
|
||||
const statuses = await library.fetchStatus();
|
||||
allStatuses.push(...statuses);
|
||||
} catch (error) {
|
||||
// suppressing error
|
||||
}
|
||||
}
|
||||
return allStatuses;
|
||||
}
|
||||
|
||||
async fetchLibraries(backend: string) {
|
||||
return this.store.query('ldap/library', { backend }).catch(() => []);
|
||||
}
|
||||
|
||||
async model() {
|
||||
const backend = this.secretMountPath.currentPath;
|
||||
const libraries = await this.fetchLibraries(backend);
|
||||
return hash({
|
||||
promptConfig: this.promptConfig,
|
||||
backendModel: this.modelFor('application'),
|
||||
roles: this.store.query('ldap/role', { backend }).catch(() => []),
|
||||
libraries,
|
||||
librariesStatus: this.fetchLibrariesStatus(libraries as Array<LdapLibraryModel>),
|
||||
});
|
||||
}
|
||||
|
||||
setupController(
|
||||
controller: LdapOverviewController,
|
||||
resolvedModel: LdapOverviewRouteModel,
|
||||
transition: Transition
|
||||
) {
|
||||
super.setupController(controller, resolvedModel, transition);
|
||||
|
||||
controller.breadcrumbs = [
|
||||
{ label: 'secrets', route: 'secrets', linkExternal: true },
|
||||
{ label: resolvedModel.backendModel.id },
|
||||
];
|
||||
}
|
||||
}
|
43
ui/lib/ldap/addon/routes/roles/create.ts
Normal file
43
ui/lib/ldap/addon/routes/roles/create.ts
Normal file
@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: MPL-2.0
|
||||
*/
|
||||
|
||||
import Route from '@ember/routing/route';
|
||||
import { inject as service } from '@ember/service';
|
||||
|
||||
import type Store from '@ember-data/store';
|
||||
import type SecretMountPath from 'vault/services/secret-mount-path';
|
||||
import type LdapRoleModel from 'vault/models/ldap/role';
|
||||
import type Controller from '@ember/controller';
|
||||
import type Transition from '@ember/routing/transition';
|
||||
import type { Breadcrumb } from 'vault/vault/app-types';
|
||||
|
||||
interface LdapRolesCreateController extends Controller {
|
||||
breadcrumbs: Array<Breadcrumb>;
|
||||
model: LdapRoleModel;
|
||||
}
|
||||
|
||||
export default class LdapRolesCreateRoute extends Route {
|
||||
@service declare readonly store: Store;
|
||||
@service declare readonly secretMountPath: SecretMountPath;
|
||||
|
||||
model() {
|
||||
const backend = this.secretMountPath.currentPath;
|
||||
return this.store.createRecord('ldap/role', { backend });
|
||||
}
|
||||
|
||||
setupController(
|
||||
controller: LdapRolesCreateController,
|
||||
resolvedModel: LdapRoleModel,
|
||||
transition: Transition
|
||||
) {
|
||||
super.setupController(controller, resolvedModel, transition);
|
||||
|
||||
controller.breadcrumbs = [
|
||||
{ label: resolvedModel.backend, route: 'overview' },
|
||||
{ label: 'roles', route: 'roles' },
|
||||
{ label: 'create' },
|
||||
];
|
||||
}
|
||||
}
|
61
ui/lib/ldap/addon/routes/roles/index.ts
Normal file
61
ui/lib/ldap/addon/routes/roles/index.ts
Normal file
@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: MPL-2.0
|
||||
*/
|
||||
|
||||
import Route from '@ember/routing/route';
|
||||
import { inject as service } from '@ember/service';
|
||||
import { withConfig } from 'core/decorators/fetch-secrets-engine-config';
|
||||
import { hash } from 'rsvp';
|
||||
|
||||
import type Store from '@ember-data/store';
|
||||
import type SecretMountPath from 'vault/services/secret-mount-path';
|
||||
import type Transition from '@ember/routing/transition';
|
||||
import type LdapRoleModel from 'vault/models/ldap/role';
|
||||
import type SecretEngineModel from 'vault/models/secret-engine';
|
||||
import type Controller from '@ember/controller';
|
||||
import type { Breadcrumb } from 'vault/vault/app-types';
|
||||
|
||||
interface LdapRolesRouteModel {
|
||||
backendModel: SecretEngineModel;
|
||||
promptConfig: boolean;
|
||||
roles: Array<LdapRoleModel>;
|
||||
}
|
||||
interface LdapRolesController extends Controller {
|
||||
breadcrumbs: Array<Breadcrumb>;
|
||||
model: LdapRolesRouteModel;
|
||||
}
|
||||
|
||||
@withConfig('ldap/config')
|
||||
export default class LdapRolesRoute extends Route {
|
||||
@service declare readonly store: Store;
|
||||
@service declare readonly secretMountPath: SecretMountPath;
|
||||
|
||||
declare promptConfig: boolean;
|
||||
|
||||
model() {
|
||||
const backendModel = this.modelFor('application') as SecretEngineModel;
|
||||
return hash({
|
||||
backendModel,
|
||||
promptConfig: this.promptConfig,
|
||||
roles: this.store.query(
|
||||
'ldap/role',
|
||||
{ backend: backendModel.id },
|
||||
{ adapterOptions: { showPartialError: true } }
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
setupController(
|
||||
controller: LdapRolesController,
|
||||
resolvedModel: LdapRolesRouteModel,
|
||||
transition: Transition
|
||||
) {
|
||||
super.setupController(controller, resolvedModel, transition);
|
||||
|
||||
controller.breadcrumbs = [
|
||||
{ label: 'secrets', route: 'secrets', linkExternal: true },
|
||||
{ label: resolvedModel.backendModel.id },
|
||||
];
|
||||
}
|
||||
}
|
26
ui/lib/ldap/addon/routes/roles/role.ts
Normal file
26
ui/lib/ldap/addon/routes/roles/role.ts
Normal file
@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: MPL-2.0
|
||||
*/
|
||||
|
||||
import Route from '@ember/routing/route';
|
||||
import { inject as service } from '@ember/service';
|
||||
|
||||
import type Store from '@ember-data/store';
|
||||
import type SecretMountPath from 'vault/services/secret-mount-path';
|
||||
|
||||
interface LdapRoleRouteParams {
|
||||
name: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
export default class LdapRoleRoute extends Route {
|
||||
@service declare readonly store: Store;
|
||||
@service declare readonly secretMountPath: SecretMountPath;
|
||||
|
||||
model(params: LdapRoleRouteParams) {
|
||||
const backend = this.secretMountPath.currentPath;
|
||||
const { name, type } = params;
|
||||
return this.store.queryRecord('ldap/role', { backend, name, type });
|
||||
}
|
||||
}
|
61
ui/lib/ldap/addon/routes/roles/role/credentials.ts
Normal file
61
ui/lib/ldap/addon/routes/roles/role/credentials.ts
Normal file
@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: MPL-2.0
|
||||
*/
|
||||
|
||||
import Route from '@ember/routing/route';
|
||||
import { inject as service } from '@ember/service';
|
||||
|
||||
import type Store from '@ember-data/store';
|
||||
import type LdapRoleModel from 'vault/models/ldap/role';
|
||||
import type Controller from '@ember/controller';
|
||||
import type Transition from '@ember/routing/transition';
|
||||
import type { Breadcrumb } from 'vault/vault/app-types';
|
||||
|
||||
interface LdapRoleCredentialsController extends Controller {
|
||||
breadcrumbs: Array<Breadcrumb>;
|
||||
model: LdapRoleModel;
|
||||
}
|
||||
export interface LdapStaticRoleCredentials {
|
||||
dn: string;
|
||||
last_vault_rotation: string;
|
||||
password: string;
|
||||
last_password: string;
|
||||
rotation_period: number;
|
||||
ttl: number;
|
||||
username: string;
|
||||
type: string;
|
||||
}
|
||||
export interface LdapDynamicRoleCredentials {
|
||||
distinguished_names: Array<string>;
|
||||
password: string;
|
||||
username: string;
|
||||
lease_id: string;
|
||||
lease_duration: string;
|
||||
renewable: boolean;
|
||||
type: string;
|
||||
}
|
||||
|
||||
export default class LdapRoleCredentialsRoute extends Route {
|
||||
@service declare readonly store: Store;
|
||||
|
||||
model() {
|
||||
const role = this.modelFor('roles.role') as LdapRoleModel;
|
||||
return role.fetchCredentials();
|
||||
}
|
||||
setupController(
|
||||
controller: LdapRoleCredentialsController,
|
||||
resolvedModel: LdapStaticRoleCredentials | LdapDynamicRoleCredentials,
|
||||
transition: Transition
|
||||
) {
|
||||
super.setupController(controller, resolvedModel, transition);
|
||||
|
||||
const role = this.modelFor('roles.role') as LdapRoleModel;
|
||||
controller.breadcrumbs = [
|
||||
{ label: role.backend, route: 'overview' },
|
||||
{ label: 'roles', route: 'roles' },
|
||||
{ label: role.name, route: 'roles.role' },
|
||||
{ label: 'credentials' },
|
||||
];
|
||||
}
|
||||
}
|
32
ui/lib/ldap/addon/routes/roles/role/details.ts
Normal file
32
ui/lib/ldap/addon/routes/roles/role/details.ts
Normal file
@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: MPL-2.0
|
||||
*/
|
||||
|
||||
import Route from '@ember/routing/route';
|
||||
|
||||
import type LdapRoleModel from 'vault/models/ldap/role';
|
||||
import type Controller from '@ember/controller';
|
||||
import type Transition from '@ember/routing/transition';
|
||||
import type { Breadcrumb } from 'vault/vault/app-types';
|
||||
|
||||
interface LdapRoleDetailsController extends Controller {
|
||||
breadcrumbs: Array<Breadcrumb>;
|
||||
model: LdapRoleModel;
|
||||
}
|
||||
|
||||
export default class LdapRoleEditRoute extends Route {
|
||||
setupController(
|
||||
controller: LdapRoleDetailsController,
|
||||
resolvedModel: LdapRoleModel,
|
||||
transition: Transition
|
||||
) {
|
||||
super.setupController(controller, resolvedModel, transition);
|
||||
|
||||
controller.breadcrumbs = [
|
||||
{ label: resolvedModel.backend, route: 'overview' },
|
||||
{ label: 'roles', route: 'roles' },
|
||||
{ label: resolvedModel.name },
|
||||
];
|
||||
}
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user