diff --git a/addons/api/addon/models/user.js b/addons/api/addon/models/user.js
index 51053788f..c2be5cfb0 100644
--- a/addons/api/addon/models/user.js
+++ b/addons/api/addon/models/user.js
@@ -1,3 +1,82 @@
import GeneratedUserModel from '../generated/models/user';
+import { attr } from '@ember-data/model';
+import { A } from '@ember/array';
-export default class UserModel extends GeneratedUserModel {}
+export default class UserModel extends GeneratedUserModel {
+
+ // =attributes
+
+ /**
+ * Accounts is read-only under normal circumstances. But accounts can
+ * be persisted via calls to `addAccounts()` or `removeAccounts()`.
+ */
+ @attr({
+ readOnly: true,
+ defaultValue: () => A(),
+ emptyArrayIfMissing: true,
+ })
+ account_ids;
+
+ // =methods
+
+ /**
+ * Adds accounts via the `add-accounts` method.
+ * See serializer and adapter for more information.
+ * @param {[string]} accountIDs
+ * @param {object} options
+ * @param {object} options.adapterOptions
+ * @return {Promise}
+ */
+ addAccounts(accountIDs, options = { adapterOptions: {} }) {
+ const defaultAdapterOptions = {
+ method: 'add-accounts',
+ accountIDs,
+ };
+ // There is no "deep merge" in ES.
+ // All of this nonsense is here to ensure we get
+ // a decent merge of `adapterOptions`.
+ return this.save({
+ ...options,
+ adapterOptions: {
+ ...defaultAdapterOptions,
+ ...options.adapterOptions,
+ },
+ });
+ }
+
+ /**
+ * Remove accounts via the `remove-accounts` method.
+ * See serializer and adapter for more information.
+ * @param {[string]} accountIDs
+ * @param {object} options
+ * @param {object} options.adapterOptions
+ * @return {Promise}
+ */
+ removeAccounts(accountIDs, options = { adapterOptions: {} }) {
+ const defaultAdapterOptions = {
+ method: 'remove-accounts',
+ accountIDs,
+ };
+ // There is no "deep merge" in ES.
+ // All of this nonsense is here to ensure we get
+ // a decent merge of `adapterOptions`.
+ return this.save({
+ ...options,
+ adapterOptions: {
+ ...defaultAdapterOptions,
+ ...options.adapterOptions,
+ },
+ });
+ }
+
+ /**
+ * Remove a single account via the `remove-accounts` method.
+ * @param {number} accountID
+ * @param {object} options
+ * @return {Promise}
+ */
+ removeAccount(accountID, options) {
+ return this.removeAccounts([accountID], options);
+ }
+
+}
diff --git a/addons/api/addon/serializers/user.js b/addons/api/addon/serializers/user.js
new file mode 100644
index 000000000..9c4a47194
--- /dev/null
+++ b/addons/api/addon/serializers/user.js
@@ -0,0 +1,33 @@
+import ApplicationSerializer from './application';
+
+export default class UserSerializer extends ApplicationSerializer {
+ // =methods
+
+ /**
+ * If `adapterOptions.accountIDs` is set (to an array of user IDs),
+ * then the payload is serialized via `serializewithAccounts`.
+ * @override
+ * @param {Snapshot} snapshot
+ * @return {object}
+ */
+ serialize(snapshot) {
+ let serialized = super.serialize(...arguments);
+ const accountIDs = snapshot?.adapterOptions?.accountIDs;
+ if (accountIDs) serialized =
+ this.serializewithAccounts(snapshot, accountIDs);
+ return serialized;
+ }
+
+ /**
+ * Returns a payload containing only the `account_ids` array (and version).
+ * @param {Snapshot} snapshot
+ * @param {[string]} account_ids
+ * @return {object}
+ */
+ serializewithAccounts(snapshot, account_ids) {
+ return {
+ version: snapshot.attr('version'),
+ account_ids,
+ };
+ }
+}
diff --git a/addons/api/app/serializers/user.js b/addons/api/app/serializers/user.js
new file mode 100644
index 000000000..5c5a92643
--- /dev/null
+++ b/addons/api/app/serializers/user.js
@@ -0,0 +1 @@
+export { default } from 'api/serializers/user';
diff --git a/addons/api/tests/unit/models/user-test.js b/addons/api/tests/unit/models/user-test.js
index df69a6501..75a8f0916 100644
--- a/addons/api/tests/unit/models/user-test.js
+++ b/addons/api/tests/unit/models/user-test.js
@@ -1,12 +1,115 @@
import { module, test } from 'qunit';
import { setupTest } from 'ember-qunit';
+import setupMirage from 'ember-cli-mirage/test-support/setup-mirage';
module('Unit | Model | user', function (hooks) {
setupTest(hooks);
+ setupMirage(hooks);
- test('it exists', function (assert) {
- let store = this.owner.lookup('service:store');
- let model = store.createRecord('user', {});
- assert.ok(model);
+ test('it has an `addAccounts` method that users a specific POST API endpoint and serialization', async function (assert) {
+ assert.expect(1);
+ this.server.post('/v1/users/123abc:add-accounts', (schema, request) => {
+ const body = JSON.parse(request.requestBody);
+ assert.deepEqual(body, {
+ account_ids: ['123_abc', 'foobar'],
+ version: 1,
+ });
+ });
+ const store = this.owner.lookup('service:store');
+ store.push({
+ data: {
+ id: '123abc',
+ type: 'user',
+ attributes: {
+ name: 'User',
+ description: 'Description',
+ account_ids: ['1', '2'],
+ version: 1,
+ scope: {
+ scope_id: 'o_1',
+ type: 'scope',
+ },
+ },
+ },
+ });
+ const model = store.peekRecord('user', '123abc');
+ await model.addAccounts(['123_abc', 'foobar']);
+ });
+
+ test('it has a `removeAccounts` method that users a specific POST API endpoint and serialization', async function (assert) {
+ assert.expect(1);
+ this.server.post('/v1/users/123abc:remove-accounts', (schema, request) => {
+ const body = JSON.parse(request.requestBody);
+ assert.deepEqual(body, {
+ account_ids: ['1', '3'],
+ version: 1,
+ });
+ });
+ const store = this.owner.lookup('service:store');
+ store.push({
+ data: {
+ id: '123abc',
+ type: 'user',
+ attributes: {
+ name: 'user',
+ description: 'Description',
+ account_ids: ['4', '5'],
+ version: 1,
+ scope: {
+ scope_id: 'o_1',
+ type: 'scope',
+ },
+ },
+ },
+ });
+ const model = store.peekRecord('user', '123abc');
+ await model.removeAccounts(['1', '3']);
+ });
+
+ test('it has a `removeAccount` method that deletes a single account set using `removeAccounts` method', async function (assert) {
+ assert.expect(1);
+ this.server.post('/v1/users/123abc:remove-accounts', (schema, request) => {
+ const body = JSON.parse(request.requestBody);
+ assert.deepEqual(body, {
+ account_ids: ['3'],
+ version: 1,
+ });
+ });
+ const store = this.owner.lookup('service:store');
+ store.push({
+ data: {
+ id: '123abc',
+ type: 'user',
+ attributes: {
+ name: 'user',
+ description: 'Description',
+ account_ids: ['1', '3'],
+ version: 1,
+ scope: {
+ scope_id: 'o_1',
+ type: 'scope',
+ },
+ },
+ },
+ });
+ const model = store.peekRecord('user', '123abc');
+ await model.removeAccount('3');
+ });
+
+ test('it defaults `accounts_ids` to an empty array when model instance does not define it', function (assert) {
+ assert.expect(1);
+ const store = this.owner.lookup('service:store');
+ store.push({
+ data: {
+ id: 'user_123',
+ type: 'user',
+ },
+ });
+ const user = store.peekRecord('user', 'user_123');
+ assert.equal(
+ user.account_ids.length,
+ 0,
+ 'User has empty account_ids by default'
+ );
});
});
diff --git a/addons/api/tests/unit/serializers/application-test.js b/addons/api/tests/unit/serializers/application-test.js
index 5c2c07a20..10de9d4fb 100644
--- a/addons/api/tests/unit/serializers/application-test.js
+++ b/addons/api/tests/unit/serializers/application-test.js
@@ -154,7 +154,11 @@ module('Unit | Serializer | application', function (hooks) {
data: {
id: '1',
type: 'user',
- attributes: { name: 'User 1', scope: { scope_id: 'o_123' } },
+ attributes: {
+ name: 'User 1',
+ account_ids: [],
+ scope: { scope_id: 'o_123' }
+ },
relationships: {},
},
});
diff --git a/addons/api/tests/unit/serializers/user-test.js b/addons/api/tests/unit/serializers/user-test.js
new file mode 100644
index 000000000..c0d2bd061
--- /dev/null
+++ b/addons/api/tests/unit/serializers/user-test.js
@@ -0,0 +1,77 @@
+import { module, test } from 'qunit';
+import { setupTest } from 'ember-qunit';
+
+module('Unit | Serializer | user', function (hooks) {
+ setupTest(hooks);
+
+ test('it serializes users normally, without accounts', function (assert) {
+ assert.expect(1);
+ const store = this.owner.lookup('service:store');
+ const serializer = store.serializerFor('user');
+ const record = store.createRecord('user', {
+ name: 'user',
+ description: 'Description',
+ account_ids: ['1', '2'],
+ version: 1,
+ });
+ const snapshot = record._createSnapshot();
+ snapshot.adapterOptions = {};
+ const serializedRecord = serializer.serialize(snapshot);
+ assert.deepEqual(serializedRecord, {
+ name: 'user',
+ description: 'Description',
+ version: 1,
+ });
+ });
+
+ test('it serializes only host sets and version when an `adapterOptions.accountIDs` array is passed', function (assert) {
+ assert.expect(1);
+ const store = this.owner.lookup('service:store');
+ const serializer = store.serializerFor('user');
+ const record = store.createRecord('user', {
+ name: 'user',
+ description: 'Description',
+ account_ids: ['1', '2'],
+ version: 1,
+ });
+ const snapshot = record._createSnapshot();
+ snapshot.adapterOptions = {
+ accountIDs: ['4', '5'],
+ };
+ const serializedRecord = serializer.serialize(snapshot);
+ assert.deepEqual(serializedRecord, {
+ account_ids: ['4', '5'],
+ version: 1,
+ });
+ });
+
+ test('it normalizes missing account_ids to empty array', function (assert) {
+ assert.expect(1);
+ const store = this.owner.lookup('service:store');
+ const serializer = store.serializerFor('user');
+ const user = store.createRecord('user').constructor;
+ const payload = {
+ id: '1',
+ name: 'user 1',
+ scope: { id: 'o_123' },
+ };
+ const normalized = serializer.normalizeSingleResponse(
+ store,
+ user,
+ payload
+ );
+ assert.deepEqual(normalized, {
+ included: [],
+ data: {
+ id: '1',
+ type: 'user',
+ attributes: {
+ name: 'user 1',
+ scope: { scope_id: 'o_123' },
+ account_ids: [],
+ },
+ relationships: {},
+ },
+ });
+ });
+});
diff --git a/addons/rose/addon/styles/rose/components/dropdown/_button.scss b/addons/rose/addon/styles/rose/components/dropdown/_button.scss
index 7da5009a9..2fe195562 100644
--- a/addons/rose/addon/styles/rose/components/dropdown/_button.scss
+++ b/addons/rose/addon/styles/rose/components/dropdown/_button.scss
@@ -25,11 +25,11 @@
}
&.rose-dropdown-button-danger {
- --background-color: var(--white);
+ --background-color: var(--subtle);
--color: var(--failure);
&:hover {
- --color: var(--white);
+ --color: var(--subtle);
--background-color: var(--failure);
}
}
diff --git a/addons/rose/addon/styles/rose/components/dropdown/_dropdown.scss b/addons/rose/addon/styles/rose/components/dropdown/_dropdown.scss
index b3467aff9..66d3d7188 100644
--- a/addons/rose/addon/styles/rose/components/dropdown/_dropdown.scss
+++ b/addons/rose/addon/styles/rose/components/dropdown/_dropdown.scss
@@ -17,7 +17,7 @@ $chevron: "data:image/svg+xml;utf-8,");
diff --git a/addons/rose/addon/styles/rose/components/nav/_sidebar.scss b/addons/rose/addon/styles/rose/components/nav/_sidebar.scss
index 7dae497d6..010a3bd52 100644
--- a/addons/rose/addon/styles/rose/components/nav/_sidebar.scss
+++ b/addons/rose/addon/styles/rose/components/nav/_sidebar.scss
@@ -13,7 +13,7 @@
line-height: $lineHeight;
padding: 0 sizing.rems(m);
margin-bottom: $threePX;
- border-bottom: sizing.rems(xxxxs) solid var(--ui-gray-subtler-2);
+ border-bottom: sizing.rems(xxxxs) solid var(--ui-border-subtler-1);
}
.rose-nav-link {
@@ -32,7 +32,7 @@
}
&:hover {
- text-decoration: underline;
+ //text-decoration: underline;
color: var(--action);
background-color: var(--action-subtler-1h);
}
diff --git a/addons/rose/addon/styles/rose/components/nav/_tabs.scss b/addons/rose/addon/styles/rose/components/nav/_tabs.scss
index 2e2ca44f3..19f112bd1 100644
--- a/addons/rose/addon/styles/rose/components/nav/_tabs.scss
+++ b/addons/rose/addon/styles/rose/components/nav/_tabs.scss
@@ -5,7 +5,7 @@
display: flex;
flex-wrap: wrap;
margin-bottom: sizing.rems(m);
- box-shadow: inset 0 -#{sizing.rems(xxxxs)} 0 var(--ui-gray-subtler-2);
+ box-shadow: inset 0 -#{sizing.rems(xxxxs)} 0 var(--ui-border);
.rose-nav-link {
--color: var(--ui-gray-starker-1);
@@ -22,13 +22,17 @@
&:hover {
--box-shadow-color: var(--ui-gray-subtler-2);
- --background: var(--ui-gray-subtler-5);
+ --background: var(--ui-gray-subtler-6);
}
&:active,
&.active {
--color: var(--action);
--box-shadow-color: currentColor;
+
+ &:hover {
+ --color: var(--action-starker-1);
+ }
}
}
}
diff --git a/addons/rose/addon/styles/rose/variables/color/_css.scss b/addons/rose/addon/styles/rose/variables/color/_css.scss
index 6817be7a4..cb000c5df 100644
--- a/addons/rose/addon/styles/rose/variables/color/_css.scss
+++ b/addons/rose/addon/styles/rose/variables/color/_css.scss
@@ -79,64 +79,63 @@
--ui-header-subtler: #{color(ui-cool-gray, 700)};
}
-// NOTE: dark mode is disabled for now.
// Dark Mode
-// @media (prefers-color-scheme: dark) {
-// :root {
-// // poles
-// --subtle: #{darken(color(ui-gray, 900), 2%)};
-// --stark: var(--white);
-// --stark-components: 255, 255, 255;
-//
-// // theme colors
-// --info-subtler-3: #{color(blue, 800)};
-// --info-subtler-2: #{color(blue, 700)};
-// --info-subtler-1: #{color(blue, 600)};
-// --info: #{color(blue)};
-// --info-starker-1: #{color(blue, 300)};
-// --info-starker-2: #{color(blue, 200)};
-//
-// --alert-subtler-2: #{color(orange, 900)};
-// --alert-subtler-1: #{color(orange, 800)};
-// --alert: #{color(orange)};
-// --alert-starker-1: #{color(orange, 200)};
-//
-// --warning-subtler-2: #{color(yellow, 900)};
-// --warning-subtler-1: #{color(yellow, 800)};
-// --warning: #{color(yellow)};
-// --warning-starker-1: #{color(yellow, 200)};
-//
-// --failure-subtler-2: #{color(red, 900)};
-// --failure-subtler-1: #{color(red, 800)};
-// --failure: #{lighten(color(red, 400), 2.5%)};
-// --failure-starker-1: #{color(red, 300)};
-//
-// --success-subtler-2: #{color(green, 900)};
-// --success-subtler-1: #{color(green, 800)};
-// --success: #{color(green)};
-// --success-starker-1: #{color(green, 200)};
-//
-// --action-subtler-2: #{color(blue, 900)};
-// --action-subtler-1h: #{color(blue, 800)};
-// --action-subtler-1: #{color(blue, 800)};
-// --action: #{color(blue, 300)};
-// --action-starker-1: #{color(blue, 200)};
-//
-// --ui-border-subtler-1: #{color(ui-gray, 800)};
-// --ui-border: #{color(ui-gray, 700)};
-//
-// --ui-gray-subtler-6: #{color(ui-gray, 900)};
-// --ui-gray-subtler-5: #{color(ui-gray, 800)};
-// --ui-gray-subtler-4: #{color(ui-gray, 700)};
-// --ui-gray-subtler-3: #{color(ui-gray, 600)};
-// --ui-gray-subtler-2: #{color(ui-gray, 500)};
-// --ui-gray-subtler-1: #{color(ui-gray, 400)};
-// --ui-gray: #{color(ui-gray, 300)};
-// --ui-gray-starker-1: #{color(ui-gray, 200)};
-// --ui-gray-starker-2: #{color(ui-gray, 100)};
-// --ui-gray-starker-3: #{color(ui-gray, 050)};
-// --ui-gray-starker-4: #{color(ui-gray, 010)};
-//
-// --ui-header-subtler: #{color(ui-cool-gray, 100)};
-// }
-// }
+@media (prefers-color-scheme: dark) {
+ :root {
+ // poles
+ --subtle: #{darken(color(ui-gray, 900), 3%)};
+ --stark: #{color(ui-gray, 100)};
+ --stark-components: 255, 255, 255;
+
+ // theme colors
+ --info-subtler-3: #{color(blue, 800)};
+ --info-subtler-2: #{color(blue, 700)};
+ --info-subtler-1: #{color(blue, 600)};
+ --info: #{color(blue)};
+ --info-starker-1: #{color(blue, 300)};
+ --info-starker-2: #{color(blue, 200)};
+
+ --alert-subtler-2: #{color(orange, 900)};
+ --alert-subtler-1: #{color(orange, 800)};
+ --alert: #{color(orange)};
+ --alert-starker-1: #{color(orange, 200)};
+
+ --warning-subtler-2: #{color(yellow, 900)};
+ --warning-subtler-1: #{color(yellow, 800)};
+ --warning: #{color(yellow)};
+ --warning-starker-1: #{color(yellow, 200)};
+
+ --failure-subtler-2: #{color(red, 900)};
+ --failure-subtler-1: #{color(red, 800)};
+ --failure: #{color(red, 300)};
+ --failure-starker-1: #{color(red, 200)};
+
+ --success-subtler-2: #{color(green, 900)};
+ --success-subtler-1: #{color(green, 800)};
+ --success: #{color(green, 700)};
+ --success-starker-1: #{color(green, 400)};
+
+ --action-subtler-2: #{darken(color(blue, 900), 10%)};
+ --action-subtler-1h: #{darken(color(blue, 900), 5%)};
+ --action-subtler-1: #{color(blue, 800)};
+ --action: #{color(blue, 400)};
+ --action-starker-1: #{color(blue, 200)};
+
+ --ui-border-subtler-1: #{color(ui-gray, 800)};
+ --ui-border: #{color(ui-gray, 700)};
+
+ --ui-gray-subtler-6: #{color(ui-gray, 900)};
+ --ui-gray-subtler-5: #{color(ui-gray, 800)};
+ --ui-gray-subtler-4: #{color(ui-gray, 700)};
+ --ui-gray-subtler-3: #{color(ui-gray, 600)};
+ --ui-gray-subtler-2: #{color(ui-gray, 500)};
+ --ui-gray-subtler-1: #{color(ui-gray, 400)};
+ --ui-gray: #{color(ui-gray, 300)};
+ --ui-gray-starker-1: #{color(ui-gray, 200)};
+ --ui-gray-starker-2: #{color(ui-gray, 100)};
+ --ui-gray-starker-3: #{color(ui-gray, 050)};
+ --ui-gray-starker-4: #{color(ui-gray, 010)};
+
+ --ui-header-subtler: #{color(ui-cool-gray, 100)};
+ }
+}
diff --git a/ui/core/app/components/form/account/password/index.hbs b/ui/core/app/components/form/account/password/index.hbs
index e2890657e..34b7ed0e4 100644
--- a/ui/core/app/components/form/account/password/index.hbs
+++ b/ui/core/app/components/form/account/password/index.hbs
@@ -55,7 +55,6 @@
@value={{@model.attributes.login_name}}
@label={{t "form.login_name.label"}}
@error={{@model.errors.login_name}}
- @helperText={{t "form.login_name.help"}}
readonly={{false}}
as |field|>
{{#if @model.errors.login_name}}
@@ -74,7 +73,6 @@
@value={{this.password}}
@label={{t "form.password.label"}}
@error={{@model.errors.password}}
- @helperText={{t "form.password.help"}}
readonly={{false}}
autocomplete="new-password"
as |field|>
diff --git a/ui/core/app/components/form/target/add-host-sets/index.hbs b/ui/core/app/components/form/target/add-host-sets/index.hbs
index fa02412bb..545f45f91 100644
--- a/ui/core/app/components/form/target/add-host-sets/index.hbs
+++ b/ui/core/app/components/form/target/add-host-sets/index.hbs
@@ -18,7 +18,7 @@
{{t 'form.id.label'}}
{{t 'form.name.label'}}
{{t 'form.type.label'}}
- {{t 'resources.host-catalog.title_plural'}}
+ {{t 'resources.host-catalog.title'}}
@@ -57,4 +57,4 @@
-{{/unless}}
\ No newline at end of file
+{{/unless}}
diff --git a/ui/core/app/components/form/user/add-accounts/index.hbs b/ui/core/app/components/form/user/add-accounts/index.hbs
new file mode 100644
index 000000000..f3bfed288
--- /dev/null
+++ b/ui/core/app/components/form/user/add-accounts/index.hbs
@@ -0,0 +1,56 @@
+{{#if this.hasAvailableAccounts}}
+
+
+
+
+
+
+
+ {{t 'form.id.label'}}
+ {{t 'form.username.label'}}
+ {{t 'resources.auth-method.title'}}
+
+
+
+ {{#each this.filteredAccounts as |account|}}
+
+
+
+
+ {{account.username}}
+
+ {{account.auth_method_id}}
+
+
+ {{/each}}
+
+
+
+
+{{/if}}
+
+{{#unless this.hasAvailableAccounts}}
+
+
+
+ {{t "resources.user.messages.no-accounts.description"}}
+
+
+
+ {{t "actions.back"}}
+
+
+
+{{/unless}}
diff --git a/ui/core/app/components/form/user/add-accounts/index.js b/ui/core/app/components/form/user/add-accounts/index.js
new file mode 100644
index 000000000..b5f49e947
--- /dev/null
+++ b/ui/core/app/components/form/user/add-accounts/index.js
@@ -0,0 +1,53 @@
+import Component from '@glimmer/component';
+import { computed, action } from '@ember/object';
+import { A } from '@ember/array';
+
+export default class FormUserAddAccountsComponent extends Component {
+ // =properties
+
+ /**
+ * Array of selected account IDs.
+ * @type {EmberArray}
+ */
+ selectedAccountIDs = A();
+
+ /**
+ * Checks for unassigned accounts.
+ * @param {[AccountModel]} filteredAccounts
+ * @type {boolean}
+ */
+ @computed('filteredAccounts')
+ get hasAvailableAccounts() {
+ return this.filteredAccounts.length > 0;
+ }
+
+ /**
+ * Accounts not already added to the user.
+ * @type {[AccountModel]}
+ */
+ @computed('args.{accounts.[],model.account_ids.[]}')
+ get filteredAccounts() {
+ // Get IDs for accounts already added to the current user
+ const alreadyAddedAccountIDs = this.args.model.account_ids;
+ const notAddedAccounts = this.args.accounts.filter(
+ ({ id }) => !alreadyAddedAccountIDs.includes(id)
+ );
+ return notAddedAccounts;
+ }
+
+ // =actions
+
+ @action
+ toggleAccount(account) {
+ if (!this.selectedAccountIDs.includes(account.id)) {
+ this.selectedAccountIDs.addObject(account.id);
+ } else {
+ this.selectedAccountIDs.removeObject(account.id);
+ }
+ }
+
+ @action
+ submit(fn) {
+ fn(this.selectedAccountIDs);
+ }
+}
diff --git a/ui/core/app/controllers/scopes/scope/users/user/add-accounts.js b/ui/core/app/controllers/scopes/scope/users/user/add-accounts.js
new file mode 100644
index 000000000..de02366b1
--- /dev/null
+++ b/ui/core/app/controllers/scopes/scope/users/user/add-accounts.js
@@ -0,0 +1,17 @@
+import Controller from '@ember/controller';
+import { inject as service } from '@ember/service';
+
+export default class ScopesScopeUsersUserAddAccountsController extends Controller {
+ // =services
+
+ @service intl;
+
+ // =attributes
+
+ /**
+ * @type {string}
+ */
+ get breadCrumb() {
+ return this.intl.t('resources.user.messages.add-accounts.title');
+ }
+}
diff --git a/ui/core/app/router.js b/ui/core/app/router.js
index 0894289f9..748ad73a6 100644
--- a/ui/core/app/router.js
+++ b/ui/core/app/router.js
@@ -58,7 +58,10 @@ Router.map(function () {
});
this.route('users', function () {
- this.route('user', { path: ':user_id' }, function () {});
+ this.route('user', { path: ':user_id' }, function () {
+ this.route('accounts');
+ this.route('add-accounts');
+ });
this.route('new');
});
diff --git a/ui/core/app/routes/scopes/scope/users/user.js b/ui/core/app/routes/scopes/scope/users/user.js
index d26912707..2d7ed072e 100644
--- a/ui/core/app/routes/scopes/scope/users/user.js
+++ b/ui/core/app/routes/scopes/scope/users/user.js
@@ -12,4 +12,27 @@ export default class ScopesScopeUsersUserRoute extends Route {
async model({ user_id }) {
return this.store.findRecord('user', user_id);
}
+
+ /**
+ * Renders the route-set specific page sections
+ * @override
+ */
+ renderTemplate() {
+ super.renderTemplate(...arguments);
+
+ this.render('scopes/scope/users/user/-header', {
+ into: 'scopes/scope/users/user',
+ outlet: 'header',
+ });
+
+ this.render('scopes/scope/users/user/-actions', {
+ into: 'scopes/scope/users/user',
+ outlet: 'actions',
+ });
+
+ this.render('scopes/scope/users/user/-navigation', {
+ into: 'scopes/scope/users/user',
+ outlet: 'navigation',
+ });
+ }
}
diff --git a/ui/core/app/routes/scopes/scope/users/user/accounts.js b/ui/core/app/routes/scopes/scope/users/user/accounts.js
new file mode 100644
index 000000000..cb00ca24e
--- /dev/null
+++ b/ui/core/app/routes/scopes/scope/users/user/accounts.js
@@ -0,0 +1,51 @@
+import Route from '@ember/routing/route';
+import { action } from '@ember/object';
+import { inject as service } from '@ember/service';
+import { hash, all } from 'rsvp';
+import loading from 'ember-loading/decorator';
+
+export default class ScopesScopeUsersUserAccountsRoute extends Route {
+ // =services
+
+ @service intl;
+ @service notify;
+
+ // =methods
+
+ /**
+ * Returns users associated with this user.
+ * @return {Promise{user: UserModel, accounts: Promise{[AccountModel]}}}
+ */
+ model() {
+ const { id: scopeID } = this.modelFor('scopes.scope');
+ const user = this.modelFor('scopes.scope.users.user');
+ return hash({
+ user,
+ accounts: all(
+ user.account_ids.map((id) =>
+ this.store.findRecord('account', id, { adapterOptions: { scopeID } })
+ )
+ ),
+ });
+ }
+
+ // =actions
+
+ /**
+ * Remove a account from the current role and redirect to accounts index.
+ * @param {UserModel} user
+ * @param {AccountModel} account
+ */
+ @action
+ @loading
+ async removeAccount(user, account) {
+ try {
+ await user.removeAccount(account.id);
+ await this.refresh();
+ this.notify.success(this.intl.t('notifications.remove-success'));
+ } catch (error) {
+ // TODO: replace with translated strings
+ this.notify.error(error.message, { closeAfter: null });
+ }
+ }
+}
diff --git a/ui/core/app/routes/scopes/scope/users/user/add-accounts.js b/ui/core/app/routes/scopes/scope/users/user/add-accounts.js
new file mode 100644
index 000000000..bdb7b0e9c
--- /dev/null
+++ b/ui/core/app/routes/scopes/scope/users/user/add-accounts.js
@@ -0,0 +1,90 @@
+import Route from '@ember/routing/route';
+import { inject as service } from '@ember/service';
+import { action } from '@ember/object';
+import { all } from 'rsvp';
+import loading from 'ember-loading/decorator';
+
+export default class ScopesScopeUsersUserAddAccountsRoute extends Route {
+ // =services
+
+ @service intl;
+ @service notify;
+
+ // =methods
+
+ /**
+ * Emtpy out any previously loaded acconuts.
+ */
+ beforeModel() {
+ this.store.unloadAll('account');
+ }
+
+ /**
+ * Returns the current target, all host catalogs, and all host sets.
+ * @return {{target: TargetModel, hostCatalogs: [HostCatalogModel], hostSets: [HostSetModel]}}
+ */
+ async model() {
+ const user = this.modelFor('scopes.scope.users.user');
+ const { id: scope_id } = this.modelFor('scopes.scope');
+ const authMethods = await this.store.query('auth-method', { scope_id });
+ await all(
+ authMethods.map(({ id: auth_method_id }) =>
+ this.store.query('account', { auth_method_id })
+ )
+ );
+ const accounts = this.store.peekAll('account');
+ return { user, authMethods, accounts };
+ }
+
+ /**
+ * Renders the route-set specific page sections
+ * @override
+ */
+ renderTemplate() {
+ super.renderTemplate(...arguments);
+
+ this.render('scopes/scope/users/user/add-accounts/-header', {
+ into: 'scopes/scope/users/user',
+ outlet: 'header',
+ });
+
+ this.render('-empty', {
+ into: 'scopes/scope/users/user',
+ outlet: 'actions',
+ });
+
+ this.render('-empty', {
+ into: 'scopes/scope/users/user',
+ outlet: 'navigation',
+ });
+ }
+
+ // =actions
+
+ /**
+ * Adds accounts to the user and saves, replaces with the accounts index
+ * route, and notifies the user of success or error.
+ * @param {UserModel} user
+ * @param {[string]} accountIDs
+ */
+ @action
+ @loading
+ async addAccounts(user, accountIDs) {
+ try {
+ await user.addAccounts(accountIDs);
+ await this.replaceWith('scopes.scope.users.user.accounts');
+ this.notify.success(this.intl.t('notifications.add-success'));
+ } catch (error) {
+ // TODO: replace with translated strings
+ this.notify.error(error.message, { closeAfter: null });
+ }
+ }
+
+ /**
+ * Redirect to user accounts as if nothing ever happened.
+ */
+ @action
+ cancel() {
+ this.replaceWith('scopes.scope.users.user.accounts');
+ }
+}
diff --git a/ui/core/app/styles/_notify.scss b/ui/core/app/styles/_notify.scss
index 438f0950c..784bc746f 100644
--- a/ui/core/app/styles/_notify.scss
+++ b/ui/core/app/styles/_notify.scss
@@ -61,19 +61,19 @@
.ember-notify-show {
-webkit-animation-duration: 500ms;
-webkit-animation-name: ember-notify-show;
- -webkit-animation-timing-function: cubic-bezier(0.175, 0.885, 0.32, 1.275);
+ -webkit-animation-timing-function: cubic-bezier(.175, .885, .32, 1.275);
animation-duration: 500ms;
animation-name: ember-notify-show;
- animation-timing-function: cubic-bezier(0.175, 0.885, 0.32, 1.275);
+ animation-timing-function: cubic-bezier(.175, .885, .32, 1.275);
}
.ember-notify-hide {
-webkit-animation-duration: 250ms;
-webkit-animation-name: ember-notify-hide;
- -webkit-animation-timing-function: cubic-bezier(0.6, -0.28, 0.735, 0.045);
+ -webkit-animation-timing-function: cubic-bezier(.6, -.28, .735, .045);
animation-duration: 250ms;
animation-name: ember-notify-hide;
- animation-timing-function: cubic-bezier(0.6, -0.28, 0.735, 0.045);
+ animation-timing-function: cubic-bezier(.6, -.28, .735, .045);
&:hover {
-moz-animation-play-state: paused;
diff --git a/ui/core/app/styles/app.scss b/ui/core/app/styles/app.scss
index 9e72b055f..1f19644b9 100644
--- a/ui/core/app/styles/app.scss
+++ b/ui/core/app/styles/app.scss
@@ -25,6 +25,10 @@
& + .rose-nav-sidebar {
margin-top: 0;
}
+
+ .rose-icon {
+ margin-right: sizing.rems(xs);
+ }
}
.rose-form {
@@ -61,17 +65,16 @@
&.loading {
&::after {
- content: " ";
- position: absolute;
- top: 100%;
- left: 0;
- right: 0;
- height: 3px;
-
- background: repeating-linear-gradient(to right, #fff, #fff, var(--info), #fff, #fff);
- background-size: 200% auto;
- background-position: 0 100%;
animation: gradient 1s linear infinite;
+ background: repeating-linear-gradient(to right, var(--white), var(--white), var(--info), var(--white), var(--white));
+ background-position: 0 100%;
+ background-size: 200% auto;
+ content: ' ';
+ height: 3px;
+ left: 0;
+ position: absolute;
+ right: 0;
+ top: 100%;
@keyframes gradient {
0% { background-position: 0 0; }
@@ -81,6 +84,8 @@
}
}
-.copyable .copyable-button {
- padding: 0rem;
+.copyable {
+ .copyable-button {
+ padding: 0;
+ }
}
diff --git a/ui/core/app/templates/application.hbs b/ui/core/app/templates/application.hbs
index d0728605a..5a8003366 100644
--- a/ui/core/app/templates/application.hbs
+++ b/ui/core/app/templates/application.hbs
@@ -17,7 +17,7 @@
{{t 'actions.change-password'}}
diff --git a/ui/core/app/templates/scopes/scope.hbs b/ui/core/app/templates/scopes/scope.hbs
index 04b437e4d..718ce849e 100644
--- a/ui/core/app/templates/scopes/scope.hbs
+++ b/ui/core/app/templates/scopes/scope.hbs
@@ -5,21 +5,36 @@
+
{{t "resources.project.title_plural"}}
+
{{t "resources.user.title_plural"}}
+
{{t "resources.group.title_plural"}}
+
{{t "resources.role.title_plural"}}
+
{{t "resources.auth-method.title_plural"}}
diff --git a/ui/core/app/templates/scopes/scope/auth-methods/auth-method/accounts/index.hbs b/ui/core/app/templates/scopes/scope/auth-methods/auth-method/accounts/index.hbs
index 81b93da9c..7df042046 100644
--- a/ui/core/app/templates/scopes/scope/auth-methods/auth-method/accounts/index.hbs
+++ b/ui/core/app/templates/scopes/scope/auth-methods/auth-method/accounts/index.hbs
@@ -4,7 +4,7 @@
{{t 'form.name.label'}}
- {{t 'form.login_name.label'}}
+ {{t 'form.username.label'}}
{{t 'form.id.label'}}
diff --git a/ui/core/app/templates/scopes/scope/projects/project/_sidebar.hbs b/ui/core/app/templates/scopes/scope/projects/project/_sidebar.hbs
index b9d440b7c..5f7845697 100644
--- a/ui/core/app/templates/scopes/scope/projects/project/_sidebar.hbs
+++ b/ui/core/app/templates/scopes/scope/projects/project/_sidebar.hbs
@@ -1,11 +1,20 @@
+
{{t "resources.session.title_plural"}}
+
{{t "resources.target.title_plural"}}
+
{{t "resources.host-catalog.title_plural"}}
diff --git a/ui/core/app/templates/scopes/scope/users/user.hbs b/ui/core/app/templates/scopes/scope/users/user.hbs
index d66e83390..2a878ff51 100644
--- a/ui/core/app/templates/scopes/scope/users/user.hbs
+++ b/ui/core/app/templates/scopes/scope/users/user.hbs
@@ -1,3 +1,25 @@
{{title @model.displayName}}
-{{outlet}}
+
+
+
+
+
+
+
+ {{outlet "header"}}
+
+
+
+ {{outlet "navigation"}}
+
+
+
+ {{outlet "actions"}}
+
+
+
+ {{outlet}}
+
+
+
diff --git a/ui/core/app/templates/scopes/scope/users/user/-actions.hbs b/ui/core/app/templates/scopes/scope/users/user/-actions.hbs
new file mode 100644
index 000000000..c5de304c1
--- /dev/null
+++ b/ui/core/app/templates/scopes/scope/users/user/-actions.hbs
@@ -0,0 +1,12 @@
+
+
+ {{t "resources.user.actions.add-accounts"}}
+
+
+ {{t 'actions.delete-user'}}
+
+
diff --git a/ui/core/app/templates/scopes/scope/users/user/-header.hbs b/ui/core/app/templates/scopes/scope/users/user/-header.hbs
new file mode 100644
index 000000000..2b107e842
--- /dev/null
+++ b/ui/core/app/templates/scopes/scope/users/user/-header.hbs
@@ -0,0 +1,3 @@
+{{t "resources.user.title"}}
+{{t "resources.user.description"}}
+{{@model.id}}
diff --git a/ui/core/app/templates/scopes/scope/users/user/-navigation.hbs b/ui/core/app/templates/scopes/scope/users/user/-navigation.hbs
new file mode 100644
index 000000000..0228c92e8
--- /dev/null
+++ b/ui/core/app/templates/scopes/scope/users/user/-navigation.hbs
@@ -0,0 +1,8 @@
+
+
+ {{t 'titles.details'}}
+
+
+ {{t 'resources.user.messages.accounts.title'}}
+
+
diff --git a/ui/core/app/templates/scopes/scope/users/user/accounts.hbs b/ui/core/app/templates/scopes/scope/users/user/accounts.hbs
new file mode 100644
index 000000000..66c808da9
--- /dev/null
+++ b/ui/core/app/templates/scopes/scope/users/user/accounts.hbs
@@ -0,0 +1,53 @@
+{{title (t 'resources.user.messages.accounts.title')}}
+
+{{#if @model.accounts}}
+
+
+
+
+ {{t 'form.username.label'}}
+ {{t 'form.id.label'}}
+ {{t "titles.actions"}}
+
+
+
+ {{#each @model.accounts as |account|}}
+
+
+ {{account.username}}
+
+
+ {{account.id}}
+
+
+
+
+ {{t "actions.remove"}}
+
+
+
+
+ {{/each}}
+
+
+
+{{else}}
+
+
+
+
+ {{t "resources.user.messages.accounts.description"}}
+
+
+
+
+{{/if}}
diff --git a/ui/core/app/templates/scopes/scope/users/user/add-accounts.hbs b/ui/core/app/templates/scopes/scope/users/user/add-accounts.hbs
new file mode 100644
index 000000000..eea2fb88d
--- /dev/null
+++ b/ui/core/app/templates/scopes/scope/users/user/add-accounts.hbs
@@ -0,0 +1,25 @@
+{{title (t 'resources.user.actions.add-accounts')}}
+
+{{#if @model.accounts}}
+
+{{/if}}
+
+{{#unless @model.accounts}}
+
+
+
+ {{t "resources.user.messages.no-accounts.description"}}
+
+
+
+ {{t "actions.back"}}
+
+
+
+{{/unless}}
diff --git a/ui/core/app/templates/scopes/scope/users/user/add-accounts/-header.hbs b/ui/core/app/templates/scopes/scope/users/user/add-accounts/-header.hbs
new file mode 100644
index 000000000..d9a819fe8
--- /dev/null
+++ b/ui/core/app/templates/scopes/scope/users/user/add-accounts/-header.hbs
@@ -0,0 +1,2 @@
+{{t "resources.user.messages.add-accounts.title"}}
+{{t "resources.user.messages.add-accounts.description"}}
diff --git a/ui/core/app/templates/scopes/scope/users/user/index.hbs b/ui/core/app/templates/scopes/scope/users/user/index.hbs
index 35db006e9..67714eca0 100644
--- a/ui/core/app/templates/scopes/scope/users/user/index.hbs
+++ b/ui/core/app/templates/scopes/scope/users/user/index.hbs
@@ -1,41 +1,5 @@
-
-
-
-
-
-
-
- {{t "resources.user.title"}}
- {{t "resources.user.description"}}
- {{@model.id}}
-
-
-
-
-
- {{t 'actions.delete-user'}}
-
-
-
-
-
-
-
- {{t 'titles.details'}}
-
-
-
-
-
-
-
-
-
+
diff --git a/ui/core/mirage/config.js b/ui/core/mirage/config.js
index 3d332f121..678d5fafb 100644
--- a/ui/core/mirage/config.js
+++ b/ui/core/mirage/config.js
@@ -90,6 +90,31 @@ export default function() {
this.get('/users/:id');
this.patch('/users/:id');
this.del('/users/:id');
+ this.post('/users/:idMethod', function ({ users }, { params: { idMethod } }) {
+ const attrs = this.normalizedRequestAttrs();
+ const id = idMethod.split(':')[0];
+ const method = idMethod.split(':')[1];
+ const user = users.find(id);
+ const updatedAttrs = {
+ version: attrs.version,
+ accountIds: user.accountIds
+ };
+ // If adding accounts, push them into the array
+ if (method === 'add-accounts') {
+ attrs.accountIds.forEach(id => {
+ if (!updatedAttrs.accountIds.includes(id)) {
+ updatedAttrs.accountIds.push(id);
+ }
+ });
+ }
+ // If deleting accounts, filter them out of the array
+ if (method === 'remove-accounts') {
+ updatedAttrs.accountIds = updatedAttrs.accountIds.filter(id => {
+ return !attrs.accountIds.includes(id);
+ });
+ }
+ return user.update(updatedAttrs);
+ });
// IAM: Groups
this.get('/groups', ({ groups }, { queryParams: { scope_id: scopeId } }) => {
diff --git a/ui/core/mirage/factories/auth-method.js b/ui/core/mirage/factories/auth-method.js
index 80254614a..61d29726b 100644
--- a/ui/core/mirage/factories/auth-method.js
+++ b/ui/core/mirage/factories/auth-method.js
@@ -5,12 +5,15 @@ export default factory.extend({
id: (i) => `auth-method-id-${i}`,
/**
- * Adds accounts to auth method.
+ * Adds accounts (with associated users) to auth method.
*/
- withAccounts: trait({
+ withAccountsAndUsers: trait({
afterCreate(authMethod, server) {
const { scope } = authMethod;
- server.createList('account', 5, { scope, authMethod });
+ server.createList('user', 5, { scope }).map(user => {
+ const { id } = server.create('account', { scope, authMethod });
+ user.update({ accountIds: [id] });
+ });
}
})
diff --git a/ui/core/mirage/models/user.js b/ui/core/mirage/models/user.js
index 08b1f5ad3..45cf14952 100644
--- a/ui/core/mirage/models/user.js
+++ b/ui/core/mirage/models/user.js
@@ -1,7 +1,8 @@
-import { Model, belongsTo } from 'ember-cli-mirage';
+import { Model, belongsTo, hasMany } from 'ember-cli-mirage';
export default Model.extend({
- scope: belongsTo()
+ scope: belongsTo(),
+ accounts: hasMany('account')
});
diff --git a/ui/core/mirage/scenarios/default.js b/ui/core/mirage/scenarios/default.js
index 5d1a85fe6..e4cd01854 100644
--- a/ui/core/mirage/scenarios/default.js
+++ b/ui/core/mirage/scenarios/default.js
@@ -15,7 +15,7 @@ export default function(server) {
// Auth
server.createList('auth-method', 3, { scope: globalScope });
- server.createList('auth-method', 3, { scope: orgScope }, 'withAccounts');
+ server.createList('auth-method', 3, { scope: orgScope }, 'withAccountsAndUsers');
// Groups and Users
server.createList('group', 5, { scope: orgScope }, 'withMembers');
diff --git a/ui/core/mirage/serializers/user.js b/ui/core/mirage/serializers/user.js
index 142cbf508..0ef9298f5 100644
--- a/ui/core/mirage/serializers/user.js
+++ b/ui/core/mirage/serializers/user.js
@@ -1,5 +1,18 @@
import ApplicationSerializer from './application';
+import { underscore } from '@ember/string';
export default ApplicationSerializer.extend({
- modelName: 'user'
+ modelName: 'user',
+
+ keyForRelationshipIds(relationshipName) {
+ return `${this._container.inflector.singularize(
+ underscore(relationshipName)
+ )}_ids`;
+ },
+
+ _hashForModel(model) {
+ const json = ApplicationSerializer.prototype._hashForModel.apply(this, arguments);
+ if (model.accountIds.length) json.account_ids = model.accountIds;
+ return json;
+ }
});
diff --git a/ui/core/public/boundary-icons/action-session.svg b/ui/core/public/boundary-icons/action-session.svg
new file mode 100644
index 000000000..6a9d8ca21
--- /dev/null
+++ b/ui/core/public/boundary-icons/action-session.svg
@@ -0,0 +1,4 @@
+
diff --git a/ui/core/public/boundary-icons/auth-method.svg b/ui/core/public/boundary-icons/auth-method.svg
new file mode 100644
index 000000000..8e3768084
--- /dev/null
+++ b/ui/core/public/boundary-icons/auth-method.svg
@@ -0,0 +1,4 @@
+
diff --git a/ui/core/public/boundary-icons/gear.svg b/ui/core/public/boundary-icons/gear.svg
new file mode 100644
index 000000000..d2bca3dc7
--- /dev/null
+++ b/ui/core/public/boundary-icons/gear.svg
@@ -0,0 +1,4 @@
+
diff --git a/ui/core/public/boundary-icons/group.svg b/ui/core/public/boundary-icons/group.svg
new file mode 100644
index 000000000..2d2966c5a
--- /dev/null
+++ b/ui/core/public/boundary-icons/group.svg
@@ -0,0 +1,3 @@
+
diff --git a/ui/core/public/boundary-icons/host-catalogs.svg b/ui/core/public/boundary-icons/host-catalogs.svg
new file mode 100644
index 000000000..18372da0c
--- /dev/null
+++ b/ui/core/public/boundary-icons/host-catalogs.svg
@@ -0,0 +1,3 @@
+
diff --git a/ui/core/public/boundary-icons/org.svg b/ui/core/public/boundary-icons/org.svg
new file mode 100644
index 000000000..238cc8435
--- /dev/null
+++ b/ui/core/public/boundary-icons/org.svg
@@ -0,0 +1,3 @@
+
diff --git a/ui/core/public/boundary-icons/project.svg b/ui/core/public/boundary-icons/project.svg
new file mode 100644
index 000000000..5b9515d08
--- /dev/null
+++ b/ui/core/public/boundary-icons/project.svg
@@ -0,0 +1,3 @@
+
diff --git a/ui/core/public/boundary-icons/roles.svg b/ui/core/public/boundary-icons/roles.svg
new file mode 100644
index 000000000..a0b92dd37
--- /dev/null
+++ b/ui/core/public/boundary-icons/roles.svg
@@ -0,0 +1,3 @@
+
diff --git a/ui/core/public/boundary-icons/target.svg b/ui/core/public/boundary-icons/target.svg
new file mode 100644
index 000000000..1889f89e5
--- /dev/null
+++ b/ui/core/public/boundary-icons/target.svg
@@ -0,0 +1,3 @@
+
diff --git a/ui/core/public/boundary-icons/user-profile.svg b/ui/core/public/boundary-icons/user-profile.svg
new file mode 100644
index 000000000..f70d94404
--- /dev/null
+++ b/ui/core/public/boundary-icons/user-profile.svg
@@ -0,0 +1,3 @@
+
diff --git a/ui/core/public/boundary-icons/users.svg b/ui/core/public/boundary-icons/users.svg
new file mode 100644
index 000000000..e086f6503
--- /dev/null
+++ b/ui/core/public/boundary-icons/users.svg
@@ -0,0 +1,3 @@
+
diff --git a/ui/core/tests/acceptance/users/accounts-test.js b/ui/core/tests/acceptance/users/accounts-test.js
new file mode 100644
index 000000000..8bcaa951a
--- /dev/null
+++ b/ui/core/tests/acceptance/users/accounts-test.js
@@ -0,0 +1,142 @@
+import { module, test } from 'qunit';
+import { visit, currentURL, click, find, findAll } from '@ember/test-helpers';
+import { setupApplicationTest } from 'ember-qunit';
+import setupMirage from 'ember-cli-mirage/test-support/setup-mirage';
+import a11yAudit from 'ember-a11y-testing/test-support/audit';
+import { Response } from 'miragejs';
+import {
+ authenticateSession,
+ // These are left here intentionally for future reference.
+ //currentSession,
+ //invalidateSession,
+} from 'ember-simple-auth/test-support';
+
+module('Acceptance | users | accounts', function (hooks) {
+ setupApplicationTest(hooks);
+ setupMirage(hooks);
+
+ const instances = {
+ scopes: {
+ global: null,
+ org: null,
+ },
+ user: null,
+ };
+ const urls = {
+ orgScope: null,
+ users: null,
+ user: null,
+ accounts: null,
+ addAccounts: null,
+ };
+ let accountsCount;
+
+ hooks.beforeEach(function () {
+ authenticateSession({});
+ instances.scopes.global = this.server.create('scope', { id: 'global' });
+ instances.scopes.org = this.server.create('scope', {
+ type: 'org',
+ scope: { id: 'global', type: 'global' },
+ });
+ this.server.create('auth-method', {
+ scope: instances.scopes.org
+ }, 'withAccountsAndUsers');
+ instances.user = this.server.schema.users.all().models[0];
+ accountsCount = instances.user.accountIds.length;
+ urls.users = `/scopes/${instances.scopes.org.id}/users`;
+ urls.user = `${urls.users}/${instances.user.id}`;
+ urls.accounts = `${urls.user}/accounts`;
+ urls.addAccounts = `${urls.user}/add-accounts`;
+ });
+
+ test('visiting user accounts', async function (assert) {
+ assert.expect(2);
+ await visit(urls.accounts);
+ await a11yAudit();
+ assert.equal(currentURL(), urls.accounts);
+ assert.equal(findAll('tbody tr').length, accountsCount);
+ });
+
+ test('can remove a account', async function (assert) {
+ assert.expect(2);
+ await visit(urls.accounts);
+ assert.equal(findAll('tbody tr').length, accountsCount);
+ await click('tbody tr .rose-dropdown-button-danger');
+ assert.equal(findAll('tbody tr').length, accountsCount - 1);
+ });
+
+ test('shows error message on account remove', async function (assert) {
+ assert.expect(2);
+ this.server.post('/users/:idMethod', () => {
+ return new Response(
+ 400,
+ {},
+ {
+ status: 400,
+ code: 'invalid_argument',
+ message: 'The request was invalid.',
+ details: {},
+ }
+ );
+ });
+ await visit(urls.accounts);
+ assert.equal(findAll('tbody tr').length, accountsCount);
+ await click('tbody tr .rose-dropdown-button-danger');
+ assert.ok(find('[role="alert"]'));
+ });
+
+ test('visiting account add accounts', async function(assert) {
+ assert.expect(1);
+ await visit(urls.addAccounts);
+ await a11yAudit();
+ assert.equal(currentURL(), urls.addAccounts);
+ });
+
+ test('select and save accounts to add', async function (assert) {
+ assert.expect(3);
+ instances.user.update({ accountIds: [] });
+ await visit(urls.accounts);
+ assert.equal(findAll('tbody tr').length, 0);
+ await click('.rose-layout-page-actions a')
+ assert.equal(currentURL(), urls.addAccounts);
+ await click('tbody label');
+ await click('form [type="submit"]');
+ await visit(urls.accounts);
+ assert.equal(findAll('tbody tr').length, 1);
+ });
+
+ test('select and cancel accounts to add', async function (assert) {
+ assert.expect(4);
+ await visit(urls.accounts);
+ assert.equal(findAll('tbody tr').length, accountsCount);
+ await click('tbody tr .rose-dropdown-button-danger');
+ assert.equal(findAll('tbody tr').length, accountsCount - 1);
+ await click('.rose-layout-page-actions a')
+ assert.equal(currentURL(), urls.addAccounts);
+ await click('tbody label');
+ await click('form [type="button"]');
+ await visit(urls.accounts);
+ assert.equal(findAll('tbody tr').length, accountsCount - 1);
+ });
+
+ test('shows error message on account add', async function (assert) {
+ assert.expect(1);
+ this.server.post('/users/:idMethod', () => {
+ return new Response(
+ 400,
+ {},
+ {
+ status: 400,
+ code: 'invalid_argument',
+ message: 'The request was invalid.',
+ details: {},
+ }
+ );
+ });
+ instances.user.update({ accountIds: [] });
+ await visit(urls.addAccounts);
+ await click('tbody label');
+ await click('form [type="submit"]');
+ assert.ok(find('[role="alert"]'));
+ });
+});
diff --git a/ui/core/tests/integration/components/form/user/add-accounts-test.js b/ui/core/tests/integration/components/form/user/add-accounts-test.js
new file mode 100644
index 000000000..ba96e4dba
--- /dev/null
+++ b/ui/core/tests/integration/components/form/user/add-accounts-test.js
@@ -0,0 +1,12 @@
+import { module, test } from 'qunit';
+import { setupRenderingTest } from 'ember-qunit';
+// import { render } from '@ember/test-helpers';
+// import { hbs } from 'ember-cli-htmlbars';
+
+module('Integration | Component | form/user/add-accounts', function(hooks) {
+ setupRenderingTest(hooks);
+
+ test('it renders', async function(assert) {
+ assert.expect(0);
+ });
+});
diff --git a/ui/core/tests/unit/controllers/scopes/scope/users/user/add-accounts-test.js b/ui/core/tests/unit/controllers/scopes/scope/users/user/add-accounts-test.js
new file mode 100644
index 000000000..a71b3d2e0
--- /dev/null
+++ b/ui/core/tests/unit/controllers/scopes/scope/users/user/add-accounts-test.js
@@ -0,0 +1,12 @@
+import { module, test } from 'qunit';
+import { setupTest } from 'ember-qunit';
+
+module('Unit | Controller | scopes/scope/users/user/add-accounts', function(hooks) {
+ setupTest(hooks);
+
+ // Replace this with your real tests.
+ test('it exists', function(assert) {
+ let controller = this.owner.lookup('controller:scopes/scope/users/user/add-accounts');
+ assert.ok(controller);
+ });
+});
diff --git a/ui/core/tests/unit/routes/scopes/scope/users/user/accounts-test.js b/ui/core/tests/unit/routes/scopes/scope/users/user/accounts-test.js
new file mode 100644
index 000000000..a98f97d71
--- /dev/null
+++ b/ui/core/tests/unit/routes/scopes/scope/users/user/accounts-test.js
@@ -0,0 +1,11 @@
+import { module, test } from 'qunit';
+import { setupTest } from 'ember-qunit';
+
+module('Unit | Route | scopes/scope/users/user/accounts', function(hooks) {
+ setupTest(hooks);
+
+ test('it exists', function(assert) {
+ let route = this.owner.lookup('route:scopes/scope/users/user/accounts');
+ assert.ok(route);
+ });
+});
diff --git a/ui/core/tests/unit/routes/scopes/scope/users/user/add-accounts-test.js b/ui/core/tests/unit/routes/scopes/scope/users/user/add-accounts-test.js
new file mode 100644
index 000000000..2aac18540
--- /dev/null
+++ b/ui/core/tests/unit/routes/scopes/scope/users/user/add-accounts-test.js
@@ -0,0 +1,11 @@
+import { module, test } from 'qunit';
+import { setupTest } from 'ember-qunit';
+
+module('Unit | Route | scopes/scope/users/user/add-accounts', function(hooks) {
+ setupTest(hooks);
+
+ test('it exists', function(assert) {
+ let route = this.owner.lookup('route:scopes/scope/users/user/add-accounts');
+ assert.ok(route);
+ });
+});
diff --git a/ui/core/translations/form/en-us.yaml b/ui/core/translations/form/en-us.yaml
index ed2e1e160..7bbf5c7bc 100644
--- a/ui/core/translations/form/en-us.yaml
+++ b/ui/core/translations/form/en-us.yaml
@@ -10,14 +10,14 @@ type:
label: Type
help: The type of the resource, to help differentiate schemas
login_name:
+ label: Login Name
+username:
label: Username
- help: The username for the account
set_password:
label: Password
- help: Create a new password for the account
+ help: Create a new password
password:
label: Password
- help: The password for the account
grant:
label: Grant
help: A permissions grant string for a role
diff --git a/ui/core/translations/resources/en-us.yaml b/ui/core/translations/resources/en-us.yaml
index fb595f18d..81ade9e0f 100644
--- a/ui/core/translations/resources/en-us.yaml
+++ b/ui/core/translations/resources/en-us.yaml
@@ -122,6 +122,17 @@ user:
messages:
welcome:
title: Welcome to Users
+ accounts:
+ title: Accounts
+ description: Users may be associated with any number of accounts.
+ add-accounts:
+ title: Add Accounts
+ description: Select accounts to associate with this user.
+ no-accounts:
+ title: No Accounts Available
+ description: There are no accounts available to add to this user.
+ actions:
+ add-accounts: Add Accounts
role:
title: Role
title_plural: Roles
@@ -180,4 +191,4 @@ target:
description: No hosts sets available to add to target.
add:
title: Add Host Sets
- description: Select host sets to assign to this target.
\ No newline at end of file
+ description: Select host sets to assign to this target.