diff --git a/addons/api/addon/generated/models/session.js b/addons/api/addon/generated/models/session.js new file mode 100644 index 000000000..a33426e4c --- /dev/null +++ b/addons/api/addon/generated/models/session.js @@ -0,0 +1,36 @@ +import BaseModel from '../../models/base'; +import { attr } from '@ember-data/model'; + +export default class GeneratedSessionModel extends BaseModel { + + // =attributes + + @attr('string') user_id; + @attr('string') target_id; + @attr('string') host_id; + @attr('string') host_set_id; + + @attr('string', { + description: 'The name of the current status of this session.', + readOnly: true, + }) + status; + + @attr('date', { + description: 'The time this resource was created\nOutput only.', + readOnly: true, + }) + created_time; + + @attr('date', { + description: 'The time this resource was last updated\nOutput only.', + readOnly: true, + }) + updated_time; + + @attr('number', { + description: 'Current version number of this resource.', + }) + version; + +} diff --git a/addons/api/addon/models/account.js b/addons/api/addon/models/account.js index b28558732..71b337f44 100644 --- a/addons/api/addon/models/account.js +++ b/addons/api/addon/models/account.js @@ -1,13 +1,32 @@ import GeneratedAccountModel from '../generated/models/account'; import { fragment } from 'ember-data-model-fragments/attributes'; +import { computed } from '@ember/object'; export default class AccountModel extends GeneratedAccountModel { + + // =attributes + /** * Attributes of this resource, if any, represented as a JSON fragment. * @type {FragmentAccountAttributesModel} */ @fragment('fragment-account-attributes', { defaultValue: {} }) attributes; + /** + * Convenience for getting username in account. + * @type {string} + */ + @computed('type', 'attributes.{login_name}') + get username() { + let username = ''; + switch (this.type) { + case 'password': + username = this.attributes.login_name; + break; + } + return username; + } + // =methods /** @@ -41,7 +60,11 @@ export default class AccountModel extends GeneratedAccountModel { * @param {object} options.adapterOptions * @return {Promise} */ - changePassword(currentPassword, newPassword, options = { adapterOptions: {} }) { + changePassword( + currentPassword, + newPassword, + options = { adapterOptions: {} } + ) { const defaultAdapterOptions = { method: 'change-password', currentPassword, diff --git a/addons/api/addon/models/session.js b/addons/api/addon/models/session.js new file mode 100644 index 000000000..b37f08fc2 --- /dev/null +++ b/addons/api/addon/models/session.js @@ -0,0 +1,27 @@ +import GeneratedSessionModel from '../generated/models/session'; + +export default class SessionModel extends GeneratedSessionModel { + + // =methods + + /** + * Cancels the session via the `cancel` method. + * See serializer and adapter for more information. + * @param {object} options + * @param {object} options.adapterOptions + * @return {Promise} + */ + cancelSession(options = { adapterOptions: {} }) { + const defaultAdapterOptions = { + method: 'cancel', + }; + return this.save({ + ...options, + adapterOptions: { + ...defaultAdapterOptions, + ...options.adapterOptions, + }, + }); + } + +} diff --git a/addons/api/addon/serializers/account.js b/addons/api/addon/serializers/account.js index 416bc488e..01618a081 100644 --- a/addons/api/addon/serializers/account.js +++ b/addons/api/addon/serializers/account.js @@ -22,8 +22,11 @@ export default class AccountSerializer extends ApplicationSerializer { } if (snapshot?.adapterOptions?.method === 'change-password') { const { currentPassword, newPassword } = snapshot?.adapterOptions; - serialized = - this.serializeForChangePassword(snapshot, currentPassword, newPassword); + serialized = this.serializeForChangePassword( + snapshot, + currentPassword, + newPassword + ); } return serialized; } diff --git a/addons/api/addon/serializers/session.js b/addons/api/addon/serializers/session.js new file mode 100644 index 000000000..d8d5ea7f8 --- /dev/null +++ b/addons/api/addon/serializers/session.js @@ -0,0 +1,31 @@ +import ApplicationSerializer from './application'; + +export default class SessionSerializer extends ApplicationSerializer { + // =methods + + /** + * If `adapterOptions.method` is `cancel`, the serialization should + * include the version field only. + * @override + * @param {Snapshot} snapshot + * @return {object} + */ + serialize(snapshot) { + let serialized = super.serialize(...arguments); + if (snapshot?.adapterOptions?.method === 'cancel') { + serialized = this.serializeForCancel(snapshot); + } + return serialized; + } + + /** + * Returns a payload containing only version + * @param {Snapshot} snapshot + * @return {object} + */ + serializeForCancel(snapshot) { + return { + version: snapshot.attr('version'), + }; + } +} diff --git a/addons/api/app/models/session.js b/addons/api/app/models/session.js new file mode 100644 index 000000000..3106229d6 --- /dev/null +++ b/addons/api/app/models/session.js @@ -0,0 +1 @@ +export { default } from 'api/models/session'; diff --git a/addons/api/app/serializers/session.js b/addons/api/app/serializers/session.js new file mode 100644 index 000000000..407fe5c23 --- /dev/null +++ b/addons/api/app/serializers/session.js @@ -0,0 +1 @@ +export { default } from 'api/serializers/session'; diff --git a/addons/api/tests/unit/models/account-test.js b/addons/api/tests/unit/models/account-test.js index 554a72be5..f937b3c26 100644 --- a/addons/api/tests/unit/models/account-test.js +++ b/addons/api/tests/unit/models/account-test.js @@ -9,4 +9,17 @@ module('Unit | Model | account', function (hooks) { let model = store.createRecord('account', {}); assert.ok(model); }); + + test('it has an username attribute for password account', function (assert) { + assert.expect(2); + const store = this.owner.lookup('service:store'); + let model = store.createRecord('account', { + name: 'name', + type: 'password', + }); + assert.notOk(model.username); + model.attributes.login_name = 'username'; + assert.equal(model.username, 'username'); + }); + }); diff --git a/addons/api/tests/unit/models/session-test.js b/addons/api/tests/unit/models/session-test.js new file mode 100644 index 000000000..2d373a2f6 --- /dev/null +++ b/addons/api/tests/unit/models/session-test.js @@ -0,0 +1,13 @@ +import { module, test } from 'qunit'; +import { setupTest } from 'ember-qunit'; + +module('Unit | Model | session', function(hooks) { + setupTest(hooks); + + // Replace this with your real tests. + test('it exists', function(assert) { + let store = this.owner.lookup('service:store'); + let model = store.createRecord('session', {}); + assert.ok(model); + }); +}); diff --git a/addons/api/tests/unit/serializers/session-test.js b/addons/api/tests/unit/serializers/session-test.js new file mode 100644 index 000000000..fde7c215f --- /dev/null +++ b/addons/api/tests/unit/serializers/session-test.js @@ -0,0 +1,31 @@ +import { module, test } from 'qunit'; +import { setupTest } from 'ember-qunit'; + +module('Unit | Serializer | session', function(hooks) { + setupTest(hooks); + + // Replace this with your real tests. + test('it exists', function(assert) { + let store = this.owner.lookup('service:store'); + let serializer = store.serializerFor('session'); + + assert.ok(serializer); + }); + + test('it serializes only version when `adapterOptions.method` is `cancel`', function (assert) { + assert.expect(1); + const store = this.owner.lookup('service:store'); + const serializer = store.serializerFor('session'); + const record = store.createRecord('session', { + version: 1, + }); + const snapshot = record._createSnapshot(); + snapshot.adapterOptions = { + method: 'cancel', + }; + const serializedRecord = serializer.serialize(snapshot); + assert.deepEqual(serializedRecord, { + version: 1, + }); + }); +}); diff --git a/addons/rose/addon/styles/rose/_typography.scss b/addons/rose/addon/styles/rose/_typography.scss index b44914ce8..c5fda86e4 100644 --- a/addons/rose/addon/styles/rose/_typography.scss +++ b/addons/rose/addon/styles/rose/_typography.scss @@ -54,3 +54,7 @@ p { a { color: var(--action); } + +time { + font-variant-numeric: tabular-nums; +} diff --git a/addons/rose/addon/styles/rose/components/table/_table.scss b/addons/rose/addon/styles/rose/components/table/_table.scss index ba63d37aa..956ab5faa 100644 --- a/addons/rose/addon/styles/rose/components/table/_table.scss +++ b/addons/rose/addon/styles/rose/components/table/_table.scss @@ -34,4 +34,8 @@ padding: sizing.rems(l) sizing.rems(s); } } + + [class*="rose-button"] { + margin-bottom: 0; + } } diff --git a/ui/core/app/formats.js b/ui/core/app/formats.js index f5d1f1502..9ea07ca26 100644 --- a/ui/core/app/formats.js +++ b/ui/core/app/formats.js @@ -7,6 +7,15 @@ export default { }, }, date: { + default: { + day: '2-digit', + month: '2-digit', + year: 'numeric', + hour: 'numeric', + minute: 'numeric', + second: 'numeric', + hour12: false + }, hhmmss: { hour: 'numeric', minute: 'numeric', diff --git a/ui/core/app/helpers/format-date-iso-human.js b/ui/core/app/helpers/format-date-iso-human.js new file mode 100644 index 000000000..2744a4eeb --- /dev/null +++ b/ui/core/app/helpers/format-date-iso-human.js @@ -0,0 +1,7 @@ +import { helper } from '@ember/component/helper'; + +export default helper(function formatDateIsoHuman(params/*, hash*/) { + return params[0].toISOString() + .replace('T', ' ') + .replace(/\.\d*Z/, ''); +}); diff --git a/ui/core/app/helpers/format-date-iso.js b/ui/core/app/helpers/format-date-iso.js new file mode 100644 index 000000000..52cd9687d --- /dev/null +++ b/ui/core/app/helpers/format-date-iso.js @@ -0,0 +1,5 @@ +import { helper } from '@ember/component/helper'; + +export default helper(function formatDateIso(params/*, hash*/) { + return params[0].toISOString(); +}); diff --git a/ui/core/app/router.js b/ui/core/app/router.js index 204ced768..0894289f9 100644 --- a/ui/core/app/router.js +++ b/ui/core/app/router.js @@ -52,6 +52,7 @@ Router.map(function () { }); this.route('new'); }); + this.route('sessions', function() {}); }); this.route('new'); }); diff --git a/ui/core/app/routes/scopes/scope/projects/project/sessions.js b/ui/core/app/routes/scopes/scope/projects/project/sessions.js new file mode 100644 index 000000000..b41d76d7a --- /dev/null +++ b/ui/core/app/routes/scopes/scope/projects/project/sessions.js @@ -0,0 +1,85 @@ +import Route from '@ember/routing/route'; +import { inject as service } from '@ember/service'; +import { action } from '@ember/object'; +import { all, hash } from 'rsvp'; +import { task, timeout } from 'ember-concurrency'; + +const POLL_TIMEOUT_SECONDS = 2.5; + +export default class ScopesScopeProjectsProjectSessionsRoute extends Route { + + // =services + + @service intl; + @service notify; + + // =attributes + + /** + * A simple Ember Concurrency-based polling task that refreshes the route + * every POLL_TIMEOUT_SECONDS seconds. This is necessary to display changes + * to session `status` that may occur. + * + * NOTE: tasks are sort of attributes and sort of methods, but they are not + * language-level constructs. Thus we annotate this task as if it + * is an attribute. + * @type {Task} + */ + @task(function * () { + while(true) { + yield timeout(POLL_TIMEOUT_SECONDS * 1000); + yield this.refresh(); + } + }).drop() poller; + + // =methods + + /** + * Loads all sessions under the current scope and encapsulates them into + * an array of objects containing their associated users and targets. + * @return {Promise{[{session: SessionModel, user: UserModel, target: TargetModel}]}} + */ + async model() { + const { id: scope_id } = this.modelFor('scopes.scope.projects.project'); + const sessions = await this.store.query('session', { scope_id }); + return all( + sessions.map(session => hash({ + session, + user: this.store.findRecord('user', session.user_id), + target: this.store.findRecord('target', session.target_id), + })) + ); + } + + /** + * When this route is activated (entered), begin polling for changes. + */ + activate() { + this.poller.perform(); + } + + /** + * When this route is deactivated (exited), stop polling for changes. + */ + deactivate() { + this.poller.cancelAll(); + } + + // =actions + + /** + * Cancels the specified session and notifies user of success or error. + * @param {SessionModel} + */ + @action + async cancelSession(session) { + try { + await session.cancelSession(); + this.notify.success(this.intl.t('notify.session-canceled')); + } catch (error) { + // TODO: replace with translated strings + this.notify.error(error.message, { closeAfter: null }); + } + } + +} diff --git a/ui/core/app/routes/scopes/scope/projects/project/sessions/index.js b/ui/core/app/routes/scopes/scope/projects/project/sessions/index.js new file mode 100644 index 000000000..a8ae56692 --- /dev/null +++ b/ui/core/app/routes/scopes/scope/projects/project/sessions/index.js @@ -0,0 +1,4 @@ +import Route from '@ember/routing/route'; + +export default class ScopesScopeProjectsProjectSessionsIndexRoute extends Route { +} 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 8586ac7c0..83ed4fa1a 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,6 +4,7 @@ {{t 'form.label.name'}} + {{t 'form.label.identification'}} {{t 'form.label.id'}} @@ -19,6 +20,9 @@

{{account.description}}

+ + {{account.username}} + {{account.id}} diff --git a/ui/core/app/templates/scopes/scope/groups/group/members.hbs b/ui/core/app/templates/scopes/scope/groups/group/members.hbs index fb562164b..24a6e13d9 100644 --- a/ui/core/app/templates/scopes/scope/groups/group/members.hbs +++ b/ui/core/app/templates/scopes/scope/groups/group/members.hbs @@ -7,7 +7,7 @@ {{t 'form.label.name'}} {{t 'form.label.id'}} - {{t "form.label.actions"}} + {{t "titles.actions"}} 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 ae4f79e0b..e29af0451 100644 --- a/ui/core/app/templates/scopes/scope/projects/project/_sidebar.hbs +++ b/ui/core/app/templates/scopes/scope/projects/project/_sidebar.hbs @@ -1,4 +1,7 @@ + + {{t "resources.sessions"}} + {{t "resources.targets"}} diff --git a/ui/core/app/templates/scopes/scope/projects/project/host-catalogs/host-catalog/host-sets/host-set/hosts.hbs b/ui/core/app/templates/scopes/scope/projects/project/host-catalogs/host-catalog/host-sets/host-set/hosts.hbs index 437140df4..b0ef4a625 100644 --- a/ui/core/app/templates/scopes/scope/projects/project/host-catalogs/host-catalog/host-sets/host-set/hosts.hbs +++ b/ui/core/app/templates/scopes/scope/projects/project/host-catalogs/host-catalog/host-sets/host-set/hosts.hbs @@ -6,7 +6,7 @@ {{t "form.label.name"}} {{t "form.label.type"}} {{t "form.label.id"}} - {{t "form.label.actions"}} + {{t "titles.actions"}} diff --git a/ui/core/app/templates/scopes/scope/projects/project/sessions.hbs b/ui/core/app/templates/scopes/scope/projects/project/sessions.hbs new file mode 100644 index 000000000..37fea125e --- /dev/null +++ b/ui/core/app/templates/scopes/scope/projects/project/sessions.hbs @@ -0,0 +1,3 @@ +{{title (t "resources.sessions")}} + +{{outlet}} diff --git a/ui/core/app/templates/scopes/scope/projects/project/sessions/index.hbs b/ui/core/app/templates/scopes/scope/projects/project/sessions/index.hbs new file mode 100644 index 000000000..6fe82d12b --- /dev/null +++ b/ui/core/app/templates/scopes/scope/projects/project/sessions/index.hbs @@ -0,0 +1,66 @@ + + + + + + + +

{{t "resources.sessions"}}

+
+ + + + {{#if @model}} + + + + + {{t "form.label.id"}} + {{t "resources.user"}} + {{t "resources.target"}} + {{t "form.label.started"}} + {{t "form.label.status"}} + {{t "titles.actions"}} + + + + {{#each @model as |sessionAggregate|}} + + + {{sessionAggregate.session.id}} + + {{sessionAggregate.user.displayName}} + {{sessionAggregate.target.displayName}} + + + + + {{sessionAggregate.session.status}} + + + + {{t "actions.cancel"}} + + + + {{/each}} + + + + {{else}} + + + + + {{t "descriptions.no-sessions"}} + + + + + {{/if}} + + + +
diff --git a/ui/core/app/templates/scopes/scope/projects/project/targets/target/host-sets.hbs b/ui/core/app/templates/scopes/scope/projects/project/targets/target/host-sets.hbs index 40f12df7f..583a8df55 100644 --- a/ui/core/app/templates/scopes/scope/projects/project/targets/target/host-sets.hbs +++ b/ui/core/app/templates/scopes/scope/projects/project/targets/target/host-sets.hbs @@ -8,7 +8,7 @@ {{t "form.label.name"}} {{t "form.label.type"}} {{t "form.label.id"}} - {{t "form.label.actions"}} + {{t "titles.actions"}} diff --git a/ui/core/app/templates/scopes/scope/roles/role/principals.hbs b/ui/core/app/templates/scopes/scope/roles/role/principals.hbs index b364b136f..160c91c7b 100644 --- a/ui/core/app/templates/scopes/scope/roles/role/principals.hbs +++ b/ui/core/app/templates/scopes/scope/roles/role/principals.hbs @@ -8,7 +8,7 @@ {{t 'form.label.name'}} {{t 'form.label.type'}} {{t 'form.label.id'}} - {{t "form.label.actions"}} + {{t "titles.actions"}} diff --git a/ui/core/mirage/config.js b/ui/core/mirage/config.js index 399d7f03e..59cbf0180 100644 --- a/ui/core/mirage/config.js +++ b/ui/core/mirage/config.js @@ -1,7 +1,7 @@ import config from '../config/environment'; import { authHandler, deauthHandler } from './route-handlers/auth'; -// Uncomment to use in simulating error responses -// import { Response } from 'miragejs'; +import { pickRandomStatusString } from './factories/session'; +//import { Response } from 'miragejs'; export default function() { @@ -294,6 +294,38 @@ export default function() { return target.update(updatedAttrs); }); + // session + + // To simulate changes to `session.status` that may occur in the backend, + // we quietly randomize the value of the field on GET. + this.get('/sessions', function ({ sessions }, { queryParams: { scope_id } }) { + sessions.where(session => session.scopeId === scope_id) + .models + .forEach(session => session.update({ + status: pickRandomStatusString() + })); + return sessions.where(session => session.scopeId === scope_id) + }); + this.get('/sessions/:id', function ({ sessions }, { params: { id } }) { + const session = sessions.find(id); + return session.update({ + status: pickRandomStatusString() + }); + }); + this.post('/sessions/:idMethod', function ({ sessions }, { params: { idMethod } }) { + const attrs = this.normalizedRequestAttrs(); + const id = idMethod.split(':')[0]; + const method = idMethod.split(':')[1]; + const session = sessions.find(id); + const updatedAttrs = { + version: attrs.version + }; + if (method === 'cancel') { + updatedAttrs.status = 'canceling'; + } + return session.update(updatedAttrs); + }); + /* Uncomment the following line and the Response import above * Then change the response code to simulate error responses. * this.get('/scopes', () => new Response(505)); diff --git a/ui/core/mirage/factories/session.js b/ui/core/mirage/factories/session.js new file mode 100644 index 000000000..0100a4b75 --- /dev/null +++ b/ui/core/mirage/factories/session.js @@ -0,0 +1,46 @@ +import factory from '../generated/factories/target'; +import { trait } from 'ember-cli-mirage'; +import { random } from 'faker'; + +const statusStrings = [ + 'pending', + 'active', + 'canceling', + 'terminated' +]; + +export const pickRandomStatusString = () => random.arrayElement(statusStrings); + +export default factory.extend({ + + id: (i) => `session-id-${i}`, + status: pickRandomStatusString, + + /** + * Sessions generated with this trait will automatically form + * associations to user, target, host set, and host (if available) within + * the current scope. + */ + withAssociations: trait({ + afterCreate(record, server) { + const scopeId = record.scopeId; + if (record.scope.type === 'project') { + const orgScopeId = record.scope?.scope.id; + const user = + server.schema.users.where({ scopeId: orgScopeId }).models[0]; + if (user) record.update({ user }); + } + const target = server.schema.targets.where({ scopeId }).models[0]; + if (target) { + record.update({ target }); + const hostSet = target.hostSets.models[0]; + if (hostSet) { + record.update({ hostSet }); + const host = hostSet.hosts.models[0]; + if (host) record.update({ host }); + } + } + } + }) + +}); diff --git a/ui/core/mirage/generated/factories/session.js b/ui/core/mirage/generated/factories/session.js new file mode 100644 index 000000000..25aca3759 --- /dev/null +++ b/ui/core/mirage/generated/factories/session.js @@ -0,0 +1,14 @@ +import { Factory } from 'ember-cli-mirage'; +import { random, date } from 'faker'; + +/** + * GeneratedSessionModelFactory + */ +export default Factory.extend({ + + type: 'tcp', + created_time: () => date.recent(), + updated_time: () => date.recent(), + version: () => random.number(), + +}); diff --git a/ui/core/mirage/models/host-catalog.js b/ui/core/mirage/models/host-catalog.js index 08b1f5ad3..8c2757e71 100644 --- a/ui/core/mirage/models/host-catalog.js +++ b/ui/core/mirage/models/host-catalog.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(), + hostSets: hasMany() }); diff --git a/ui/core/mirage/models/session.js b/ui/core/mirage/models/session.js new file mode 100644 index 000000000..91a4da594 --- /dev/null +++ b/ui/core/mirage/models/session.js @@ -0,0 +1,11 @@ +import { Model, belongsTo } from 'ember-cli-mirage'; + +export default Model.extend({ + + scope: belongsTo(), + user: belongsTo(), + target: belongsTo(), + host: belongsTo(), + hostSet: belongsTo(), + +}); diff --git a/ui/core/mirage/scenarios/default.js b/ui/core/mirage/scenarios/default.js index 9492f92e3..5d1a85fe6 100644 --- a/ui/core/mirage/scenarios/default.js +++ b/ui/core/mirage/scenarios/default.js @@ -26,6 +26,7 @@ export default function(server) { server.schema.scopes.where({type: 'project'}).models.forEach(scope => { server.createList('host-catalog', 2, { scope }, 'withChildren'); server.createList('target', 2, { scope }, 'withRandomHostSets'); + server.createList('session', 4, { scope }, 'withAssociations'); }); } diff --git a/ui/core/mirage/serializers/session.js b/ui/core/mirage/serializers/session.js new file mode 100644 index 000000000..3e14a23e6 --- /dev/null +++ b/ui/core/mirage/serializers/session.js @@ -0,0 +1,22 @@ +import ApplicationSerializer from './application'; +import { underscore } from '@ember/string'; + +export default ApplicationSerializer.extend({ + modelName: 'session', + + keyForRelationshipId(relationshipName) { + return `${this._container.inflector.singularize( + underscore(relationshipName) + )}_id`; + }, + + _hashForModel(model) { + const json = ApplicationSerializer.prototype._hashForModel.apply(this, arguments); + json.user_id = model.userId; + json.target_id = model.targetId; + json.host_set_id = model.hostSetId; + json.host_id = model.hostId; + return json; + } + +}); diff --git a/ui/core/package.json b/ui/core/package.json index 258f13a98..212ce3416 100644 --- a/ui/core/package.json +++ b/ui/core/package.json @@ -54,6 +54,7 @@ "ember-cli-sri": "^2.1.1", "ember-cli-template-lint": "^1.0.0-beta.3", "ember-cli-uglify": "^3.0.0", + "ember-concurrency": "^1.3.0", "ember-data": "~3.16.0", "ember-data-model-fragments": "5.0.0-beta.0", "ember-exam": "^5.0.1", diff --git a/ui/core/public/logo-boundary.svg b/ui/core/public/logo-boundary.svg index ea71154fb..21465bdef 100644 --- a/ui/core/public/logo-boundary.svg +++ b/ui/core/public/logo-boundary.svg @@ -2,56 +2,55 @@ height="30" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 310 67"> - { + return new Response( + 400, + {}, + { + status: 400, + code: 'invalid_argument', + message: 'The request was invalid.', + details: { + request_fields: [ + { + name: 'name', + description: 'Name is required.', + }, + ], + }, + } + ); + }); + await visit(urls.newAccount); + await fillIn('[name="name"]', 'new account'); + await click('form [type="submit"]'); + await a11yAudit(); + assert.ok( + find('[role="alert"]').textContent.trim(), + 'The request was invalid.', + 'Displays primary error message.' + ); + assert.ok( + find('.rose-form-error-message').textContent.trim(), + 'Name is required.', + 'Displays field-level errors.' + ); + }); + + test('errors are displayed when save on account fails', async function (assert) { + assert.expect(1); + this.server.patch('/accounts/:id', () => { + return new Response( + 490, + {}, + { + status: 490, + code: 'error', + message: 'Oops.', + } + ); + }); + await visit(urls.account); + await fillIn('[name="name"]', 'save account'); + await click('form [type="submit"]'); + await a11yAudit(); + assert.ok( + find('[role="alert"]').textContent.trim(), + 'Oops.', + 'Displays primary error message.' + ); + }); + + test('errors are displayed when delete on account fails', async function (assert) { + assert.expect(1); + this.server.del('/accounts/:id', () => { + return new Response( + 490, + {}, + { + status: 490, + code: 'error', + message: 'Oops.', + } + ); + }); + await visit(urls.account); + await click('.rose-layout-page-actions .rose-dropdown-button-danger'); + await a11yAudit(); + assert.ok( + find('[role="alert"]').textContent.trim(), + 'Oops.', + 'Displays primary error message.' + ); + }); + + test('saving an existing account with invalid fields displays error messages', async function (assert) { + assert.expect(2); + this.server.patch('/accounts/:id', () => { + return new Response( + 400, + {}, + { + status: 400, + code: 'invalid_argument', + message: 'The request was invalid.', + details: { + request_fields: [ + { + name: 'name', + description: 'Name is required.', + }, + ], + }, + } + ); + }); + await visit(urls.account); + await fillIn('[name="name"]', 'existing account'); + await click('form [type="submit"]'); + await a11yAudit(); + assert.ok( + find('[role="alert"]').textContent.trim(), + 'The request was invalid.', + 'Displays primary error message.' + ); + assert.ok( + find('.rose-form-error-message').textContent.trim(), + 'Name is required.', + 'Displays field-level errors.' + ); + }); + + module('password', function() { + test('can set a new password for account', async function (assert) { + assert.expect(1); + this.server.post('/accounts/:id', (_, { requestBody }) => { + const attrs = JSON.parse(requestBody); + assert.equal(attrs.password, 'update password', 'new password is set'); + }); + await visit(urls.setAccountPassword); + await fillIn('[name="password"]', 'update password'); + await click('form [type="submit"]:not(:disabled)'); + await a11yAudit(); + }); + + test('can cancel setting new password', async function (assert) { + assert.expect(1); + await visit(urls.setAccountPassword); + await fillIn('[name="password"]', 'update password'); + await click('form button:not([type="submit"])'); + assert.notOk(find('[name="password"]').textContent.trim()); + }); + + test('errors are displayed when setting password fails', async function (assert) { + assert.expect(1); + this.server.post('/accounts/:id', () => { + return new Response( + 490, + {}, + { + status: 490, + code: 'error', + message: 'Oops.', + } + ); + }); + await visit(urls.setAccountPassword); + await fillIn('[name="password"]', 'update password'); + await click('form [type="submit"]'); + await a11yAudit(); + assert.ok( + find('[role="alert"]').textContent.trim(), + 'Oops.', + 'Displays primary error message.' + ); + }); + }); +}); diff --git a/ui/core/tests/acceptance/targets-test.js b/ui/core/tests/acceptance/targets-test.js index d7858be8a..822986f12 100644 --- a/ui/core/tests/acceptance/targets-test.js +++ b/ui/core/tests/acceptance/targets-test.js @@ -1,77 +1,330 @@ import { module, test } from 'qunit'; -//import { visit, currentURL } from '@ember/test-helpers'; +import { visit, currentURL, click, find, findAll, fillIn } 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 { -// authenticateSession, +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'; +} from 'ember-simple-auth/test-support'; module('Acceptance | targets', function (hooks) { setupApplicationTest(hooks); setupMirage(hooks); + const instances = { + scopes: { + global: null, + org: null, + project: null, + }, + target: null, + hostSets: null, + }; + const urls = { + orgScope: null, + projects: null, + project: null, + targets: null, + newTarget: null, + target: null, + targetHostSets: null, + targetAddHostSets: null, + }; + hooks.beforeEach(function () { // Setup Mirage mock resources for this test + authenticateSession({}); + instances.scopes.global = this.server.create('scope', { id: 'global' }); + instances.scopes.org = this.server.create('scope', { + type: 'org', + scope: { id: 'global', type: 'global' }, + }); + instances.scopes.project = this.server.create('scope', { + type: 'project', + scope: { id: instances.scopes.org.id, type: 'org' }, + }); + + instances.hostCatalog = this.server.create('host-catalog', { + scope: instances.scopes.project + }, 'withChildren'); + instances.target = this.server.create('target', { + scope: instances.scopes.project, + hostSets: instances.hostCatalog.hostSets + }); + // Generate route URLs for resources + urls.orgScope = `/scopes/${instances.scopes.org.id}`; + urls.projects = `${urls.orgScope}/projects`; + urls.project = `${urls.projects}/${instances.scopes.project.id}`; + urls.targets = `${urls.project}/targets`; + urls.newTarget = `${urls.targets}/new`; + urls.target = `${urls.targets}/${instances.target.id}`; + urls.targetHostSets = `${urls.target}/host-sets`; + urls.targetAddHostSets = `${urls.target}/add-host-sets`; + }); + + hooks.afterEach(async function() { + const notification = find('.rose-notification'); + if(notification) { + await click('.rose-notification-dismiss'); + } }); test('visiting targets', async function (assert) { - assert.expect(0); + assert.expect(1); + await visit(urls.targets); + await a11yAudit(); + assert.equal(currentURL(), urls.targets); }); test('can navigate to a target form', async function (assert) { - assert.expect(0); + assert.expect(1); + await visit(urls.targets); + await click('main tbody .rose-table-header-cell:nth-child(1) a'); + await a11yAudit(); + assert.equal(currentURL(), urls.target); }); test('can update a target and save changes', async function (assert) { - assert.expect(0); + assert.expect(2); + await visit(urls.target); + await fillIn('[name="name"]', 'update name'); + await fillIn('[name="default_port"]', '1234'); + await click('form [type="submit"]:not(:disabled)'); + assert.equal(this.server.db.targets[0].name, 'update name'); + assert.equal(this.server.db.targets[0].attributes.default_port, '1234'); }); test('can update a target and cancel changes', async function (assert) { - assert.expect(0); + assert.expect(2); + await visit(urls.target); + await fillIn('[name="name"]', 'update name'); + await fillIn('[name="default_port"]', '1234'); + await click('form button:not([type="submit"])'); + assert.notEqual(this.server.db.targets[0].name, 'update name'); + assert.notEqual(this.server.db.targets[0].port, '1234'); }); test('can create new target', async function (assert) { - assert.expect(0); + assert.expect(1); + const targetsCount = this.server.db.targets.length; + await visit(urls.newTarget); + await fillIn('[name="name"]', 'Target name'); + await fillIn('[name="description"]', 'description'); + await fillIn('[name="default_port"]', '1234'); + await click('form [type="submit"]:not(:disabled)'); + assert.equal(this.server.db.targets.length, targetsCount + 1); }); test('can cancel new target creation', async function (assert) { - assert.expect(0); + assert.expect(2); + const targetsCount = this.server.db.targets.length; + await visit(urls.newTarget); + await fillIn('[name="name"]', 'Target Name'); + await click('form button:not([type="submit"])'); + assert.equal(this.server.db.targets.length, targetsCount); + assert.equal(currentURL(), urls.targets); + }); + + test('can delete a target', async function(assert) { + assert.expect(1); + const targetsCount = this.server.db.targets.length; + await visit(urls.target); + await click('.rose-layout-page-actions .rose-dropdown-button-danger'); + assert.equal(this.server.db.targets.length, targetsCount - 1); }); test('saving a new target with invalid fields displays error messages', async function (assert) { - assert.expect(0); + assert.expect(2); + this.server.post('/targets', () => { + return new Response( + 400, + {}, + { + status: 400, + code: 'invalid_argument', + message: 'The request was invalid.', + details: { + request_fields: [ + { + name: 'name', + description: 'Name is required.', + }, + ], + }, + } + ); + }); + await visit(urls.newTarget); + await fillIn('[name="name"]', 'new target'); + await click('form [type="submit"]'); + await a11yAudit(); + assert.ok(find('[role="alert"]')); + assert.ok(find('.rose-form-error-message')); }); test('errors are displayed when save on target fails', async function (assert) { - assert.expect(0); + assert.expect(1); + this.server.patch('/targets/:id', () => { + return new Response( + 490, + {}, + { + status: 490, + code: 'error', + message: 'Oops.', + } + ); + }); + await visit(urls.target); + await fillIn('[name="name"]', 'save target'); + await click('form [type="submit"]'); + await a11yAudit(); + assert.ok(find('[role="alert"]')); }); test('errors are displayed when delete on a target fails', async function (assert) { - assert.expect(0); + assert.expect(1); + this.server.del('/targets/:id', () => { + return new Response( + 490, + {}, + { + status: 490, + code: 'error', + message: 'Oops.', + } + ); + }); + await visit(urls.target); + await click('.rose-layout-page-actions .rose-dropdown-button-danger'); + await a11yAudit(); + assert.ok(find('[role="alert"]')); }); test('saving an existing target with invalid fields displays error messages', async function (assert) { - assert.expect(0); + assert.expect(2); + this.server.patch('/targets/:id', () => { + return new Response( + 400, + {}, + { + status: 400, + code: 'invalid_argument', + message: 'The request was invalid.', + details: { + request_fields: [ + { + name: 'name', + description: 'Name is required.', + }, + ], + }, + } + ); + }); + await visit(urls.target); + await fillIn('[name="name"]', 'existing target'); + await click('form [type="submit"]'); + await a11yAudit(); + assert.ok(find('[role="alert"]')); + assert.ok(find('.rose-form-error-message')); }); - test('viewing target host sets', async function (assert) { - assert.expect(0); + test('visiting target host sets', async function (assert) { + assert.expect(3); + const targetHostSetCount = instances.target.hostSets.length; + await visit(urls.targetHostSets); + await a11yAudit(); + assert.equal(currentURL(), urls.targetHostSets); + assert.ok(targetHostSetCount); + assert.equal(findAll('tbody tr').length, targetHostSetCount); }); test('can delete a host sets', async function (assert) { - assert.expect(0); + assert.expect(2); + const targetHostSetCount = instances.target.hostSets.length; + await visit(urls.targetHostSets); + assert.equal(findAll('tbody tr').length, targetHostSetCount); + await click('tbody tr .rose-dropdown-button-danger'); + assert.equal(findAll('tbody tr').length, targetHostSetCount - 1); + }); + + test('shows error message on host set remove', async function (assert) { + assert.expect(2); + this.server.post('/targets/:idMethod', () => { + return new Response( + 400, + {}, + { + status: 400, + code: 'invalid_argument', + message: 'The request was invalid.', + details: {}, + } + ); + }); + const targetHostSetCount = instances.target.hostSets.length; + await visit(urls.targetHostSets); + assert.equal(findAll('tbody tr').length, targetHostSetCount); + await click('tbody tr .rose-dropdown-button-danger'); + assert.ok(find('[role="alert"]')); }); test('select and save host sets to add', async function (assert) { - assert.expect(0); + assert.expect(3); + instances.target.update({ hostSetIds: [] }); + await visit(urls.targetHostSets); + assert.equal(findAll('tbody tr').length, 0); + await click('.rose-layout-page-actions a') + assert.equal(currentURL(), urls.targetAddHostSets); + await click('tbody label'); + await click('form [type="submit"]'); + await visit(urls.targetHostSets); + assert.equal(findAll('tbody tr').length, 1); }); test('select and cancel host sets to add', async function (assert) { - assert.expect(0); + assert.expect(4); + const targetHostSetCount = instances.target.hostSets.length; + await visit(urls.targetHostSets); + assert.equal(findAll('tbody tr').length, targetHostSetCount); + // first, remove a target host set (otherwise none would be available to add) + await click('tbody tr .rose-dropdown-button-danger'); + assert.equal(findAll('tbody tr').length, targetHostSetCount - 1); + await click('.rose-layout-page-actions a') + assert.equal(currentURL(), urls.targetAddHostSets); + await click('tbody label'); + await click('form [type="button"]'); + await visit(urls.targetHostSets); + assert.equal(findAll('tbody tr').length, targetHostSetCount - 1); + }); + + test('shows error message on host set add', async function (assert) { + assert.expect(4); + this.server.post('/targets/:idMethod', () => { + return new Response( + 400, + {}, + { + status: 400, + code: 'invalid_argument', + message: 'The request was invalid.', + details: {}, + } + ); + }); + instances.target.update({ hostSetIds: [] }); + await visit(urls.targetAddHostSets); + assert.equal(instances.target.hostSets.length, 0); + assert.equal(currentURL(), urls.targetAddHostSets); + await click('tbody label'); + await click('form [type="submit"]'); + assert.equal(currentURL(), urls.targetAddHostSets); + assert.equal(instances.target.hostSets.length, 0); }); }); diff --git a/ui/core/tests/integration/helpers/format-date-iso-human-test.js b/ui/core/tests/integration/helpers/format-date-iso-human-test.js new file mode 100644 index 000000000..94eec92fa --- /dev/null +++ b/ui/core/tests/integration/helpers/format-date-iso-human-test.js @@ -0,0 +1,17 @@ +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'ember-qunit'; +import { render } from '@ember/test-helpers'; +import { hbs } from 'ember-cli-htmlbars'; + +module('Integration | Helper | format-date-iso-human', function(hooks) { + setupRenderingTest(hooks); + + // Replace this with your real tests. + test('it renders', async function(assert) { + this.set('inputValue', new Date('2020-01-01T00:00:00.999Z')); + + await render(hbs`{{format-date-iso-human inputValue}}`); + + assert.equal(this.element.textContent.trim(), '2020-01-01 00:00:00'); + }); +}); diff --git a/ui/core/tests/integration/helpers/format-date-iso-test.js b/ui/core/tests/integration/helpers/format-date-iso-test.js new file mode 100644 index 000000000..2954d3890 --- /dev/null +++ b/ui/core/tests/integration/helpers/format-date-iso-test.js @@ -0,0 +1,17 @@ +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'ember-qunit'; +import { render } from '@ember/test-helpers'; +import { hbs } from 'ember-cli-htmlbars'; + +module('Integration | Helper | format-date-iso', function(hooks) { + setupRenderingTest(hooks); + + // Replace this with your real tests. + test('it renders', async function(assert) { + this.set('inputValue', new Date('2020-01-01T00:00:00.999Z')); + + await render(hbs`{{format-date-iso inputValue}}`); + + assert.equal(this.element.textContent.trim(), '2020-01-01T00:00:00.999Z'); + }); +}); diff --git a/ui/core/tests/unit/routes/scopes/scope/projects/project/sessions-test.js b/ui/core/tests/unit/routes/scopes/scope/projects/project/sessions-test.js new file mode 100644 index 000000000..70769438e --- /dev/null +++ b/ui/core/tests/unit/routes/scopes/scope/projects/project/sessions-test.js @@ -0,0 +1,11 @@ +import { module, test } from 'qunit'; +import { setupTest } from 'ember-qunit'; + +module('Unit | Route | scopes/scope/projects/project/sessions', function(hooks) { + setupTest(hooks); + + test('it exists', function(assert) { + let route = this.owner.lookup('route:scopes/scope/projects/project/sessions'); + assert.ok(route); + }); +}); diff --git a/ui/core/tests/unit/routes/scopes/scope/projects/project/sessions/index-test.js b/ui/core/tests/unit/routes/scopes/scope/projects/project/sessions/index-test.js new file mode 100644 index 000000000..a774bf223 --- /dev/null +++ b/ui/core/tests/unit/routes/scopes/scope/projects/project/sessions/index-test.js @@ -0,0 +1,11 @@ +import { module, test } from 'qunit'; +import { setupTest } from 'ember-qunit'; + +module('Unit | Route | scopes/scope/projects/project/sessions/index', function(hooks) { + setupTest(hooks); + + test('it exists', function(assert) { + let route = this.owner.lookup('route:scopes/scope/projects/project/sessions/index'); + assert.ok(route); + }); +}); diff --git a/ui/core/translations/en-us.yaml b/ui/core/translations/en-us.yaml index fdfb779f9..029474e65 100644 --- a/ui/core/translations/en-us.yaml +++ b/ui/core/translations/en-us.yaml @@ -77,6 +77,8 @@ resources: targets: Targets accounts: Accounts account: Account + sessions: Sessions + session: Session titles: iam: IAM new: New @@ -114,6 +116,8 @@ titles: accounts: Accounts account: Account accounts-welcome: Welcome to Accounts + no-sessions: No Active Sessions + actions: Actions descriptions: host-catalogs: Overview of collections of hosts and host sets. empty-set: There are no items to display yet. You may be able to add items or try back later. @@ -136,6 +140,7 @@ descriptions: no-available-users: No users are available to add to this group. account-set-password: Set a password for the account. accounts-welcome: No accounts are available in this auth method. + no-sessions: There are no active sessions at this time. states: info: Info success: Success @@ -157,6 +162,8 @@ form: default_port: Default Port current_password: Current Password new_password: New Password + started: Started + status: Status errors: 401: title: You are not signed in @@ -180,3 +187,4 @@ notify: create-success: Created succesfully. save-success: Saved succesfully. delete-success: Deleted successfully. + session-canceled: Session canceled. diff --git a/yarn.lock b/yarn.lock index 5fd953ce0..03ce48f88 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8918,6 +8918,15 @@ ember-compatibility-helpers@^1.1.1, ember-compatibility-helpers@^1.1.2, ember-co ember-cli-version-checker "^2.1.1" semver "^5.4.1" +ember-concurrency@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/ember-concurrency/-/ember-concurrency-1.3.0.tgz#66f90fb792687470bcee1172adc0ebf33f5e8b9c" + integrity sha512-DwGlfWFpYyAkTwsedlEtK4t1DznJSculAW6Vq5S1C0shVPc5b6tTpHB2FFYisannSYkm+wpm1f1Pd40qiNPtOQ== + dependencies: + ember-cli-babel "^7.7.3" + ember-compatibility-helpers "^1.2.0" + ember-maybe-import-regenerator "^0.1.6" + ember-cookies@^0.5.0, ember-cookies@^0.5.2: version "0.5.2" resolved "https://registry.yarnpkg.com/ember-cookies/-/ember-cookies-0.5.2.tgz#06e33463f2f83254fefaf224cc944dec3fb9d3ba"