diff --git a/addons/api/addon/abilities/session.js b/addons/api/addon/abilities/session.js
index ba9e7301f..5a506c049 100644
--- a/addons/api/addon/abilities/session.js
+++ b/addons/api/addon/abilities/session.js
@@ -14,13 +14,25 @@ export default class SessionAbility extends ModelAbility {
// =permissions
/**
- * Only "active" or "pending" sessions may be read.
+ * Only available sessions may be read.
* @type {boolean}
*/
get canRead() {
return (
- (this.model.isActive || this.model.isPending) &&
+ this.model.isAvailable &&
(this.hasAuthorizedAction('read:self') || super.canRead)
);
}
+
+ /**
+ * Only available sessions may be canceled.
+ * @type {boolean}
+ */
+ get canCancel() {
+ return (
+ this.model.isAvailable &&
+ (this.hasAuthorizedAction('cancel:self') ||
+ this.hasAuthorizedAction('cancel'))
+ );
+ }
}
diff --git a/addons/api/addon/models/session.js b/addons/api/addon/models/session.js
index 6096a8745..6848ba2ef 100644
--- a/addons/api/addon/models/session.js
+++ b/addons/api/addon/models/session.js
@@ -4,7 +4,6 @@
*/
import GeneratedSessionModel from '../generated/models/session';
-import { computed } from '@ember/object';
import { equal } from '@ember/object/computed';
import { tracked } from '@glimmer/tracking';
import { A } from '@ember/array';
@@ -127,7 +126,6 @@ export default class SessionModel extends GeneratedSessionModel {
/**
* @type {boolean}
*/
- @computed('isActive', 'isPending')
get isAvailable() {
return this.isActive || this.isPending;
}
@@ -136,7 +134,6 @@ export default class SessionModel extends GeneratedSessionModel {
* The full proxy address and port if address exists, otherwise null.
* @type {?string}
*/
- @computed('proxy_address', 'proxy_port')
get proxy() {
return this.proxy_address
? `${this.proxy_address}:${this.proxy_port}`
@@ -148,6 +145,9 @@ export default class SessionModel extends GeneratedSessionModel {
* @type {TargetModel}
*/
get target() {
+ if (!this.target_id) {
+ return null;
+ }
return this.store.peekRecord('target', this.target_id);
}
diff --git a/addons/api/mirage/factories/session.js b/addons/api/mirage/factories/session.js
index 48c681814..864d1494a 100644
--- a/addons/api/mirage/factories/session.js
+++ b/addons/api/mirage/factories/session.js
@@ -7,6 +7,7 @@ import factory from '../generated/factories/session';
import { trait } from 'miragejs';
import { faker } from '@faker-js/faker';
import generateId from '../helpers/id';
+import permissions from '../helpers/permissions';
const statusStrings = ['pending', 'active', 'canceling', 'terminated'];
@@ -15,7 +16,8 @@ export const pickRandomStatusString = () =>
export default factory.extend({
id: () => generateId('ss_'),
-
+ authorized_actions: () =>
+ permissions.authorizedActionsFor('session') || ['read', 'cancel'],
status: pickRandomStatusString(),
/**
diff --git a/addons/api/tests/unit/abilities/session-test.js b/addons/api/tests/unit/abilities/session-test.js
index 2d2d2ab75..5cc2166d5 100644
--- a/addons/api/tests/unit/abilities/session-test.js
+++ b/addons/api/tests/unit/abilities/session-test.js
@@ -108,4 +108,85 @@ module('Unit | Ability | session', function (hooks) {
assert.true(canService.can('read session', session));
});
+
+ test('can cancel a session that is active', function (assert) {
+ assert.expect(1);
+
+ const session = store.createRecord('session', {
+ authorized_actions: ['cancel'],
+ status: STATUS_SESSION_ACTIVE,
+ });
+
+ assert.true(canService.can('cancel session', session));
+ });
+
+ test('can cancel a session that has cancel:self', function (assert) {
+ assert.expect(1);
+
+ const session = store.createRecord('session', {
+ authorized_actions: ['cancel:self'],
+ status: STATUS_SESSION_ACTIVE,
+ });
+
+ assert.true(canService.can('cancel session', session));
+ });
+
+ test('can cancel a session that is pending', function (assert) {
+ assert.expect(1);
+
+ const session = store.createRecord('session', {
+ authorized_actions: ['cancel'],
+ status: STATUS_SESSION_PENDING,
+ });
+
+ assert.true(canService.can('cancel session', session));
+ });
+
+ test('cannot cancel a session that is canceling', function (assert) {
+ assert.expect(1);
+
+ const session = store.createRecord('session', {
+ authorized_actions: ['cancel'],
+ status: STATUS_SESSION_CANCELING,
+ });
+
+ assert.false(canService.can('cancel session', session));
+ });
+
+ test('cannot cancel a session that is terminated', function (assert) {
+ assert.expect(1);
+
+ const session = store.createRecord('session', {
+ authorized_actions: ['cancel'],
+ status: STATUS_SESSION_TERMINATED,
+ });
+
+ assert.false(canService.can('cancel session', session));
+ });
+
+ test('cannot cancel a session that is not authorized', function (assert) {
+ assert.expect(4);
+
+ const activeSession = store.createRecord('session', {
+ authorized_actions: [],
+ status: STATUS_SESSION_ACTIVE,
+ });
+ const pendingSession = store.createRecord('session', {
+ authorized_actions: [],
+ status: STATUS_SESSION_PENDING,
+ });
+ const cancelingSession = store.createRecord('session', {
+ authorized_actions: [],
+ status: STATUS_SESSION_CANCELING,
+ });
+ const terminatedSession = store.createRecord('session', {
+ authorized_actions: [],
+ status: STATUS_SESSION_TERMINATED,
+ });
+
+ assert.false(canService.can('cancel session', activeSession));
+ assert.false(canService.can('cancel session', pendingSession));
+ assert.false(canService.can('cancel session', cancelingSession));
+ assert.false(canService.can('cancel session', terminatedSession));
+ });
});
diff --git a/addons/rose/app/styles/rose/components/layout/_body-content.scss b/addons/rose/app/styles/rose/components/layout/_body-content.scss
index 0195535af..74b4e57ac 100644
--- a/addons/rose/app/styles/rose/components/layout/_body-content.scss
+++ b/addons/rose/app/styles/rose/components/layout/_body-content.scss
@@ -8,7 +8,7 @@
.rose-layout-body-content {
display: grid;
grid-template-areas: 'body body sidebar';
- grid-template-columns: 1fr 1fr 300px;
+ grid-template-columns: 1fr 1fr fit-content(300px);
grid-template-rows: auto 1fr;
gap: 2rem;
diff --git a/ui/desktop/app/components/session/tabs/index.hbs b/ui/desktop/app/components/session/tabs/index.hbs
index b01005577..bfcc82975 100644
--- a/ui/desktop/app/components/session/tabs/index.hbs
+++ b/ui/desktop/app/components/session/tabs/index.hbs
@@ -34,98 +34,105 @@
{{/if}}
-
-
+
+ {{/if}}
diff --git a/ui/desktop/app/routes/scopes/scope/projects/sessions.js b/ui/desktop/app/routes/scopes/scope/projects/sessions.js
index 7def33b5c..f15e08917 100644
--- a/ui/desktop/app/routes/scopes/scope/projects/sessions.js
+++ b/ui/desktop/app/routes/scopes/scope/projects/sessions.js
@@ -7,7 +7,6 @@ import Route from '@ember/routing/route';
import { inject as service } from '@ember/service';
import { action } from '@ember/object';
import { notifySuccess, notifyError } from 'core/decorators/notify';
-import { resourceFilter } from 'core/decorators/resource-filter';
import { loading } from 'ember-loading';
export default class ScopesScopeProjectsSessionsRoute extends Route {
@@ -16,23 +15,8 @@ export default class ScopesScopeProjectsSessionsRoute extends Route {
@service store;
@service ipc;
@service session;
- @service resourceFilterStore;
@service router;
-
- // =attributes
-
- @resourceFilter({
- allowed: ['active', 'pending', 'canceling', 'terminated'],
- defaultValue: ['active', 'pending', 'canceling'],
- })
- status;
-
- @resourceFilter({
- allowed: (route) => route.modelFor('scopes.scope.projects'),
- serialize: ({ id }) => id,
- findBySerialized: ({ id }, value) => id === value,
- })
- project;
+ @service can;
// =methods
@@ -43,45 +27,6 @@ export default class ScopesScopeProjectsSessionsRoute extends Route {
if (!this.session.isAuthenticated) this.router.transitionTo('index');
}
- /**
- * Loads all sessions under current scope for the current user.
- *
- * NOTE: previously, sessions were filtered only with API filter queries.
- * In an effort to offload processing from the controller, sessions
- * are now filtered on the client by projects and status,
- * while user_id filtering remains server side.
- *
- * @return {Promise{[SessionModel]}}
- */
- async model() {
- const { id: scope_id } = this.modelFor('scopes.scope');
- const queryOptions = {
- scope_id,
- recursive: true,
- include_terminated: this.status?.includes('terminated'),
- };
-
- // Recursively query sessions within the current scope for the current user
- let sessions = await this.resourceFilterStore.queryBy(
- 'session',
- { user_id: this.session.data.authenticated.user_id },
- queryOptions
- );
-
- // If project filters are selected, filter sessions by the selected projects
- if (this.project?.length) {
- const projectIDs = this.project.map((p) => p?.id);
- sessions = sessions.filter((s) => projectIDs.includes(s?.scopeID));
- }
-
- // If status filters are selected, filter sessions by the selected statuses
- if (this.status?.length) {
- sessions = sessions.filter((s) => this.status.includes(s?.status));
- }
-
- return sessions;
- }
-
// =actions
/**
@@ -93,10 +38,14 @@ export default class ScopesScopeProjectsSessionsRoute extends Route {
@notifyError(({ message }) => message, { catch: true })
@notifySuccess('notifications.canceled-success')
async cancelSession(session) {
+ let updatedSession = session;
// fetch session from API to verify we have most up to date record
- const updatedSession = await this.store.findRecord('session', session.id, {
- reload: true,
- });
+ if (this.can.can('read session', session)) {
+ updatedSession = await this.store.findRecord('session', session.id, {
+ reload: true,
+ });
+ }
+
await updatedSession.cancelSession();
await this.ipc.invoke('stop', { session_id: session.id });
@@ -108,25 +57,6 @@ export default class ScopesScopeProjectsSessionsRoute extends Route {
}
}
- /**
- * Sets the specified resource filter field to the specified value.
- * @param {string} field
- * @param value
- */
- @action
- filterBy(field, value) {
- this[field] = value;
- }
-
- /**
- * Clears and filter selections.
- */
- @action
- clearAllFilters() {
- this.status = [];
- this.project = [];
- }
-
/**
* refreshes session data.
*/
diff --git a/ui/desktop/app/routes/scopes/scope/projects/sessions/index.js b/ui/desktop/app/routes/scopes/scope/projects/sessions/index.js
index 8f251194e..a41b08761 100644
--- a/ui/desktop/app/routes/scopes/scope/projects/sessions/index.js
+++ b/ui/desktop/app/routes/scopes/scope/projects/sessions/index.js
@@ -4,5 +4,88 @@
*/
import Route from '@ember/routing/route';
+import { action } from '@ember/object';
+import { inject as service } from '@ember/service';
+import { resourceFilter } from 'core/decorators/resource-filter';
-export default class ScopesScopeProjectsSessionsIndexRoute extends Route {}
+export default class ScopesScopeProjectsSessionsIndexRoute extends Route {
+ // =services
+
+ @service session;
+ @service resourceFilterStore;
+
+ // =attributes
+
+ @resourceFilter({
+ allowed: ['active', 'pending', 'canceling', 'terminated'],
+ defaultValue: ['active', 'pending', 'canceling'],
+ })
+ status;
+
+ @resourceFilter({
+ allowed: (route) => route.modelFor('scopes.scope.projects'),
+ serialize: ({ id }) => id,
+ findBySerialized: ({ id }, value) => id === value,
+ })
+ project;
+
+ // =methods
+
+ /**
+ * Loads all sessions under current scope for the current user.
+ *
+ * NOTE: previously, sessions were filtered only with API filter queries.
+ * In an effort to offload processing from the controller, sessions
+ * are now filtered on the client by projects and status,
+ * while user_id filtering remains server side.
+ *
+ * @return {Promise{[SessionModel]}}
+ */
+ async model() {
+ const { id: scope_id } = this.modelFor('scopes.scope');
+ const queryOptions = {
+ scope_id,
+ recursive: true,
+ include_terminated: this.status?.includes('terminated'),
+ };
+
+ // Recursively query sessions within the current scope for the current user
+ let sessions = await this.resourceFilterStore.queryBy(
+ 'session',
+ { user_id: this.session.data.authenticated.user_id },
+ queryOptions
+ );
+
+ // If project filters are selected, filter sessions by the selected projects
+ if (this.project?.length) {
+ const projectIDs = this.project.map((p) => p?.id);
+ sessions = sessions.filter((s) => projectIDs.includes(s?.scopeID));
+ }
+
+ // If status filters are selected, filter sessions by the selected statuses
+ if (this.status?.length) {
+ sessions = sessions.filter((s) => this.status.includes(s?.status));
+ }
+
+ return sessions;
+ }
+
+ /**
+ * Sets the specified resource filter field to the specified value.
+ * @param {string} field
+ * @param value
+ */
+ @action
+ filterBy(field, value) {
+ this[field] = value;
+ }
+
+ /**
+ * Clears and filter selections.
+ */
+ @action
+ clearAllFilters() {
+ this.status = [];
+ this.project = [];
+ }
+}
diff --git a/ui/desktop/app/routes/scopes/scope/projects/targets/target.js b/ui/desktop/app/routes/scopes/scope/projects/targets/target.js
index f74d07f3b..5d2d19954 100644
--- a/ui/desktop/app/routes/scopes/scope/projects/targets/target.js
+++ b/ui/desktop/app/routes/scopes/scope/projects/targets/target.js
@@ -178,6 +178,7 @@ export default class ScopesScopeProjectsTargetsTargetRoute extends Route {
id: session_id,
proxy_address: address,
proxy_port: port,
+ target_id: model.id,
},
],
});
diff --git a/ui/desktop/app/templates/scopes/scope/projects/sessions/index.hbs b/ui/desktop/app/templates/scopes/scope/projects/sessions/index.hbs
index c035a5cff..e01c4a1ce 100644
--- a/ui/desktop/app/templates/scopes/scope/projects/sessions/index.hbs
+++ b/ui/desktop/app/templates/scopes/scope/projects/sessions/index.hbs
@@ -18,7 +18,7 @@
{{#let
- (resource-filter 'scopes.scope.projects.sessions' 'status')
+ (resource-filter 'scopes.scope.projects.sessions.index' 'status')
as |filter|
}}
@@ -201,11 +201,12 @@
- {{#if B.data.isAvailable}}
+ {{#if (can 'cancel session' B.data)}}
{{/if}}
@@ -214,7 +215,7 @@
{{else if (has-resource-filter-selections
- 'scopes.scope.projects.sessions' 'status' 'project'
+ 'scopes.scope.projects.sessions.index' 'status' 'project'
)}}
diff --git a/ui/desktop/app/templates/scopes/scope/projects/sessions/session.hbs b/ui/desktop/app/templates/scopes/scope/projects/sessions/session.hbs
index 949943d37..55177d90a 100644
--- a/ui/desktop/app/templates/scopes/scope/projects/sessions/session.hbs
+++ b/ui/desktop/app/templates/scopes/scope/projects/sessions/session.hbs
@@ -15,13 +15,15 @@
-
+ {{#if (can 'cancel session' @model)}}
+
+ {{/if}}
diff --git a/ui/desktop/tests/acceptance/projects/sessions/index-test.js b/ui/desktop/tests/acceptance/projects/sessions/index-test.js
index 2d052ed74..4be5c71a1 100644
--- a/ui/desktop/tests/acceptance/projects/sessions/index-test.js
+++ b/ui/desktop/tests/acceptance/projects/sessions/index-test.js
@@ -191,14 +191,7 @@ module('Acceptance | projects | sessions', function (hooks) {
test('can link to an active session with read:self permissions', async function (assert) {
assert.expect(1);
- this.server.schema.sessions.all().destroy();
- this.server.create('session', {
- authorized_actions: ['read:self'],
- scope: instances.scopes.project,
- target: instances.target,
- status: STATUS_SESSION_ACTIVE,
- user: instances.user,
- });
+ instances.session.update({ authorized_actions: ['read:self'] });
await visit(urls.sessions);
@@ -222,6 +215,19 @@ module('Acceptance | projects | sessions', function (hooks) {
.isVisible();
});
+ test('can link to session even without read permissions', async function (assert) {
+ assert.expect(1);
+ instances.session.update({ authorized_actions: [] });
+
+ await visit(urls.sessions);
+
+ assert
+ .dom(
+ 'tbody tr:first-child td:first-child [data-test-session-detail-link]'
+ )
+ .isVisible();
+ });
+
test('cannot link to a canceling session', async function (assert) {
assert.expect(2);
instances.session.update({ status: STATUS_SESSION_CANCELING });
@@ -255,6 +261,38 @@ module('Acceptance | projects | sessions', function (hooks) {
.isVisible();
});
+ test('can cancel an active session with cancel permissions', async function (assert) {
+ assert.expect(1);
+
+ await visit(urls.sessions);
+
+ assert
+ .dom('tbody tr:first-child [data-test-session-cancel-button]')
+ .isVisible();
+ });
+
+ test('can cancel an active session with cancel:self permissions', async function (assert) {
+ assert.expect(1);
+ instances.session.update({ authorized_actions: ['cancel:self'] });
+
+ await visit(urls.sessions);
+
+ assert
+ .dom('tbody tr:first-child [data-test-session-cancel-button]')
+ .isVisible();
+ });
+
+ test('cannot click cancel button without cancel permissions', async function (assert) {
+ assert.expect(1);
+ instances.session.update({ authorized_actions: [] });
+
+ await visit(urls.sessions);
+
+ assert
+ .dom('tbody tr:first-child [data-test-session-cancel-button]')
+ .isNotVisible();
+ });
+
test('cancelling a session shows success alert', async function (assert) {
assert.expect(1);
stubs.ipcService.withArgs('stop');
diff --git a/ui/desktop/tests/acceptance/projects/sessions/session-test.js b/ui/desktop/tests/acceptance/projects/sessions/session-test.js
index 2e6491a37..bcbf6a98a 100644
--- a/ui/desktop/tests/acceptance/projects/sessions/session-test.js
+++ b/ui/desktop/tests/acceptance/projects/sessions/session-test.js
@@ -123,6 +123,11 @@ module('Acceptance | projects | sessions | session', function (hooks) {
stubs.ipcService = sinon.stub(ipcService, 'invoke');
});
+ hooks.afterEach(function () {
+ // reset onUncaughtException to original state
+ QUnit.onUncaughtException = originalUncaughtException;
+ });
+
test('visiting session detail', async function (assert) {
assert.expect(1);
@@ -132,11 +137,6 @@ module('Acceptance | projects | sessions | session', function (hooks) {
assert.strictEqual(currentURL(), urls.session);
});
- hooks.afterEach(function () {
- // reset onUncaughtException to original state
- QUnit.onUncaughtException = originalUncaughtException;
- });
-
test('visiting session with no credentials', async function (assert) {
assert.expect(4);
stubs.ipcService.withArgs('cliExists').returns(true);
@@ -199,6 +199,24 @@ module('Acceptance | projects | sessions | session', function (hooks) {
assert.strictEqual(currentURL(), urls.session);
});
+ test('can cancel a session with cancel:self permissions', async function (assert) {
+ assert.expect(1);
+ instances.session.update({ authorized_actions: ['cancel:self'] });
+
+ await visit(urls.session);
+
+ assert.dom('[data-test-session-detail-cancel-button]').isVisible();
+ });
+
+ test('cannot cancel a session without cancel permissions', async function (assert) {
+ assert.expect(1);
+ instances.session.update({ authorized_actions: [] });
+
+ await visit(urls.session);
+
+ assert.dom('[data-test-session-detail-cancel-button]').isNotVisible();
+ });
+
test('cancelling a session shows success alert', async function (assert) {
assert.expect(1);
stubs.ipcService.withArgs('stop');