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.