diff --git a/forge/db/migrations/20260727-01-add-asyncloginsession.js b/forge/db/migrations/20260727-01-add-asyncloginsession.js new file mode 100644 index 0000000000..93714c1cba --- /dev/null +++ b/forge/db/migrations/20260727-01-add-asyncloginsession.js @@ -0,0 +1,36 @@ +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.TEXT + }, + createdAt: { + type: DataTypes.DATE, + allowNull: false + }, + updatedAt: { + type: DataTypes.DATE, + allowNull: false + } + }) + await context.addIndex('AsyncLoginSessions', { name: 'async_login_sessions_done_token', fields: ['doneToken'] }) + }, + down: async (context, Sequelize) => { } +} diff --git a/forge/db/models/AsyncLoginSession.js b/forge/db/models/AsyncLoginSession.js new file mode 100644 index 0000000000..8447ea6e2a --- /dev/null +++ b/forge/db/models/AsyncLoginSession.js @@ -0,0 +1,71 @@ +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: { + sessionToken: { + type: DataTypes.STRING, + primaryKey: true + }, + doneToken: { + type: DataTypes.STRING + }, + status: { + type: DataTypes.STRING, + defaultValue: 'pending' + }, + result: { + type: DataTypes.TEXT + } + }, + indexes: [ + { name: 'async_login_sessions_done_token', fields: ['doneToken'] } + ], + finders: function (M) { + return { + static: { + createToken: async () => { + const sessionToken = generateToken(16) + const doneToken = generateToken(32) + const session = await this.create({ + sessionToken, + doneToken + }) + return session + }, + bySessionToken: async (sessionToken) => { + const session = await this.findOne({ + where: { sessionToken } + }) + return validateSessionExpiry(session) + }, + getAndExpireByDoneToken: async (doneToken) => { + let session = await this.findOne({ + where: { doneToken } + }) + 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/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. diff --git a/forge/housekeeper/tasks/expireTokens.js b/forge/housekeeper/tasks/expireTokens.js index 82a1ef14bd..5343a0ade2 100644 --- a/forge/housekeeper/tasks/expireTokens.js +++ b/forge/housekeeper/tasks/expireTokens.js @@ -13,5 +13,7 @@ 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 } } }) } } diff --git a/forge/routes/api/device.js b/forge/routes/api/device.js index a5bfbed67a..b2fb95ade5 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) { @@ -389,10 +421,107 @@ 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() }) } }) + app.post('/_/register', { + config: { + allowAnonymous: true, + // Rate limit to 5 requests per 30 seconds per IP address. Given + // this is a manual registration flow, we don't support high volume requests + // from single sources. + rateLimit: app.config.rate_limits ? { max: 5, timeWindow: 30000 } : false + }, + 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() + 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/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/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/Loading.vue b/frontend/src/components/Loading.vue index 8b7e1d178f..b0da2c4ab5 100644 --- a/frontend/src/components/Loading.vue +++ b/frontend/src/components/Loading.vue @@ -4,7 +4,7 @@ :class="{'theme-light': resolvedColor == 'black', 'theme-dark': resolvedColor == 'white'}" data-el="loading" > -
+

{{ message || 'Loading...' }}

@@ -26,12 +26,24 @@ export default { message: { default: null, type: String + }, + scale: { + default: 'regular', + type: String } }, computed: { ...mapState(useThemeStore, ['effective']), resolvedColor () { return this.color || (this.effective === 'dark' ? 'white' : 'black') + }, + scaleClass () { + if (this.scale === 'small') { + return 'w-32' + } else if (this.scale === 'tiny') { + return 'w-16' + } + return 'w-64' } } } diff --git a/frontend/src/components/banners/FeatureUnavailableToTeam.vue b/frontend/src/components/banners/FeatureUnavailableToTeam.vue index fbbc0df25c..d83626950a 100644 --- a/frontend/src/components/banners/FeatureUnavailableToTeam.vue +++ b/frontend/src/components/banners/FeatureUnavailableToTeam.vue @@ -9,13 +9,13 @@
{{ fullMessage }} - Please upgrade + Please upgrade your Team to continue. {{ featureName }} is not available for your current Team. Please - upgrade your Team in order to use it. + upgrade your Team in order to use it.
@@ -55,11 +55,6 @@ export default { upgradePath () { return { name: 'TeamChangeType', params: { team_slug: this.team.slug } } } - }, - methods: { - navigateToUpgrade () { - this.$router.push(this.upgradePath) - } } } 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..949822c8f3 --- /dev/null +++ b/frontend/src/components/multi-step-forms/device/MultiStepDeviceForm.vue @@ -0,0 +1,253 @@ + + + + + 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..be031fa670 --- /dev/null +++ b/frontend/src/components/multi-step-forms/device/steps/DeviceStep.vue @@ -0,0 +1,255 @@ + + + + + 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..5bd6d57a8a --- /dev/null +++ b/frontend/src/components/multi-step-forms/device/steps/SuccessStep.vue @@ -0,0 +1,133 @@ + + + + + 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..0ffb53eacc 100644 --- a/frontend/src/components/multi-step-forms/instance/steps/ApplicationStep.vue +++ b/frontend/src/components/multi-step-forms/instance/steps/ApplicationStep.vue @@ -12,7 +12,7 @@ >