From e7473832289c84c11951bd3029847b699399e016 Mon Sep 17 00:00:00 2001 From: Nick O'Leary Date: Tue, 14 Jul 2026 09:44:50 +0100 Subject: [PATCH 01/24] Add plain layout --- frontend/src/App.vue | 12 +++++++-- frontend/src/layouts/Plain.vue | 37 +++++++++++++++++++++++++++ frontend/src/layouts/Platform.vue | 19 -------------- frontend/src/stylesheets/layouts.scss | 21 ++++++--------- 4 files changed, 55 insertions(+), 34 deletions(-) create mode 100644 frontend/src/layouts/Plain.vue diff --git a/frontend/src/App.vue b/frontend/src/App.vue index fa8c3c2b7a..0f793be915 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -37,6 +37,11 @@ + @@ -73,7 +78,9 @@ import EducationModal from './components/dialogs/EducationModal.vue' import FFLayoutBox from './layouts/Box.vue' import FFLayoutDocs from './layouts/Docs.vue' import FFLayoutImmersiveEditor from './layouts/ImmersiveEditor.vue' +import FFLayoutPlain from './layouts/Plain.vue' import FFLayoutPlatform from './layouts/Platform.vue' + import Login from './pages/Login.vue' import PasswordExpired from './pages/PasswordExpired.vue' import TermsAndConditions from './pages/TermsAndConditions.vue' @@ -103,7 +110,8 @@ export default { 'ff-layout-platform': FFLayoutPlatform, 'ff-layout-box': FFLayoutBox, 'ff-layout-docs': FFLayoutDocs, - 'ff-layout-immersive': FFLayoutImmersiveEditor + 'ff-layout-immersive': FFLayoutImmersiveEditor, + 'ff-layout-plain': FFLayoutPlain }, computed: { ...mapState(useUxDrawersStore, ['hiddenLeftDrawer']), @@ -143,7 +151,7 @@ export default { }, pageLayout () { const layout = this.$route.meta?.layout - return ['platform', 'modal', 'docs', 'immersive'].includes(layout) ? layout : 'platform' + return ['platform', 'modal', 'docs', 'immersive', 'plain'].includes(layout) ? layout : 'platform' } }, watch: { diff --git a/frontend/src/layouts/Plain.vue b/frontend/src/layouts/Plain.vue new file mode 100644 index 0000000000..4327a1e7ab --- /dev/null +++ b/frontend/src/layouts/Plain.vue @@ -0,0 +1,37 @@ + + + diff --git a/frontend/src/layouts/Platform.vue b/frontend/src/layouts/Platform.vue index 5641ad31ba..6eaccb41d5 100644 --- a/frontend/src/layouts/Platform.vue +++ b/frontend/src/layouts/Platform.vue @@ -52,25 +52,6 @@ export default { ...mapState(useUxStore, ['overlay']), ...mapState(useProductBrokersStore, ['interview']), ...mapState(useAccountSettingsStore, ['hasAvailableTeams']) - }, - watch: { - $route: function () { - this.checkRouteMeta() - } - }, - mounted () { - this.checkRouteMeta() - }, - methods: { - checkRouteMeta () { - for (let l = 0; l < this.$route.matched.length; l++) { - const level = this.$route.matched[l] - if (level.meta.hideSideMenu) { - this.hideTeamOptions = true - break - } - } - } } } diff --git a/frontend/src/stylesheets/layouts.scss b/frontend/src/stylesheets/layouts.scss index 7c552fbe9e..65f34f283a 100644 --- a/frontend/src/stylesheets/layouts.scss +++ b/frontend/src/stylesheets/layouts.scss @@ -193,7 +193,8 @@ $nav_height: 60px; flex-direction: column; } -.ff-layout--platform--wrapper{ +.ff-layout--platform--wrapper, +.ff-layout--plain--wrapper { padding-top: $nav_height; position: absolute; width: 100%; @@ -221,17 +222,6 @@ $nav_height: 60px; } } -.ff-layout--plain--wrapper { - height: 100%; - display: flex; - flex-direction: column; - flex: 1; - - main { - background-color: var(--ff-color-bg-surface); - flex: 1; - } -} .ff-notifications { position: absolute; @@ -853,9 +843,14 @@ $nav_height: 60px; left: 0; } } - + .ff-layout--plain--wrapper { + .ff-view { + margin-left: 0; + } + } .ff-view { margin-left: -$sidenav_width; } } + } From ff2058cbe6666f501af6d2e7d5c42eec3f599ef9 Mon Sep 17 00:00:00 2001 From: Nick O'Leary Date: Tue, 14 Jul 2026 09:50:55 +0100 Subject: [PATCH 02/24] Add AsyncLoginSession model --- .../20260714-01-add-asyncloginsession.js | 35 +++++++++++ forge/db/models/AsyncLoginSession.js | 62 +++++++++++++++++++ forge/db/models/index.js | 3 +- 3 files changed, 99 insertions(+), 1 deletion(-) create mode 100644 forge/db/migrations/20260714-01-add-asyncloginsession.js create mode 100644 forge/db/models/AsyncLoginSession.js diff --git a/forge/db/migrations/20260714-01-add-asyncloginsession.js b/forge/db/migrations/20260714-01-add-asyncloginsession.js new file mode 100644 index 0000000000..8405ae3bf6 --- /dev/null +++ b/forge/db/migrations/20260714-01-add-asyncloginsession.js @@ -0,0 +1,35 @@ +const { DataTypes } = require('sequelize') + +module.exports = { + /** + * upgrade database + * @param {QueryInterface} context Sequelize.QueryInterface + */ + up: async (context, Sequelize) => { + await context.createTable('AsyncLoginSessions', { + sessionToken: { + type: DataTypes.STRING, + primaryKey: true + }, + doneToken: { + type: DataTypes.STRING + }, + status: { + type: DataTypes.STRING, + defaultValue: 'pending' + }, + result: { + type: DataTypes.STRING + }, + createdAt: { + type: DataTypes.DATE, + allowNull: false + }, + updatedAt: { + type: DataTypes.DATE, + allowNull: false + } + }) + }, + down: async (context, Sequelize) => { } +} diff --git a/forge/db/models/AsyncLoginSession.js b/forge/db/models/AsyncLoginSession.js new file mode 100644 index 0000000000..622b90c492 --- /dev/null +++ b/forge/db/models/AsyncLoginSession.js @@ -0,0 +1,62 @@ +const { DataTypes } = require('sequelize') + +const { generateToken } = require('../utils') + +module.exports = { + name: 'AsyncLoginSession', + schema: { + sessionToken: { + type: DataTypes.STRING, + primaryKey: true + }, + doneToken: { + type: DataTypes.STRING + }, + status: { + type: DataTypes.STRING, + defaultValue: 'pending' + }, + result: { + type: DataTypes.STRING + } + }, + finders: function (M) { + return { + static: { + createToken: async () => { + const sessionToken = generateToken(32) + const doneToken = generateToken(32) + const session = await this.create({ + sessionToken, + doneToken + }) + return session + }, + bySessionToken: async (sessionToken) => { + return this.findOne({ + where: { sessionToken } + }) + }, + getAndExpireByDoneToken: async (doneToken) => { + const session = await this.findOne({ + where: { doneToken } + }) + if (session) { + const age = Date.now() - session.createdAt.getTime() + if (age > 1000 * 60 * 30) { + // session is older than 30 minutes, expire it + await session.destroy() + return null + } + if (session.status !== 'pending') { + // The session is resolved. We only allow retrieving it once, + // so we destroy it after retrieval. + await session.destroy() + } + } + return session + } + } + } + } +} diff --git a/forge/db/models/index.js b/forge/db/models/index.js index acb7f596ce..93ae1e13ea 100644 --- a/forge/db/models/index.js +++ b/forge/db/models/index.js @@ -84,7 +84,8 @@ const modelTypes = [ 'TeamBrokerClient', 'BrokerCredentials', 'MQTTTopicSchema', - 'TeamBrokerAgent' + 'TeamBrokerAgent', + 'AsyncLoginSession' ] // A local map of the known models. From c1d238a49e3c47a75e5c4962f1b2e4f3061d70c1 Mon Sep 17 00:00:00 2001 From: Nick O'Leary Date: Tue, 14 Jul 2026 10:51:52 +0100 Subject: [PATCH 03/24] Add device async registration flow --- forge/routes/api/device.js | 121 ++++++++++++++++- test/unit/forge/routes/api/device_spec.js | 154 ++++++++++++++++++++++ 2 files changed, 272 insertions(+), 3 deletions(-) diff --git a/forge/routes/api/device.js b/forge/routes/api/device.js index a5bfbed67a..a6a452d0af 100644 --- a/forge/routes/api/device.js +++ b/forge/routes/api/device.js @@ -200,6 +200,17 @@ module.exports = async function (app) { } } else if (request.body?.team && request.session.User) { // User action: check if the user is in the team and has the required role + if (request.body.application) { + // This request is to create the device and assign directly to an application + // Validate application is in the team + const application = await app.db.models.Application.byId(request.body.application) + if (!application || application.Team.hashid !== request.body.team) { + reply.code(400).send({ code: 'invalid_application', error: 'Invalid application' }) + return + } + // Attach to the request so Granular RBAC checks can be applied + request.application = application + } request.teamMembership = await request.session.User.getTeamMembership(request.body.team) const hasPermission = app.needsPermission('device:create') await hasPermission(request, reply) // hasPermission sends the error response if required which stops the request @@ -225,7 +236,8 @@ module.exports = async function (app) { allOf: [ { required: ['setup'] }, // when provided, setup must be `true` (see enum below) { not: { required: ['name'] } }, // neither of name or team are allowed when setting up a device - { not: { required: ['team'] } } + { not: { required: ['team'] } }, + { not: { required: ['application'] } } ] } ], @@ -233,8 +245,10 @@ module.exports = async function (app) { name: { type: 'string' }, type: { type: 'string' }, team: { type: 'string' }, + application: { type: 'string' }, setup: { type: 'boolean', enum: [true] }, // enum only permits a value of true for setup - agentHost: { type: 'string' } // future, for audit log + agentHost: { type: 'string' }, // future, for audit log + registrationSession: { type: 'string' } // optional, for async device registration flow } }, response: { @@ -316,6 +330,16 @@ module.exports = async function (app) { } const teamMembership = await request.session.User.getTeamMembership(request.body.team, true) team = teamMembership.get('Team') + // If application was provided, the preHandler will have already populated request.application + application = request.application || null + + if (request.body.registrationSession) { + request.body.asyncSession = await app.db.models.AsyncLoginSession.bySessionToken(request.body.registrationSession) + if (!request.body.asyncSession || request.body.asyncSession.status !== 'pending') { + reply.code(400).send({ code: 'invalid_request', error: 'Invalid registration session' }) + return + } + } } const transaction = await app.db.sequelize.transaction() @@ -353,7 +377,7 @@ module.exports = async function (app) { await app.auditLog.Team.team.device.created(actionedBy, null, team, device) // When device provisioning: if a project was specified, add the device to the project - if (provisioningMode && application) { + if (application) { await assignDeviceToApplication(device, application) await device.save() await device.reload({ @@ -381,6 +405,14 @@ module.exports = async function (app) { response.meta = { ffVersion: app.config.version } + + if (request.body.asyncSession) { + // This is an async registration session that started with a POST to /_/register. + // We need to update the AsyncLoginSession with the result of this device creation + request.body.asyncSession.status = 'success' + request.body.asyncSession.result = JSON.stringify({ id: device.hashid, otc: credentials.otc }) + await request.body.asyncSession.save() + } reply.send(response) } finally { if (app.license.active() && app.billing) { @@ -393,6 +425,89 @@ module.exports = async function (app) { } }) + app.post('/_/register', { + config: { allowAnonymous: true }, + schema: { + summary: 'Start an asynchronous registration for a device', + response: { + 200: { + type: 'object', + properties: { + registerUrl: { type: 'string' }, + doneUrl: { type: 'string' } + } + }, + '4xx': { + $ref: 'APIError' + } + } + } + }, async (request, reply) => { + // Create a new AsyncLoginSession with a unique sessionToken and doneToken + const session = await app.db.models.AsyncLoginSession.createToken() + // TODO: this should be a front-end URL + const registerUrl = `/register/remote-instance/${session.sessionToken}` + const doneUrl = `/api/v1/devices/_/register/done/${session.doneToken}` + reply.send({ registerUrl, doneUrl }) + }) + app.get('/_/register/status/:sessionToken', { + schema: { + summary: 'Get the status of an asynchronous registration session', + response: { + 202: { + type: 'object', + }, + '4xx': { + $ref: 'APIError' + } + } + } + }, async (request, reply) => { + // Check if the session exists and is still pending + const session = await app.db.models.AsyncLoginSession.bySessionToken(request.params.sessionToken) + if (!session || session.status !== 'pending') { + reply.code(404).send({ code: 'not_found', error: 'Not found' }) + return + } + reply.code(202).send({}) + }) + app.get('/_/register/done/:doneToken', { + config: { allowAnonymous: true }, + schema: { + summary: 'Get the completion result of an asynchronous registration for a device', + response: { + 200: { + type: 'object', + properties: { + otc: { type: 'string' } + } + }, + 202: { + type: 'object' + }, + '4xx': { + $ref: 'APIError' + } + } + } + }, async (request, reply) => { + const session = await app.db.models.AsyncLoginSession.getAndExpireByDoneToken(request.params.doneToken) + if (!session) { + reply.code(404).send({ code: 'not_found', error: 'Not found' }) + return + } + if (session.status === 'pending') { + reply.header('retry-after', '5').code(202).send({}) + } else if (session.status === 'success') { + const result = JSON.parse(session.result) + reply.code(200).send(result) + } else if (session.status === 'error') { + reply.code(400).send({ code: 'unexpected_error', error: session.result }) + } else { + reply.code(400).send({ code: 'unexpected_error', error: 'Unexpected error' }) + } + }) + /** * Delete a device * @name /api/v1/devices diff --git a/test/unit/forge/routes/api/device_spec.js b/test/unit/forge/routes/api/device_spec.js index 7bded4b02e..ce29f35435 100644 --- a/test/unit/forge/routes/api/device_spec.js +++ b/test/unit/forge/routes/api/device_spec.js @@ -471,6 +471,160 @@ describe('Device API', async function () { }) }) + describe.only('Async device registration', async function () { + + it('session token status check requires user session', async function () { + const response = await app.inject({ + method: 'GET', + url: '/api/v1/devices/_/register/status/invalid_session_token' + }) + // 401 as this is unauthorized rather than non-existent + response.statusCode.should.equal(401) + }) + it('rejects if the session token is invalid', async function () { + const response = await app.inject({ + method: 'GET', + url: '/api/v1/devices/_/register/status/invalid_session_token', + cookies: { sid: TestObjects.tokens.alice } + }) + // 404 as this is non-existent rather than unauthorized + response.statusCode.should.equal(404) + }) + it('rejects if the done token is invalid', async function () { + const response = await app.inject({ + method: 'GET', + url: '/api/v1/devices/_/register/done/invalid_done_token', + }) + response.statusCode.should.equal(404) + }) + it('starts a device registration session', async function () { + const response = await app.inject({ + method: 'POST', + url: '/api/v1/devices/_/register' + // No cookie as this is an anonymous request to start the registration process + }) + response.statusCode.should.equal(200) + const result = response.json() + result.should.have.property('registerUrl').and.be.a.String() + result.should.have.property('doneUrl').and.be.a.String() + + const sessionToken = result.registerUrl.split('/').pop() + // Verify status cannot be checked without a valid user session + let statusResponse = await app.inject({ + method: 'GET', + url: `/api/v1/devices/_/register/status/${sessionToken}` + }) + // 401 as this is unauthorized rather than non-existent + statusResponse.statusCode.should.equal(401) + + // Verify status can be checked with a valid user session + statusResponse = await app.inject({ + method: 'GET', + url: `/api/v1/devices/_/register/status/${sessionToken}`, + cookies: { sid: TestObjects.tokens.alice } + }) + statusResponse.statusCode.should.equal(202) + + // Verify done URL can be checked without a valid user session + let doneResponse = await app.inject({ + method: 'GET', + url: result.doneUrl + }) + doneResponse.statusCode.should.equal(202) + + // The user will now do the UI walkthrough to complete the registration process. + // This will result in a post to the create endpoint, including the sessionToken + // which we recreate here: + + const createResponse = await app.inject({ + method: 'POST', + url: '/api/v1/devices', + body: { + name: 'my device', + type: 'test device', + team: TestObjects.ATeam.hashid, + application: TestObjects.Application1.hashid, + registrationSession: sessionToken + }, + cookies: { sid: TestObjects.tokens.alice } + }) + createResponse.statusCode.should.equal(200) + const createResult = createResponse.json() + createResult.should.have.property('id') + createResult.should.have.property('name', 'my device') + createResult.should.have.property('type', 'test device') + createResult.should.have.property('team').and.be.an.Object() + createResult.should.have.property('application').and.be.an.Object() + createResult.should.have.property('credentials').and.be.an.Object() + createResult.team.should.have.property('id', TestObjects.ATeam.hashid) + createResult.application.should.have.property('id', TestObjects.Application1.hashid) + createResult.credentials.should.have.property('otc') + const deviceOTC = createResult.credentials.otc + + // Verify sessionToken now returns a 404 on the status endpoint + statusResponse = await app.inject({ + method: 'GET', + url: `/api/v1/devices/_/register/status/${sessionToken}`, + cookies: { sid: TestObjects.tokens.alice } + }) + statusResponse.statusCode.should.equal(404) + + // At this point, the session still exists and will do until `doneURL` is polled. + //Verify we can't reuse the sessionToken to create another device within this window + const createResponse2 = await app.inject({ + method: 'POST', + url: '/api/v1/devices', + body: { + name: 'my device 2', + type: 'test device', + team: TestObjects.ATeam.hashid, + application: TestObjects.Application1.hashid, + registrationSession: sessionToken + }, + cookies: { sid: TestObjects.tokens.alice } + }) + createResponse2.statusCode.should.equal(400) + const result2 = createResponse2.json() + result2.should.have.property('error', 'Invalid registration session') + result2.should.have.property('code', 'invalid_request') + + // Verify the doneUrl now returns a 200 and includes the device credentials + doneResponse = await app.inject({ + method: 'GET', + url: result.doneUrl + }) + doneResponse.statusCode.should.equal(200) + const doneResult = doneResponse.json() + doneResult.should.have.property('otc', deviceOTC) + + // Verify the doneURL now returns a 404 on subsequent requests + doneResponse = await app.inject({ + method: 'GET', + url: result.doneUrl + }) + doneResponse.statusCode.should.equal(404) + + }) + it('rejects attempting to create device with an invalid registration session token', async function () { + const createResponse = await app.inject({ + method: 'POST', + url: '/api/v1/devices', + body: { + name: 'my device', + type: 'test device', + team: TestObjects.ATeam.hashid, + application: TestObjects.Application1.hashid, + registrationSession: 'invalid_session_token' + }, + cookies: { sid: TestObjects.tokens.alice } + }) + createResponse.statusCode.should.equal(400) + const result = createResponse.json() + result.should.have.property('error', 'Invalid registration session') + result.should.have.property('code', 'invalid_request') + }) + }) + describe('Generate Device credentials', function () { it('regenerates a devices credentials', async function () { const response = await app.inject({ From b8c7cc97acd10708d45fc297bb0c613900f6cb10 Mon Sep 17 00:00:00 2001 From: Nick O'Leary Date: Tue, 14 Jul 2026 11:48:14 +0100 Subject: [PATCH 04/24] Add async device registration flow --- forge/db/models/AsyncLoginSession.js | 34 ++- frontend/src/api/devices.js | 7 +- .../device/MultiStepDeviceForm.vue | 261 ++++++++++++++++++ .../device/steps/DeviceStep.vue | 201 ++++++++++++++ .../device/steps/SuccessStep.vue | 81 ++++++ .../device/steps/TeamStep.vue | 97 +++++++ .../instance/steps/ApplicationStep.vue | 8 +- frontend/src/pages/team/registerDevice.vue | 154 +++++++++++ frontend/src/pages/team/routes.js | 20 +- 9 files changed, 842 insertions(+), 21 deletions(-) create mode 100644 frontend/src/components/multi-step-forms/device/MultiStepDeviceForm.vue create mode 100644 frontend/src/components/multi-step-forms/device/steps/DeviceStep.vue create mode 100644 frontend/src/components/multi-step-forms/device/steps/SuccessStep.vue create mode 100644 frontend/src/components/multi-step-forms/device/steps/TeamStep.vue create mode 100644 frontend/src/pages/team/registerDevice.vue diff --git a/forge/db/models/AsyncLoginSession.js b/forge/db/models/AsyncLoginSession.js index 622b90c492..d58bf8780b 100644 --- a/forge/db/models/AsyncLoginSession.js +++ b/forge/db/models/AsyncLoginSession.js @@ -2,6 +2,18 @@ const { DataTypes } = require('sequelize') const { generateToken } = require('../utils') +async function validateSessionExpiry (session) { + if (session) { + const age = Date.now() - session.createdAt.getTime() + if (age > 1000 * 60 * 30) { + // session is older than 30 minutes, expire it + await session.destroy() + return null + } + } + return session +} + module.exports = { name: 'AsyncLoginSession', schema: { @@ -33,26 +45,20 @@ module.exports = { return session }, bySessionToken: async (sessionToken) => { - return this.findOne({ + const session = await this.findOne({ where: { sessionToken } }) + return validateSessionExpiry(session) }, getAndExpireByDoneToken: async (doneToken) => { - const session = await this.findOne({ + let session = await this.findOne({ where: { doneToken } }) - if (session) { - const age = Date.now() - session.createdAt.getTime() - if (age > 1000 * 60 * 30) { - // session is older than 30 minutes, expire it - await session.destroy() - return null - } - if (session.status !== 'pending') { - // The session is resolved. We only allow retrieving it once, - // so we destroy it after retrieval. - await session.destroy() - } + session = await validateSessionExpiry(session) + if (session && session.status !== 'pending') { + // The session is resolved. We only allow retrieving it once, + // so we destroy it after retrieval. + await session.destroy() } return session } diff --git a/frontend/src/api/devices.js b/frontend/src/api/devices.js index ae540423ab..9edde22e34 100644 --- a/frontend/src/api/devices.js +++ b/frontend/src/api/devices.js @@ -270,7 +270,9 @@ const updateHTTPToken = async (deviceId, tokenId, scope, expiresAt) => { const deleteHTTPToken = async (deviceId, tokenId) => { return client.delete(`/api/v1/devices/${deviceId}/httpTokens/${tokenId}`) } - +const checkRegistrationSession = async (sessionToken) => { + return client.get(`/api/v1/devices/_/register/status/${sessionToken}`).then(res => res.data) +} export default { create, getDevice, @@ -300,5 +302,6 @@ export default { getHTTPTokens, createHTTPToken, updateHTTPToken, - deleteHTTPToken + deleteHTTPToken, + checkRegistrationSession } diff --git a/frontend/src/components/multi-step-forms/device/MultiStepDeviceForm.vue b/frontend/src/components/multi-step-forms/device/MultiStepDeviceForm.vue new file mode 100644 index 0000000000..23e7ebcee9 --- /dev/null +++ b/frontend/src/components/multi-step-forms/device/MultiStepDeviceForm.vue @@ -0,0 +1,261 @@ + + + + + diff --git a/frontend/src/components/multi-step-forms/device/steps/DeviceStep.vue b/frontend/src/components/multi-step-forms/device/steps/DeviceStep.vue new file mode 100644 index 0000000000..264b372f43 --- /dev/null +++ b/frontend/src/components/multi-step-forms/device/steps/DeviceStep.vue @@ -0,0 +1,201 @@ + + + + + diff --git a/frontend/src/components/multi-step-forms/device/steps/SuccessStep.vue b/frontend/src/components/multi-step-forms/device/steps/SuccessStep.vue new file mode 100644 index 0000000000..573c3cbb74 --- /dev/null +++ b/frontend/src/components/multi-step-forms/device/steps/SuccessStep.vue @@ -0,0 +1,81 @@ + + + + + diff --git a/frontend/src/components/multi-step-forms/device/steps/TeamStep.vue b/frontend/src/components/multi-step-forms/device/steps/TeamStep.vue new file mode 100644 index 0000000000..93c6b0a4da --- /dev/null +++ b/frontend/src/components/multi-step-forms/device/steps/TeamStep.vue @@ -0,0 +1,97 @@ + + + + + diff --git a/frontend/src/components/multi-step-forms/instance/steps/ApplicationStep.vue b/frontend/src/components/multi-step-forms/instance/steps/ApplicationStep.vue index bcc261a1f2..c1f49f03ac 100644 --- a/frontend/src/components/multi-step-forms/instance/steps/ApplicationStep.vue +++ b/frontend/src/components/multi-step-forms/instance/steps/ApplicationStep.vue @@ -5,14 +5,14 @@

Applications are used to manage and group together your Node-RED Instances and resources.

-
+
- @@ -62,9 +64,6 @@ import MultiStepDeviceForm from '../../components/multi-step-forms/device/MultiS import SuccessStep from '../../components/multi-step-forms/device/steps/SuccessStep.vue' -// import LocalStorageService from '../../services/storage/local-storage.service.js' - -// import { useAccountAuthStore } from '@/stores/account-auth.js' import { useContextStore } from '@/stores/context.js' export default { @@ -81,13 +80,6 @@ export default { mounted: false, device: null, invalidSession: false, - testDevice: { - id: 'O65j9bmX8e', - name: 'My Remote Instance', - credentials: { - otc: 'phantom-secret-potato' - } - }, form: { nextButtonState: false, previousButtonState: false, From fa1619b6976cb205cda3b83e805e18e3af465dd2 Mon Sep 17 00:00:00 2001 From: Nick O'Leary Date: Mon, 27 Jul 2026 09:50:50 +0100 Subject: [PATCH 14/24] Update types --- frontend/src/types/generated.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/frontend/src/types/generated.ts b/frontend/src/types/generated.ts index 108997ea9b..5743be59c8 100644 --- a/frontend/src/types/generated.ts +++ b/frontend/src/types/generated.ts @@ -5599,10 +5599,12 @@ export interface paths { name?: string; type?: string; team?: string; + application?: string; /** @enum {boolean} */ setup?: true; agentHost?: string; - } & ((unknown & unknown & unknown) | (unknown & unknown & unknown)); + registrationSession?: string; + } & ((unknown & unknown & unknown) | (unknown & unknown & unknown & unknown)); }; }; responses: { From fbb08b5ff22ba928bc6f192202c1d89745c3e04a Mon Sep 17 00:00:00 2001 From: Nick O'Leary Date: Mon, 27 Jul 2026 10:01:03 +0100 Subject: [PATCH 15/24] Ensure errors during device creation are reflected in AsyncSesssion --- forge/routes/api/device.js | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/forge/routes/api/device.js b/forge/routes/api/device.js index a6a452d0af..5480fd3d34 100644 --- a/forge/routes/api/device.js +++ b/forge/routes/api/device.js @@ -421,6 +421,15 @@ module.exports = async function (app) { } } catch (err) { await transaction.rollback() + if (request.body.asyncSession) { + try { + // This is an async registration session that started with a POST to /_/register. + // We need to update the AsyncLoginSession with the result of this device creation + request.body.asyncSession.status = 'error' + request.body.asyncSession.result = JSON.stringify({ error: 'Failed to register device' }) + await request.body.asyncSession.save() + } catch (err) { } + } reply.code(400).send({ code: 'unexpected_error', error: err.toString() }) } }) @@ -445,7 +454,6 @@ module.exports = async function (app) { }, async (request, reply) => { // Create a new AsyncLoginSession with a unique sessionToken and doneToken const session = await app.db.models.AsyncLoginSession.createToken() - // TODO: this should be a front-end URL const registerUrl = `/register/remote-instance/${session.sessionToken}` const doneUrl = `/api/v1/devices/_/register/done/${session.doneToken}` reply.send({ registerUrl, doneUrl }) From 5090e0d5610e6dcdbeede7700567b16f76afd66c Mon Sep 17 00:00:00 2001 From: Nick O'Leary Date: Mon, 27 Jul 2026 10:02:06 +0100 Subject: [PATCH 16/24] Remove expired tokens --- forge/housekeeper/tasks/expireTokens.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/forge/housekeeper/tasks/expireTokens.js b/forge/housekeeper/tasks/expireTokens.js index 82a1ef14bd..34d8b2912a 100644 --- a/forge/housekeeper/tasks/expireTokens.js +++ b/forge/housekeeper/tasks/expireTokens.js @@ -13,5 +13,8 @@ module.exports = { await app.db.models.AccessToken.destroy({ where: { expiresAt: { [Op.lt]: Date.now() } } }) // Remove any OAuthSession objects that were created more than 5 minutes ago await app.db.models.OAuthSession.destroy({ where: { createdAt: { [Op.lt]: Date.now() - 1000 * 60 * 5 } } }) + // Remove any AsyncLoginSession objects that were created more than 30 minutes ago + await app.db.models.AsyncLoginSession.destroy({ where: { createdAt: { [Op.lt]: Date.now() - 1000 * 60 * 30 } } }) + } } From ca75095928e5df478c5e46999340680fd2052899 Mon Sep 17 00:00:00 2001 From: Nick O'Leary Date: Mon, 27 Jul 2026 10:04:44 +0100 Subject: [PATCH 17/24] Update migration file to latest --- ...-asyncloginsession.js => 20260727-01-add-asyncloginsession.js} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename forge/db/migrations/{20260714-01-add-asyncloginsession.js => 20260727-01-add-asyncloginsession.js} (100%) diff --git a/forge/db/migrations/20260714-01-add-asyncloginsession.js b/forge/db/migrations/20260727-01-add-asyncloginsession.js similarity index 100% rename from forge/db/migrations/20260714-01-add-asyncloginsession.js rename to forge/db/migrations/20260727-01-add-asyncloginsession.js From f502e833117b5abdf74d82f634d8ce400bd41e40 Mon Sep 17 00:00:00 2001 From: Nick O'Leary Date: Mon, 27 Jul 2026 10:06:40 +0100 Subject: [PATCH 18/24] Update lockfile --- package-lock.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/package-lock.json b/package-lock.json index 1d5fe739f6..f2c2255538 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14821,9 +14821,9 @@ } }, "node_modules/globals": { - "version": "17.7.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-17.7.0.tgz", - "integrity": "sha512-Czmyns5dUsq4seFBR/Kdydhmo8y9kC79hiSkPn0YcGtNnYWnrgt0vjrSjx9tspoDGWm2CMarffRuLjM4xUz8xg==", + "version": "17.8.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.8.0.tgz", + "integrity": "sha512-Zz/LMDZScFmkakeL2cTHzf+PbWKdpU3uclqkZT7TjDG58j5WPt0PpA+n9uPI24fZtlw07q0OtEi84K+umsRzqQ==", "dev": true, "license": "MIT", "engines": { @@ -36730,9 +36730,9 @@ } }, "globals": { - "version": "17.7.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-17.7.0.tgz", - "integrity": "sha512-Czmyns5dUsq4seFBR/Kdydhmo8y9kC79hiSkPn0YcGtNnYWnrgt0vjrSjx9tspoDGWm2CMarffRuLjM4xUz8xg==", + "version": "17.8.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.8.0.tgz", + "integrity": "sha512-Zz/LMDZScFmkakeL2cTHzf+PbWKdpU3uclqkZT7TjDG58j5WPt0PpA+n9uPI24fZtlw07q0OtEi84K+umsRzqQ==", "dev": true }, "globalthis": { From 5b7f6cb561b5bbf3084e69a27ebf8293b25009e8 Mon Sep 17 00:00:00 2001 From: Nick O'Leary Date: Mon, 27 Jul 2026 10:18:51 +0100 Subject: [PATCH 19/24] Fix linting --- forge/housekeeper/tasks/expireTokens.js | 1 - .../components/multi-step-forms/device/steps/SuccessStep.vue | 4 ++-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/forge/housekeeper/tasks/expireTokens.js b/forge/housekeeper/tasks/expireTokens.js index 34d8b2912a..5343a0ade2 100644 --- a/forge/housekeeper/tasks/expireTokens.js +++ b/forge/housekeeper/tasks/expireTokens.js @@ -15,6 +15,5 @@ module.exports = { await app.db.models.OAuthSession.destroy({ where: { createdAt: { [Op.lt]: Date.now() - 1000 * 60 * 5 } } }) // Remove any AsyncLoginSession objects that were created more than 30 minutes ago await app.db.models.AsyncLoginSession.destroy({ where: { createdAt: { [Op.lt]: Date.now() - 1000 * 60 * 30 } } }) - } } diff --git a/frontend/src/components/multi-step-forms/device/steps/SuccessStep.vue b/frontend/src/components/multi-step-forms/device/steps/SuccessStep.vue index 862adfc74d..5bd6d57a8a 100644 --- a/frontend/src/components/multi-step-forms/device/steps/SuccessStep.vue +++ b/frontend/src/components/multi-step-forms/device/steps/SuccessStep.vue @@ -35,13 +35,13 @@