Merge branch 'develop' into hide-change-password-link

This commit is contained in:
Susmitha Girumala authored and GitHub committed 2020-09-30 22:27:46 -04:00
commit 21868ce30f
36 files changed
+983 -65

No files matched your search

+80 -1
View File
@@ -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);
}
}
+33
View File
@@ -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,
};
}
}
+1
View File
@@ -0,0 +1 @@
export { default } from 'api/serializers/user';
+107 -4
View File
@@ -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'
);
});
});
@@ -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: {},
},
});
@@ -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: {},
},
});
});
});
@@ -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|>
@@ -18,7 +18,7 @@
<row.headerCell>{{t 'form.id.label'}}</row.headerCell>
<row.headerCell>{{t 'form.name.label'}}</row.headerCell>
<row.headerCell>{{t 'form.type.label'}}</row.headerCell>
<row.headerCell>{{t 'resources.host-catalog.title_plural'}}</row.headerCell>
<row.headerCell>{{t 'resources.host-catalog.title'}}</row.headerCell>
</header.row>
</table.header>
<table.body as |body|>
@@ -57,4 +57,4 @@
</message.link>
</Rose::Message>
</Rose::Layout::Centered>
{{/unless}}
{{/unless}}
@@ -0,0 +1,56 @@
{{#if this.hasAvailableAccounts}}
<Rose::Form
class="full-width"
@onSubmit={{fn this.submit @submit}}
@cancel={{@cancel}}
@disabled={{@model.isSaving}}
@error={{@model.errors.base}}
as |form|
>
<form.actions
@submitText={{t "resources.user.actions.add-accounts"}}
@cancelText={{t "actions.cancel"}} />
<Rose::Table as |table|>
<table.header as |header|>
<header.row as |row|>
<row.headerCell>{{t 'form.id.label'}}</row.headerCell>
<row.headerCell>{{t 'form.username.label'}}</row.headerCell>
<row.headerCell>{{t 'resources.auth-method.title'}}</row.headerCell>
</header.row>
</table.header>
<table.body as |body|>
{{#each this.filteredAccounts as |account|}}
<body.row as |row|>
<row.cell>
<form.checkbox
@label={{account.id}}
@description={{account.description}}
onChange={{fn this.toggleAccount account}} />
</row.cell>
<row.cell>{{account.username}}</row.cell>
<row.cell>
<code>{{account.auth_method_id}}</code>
</row.cell>
</body.row>
{{/each}}
</table.body>
</Rose::Table>
</Rose::Form>
{{/if}}
{{#unless this.hasAvailableAccounts}}
<Rose::Layout::Centered>
<Rose::Message @title={{t "resources.user.messages.no-accounts.title"}} as |message|>
<message.description>
{{t "resources.user.messages.no-accounts.description"}}
</message.description>
<message.link @route="scopes.scope.users.user.accounts">
<Rose::Icon @name="chevron-left" />
{{t "actions.back"}}
</message.link>
</Rose::Message>
</Rose::Layout::Centered>
{{/unless}}
@@ -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);
}
}
@@ -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');
}
}
+4 -1
View File
@@ -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');
});
@@ -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',
});
}
}
@@ -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 });
}
}
}
@@ -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');
}
}
@@ -4,7 +4,7 @@
<table.header as |header|>
<header.row as |row|>
<row.headerCell>{{t 'form.name.label'}}</row.headerCell>
<row.headerCell>{{t 'form.login_name.label'}}</row.headerCell>
<row.headerCell>{{t 'form.username.label'}}</row.headerCell>
<row.headerCell>{{t 'form.id.label'}}</row.headerCell>
</header.row>
</table.header>
@@ -1,3 +1,25 @@
{{title @model.displayName}}
{{outlet}}
<Rose::Layout::Page as |page|>
<page.breadcrumbs>
<BreadCrumbs />
</page.breadcrumbs>
<page.header>
{{outlet "header"}}
</page.header>
<page.navigation>
{{outlet "navigation"}}
</page.navigation>
<page.actions>
{{outlet "actions"}}
</page.actions>
<page.body>
{{outlet}}
</page.body>
</Rose::Layout::Page>
@@ -0,0 +1,12 @@
<Rose::Dropdown @text="{{t 'actions.manage'}}" @dropdownRight={{true}} as |dropdown|>
<dropdown.link @route="scopes.scope.users.user.add-accounts">
{{t "resources.user.actions.add-accounts"}}
</dropdown.link>
<dropdown.button
@style="danger"
@disabled={{@model.canSave}}
{{on "click" (route-action "delete" @model)}}
>
{{t 'actions.delete-user'}}
</dropdown.button>
</Rose::Dropdown>
@@ -0,0 +1,3 @@
<h2>{{t "resources.user.title"}}</h2>
<p>{{t "resources.user.description"}}</p>
<p><code>{{@model.id}}</code></p>
@@ -0,0 +1,8 @@
<Rose::Nav::Tabs as |nav|>
<nav.link @route='scopes.scope.users.user.index'>
{{t 'titles.details'}}
</nav.link>
<nav.link @route='scopes.scope.users.user.accounts'>
{{t 'resources.user.messages.accounts.title'}}
</nav.link>
</Rose::Nav::Tabs>
@@ -0,0 +1,53 @@
{{title (t 'resources.user.messages.accounts.title')}}
{{#if @model.accounts}}
<Rose::Table as |table|>
<table.header as |header|>
<header.row as |row|>
<row.headerCell>{{t 'form.username.label'}}</row.headerCell>
<row.headerCell>{{t 'form.id.label'}}</row.headerCell>
<row.headerCell>{{t "titles.actions"}}</row.headerCell>
</header.row>
</table.header>
<table.body as |body|>
{{#each @model.accounts as |account|}}
<body.row as |row|>
<row.headerCell>
{{account.username}}
</row.headerCell>
<row.cell>
<code>{{account.id}}</code>
</row.cell>
<row.cell>
<Rose::Dropdown
@icon="more-horizontal"
@iconOnly={{true}}
@showCaret={{false}}
@dropdownRight={{true}}
@text="{{t "actions.manage"}}" as |dropdown|
>
<dropdown.button
@style="danger"
{{on "click" (route-action "removeAccount" @model.user account)}}
>
{{t "actions.remove"}}
</dropdown.button>
</Rose::Dropdown>
</row.cell>
</body.row>
{{/each}}
</table.body>
</Rose::Table>
{{else}}
<Rose::Layout::Centered>
<Rose::Message @title={{t "resources.user.messages.accounts.title"}} as |message|>
<message.description>
{{t "resources.user.messages.accounts.description"}}
</message.description>
</Rose::Message>
</Rose::Layout::Centered>
{{/if}}
@@ -0,0 +1,25 @@
{{title (t 'resources.user.actions.add-accounts')}}
{{#if @model.accounts}}
<Form::User::AddAccounts
@model={{@model.user}}
@authMethods={{@model.authMethods}}
@accounts={{@model.accounts}}
@submit={{route-action "addAccounts" @model.user}}
@cancel={{route-action "cancel"}}
/>
{{/if}}
{{#unless @model.accounts}}
<Rose::Layout::Centered>
<Rose::Message @title={{t "resources.user.messages.no-accounts.title"}} as |message|>
<message.description>
{{t "resources.user.messages.no-accounts.description"}}
</message.description>
<message.link @route="scopes.scope.users.user.accounts">
<Rose::Icon @name="chevron-left" />
{{t "actions.back"}}
</message.link>
</Rose::Message>
</Rose::Layout::Centered>
{{/unless}}
@@ -0,0 +1,2 @@
<h2>{{t "resources.user.messages.add-accounts.title"}}</h2>
<p>{{t "resources.user.messages.add-accounts.description"}}</p>
@@ -1,41 +1,5 @@
<Rose::Layout::Page as |page|>
<page.breadcrumbs>
<BreadCrumbs />
</page.breadcrumbs>
<page.header>
<h2>{{t "resources.user.title"}}</h2>
<p>{{t "resources.user.description"}}</p>
<p><code>{{@model.id}}</code></p>
</page.header>
<page.actions>
<Rose::Dropdown @text="{{t 'actions.manage'}}" @dropdownRight={{true}} as |dropdown|>
<dropdown.button
@style="danger"
@disabled={{@model.canSave}}
{{on "click" (route-action "delete" @model)}}
>
{{t 'actions.delete-user'}}
</dropdown.button>
</Rose::Dropdown>
</page.actions>
<page.navigation>
<Rose::Nav::Tabs as |nav|>
<nav.link @route='scopes.scope.users.user'>
{{t 'titles.details'}}
</nav.link>
</Rose::Nav::Tabs>
</page.navigation>
<page.body>
<Form::User
@model={{@model}}
@submit={{route-action "save" @model}}
@cancel={{route-action "cancel" @model}}
/>
</page.body>
</Rose::Layout::Page>
<Form::User
@model={{@model}}
@submit={{route-action "save" @model}}
@cancel={{route-action "cancel" @model}}
/>
+25
View File
@@ -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 } }) => {
+6 -3
View File
@@ -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] });
});
}
})
+3 -2
View File
@@ -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')
});
+1 -1
View File
@@ -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');
+14 -1
View File
@@ -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;
}
});
@@ -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"]'));
});
});
@@ -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);
});
});
@@ -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);
});
});
@@ -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);
});
});
@@ -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);
});
});
+3 -3
View File
@@ -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
+12 -1
View File
@@ -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.
description: Select host sets to assign to this target.