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"