diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6736bd6d48..37518348d6 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,69 @@
+#### 2.12.0: Release
+
+ - Add note about Private CA chain (#4901)
+ - Bump actions/github-script from 6 to 7 (#4897)
+ - Bump flowfuse/github-actions-workflows from 0.37.0 to 0.38.0 (#4896)
+ - Make it clearer which IP address to use (#4887)
+ - Bump codecov/codecov-action from 4 to 5 (#4795)
+ - Support disabling instance launcher "auto safe mode" (#4922) @Steve-Mcl
+ - Allow NR Dashboard to be loaded in iFrames (#4900) @hardillb
+ - Add system-ui as a backup font for heebo (to match internal font) (#4946) @cstns
+ - Remove platform banners from the applications page (#4939) @cstns
+ - Better device proxy cache (#4792) @hardillb
+ - Fix application child routes not making the applications nav menu active (#4885) @cstns
+ - Decrease device auto timeout to 15 seconds from 30 (#4932) @hardillb
+ - Add logo version for dark backgrounds (#4930) @Yndira-E
+ - Open Dashboard and Editor links in new tab by default (#4923) @joepavitt
+ - Update the sign up page and box layout to new branding (#4924) @joepavitt
+ - Bump nanoid from 3.3.7 to 3.3.8 (#4918) @app/dependabot
+ - Add note to Instance Types setting default Stack (#4917) @hardillb
+ - Team Bill Of Materials UI (#4872) @cstns
+ - Remove notifications for deleted instances (#4899) @hardillb
+ - Revert Device log changes (#4916) @hardillb
+ - Allow for prefix/suffix to SSO GroupNames (#4902) @hardillb
+ - Add device agent docker timezone docs (#4907) @hardillb
+ - Ensure Device Provisioning tokens removed with Team (#4906) @hardillb
+ - Return device type in application/devices (#4904) @hardillb
+ - Fix device log race condition between publish and disconnect (#4903) @cstns
+ - Ensure device logs always shown (#4893) @hardillb
+ - Add some Team Broker developement docs (#4799) @hardillb
+ - Ensure Instance suspended on expired license (#4888) @hardillb
+ - Bump cypress from 13.13.1 to 13.16.1 (#4895) @app/dependabot
+ - ci: Fix prestaging slack notification conditional (#4892) @ppawlowski
+ - ci: "upstream" packages validation workflow (#4455) @ppawlowski
+ - docs: Change links to Docker Compose files (#4890) @ppawlowski
+ - Fix main nav matching context order (#4869) @cstns
+ - Use default behavior for platform wide anchors (part I) (#4834) @cstns
+ - Bump path-to-regexp and express (#4879) @app/dependabot
+ - Fix padding on Device Group Settings view (#4865) @knolleary
+ - docs: Add description how to start Device Agent on system boot (#4878) @ppawlowski
+ - Send invite Reminders (#4824) @hardillb
+ - Fixe the outline of the first search result title (#4877) @cstns
+ - Add more filters for admin notification targeting (#4843) @knolleary
+ - Topic hierarchy follow up (#4818) @cstns
+ - Update role-based permissions table (#4863) @sumitshinde-84
+ - Add Team BOM api endpoint (#4849) @hardillb
+ - Ensure existing http auth tokens shown (#4861) @hardillb
+ - fix hovering over pipeline and application name and update empty state message (#4859) @cstns
+ - Expand the UNS Hierarchy by default & improve hover behaviour (#4854) @joepavitt
+ - Fix access permission for team pipeline api (#4856) @hardillb
+ - Improve help text and empty state language for Teams > Pipelines (#4855) @joepavitt
+ - Navigation - Add Team Pipelines View (#4852) @cstns
+ - Prevent viewer role users from getting 404 when accesing applications (#4846) @cstns
+ - Team Pipelines API (#4847) @hardillb
+ - ci: Publish to npm only on successful tests (#4848) @ppawlowski
+ - Team member device mode toggle (#4844) @hardillb
+ - Improve padding/sizing of the global search box (#4825) @joepavitt
+ - Bump @sentry/browser and @sentry/vue (#4731) @app/dependabot
+ - Allow branding settings to be cleared in the UI (#4841) @knolleary
+ - ci: Test docs along with website (#4840) @ppawlowski
+ - Improved Admin Team view (#4770) @knolleary
+ - 4563 replace instance and audit logs dropdowns (#4567) @cstns
+ - Support Search by id in Global Search (#4814) @Steve-Mcl
+ - fix device groups layout (#4817) @Steve-Mcl
+ - docs: fix failing anchors on kubernetes and docker docs (#4812) @ppawlowski
+ - Fix broken anchor links in docs (#4811) @Steve-Mcl
+
#### 2.11.0: Release
- Bump flowfuse/github-actions-workflows from 0.36.0 to 0.37.0 (#4733)
diff --git a/forge/db/models/Project.js b/forge/db/models/Project.js
index c972312f41..d9b8b1013a 100644
--- a/forge/db/models/Project.js
+++ b/forge/db/models/Project.js
@@ -17,7 +17,7 @@ const { col, fn, DataTypes, Op, where } = require('sequelize')
const Controllers = require('../controllers')
-const { KEY_HOSTNAME, KEY_SETTINGS, KEY_HA, KEY_PROTECTED, KEY_HEALTH_CHECK_INTERVAL, KEY_CUSTOM_HOSTNAME } = require('./ProjectSettings')
+const { KEY_HOSTNAME, KEY_SETTINGS, KEY_HA, KEY_PROTECTED, KEY_HEALTH_CHECK_INTERVAL, KEY_CUSTOM_HOSTNAME, KEY_DISABLE_AUTO_SAFE_MODE } = require('./ProjectSettings')
const BANNED_NAME_LIST = [
'app',
@@ -408,7 +408,8 @@ module.exports = {
{ key: KEY_HA },
{ key: KEY_PROTECTED },
{ key: KEY_CUSTOM_HOSTNAME },
- { key: KEY_HEALTH_CHECK_INTERVAL }
+ { key: KEY_HEALTH_CHECK_INTERVAL },
+ { key: KEY_DISABLE_AUTO_SAFE_MODE }
]
},
required: false
diff --git a/forge/db/models/ProjectSettings.js b/forge/db/models/ProjectSettings.js
index 628574e766..4f6b50e2de 100644
--- a/forge/db/models/ProjectSettings.js
+++ b/forge/db/models/ProjectSettings.js
@@ -16,6 +16,7 @@ const KEY_PROTECTED = 'protected'
const KEY_HEALTH_CHECK_INTERVAL = 'healthCheckInterval'
const KEY_CUSTOM_HOSTNAME = 'customHostname'
const KEY_SHARED_ASSETS = 'sharedAssets'
+const KEY_DISABLE_AUTO_SAFE_MODE = 'disableAutoSafeMode'
module.exports = {
KEY_SETTINGS,
@@ -25,6 +26,7 @@ module.exports = {
KEY_HEALTH_CHECK_INTERVAL,
KEY_CUSTOM_HOSTNAME,
KEY_SHARED_ASSETS,
+ KEY_DISABLE_AUTO_SAFE_MODE,
name: 'ProjectSettings',
schema: {
ProjectId: { type: DataTypes.UUID, unique: 'pk_settings' },
diff --git a/forge/db/models/Team.js b/forge/db/models/Team.js
index e6449d599d..5a7c058976 100644
--- a/forge/db/models/Team.js
+++ b/forge/db/models/Team.js
@@ -182,6 +182,13 @@ module.exports = {
}]
})
},
+ countForUser: async function (User) {
+ return M.TeamMember.count({
+ where: {
+ UserId: User.id
+ }
+ })
+ },
forUser: async function (User) {
return M.TeamMember.findAll({
where: {
diff --git a/forge/db/views/Project.js b/forge/db/views/Project.js
index 3d9033c4d8..2f4c4d28d1 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, KEY_HEALTH_CHECK_INTERVAL, KEY_CUSTOM_HOSTNAME } = require('../models/ProjectSettings')
+const { KEY_HOSTNAME, KEY_SETTINGS, KEY_HA, KEY_PROTECTED, KEY_HEALTH_CHECK_INTERVAL, KEY_CUSTOM_HOSTNAME, KEY_DISABLE_AUTO_SAFE_MODE } = require('../models/ProjectSettings')
module.exports = function (app) {
app.addSchema({
@@ -37,7 +37,8 @@ module.exports = function (app) {
launcherSettings: {
type: 'object',
properties: {
- healthCheckInterval: { type: 'number' }
+ healthCheckInterval: { type: 'number' },
+ disableAutoSafeMode: { type: 'boolean' }
},
additionalProperties: false
}
@@ -75,6 +76,11 @@ module.exports = function (app) {
result.launcherSettings = {}
result.launcherSettings.healthCheckInterval = heathCheckIntervalRow?.value
}
+ const disableAutoSafeMode = proj.ProjectSettings?.find((projectSettingsRow) => projectSettingsRow.key === KEY_DISABLE_AUTO_SAFE_MODE)
+ if (typeof disableAutoSafeMode?.value === 'boolean') {
+ result.launcherSettings = result.launcherSettings || {}
+ result.launcherSettings.disableAutoSafeMode = disableAutoSafeMode.value
+ }
// Environment
result.settings.env = app.db.controllers.Project.insertPlatformSpecificEnvVars(proj, result.settings.env)
if (!result.settings.palette?.modules) {
diff --git a/forge/lib/templates.js b/forge/lib/templates.js
index be22313abd..930684a775 100644
--- a/forge/lib/templates.js
+++ b/forge/lib/templates.js
@@ -4,6 +4,7 @@ module.exports = {
'disableTours',
'httpAdminRoot',
'dashboardUI',
+ 'dashboardIFrame',
'codeEditor',
'theme',
'page_title',
@@ -36,6 +37,7 @@ module.exports = {
disableTours: false,
httpAdminRoot: '',
dashboardUI: '/ui',
+ dashboardIFrame: false,
codeEditor: 'monaco',
theme: 'forge-light',
page_title: 'FlowFuse',
@@ -65,6 +67,7 @@ module.exports = {
disableTours: true,
httpAdminRoot: true,
dashboardUI: true,
+ dashboardIFrame: true,
codeEditor: true,
theme: true,
page_title: false,
diff --git a/forge/lib/userTeam.js b/forge/lib/userTeam.js
index cd8b5b58d2..d2313ea5ce 100644
--- a/forge/lib/userTeam.js
+++ b/forge/lib/userTeam.js
@@ -33,7 +33,7 @@ async function completeUserSignup (app, user) {
// return
}
// only create a personal team if no other teams exist
- if (!((await app.db.models.Team.forUser(user)).length)) {
+ if ((await app.db.models.Team.countForUser(user)) === 0) {
let teamTypeId = app.settings.get('user:team:auto-create:teamType')
if (!teamTypeId) {
diff --git a/forge/routes/api/project.js b/forge/routes/api/project.js
index 1c635e258c..46bdc75f94 100644
--- a/forge/routes/api/project.js
+++ b/forge/routes/api/project.js
@@ -1,4 +1,4 @@
-const { KEY_SETTINGS, KEY_HEALTH_CHECK_INTERVAL, KEY_SHARED_ASSETS } = require('../../db/models/ProjectSettings')
+const { KEY_SETTINGS, KEY_HEALTH_CHECK_INTERVAL, KEY_DISABLE_AUTO_SAFE_MODE, KEY_SHARED_ASSETS } = require('../../db/models/ProjectSettings')
const { Roles } = require('../../lib/roles')
const ProjectActions = require('./projectActions')
@@ -456,15 +456,24 @@ module.exports = async function (app) {
}
// 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 (request.body?.launcherSettings) {
+ 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 }
+ }
}
- if (oldInterval !== newInterval) {
- changesToPersist.healthCheckInterval = { from: oldInterval, to: newInterval }
+ if (typeof request.body.launcherSettings.disableAutoSafeMode === 'boolean') {
+ const oldInterval = await request.project.getSetting(KEY_DISABLE_AUTO_SAFE_MODE)
+ const newInterval = request.body.launcherSettings.disableAutoSafeMode
+ if (oldInterval !== newInterval) {
+ changesToPersist.disableAutoSafeMode = { from: oldInterval, to: newInterval }
+ }
}
}
@@ -529,6 +538,10 @@ module.exports = async function (app) {
await request.project.updateSetting(KEY_HEALTH_CHECK_INTERVAL, changesToPersist.healthCheckInterval.to, { transaction })
updates.pushDifferences({ healthCheckInterval: changesToPersist.healthCheckInterval.from }, { healthCheckInterval: changesToPersist.healthCheckInterval.to })
}
+ if (changesToPersist.disableAutoSafeMode) {
+ await request.project.updateSetting(KEY_DISABLE_AUTO_SAFE_MODE, changesToPersist.disableAutoSafeMode.to, { transaction })
+ updates.pushDifferences({ disableAutoSafeMode: changesToPersist.disableAutoSafeMode.from }, { disableAutoSafeMode: changesToPersist.disableAutoSafeMode.to })
+ }
await transaction.commit() // all good, commit the transaction
@@ -802,6 +815,7 @@ module.exports = async function (app) {
settings.state = request.project.state
settings.stack = request.project.ProjectStack?.properties || {}
settings.healthCheckInterval = await request.project.getSetting(KEY_HEALTH_CHECK_INTERVAL)
+ settings.disableAutoSafeMode = await request.project.getSetting(KEY_DISABLE_AUTO_SAFE_MODE)
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/team.js b/forge/routes/api/team.js
index bf125b868c..982096c098 100644
--- a/forge/routes/api/team.js
+++ b/forge/routes/api/team.js
@@ -1,3 +1,5 @@
+const crypto = require('crypto')
+
const { Op } = require('sequelize')
const { Roles } = require('../../lib/roles')
@@ -395,7 +397,8 @@ module.exports = async function (app) {
properties: {
name: { type: 'string' },
type: { type: 'string' },
- slug: { type: 'string' }
+ slug: { type: 'string' },
+ trial: { type: 'boolean' }
}
},
response: {
@@ -429,6 +432,34 @@ module.exports = async function (app) {
return
}
+ let trialMode = false
+ if (app.license.active() && app.billing && request.body.trial) {
+ // Check this user is allowed to create a trial team of this type.
+ // Rules:
+ // 1. teamType must have trial mode enabled
+ const teamTrialActive = await teamType.getProperty('trial.active', false)
+ if (!teamTrialActive) {
+ reply.code(400).send({ code: 'invalid_request', error: 'trial mode not available' })
+ return
+ }
+ // 2. user must have no existing teams
+ const existingTeamCount = await app.db.models.Team.countForUser(request.session.User)
+ if (existingTeamCount > 0) {
+ reply.code(400).send({ code: 'invalid_request', error: 'trial mode not available' })
+ return
+ }
+ // 3. user must be < 1 week old
+ const delta = Date.now() - request.session.User.createdAt.getTime()
+ if (delta > 1000 * 60 * 60 * 24 * 7) {
+ reply.code(400).send({ code: 'invalid_request', error: 'trial mode not available' })
+ return
+ }
+ trialMode = true
+ } else if (request.body.trial) {
+ reply.code(400).send({ code: 'invalid_request', error: 'trial mode not available' })
+ return
+ }
+
let team
try {
@@ -444,9 +475,43 @@ module.exports = async function (app) {
const teamView = app.db.views.Team.team(team)
if (app.license.active() && app.billing) {
- const session = await app.billing.createSubscriptionSession(team, request.session.User)
- app.auditLog.Team.billing.session.created(request.session.User, null, team, session)
- teamView.billingURL = session.url
+ if (trialMode) {
+ await app.billing.setupTrialTeamSubscription(team, request.session.User)
+ // In trial mode, we may also auto-create their first application and instance
+ if (app.settings.get('user:team:auto-create:instanceType')) {
+ const instanceTypeId = app.settings.get('user:team:auto-create:instanceType')
+ const instanceType = await app.db.models.ProjectType.byId(instanceTypeId)
+ const instanceStack = await instanceType?.getDefaultStack() || (await instanceType.getProjectStacks())?.[0]
+ const instanceTemplate = await app.db.models.ProjectTemplate.findOne({ where: { active: true } })
+ if (!instanceType) {
+ app.log.warn(`Unable to create Trial Instance in team ${team.hashid}: Instance type with id ${instanceTypeId} from 'user:team:auto-create:instanceType' not found`)
+ } else if (!instanceStack) {
+ app.log.warn(`Unable to create Trial Instance in team ${team.hashid}: Unable to find a stack for use with instance type ${instanceTypeId}`)
+ } else if (!instanceTemplate) {
+ app.log.warn(`Unable to create Trial Instance in team ${team.hashid}: Unable to find the default instance template`)
+ } else {
+ const applicationName = `${request.session.User.name}'s Application`
+ const application = await app.db.models.Application.create({
+ name: applicationName.charAt(0).toUpperCase() + applicationName.slice(1),
+ TeamId: team.id
+ })
+ await app.auditLog.Team.application.created(request.session.User, null, team, application)
+ await app.auditLog.Application.application.created(request.session.User, null, application)
+
+ const safeTeamName = team.name.toLowerCase().replace(/[\W_]/g, '-')
+ const safeUserName = request.session.User.username.toLowerCase().replace(/[\W_]/g, '-')
+
+ const instanceProperties = {
+ name: `${safeTeamName}-${safeUserName}-${crypto.randomBytes(4).toString('hex')}`
+ }
+ await app.db.controllers.Project.create(team, application, request.session.User, instanceType, instanceStack, instanceTemplate, instanceProperties)
+ }
+ }
+ } else {
+ const session = await app.billing.createSubscriptionSession(team, request.session.User)
+ app.auditLog.Team.billing.session.created(request.session.User, null, team, session)
+ teamView.billingURL = session.url
+ }
}
reply.send(teamView)
diff --git a/frontend/src/components/PageHeader.vue b/frontend/src/components/PageHeader.vue
index 21ec32c4e0..5d9568736b 100644
--- a/frontend/src/components/PageHeader.vue
+++ b/frontend/src/components/PageHeader.vue
@@ -11,7 +11,7 @@
Upgrade your stack to be able to enable
+embedding Dashboards in iFrames
+