diff --git a/forge/db/controllers/StorageSession.js b/forge/db/controllers/StorageSession.js
index 453a7da7b5..4f06acf441 100644
--- a/forge/db/controllers/StorageSession.js
+++ b/forge/db/controllers/StorageSession.js
@@ -50,5 +50,38 @@ module.exports = {
sessions.session = '{}'
await sessions.save()
}
+ },
+ async removeUserFromTeamSessions (app, user, team) {
+ const instances = await app.db.models.Project.byTeam(team.hashid)
+ for (let i = 0; i < instances.length; i++) {
+ const instance = instances[i]
+ const sessions = await app.db.models.StorageSession.byProject(instance.id)
+ if (sessions) {
+ const sessionInfo = JSON.parse(sessions.sessions)
+ let modified = false
+ const userSessions = Object.values(sessionInfo).filter(session => {
+ if (session.user === user.username) {
+ delete sessionInfo[session.accessToken]
+ modified = true
+ return true
+ }
+ return false
+ })
+ if (modified) {
+ sessions.sessions = JSON.stringify(sessionInfo)
+ await sessions.save()
+ }
+ if (userSessions.length > 0) {
+ for (let i = 0; i < userSessions.length; i++) {
+ const token = userSessions[i].accessToken
+ try {
+ await app.containers.revokeUserToken(instance, token) // logout:nodered(step-2)
+ } catch (error) {
+ app.log.warn(`Failed to revoke token for Instance ${instance.id}: ${error.toString()}`) // log error but continue to delete session
+ }
+ }
+ }
+ }
+ }
}
}
diff --git a/forge/db/controllers/Team.js b/forge/db/controllers/Team.js
index cad7c736b7..ca09e6324c 100644
--- a/forge/db/controllers/Team.js
+++ b/forge/db/controllers/Team.js
@@ -90,8 +90,9 @@ module.exports = {
}
await userRole.destroy()
+ await app.db.controllers.StorageSession.removeUserFromTeamSessions(user, team)
+
return true
- // console.warn('TODO: forge.db.controllers.Team.removeUser - expire oauth sessions')
}
return false
diff --git a/forge/db/models/Project.js b/forge/db/models/Project.js
index 660746243e..437e0d59a2 100644
--- a/forge/db/models/Project.js
+++ b/forge/db/models/Project.js
@@ -17,7 +17,7 @@ const { DataTypes, Op } = require('sequelize')
const Controllers = require('../controllers')
-const { KEY_HOSTNAME, KEY_SETTINGS, KEY_HA, KEY_PROTECTED } = require('./ProjectSettings')
+const { KEY_HOSTNAME, KEY_SETTINGS, KEY_HA, KEY_PROTECTED, KEY_HEALTH_CHECK_INTERVAL } = require('./ProjectSettings')
const BANNED_NAME_LIST = [
'www',
@@ -358,7 +358,8 @@ module.exports = {
{ key: KEY_SETTINGS },
{ key: KEY_HOSTNAME },
{ key: KEY_HA },
- { key: KEY_PROTECTED }
+ { key: KEY_PROTECTED },
+ { key: KEY_HEALTH_CHECK_INTERVAL }
]
},
required: false
diff --git a/forge/db/models/ProjectSettings.js b/forge/db/models/ProjectSettings.js
index 5bb9de7eba..1f9c566c13 100644
--- a/forge/db/models/ProjectSettings.js
+++ b/forge/db/models/ProjectSettings.js
@@ -13,12 +13,14 @@ const KEY_SETTINGS = 'settings'
const KEY_HOSTNAME = 'hostname'
const KEY_HA = 'ha'
const KEY_PROTECTED = 'protected'
+const KEY_HEALTH_CHECK_INTERVAL = 'healthCheckInterval'
module.exports = {
KEY_SETTINGS,
KEY_HOSTNAME,
KEY_HA,
KEY_PROTECTED,
+ KEY_HEALTH_CHECK_INTERVAL,
name: 'ProjectSettings',
schema: {
ProjectId: { type: DataTypes.UUID, unique: 'pk_settings' },
diff --git a/forge/db/views/Project.js b/forge/db/views/Project.js
index 527e8bc317..6163610a69 100644
--- a/forge/db/views/Project.js
+++ b/forge/db/views/Project.js
@@ -1,4 +1,4 @@
-const { KEY_HOSTNAME, KEY_SETTINGS, KEY_HA, KEY_PROTECTED } = require('../models/ProjectSettings')
+const { KEY_HOSTNAME, KEY_SETTINGS, KEY_HA, KEY_PROTECTED, KEY_HEALTH_CHECK_INTERVAL } = require('../models/ProjectSettings')
module.exports = function (app) {
app.addSchema({
@@ -32,6 +32,13 @@ module.exports = function (app) {
protected: {
type: 'object',
additionalProperties: true
+ },
+ launcherSettings: {
+ type: 'object',
+ properties: {
+ healthCheckInterval: { type: 'number' }
+ },
+ additionalProperties: false
}
}
})
@@ -61,6 +68,13 @@ module.exports = function (app) {
} else {
result.settings = {}
}
+ // Launcher Settings
+ const heathCheckIntervalRow = proj.ProjectSettings?.find((projectSettingsRow) => projectSettingsRow.key === KEY_HEALTH_CHECK_INTERVAL)
+ if (heathCheckIntervalRow) {
+ result.launcherSettings = {}
+ result.launcherSettings.healthCheckInterval = heathCheckIntervalRow?.value
+ }
+ // Environment
result.settings.env = app.db.controllers.Project.insertPlatformSpecificEnvVars(proj, result.settings.env)
if (!result.settings.palette?.modules) {
// If there are no modules listed in settings, check the StorageSettings
diff --git a/forge/routes/api/project.js b/forge/routes/api/project.js
index d5585a20f5..359325f8a9 100644
--- a/forge/routes/api/project.js
+++ b/forge/routes/api/project.js
@@ -1,4 +1,4 @@
-const { KEY_HOSTNAME, KEY_SETTINGS } = require('../../db/models/ProjectSettings')
+const { KEY_HOSTNAME, KEY_SETTINGS, KEY_HEALTH_CHECK_INTERVAL } = require('../../db/models/ProjectSettings')
const { Roles } = require('../../lib/roles')
const { isFQDN } = require('../../lib/validate')
@@ -312,6 +312,7 @@ module.exports = async function (app) {
name: { type: 'string' },
hostname: { type: 'string' },
settings: { type: 'object' },
+ launcherSettings: { type: 'object' },
projectType: { type: 'string' },
stack: { type: 'string' },
sourceProject: {
@@ -478,6 +479,19 @@ module.exports = async function (app) {
changesToPersist.stack = { from: request.project.stack, to: stack }
}
+ // Launcher settings
+ if (request.body?.launcherSettings?.healthCheckInterval) {
+ const oldInterval = await request.project.getSetting(KEY_HEALTH_CHECK_INTERVAL)
+ const newInterval = parseInt(request.body.launcherSettings.healthCheckInterval, 10)
+ if (isNaN(newInterval) || newInterval < 5000) {
+ reply.code(400).send({ code: 'invalid_heathCheckInterval', error: 'Invalid heath check interval' })
+ return
+ }
+ if (oldInterval !== newInterval) {
+ changesToPersist.healthCheckInterval = { from: oldInterval, to: newInterval }
+ }
+ }
+
/// Persist the changes
const updates = new app.auditLog.formatters.UpdatesCollection()
const transaction = await app.db.sequelize.transaction() // start a transaction
@@ -541,6 +555,11 @@ module.exports = async function (app) {
}
}
+ if (changesToPersist.healthCheckInterval) {
+ await request.project.updateSetting(KEY_HEALTH_CHECK_INTERVAL, changesToPersist.healthCheckInterval.to, { transaction })
+ updates.pushDifferences({ healthCheckInterval: changesToPersist.healthCheckInterval.from }, { healthCheckInterval: changesToPersist.healthCheckInterval.to })
+ }
+
await transaction.commit() // all good, commit the transaction
// Log the updates
@@ -795,7 +814,9 @@ module.exports = async function (app) {
reply.code(400).send({ code: 'project_suspended', error: 'Project suspended' })
return
}
+ // get settings from the driver
const settings = await app.containers.settings(request.project)
+ // add instance settings
settings.env = settings.env || {}
settings.baseURL = request.project.url
settings.forgeURL = app.config.base_url
@@ -805,6 +826,7 @@ module.exports = async function (app) {
settings.auditURL = request.project.auditURL
settings.state = request.project.state
settings.stack = request.project.ProjectStack?.properties || {}
+ settings.healthCheckInterval = await request.project.getSetting(KEY_HEALTH_CHECK_INTERVAL)
settings.settings = await app.db.controllers.Project.getRuntimeSettings(request.project)
if (settings.settings.env) {
settings.env = Object.assign({}, settings.settings.env, settings.env)
diff --git a/forge/routes/api/teamMembers.js b/forge/routes/api/teamMembers.js
index 026430efc7..cc20504726 100644
--- a/forge/routes/api/teamMembers.js
+++ b/forge/routes/api/teamMembers.js
@@ -155,6 +155,11 @@ module.exports = async function (app) {
const role = app.auditLog.formatters.roleObject(result.role)
updates.push('role', oldRole?.role || result.oldRole, role?.role || result.role)
await app.auditLog.Team.team.user.roleChanged(request.session.User, null, request.team, result.user, updates)
+ if (result.role < result.oldRole) {
+ // We should invalidate session for this user for the teams NR instances if lower
+ // might want to make this only if it drop under Member
+ await app.db.controllers.StorageSession.removeUserFromTeamSessions(request.user, request.team)
+ }
}
reply.send({ status: 'okay' })
} catch (err) {
diff --git a/frontend/src/pages/instance/Settings/LauncherSettings.vue b/frontend/src/pages/instance/Settings/LauncherSettings.vue
new file mode 100644
index 0000000000..271180bae1
--- /dev/null
+++ b/frontend/src/pages/instance/Settings/LauncherSettings.vue
@@ -0,0 +1,116 @@
+
+ Launcher Settings
+
+
+
+
diff --git a/frontend/src/pages/instance/Settings/index.vue b/frontend/src/pages/instance/Settings/index.vue
index 1b2dec9146..52054cb9a9 100644
--- a/frontend/src/pages/instance/Settings/index.vue
+++ b/frontend/src/pages/instance/Settings/index.vue
@@ -68,6 +68,7 @@ export default {
this.sideNavigation.push({ name: 'Editor', path: './editor' })
this.sideNavigation.push({ name: 'Security', path: './security' })
this.sideNavigation.push({ name: 'Palette', path: './palette' })
+ this.sideNavigation.push({ name: 'Launcher', path: './launcher' })
if (this.features.emailAlerts && this.team.type.properties.features?.emailAlerts) {
this.sideNavigation.push({ name: 'Alerts', path: './alerts' })
}
diff --git a/frontend/src/pages/instance/Settings/routes.js b/frontend/src/pages/instance/Settings/routes.js
index 5ace28ff09..a8fb46bf2e 100644
--- a/frontend/src/pages/instance/Settings/routes.js
+++ b/frontend/src/pages/instance/Settings/routes.js
@@ -5,6 +5,7 @@ import InstanceSettingsEditor from './Editor.vue'
import InstanceSettingsEnvVar from './Environment.vue'
import InstanceSettingsGeneral from './General.vue'
import InstanceSettingsHA from './HighAvailability.vue'
+import InstanceSettingsLauncher from './LauncherSettings.vue'
import InstanceSettingsPalette from './Palette.vue'
import InstanceSettingsProtect from './ProtectInstance.vue'
import InstanceSettingsSecurity from './Security.vue'
@@ -26,5 +27,6 @@ export default [
title: 'Instance - Change Type'
}
},
+ { path: 'launcher', name: 'instance-settings-launcher', component: InstanceSettingsLauncher },
{ path: 'alerts', name: 'instance-settings-alerts', component: InstanceSettingsAlerts }
]
diff --git a/test/e2e/frontend/cypress/tests-ee/instances/alerts.spec.js b/test/e2e/frontend/cypress/tests-ee/instances/alerts.spec.js
index abba6c0778..405e480ac3 100644
--- a/test/e2e/frontend/cypress/tests-ee/instances/alerts.spec.js
+++ b/test/e2e/frontend/cypress/tests-ee/instances/alerts.spec.js
@@ -28,7 +28,6 @@ describe('FlowFuse EE - Instance - Alerts', () => {
navigateToInstanceSettings('BTeam', 'instance-2-1')
// check Alerts in list and click
- cy.get('[data-el="section-side-menu"] li').should('have.length', 8)
cy.get('[data-el="section-side-menu"] li:last a').contains('Alerts')
cy.get('[data-el="section-side-menu"] li:last').click()
diff --git a/test/e2e/frontend/cypress/tests/instances/settings/launcher.spec.js b/test/e2e/frontend/cypress/tests/instances/settings/launcher.spec.js
new file mode 100644
index 0000000000..72ec0d70e1
--- /dev/null
+++ b/test/e2e/frontend/cypress/tests/instances/settings/launcher.spec.js
@@ -0,0 +1,88 @@
+///
+describe('FlowFuse - Instance - Settings - Launcher', () => {
+ function navigateToInstanceSettings (teamName, projectName) {
+ cy.request('GET', '/api/v1/user/teams')
+ .then((response) => {
+ const team = response.body.teams.find(
+ (team) => team.name === teamName
+ )
+ return cy.request('GET', `/api/v1/teams/${team.id}/projects`)
+ })
+ .then((response) => {
+ const project = response.body.projects.find(
+ (project) => project.name === projectName
+ )
+ cy.visit(`/instance/${project.id}/settings/general`)
+ cy.wait('@getInstance')
+ })
+ }
+
+ function getForm () {
+ return cy.get('[data-el="launcher-settings-form"]')
+ }
+
+ beforeEach(() => {
+ cy.intercept('GET', '/api/*/projects/').as('getProjects')
+ cy.intercept('GET', '/api/*/projects/*').as('getInstance')
+ cy.login('bob', 'bbPassword')
+ cy.home()
+ })
+
+ it('Validates health check interval user input', () => {
+ cy.intercept('PUT', '/api/*/projects/*').as('updateInstance')
+ // navigate to instance settings -> launcher tab
+ cy.login('bob', 'bbPassword')
+ cy.home()
+ navigateToInstanceSettings('BTeam', 'instance-2-1')
+
+ cy.get('[data-el="section-side-menu"] li').contains('Launcher').click()
+
+ // wait for url /instance/***/settings/launcher
+ cy.url().should('include', 'settings/launcher')
+
+ // Change value to < 5000
+ getForm().first('div').get('.ff-input > input[type=number]').clear()
+ getForm().first('div').get('.ff-input > input[type=number]').type(4999)
+ cy.get('[data-action="save-settings"]').should('be.disabled')
+ getForm().first('div').get('[data-el="form-row-error"').contains('Health check interval must be 5000 or greater').should('exist')
+
+ // Change value to > 5000
+ getForm().first('div').get('.ff-input > input[type=number]').clear()
+ getForm().first('div').get('.ff-input > input[type=number]').type(5001)
+ cy.get('[data-action="save-settings"]').should('not.be.disabled')
+ getForm().first('div').get('[data-el="form-row-error"').should('not.exist')
+ })
+
+ it('Can set health check interval value', () => {
+ cy.intercept('PUT', '/api/*/projects/*').as('updateInstance')
+
+ navigateToInstanceSettings('BTeam', 'instance-2-1')
+
+ // locate and click on the launcher tab
+ cy.get('[data-el="section-side-menu"] li').contains('Launcher').click()
+
+ // wait for url /instance/***/settings/launcher
+ cy.url().should('include', 'settings/launcher')
+
+ // // ensure the first child's title is correct
+ getForm().should('exist')
+ getForm().first('div').should('exist')
+ getForm().first('div').get('[data-el="form-row-title"]').contains('Health check interval (ms)').should('exist')
+ // ensure the first child's numeric input exists
+ getForm().first('div').get('.ff-input > input[type=number]').should('exist')
+
+ // Change value & save
+ const randomBetween6789and9876 = Math.floor(Math.random() * (9876 - 6789 + 1)) + 6789
+ getForm().first('div').get('.ff-input > input[type=number]').clear()
+ getForm().first('div').get('.ff-input > input[type=number]').type(randomBetween6789and9876)
+ cy.get('[data-action="save-settings"]').click()
+ cy.wait('@updateInstance')
+
+ // refresh page
+ navigateToInstanceSettings('BTeam', 'instance-2-1')
+ cy.get('[data-el="section-side-menu"] li').contains('Launcher').click()
+
+ // check value is restored
+ cy.get('[data-el="launcher-settings-form"]').first().get('.ff-input > input[type=number]').should('have.value', randomBetween6789and9876)
+ })
+})
diff --git a/test/unit/forge/db/controllers/StorageSession_spec.js b/test/unit/forge/db/controllers/StorageSession_spec.js
index df902c2b7c..b89e698088 100644
--- a/test/unit/forge/db/controllers/StorageSession_spec.js
+++ b/test/unit/forge/db/controllers/StorageSession_spec.js
@@ -71,4 +71,79 @@ describe('Storage Session controller', function () {
s3Sessions.should.not.have.property('token5')
})
})
+
+ describe('removeUserFromTeamSessions', function () {
+ it('remove user sessions from a teams instance\'s', async function () {
+ const projectType = await app.factory.createProjectType({
+ name: 'projectType2',
+ description: 'default project type',
+ properties: { foo: 'bar' }
+ })
+
+ const template = await app.factory.createProjectTemplate({
+ name: 'template1',
+ settings: {
+ httpAdminRoot: '',
+ codeEditor: '',
+ palette: {
+ npmrc: 'example npmrc',
+ catalogue: ['https://example.com/catalog'],
+ modules: [
+ { name: 'node-red-dashboard', version: '3.0.0' },
+ { name: 'node-red-contrib-ping', version: '0.3.0' }
+ ]
+ }
+ },
+ policy: {
+ httpAdminRoot: true,
+ dashboardUI: true,
+ codeEditor: true
+ }
+ }, app.TestObjects.userAlice)
+ const stack = await app.factory.createStack({ name: 'stack1' }, projectType)
+ const application = await app.factory.createApplication({ name: 'application-1' }, app.TestObjects.team1)
+
+ const p1 = await app.factory.createInstance(
+ { name: 'project-1' },
+ application,
+ stack,
+ template,
+ projectType,
+ { start: false }
+ )
+ const p2 = await app.factory.createInstance(
+ { name: 'project-2' },
+ application,
+ stack,
+ template,
+ projectType,
+ { start: false }
+ )
+
+ // p1 - two active sessions for alice, one for bob
+ // p2 - no sessions for alice
+ const s1 = await app.db.models.StorageSession.create({
+ sessions: '{"token1":{"user":"alice","client":"node-red-editor","scope":["*"],"accessToken":"token1","expires":1676376174919},"token2":{"user":"alice","client":"node-red-editor","scope":["*"],"accessToken":"token2","expires":1676376174919},"token3":{"user":"bob","client":"node-red-editor","scope":["*"],"accessToken":"token3","expires":1676376174919}}',
+ ProjectId: p1.id
+ })
+ const s2 = await app.db.models.StorageSession.create({
+ sessions: '{"token4":{"user":"bob","client":"node-red-editor","scope":["*"],"accessToken":"token3","expires":1676376174919}}',
+ ProjectId: p2.id
+ })
+
+ await app.db.controllers.StorageSession.removeUserFromTeamSessions(app.TestObjects.userAlice, app.TestObjects.team1)
+
+ await Promise.all([
+ s1.reload(),
+ s2.reload()
+ ])
+
+ const s1Sessions = JSON.parse(s1.sessions)
+ const s2Sessions = JSON.parse(s2.sessions)
+ s1Sessions.should.not.have.property('token1')
+ s1Sessions.should.not.have.property('token2')
+ s1Sessions.should.have.property('token3')
+ s2Sessions.should.have.property('token4')
+ })
+ })
})
diff --git a/test/unit/forge/db/setup.js b/test/unit/forge/db/setup.js
index 36d5acd280..cce8511287 100644
--- a/test/unit/forge/db/setup.js
+++ b/test/unit/forge/db/setup.js
@@ -31,6 +31,7 @@ module.exports = async function (config = {}) {
await team2.addUser(userBob, { through: { role: Roles.Owner } })
await team2.addUser(userAlice, { through: { role: Roles.Owner } })
await team3.addUser(userAlice, { through: { role: Roles.Owner } })
+
forge.TestObjects = {
defaultTeamType,
userAlice,
diff --git a/test/unit/forge/routes/api/project_spec.js b/test/unit/forge/routes/api/project_spec.js
index e4b62aee45..813bb74632 100644
--- a/test/unit/forge/routes/api/project_spec.js
+++ b/test/unit/forge/routes/api/project_spec.js
@@ -10,7 +10,7 @@ const setup = require('../setup')
const FF_UTIL = require('flowforge-test-utils')
const { Roles } = FF_UTIL.require('forge/lib/roles')
-const { KEY_HOSTNAME } = FF_UTIL.require('forge/db/models/ProjectSettings')
+const { KEY_HOSTNAME, KEY_HEALTH_CHECK_INTERVAL } = FF_UTIL.require('forge/db/models/ProjectSettings')
const { START_DELAY, STOP_DELAY } = FF_UTIL.require('forge/containers/stub/index.js')
describe('Project API', function () {
@@ -1654,6 +1654,85 @@ describe('Project API', function () {
{ name: 'two', value: '2' }
]) // should be unchanged
})
+ it('Change launcher health check interval - owner', async function () {
+ // Setup some flows/credentials
+ await addFlowsToProject(app,
+ TestObjects.project1.id,
+ TestObjects.tokens.project,
+ TestObjects.tokens.alice,
+ [{ id: 'node1' }],
+ { testCreds: 'abc' },
+ 'key1',
+ {}
+ )
+ // call "Update a project" with new httpAdminRoot
+ const response = await app.inject({
+ method: 'PUT',
+ url: `/api/v1/projects/${TestObjects.project1.id}`,
+ payload: {
+ launcherSettings: {
+ healthCheckInterval: 9876
+ }
+ },
+ cookies: { sid: TestObjects.tokens.alice }
+ })
+ response.statusCode.should.equal(200)
+
+ const newValue = await TestObjects.project1.getSetting(KEY_HEALTH_CHECK_INTERVAL)
+ should(newValue).equal(9876)
+ })
+ it('Change launcher health check interval bad value - owner', async function () {
+ // Setup some flows/credentials
+ await addFlowsToProject(app,
+ TestObjects.project1.id,
+ TestObjects.tokens.project,
+ TestObjects.tokens.alice,
+ [{ id: 'node1' }],
+ { testCreds: 'abc' },
+ 'key1',
+ {}
+ )
+ // call "Update a project" with new httpAdminRoot
+ const response = await app.inject({
+ method: 'PUT',
+ url: `/api/v1/projects/${TestObjects.project1.id}`,
+ payload: {
+ launcherSettings: {
+ healthCheckInterval: 999 // 999 is below the 5000 is the minimum
+ }
+ },
+ cookies: { sid: TestObjects.tokens.alice }
+ })
+ response.statusCode.should.equal(400)
+ const result = response.json()
+ result.should.have.property('code', 'invalid_heathCheckInterval')
+ })
+ it('Change launcher health check interval - member', async function () {
+ // Setup some flows/credentials
+ // app, id, token, userToken, flows, creds, key, settings
+ await addFlowsToProject(
+ app,
+ TestObjects.project1.id,
+ TestObjects.tokens.project,
+ TestObjects.tokens.alice,
+ [{ id: 'node1' }],
+ { testCreds: 'abc' },
+ 'key1',
+ {}
+ )
+ // call "Update a project" with new httpAdminRoot
+ const response = await app.inject({
+ method: 'PUT',
+ url: `/api/v1/projects/${TestObjects.project1.id}`,
+ payload: {
+ launcherSettings: {
+ healthCheckInterval: 9876
+ }
+ },
+ cookies: { sid: TestObjects.tokens.bob }
+ })
+ response.statusCode.should.equal(403)
+ })
describe('Update hostname', function () {
it('Changes the projects hostname', async function () {