Merge Boundary UI OSS branch 'main' at commit f2c0458f

This commit is contained in:
github-team-secure-boundary committed 2022-12-22 22:10:39 +00:00
commit fc642bfc6a
97 files changed
+1748 -1740

No files matched your search

@@ -0,0 +1,8 @@
import ModelAbility from './model';
/**
* Provides abilities for managed groups.
*/
export default class ManagedGroupAbility extends ModelAbility {
// no op
}
+27
View File
@@ -1,9 +1,18 @@
import ModelAbility from './model';
import { inject as service } from '@ember/service';
class InvalidRolePrincipalTypeError extends Error {
name = 'InvalidRolePrincipalTypeError';
}
/**
* Provides abilities for roles.
*/
export default class RoleAbility extends ModelAbility {
// =services
@service can;
// =permissions
/**
@@ -26,4 +35,22 @@ export default class RoleAbility extends ModelAbility {
get canRemovePrincipals() {
return this.hasAuthorizedAction('remove-principals');
}
/**
* Returns the ability check for a principal model instance.
* Valid role principal models are: User, Group, and ManagedGroup.
* Throws an error if the principal type is invalid.
* @type {boolean}
* @throws {InvalidRolePrincipalTypeError}
*/
get canReadPrincipal() {
const type = this.model.constructor.modelName;
const typeIsValid =
type === 'user' || type === 'group' || type === 'managed-group';
if (!typeIsValid)
throw new InvalidRolePrincipalTypeError(
`Expected a role principal of type 'user', 'group', or 'managed-group'. Got '${type}'.`
);
return this.can.can(`read ${type}`, this.model);
}
}
+9 -1
View File
@@ -3,7 +3,7 @@ import GeneratedCredentialModel from '../generated/models/credential';
/**
* Supported Credential types.
*/
export const types = ['username_password', 'ssh_private_key'];
export const types = ['username_password', 'ssh_private_key', 'json'];
export default class CredentialModel extends GeneratedCredentialModel {
// =attributes
@@ -18,6 +18,14 @@ export default class CredentialModel extends GeneratedCredentialModel {
return this.id.startsWith('cred');
}
/**
* True if type is `json`.
* @type {boolean}
*/
get isJSON() {
return this.type === 'json';
}
/**
* True if credential is an unknown type.
* @type {boolean}
+3 -2
View File
@@ -1,6 +1,7 @@
import GeneratedHostCatalogModel from '../generated/models/host-catalog';
export const types = ['aws', 'azure'];
export const types = ['static', 'plugin'];
export const pluginTypes = ['aws', 'azure'];
export default class HostCatalogModel extends GeneratedHostCatalogModel {
// =attributes
@@ -26,7 +27,7 @@ export default class HostCatalogModel extends GeneratedHostCatalogModel {
* @type {boolean}
*/
get isUnknown() {
return this.isPlugin && !types.includes(this.plugin?.name);
return this.isPlugin && !pluginTypes.includes(this.plugin?.name);
}
/**
+2 -2
View File
@@ -1,5 +1,5 @@
import GeneratedHostSetModel from '../generated/models/host-set';
import { types } from './host-catalog';
import { pluginTypes } from './host-catalog';
export default class HostSetModel extends GeneratedHostSetModel {
// =attributes
@@ -25,7 +25,7 @@ export default class HostSetModel extends GeneratedHostSetModel {
* @type {boolean}
*/
get isUnknown() {
return this.isPlugin && !types.includes(this.plugin?.name);
return this.isPlugin && !pluginTypes.includes(this.plugin?.name);
}
/**
+119
View File
@@ -1,11 +1,13 @@
import GeneratedRoleModel from '../generated/models/role';
import { attr } from '@ember-data/model';
import { inject as service } from '@ember/service';
import { all, resolve } from 'rsvp';
export default class RoleModel extends GeneratedRoleModel {
// =services
@service store;
@service resourceFilterStore;
// =attributes
@@ -37,6 +39,123 @@ export default class RoleModel extends GeneratedRoleModel {
: null;
}
/**
* A list of IDs for principals of type `user`.
* @type {string[]}
*/
get userIDs() {
return this.principals
.filter(({ type }) => type === 'user')
.map(({ principal_id }) => principal_id);
}
/**
* A list of IDs for principals of type `group`.
* @type {string[]}
*/
get groupIDs() {
return this.principals
.filter(({ type }) => type === 'group')
.map(({ principal_id }) => principal_id);
}
/**
* A list of IDs for principals of type `managed group`.
* @type {string[]}
*/
get managedGroupIDs() {
return this.principals
.filter(({ type }) => type === 'managed group')
.map(({ principal_id }) => principal_id);
}
/**
* A promise that resolves to an array of User instances.
* When calling this getter, be sure to await resolution
* before interacting with the results.
* @type {Promise[UserModel]}
*/
get users() {
const ids = this.userIDs;
// Role has prinicipal IDs,
// return a promise which resolves model instances for those IDs
if (ids?.length) {
return this.resourceFilterStore
.queryBy('user', { id: ids }, { scope_id: 'global', recursive: true })
.then((models) => models.map((model) => model));
}
// No principal IDs,
// return a promise resolving to an empty array
return resolve([]);
}
/**
* A promise that resolves to an array of Group instances.
* When calling this getter, be sure to await resolution
* before interacting with the results.
* @type {Promise[GroupModel]}
*/
get groups() {
const ids = this.groupIDs;
// Role has prinicipal IDs,
// return a promise which resolves model instances for those IDs
if (ids?.length) {
return this.resourceFilterStore
.queryBy('group', { id: ids }, { scope_id: 'global', recursive: true })
.then((models) => models.map((model) => model));
}
// No principal IDs,
// return a promise resolving to an empty array
return resolve([]);
}
/**
* A promise that resolves to an array of ManagedGroup instances.
* When calling this getter, be sure to await resolution
* before interacting with the results.
* @type {Promise[ManagedGroupModel]}
*/
get managedGroups() {
const ids = this.managedGroupIDs;
// Role has prinicipal IDs
// return a promise which resolves model instances for those IDs
if (ids?.length) {
// Collect all auth methods.
const authMethods = this.resourceFilterStore.queryBy(
'auth-method',
{ type: 'oidc' },
{ scope_id: 'global', recursive: true }
);
// For each auth method, query all managed groups with IDs
// that match the role's manage group principal IDs.
const managedGroups = authMethods
.then((methods) =>
all(
methods.map(({ id: auth_method_id }) =>
this.resourceFilterStore
.queryBy('managed-group', { id: ids }, { auth_method_id })
.then((models) => models.map((model) => model))
)
)
)
// The result is an array of arrays of model instances (grouped by
// auth methods), so these must be flattened.
.then((grouped) => grouped.flat());
return managedGroups;
}
// No principal IDs,
// return a promise resolving to an empty array
return resolve([]);
}
// =methods
/**
+5 -3
View File
@@ -2,7 +2,9 @@ import GeneratedTargetModel from '../generated/models/target';
import { attr } from '@ember-data/model';
import { inject as service } from '@ember/service';
export const types = ['tcp', 'ssh'];
export const TYPE_TARGET_TCP = 'tcp';
export const TYPE_TARGET_SSH = 'ssh';
export const TYPES_TARGET = Object.freeze([TYPE_TARGET_TCP, TYPE_TARGET_SSH]);
export default class TargetModel extends GeneratedTargetModel {
// =services
@@ -321,7 +323,7 @@ export default class TargetModel extends GeneratedTargetModel {
* @type {boolean}
*/
get isTCP() {
return this.type === 'tcp';
return this.type === TYPE_TARGET_TCP;
}
/**
@@ -329,6 +331,6 @@ export default class TargetModel extends GeneratedTargetModel {
* @type {boolean}
*/
get isSSH() {
return this.type === 'ssh';
return this.type === TYPE_TARGET_SSH;
}
}
+3 -1
View File
@@ -20,7 +20,9 @@ export default class CredentialSerializer extends ApplicationSerializer {
delete serialized.attributes.username;
if (serialized?.attributes?.json_object) {
serialized.attributes.object = JSON.parse(serialized.attributes.json_object);
serialized.attributes.object = JSON.parse(
serialized.attributes.json_object
);
delete serialized.attributes.json_object;
}
}
@@ -0,0 +1 @@
export { default } from 'api/abilities/managed-group';
+28 -5
View File
@@ -267,14 +267,17 @@ export default function () {
this.del('/roles/:id');
this.post(
'/roles/:idMethod',
function ({ roles, users, groups }, { params: { idMethod } }) {
function (
{ roles, users, groups, managedGroups },
{ params: { idMethod } }
) {
const attrs = this.normalizedRequestAttrs();
const id = idMethod.split(':')[0];
const method = idMethod.split(':')[1];
const role = roles.find(id);
let updatedAttrs = {};
// Principals is a combined list of users and groups, but in Mirage we've
// Principals is a combined list of users, groups and managed groups, but in Mirage we've
// internally modelled them as separate lists. Therefore we must check
// the type by looking up the user or group first, then determine which
// list to add the principal to.
@@ -283,25 +286,31 @@ export default function () {
version: attrs.version,
userIds: role.userIds,
groupIds: role.groupIds,
managedGroupIds: role.managedGroupIds,
};
attrs.principalIds.forEach((id) => {
const isUser = users.find(id);
const isGroup = groups.find(id);
const isManagedGroup = managedGroups.find(id);
if (isUser && !updatedAttrs.userIds.includes(id)) {
updatedAttrs.userIds.push(id);
}
if (isGroup && !updatedAttrs.groupIds.includes(id)) {
updatedAttrs.groupIds.push(id);
}
if (isManagedGroup && !updatedAttrs.managedGroupIds.includes(id)) {
updatedAttrs.managedGroupIds.push(id);
}
});
}
// If deleting principals, filter them out of both users and groups,
// If deleting principals, filter the ids out of users, groups and managed group lists,
// and in this case don't care about the type
if (method === 'remove-principals') {
updatedAttrs = {
version: attrs.version,
userIds: role.userIds,
groupIds: role.groupIds,
managedGroupIds: role.managedGroupIds,
};
updatedAttrs.userIds = updatedAttrs.userIds.filter((id) => {
return !attrs.principalIds.includes(id);
@@ -309,6 +318,11 @@ export default function () {
updatedAttrs.groupIds = updatedAttrs.groupIds.filter((id) => {
return !attrs.principalIds.includes(id);
});
updatedAttrs.managedGroupIds = updatedAttrs.managedGroupIds.filter(
(id) => {
return !attrs.principalIds.includes(id);
}
);
}
if (method === 'set-grants') {
@@ -580,8 +594,17 @@ export default function () {
// managed-groups
this.get(
'/managed-groups',
({ managedGroups }, { queryParams: { auth_method_id: authMethodId } }) => {
return managedGroups.where({ authMethodId });
(
{ managedGroups },
{ queryParams: { auth_method_id: authMethodId, filter } }
) => {
let resultSet;
if (authMethodId) {
resultSet = managedGroups.where({ authMethodId });
} else {
resultSet = managedGroups.all();
}
return resultSet.filter(makeBooleanFilter(filter));
}
);
this.post('/managed-groups');
+5 -1
View File
@@ -28,9 +28,13 @@ export default factory.extend({
withPrincipals: trait({
afterCreate(role, server) {
const { scope } = role;
const { id: scopeId } = scope;
const users = server.createList('user', 2, { scope });
const groups = server.createList('group', 2, { scope });
role.update({ users, groups });
const managedGroups = server.schema.managedGroups.where({
scopeId,
}).models;
role.update({ users, groups, managedGroups });
},
}),
+7 -8
View File
@@ -3,10 +3,11 @@ import { trait } from 'ember-cli-mirage';
import { faker } from '@faker-js/faker';
import permissions from '../helpers/permissions';
import generateId from '../helpers/id';
import { TYPES_TARGET } from 'api/models/target';
const randomBoolean = (chance = 0.5) => Math.random() < chance;
const hostSetChance = 0.3;
const types = ['tcp', 'ssh'];
const types = [...TYPES_TARGET];
export default factory.extend({
authorized_actions: () =>
@@ -35,13 +36,11 @@ export default factory.extend({
* Generates attributes fields by type.
*/
afterCreate(target) {
if (target.type === 'tcp' || target.type === 'ssh') {
target.update({
attributes: {
default_port: faker.datatype.number(),
},
});
}
target.update({
attributes: {
default_port: faker.datatype.number(),
},
});
},
/**
@@ -1,11 +1,12 @@
import { Factory } from 'ember-cli-mirage';
import { faker } from '@faker-js/faker';
import { TYPE_TARGET_TCP } from 'api/models/target';
/**
* GeneratedTargetModelFactory
*/
export default Factory.extend({
type: 'tcp',
type: TYPE_TARGET_TCP,
name: () => faker.random.words(),
session_max_seconds: () => faker.datatype.number(),
session_connection_limit: () => faker.datatype.number(),
+1
View File
@@ -5,4 +5,5 @@ export default Model.extend({
scope: belongsTo(),
users: hasMany(),
groups: hasMany(),
managedGroups: hasMany(),
});
+38 -3
View File
@@ -20,7 +20,7 @@ export default function (server) {
// Auth
const globalAuthMethods = server.createList(
'auth-method',
1,
5,
{ scope: globalScope },
'withAccountsAndUsersAndManagedGroups'
);
@@ -51,8 +51,43 @@ export default function (server) {
server.createList('group', 1, { scope: globalScope }, 'withMembers');
server.createList('group', 5, { scope: orgScope }, 'withMembers');
// Role
server.createList('role', 1, { scope: globalScope }, 'withPrincipals');
server.createList('role', 5, { scope: orgScope }, 'withPrincipals');
const globalScopeRoles = server.createList(
'role',
1,
{ scope: globalScope },
'withPrincipals'
);
//create managed groups for role/principals in globalScope
globalScopeRoles.forEach((role) => {
const { scope } = role;
const oidcAuthMethod = globalAuthMethods.filter(
(auth) => auth.type === 'oidc'
)[0];
const managedGroups = server.createList('managed-group', 2, {
scope,
authMethodId: oidcAuthMethod.id,
});
role.update({ managedGroups });
});
const orgScopeRoles = server.createList(
'role',
5,
{ scope: orgScope },
'withPrincipals'
);
//create managed groups for role/principals in orgScope
orgScopeRoles.forEach((role) => {
const { scope } = role;
const oidcAuthMethod = orgAuthMethods.filter(
(auth) => auth.type === 'oidc'
)[0];
const managedGroups = server.createList('managed-group', 2, {
scope,
authMethodId: oidcAuthMethod.id,
});
role.update({ managedGroups });
});
// Other resources
server.schema.scopes.where({ type: 'project' }).models.forEach((scope) => {
+13 -1
View File
@@ -18,7 +18,19 @@ export default ApplicationSerializer.extend({
id: group.id,
type: 'group',
}));
json.principals = [...serializedUsers, ...serializedGroups];
const serializedManagedGroups = model.managedGroups.models.map(
(managedGroup) => ({
scope_id: model.scope.id,
id: managedGroup.id,
type: 'managed group',
})
);
json.principals = [
...serializedUsers,
...serializedGroups,
...serializedManagedGroups,
];
if (!json.principals.length) delete json.principals;
// default grant scope
+45 -6
View File
@@ -10,9 +10,9 @@ module('Unit | Abilities | Role', function (hooks) {
const model = {
authorized_actions: ['set-grants'],
};
assert.ok(service.can('setGrants role', model));
assert.true(service.can('setGrants role', model));
model.authorized_actions = [];
assert.notOk(service.can('setGrants role', model));
assert.false(service.can('setGrants role', model));
});
test('it reflects when a given role may add principals based on authorized_actions', function (assert) {
@@ -21,9 +21,9 @@ module('Unit | Abilities | Role', function (hooks) {
const model = {
authorized_actions: ['add-principals'],
};
assert.ok(service.can('addPrincipals role', model));
assert.true(service.can('addPrincipals role', model));
model.authorized_actions = [];
assert.notOk(service.can('addPrincipals role', model));
assert.false(service.can('addPrincipals role', model));
});
test('it reflects when a given role may remove principals based on authorized_actions', function (assert) {
@@ -32,8 +32,47 @@ module('Unit | Abilities | Role', function (hooks) {
const model = {
authorized_actions: ['remove-principals'],
};
assert.ok(service.can('removePrincipals role', model));
assert.true(service.can('removePrincipals role', model));
model.authorized_actions = [];
assert.notOk(service.can('removePrincipals role', model));
assert.false(service.can('removePrincipals role', model));
});
test('it proxies role principal read ability to the appropriate ability type', function (assert) {
assert.expect(6);
const service = this.owner.lookup('service:can');
const user = {
constructor: { modelName: 'user' },
authorized_actions: [],
};
const group = {
constructor: { modelName: 'group' },
authorized_actions: [],
};
const managedGroup = {
constructor: { modelName: 'managed-group' },
authorized_actions: [],
};
assert.false(service.can('readPrincipal role', user));
assert.false(service.can('readPrincipal role', group));
assert.false(service.can('readPrincipal role', managedGroup));
user.authorized_actions.push('read');
group.authorized_actions.push('read');
managedGroup.authorized_actions.push('read');
assert.true(service.can('readPrincipal role', user));
assert.true(service.can('readPrincipal role', group));
assert.true(service.can('readPrincipal role', managedGroup));
});
test('it throws an error for unknown principal types on principal read check', function (assert) {
assert.expect(2);
const service = this.owner.lookup('service:can');
const account = {
constructor: { modelName: 'account' },
authorized_actions: ['read'],
};
assert.throws(() => {
service.can('readPrincipal role', account);
});
assert.true(service.can('read account', account));
});
});
@@ -22,6 +22,22 @@ module('Unit | Model | credential', function (hooks) {
assert.false(modelC.isStatic);
});
test('it has isJSON property and returns the expected values', async function (assert) {
assert.expect(3);
const store = this.owner.lookup('service:store');
const modelA = store.createRecord('credential', { type: 'json' });
const modelB = store.createRecord('credential', {
type: 'username_password',
});
const modelC = store.createRecord('credential', {
type: 'ssh_private_key',
});
assert.true(modelA.isJSON);
assert.false(modelB.isJSON);
assert.false(modelC.isJSON);
});
test('it has isUnknown property and returns the expected values', async function (assert) {
assert.expect(3);
const store = this.owner.lookup('service:store');
+5 -2
View File
@@ -64,6 +64,7 @@ module('Unit | Model | role', function (hooks) {
principals: [
{ id: '1', type: 'user' },
{ id: '3', type: 'group' },
{ id: '5', type: 'managed group' },
],
version: 1,
scope: {
@@ -84,7 +85,7 @@ module('Unit | Model | role', function (hooks) {
(schema, request) => {
const body = JSON.parse(request.requestBody);
assert.deepEqual(body, {
principal_ids: ['1', '3'],
principal_ids: ['1', '3', '5'],
version: 1,
});
return { id: '123abc' };
@@ -101,6 +102,7 @@ module('Unit | Model | role', function (hooks) {
principals: [
{ id: '1', type: 'user' },
{ id: '3', type: 'group' },
{ id: '5', type: 'managed group' },
],
version: 1,
scope: {
@@ -111,7 +113,7 @@ module('Unit | Model | role', function (hooks) {
},
});
const model = store.peekRecord('role', '123abc');
await model.removePrincipals(['1', '3']);
await model.removePrincipals(['1', '3', '5']);
});
test('it has a `removePrincipal` method that deletes a single principal using `removePrincipals` method', async function (assert) {
@@ -138,6 +140,7 @@ module('Unit | Model | role', function (hooks) {
principals: [
{ id: '1', type: 'user' },
{ id: '3', type: 'group' },
{ id: '5', type: 'managed group' },
],
version: 1,
scope: {
+3 -2
View File
@@ -1,6 +1,7 @@
import { module, test } from 'qunit';
import { setupTest } from 'ember-qunit';
import setupMirage from 'ember-cli-mirage/test-support/setup-mirage';
import { TYPE_TARGET_TCP, TYPE_TARGET_SSH } from 'api/models/target';
module('Unit | Model | target', function (hooks) {
setupTest(hooks);
@@ -383,7 +384,7 @@ module('Unit | Model | target', function (hooks) {
assert.expect(2);
const store = this.owner.lookup('service:store');
const modelSSH = store.createRecord('target', {
type: 'ssh',
type: TYPE_TARGET_SSH,
});
assert.true(modelSSH.isSSH);
assert.false(modelSSH.isTCP);
@@ -393,7 +394,7 @@ module('Unit | Model | target', function (hooks) {
assert.expect(2);
const store = this.owner.lookup('service:store');
const modelTCP = store.createRecord('target', {
type: 'tcp',
type: TYPE_TARGET_TCP,
});
assert.true(modelTCP.isTCP);
assert.false(modelTCP.isSSH);
@@ -182,7 +182,6 @@ module('Unit | Serializer | auth method', function (hooks) {
signing_algorithms,
idp_ca_certs,
} = record;
console.log(record);
assert.deepEqual(account_claim_maps, [
{ from: 'from', to: 'to' },
{ from: 'foo', to: 'bar' },
@@ -1,5 +1,6 @@
import { module, test } from 'qunit';
import { setupTest } from 'ember-qunit';
import { TYPE_TARGET_TCP, TYPE_TARGET_SSH } from 'api/models/target';
module('Unit | Serializer | target', function (hooks) {
setupTest(hooks);
@@ -26,7 +27,7 @@ module('Unit | Serializer | target', function (hooks) {
},
default_port: 1234,
version: 1,
type: 'tcp',
type: TYPE_TARGET_TCP,
});
const snapshot = record._createSnapshot();
snapshot.adapterOptions = {};
@@ -35,7 +36,7 @@ module('Unit | Serializer | target', function (hooks) {
name: 'User',
description: 'Description',
version: 1,
type: 'tcp',
type: TYPE_TARGET_TCP,
scope_id: 'org_1',
session_max_seconds: 28800,
session_connection_limit: null,
@@ -124,7 +125,7 @@ module('Unit | Serializer | target', function (hooks) {
name: 'User',
description: 'Description',
version: 1,
type: 'ssh',
type: TYPE_TARGET_SSH,
worker_filter: 'worker',
default_port: 1234,
scope: {
@@ -137,7 +138,7 @@ module('Unit | Serializer | target', function (hooks) {
name: 'User',
description: 'Description',
version: 1,
type: 'ssh',
type: TYPE_TARGET_SSH,
scope_id: 'org_1',
session_max_seconds: 28800,
session_connection_limit: null,
-1
View File
@@ -41,7 +41,6 @@ descriptions:
neither-list-nor-create: You do not have permission to list or create {resource}.
no-results: We found no matching records.
choose-a-provider: There are several built-in plugins for the most common providers. You can choose from one of the included plugins or use a custom plugin.
choose-a-provider-azure: Boundary does not yet support rotating credentials.
provider: This is the provider for this host catalog. The format of the host set filters are specific to each provider.
questions:
delete-confirm: Are you sure you want to delete this resource?
+2 -1
View File
@@ -150,6 +150,7 @@ secret:
help: The client secret value generated by Azure for Boundary to use for authentication.
disable_credential_rotation:
label: Disable credential rotation
help: Azure host catalogs do not yet support credential rotation.
dns_names:
label: Latest DNS Names
host:
@@ -163,4 +164,4 @@ tags:
label: Tags
secret-editor:
details: This secret is saved but won't be displayed
edit: Click to replace this secret
edit: Click to replace this secret
+13 -5
View File
@@ -93,13 +93,13 @@ host-catalog:
delete: Delete Host Catalog
help:
static: Provide a list of hosts and host sets with static addresses
dynamic: Automate discovery of target hosts and services on Azure or AWS
plugin: Automate discovery of target hosts and services on Azure or AWS
types:
static: Static
unknown: Plugin
aws: AWS
azure: Azure
dynamic: Dynamic
plugin: Dynamic
form:
aws_region:
label: AWS Region
@@ -271,6 +271,7 @@ role:
types:
user: User
group: Group
managed-group: Managed Group
actions:
add-principals: Add Principals
grant:
@@ -304,11 +305,15 @@ target:
add-brokered-credential-sources: Add Brokered Credentials
add-injected-application-credential-sources: Add Injected Application Credentials
types:
tcp: TCP
tcp: Generic TCP
ssh: SSH
help:
tcp: Connections are made over TCP to support a broad range of protocols, including SSH, RDP, databases, and more. This is the most general-purpose type.
ssh: Protocol-aware connections are made over SSH. Use when requiring SSH connections to remote hosts.
tcp: Generic TCP supports a broad range of connection types.
ssh: Protocol-aware SSH with support for credential injection.
form:
type:
label: Type
help: Target type is the protocol with which end users should connect to this target. Choose Generic TCP for broad support of common protocols including RDP, K8s, many databases, and more.
host-source:
title: Host Source
title_plural: Host Sources
@@ -455,6 +460,9 @@ credential:
private_key_passphrase:
label: Passphrase
help: Optional.
json:
label: JSON
help: This is the JSON blob that provides credentials to the host. After this data is saved, you will not be able to edit it again without replacing it.
scope:
title: Scope
title_plural: Scopes
@@ -1,25 +0,0 @@
import RoseFormRadioRadioComponent from '../radio';
import { deprecate } from '@ember/debug';
export default class RoseFormRadioCardComponent extends RoseFormRadioRadioComponent {
// =attributes
layout = this.args.layout;
className = `rose-form-radio-card ${this.layout}`;
constructor() {
super(...arguments);
deprecate(
`Rose Form/Radio/Card component is deprecated, use HDS Form/RadioCard instead. For more info, refer to `,
false,
{
id: 'rose.form.radio.card',
until: '4.8.0',
for: 'rose',
since: {
enabled: '3.28.8',
},
url: 'https://boundary-ui-storybook.vercel.app/?path=/docs/rose-form-radio-radio-card--card',
}
);
}
}
@@ -1,15 +1,12 @@
import { Meta, Story, Preview } from '@storybook/addon-docs';
import { hbs } from 'ember-cli-htmlbars';
<Meta title="Rose/Form/Radio/Card" component="RadioCard" />
<Meta title="Rose/Form/Radio/Card(removed)" component="RadioCard" />
# HDS::Form::RadioCard (Recommended)
# RadioCard (removed)
> **_NOTE:_** Replaces the deprecated Rose/Form/Radio/Card.
Refer to the [HDS Form::RadioCard
documentation](https://design-system-components-hashicorp.vercel.app/components/form/radio-card)
for a complete overview
Rose/Form/Select has been removed in favor of
[HDS::Form::RadioCard](https://design-system-components-hashicorp.vercel.app/components/form/radio-card).
```handlebars
<Hds::Form::RadioCard @checked={{true}} as |R|>
@@ -44,78 +41,3 @@ export const HDSTemplate = (args) => ({
{HDSTemplate.bind({})}
</Story>
</Preview>
# Rose/Form/Radio/Card (Deprecated)
Rose/Form/Radio/Card has been deprecated in favor of [HDS::Form::RadioCard](https://design-system-components-hashicorp.vercel.app/components/form/radio-card)
### <Rose::Form::Radio::Card> usage:
# Form Radiofield in a wide format
Renders a form radiofield in a Wide format.
```handlebars
<Rose::Form::Radio::Card
@label='Yes'
@error={{false}}
@value='yes-agree'
@selectedValue='yes-agree'
@disabled={{false}}
@changed={{fn}}
@layout='wide'
/>
```
Renders a form radiofield in a tile format.
```handlebars
<Rose::Form::Radio::Card
@label='Yes'
@error={{false}}
@value='yes-agree'
@selectedValue='yes-agree'
@disabled={{false}}
@changed={{fn}}
@layout='tile'
/>
```
export const Template = (args) => ({
template: hbs`
<Rose::Form::Radio::Card
@label={{label}}
@error={{error}}
@value={{value}}
@selectedValue={{selectedValue}}
@disabled={{disabled}}
@icon={{icon}}
@helperText={{helperText}}
@layout={{Layout}}
/>`,
context: args,
});
<Preview>
<Story
name="Rose Card"
height="10rem"
args={{
label: 'Azure',
error: false,
value: 'value and selectedValue must match to select radiofield',
selectedValue: 'value and selectedValue must match to select radiofield',
helperText: 'Record financial transactions using',
disabled: false,
}}
argTypes={{
Layout: {
control: 'inline-radio',
options: ['wide', 'tile'],
name: 'layout',
},
}}
>
{Template.bind({})}
</Story>
</Preview>
@@ -8,13 +8,5 @@
changed=@changed
inline=@inline
)
card=(component
'rose/form/radio/card'
name=@name
selectedValue=@selectedValue
disabled=@disabled
changed=@changed
layout=@layout
)
)
}}
@@ -75,9 +75,9 @@ export const Template = (args) => ({
</Story>
</Preview>
# HDS::Form::RadioCard::Group (Recommended)
# HDS::Form::RadioCard::Group
> **_NOTE:_** Replaces the deprecated Rose/Form/Radio/Group/Card.
> **_NOTE:_** Replaces the removed Rose/Form/Radio/Group/Card.
```handlebars
<Hds::Form::RadioCard::Group
@@ -144,83 +144,3 @@ export const HDSTemplate = (args) => ({
{HDSTemplate.bind({})}
</Story>
</Preview>
# Rose::Form::Radio::Group::Card (Deprecated)
```handlebars
<Rose::Form::Radio::Group
@disabled={{false}}
@name={{name}}
@selectedValue='disagree'
@changed={{fn}}
@inline={{false}}
as |radioGroup|
>
<radioGroup.Card
@label='Agree'
@value='agree'
@layout='tile'
@helperText='dwsd'
/>
<radioGroup.Card
@label='disagree'
@value='disagree'
@layout='tile'
@helperText='dwsd'
/>
<radioGroup.Card
@label='maybe'
@value='maybe'
@layout='tile'
@helperText='dwsd'
/>
</Rose::Form::Radio::Group>
```
export const RoseTemplate = (args) => ({
template: hbs`
<Rose::Form::Radio::Group
@disabled={{disabled}}
@name={{name}}
@selectedValue={{selectedValue}}
@changed={{changed}}
as |radioGroup|
>
<radioGroup.card
@label={{firstFieldLabel}}
@value={{firstFieldValue}}
@icon={{firstFieldIcon}}
/>
<radioGroup.card @label={{secondFieldLabel}} @value={{secondFieldValue}} />
</Rose::Form::Radio::Group>`,
context: args,
});
<Preview>
<Story
name="Rose Basic"
height="10rem"
args={{
name: 'mustagree',
disabled: false,
selectedValue: 'disagree',
firstFieldLabel: 'Agree',
firstFieldValue: 'agree',
secondFieldLabel: 'Disagree',
secondFieldValue: 'disagree',
firstFieldIcon: 'flight-icons/svg/org-16',
}}
argTypes={{
firstFieldIcon: {
control: 'select',
options: [
'flight-icons/svg/org-16',
'flight-icons/svg/clipboard-copy-16',
],
},
changed: { action: 'changed' },
}}
>
{RoseTemplate.bind({})}
</Story>
</Preview>
+2 -2
View File
@@ -9,8 +9,8 @@ import 'codemirror/addon/edit/matchbrackets';
import 'codemirror/addon/edit/closebrackets';
import 'codemirror/addon/selection/active-line';
import 'codemirror/addon/display/autorefresh';
import 'codemirror/addon/lint/lint'
import 'codemirror/addon/lint/json-lint'
import 'codemirror/addon/lint/lint';
import 'codemirror/addon/lint/json-lint';
// Here we define default options for the editor.
// These should follow the codemirror configuration types
@@ -6,4 +6,3 @@
@import 'select';
@import 'radio';
@import 'fieldset';
@import 'radio-card';
@@ -1,240 +0,0 @@
@use '../../variables/sizing';
@use '../../utilities/type';
@use '../../variables/typography';
$size-xxl: sizing.rems(xxl); // 48
$size-xl: sizing.rems(xl); // 36
$size-l: sizing.rems(l); // 24
$size-m: sizing.rems(m); // 16
$size-s: sizing.rems(s); // 12
$size-xs: sizing.rems(xs); // 8
$size-xxs: sizing.rems(xxs); // 4
$size-xxxs: sizing.rems(xxxs); // 2
$size-xxxxs: sizing.rems(xxxxs); // 1
$radio-svg: (
'default':
"<svg viewBox='0 0 24 24' fill='%23aaaaaa' xmlns='http://www.w3.org/2000/svg'><path fill-rule='evenodd' clip-rule='evenodd' d='M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8z' /></svg>",
'hover':
"<svg viewBox='0 0 24 24' fill='%236f7682' xmlns='http://www.w3.org/2000/svg'><path fill-rule='evenodd' clip-rule='evenodd' d='M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8z' /></svg>",
'focus':
"<svg viewBox='0 0 24 24' fill='%231563ff' xmlns='http://www.w3.org/2000/svg'><path fill-rule='evenodd' clip-rule='evenodd' d='M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8z' /></svg>",
'checked':
"<svg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'><path fill-rule='evenodd' clip-rule='evenodd' d='M12 7c-2.76 0-5 2.24-5 5s2.24 5 5 5 5-2.24 5-5-2.24-5-5-5zm0-5C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8z' fill='%231563ff'/></svg>",
'disabled':
"<svg viewBox='0 0 24 24' fill='%23bac1cc' xmlns='http://www.w3.org/2000/svg'><path fill-rule='evenodd' clip-rule='evenodd' d='M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8z' /></svg>",
'disabled-checked':
"<svg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'><path fill-rule='evenodd' clip-rule='evenodd' d='M12 7c-2.76 0-5 2.24-5 5s2.24 5 5 5 5-2.24 5-5-2.24-5-5-5zm0-5C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8z' fill='%23bac1cc'/></svg>",
'error':
"<svg viewBox='0 0 24 24' fill='%23c73445' xmlns='http://www.w3.org/2000/svg'><path fill-rule='evenodd' clip-rule='evenodd' d='M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8z' /></svg>",
'error-checked':
"<svg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'><path fill-rule='evenodd' clip-rule='evenodd' d='M12 7c-2.76 0-5 2.24-5 5s2.24 5 5 5 5-2.24 5-5-2.24-5-5-5zm0-5C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8z' fill='%23c73445'/></svg>",
);
.rose-form-radio-card {
$card-wide-width: $size-m * 10;
$card-tile-width: $size-m * 7.25;
$border-width: $size-xxxs * 2;
&.wide {
width: ($size-m) * 20;
}
&.tile {
width: ($size-m) * 15;
.rose-icon {
--size: #{$size-xl};
padding: $size-xxxs;
}
.rose-form-radio-label {
display: flex;
flex-direction: column;
align-items: center;
height: $size-m * 9;
&::after {
left: $card-tile-width - $border-width;
}
}
}
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
margin: $size-m $size-xs $size-m 0;
.radio-card-overlay {
position: absolute;
height: $size-l + $size-xxs;
background-color: var(--ui-gray-subtler-5);
width: 100%;
bottom: 0;
right: 0;
border-bottom-left-radius: $size-xxxs;
border-bottom-right-radius: $size-xxxs;
}
.rose-form-radio-label {
@include type.type(s, bold);
width: ($size-m) * 23;
height: 100%;
display: block;
position: relative;
text-align: left;
border: $size-xxxxs solid var(--ui-gray-subtler-2);
box-shadow: 0 $size-xxs $size-xxs
rgba(var(--stark-components), var(--opacity-2));
border-radius: $size-xxs;
padding: $size-s $size-m ($size-xl + $size-m);
box-sizing: border-box;
.rose-form-helper-text {
@include type.type(xs, normal);
padding-top: $size-xs;
}
&::after {
position: absolute;
bottom: $size-xxs + $size-xxxxs;
right: $card-wide-width - $border-width;
display: block;
width: $size-m;
height: $size-m;
transform: scale(2);
pointer-events: none;
content: ' ';
background: no-repeat 50%/50% 50%;
background-image: url($data-svg-prefix + map-get($radio-svg, 'default'));
}
}
.rose-form-radio-field {
position: absolute;
z-index: 1;
bottom: $size-xs;
opacity: 0;
&:not(:disabled) {
&:focus {
~ .rose-form-radio-label {
&::after {
background-image: url($data-svg-prefix + map-get(
$radio-svg,
'focus'
));
}
}
}
&:hover {
~ .rose-form-radio-label {
border: $size-xxxxs solid var(--ui-gray);
}
}
&:checked {
~ .rose-form-radio-label {
&::after {
background-image: url($data-svg-prefix + map-get(
$radio-svg,
'checked'
));
}
.radio-card-overlay {
background-color: var(--action-subtler-1h);
.ember-application.rose-theme-light & {
background-color: var(--action-subtler-1h);
}
@media (prefers-color-scheme: dark) {
background-color: var(--action-subtler-1);
}
.ember-application.rose-theme-dark & {
background-color: var(--action-subtler-1);
}
}
border: $size-xxxxs solid var(--info-subtler-1);
}
}
}
&:disabled {
~ .rose-form-radio-label {
border: $size-xxxxs solid var(--ui-border-subtler-1);
background-color: var(--ui-gray-subtler-6);
color: var(--ui-gray-starker-1);
&::after {
background-image: url($data-svg-prefix + map-get(
$radio-svg,
'disabled'
));
}
}
&:checked {
~ .rose-form-radio-label {
&::after {
background-image: url($data-svg-prefix + map-get(
$radio-svg,
'disabled-checked'
));
}
}
}
}
}
&.error {
.rose-form-radio-field {
&:not(:disabled, :focus) {
~ .rose-form-radio-label {
color: var(--ui-gray);
border: $size-xxxxs solid var(--failure);
.radio-card-overlay {
background-color: var(--failure-subtler-2);
}
.rose-form-helper-text.error {
color: var(--ui-gray);
}
&::after {
background-image: url($data-svg-prefix + map-get(
$radio-svg,
'error'
));
}
}
&:checked {
~ .rose-form-radio-label {
color: var(--ui-gray);
border: $size-xxxxs solid var(--failure);
.radio-card-overlay {
background-color: var(--failure-subtler-2);
}
&::after {
background-image: url($data-svg-prefix + map-get(
$radio-svg,
'error-checked'
));
}
}
}
}
}
}
}
+1 -1
View File
@@ -42,7 +42,7 @@
"ember-cli-htmlbars": "^6.1.0",
"ember-cli-sass": "^11.0.1",
"ember-composable-helpers": "^5.0.0",
"ember-focus-trap": "^0.7.0",
"ember-focus-trap": "^1.0.1",
"ember-named-blocks-polyfill": "^0.2.5",
"ember-radio-button": "github:yapplabs/ember-radio-button.git#b2ab65f",
"ember-uuid": "^2.1.0",
@@ -1,53 +0,0 @@
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { render, find } from '@ember/test-helpers';
import hbs from 'htmlbars-inline-precompile';
module('Integration | Component | rose/form/radio/card', function (hooks) {
setupRenderingTest(hooks);
test('it renders', async function (assert) {
await render(hbs`<Rose::Form::Radio::Card />`);
assert.ok(await find('input'));
assert.strictEqual(await find('input').type, 'radio');
});
test('it renders in a tile format when tile layout is applied', async function (assert) {
await render(hbs`<Rose::Form::Radio::Card @layout="tile"/>`);
assert.ok(await find('.tile'));
});
test('it supports an icon', async function (assert) {
await render(
hbs`<Rose::Form::Radio::Card @icon="flight-icons/svg/user-circle-16" />`
);
assert.ok(find('svg'));
});
test('it is checked when @value and @selectedValue values match', async function (assert) {
await render(
hbs`<Rose::Form::Radio::Card @value="tree" @selectedValue="tree"/>`
);
assert.true(await find('input').checked);
});
test('it is not disabled by default', async function (assert) {
await render(hbs`<Rose::Form::Radio::Card />`);
assert.false(await find('input').disabled);
});
test('it is not in error state by default', async function (assert) {
await render(hbs`<Rose::Form::Radio::Card />`);
assert.notOk(await find('.error'));
});
test('it is disabled when @disabled={{true}}', async function (assert) {
await render(hbs`<Rose::Form::Radio::Card @disabled={{true}}/>`);
assert.true(await find('input').disabled);
});
test('it is in error state when @error={{true}}', async function (assert) {
await render(hbs`<Rose::Form::Radio::Card @error={{true}} />`);
assert.ok(await find('.error'));
});
});
+2 -1
View File
@@ -105,7 +105,8 @@
"async": "^2.6.4",
"terser": "^5.14.2",
"jpeg-js": "^0.4.4",
"got": "^11.8.5"
"got": "^11.8.5",
"loader-utils": "^2.0.4"
},
"config": {
"commitizen": {
+1
View File
@@ -32,6 +32,7 @@ module.exports = {
'.eslintrc.js',
'.prettierrc.js',
'.template-lintrc.js',
'.stylelintrc.js',
'ember-cli-build.js',
'testem.js',
'blueprints/*/index.js',
-8
View File
@@ -1,8 +0,0 @@
files:
include: 'app/styles/**/*.s+(a|c)ss'
rules:
leading-zero: 0
mixins-before-declarations: 0
nesting-depth: 0
placeholder-in-extend: 0
property-sort-order: 0
+33
View File
@@ -0,0 +1,33 @@
'use strict';
module.exports = {
extends: ['stylelint-config-standard-scss', 'stylelint-config-prettier-scss'],
rules: {
// We oftentimes want to have long form properties for readability
'declaration-block-no-redundant-longhand-properties': null,
// This is just a personal preference unless the quotes are needed
// to escape special characters
'function-url-quotes': null,
// This is often limited by the DOM and we want to order our declarations by our own standard
'no-descending-specificity': null,
// -Webkit animation CSS for older browsers. Need a decision to remove `-webkit` syntax in
// favor of normal CSS
'property-no-vendor-prefix': null,
'at-rule-no-vendor-prefix': null,
// As a team we want to stick to using kebab-case as our selector class pattern, but there are a couple
// of exceptions in which we overrride CSS from addons like CodeMirror and HDS, in which they use
// pascal case and a double-kebab-case. This regex checks for all of those 3 patterns and won't fail
// lint check, but will fail if its any other pattern.
'selector-class-pattern': [
'(^([a-z][a-z0-9]*)(-{1,2}[a-z0-9]+)*$)|(^CodeMirror)',
{
message: (selector) =>
`Expected class selector "${selector}" to be kebab-case`,
},
],
},
};
+18
View File
@@ -0,0 +1,18 @@
import CredentialAbility from 'api/abilities/credential';
import { inject as service } from '@ember/service';
export default class OverrideCredentialAbility extends CredentialAbility {
// =service
@service features;
/**
* This override ensures that JSON credentials may be read only if the
* json-credentials feature flag is enabled. All other types are subject
* to the standard logic found in the api addon.
*/
get canRead() {
return this.features.isEnabled('json-credentials') || !this.model.isJSON
? super.canRead
: false;
}
}
@@ -70,14 +70,13 @@
</Hds::Form::RadioCard::Group>
{{else}}
<InfoField
@value={{t (concat 'resources.credential-store.help.' @model.type)}}
@value={{t (concat 'resources.credential-store.types.' @model.type)}}
@icon='keychain'
as |F|
>
<F.Label>{{t 'form.type.label'}}</F.Label>
<F.HelperText>{{t
(concat 'resources.credential-store.types.' @model.type)
}}
<F.HelperText>
{{t (concat 'resources.credential-store.help.' @model.type)}}
</F.HelperText>
</InfoField>
{{/if}}
@@ -70,14 +70,13 @@
</Hds::Form::RadioCard::Group>
{{else}}
<InfoField
@value={{t (concat 'resources.credential-store.help.' @model.type)}}
@value={{t (concat 'resources.credential-store.types.' @model.type)}}
@icon='vault'
as |F|
>
<F.Label>{{t 'form.type.label'}}</F.Label>
<F.HelperText>{{t
(concat 'resources.credential-store.types.' @model.type)
}}
<F.HelperText>
{{t (concat 'resources.credential-store.help.' @model.type)}}
</F.HelperText>
</InfoField>
{{/if}}
@@ -2,6 +2,7 @@
{{component
(concat 'form/credential/' @model.type)
model=@model
types=this.credentialTypes
submit=@submit
cancel=@cancel
changeType=@changeType
@@ -0,0 +1,19 @@
import Component from '@glimmer/component';
import { inject as service } from '@ember/service';
import { types } from 'api/models/credential';
export default class FormCredentialComponent extends Component {
// =services
@service features;
// =attributes
/**
* Returns an array of available credential types the user can create
* @type {object}
*/
get credentialTypes() {
return this.features.isEnabled('json-credentials')
? types
: types.filter((type) => type !== 'json');
}
}
@@ -0,0 +1,112 @@
<Rose::Form
@onSubmit={{@submit}}
@cancel={{@cancel}}
@disabled={{@model.isSaving}}
@showEditToggle={{if @model.isNew false true}}
as |form|
>
<form.input
@name='name'
@value={{@model.name}}
@label={{t 'form.name.label'}}
@error={{@model.errors.name}}
@helperText={{t 'form.name.help'}}
as |field|
>
{{#if @model.errors.name}}
<field.errors as |errors|>
{{#each @model.errors.name as |error|}}
<errors.message>{{error.message}}</errors.message>
{{/each}}
</field.errors>
{{/if}}
</form.input>
<form.textarea
@name='description'
@value={{@model.description}}
@label={{t 'form.description.label'}}
@error={{@model.errors.description}}
@helperText={{t 'form.description.help'}}
as |field|
>
{{#if @model.errors.description}}
<field.errors as |errors|>
{{#each @model.errors.description as |error|}}
<errors.message>{{error.message}}</errors.message>
{{/each}}
</field.errors>
{{/if}}
</form.textarea>
{{#if @model.isNew}}
<form.fieldset>
<:body>
<Hds::Form::RadioCard::Group as |G|>
<G.Legend>{{t 'form.type.label'}}</G.Legend>
{{#each @types as |credentialType|}}
<G.RadioCard
@name={{credentialType}}
@value={{credentialType}}
@checked={{eq credentialType @model.type}}
@maxWidth='20rem'
{{on 'input' (fn @changeType credentialType)}}
as |R|
>
<R.Label>{{t
(concat 'resources.credential.types.' credentialType)
}}</R.Label>
<R.Description>{{t
(concat 'resources.credential.help.' credentialType)
}}</R.Description>
</G.RadioCard>
{{/each}}
</Hds::Form::RadioCard::Group>
</:body>
</form.fieldset>
<form.fieldset>
<:title>{{t 'resources.credential.form.json.label'}}</:title>
<:description>{{t 'resources.credential.form.json.help'}}</:description>
<:body>
<Form::Field::JsonSecret
@value={{@model.json_object}}
@onInput={{fn (mut @model.json_object)}}
@showEditButton={{false}}
/>
</:body>
</form.fieldset>
{{else}}
<InfoField
@value={{t (concat 'resources.credential.types.' @model.type)}}
as |F|
>
<F.Label>{{t 'form.type.label'}}</F.Label>
<F.HelperText>{{t (concat 'resources.credential.help.' @model.type)}}
</F.HelperText>
</InfoField>
<form.fieldset>
<:title>{{t 'resources.credential.form.json.label'}}</:title>
<:description>{{t 'resources.credential.form.json.help'}}</:description>
<:body>
<Form::Field::JsonSecret
@disabled={{form.disabled}}
@value={{@model.json_object}}
@onInput={{fn (mut @model.json_object)}}
@showEditButton={{true}}
/>
</:body>
</form.fieldset>
{{/if}}
{{#if (can 'save model' @model)}}
<form.actions
@enableEditText={{t 'actions.edit-form'}}
@submitText={{t 'actions.save'}}
@cancelText={{t 'actions.cancel'}}
/>
{{/if}}
</Rose::Form>
@@ -12,8 +12,6 @@
@label={{t 'form.name.label'}}
@error={{@model.errors.name}}
@helperText={{t 'form.name.help'}}
disabled={{@model.isSaving}}
readonly={{false}}
as |field|
>
{{#if @model.errors.name}}
@@ -31,8 +29,6 @@
@label={{t 'form.description.label'}}
@error={{@model.errors.description}}
@helperText={{t 'form.description.help'}}
disabled={{@model.isSaving}}
readonly={{false}}
as |field|
>
{{#if @model.errors.description}}
@@ -44,9 +40,9 @@
{{/if}}
</form.textarea>
{{#if @model.isNew}}
<Hds::Form::RadioCard::Group @name='types' as |G|>
<Hds::Form::RadioCard::Group @name='type' as |G|>
<G.Legend>{{t 'form.type.label'}}</G.Legend>
{{#each this.credentialTypes as |credentialType|}}
{{#each @types as |credentialType|}}
<G.RadioCard
@name={{credentialType}}
@value={{credentialType}}
@@ -67,11 +63,11 @@
</Hds::Form::RadioCard::Group>
{{else}}
<InfoField
@value={{t (concat 'resources.credential.help.' @model.type)}}
@value={{t (concat 'resources.credential.types.' @model.type)}}
as |F|
>
<F.Label>{{t 'form.type.label'}}</F.Label>
<F.HelperText>{{t (concat 'resources.credential.types.' @model.type)}}
<F.HelperText>{{t (concat 'resources.credential.help.' @model.type)}}
</F.HelperText>
</InfoField>
{{/if}}
@@ -82,8 +78,6 @@
@label={{t 'resources.credential.form.username.label'}}
@helperText={{t 'resources.credential.form.username.help'}}
@error={{@model.errors.username}}
disabled={{@model.isSaving}}
readonly={{false}}
as |field|
>
{{#if @model.errors.username}}
@@ -102,8 +96,6 @@
@label={{t 'resources.credential.form.private_key.label'}}
@helperText={{t 'resources.credential.form.private_key.help'}}
@error={{@model.errors.private_key}}
disabled={{@model.isSaving}}
readonly={{false}}
as |field|
>
{{#if @model.errors.private_key}}
@@ -121,8 +113,6 @@
@label={{t 'resources.credential.form.private_key_passphrase.label'}}
@helperText={{t 'resources.credential.form.private_key_passphrase.help'}}
@error={{@model.errors.private_key_passphrase}}
disabled={{@model.isSaving}}
readonly={{false}}
autocomplete='new-password'
as |field|
>
@@ -1,10 +0,0 @@
import Component from '@glimmer/component';
import { types } from 'api/models/credential';
export default class FormSSHPrivateKeyCredentialComponent extends Component {
// =attributes
/**
* @type {Array}
*/
credentialTypes = types;
}
@@ -12,8 +12,6 @@
@label={{t 'form.name.label'}}
@error={{@model.errors.name}}
@helperText={{t 'form.name.help'}}
disabled={{@model.isSaving}}
readonly={{false}}
as |field|
>
{{#if @model.errors.name}}
@@ -31,8 +29,6 @@
@label={{t 'form.description.label'}}
@error={{@model.errors.description}}
@helperText={{t 'form.description.help'}}
disabled={{@model.isSaving}}
readonly={{false}}
as |field|
>
{{#if @model.errors.description}}
@@ -44,9 +40,9 @@
{{/if}}
</form.textarea>
{{#if @model.isNew}}
<Hds::Form::RadioCard::Group @name='types' as |G|>
<Hds::Form::RadioCard::Group @name='type' as |G|>
<G.Legend>{{t 'form.type.label'}}</G.Legend>
{{#each this.credentialTypes as |credentialType|}}
{{#each @types as |credentialType|}}
<G.RadioCard
@name={{credentialType}}
@value={{credentialType}}
@@ -67,11 +63,11 @@
</Hds::Form::RadioCard::Group>
{{else}}
<InfoField
@value={{t (concat 'resources.credential.help.' @model.type)}}
@value={{t (concat 'resources.credential.types.' @model.type)}}
as |F|
>
<F.Label>{{t 'form.type.label'}}</F.Label>
<F.HelperText>{{t (concat 'resources.credential.types.' @model.type)}}
<F.HelperText>{{t (concat 'resources.credential.help.' @model.type)}}
</F.HelperText>
</InfoField>
{{/if}}
@@ -82,8 +78,6 @@
@label={{t 'resources.credential.form.username.label'}}
@helperText={{t 'resources.credential.form.username.help'}}
@error={{@model.errors.username}}
disabled={{@model.isSaving}}
readonly={{false}}
as |field|
>
{{#if @model.errors.username}}
@@ -103,8 +97,6 @@
@label={{t 'resources.credential.form.password.label'}}
@helperText={{t 'resources.credential.form.password.help'}}
@error={{@model.errors.password}}
disabled={{@model.isSaving}}
readonly={{false}}
autocomplete='new-password'
as |field|
>
@@ -1,10 +0,0 @@
import Component from '@glimmer/component';
import { types } from 'api/models/credential';
export default class FormUserNamePasswordCredentialComponent extends Component {
// =attributes
/**
* @type {Array}
*/
credentialTypes = types;
}
@@ -41,4 +41,4 @@ export default class FormFieldJsonSecretComponent extends Component {
enableEditing() {
if (!this.args.disabled) this.editing = true;
}
}
}
@@ -44,62 +44,47 @@
</form.textarea>
{{#if @model.isNew}}
<form.fieldset>
<:title>{{t 'form.type.label'}}</:title>
<:body>
<form.radioGroup
@name='types'
@selectedValue={{@model.type}}
@changed={{@changeType}}
as |radioGroup|
<Hds::Form::RadioCard::Group @name='type' as |G|>
<G.Legend>{{t 'form.type.label'}}</G.Legend>
{{#each this.hostCatalogTypes as |hostCatalogType|}}
<G.RadioCard
@value={{hostCatalogType}}
@maxWidth='20rem'
@checked={{eq hostCatalogType @model.type}}
{{on 'input' (fn @changeType hostCatalogType)}}
as |R|
>
<radioGroup.card
@label={{t 'resources.host-catalog.types.static'}}
@value='static'
@helperText={{t 'resources.host-catalog.help.static'}}
@layout='wide'
/>
<radioGroup.card
@label={{t 'resources.host-catalog.types.dynamic'}}
@value='plugin'
@helperText={{t 'resources.host-catalog.help.dynamic'}}
@layout='wide'
/>
</form.radioGroup>
</:body>
</form.fieldset>
<R.Label>{{t
(concat 'resources.host-catalog.types.' hostCatalogType)
}}</R.Label>
<R.Description>{{t
(concat 'resources.host-catalog.help.' hostCatalogType)
}}</R.Description>
</G.RadioCard>
{{/each}}
</Hds::Form::RadioCard::Group>
<Hds::Form::RadioCard::Group @name='type' @alignment='center' as |G|>
<G.Legend>{{t 'titles.provider'}}</G.Legend>
<G.Legend>{{t 'titles.choose-a-provider'}}</G.Legend>
<G.HelperText>{{t 'descriptions.choose-a-provider'}}</G.HelperText>
<Rose::Separator />
<form.fieldset>
<:title>{{t 'titles.provider'}}</:title>
</form.fieldset>
<form.fieldset>
<:title>{{t 'titles.choose-a-provider'}}</:title>
<:description>{{t 'descriptions.choose-a-provider'}}</:description>
<:body>
<form.radioGroup
@name='provider'
@selectedValue={{@model.compositeType}}
@changed={{@changeType}}
as |radioGroup|
{{#each-in this.mapResourceTypeWithIcon as |pluginType icon|}}
<G.RadioCard
@value={{pluginType}}
@maxWidth='20rem'
@checked={{eq pluginType @model.compositeType}}
{{on 'input' (fn @changeType pluginType)}}
as |R|
>
<radioGroup.card
@label={{t 'resources.host-catalog.types.aws'}}
@value='aws'
@layout='tile'
@icon='flight-icons/svg/aws-color-16'
/>
<radioGroup.card
@label={{t 'resources.host-catalog.types.azure'}}
@value='azure'
@layout='tile'
@icon='flight-icons/svg/azure-color-16'
/>
</form.radioGroup>
</:body>
</form.fieldset>
<R.Label>{{t
(concat 'resources.host-catalog.types.' pluginType)
}}</R.Label>
<R.Icon @name={{icon}} />
</G.RadioCard>
{{/each-in}}
</Hds::Form::RadioCard::Group>
{{else}}
<InfoField
@value={{t 'resources.host-catalog.types.aws'}}
@@ -1,7 +1,23 @@
import Component from '@glimmer/component';
import { action } from '@ember/object';
import { types, pluginTypes } from 'api/models/host-catalog';
//Note: this is a temporary solution till we have resource type helper in place
const icons = ['aws-color', 'azure-color'];
export default class FormHostCatalogAwsComponent extends Component {
// =properties
hostCatalogTypes = types;
/**
* maps resource type with icon
* @type {object}
*/
get mapResourceTypeWithIcon() {
return pluginTypes.reduce(
(obj, plugin, i) => ({ ...obj, [plugin]: icons[i] }),
{}
);
}
// =actions
@action
@@ -44,67 +44,47 @@
</form.textarea>
{{#if @model.isNew}}
<form.fieldset>
<:title>{{t 'form.type.label'}}</:title>
<:body>
<form.radioGroup
@name='types'
@selectedValue={{@model.type}}
@changed={{@changeType}}
as |radioGroup|
<Hds::Form::RadioCard::Group @name='type' as |G|>
<G.Legend>{{t 'form.type.label'}}</G.Legend>
{{#each this.hostCatalogTypes as |hostCatalogType|}}
<G.RadioCard
@value={{hostCatalogType}}
@maxWidth='20rem'
@checked={{eq hostCatalogType @model.type}}
{{on 'input' (fn @changeType hostCatalogType)}}
as |R|
>
<radioGroup.card
@label={{t 'resources.host-catalog.types.static'}}
@value='static'
@helperText={{t 'resources.host-catalog.help.static'}}
@layout='wide'
/>
<radioGroup.card
@label={{t 'resources.host-catalog.types.dynamic'}}
@value='plugin'
@helperText={{t 'resources.host-catalog.help.dynamic'}}
@layout='wide'
/>
</form.radioGroup>
</:body>
</form.fieldset>
<Rose::Separator />
<form.fieldset>
<:title>{{t 'titles.provider'}}</:title>
</form.fieldset>
<form.fieldset>
<:title>{{t 'titles.choose-a-provider'}}</:title>
<:description>
{{concat
<R.Label>{{t
(concat 'resources.host-catalog.types.' hostCatalogType)
}}</R.Label>
<R.Description>{{t
(concat 'resources.host-catalog.help.' hostCatalogType)
}}</R.Description>
</G.RadioCard>
{{/each}}
</Hds::Form::RadioCard::Group>
<Hds::Form::RadioCard::Group @name='type' @alignment='center' as |G|>
<G.Legend>{{t 'titles.provider'}}</G.Legend>
<G.Legend>{{t 'titles.choose-a-provider'}}</G.Legend>
<G.HelperText>{{concat
(t 'descriptions.choose-a-provider')
' '
(t 'descriptions.choose-a-provider-azure')
}}
</:description>
}}</G.HelperText>
<:body>
<form.radioGroup
@name='provider'
@selectedValue={{@model.compositeType}}
@changed={{@changeType}}
as |radioGroup|
{{#each-in this.mapResourceTypeWithIcon as |pluginType icon|}}
<G.RadioCard
@value={{pluginType}}
@maxWidth='20rem'
@checked={{eq pluginType @model.compositeType}}
{{on 'input' (fn @changeType pluginType)}}
as |R|
>
<radioGroup.card
@label={{t 'resources.host-catalog.types.aws'}}
@value='aws'
@layout='tile'
@icon='flight-icons/svg/aws-color-16'
/>
<radioGroup.card
@label={{t 'resources.host-catalog.types.azure'}}
@value='azure'
@layout='tile'
@icon='flight-icons/svg/azure-color-16'
/>
</form.radioGroup>
</:body>
</form.fieldset>
<R.Label>{{t
(concat 'resources.host-catalog.types.' pluginType)
}}</R.Label>
<R.Icon @name={{icon}} />
</G.RadioCard>
{{/each-in}}
</Hds::Form::RadioCard::Group>
{{else}}
<InfoField
@value={{t 'resources.host-catalog.types.azure'}}
@@ -162,6 +142,14 @@
readonly={{false}}
/>
<form.checkbox
@name='disable_credential_rotation'
@checked={{@model.disable_credential_rotation}}
@helperText={{t 'form.disable_credential_rotation.help'}}
@label={{t 'form.disable_credential_rotation.label'}}
@disabled={{true}}
/>
{{#if (can 'save model' @model)}}
<form.actions
@enableEditText={{t 'actions.edit-form'}}
@@ -0,0 +1,20 @@
import Component from '@glimmer/component';
import { types, pluginTypes } from 'api/models/host-catalog';
//Note: this is a temporary solution till we have resource type helper in place
const icons = ['aws-color', 'azure-color'];
export default class FormAzureHostCatalogAwsComponent extends Component {
// =properties
hostCatalogTypes = types;
/**
* maps resource type with icon
* @type {object}
*/
get mapResourceTypeWithIcon() {
return pluginTypes.reduce(
(obj, plugin, i) => ({ ...obj, [plugin]: icons[i] }),
{}
);
}
}
@@ -46,60 +46,51 @@
</form.textarea>
{{#if @model.isNew}}
<form.fieldset>
<:title>{{t 'form.type.label'}}</:title>
<:body>
<form.radioGroup
@name='types'
@selectedValue={{@model.type}}
@changed={{@changeType}}
as |radioGroup|
<Hds::Form::RadioCard::Group @name='type' as |G|>
<G.Legend>{{t 'form.type.label'}}</G.Legend>
{{#each this.hostCatalogTypes as |hostCatalogType|}}
<G.RadioCard
@value={{hostCatalogType}}
@maxWidth='20rem'
@checked={{eq hostCatalogType @model.type}}
{{on 'input' (fn @changeType hostCatalogType)}}
as |R|
>
<radioGroup.card
@label={{t 'resources.host-catalog.types.static'}}
@value='static'
@helperText={{t 'resources.host-catalog.help.static'}}
@layout='wide'
/>
<radioGroup.card
@label={{t 'resources.host-catalog.types.dynamic'}}
@value='plugin'
@helperText={{t 'resources.host-catalog.help.dynamic'}}
@layout='wide'
/>
</form.radioGroup>
</:body>
</form.fieldset>
<R.Label>{{t
(concat 'resources.host-catalog.types.' hostCatalogType)
}}</R.Label>
<R.Description>{{t
(concat 'resources.host-catalog.help.' hostCatalogType)
}}</R.Description>
</G.RadioCard>
{{/each}}
</Hds::Form::RadioCard::Group>
{{#if @model.isPlugin}}
<Rose::Separator />
<form.fieldset>
<:title>{{t 'titles.provider'}}</:title>
</form.fieldset>
<form.fieldset>
<:title>{{t 'titles.choose-a-provider'}}</:title>
<:description>{{t 'descriptions.choose-a-provider'}}</:description>
<:body>
<form.radioGroup
@name='provider'
@selectedValue={{@model.compositeType}}
@changed={{@changeType}}
as |radioGroup|
<Hds::Form::RadioCard::Group @name='type' as |G|>
<G.Legend>{{t 'titles.provider'}}</G.Legend>
<G.Legend>{{t 'titles.choose-a-provider'}}</G.Legend>
<G.HelperText>{{t 'descriptions.choose-a-provider'}}</G.HelperText>
{{#each-in this.mapResourceTypeWithIcon as |pluginType|}}
<G.RadioCard
@value={{pluginType}}
@checked={{eq pluginType @model.type}}
@maxWidth='20rem'
{{on 'input' (fn @changeType pluginType)}}
as |R|
>
<radioGroup.card
@label={{t 'resources.host-catalog.types.aws'}}
@value='aws'
@layout='tile'
/>
<radioGroup.card
@label={{t 'resources.host-catalog.types.azure'}}
@value='azure'
@layout='tile'
/>
</form.radioGroup>
</:body>
</form.fieldset>
<R.Label>{{t
(concat 'resources.host-catalog.types.' pluginType)
}}</R.Label>
<R.Description>{{t
(concat 'resources.host-catalog.help.' pluginType)
}}</R.Description>
</G.RadioCard>
{{/each-in}}
</Hds::Form::RadioCard::Group>
{{/if}}
{{/if}}
@@ -0,0 +1,20 @@
import Component from '@glimmer/component';
import { types, pluginTypes } from 'api/models/host-catalog';
//Note: this is a temporary solution till we have resource type helper in place
const icons = ['aws-color', 'azure-color'];
export default class FormStaticHostCatalogAwsComponent extends Component {
// =properties
hostCatalogTypes = types;
/**
* maps resource type with icon
* @type {object}
*/
get mapResourceTypeWithIcon() {
return pluginTypes.reduce(
(obj, plugin, i) => ({ ...obj, [plugin]: icons[i] }),
{}
);
}
}
@@ -53,26 +53,17 @@
@disabled={{true}}
/>
<form.fieldset>
<:title>{{t 'titles.provider'}}</:title>
<:description>{{t 'descriptions.provider'}}</:description>
<:body>
<form.radioGroup
@name='provider'
@selectedValue={{@model.compositeType}}
@disabled={{true}}
as |radioGroup|
>
<radioGroup.card
@label={{t 'resources.host-catalog.types.aws'}}
@value={{@model.compositeType}}
@layout='tile'
@icon='flight-icons/svg/aws-color-16'
/>
</form.radioGroup>
</:body>
</form.fieldset>
<InfoField
@value={{t 'descriptions.provider'}}
@icon='aws-color'
disabled={{form.disabled}}
as |F|
>
<F.Label>{{t 'titles.provider'}}</F.Label>
<F.HelperText>
{{t 'resources.host-catalog.types.aws'}}
</F.HelperText>
</InfoField>
<Rose::Form::Fieldset
@helperText={{t 'resources.host-set.form.preferred_endpoints.help'}}
@@ -53,26 +53,17 @@
@disabled={{true}}
/>
<form.fieldset>
<:title>{{t 'titles.provider'}}</:title>
<:description>{{t 'descriptions.provider'}}</:description>
<:body>
<form.radioGroup
@name='provider'
@selectedValue={{@model.compositeType}}
@disabled={{true}}
as |radioGroup|
>
<radioGroup.card
@label={{t 'resources.host-catalog.types.azure'}}
@value={{@model.compositeType}}
@layout='tile'
@icon='flight-icons/svg/azure-color-16'
/>
</form.radioGroup>
</:body>
</form.fieldset>
<InfoField
@value={{t 'descriptions.provider'}}
@icon='azure-color'
disabled={{form.disabled}}
as |F|
>
<F.Label>{{t 'titles.provider'}}</F.Label>
<F.HelperText>
{{t 'resources.host-catalog.types.azure'}}
</F.HelperText>
</InfoField>
<Rose::Form::Fieldset
@helperText={{t 'resources.host-set.form.preferred_endpoints.help'}}
+11 -18
View File
@@ -52,24 +52,17 @@
{{/if}}
</form.textarea>
<form.fieldset>
<:title>{{t 'titles.provider'}}</:title>
<:body>
<form.radioGroup
@name='provider'
@selectedValue={{@model.compositeType}}
@disabled={{true}}
as |radioGroup|
>
<radioGroup.card
@label={{t 'resources.host-catalog.types.aws'}}
@value={{@model.compositeType}}
@layout='tile'
@icon={{concat 'flight-icons/svg/' @model.compositeType '-color-16'}}
/>
</form.radioGroup>
</:body>
</form.fieldset>
<InfoField
@value={{t 'descriptions.provider'}}
@icon='aws-color'
disabled={{form.disabled}}
as |F|
>
<F.Label>{{t 'titles.provider'}}</F.Label>
<F.HelperText>
{{t 'resources.host-catalog.types.aws'}}
</F.HelperText>
</InfoField>
<form.fieldset>
<:title>{{t 'form.ip_addresses.label'}}</:title>
@@ -52,25 +52,17 @@
{{/if}}
</form.textarea>
<form.fieldset>
<:title>{{t 'titles.provider'}}</:title>
<:body>
<form.radioGroup
@name='provider'
@selectedValue={{@model.compositeType}}
@disabled={{true}}
as |radioGroup|
>
<radioGroup.card
@label={{t 'resources.host-catalog.types.azure'}}
@value={{@model.compositeType}}
@layout='tile'
@icon={{concat 'flight-icons/svg/' @model.compositeType '-color-16'}}
/>
</form.radioGroup>
</:body>
</form.fieldset>
<InfoField
@value={{t 'descriptions.provider'}}
@icon='azure-color'
disabled={{form.disabled}}
as |F|
>
<F.Label>{{t 'titles.provider'}}</F.Label>
<F.HelperText>
{{t 'resources.host-catalog.types.azure'}}
</F.HelperText>
</InfoField>
<form.fieldset>
<:title>{{t 'form.ip_addresses.label'}}</:title>
<:body>
@@ -5,26 +5,33 @@
@onSubmit={{fn @submit this.path}}
as |form|
>
<form.radioGroup
@name='paths'
@selectedValue={{this.path}}
@changed={{this.toggleRadio}}
as |radioGroup|
>
<div class='choose-path-cards'>
<radioGroup.card
@label={{t 'onboarding.quick-start.titles.quick-setup'}}
@value='guided'
@helperText={{t 'onboarding.quick-start.descriptions.quick-setup'}}
@layout='tile'
/>
<radioGroup.card
@label={{t 'onboarding.quick-start.titles.manual'}}
@value='manual'
@helperText={{t 'onboarding.quick-start.descriptions.manual'}}
@layout='tile'
/>
</div>
</form.radioGroup>
<Hds::Form::RadioCard::Group @name='path' as |G|>
<G.RadioCard
@maxWidth='20rem'
@checked={{eq this.path 'guided'}}
@value='guided'
{{on 'input' (fn this.toggleRadio 'guided')}}
as |R|
>
<R.Label>{{t 'onboarding.quick-start.titles.quick-setup'}}</R.Label>
<R.Description>{{t
'onboarding.quick-start.descriptions.quick-setup'
}}</R.Description>
</G.RadioCard>
<G.RadioCard
@maxWidth='20rem'
@checked={{eq this.path 'manual'}}
@value='manual'
{{on 'input' (fn this.toggleRadio 'manual')}}
as |R|
>
<R.Label>{{t 'onboarding.quick-start.titles.manual'}}</R.Label>
<R.Description>{{t
'onboarding.quick-start.descriptions.manual'
}}</R.Description>
</G.RadioCard>
</Hds::Form::RadioCard::Group>
<form.actions @submitText={{t 'actions.next'}} @showCancel={{false}} />
</Rose::Form>
+28 -25
View File
@@ -6,44 +6,47 @@
as |form|
>
<form.input
@name='name'
@type='name'
<Hds::Form::TextInput::Field
@isRequired={{false}}
@isOptional={{true}}
@value={{@model.name}}
@label={{t 'form.name.label'}}
@helperText={{t 'form.name.help'}}
@error={{@model.errors.name}}
readonly={{false}}
as |field|
@isInvalid={{@model.errors.name}}
@type='text'
name='name'
disabled={{form.disabled}}
{{on 'input' (set-from-event @model 'name')}}
as |F|
>
<F.Label>{{t 'form.name.label'}}</F.Label>
<F.HelperText>{{t 'form.name.help'}}</F.HelperText>
{{#if @model.errors.name}}
<field.errors as |errors|>
<F.Error as |E|>
{{#each @model.errors.name as |error|}}
<errors.message>{{error.message}}</errors.message>
<E.Message>{{error.message}}</E.Message>
{{/each}}
</field.errors>
</F.Error>
{{/if}}
</form.input>
</Hds::Form::TextInput::Field>
<form.textarea
@name='description'
@type='description'
<Hds::Form::Textarea::Field
@isRequired={{false}}
@isOptional={{true}}
@value={{@model.description}}
@label={{t 'form.description.label'}}
@helperText={{t 'form.description.help'}}
@error={{@model.errors.description}}
disabled={{@model.isSaving}}
readonly={{false}}
as |field|
@isInvalid={{@model.errors.description}}
name='description'
disabled={{form.disabled}}
as |F|
>
<F.Label>{{t 'form.description.label'}}</F.Label>
<F.HelperText>{{t 'form.description.help'}}</F.HelperText>
{{#if @model.errors.description}}
<field.errors as |errors|>
<F.Error as |E|>
{{#each @model.errors.description as |error|}}
<errors.message>{{error.message}}</errors.message>
<E.Message>{{error.message}}</E.Message>
{{/each}}
</field.errors>
</F.Error>
{{/if}}
</form.textarea>
</Hds::Form::Textarea::Field>
{{#unless @model.isNew}}
<fieldset
@@ -42,34 +42,35 @@
</form.textarea>
{{#if (and @model.isNew (feature-flag 'ssh-target'))}}
<Hds::Form::RadioCard::Group @name='types' @alignment='center' as |G|>
<G.Legend>{{t 'form.type.label'}}</G.Legend>
{{#each-in this.mapResourceTypeWithIcon as |targetType icon|}}
<Hds::Form::RadioCard::Group @name='type' @alignment='center' as |G|>
<G.Legend>{{t 'resources.target.form.type.label'}}</G.Legend>
<G.HelperText>{{t 'resources.target.form.type.help'}}</G.HelperText>
{{#each this.typeMetas as |type|}}
<G.RadioCard
@name={{targetType}}
@value={{targetType}}
@value={{type.type}}
@maxWidth='20rem'
@checked={{eq targetType @model.type}}
{{on 'input' (fn @changeType targetType)}}
@checked={{eq type.type @model.type}}
{{on 'input' (fn @changeType type.type)}}
as |R|
>
<R.Label>{{t (concat 'resources.target.types.' targetType)}}</R.Label>
<R.Label>{{t (concat 'resources.target.types.' type.type)}}</R.Label>
<R.Description>{{t
(concat 'resources.target.help.' targetType)
(concat 'resources.target.help.' type.type)
}}</R.Description>
<R.Icon @name={{icon}} />
<R.Icon @name={{type.icon}} />
</G.RadioCard>
{{/each-in}}
{{/each}}
</Hds::Form::RadioCard::Group>
<br />
{{else}}
<InfoField
@value={{t (concat 'resources.target.help.' @model.type)}}
@value={{t (concat 'resources.target.types.' @model.type)}}
@icon={{this.icon}}
as |F|
>
<F.Label>{{t 'form.type.label'}}</F.Label>
<F.HelperText>{{t (concat 'resources.target.types.' @model.type)}}
<F.HelperText>
{{t (concat 'resources.target.help.' @model.type)}}
</F.HelperText>
</InfoField>
{{/if}}
@@ -1,8 +1,12 @@
import Component from '@glimmer/component';
import { types } from 'api/models/target';
import { TYPES_TARGET } from 'api/models/target';
//Note: this is a temporary solution till we have resource type helper in place
const icons = ['network', 'terminal'];
// NOTE: this is all a temporary solution till we have a resource type helper.
const types = [...TYPES_TARGET].reverse();
const icons = {
ssh: 'terminal-screen',
tcp: 'network',
};
export default class FormTargetComponent extends Component {
// =properties
@@ -10,8 +14,11 @@ export default class FormTargetComponent extends Component {
* maps resource type with icon
* @type {object}
*/
get mapResourceTypeWithIcon() {
return types.reduce((obj, type, i) => ({ ...obj, [type]: icons[i] }), {});
get typeMetas() {
return types.map((type) => ({
type,
icon: icons[type],
}));
}
/**
@@ -20,10 +27,6 @@ export default class FormTargetComponent extends Component {
* @type {string}
*/
get icon() {
if (this.args.model.type === 'tcp') {
return icons[0];
} else {
return icons[1];
}
return icons[this.args.model.type];
}
}
@@ -108,10 +108,19 @@ ${listenerText}
* @type {string}
*/
get installBoundaryText() {
return `curl -fsSL https://apt.releases.hashicorp.com/gpg | sudo apt-key add - ;\\
const configPath = this.configFilePath || '<path>';
const ossContent = `curl -fsSL https://apt.releases.hashicorp.com/gpg | sudo apt-key add - ;\\
sudo apt-add-repository "deb [arch=amd64] https://apt.releases.hashicorp.com $(lsb_release -cs) main" ;\\
sudo apt-get update && sudo apt-get install boundary ;\\
boundary server -config="${this.configFilePath || '<path>'}/pki-worker.hcl"`;
boundary server -config="${configPath}/pki-worker.hcl"`;
const hcpContent = `wget -q https://releases.hashicorp.com/boundary-worker/0.11.2+hcp/boundary-worker_0.11.2+hcp_linux_amd64.zip ;\\
sudo apt-get update && sudo apt-get install unzip ;\\
unzip *.zip ;\\
./boundary-worker server -config="${configPath}/pki-worker.hcl"`;
return this.features.isEnabled('byow-pki-hcp-cluster-id')
? hcpContent
: ossContent;
}
/**
@@ -3,6 +3,7 @@ import { inject as service } from '@ember/service';
import { action } from '@ember/object';
import { loading } from 'ember-loading';
import { notifyError } from 'core/decorators/notify';
import { TYPE_TARGET_TCP } from 'api/models/target';
export default class OnboardingQuickSetupCreateResourcesRoute extends Route {
// =services
@@ -40,7 +41,7 @@ export default class OnboardingQuickSetupCreateResourcesRoute extends Route {
description: 'Sample host created by quick setup',
}),
target: this.store.createRecord('target', {
type: 'tcp',
type: TYPE_TARGET_TCP,
name: 'EC2 Instances',
description: 'Sample target created by quick setup',
}),
@@ -29,15 +29,23 @@ export default class ScopesScopeCredentialStoresCredentialStoreCredentialsNewRou
'scopes.scope.credential-stores.credential-store'
);
let name, description;
/**
* this sets the credential attribute json_object to a string that
* replicates an empty json blob as CodeMirror expects a string
*/
const json_object = '{}';
if (this.currentModel?.isNew) {
({ name, description } = this.currentModel);
this.currentModel.rollbackAttributes();
}
return this.store.createRecord('credential', {
type,
credential_store_id,
name,
description,
json_object,
});
}
@@ -8,60 +8,32 @@ import { notifySuccess, notifyError } from 'core/decorators/notify';
export default class ScopesScopeRolesRolePrincipalsRoute extends Route {
// =services
@service intl;
@service resourceFilterStore;
// =methods
/**
* Returns users and groups associated with this role.
* Returns users, groups and managed-groups associated with this role.
* @param {object} params
* @return {Promise{RoleModel}}
* @return {Promise{role, principals}}
*/
async model() {
const role = this.modelFor('scopes.scope.roles.role');
// Fetch user and group principals.
const users = await role.users;
const groups = await role.groups;
const managedGroups = await role.managedGroups;
// Merge polymorphic principals.
const principals = [...users, ...groups, ...managedGroups];
// Gather user and group IDs as separate arrays, since these
// will be queried in separate API queries.
const userIDs = role.principals
.filter(({ type }) => type === 'user')
.map(({ principal_id }) => principal_id);
const groupIDs = role.principals
.filter(({ type }) => type === 'group')
.map(({ principal_id }) => principal_id);
// Query for users.
const users = userIDs?.length
? (
await this.resourceFilterStore.queryBy(
'user',
{ id: userIDs },
{ scope_id: 'global', recursive: true }
)
).map((model) => model)
: [];
// Query for groups.
const groups = groupIDs?.length
? (
await this.resourceFilterStore.queryBy(
'group',
{ id: groupIDs },
{ scope_id: 'global', recursive: true }
)
).map((model) => model)
: [];
return {
role,
principals: users.concat(groups),
};
return { role, principals };
}
// =actions
/**
* Remove a principal from the current role and redirect to principals index.
* @param {UserModel, GroupModel} principal
* @param {UserModel, GroupModel, ManagedGroupModel} principal
*/
@action
@loading
@@ -1,12 +1,14 @@
import Route from '@ember/routing/route';
import { action } from '@ember/object';
import { inject as service } from '@ember/service';
import { TYPE_TARGET_TCP, TYPE_TARGET_SSH } from 'api/models/target';
export default class ScopesScopeTargetsNewRoute extends Route {
// =services
@service store;
@service router;
@service features;
// =methods
@@ -23,9 +25,16 @@ export default class ScopesScopeTargetsNewRoute extends Route {
* @return {TargetModel}
*/
model({ type = 'tcp' }) {
const scopeModel = this.modelFor('scopes.scope');
model({ type }) {
let name, description;
const scopeModel = this.modelFor('scopes.scope');
// default type is SSH if the feature is enabled, otherwise TCP
if (!type)
type = this.features.isEnabled('ssh-target')
? TYPE_TARGET_SSH
: TYPE_TARGET_TCP;
if (this.currentModel?.isNew) {
({ name, description } = this.currentModel);
this.currentModel.rollbackAttributes();
+4 -5
View File
@@ -1,7 +1,6 @@
// This file is essentially a rip of the default ember notify styles with some minor tweaks for positioning.
// sass-lint:disable no-vendor-prefixes
@-webkit-keyframes 'ember-notify-show' {
@-webkit-keyframes ember-notify-show {
from {
left: -310px;
}
@@ -10,7 +9,7 @@
left: 0;
}
}
@keyframes 'ember-notify-show' {
@keyframes ember-notify-show {
from {
left: -310px;
}
@@ -19,7 +18,7 @@
left: 0;
}
}
@-webkit-keyframes 'ember-notify-hide' {
@-webkit-keyframes ember-notify-hide {
from {
left: 0;
}
@@ -28,7 +27,7 @@
left: -310px;
}
}
@keyframes 'ember-notify-hide' {
@keyframes ember-notify-hide {
from {
left: 0;
}
+20 -23
View File
@@ -1,4 +1,3 @@
// sass-lint:disable no-color-literals class-name-format
@use 'rose/variables/sizing';
@use 'rose/variables/media';
@use 'rose/utilities/type';
@@ -194,11 +193,11 @@
}
mark {
animation: fadeBackground 0.75s linear;
animation: fade-background 0.75s linear;
background-color: transparent;
color: inherit;
@keyframes fadeBackground {
@keyframes fade-background {
0% {
background-color: var(--action-subtler-1);
}
@@ -222,7 +221,7 @@
white-space: nowrap;
}
// Spinning animation for refresh button icon
// Spinning animation for refresh button icon
@media (prefers-reduced-motion: no-preference) {
.is-loading {
.rose-icon {
@@ -239,7 +238,7 @@
}
}
//onboarding quick setup styles
// onboarding quick setup styles
.choose-path {
display: block;
width: sizing.rems(xl) * 15;
@@ -416,7 +415,7 @@
.worker-auth-token {
align-items: flex-end;
display: flex;
gap: sizing.rems(m); // sass-lint:disable-line no-misspelled-properties
gap: sizing.rems(m);
}
.worker-success {
@@ -427,7 +426,7 @@
> div {
align-items: center;
display: flex;
gap: sizing.rems(xs); // sass-lint:disable-line no-misspelled-properties
gap: sizing.rems(xs);
p {
margin-bottom: sizing.rems(xxs);
@@ -458,7 +457,7 @@
.info-field {
.hds-form-field--layout-vertical {
grid-template-areas: 'label' 'helper-text' 'text-input';
grid-template-areas: 'label' 'text-input' 'helper-text';
.hds-form-label {
font-size: sizing.rems(m);
@@ -466,26 +465,25 @@
line-height: sizing.rems(m) + sizing.rems(xxs);
}
.hds-form-helper-text {
color: var(--token-form-label-color);
font-size: sizing.rems(m);
grid-area: helper-text;
}
.hds-form-text-input {
background-color: transparent;
border: 0;
color: var(--token-form-label-color);
grid-area: text-input;
line-height: sizing.rems(m) + sizing.rems(xxs);
margin-top: sizing.rems(xs);
font-size: sizing.rems(m);
padding: 0;
}
.hds-form-helper-text {
color: var(--token-form-label-color);
grid-area: helper-text;
}
}
&.has-icon {
.flight-icon {
position: absolute;
transform: translatey(sizing.rems(xl) - sizing.rems(s));
transform: translateY(sizing.rems(xl) - sizing.rems(xs));
}
.hds-form-helper-text,
@@ -508,28 +506,25 @@
border-radius: sizing.rems(xxs);
height: var(--secret-editor-height);
margin-bottom: sizing.rems(l);
width: 100%;
.secret-editor-json {
.CodeMirror {
border-radius: sizing.rems(xxs);
height: var(--secret-editor-height);
display: block;
}
.CodeMirror-gutters {
background: var(--secret-editor-gutter);
}
// override CodeMirror padding to add padding to left of line numbers
.CodeMirror-linenumber {
padding: 0;
}
}
.secret-editor-skeleton-wrapper {
// using ui-gray directly so it does not change when
// user is in dark mode since the secret editor
// does not change theme colors between light and dark
color: #{color(ui-gray)};
color: var(--token-color-palette-neutral-300);
display: flex;
height: 100%;
position: relative;
@@ -581,6 +576,7 @@
left: 50%;
position: absolute;
top: 50%;
// centers message with editor body and gutter
transform: translate(-50%, -50%);
z-index: 1;
@@ -591,6 +587,7 @@
border: sizing.rems(xxxxs) solid var(--ui-gray);
border-radius: sizing.rems(xxxs);
box-shadow: 0 sizing.rems(xxs) sizing.rems(xxs) var(--button-shadow);
// using ui-gray directly so it does not change when
// user is in dark mode since the secret editor
// does not change theme colors between light and dark
@@ -37,7 +37,7 @@
}}
<body.row as |row|>
<row.headerCell>
{{#if brokeredCredentialSource.isUnknown}}
{{#if (cannot 'read credential' brokeredCredentialSource)}}
{{brokeredCredentialSource.displayName}}
{{else}}
{{#if brokeredCredentialSource.isStatic}}
+5 -3
View File
@@ -21,7 +21,8 @@
"lint:hbs": "ember-template-lint .",
"lint:hbs:fix": "ember-template-lint . --fix",
"lint:js": "eslint .",
"lint:sass": "sass-lint -v -q",
"lint:sass": "stylelint 'app/styles/**/*.scss'",
"lint:sass:fix": "stylelint 'app/styles/**/*.scss' --fix",
"format": "npm-run-all format:*",
"format:hbs": "prettier --write 'app/**/*.hbs'",
"format:js": "prettier --write '{app,config,stories,tests}/**/*.js' *.js",
@@ -91,8 +92,9 @@
"qunit": "^2.17.2",
"qunit-dom": "^2.0.0",
"rose": "*",
"sass": "^1.55.0",
"sass-lint": "^1.13.1",
"stylelint": "^14.14.0",
"stylelint-config-prettier-scss": "^0.0.1",
"stylelint-config-standard-scss": "^6.0.0",
"sinon": "^14.0.0",
"webpack": "^5"
},
@@ -1,5 +1,5 @@
import { module, test } from 'qunit';
import { visit, currentURL, find, click, fillIn } from '@ember/test-helpers';
import { visit, currentURL, click, fillIn } from '@ember/test-helpers';
import { setupApplicationTest } from 'ember-qunit';
import setupMirage from 'ember-cli-mirage/test-support/setup-mirage';
import { authenticateSession } from 'ember-simple-auth/test-support';
@@ -14,6 +14,8 @@ module(
let getCredentialsCount;
let getUsernamePasswordCredentialCount;
let getUsernameKeyPairCredentialCount;
let getJsonCredentialCount;
let featuresService;
const instances = {
scopes: {
@@ -33,6 +35,7 @@ module(
hooks.beforeEach(function () {
// Generate resources
featuresService = this.owner.lookup('service:features');
instances.scopes.org = this.server.create('scope', {
type: 'org',
scope: { id: 'global', type: 'global' },
@@ -50,7 +53,7 @@ module(
urls.credentialStores = `${urls.projectScope}/credential-stores`;
urls.staticCredentialStore = `${urls.credentialStores}/${instances.staticCredentialStore.id}`;
urls.credentials = `${urls.staticCredentialStore}/credentials`;
urls.newCredential = `${urls.staticCredentialStore}/credentials/new`;
urls.newCredential = `${urls.credentials}/new`;
// Generate resource counter
getCredentialsCount = () => {
return this.server.schema.credentials.all().models.length;
@@ -64,17 +67,23 @@ module(
return this.server.schema.credentials.where({ type: 'ssh_private_key' })
.length;
};
getJsonCredentialCount = () => {
return this.server.schema.credentials.where({ type: 'json' }).length;
};
authenticateSession({});
});
test('Users can create a new username & password credential', async function (assert) {
test('users can create a new username & password credential', async function (assert) {
assert.expect(2);
const credentialsCount = getCredentialsCount();
const usernamePasswordCredentialCount =
getUsernamePasswordCredentialCount();
await visit(urls.newCredential);
await visit(urls.credentials);
await click(`[href="${urls.newCredential}"]`);
await fillIn('[name="name"]', 'random string');
await click('[type="submit"]');
assert.strictEqual(getCredentialsCount(), credentialsCount + 1);
assert.strictEqual(
getUsernamePasswordCredentialCount(),
@@ -82,15 +91,18 @@ module(
);
});
test('Users can create a new username & key pair credential', async function (assert) {
test('users can create a new username & key pair credential', async function (assert) {
assert.expect(2);
const credentialsCount = getCredentialsCount();
const usernameKeyPairCredentialCount =
getUsernameKeyPairCredentialCount();
await visit(urls.newCredential);
await visit(urls.credentials);
await click(`[href="${urls.newCredential}"]`);
await fillIn('[name="name"]', 'random string');
await click('[value="ssh_private_key"]');
await click('[type="submit"]');
assert.strictEqual(getCredentialsCount(), credentialsCount + 1);
assert.strictEqual(
getUsernameKeyPairCredentialCount(),
@@ -98,42 +110,85 @@ module(
);
});
test('Users can cancel create new username & password credential', async function (assert) {
test('users can create a new json credential', async function (assert) {
assert.expect(2);
const credentialsCount = getCredentialsCount();
await visit(urls.newCredential);
const jsonCredentialCount = getJsonCredentialCount();
await visit(urls.credentials);
await click(`[href="${urls.newCredential}"]`);
await fillIn('[name="name"]', 'random string');
await click('[value="json"]');
await click('[type="submit"]');
assert.strictEqual(getCredentialsCount(), credentialsCount + 1);
assert.strictEqual(getJsonCredentialCount(), jsonCredentialCount + 1);
});
test('users can cancel create new username & password credential', async function (assert) {
assert.expect(2);
const credentialsCount = getCredentialsCount();
await visit(urls.credentials);
await click(`[href="${urls.newCredential}"]`);
await fillIn('[name="name"]', 'random string');
await click('.rose-form-actions [type="button"]');
assert.strictEqual(currentURL(), urls.credentials);
assert.strictEqual(getCredentialsCount(), credentialsCount);
});
test('Users can cancel create new username & key pair credential', async function (assert) {
test('users can cancel create new username & key pair credential', async function (assert) {
assert.expect(2);
const credentialsCount = getCredentialsCount();
await visit(urls.newCredential);
await visit(urls.credentials);
await click(`[href="${urls.newCredential}"]`);
await fillIn('[name="name"]', 'random string');
await click('[value="ssh_private_key"]');
await click('.rose-form-actions [type="button"]');
assert.strictEqual(currentURL(), urls.credentials);
assert.strictEqual(getCredentialsCount(), credentialsCount);
});
test('Users cannot navigate to new credential route without proper authorization', async function (assert) {
test('users can cancel create new json credential', async function (assert) {
assert.expect(2);
const credentialsCount = getCredentialsCount();
await visit(urls.credentials);
await click(`[href="${urls.newCredential}"]`);
await fillIn('[name="name"]', 'random string');
await click('[value="json"]');
await click('.rose-form-actions [type="button"]');
assert.strictEqual(currentURL(), urls.credentials);
assert.strictEqual(getCredentialsCount(), credentialsCount);
});
test('users cannot navigate to new credential route without proper authorization', async function (assert) {
assert.expect(2);
instances.staticCredentialStore.authorized_collection_actions.credentials =
[];
await visit(urls.staticCredentialStore);
assert.notOk(
instances.staticCredentialStore.authorized_collection_actions.credentials.filter(
(item) => {
item !== 'create';
}
);
await visit(urls.credentialStores);
await click(`[href="${urls.staticCredentialStore}"]`);
assert.false(
instances.staticCredentialStore.authorized_collection_actions.credentials.includes(
'create'
)
);
assert.notOk(find(`[href="${urls.newCredential}"]`));
assert.dom('.rose-layout-page-actions a').doesNotExist();
});
test('saving a new username & password credential with invalid fields displays error messages', async function (assert) {
assert.expect(2);
await visit(urls.credentials);
this.server.post('/credentials', () => {
return new Response(
400,
@@ -154,20 +209,21 @@ module(
}
);
});
await visit(urls.newCredential);
await click(`[href="${urls.newCredential}"]`);
await click('[type="submit"]');
assert.ok(
find('[role="alert"]').textContent.trim(),
'Error in provided request.'
);
assert.ok(
find('.rose-form-error-message').textContent.trim(),
'Field required for creating a username-password credential.'
);
assert
.dom('.rose-notification-body')
.hasText('Error in provided request.');
assert
.dom('.rose-form-error-message')
.hasText('Field required for creating a username-password credential.');
});
test('saving a new username & key pair credential with invalid fields displays error messages', async function (assert) {
assert.expect(2);
await visit(urls.credentials);
this.server.post('/credentials', () => {
return new Response(
400,
@@ -188,17 +244,70 @@ module(
}
);
});
await visit(urls.newCredential);
await click(`[href="${urls.newCredential}"]`);
await click('[value="ssh_private_key"]');
await click('[type="submit"]');
assert.ok(
find('[role="alert"]').textContent.trim(),
'Error in provided request.'
);
assert.ok(
find('.rose-form-error-message').textContent.trim(),
'Field required for creating a username-key-pair credential.'
assert
.dom('.rose-notification-body')
.hasText('Error in provided request.');
assert
.dom('.rose-form-error-message')
.hasText('Field required for creating a username-key-pair credential.');
});
test('saving a new json credential with invalid fields displays error messages', async function (assert) {
assert.expect(1);
await visit(urls.credentials);
this.server.post('/credentials', () => {
return new Response(
400,
{},
{
status: 400,
code: 'invalid_argument',
message: 'Error in provided request.',
details: {
request_fields: [
{
name: 'attributes.json_object',
description: 'Field required for creating a json credential.',
},
],
},
}
);
});
await click(`[href="${urls.newCredential}"]`);
await click('[value="json"]');
await click('[type="submit"]');
assert
.dom('.rose-notification-body')
.hasText('Error in provided request.');
});
test('cannot navigate to json credential when feature is disabled', async function (assert) {
featuresService.disable('json-credentials');
assert.expect(4);
await visit(urls.credentials);
await click(`[href="${urls.newCredential}"]`);
assert.true(
instances.staticCredentialStore.authorized_collection_actions.credentials.includes(
'create'
)
);
assert.dom('.hds-form-radio-card').exists({ count: 2 });
assert
.dom('.hds-form-radio-card:first-child input')
.hasAttribute('value', 'username_password');
assert
.dom('.hds-form-radio-card:nth-child(2) input')
.hasAttribute('value', 'ssh_private_key');
});
}
);
@@ -13,6 +13,7 @@ module(
let getUsernamePasswordCredentialCount;
let getUsernameKeyPairCredentialCount;
let getJSONCredentialCount;
const instances = {
scopes: {
@@ -28,6 +29,7 @@ module(
credentials: null,
usernamePasswordCredential: null,
usernameKeyPairCredential: null,
jsonCredential: null,
};
hooks.beforeEach(function () {
@@ -54,6 +56,11 @@ module(
credentialStore: instances.staticCredentialStore,
type: 'ssh_private_key',
});
instances.jsonCredential = this.server.create('credential', {
scope: instances.scopes.project,
credentialStore: instances.staticCredentialStore,
type: 'json',
});
// Generate route URLs for resources
urls.projectScope = `/scopes/${instances.scopes.project.id}`;
urls.credentialStores = `${urls.projectScope}/credential-stores`;
@@ -61,6 +68,7 @@ module(
urls.credentials = `${urls.staticCredentialStore}/credentials`;
urls.usernamePasswordCredential = `${urls.credentials}/${instances.usernamePasswordCredential.id}`;
urls.usernameKeyPairCredential = `${urls.credentials}/${instances.usernameKeyPairCredential.id}`;
urls.jsonCredential = `${urls.credentials}/${instances.jsonCredential.id}`;
// Generate resource counter
getUsernamePasswordCredentialCount = () => {
return this.server.schema.credentials.where({
@@ -71,6 +79,9 @@ module(
return this.server.schema.credentials.where({ type: 'ssh_private_key' })
.length;
};
getJSONCredentialCount = () => {
return this.server.schema.credentials.where({ type: 'json' }).length;
};
authenticateSession({});
});
@@ -100,6 +111,15 @@ module(
);
});
test('can delete JSON credential', async function (assert) {
assert.expect(2);
const jsonCredentialCount = getJSONCredentialCount();
await visit(urls.jsonCredential);
await click('.rose-layout-page-actions .rose-dropdown-button-danger');
assert.strictEqual(currentURL(), urls.credentials);
assert.strictEqual(getJSONCredentialCount(), jsonCredentialCount - 1);
});
test('cannot delete a username & password credential without proper authorization', async function (assert) {
assert.expect(3);
const usernamePasswordCredentialCount =
@@ -138,6 +158,21 @@ module(
);
});
test('cannot delete a JSON credential without proper authorization', async function (assert) {
assert.expect(3);
const jsonCredentialCount = getJSONCredentialCount();
instances.jsonCredential.authorized_actions =
instances.jsonCredential.authorized_actions.filter(
(item) => item !== 'delete'
);
await visit(urls.jsonCredential);
assert.strictEqual(currentURL(), urls.jsonCredential);
assert
.dom('.rose-layout-page-actions .rose-dropdown-button-danger')
.doesNotExist();
assert.strictEqual(getJSONCredentialCount(), jsonCredentialCount);
});
test('can accept delete username & password credential via dialog', async function (assert) {
assert.expect(2);
const confirmService = this.owner.lookup('service:confirm');
@@ -170,6 +205,18 @@ module(
);
});
test('can accept delete JSON credential via dialog', async function (assert) {
assert.expect(2);
const confirmService = this.owner.lookup('service:confirm');
confirmService.enabled = true;
const jsonCredentialCount = getJSONCredentialCount();
await visit(urls.jsonCredential);
await click('.rose-layout-page-actions .rose-dropdown-button-danger');
await click('.rose-dialog footer .rose-button-primary');
assert.strictEqual(currentURL(), urls.credentials);
assert.strictEqual(getJSONCredentialCount(), jsonCredentialCount - 1);
});
test('can cancel delete username & password credential via dialog', async function (assert) {
assert.expect(2);
const confirmService = this.owner.lookup('service:confirm');
@@ -202,6 +249,18 @@ module(
);
});
test('can cancel delete JSON credential via dialog', async function (assert) {
assert.expect(2);
const confirmService = this.owner.lookup('service:confirm');
confirmService.enabled = true;
const jsonCredentialCount = getJSONCredentialCount();
await visit(urls.jsonCredential);
await click('.rose-layout-page-actions .rose-dropdown-button-danger');
await click('.rose-dialog footer .rose-button-secondary');
assert.strictEqual(currentURL(), urls.jsonCredential);
assert.strictEqual(getJSONCredentialCount(), jsonCredentialCount);
});
test('deleting a username & password credential which errors displays error message', async function (assert) {
assert.expect(1);
this.server.del('/credentials/:id', () => {
@@ -237,5 +296,23 @@ module(
await click('.rose-layout-page-actions .rose-dropdown-button-danger');
assert.ok(find('[role="alert"]').textContent.trim(), 'Oops.');
});
test('deleting a JSON credential which errors displays error message', async function (assert) {
assert.expect(1);
this.server.del('/credentials/:id', () => {
return new Response(
490,
{},
{
status: 490,
code: 'error',
message: 'Oops.',
}
);
});
await visit(urls.jsonCredential);
await click('.rose-layout-page-actions .rose-dropdown-button-danger');
assert.ok(find('[role="alert"]').textContent.trim(), 'Oops.');
});
}
);
@@ -16,6 +16,7 @@ module('Acceptance | credential-stores | credentials | list', function (hooks) {
staticCredentialStore: null,
usernamePasswordCredential: null,
usernameKeyPairCredential: null,
jsonCredential: null,
};
const urls = {
@@ -50,6 +51,11 @@ module('Acceptance | credential-stores | credentials | list', function (hooks) {
credentialStore: instances.staticCredentialStore,
type: 'ssh_private_key',
});
instances.jsonCredential = this.server.create('credential', {
scope: instances.scopes.project,
credentialStore: instances.staticCredentialStore,
type: 'json',
});
// Generate route URLs for resources
urls.projectScope = `/scopes/${instances.scopes.project.id}`;
urls.credentialStores = `${urls.projectScope}/credential-stores`;
@@ -75,7 +81,7 @@ module('Acceptance | credential-stores | credentials | list', function (hooks) {
.isVisible({ count: this.server.schema.credentials.all().models.length });
});
test('User cannot navigate to index without either list or create action', async function (assert) {
test('User cannot navigate to Credentials tab without either list or create action', async function (assert) {
assert.expect(3);
instances.staticCredentialStore.authorized_collection_actions.credentials =
[];
@@ -93,7 +99,7 @@ module('Acceptance | credential-stores | credentials | list', function (hooks) {
assert.dom('.rose-nav-tabs a:nth-child(2)').doesNotExist();
});
test('User can navigate to index with only create action', async function (assert) {
test('User can navigate to new credential screen with only create action', async function (assert) {
assert.expect(2);
instances.staticCredentialStore.authorized_collection_actions.credentials =
['create'];
@@ -17,6 +17,7 @@ module('Acceptance | credential-stores | credentials | read', function (hooks) {
staticCredentialStore: null,
usernamePasswordCredential: null,
usernameKeyPairCredential: null,
jsonCredential: null,
};
const urls = {
@@ -26,6 +27,7 @@ module('Acceptance | credential-stores | credentials | read', function (hooks) {
credentials: null,
usernamePasswordCredential: null,
usernameKeyPairCredential: null,
jsonCredential: null,
unknownCredential: null,
};
@@ -53,6 +55,11 @@ module('Acceptance | credential-stores | credentials | read', function (hooks) {
credentialStore: instances.staticCredentialStore,
type: 'ssh_private_key',
});
instances.jsonCredential = this.server.create('credential', {
scope: instances.scopes.project,
credentialStore: instances.staticCredentialStore,
type: 'json',
});
// Generate route URLs for resources
urls.projectScope = `/scopes/${instances.scopes.project.id}`;
urls.credentialStores = `${urls.projectScope}/credential-stores`;
@@ -60,6 +67,7 @@ module('Acceptance | credential-stores | credentials | read', function (hooks) {
urls.credentials = `${urls.staticCredentialStore}/credentials`;
urls.usernamePasswordCredential = `${urls.credentials}/${instances.usernamePasswordCredential.id}`;
urls.usernameKeyPairCredential = `${urls.credentials}/${instances.usernameKeyPairCredential.id}`;
urls.jsonCredential = `${urls.credentials}/${instances.jsonCredential.id}`;
urls.unknownCredential = `${urls.credentials}/foo`;
authenticateSession({});
});
@@ -86,6 +94,17 @@ module('Acceptance | credential-stores | credentials | read', function (hooks) {
assert.strictEqual(currentURL(), urls.usernameKeyPairCredential);
});
test('visiting JSON credential', async function (assert) {
assert.expect(2);
await visit(urls.staticCredentialStore);
await click(`[href="${urls.credentials}"]`);
await a11yAudit();
assert.strictEqual(currentURL(), urls.credentials);
await click(`[href="${urls.jsonCredential}"]`);
await a11yAudit();
assert.strictEqual(currentURL(), urls.jsonCredential);
});
test('cannot navigate to a username & password credential form without proper authorization', async function (assert) {
assert.expect(1);
instances.usernamePasswordCredential.authorized_actions =
@@ -106,6 +125,24 @@ module('Acceptance | credential-stores | credentials | read', function (hooks) {
assert.dom('.rose-table-body tr:nth-child(2) a').doesNotExist();
});
test('cannot navigate to a JSON credential form without proper authorization', async function (assert) {
assert.expect(1);
instances.jsonCredential.authorized_actions =
instances.jsonCredential.authorized_actions.filter(
(item) => item != 'read'
);
await visit(urls.credentials);
assert.dom('.rose-table-body tr:nth-child(3) a').doesNotExist();
});
test('cannot navigate to a JSON credential form when feature not enabled', async function (assert) {
assert.expect(1);
const featuresService = this.owner.lookup('service:features');
featuresService.disable('json-credentials');
await visit(urls.credentials);
assert.dom('.rose-table-body tr:nth-child(3) a').doesNotExist();
});
test('visiting an unknown credential displays 404 message', async function (assert) {
assert.expect(1);
await visit(urls.unknownCredential);
@@ -1,5 +1,12 @@
import { module, test } from 'qunit';
import { visit, currentURL, find, click, fillIn } from '@ember/test-helpers';
import {
visit,
currentURL,
find,
click,
fillIn,
waitUntil,
} from '@ember/test-helpers';
import { setupApplicationTest } from 'ember-qunit';
import setupMirage from 'ember-cli-mirage/test-support/setup-mirage';
import { Response } from 'miragejs';
@@ -20,6 +27,7 @@ module(
staticCredentialStore: null,
usernamePasswordCredential: null,
usernameKeyPairCredential: null,
jsonCredential: null,
};
const urls = {
@@ -29,6 +37,7 @@ module(
credentials: null,
usernamePasswordCredential: null,
usernameKeyPairCredential: null,
jsonCredential: null,
};
hooks.beforeEach(function () {
@@ -56,6 +65,11 @@ module(
credentialStore: instances.staticCredentialStore,
type: 'ssh_private_key',
});
instances.jsonCredential = this.server.create('credential', {
scope: instances.scopes.project,
credentialStore: instances.staticCredentialStore,
type: 'json',
});
// Generate route URLs for resources
urls.projectScope = `/scopes/${instances.scopes.project.id}`;
urls.credentialStores = `${urls.projectScope}/credential-stores`;
@@ -63,6 +77,7 @@ module(
urls.credentials = `${urls.staticCredentialStore}/credentials`;
urls.usernamePasswordCredential = `${urls.credentials}/${instances.usernamePasswordCredential.id}`;
urls.usernameKeyPairCredential = `${urls.credentials}/${instances.usernameKeyPairCredential.id}`;
urls.jsonCredential = `${urls.credentials}/${instances.jsonCredential.id}`;
authenticateSession({});
});
@@ -98,6 +113,21 @@ module(
);
});
test('can save changes to existing JSON credential', async function (assert) {
assert.expect(3);
const mockInput = 'random string';
assert.notEqual(instances.jsonCredential.name, mockInput);
await visit(urls.jsonCredential);
await click('form [type="button"]', 'Activate edit mode');
await fillIn('[name="name"]', mockInput);
await click('.rose-form-actions [type="submit"]');
assert.strictEqual(currentURL(), urls.jsonCredential);
assert.strictEqual(
this.server.schema.credentials.where({ type: 'json' }).models[0].name,
mockInput
);
});
test('cannot make changes to an existing username & password credential without proper authorization', async function (assert) {
assert.expect(1);
instances.usernamePasswordCredential.authorized_actions =
@@ -122,6 +152,18 @@ module(
.doesNotExist();
});
test('cannot make changes to an existing JSON credential without proper authorization', async function (assert) {
assert.expect(1);
instances.jsonCredential.authorized_actions =
instances.jsonCredential.authorized_actions.filter(
(item) => item !== 'update'
);
await visit(urls.jsonCredential);
assert
.dom('.rose-layout-page-actions .rose-button-secondary')
.doesNotExist();
});
test('can cancel changes to existing username & password credential', async function (assert) {
assert.expect(2);
const mockInput = 'random string';
@@ -150,6 +192,20 @@ module(
);
});
test('can cancel changes to existing JSON credential', async function (assert) {
assert.expect(2);
const mockInput = 'random string';
await visit(urls.jsonCredential);
await click('form [type="button"]', 'Activate edit mode');
await fillIn('[name="name"]', mockInput);
await click('.rose-form-actions [type="button"]');
assert.notEqual(instances.jsonCredential.name, mockInput);
assert.strictEqual(
find('[name="name"]').value,
instances.jsonCredential.name
);
});
test('saving an existing username & password credential with invalid fields displays error message', async function (assert) {
assert.expect(2);
const mockInput = 'random string';
@@ -222,6 +278,42 @@ module(
);
});
test('saving an existing JSON credential with invalid fields displays error message', async function (assert) {
assert.expect(2);
const mockInput = 'random string';
this.server.patch('/credentials/:id', () => {
return new Response(
400,
{},
{
status: 400,
code: 'invalid_argument',
message: 'Error in provided request.',
details: {
request_fields: [
{
name: 'name',
description: 'Name is required.',
},
],
},
}
);
});
await visit(urls.jsonCredential);
await click('form [type="button"]', 'Activate edit mode');
await fillIn('[name="name"]', mockInput);
await click('[type="submit"]');
assert.ok(
find('[role="alert"]').textContent.trim(),
'Error in provided request.'
);
assert.ok(
find('.rose-form-error-message').textContent.trim(),
'Name is required.'
);
});
test('can discard unsaved username & password credential changes via dialog', async function (assert) {
assert.expect(5);
const mockInput = 'random string';
@@ -236,7 +328,10 @@ module(
await visit(urls.credentials);
} catch (e) {
assert.dom('.rose-dialog').isVisible();
await click('.rose-dialog-footer button:first-child', 'Click Discard');
await click(
'.rose-dialog-footer button:first-of-type',
'Click Discard'
);
assert.strictEqual(currentURL(), urls.credentials);
assert.notEqual(
this.server.schema.credentials.where({ type: 'username_password' })
@@ -260,7 +355,10 @@ module(
await visit(urls.credentials);
} catch (e) {
assert.dom('.rose-dialog').isVisible();
await click('.rose-dialog-footer button:first-child', 'Click Discard');
await click(
'.rose-dialog-footer button:first-of-type',
'Click Discard'
);
assert.strictEqual(currentURL(), urls.credentials);
assert.notEqual(
this.server.schema.credentials.where({ type: 'ssh_private_key' })
@@ -270,6 +368,32 @@ module(
}
});
test('can discard unsaved JSON credential changes via dialog', async function (assert) {
assert.expect(5);
const mockInput = 'random string';
const confirmService = this.owner.lookup('service:confirm');
confirmService.enabled = true;
assert.notEqual(instances.jsonCredential.name, mockInput);
await visit(urls.jsonCredential);
await click('form [type="button"]', 'Activate edit mode');
await fillIn('[name="name"]', mockInput);
assert.strictEqual(currentURL(), urls.jsonCredential);
try {
await visit(urls.credentials);
} catch (e) {
assert.dom('.rose-dialog').isVisible();
await click(
'.rose-dialog-footer button:first-of-type',
'Click Discard'
);
assert.strictEqual(currentURL(), urls.credentials);
assert.notEqual(
this.server.schema.credentials.where({ type: 'json' }).models[0].name,
mockInput
);
}
});
test('can cancel discard unsaved username & password credential changes via dialog', async function (assert) {
assert.expect(6);
const mockInput = 'random string';
@@ -322,6 +446,31 @@ module(
}
});
test('can cancel discard unsaved JSON credential changes via dialog', async function (assert) {
assert.expect(6);
const mockInput = 'random string';
const confirmService = this.owner.lookup('service:confirm');
confirmService.enabled = true;
assert.notEqual(instances.jsonCredential.name, mockInput);
await visit(urls.jsonCredential);
await click('form [type="button"]', 'Activate edit mode');
const credentialName = find('[name="name"]').value;
await fillIn('[name="name"]', mockInput);
assert.strictEqual(currentURL(), urls.jsonCredential);
try {
await visit(urls.credentials);
} catch (e) {
assert.dom('.rose-dialog').isVisible();
await click('.rose-dialog-footer button:last-child', 'Click Cancel');
assert.strictEqual(currentURL(), urls.jsonCredential);
assert.strictEqual(find('[name="name"]').value, mockInput);
assert.strictEqual(
this.server.schema.credentials.where({ type: 'json' }).models[0].name,
credentialName
);
}
});
test('password field renders in edit mode only for a username & password credential', async function (assert) {
assert.expect(3);
await visit(urls.usernamePasswordCredential);
@@ -341,5 +490,28 @@ module(
assert.dom('[name="private_key"]').isVisible();
assert.dom('[name="private_key_passphrase"]').isVisible();
});
test('secret editor is in actionable state when entering edit mode of a JSON credential', async function (assert) {
assert.expect(3);
await visit(urls.jsonCredential);
assert.dom('.secret-editor-skeleton-message button').doesNotExist();
await click('form [type="button"]', 'Activate edit mode');
assert.strictEqual(currentURL(), urls.jsonCredential);
assert.dom('.secret-editor-skeleton-message button').isVisible();
});
test('secret editor enters editing state when clicking edit button in the secret editor of a JSON credential', async function (assert) {
assert.expect(4);
await visit(urls.jsonCredential);
await click('form [type="button"]', 'Activate edit mode');
assert.strictEqual(currentURL(), urls.jsonCredential);
assert.dom('.secret-editor-skeleton-message button').isVisible();
await click(
'.secret-editor-skeleton-message button',
'Enter editing mode'
);
assert.dom('.secret-editor-skeleton-message button').doesNotExist();
await waitUntil(() => assert.dom('.CodeMirror').isVisible());
});
}
);
@@ -32,7 +32,8 @@ module('Acceptance | host-catalogs | create', function (hooks) {
hostCatalog: null,
newHostCatalog: null,
newStaticHostCatalog: null,
newDynamicHostCatalog: null,
newAWSDynamicHostCatalog: null,
newAzureDynamicHostCatalog: null,
};
hooks.beforeEach(function () {
@@ -67,7 +68,8 @@ module('Acceptance | host-catalogs | create', function (hooks) {
urls.hostCatalog = `${urls.hostCatalogs}/${instances.hostCatalog.id}`;
urls.newHostCatalog = `${urls.hostCatalogs}/new`;
urls.newStaticHostCatalog = `${urls.newHostCatalog}?type=static`;
urls.newDynamicHostCatalog = `${urls.newHostCatalog}?type=aws`;
urls.newAWSDynamicHostCatalog = `${urls.newHostCatalog}?type=aws`;
urls.newAzureDynamicHostCatalog = `${urls.newHostCatalog}?type=azure`;
// Generate resource couner
gethostCatalogCount = () =>
this.server.schema.hostCatalogs.all().models.length;
@@ -81,18 +83,29 @@ module('Acceptance | host-catalogs | create', function (hooks) {
await visit(urls.newStaticHostCatalog);
await fillIn('[name="name"]', 'random string');
await fillIn('[name="description"]', 'random string');
await fillIn('[name="types"]', 'static');
await fillIn('[name="type"]', 'static');
await click('[type="submit"]');
assert.strictEqual(gethostCatalogCount(), count + 1);
});
test('Users can create new dynamic host catalogs', async function (assert) {
test('Users can create new dynamic aws host catalogs with aws provider', async function (assert) {
assert.expect(1);
const count = gethostCatalogCount();
await visit(urls.newDynamicHostCatalog);
await visit(urls.newAWSDynamicHostCatalog);
await fillIn('[name="name"]', 'random string');
await fillIn('[name="description"]', 'random string');
await fillIn('[name="types"]', 'aws');
await fillIn('[name="type"]', 'aws');
await click('[type="submit"]');
assert.strictEqual(gethostCatalogCount(), count + 1);
});
test('Users can create new dynamic aws host catalogs with azure provider', async function (assert) {
assert.expect(1);
const count = gethostCatalogCount();
await visit(urls.newAzureDynamicHostCatalog);
await fillIn('[name="name"]', 'random string');
await fillIn('[name="description"]', 'random string');
await fillIn('[name="type"]', 'azure');
await click('[type="submit"]');
assert.strictEqual(gethostCatalogCount(), count + 1);
});
@@ -107,10 +120,20 @@ module('Acceptance | host-catalogs | create', function (hooks) {
assert.strictEqual(gethostCatalogCount(), count);
});
test('Users can cancel creation of new dynamic host catalogs', async function (assert) {
test('Users can cancel creation of new dynamic host catalogs with AWS provider', async function (assert) {
assert.expect(2);
const count = gethostCatalogCount();
await visit(urls.newDynamicHostCatalog);
await visit(urls.newAWSDynamicHostCatalog);
await fillIn('[name="name"]', 'random string');
await click('.rose-form-actions [type="button"]');
assert.strictEqual(currentURL(), urls.hostCatalogs);
assert.strictEqual(gethostCatalogCount(), count);
});
test('Users can cancel creation of new dynamic host catalogs with Azure provider', async function (assert) {
assert.expect(2);
const count = gethostCatalogCount();
await visit(urls.newAzureDynamicHostCatalog);
await fillIn('[name="name"]', 'random string');
await click('.rose-form-actions [type="button"]');
assert.strictEqual(currentURL(), urls.hostCatalogs);
@@ -97,9 +97,9 @@ module('Acceptance | host-catalogs | host sets | create', function (hooks) {
const name = 'aws host set';
await fillIn('[name="name"]', name);
await fillIn('[name="preferred_endpoints"]', 'endpoint');
await click('form fieldset:nth-of-type(2) [title="Add"]');
await click('form fieldset:nth-of-type(1) [title="Add"]');
await fillIn('[name="filters"]', 'filter');
await click('form fieldset:nth-of-type(3) [title="Add"]');
await click('form fieldset:nth-of-type(2) [title="Add"]');
await fillIn('[name="sync_interval_seconds"]', 10);
await click('[type="submit"]');
@@ -128,7 +128,7 @@ module('Acceptance | host-catalogs | host sets | create', function (hooks) {
const name = 'azure host set';
await fillIn('[name="name"]', name);
await fillIn('[name="preferred_endpoints"]', 'endpoint');
await click('form fieldset:nth-of-type(2) [title="Add"]');
await click('form fieldset:nth-of-type(1) [title="Add"]');
await fillIn('[name="filter"]', 'filter');
await fillIn('[name="sync_interval_seconds"]', 10);
await click('[type="submit"]');
@@ -147,20 +147,20 @@ module('Acceptance | host-catalogs | host sets | update', function (hooks) {
await fillIn('[name="name"]', name);
// Remove all the preferred endpoints
await Promise.all(
findAll('form fieldset:nth-of-type(2) [title="Remove"]').map((element) =>
findAll('form fieldset:nth-of-type(1) [title="Remove"]').map((element) =>
click(element)
)
);
await fillIn('[name="preferred_endpoints"]', 'endpoint');
await click('form fieldset:nth-of-type(2) [title="Add"]');
await click('form fieldset:nth-of-type(1) [title="Add"]');
// Remove all the filters
await Promise.all(
findAll('form fieldset:nth-of-type(3) [title="Remove"]').map((element) =>
findAll('form fieldset:nth-of-type(2) [title="Remove"]').map((element) =>
click(element)
)
);
await fillIn('[name="filters"]', 'filter');
await click('form fieldset:nth-of-type(3) [title="Add"]');
await click('form fieldset:nth-of-type(2) [title="Add"]');
await fillIn('[name="sync_interval_seconds"]', 10);
await click('.rose-form-actions [type="submit"]');
@@ -198,12 +198,12 @@ module('Acceptance | host-catalogs | host sets | update', function (hooks) {
await fillIn('[name="name"]', name);
// Remove all the preferred endpoints
await Promise.all(
findAll('form fieldset:nth-of-type(2) [title="Remove"]').map((element) =>
findAll('form fieldset:nth-of-type(1) [title="Remove"]').map((element) =>
click(element)
)
);
await fillIn('[name="preferred_endpoints"]', 'endpoint');
await click('form fieldset:nth-of-type(2) [title="Add"]');
await click('form fieldset:nth-of-type(1) [title="Add"]');
await fillIn('[name="filter"]', 'filter');
await fillIn('[name="sync_interval_seconds"]', 10);
await click('.rose-form-actions [type="submit"]');
@@ -21,15 +21,15 @@ module('Acceptance | onboarding | choose-path', function (hooks) {
test('defaults to the guided path', async function (assert) {
assert.expect(2);
await visit(urls.choosePath);
assert.strictEqual(find('input[name=paths]:checked').value, 'guided');
assert.strictEqual(find('input[name=path]:checked').value, 'guided');
assert.false(find('input[value=manual]').checked);
});
test('updates to the manual path when manual card clicked', async function (assert) {
assert.expect(2);
await visit(urls.choosePath);
await click('[name="paths"][value="manual"]');
assert.strictEqual(find('input[name=paths]:checked').value, 'manual');
await click('[name="path"][value="manual"]');
assert.strictEqual(find('input[name=path]:checked').value, 'manual');
assert.false(find('input[value=guided]').checked);
});
@@ -43,7 +43,7 @@ module('Acceptance | onboarding | choose-path', function (hooks) {
test('guided path directs user to /global/new route', async function (assert) {
assert.expect(1);
await visit(urls.choosePath);
await click('[name="paths"][value="manual"]');
await click('[name="path"][value="manual"]');
await click('[type="submit"]');
assert.strictEqual(currentURL(), urls.newOrg);
});
@@ -126,6 +126,6 @@ module('Acceptance | roles | create', function (hooks) {
await click('form [type="submit"]');
await a11yAudit();
assert.ok(find('[role="alert"]'));
assert.ok(find('.rose-form-error-message'));
assert.ok(find('.hds-form-error__message'));
});
});
@@ -46,7 +46,8 @@ module('Acceptance | roles | principals', function (hooks) {
);
principalsCount =
this.server.db.roles[0].userIds.length +
this.server.db.roles[0].groupIds.length;
this.server.db.roles[0].groupIds.length +
this.server.db.roles[0].managedGroupIds.length;
urls.roles = `/scopes/${instances.scopes.org.id}/roles`;
urls.role = `${urls.roles}/${instances.role.id}`;
urls.rolePrincipals = `${urls.role}/principals`;
@@ -111,7 +112,7 @@ module('Acceptance | roles | principals', function (hooks) {
test('select and save principals to add', async function (assert) {
assert.expect(3);
instances.role.update({ userIds: [], groupIds: [] });
instances.role.update({ userIds: [], groupIds: [], managedGroupIds: [] });
await visit(urls.rolePrincipals);
assert.strictEqual(findAll('tbody tr').length, 0);
await click('.rose-layout-page-actions a');
@@ -154,7 +155,7 @@ module('Acceptance | roles | principals', function (hooks) {
}
);
});
instances.role.update({ userIds: [], groupIds: [] });
instances.role.update({ userIds: [], groupIds: [], managedGroupIds: [] });
await visit(urls.addPrincipals);
await click('tbody label');
await click('form [type="submit"]');
@@ -113,7 +113,7 @@ module('Acceptance | roles | update', function (hooks) {
'Displays primary error message.'
);
assert.strictEqual(
find('.rose-form-error-message').textContent.trim(),
find('.hds-form-error__message').textContent.trim(),
'Name is required.',
'Displays field-level errors.'
);
@@ -15,6 +15,7 @@ module('Acceptance | targets | brokered credential sources', function (hooks) {
let credentialSourceCount;
let randomlySelectedCredentialLibraries;
let randomlySelectedCredentials;
let featuresService;
const instances = {
scopes: {
@@ -37,11 +38,14 @@ module('Acceptance | targets | brokered credential sources', function (hooks) {
target: null,
credentialLibraries: null,
credentialLibrary: null,
credential: null,
jsonCredential: null,
addBrokeredCredentialSources: null,
brokeredCredentialSources: null,
};
hooks.beforeEach(function () {
featuresService = this.owner.lookup('service:features');
// Generate resources
instances.scopes.global = this.server.create('scope', { id: 'global' });
instances.scopes.org = this.server.create('scope', {
@@ -60,7 +64,7 @@ module('Acceptance | targets | brokered credential sources', function (hooks) {
type: 'static',
scope: instances.scopes.project,
});
instances.credentials = this.server.createList('credential', 2, {
instances.credentials = this.server.createList('credential', 3, {
scope: instances.scopes.project,
credentialStore: instances.staticCredentialStore,
});
@@ -100,6 +104,7 @@ module('Acceptance | targets | brokered credential sources', function (hooks) {
urls.brokeredCredentialSources = `${urls.target}/brokered-credential-sources`;
urls.credentialLibrary = `${urls.projectScope}/credential-stores/${instances.credentialLibrary.credentialStoreId}/credential-libraries/${instances.credentialLibrary.id}`;
urls.credential = `${urls.projectScope}/credential-stores/${instances.credential.credentialStoreId}/credentials/${instances.credential.id}`;
urls.jsonCredential = `${urls.projectScope}/credential-stores/${instances.credentials[2].credentialStoreId}/credentials/${instances.credentials[2].id}`;
urls.addBrokeredCredentialSources = `${urls.target}/add-brokered-credential-sources`;
getCredentialLibraryCount = () =>
this.server.schema.credentialLibraries.all().models.length;
@@ -137,6 +142,21 @@ module('Acceptance | targets | brokered credential sources', function (hooks) {
assert.strictEqual(currentURL(), urls.credential);
});
test('cannot navigate to a json type credential when feature is disabled', async function (assert) {
featuresService.disable('json-credentials');
assert.expect(2);
const jsonCredential = instances.credentials[2];
instances.target.update({
brokeredCredentialSourceIds: [...randomlySelectedCredentials],
});
await visit(urls.brokeredCredentialSources);
assert
.dom('.rose-table-row:nth-child(3)')
.includesText(jsonCredential.name);
assert.dom(`[href="${urls.jsonCredential}"]`).doesNotExist();
});
test('visiting add brokered credential sources', async function (assert) {
assert.expect(1);
await visit(urls.addBrokeredCredentialSources);
@@ -157,7 +177,7 @@ module('Acceptance | targets | brokered credential sources', function (hooks) {
test('displays list of brokered credential sources with only credential libraries available', async function (assert) {
assert.expect(2);
instances.target.update({
brokeredCredentialSourceIds: [...randomlySelectedCredentialLibraries],
brokeredCredentialSourceIds: [...randomlySelectedCredentials],
});
await visit(urls.addBrokeredCredentialSources);
assert.strictEqual(findAll('tbody tr').length, getCredentialLibraryCount());
@@ -300,12 +320,16 @@ module('Acceptance | targets | brokered credential sources', function (hooks) {
brokeredCredentialSourceIds: [...randomlySelectedCredentialLibraries],
});
const credentialLibraryCount = getCredentialLibraryCount();
const availableCredentialsCount = getCredentialCount();
await visit(urls.brokeredCredentialSources);
assert.strictEqual(findAll('tbody tr').length, credentialLibraryCount);
await click('tbody tr .rose-dropdown-button-danger');
assert.strictEqual(findAll('tbody tr').length, credentialLibraryCount - 1);
await visit(urls.addBrokeredCredentialSources);
assert.strictEqual(findAll('tbody tr').length, credentialLibraryCount + 1);
assert.strictEqual(
findAll('tbody tr').length,
availableCredentialsCount + 1
);
});
test('can remove a username & password type credential', async function (assert) {
@@ -314,12 +338,16 @@ module('Acceptance | targets | brokered credential sources', function (hooks) {
brokeredCredentialSourceIds: [...randomlySelectedCredentials],
});
const credentialCount = getCredentialCount();
const availableCredentialsCount = getCredentialLibraryCount();
await visit(urls.brokeredCredentialSources);
assert.strictEqual(findAll('tbody tr').length, credentialCount);
await click('tbody tr .rose-dropdown-button-danger');
assert.strictEqual(findAll('tbody tr').length, credentialCount - 1);
await visit(urls.addBrokeredCredentialSources);
assert.strictEqual(findAll('tbody tr').length, credentialCount + 1);
assert.strictEqual(
findAll('tbody tr').length,
availableCredentialsCount + 1
);
});
test('cannot remove credential libraries without proper authorization', async function (assert) {
@@ -16,6 +16,7 @@ import {
//currentSession,
//invalidateSession,
} from 'ember-simple-auth/test-support';
import { TYPE_TARGET_TCP, TYPE_TARGET_SSH } from 'api/models/target';
module('Acceptance | targets | create', function (hooks) {
setupApplicationTest(hooks);
@@ -71,31 +72,32 @@ module('Acceptance | targets | create', function (hooks) {
// Generate resource counter
getTargetCount = () => this.server.schema.targets.all().models.length;
getSSHTargetCount = () =>
this.server.schema.targets.where({ type: 'ssh' }).models.length;
this.server.schema.targets.where({ type: TYPE_TARGET_SSH }).models.length;
getTCPTargetCount = () =>
this.server.schema.targets.where({ type: 'tcp' }).models.length;
this.server.schema.targets.where({ type: TYPE_TARGET_TCP }).models.length;
authenticateSession({});
});
test('defaults to a new TCP target when no query param provided', async function (assert) {
test('defaults to type `ssh` when no query param provided', async function (assert) {
assert.expect(1);
await visit(urls.newTarget);
assert.strictEqual(find('input[name=tcp]:checked').value, 'tcp');
assert.strictEqual(find('[name="type"]:checked').value, TYPE_TARGET_SSH);
});
test('can create new targets of type TCP', async function (assert) {
test('can create type `ssh`', async function (assert) {
assert.expect(4);
const targetCount = getTargetCount();
const tcpTargetCount = getTCPTargetCount();
const sshTargetCount = getSSHTargetCount();
await visit(urls.targets);
await click(`[href="${urls.newTarget}"]`);
await click('[value="ssh"]');
await fillIn('[name="name"]', 'random string');
await fillIn('[name="worker_filter"]', 'random filter');
await click('[type="submit"]');
assert.strictEqual(getSSHTargetCount(), sshTargetCount + 1);
assert.strictEqual(getTargetCount(), targetCount + 1);
assert.strictEqual(getTCPTargetCount(), tcpTargetCount + 1);
assert.strictEqual(
this.server.schema.targets.all().models[getTargetCount() - 1].name,
'random string'
@@ -107,20 +109,20 @@ module('Acceptance | targets | create', function (hooks) {
);
});
test('can create new targets of type SSH', async function (assert) {
test('can create type `tcp`', async function (assert) {
assert.expect(4);
const targetCount = getTargetCount();
const sshTargetCount = getSSHTargetCount();
const tcpTargetCount = getTCPTargetCount();
await visit(urls.targets);
await click(`[href="${urls.newTarget}"]`);
await click('[value="tcp"]');
await fillIn('[name="name"]', 'random string');
await click('[value="ssh"]');
await fillIn('[name="worker_filter"]', 'random filter');
await click('[type="submit"]');
assert.strictEqual(getSSHTargetCount(), sshTargetCount + 1);
assert.strictEqual(getTargetCount(), targetCount + 1);
assert.strictEqual(getTCPTargetCount(), tcpTargetCount + 1);
assert.strictEqual(
this.server.schema.targets.all().models[getTargetCount() - 1].name,
'random string'
@@ -5,6 +5,7 @@ import setupMirage from 'ember-cli-mirage/test-support/setup-mirage';
import { Response } from 'miragejs';
import a11yAudit from 'ember-a11y-testing/test-support/audit';
import { authenticateSession } from 'ember-simple-auth/test-support';
import { TYPE_TARGET_SSH } from 'api/models/target';
module(
'Acceptance | targets | injected application credential sources',
@@ -78,7 +79,7 @@ module(
instances.credential = instances.credentials[0];
instances.target = this.server.create('target', {
scope: instances.scopes.project,
type: 'ssh',
type: TYPE_TARGET_SSH,
});
randomlySelectedCredentials = this.server.schema.credentials
.all()
@@ -9,6 +9,7 @@ import {
//currentSession,
//invalidateSession,
} from 'ember-simple-auth/test-support';
import { TYPE_TARGET_TCP, TYPE_TARGET_SSH } from 'api/models/target';
module('Acceptance | targets | read', function (hooks) {
setupApplicationTest(hooks);
@@ -44,11 +45,11 @@ module('Acceptance | targets | read', function (hooks) {
scope: { id: instances.scopes.org.id, type: 'org' },
});
instances.sshTarget = this.server.create('target', {
type: 'ssh',
type: TYPE_TARGET_SSH,
scope: instances.scopes.project,
});
instances.tcpTarget = this.server.create('target', {
type: 'tcp',
type: TYPE_TARGET_TCP,
scope: instances.scopes.project,
});
// Generate route URLs for resources
@@ -60,6 +60,26 @@ module('Acceptance | workers | create', function (hooks) {
assert.dom(labels[2]).hasText('Initial Upstreams');
});
test('download and install step shows correct oss instructions', async function (assert) {
assert.expect(2);
const featuresService = this.owner.lookup('service:features');
featuresService.disable('byow-pki-hcp-cluster-id');
await visit(newWorkerURL);
const createSection = findAll('.worker-create-section');
assert.dom(createSection[1]).includesText('curl -fsSL');
assert.dom(createSection[1]).doesNotIncludeText('wget -q');
});
test('download and install step shows correct hcp instructions', async function (assert) {
assert.expect(2);
const featuresService = this.owner.lookup('service:features');
featuresService.enable('byow-pki-hcp-cluster-id');
await visit(newWorkerURL);
const createSection = findAll('.worker-create-section');
assert.dom(createSection[1]).includesText('wget -q');
assert.dom(createSection[1]).doesNotIncludeText('curl -fsSL');
});
test('Users can navigate to new workers route with proper authorization', async function (assert) {
assert.expect(2);
await visit(newWorkerURL);
@@ -3,18 +3,21 @@ import { setupRenderingTest } from 'ember-qunit';
import { render } from '@ember/test-helpers';
import { hbs } from 'ember-cli-htmlbars';
module('Integration | Component | form/field/json-secret/skeleton', function (hooks) {
setupRenderingTest(hooks);
module(
'Integration | Component | form/field/json-secret/skeleton',
function (hooks) {
setupRenderingTest(hooks);
test('it renders', async function (assert) {
assert.expect(2);
await render(hbs`
test('it renders', async function (assert) {
assert.expect(2);
await render(hbs`
<Form::Field::JsonSecret::Skeleton>
Skeleton Message
</Form::Field::JsonSecret::Skeleton>
`);
assert.dom('.secret-editor-skeleton-wrapper').isVisible();
assert.dom('.secret-editor-skeleton-message').hasText('Skeleton Message');
});
});
assert.dom('.secret-editor-skeleton-wrapper').isVisible();
assert.dom('.secret-editor-skeleton-message').hasText('Skeleton Message');
});
}
);
@@ -26,7 +26,9 @@ module('Integration | Component | form/field/json-secret', function (hooks) {
assert.dom('.secret-editor').isVisible();
assert.dom('.secret-editor-json').isVisible();
assert.dom('.secret-editor-json .CodeMirror-line').hasText(this.model.json_object);
assert
.dom('.secret-editor-json .CodeMirror-line')
.hasText(this.model.json_object);
});
test('it renders the disabled editor', async function (assert) {
@@ -42,7 +44,9 @@ module('Integration | Component | form/field/json-secret', function (hooks) {
assert.dom('.secret-editor').isVisible();
assert.dom('.secret-editor-json').doesNotExist();
assert.dom('.secret-editor-skeleton-wrapper').hasText('This secret is saved but won\'t be displayed');
assert
.dom('.secret-editor-skeleton-wrapper')
.hasText("This secret is saved but won't be displayed");
});
test('it renders the actionable editor', async function (assert) {
@@ -58,7 +62,9 @@ module('Integration | Component | form/field/json-secret', function (hooks) {
assert.dom('.secret-editor').isVisible();
assert.dom('.secret-editor-json').doesNotExist();
assert.dom('.secret-editor-skeleton-wrapper').hasText('Click to replace this secret');
assert
.dom('.secret-editor-skeleton-wrapper')
.hasText('Click to replace this secret');
});
test('it renders the active editor from the actionable view', async function (assert) {
@@ -74,9 +80,11 @@ module('Integration | Component | form/field/json-secret', function (hooks) {
assert.dom('.secret-editor').isVisible();
assert.dom('.secret-editor-json').doesNotExist();
assert.dom('.secret-editor-skeleton-wrapper').hasText('Click to replace this secret');
assert
.dom('.secret-editor-skeleton-wrapper')
.hasText('Click to replace this secret');
await click('button')
await click('button');
assert.dom('.secret-editor-json').isVisible();
});
});
});
@@ -11,15 +11,15 @@ module('Integration | Component | info-field', function (hooks) {
await render(
hbs`
<InfoField @value="Description" as |F|>
<InfoField @value="JSON" as |F|>
<F.Label>Type</F.Label>
<F.HelperText>JSON</F.HelperText>
<F.HelperText>Description</F.HelperText>
</InfoField>
`
);
assert.dom('.info-field').isVisible();
assert.dom('.info-field .hds-form-label').hasText('Type');
assert.dom('.info-field .hds-form-helper-text').hasText('JSON');
assert.dom('.info-field .hds-form-helper-text').hasText('Description');
assert.dom('.info-field .hds-form-text-input').isVisible();
});
@@ -28,9 +28,9 @@ module('Integration | Component | info-field', function (hooks) {
await render(
hbs`
<InfoField @value="Description" @icon='apple' as |F|>
<InfoField @value="JSON" @icon='apple' as |F|>
<F.Label>Type</F.Label>
<F.HelperText>JSON</F.HelperText>
<F.HelperText>Description</F.HelperText>
</InfoField>
`
);
@@ -38,7 +38,7 @@ module('Integration | Component | info-field', function (hooks) {
assert.dom('.info-field .hds-form-label').hasText('Type');
assert
.dom('.info-field .hds-form-helper-text:last-of-type')
.hasText('JSON');
.hasText('Description');
assert.dom('[data-test-icon="apple"]').isVisible();
assert.dom('.info-field .hds-form-text-input').isVisible();
});
@@ -0,0 +1,77 @@
import { module, test } from 'qunit';
import { setupTest } from 'ember-qunit';
module('Unit | Abilities | credential', function (hooks) {
setupTest(hooks);
let features;
hooks.beforeEach(function () {
// Enable json-credentials feature by default
features = this.owner.lookup('service:features');
features.enable('json-credentials');
});
test('can read credentials, including JSON credentials, when authorized and json-credentials feature is enabled', function (assert) {
assert.expect(3);
const service = this.owner.lookup('service:can');
const store = this.owner.lookup('service:store');
const credential = store.createRecord('credential', {
authorized_actions: ['read'],
type: 'json',
});
assert.true(service.can('read credential', credential));
credential.type = 'username_password';
assert.true(service.can('read credential', credential));
credential.type = 'ssh_private_key';
assert.true(service.can('read credential', credential));
});
test('cannot read credentials, including JSON credentials, when unauthorized and json-credentials feature is enabled', function (assert) {
assert.expect(3);
const service = this.owner.lookup('service:can');
const store = this.owner.lookup('service:store');
const credential = store.createRecord('credential', {
authorized_actions: [],
type: 'json',
});
assert.false(service.can('read credential', credential));
credential.type = 'username_password';
assert.false(service.can('read credential', credential));
credential.type = 'ssh_private_key';
assert.false(service.can('read credential', credential));
});
test('cannot read credentials, including JSON credentials, when unauthorized and json-credentials feature is disabled', function (assert) {
assert.expect(3);
const service = this.owner.lookup('service:can');
const featuresService = this.owner.lookup('service:features');
featuresService.disable('json-credentials');
const store = this.owner.lookup('service:store');
const credential = store.createRecord('credential', {
authorized_actions: [],
type: 'json',
});
assert.false(service.can('read credential', credential));
credential.type = 'username_password';
assert.false(service.can('read credential', credential));
credential.type = 'ssh_private_key';
assert.false(service.can('read credential', credential));
});
test('can read credentials, excepting JSON credentials, when authorized and json-credentials feature is disabled', function (assert) {
assert.expect(3);
features.disable('json-credentials');
const service = this.owner.lookup('service:can');
const store = this.owner.lookup('service:store');
const credential = store.createRecord('credential', {
authorized_actions: ['read'],
type: 'json',
});
assert.false(service.can('read credential', credential));
credential.type = 'username_password';
assert.true(service.can('read credential', credential));
credential.type = 'ssh_private_key';
assert.true(service.can('read credential', credential));
});
});
@@ -30,7 +30,7 @@
{{#if host.isStatic}}
{{host.address}}
{{else}}
<Hds::Badge @text={{t 'resources.host-catalog.types.dynamic'}} />
<Hds::Badge @text={{t 'resources.host-catalog.types.plugin'}} />
{{/if}}
</row.cell>
<row.cell>
+3 -3
View File
@@ -3262,9 +3262,9 @@ punycode@^2.1.0, punycode@^2.1.1:
integrity sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==
qs@~6.5.2:
version "6.5.2"
resolved "https://registry.yarnpkg.com/qs/-/qs-6.5.2.tgz#cb3ae806e8740444584ef154ce8ee98d403f3e36"
integrity sha512-N5ZAX4/LxJmF+7wN74pUD6qAh9/wnvdQcjq9TZjevvXzSUo7bfmw91saqMjzGS2xq91/odN2dW/WOl7qQHNDGA==
version "6.5.3"
resolved "https://registry.yarnpkg.com/qs/-/qs-6.5.3.tgz#3aeeffc91967ef6e35c0e488ef46fb296ab76aad"
integrity sha512-qxXIEh4pCGfHICj1mAJQ2/2XVZkjCDTcEgfoSQxc/fYivUZxTkk7L3bDBJSoNrEzXI17oUO5Dp07ktqE5KzczA==
queue-microtask@^1.2.2:
version "1.2.3"
@@ -10,6 +10,7 @@ import {
authenticateSession,
invalidateSession,
} from 'ember-simple-auth/test-support';
import { TYPE_TARGET_SSH } from 'api/models/target';
module('Acceptance | projects | targets', function (hooks) {
setupApplicationTest(hooks);
@@ -247,7 +248,7 @@ module('Acceptance | projects | targets', function (hooks) {
test('displays the correct target type (SSH) in the success dialog body', async function (assert) {
assert.expect(1);
instances.target.update({
type: 'ssh',
type: TYPE_TARGET_SSH,
});
stubs.ipcService.withArgs('cliExists').returns(true);
stubs.ipcService.withArgs('connect').returns({
+126 -644
View File
File diff suppressed because it is too large. Load diff