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 @@ - +
diff --git a/frontend/src/components/TeamTypeSelection.vue b/frontend/src/components/TeamTypeSelection.vue new file mode 100644 index 0000000000..2c41bfc209 --- /dev/null +++ b/frontend/src/components/TeamTypeSelection.vue @@ -0,0 +1,34 @@ + + + diff --git a/frontend/src/components/TeamTypeTile.vue b/frontend/src/components/TeamTypeTile.vue new file mode 100644 index 0000000000..19b0e3c100 --- /dev/null +++ b/frontend/src/components/TeamTypeTile.vue @@ -0,0 +1,143 @@ + + + + + diff --git a/frontend/src/pages/Home.vue b/frontend/src/pages/Home.vue index c9c9de5165..3ad5432886 100644 --- a/frontend/src/pages/Home.vue +++ b/frontend/src/pages/Home.vue @@ -1,13 +1,20 @@ @@ -17,13 +24,13 @@ import { mapGetters, mapState } from 'vuex' import FlowFuseLogo from '../components/Logo.vue' -import NoTeamsUser from './account/NoTeamsUser.vue' +import TeamTypeSelection from '../components/TeamTypeSelection.vue' export default { name: 'HomePage', components: { FlowFuseLogo, - NoTeamsUser + TeamTypeSelection }, data () { return { diff --git a/frontend/src/pages/account/NoTeamsUser.vue b/frontend/src/pages/account/NoTeamsUser.vue deleted file mode 100644 index dfd7eb0aa8..0000000000 --- a/frontend/src/pages/account/NoTeamsUser.vue +++ /dev/null @@ -1,44 +0,0 @@ - - - diff --git a/frontend/src/pages/admin/TeamTypes/dialogs/TeamTypeEditDialog.vue b/frontend/src/pages/admin/TeamTypes/dialogs/TeamTypeEditDialog.vue index 082c7b8b83..de0ff84900 100644 --- a/frontend/src/pages/admin/TeamTypes/dialogs/TeamTypeEditDialog.vue +++ b/frontend/src/pages/admin/TeamTypes/dialogs/TeamTypeEditDialog.vue @@ -14,7 +14,7 @@ Order - + Description