diff --git a/README.md b/README.md index 53c0cd5..d9a1869 100644 --- a/README.md +++ b/README.md @@ -62,11 +62,10 @@ npm run dev Frontend runs on **http://localhost:3000** -### Default Login +### Login -After seeding, login with credentials configured in `backend/.env`: -- Email: `ADMIN_EMAIL` (default: admin@flatlogic.com) -- Password: `ADMIN_PASS` (default: 88dbeaf8) +The login page does not display or prefill seeded credentials in any environment. +After seeding, use the credentials configured in backend environment/config values. ## Project Structure @@ -161,7 +160,6 @@ Base URL: `http://localhost:8080/api` | Endpoint | Description | |----------|-------------| | `POST /auth/signin/local` | Login | -| `POST /auth/signup` | Register | | `GET /auth/me` | Current user | | `GET /projects` | List projects | | `POST /publish/save-to-stage` | Copy dev → stage | diff --git a/backend/src/db/api/base.api.js b/backend/src/db/api/base.api.js index 411001c..3f411a2 100644 --- a/backend/src/db/api/base.api.js +++ b/backend/src/db/api/base.api.js @@ -306,8 +306,8 @@ class GenericDBApi { static async findAll(filter = {}, options = {}) { filter = filter || {}; const limit = filter.limit || 0; - const currentPage = +filter.page || 0; - const offset = currentPage * limit; + const currentPage = Number(filter.page) || 0; + const offset = Math.max(currentPage - 1, 0) * limit; let where = {}; let include = [...this.FIND_ALL_INCLUDES]; diff --git a/backend/src/db/api/project_audio_tracks.js b/backend/src/db/api/project_audio_tracks.js index d271574..4a57c67 100644 --- a/backend/src/db/api/project_audio_tracks.js +++ b/backend/src/db/api/project_audio_tracks.js @@ -88,8 +88,8 @@ class Project_audio_tracksDBApi extends GenericDBApi { static async findAll(filter = {}, options = {}) { filter = filter || {}; const limit = filter.limit || 0; - const currentPage = +filter.page || 0; - const offset = currentPage * limit; + const currentPage = Number(filter.page) || 0; + const offset = Math.max(currentPage - 1, 0) * limit; let where = {}; diff --git a/backend/src/db/api/project_element_defaults.js b/backend/src/db/api/project_element_defaults.js index f21c11f..87fbef0 100644 --- a/backend/src/db/api/project_element_defaults.js +++ b/backend/src/db/api/project_element_defaults.js @@ -104,8 +104,8 @@ class Project_element_defaultsDBApi extends GenericDBApi { static async findAll(filter = {}, options = {}) { filter = filter || {}; const limit = filter.limit || 0; - const currentPage = +filter.page || 0; - const offset = currentPage * limit; + const currentPage = Number(filter.page) || 0; + const offset = Math.max(currentPage - 1, 0) * limit; let where = {}; diff --git a/backend/src/db/api/project_transition_settings.js b/backend/src/db/api/project_transition_settings.js index f1be307..6757dd1 100644 --- a/backend/src/db/api/project_transition_settings.js +++ b/backend/src/db/api/project_transition_settings.js @@ -166,8 +166,8 @@ class Project_transition_settingsDBApi extends GenericDBApi { static async findAll(filter = {}, options = {}) { filter = filter || {}; const limit = filter.limit || 0; - const currentPage = +filter.page || 0; - const offset = currentPage * limit; + const currentPage = Number(filter.page) || 0; + const offset = Math.max(currentPage - 1, 0) * limit; let where = {}; diff --git a/backend/src/db/api/projects.js b/backend/src/db/api/projects.js index d0edb92..d066d57 100644 --- a/backend/src/db/api/projects.js +++ b/backend/src/db/api/projects.js @@ -23,6 +23,7 @@ class ProjectsDBApi extends GenericDBApi { 'logo_url', 'favicon_url', 'og_image_url', + 'production_presentation_visibility', ]; } @@ -61,6 +62,10 @@ class ProjectsDBApi extends GenericDBApi { 'og_image_url' in data ? data.og_image_url || null : undefined, design_width: 'design_width' in data ? data.design_width : undefined, design_height: 'design_height' in data ? data.design_height : undefined, + production_presentation_visibility: + 'production_presentation_visibility' in data + ? data.production_presentation_visibility || 'public' + : undefined, }; } @@ -71,6 +76,7 @@ class ProjectsDBApi extends GenericDBApi { static get ALL_INCLUDES() { return [ { association: 'project_memberships_project' }, + { association: 'production_presentation_access_project' }, { association: 'assets_project' }, { association: 'presigned_url_requests_project' }, { association: 'tour_pages_project' }, @@ -128,8 +134,8 @@ class ProjectsDBApi extends GenericDBApi { static async findAll(filter = {}, options = {}) { filter = filter || {}; const limit = filter.limit || 0; - const currentPage = +filter.page || 0; - const offset = currentPage * limit; + const currentPage = Number(filter.page) || 0; + const offset = Math.max(currentPage - 1, 0) * limit; let where = {}; let include = []; diff --git a/backend/src/db/api/tour_pages.js b/backend/src/db/api/tour_pages.js index fe51dbf..6f07766 100644 --- a/backend/src/db/api/tour_pages.js +++ b/backend/src/db/api/tour_pages.js @@ -165,8 +165,8 @@ class Tour_pagesDBApi extends GenericDBApi { static async findAll(filter = {}, options = {}) { filter = filter || {}; const limit = filter.limit || 0; - const currentPage = +filter.page || 0; - const offset = currentPage * limit; + const currentPage = Number(filter.page) || 0; + const offset = Math.max(currentPage - 1, 0) * limit; let where = {}; diff --git a/backend/src/db/api/users.js b/backend/src/db/api/users.js index 370fc8e..074f448 100644 --- a/backend/src/db/api/users.js +++ b/backend/src/db/api/users.js @@ -54,6 +54,7 @@ module.exports = class UsersDBApi { static async create(data, options) { const currentUser = (options && options.currentUser) || { id: null }; const transaction = (options && options.transaction) || undefined; + const password = data.data.password || crypto.randomBytes(20).toString('hex'); const users = await db.users.create( { @@ -65,7 +66,7 @@ module.exports = class UsersDBApi { email: data.data.email || null, disabled: data.data.disabled || false, - password: data.data.password || null, + password: bcrypt.hashSync(password, config.bcrypt.saltRounds), emailVerified: data.data.emailVerified || true, emailVerificationToken: data.data.emailVerificationToken || null, @@ -74,7 +75,7 @@ module.exports = class UsersDBApi { passwordResetToken: data.data.passwordResetToken || null, passwordResetTokenExpiresAt: data.data.passwordResetTokenExpiresAt || null, - provider: data.data.provider || null, + provider: data.data.provider || config.providers.LOCAL, importHash: data.data.importHash || null, createdById: currentUser.id, updatedById: currentUser.id, @@ -128,7 +129,10 @@ module.exports = class UsersDBApi { email: item.email || null, disabled: item.disabled || false, - password: item.password || null, + password: bcrypt.hashSync( + item.password || crypto.randomBytes(20).toString('hex'), + config.bcrypt.saltRounds, + ), emailVerified: item.emailVerified || false, emailVerificationToken: item.emailVerificationToken || null, @@ -136,7 +140,7 @@ module.exports = class UsersDBApi { item.emailVerificationTokenExpiresAt || null, passwordResetToken: item.passwordResetToken || null, passwordResetTokenExpiresAt: item.passwordResetTokenExpiresAt || null, - provider: item.provider || null, + provider: item.provider || config.providers.LOCAL, importHash: item.importHash || null, createdById: currentUser.id, updatedById: currentUser.id, @@ -352,6 +356,7 @@ module.exports = class UsersDBApi { association: 'app_role', include: [{ association: 'permissions' }], }, + { association: 'custom_permissions' }, ], }); @@ -373,9 +378,9 @@ module.exports = class UsersDBApi { const limit = filter.limit || 0; let offset = 0; let where = {}; - const currentPage = +filter.page; + const currentPage = Number(filter.page) || 1; - offset = currentPage * limit; + offset = Math.max(currentPage - 1, 0) * limit; const appRoleTerms = filter.app_role ? filter.app_role.split('|') : []; const appRoleValidUuids = Utils.filterValidUuids(appRoleTerms); diff --git a/backend/src/db/migrations/20260626000001-add-private-production-presentation-access.js b/backend/src/db/migrations/20260626000001-add-private-production-presentation-access.js new file mode 100644 index 0000000..55f5b05 --- /dev/null +++ b/backend/src/db/migrations/20260626000001-add-private-production-presentation-access.js @@ -0,0 +1,103 @@ +'use strict'; + +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.addColumn('projects', 'production_presentation_visibility', { + type: Sequelize.ENUM('public', 'private'), + allowNull: false, + defaultValue: 'public', + }); + + await queryInterface.createTable('production_presentation_access', { + id: { + type: Sequelize.UUID, + defaultValue: Sequelize.UUIDV4, + primaryKey: true, + }, + projectId: { + type: Sequelize.UUID, + allowNull: false, + references: { + model: 'projects', + key: 'id', + }, + onDelete: 'CASCADE', + onUpdate: 'CASCADE', + }, + userId: { + type: Sequelize.UUID, + allowNull: false, + references: { + model: 'users', + key: 'id', + }, + onDelete: 'CASCADE', + onUpdate: 'CASCADE', + }, + createdById: { + type: Sequelize.UUID, + allowNull: true, + references: { + model: 'users', + key: 'id', + }, + onDelete: 'SET NULL', + onUpdate: 'CASCADE', + }, + updatedById: { + type: Sequelize.UUID, + allowNull: true, + references: { + model: 'users', + key: 'id', + }, + onDelete: 'SET NULL', + onUpdate: 'CASCADE', + }, + createdAt: { + allowNull: false, + type: Sequelize.DATE, + }, + updatedAt: { + allowNull: false, + type: Sequelize.DATE, + }, + deletedAt: { + type: Sequelize.DATE, + allowNull: true, + }, + importHash: { + type: Sequelize.STRING(255), + allowNull: true, + unique: true, + }, + }); + + await queryInterface.sequelize.query(` + CREATE UNIQUE INDEX IF NOT EXISTS production_presentation_access_project_user_unique + ON production_presentation_access ("projectId", "userId") + WHERE "deletedAt" IS NULL + `); + + await queryInterface.sequelize.query(` + CREATE INDEX IF NOT EXISTS production_presentation_access_project_idx + ON production_presentation_access ("projectId") + `); + + await queryInterface.sequelize.query(` + CREATE INDEX IF NOT EXISTS production_presentation_access_user_idx + ON production_presentation_access ("userId") + `); + }, + + async down(queryInterface) { + await queryInterface.dropTable('production_presentation_access'); + await queryInterface.removeColumn( + 'projects', + 'production_presentation_visibility', + ); + await queryInterface.sequelize.query( + 'DROP TYPE IF EXISTS "enum_projects_production_presentation_visibility";', + ); + }, +}; diff --git a/backend/src/db/migrations/20260626000002-grant-account-manager-create-users.js b/backend/src/db/migrations/20260626000002-grant-account-manager-create-users.js new file mode 100644 index 0000000..266a60d --- /dev/null +++ b/backend/src/db/migrations/20260626000002-grant-account-manager-create-users.js @@ -0,0 +1,32 @@ +'use strict'; + +module.exports = { + async up(queryInterface) { + await queryInterface.sequelize.query(` + INSERT INTO "rolesPermissionsPermissions" + ("createdAt", "updatedAt", "roles_permissionsId", "permissionId") + SELECT NOW(), NOW(), r.id, p.id + FROM roles r + JOIN permissions p ON p.name = 'CREATE_USERS' + WHERE r.name = 'Account Manager' + AND NOT EXISTS ( + SELECT 1 + FROM "rolesPermissionsPermissions" rp + WHERE rp."roles_permissionsId" = r.id + AND rp."permissionId" = p.id + ) + `); + }, + + async down(queryInterface) { + await queryInterface.sequelize.query(` + DELETE FROM "rolesPermissionsPermissions" + WHERE "roles_permissionsId" IN ( + SELECT id FROM roles WHERE name = 'Account Manager' + ) + AND "permissionId" IN ( + SELECT id FROM permissions WHERE name = 'CREATE_USERS' + ) + `); + }, +}; diff --git a/backend/src/db/models/production_presentation_access.js b/backend/src/db/models/production_presentation_access.js new file mode 100644 index 0000000..6933fda --- /dev/null +++ b/backend/src/db/models/production_presentation_access.js @@ -0,0 +1,60 @@ +module.exports = function (sequelize, DataTypes) { + const production_presentation_access = sequelize.define( + 'production_presentation_access', + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + + importHash: { + type: DataTypes.STRING(255), + allowNull: true, + unique: true, + }, + }, + { + timestamps: true, + paranoid: true, + freezeTableName: true, + indexes: [ + { fields: ['projectId'] }, + { fields: ['userId'] }, + { fields: ['deletedAt'] }, + ], + }, + ); + + production_presentation_access.associate = (db) => { + db.production_presentation_access.belongsTo(db.projects, { + as: 'project', + foreignKey: { + name: 'projectId', + }, + constraints: true, + onDelete: 'CASCADE', + onUpdate: 'CASCADE', + }); + + db.production_presentation_access.belongsTo(db.users, { + as: 'user', + foreignKey: { + name: 'userId', + }, + constraints: true, + onDelete: 'CASCADE', + onUpdate: 'CASCADE', + }); + + db.production_presentation_access.belongsTo(db.users, { + as: 'createdBy', + }); + + db.production_presentation_access.belongsTo(db.users, { + as: 'updatedBy', + }); + }; + + return production_presentation_access; +}; diff --git a/backend/src/db/models/projects.js b/backend/src/db/models/projects.js index d345756..6c800a9 100644 --- a/backend/src/db/models/projects.js +++ b/backend/src/db/models/projects.js @@ -65,6 +65,12 @@ module.exports = function (sequelize, DataTypes) { defaultValue: 1080, }, + production_presentation_visibility: { + type: DataTypes.ENUM('public', 'private'), + allowNull: false, + defaultValue: 'public', + }, + // Note: transition_settings moved to project_transition_settings table // for environment-aware storage (dev, stage, production) @@ -95,6 +101,16 @@ module.exports = function (sequelize, DataTypes) { onUpdate: 'CASCADE', }); + db.projects.hasMany(db.production_presentation_access, { + as: 'production_presentation_access_project', + foreignKey: { + name: 'projectId', + }, + constraints: true, + onDelete: 'CASCADE', + onUpdate: 'CASCADE', + }); + db.projects.hasMany(db.assets, { as: 'assets_project', foreignKey: { diff --git a/backend/src/db/models/users.js b/backend/src/db/models/users.js index 741ce82..e03d8f1 100644 --- a/backend/src/db/models/users.js +++ b/backend/src/db/models/users.js @@ -127,6 +127,16 @@ module.exports = function (sequelize, DataTypes) { onUpdate: 'CASCADE', }); + db.users.hasMany(db.production_presentation_access, { + as: 'production_presentation_access_user', + foreignKey: { + name: 'userId', + }, + constraints: true, + onDelete: 'CASCADE', + onUpdate: 'CASCADE', + }); + db.users.hasMany(db.presigned_url_requests, { as: 'presigned_url_requests_user', foreignKey: { diff --git a/backend/src/factories/router.factory.js b/backend/src/factories/router.factory.js index ad00668..9184b88 100644 --- a/backend/src/factories/router.factory.js +++ b/backend/src/factories/router.factory.js @@ -20,7 +20,7 @@ function createEntityRouter(entityName, Service, DBApi, options = {}) { req.body.data, req.currentUser, true, - link.host, + link.origin, ); res.status(200).send(payload); }), @@ -33,7 +33,7 @@ function createEntityRouter(entityName, Service, DBApi, options = {}) { req.headers.referer || `${req.protocol}://${req.hostname}${req.originalUrl}`; const link = new URL(referer); - await Service.bulkImport(req, res, true, link.host); + await Service.bulkImport(req, res, true, link.origin); res.status(200).send(true); }), ); diff --git a/backend/src/index.js b/backend/src/index.js index 6a72890..e57488c 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -56,11 +56,13 @@ const project_transition_settingsRoutes = require('./routes/project_transition_s const publishRoutes = require('./routes/publish'); const runtimeContextRoutes = require('./routes/runtime-context'); +const runtimeAccessRoutes = require('./routes/runtime-access'); const { runtimeContextMiddleware } = require('./middlewares/runtime-context'); const { blockNonPublicRuntimeListEndpoints, sanitizePublicRuntimeListResponse, } = require('./middlewares/runtime-public'); +const RuntimePresentationAccessService = require('./services/runtime-presentation-access'); const getBaseUrl = (url) => { if (!url) return ''; @@ -147,21 +149,73 @@ app.use(bodyParser.json({ limit: '50mb' })); app.use(bodyParser.urlencoded({ extended: true, limit: '50mb' })); app.use(runtimeContextMiddleware); -const requireRuntimeReadOrAuth = (req, res, next) => { - const headerEnvironment = req.runtimeContext?.headerEnvironment; - const isReadOnlyRequest = ['GET', 'OPTIONS'].includes(req.method); - const hasAuthHeader = Boolean(req.headers.authorization); +const requireRuntimeReadOrAuth = async (req, res, next) => { + try { + const headerEnvironment = req.runtimeContext?.headerEnvironment; + const headerProjectSlug = req.runtimeContext?.headerProjectSlug; + const isReadOnlyRequest = ['GET', 'OPTIONS'].includes(req.method); + const hasAuthHeader = Boolean(req.headers.authorization); - // Only production is public. Stage requires authentication (workspace for review). - const isPublicEnvironment = headerEnvironment === 'production'; + // Only production is public. Stage requires authentication (workspace for review). + const isPublicEnvironment = headerEnvironment === 'production'; - if (isPublicEnvironment && isReadOnlyRequest && !hasAuthHeader) { - req.isRuntimePublicRequest = true; - return next(); + if (!isPublicEnvironment || !isReadOnlyRequest) { + req.isRuntimePublicRequest = false; + return jwtAuth(req, res, next); + } + + const isPrivateProductionPresentation = + await RuntimePresentationAccessService.isPrivateProductionPresentation( + headerProjectSlug, + ); + + if (!isPrivateProductionPresentation) { + req.isRuntimePublicRequest = true; + return next(); + } + + if (!hasAuthHeader) { + req.isRuntimePublicRequest = false; + return res.status(401).send({ message: 'Authentication required' }); + } + + return passport.authenticate( + 'jwt', + { session: false }, + async (error, user) => { + if (error) return next(error); + + if (!user) { + req.isRuntimePublicRequest = false; + return res.status(401).send({ message: 'Authentication required' }); + } + + req.currentUser = user; + + try { + const canAccess = + await RuntimePresentationAccessService.canUserAccessPrivateProductionPresentation( + user, + headerProjectSlug, + ); + + if (!canAccess) { + req.isRuntimePublicRequest = false; + return res + .status(403) + .send({ message: 'Presentation access denied' }); + } + + req.isRuntimePublicRequest = true; + return next(); + } catch (accessError) { + return next(accessError); + } + }, + )(req, res, next); + } catch (error) { + return next(error); } - - req.isRuntimePublicRequest = false; - return jwtAuth(req, res, next); }; // Health check endpoint (no auth required) @@ -189,6 +243,7 @@ app.get('/api/health', async (req, res) => { app.use('/api/auth', authRoutes); app.use('/api/runtime-context', runtimeContextRoutes); +app.use('/api/runtime-access', runtimeAccessRoutes); app.use('/api/users', jwtAuth, usersRoutes); diff --git a/backend/src/middlewares/rateLimiter.js b/backend/src/middlewares/rateLimiter.js index 821a999..5940f14 100644 --- a/backend/src/middlewares/rateLimiter.js +++ b/backend/src/middlewares/rateLimiter.js @@ -175,17 +175,6 @@ const authLimiter = createRateLimiter({ skipFailedRequests: false, // Count failed attempts }); -/** - * Signup limiter - Very strict limits for registration - * 5 signups per hour per IP - */ -const signupLimiter = createRateLimiter({ - keyPrefix: 'signup', - windowMs: 60 * 60 * 1000, // 1 hour - max: 5, - message: 'Too many signup attempts. Please try again later.', -}); - /** * Password reset limiter - Prevent password reset abuse * 5 requests per hour per IP @@ -259,7 +248,6 @@ module.exports = { createRateLimiter, createAuthenticatedRateLimiter, authLimiter, - signupLimiter, passwordResetLimiter, apiLimiter, uploadLimiter, diff --git a/backend/src/middlewares/runtime-public.js b/backend/src/middlewares/runtime-public.js index bcc0498..281ae42 100644 --- a/backend/src/middlewares/runtime-public.js +++ b/backend/src/middlewares/runtime-public.js @@ -7,6 +7,7 @@ const PUBLIC_RUNTIME_ENTITY_FIELDS = { 'logo_url', 'favicon_url', 'og_image_url', + 'production_presentation_visibility', ], tour_pages: [ 'id', diff --git a/backend/src/routes/auth.js b/backend/src/routes/auth.js index 4ffee9a..067c37a 100644 --- a/backend/src/routes/auth.js +++ b/backend/src/routes/auth.js @@ -5,10 +5,10 @@ const config = require('../config'); const AuthService = require('../services/auth'); const ForbiddenError = require('../services/notifications/errors/forbidden'); const EmailSender = require('../services/email'); +const RuntimePresentationAccessService = require('../services/runtime-presentation-access'); const wrapAsync = require('../helpers').wrapAsync; const { authLimiter: signinLimiter, - signupLimiter, passwordResetLimiter, } = require('../middlewares/rateLimiter'); @@ -138,15 +138,21 @@ router.post( router.get( '/me', passport.authenticate('jwt', { session: false }), - (req, res) => { + wrapAsync(async (req, res) => { if (!req.currentUser || !req.currentUser.id) { throw new ForbiddenError(); } - const payload = req.currentUser; + const payload = { + ...req.currentUser, + allowedPrivateProductionSlugs: + await RuntimePresentationAccessService.getAllowedPrivateProductionSlugs( + req.currentUser, + ), + }; delete payload.password; res.status(200).send(payload); - }, + }), ); router.put( @@ -199,45 +205,6 @@ router.post( }), ); -/** - * @swagger - * /api/auth/signup: - * post: - * tags: [Auth] - * summary: Register new user into the system - * description: Register new user into the system - * requestBody: - * description: Set valid user email and password - * content: - * application/json: - * schema: - * $ref: "#/components/schemas/Auth" - * responses: - * 200: - * description: New user successfully signed up - * 400: - * description: Invalid username/password supplied - * 500: - * description: Some server error - * x-codegen-request-body-name: body - */ - -router.post( - '/signup', - signupLimiter, - wrapAsync(async (req, res) => { - const host = getRequestHost(req); - const payload = await AuthService.signup( - req.body.email, - req.body.password, - - req, - host, - ); - res.status(200).send(payload); - }), -); - router.put( '/profile', passport.authenticate('jwt', { session: false }), diff --git a/backend/src/routes/project_transition_settings.js b/backend/src/routes/project_transition_settings.js index bf9007e..987280c 100644 --- a/backend/src/routes/project_transition_settings.js +++ b/backend/src/routes/project_transition_settings.js @@ -1,9 +1,11 @@ const express = require('express'); const passport = require('passport'); +const db = require('../db/models'); const Project_transition_settingsService = require('../services/project_transition_settings'); const Project_transition_settingsDBApi = require('../db/api/project_transition_settings'); const { wrapAsync, commonErrorHandler, isUuidV4 } = require('../helpers'); const { checkCrudPermissions } = require('../middlewares/check-permissions'); +const RuntimePresentationAccessService = require('../services/runtime-presentation-access'); const router = express.Router(); const jwtAuth = passport.authenticate('jwt', { session: false }); @@ -23,16 +25,69 @@ const allowAuthenticatedRead = (req, _res, next) => { * Middleware: Production GET is public, everything else requires JWT. * Determines public access from URL path, not headers. */ -const requireProductionOrAuth = (req, res, next) => { +const getRuntimeProjectSlug = async (req) => { + if (req.runtimeContext?.headerProjectSlug) { + return req.runtimeContext.headerProjectSlug; + } + + if (!isUuidV4(req.params.projectId)) { + return null; + } + + const project = await db.projects.findByPk(req.params.projectId, { + attributes: ['slug'], + }); + return project?.slug || null; +}; + +const requireProductionOrAuth = async (req, res, next) => { const { environment } = req.params; const isProduction = environment === 'production'; const isReadOnly = ['GET', 'OPTIONS'].includes(req.method); - if (isProduction && isReadOnly) { + let runtimeProjectSlug = null; + let isPrivateProductionPresentation = false; + + try { + runtimeProjectSlug = await getRuntimeProjectSlug(req); + isPrivateProductionPresentation = + await RuntimePresentationAccessService.isPrivateProductionPresentation( + runtimeProjectSlug, + ); + } catch (error) { + return next(error); + } + + if (isProduction && isReadOnly && !isPrivateProductionPresentation) { // Public access for production GET return next(); } + if (isProduction && isReadOnly && isPrivateProductionPresentation) { + return passport.authenticate('jwt', { session: false }, async (error, user) => { + if (error) return next(error); + + if (!user) { + return res.status(401).send({ message: 'Authentication required' }); + } + + req.currentUser = user; + + const canAccess = + await RuntimePresentationAccessService.canUserAccessPrivateProductionPresentation( + user, + runtimeProjectSlug, + ); + + if (!canAccess) { + return res.status(403).send({ message: 'Presentation access denied' }); + } + + req.isRuntimePublicRequest = true; + return next(); + })(req, res, next); + } + // Require JWT for non-production or write operations return jwtAuth(req, res, next); }; diff --git a/backend/src/routes/runtime-access.js b/backend/src/routes/runtime-access.js new file mode 100644 index 0000000..ac8f748 --- /dev/null +++ b/backend/src/routes/runtime-access.js @@ -0,0 +1,59 @@ +const express = require('express'); +const passport = require('passport'); +const RuntimePresentationAccessService = require('../services/runtime-presentation-access'); +const { checkPermissions } = require('../middlewares/check-permissions'); +const { wrapAsync } = require('../helpers'); + +const router = express.Router(); + +router.get('/presentations/:slug', wrapAsync(async (req, res) => { + const slug = RuntimePresentationAccessService.normalizeSlug(req.params.slug); + res.status(200).send({ + slug, + isPrivateProductionPresentation: + await RuntimePresentationAccessService.isPrivateProductionPresentation( + slug, + ), + }); +})); + +router.get( + '/private-production-presentations', + passport.authenticate('jwt', { session: false }), + checkPermissions('CREATE_USERS'), + wrapAsync(async (_req, res) => { + res + .status(200) + .send( + await RuntimePresentationAccessService.listPrivateProductionPresentations(), + ); + }), +); + +router.get( + '/private-production-presentations/autocomplete', + passport.authenticate('jwt', { session: false }), + checkPermissions('CREATE_USERS'), + wrapAsync(async (_req, res) => { + res + .status(200) + .send( + await RuntimePresentationAccessService.listPrivateProductionPresentations(), + ); + }), +); + +router.get( + '/me', + passport.authenticate('jwt', { session: false }), + wrapAsync(async (req, res) => { + res.status(200).send({ + allowedPrivateProductionSlugs: + await RuntimePresentationAccessService.getAllowedPrivateProductionSlugs( + req.currentUser, + ), + }); + }), +); + +module.exports = router; diff --git a/backend/src/routes/tour_pages.js b/backend/src/routes/tour_pages.js index 53dcef0..8608257 100644 --- a/backend/src/routes/tour_pages.js +++ b/backend/src/routes/tour_pages.js @@ -168,7 +168,7 @@ router.post( req.body.data, req.currentUser, true, - link.host, + link.origin, ); res.status(200).send(payload); }), @@ -182,7 +182,7 @@ router.post( req.headers.referer || `${req.protocol}://${req.hostname}${req.originalUrl}`; const link = new URL(referer); - await Tour_pagesService.bulkImport(req, res, true, link.host); + await Tour_pagesService.bulkImport(req, res, true, link.origin); res.status(200).send(true); }), ); diff --git a/backend/src/services/auth.js b/backend/src/services/auth.js index 0fc416f..aeb0549 100644 --- a/backend/src/services/auth.js +++ b/backend/src/services/auth.js @@ -11,62 +11,6 @@ const config = require('../config'); const helpers = require('../helpers'); class Auth { - static async signup(email, password, options = {}, host) { - const user = await UsersDBApi.findBy({ email }); - - const hashedPassword = await bcrypt.hash( - password, - config.bcrypt.saltRounds, - ); - - if (user) { - if (user.authenticationUid) { - throw new ValidationError('auth.emailAlreadyInUse'); - } - - if (user.disabled) { - throw new ValidationError('auth.userDisabled'); - } - - await UsersDBApi.updatePassword(user.id, hashedPassword, options); - - if (EmailSender.isConfigured) { - await this.sendEmailAddressVerificationEmail(user.email, host); - } - - const data = { - user: { - id: user.id, - email: user.email, - }, - }; - - return helpers.jwtSign(data); - } - - const newUser = await UsersDBApi.createFromAuth( - { - firstName: email.split('@')[0], - password: hashedPassword, - email: email, - }, - options, - ); - - if (EmailSender.isConfigured) { - await this.sendEmailAddressVerificationEmail(newUser.email, host); - } - - const data = { - user: { - id: newUser.id, - email: newUser.email, - }, - }; - - return helpers.jwtSign(data); - } - static async signin(email, password) { const user = await UsersDBApi.findBy({ email }); diff --git a/backend/src/services/runtime-presentation-access.js b/backend/src/services/runtime-presentation-access.js new file mode 100644 index 0000000..827f99c --- /dev/null +++ b/backend/src/services/runtime-presentation-access.js @@ -0,0 +1,121 @@ +const db = require('../db/models'); + +class RuntimePresentationAccessService { + static normalizeSlug(slug) { + return String(slug || '') + .trim() + .replace(/^\/+|\/+$/g, '') + .toLowerCase(); + } + + static async getProjectBySlug(slug, options = {}) { + const normalizedSlug = this.normalizeSlug(slug); + if (!normalizedSlug) return null; + + return db.projects.findOne({ + where: { slug: normalizedSlug }, + attributes: ['id', 'name', 'slug', 'production_presentation_visibility'], + transaction: options.transaction, + }); + } + + static async isPrivateProductionPresentation(slug, options = {}) { + const project = await this.getProjectBySlug(slug, options); + return project?.production_presentation_visibility === 'private'; + } + + static userHasAnyPermission(user) { + const customPermissions = Array.isArray(user?.custom_permissions) + ? user.custom_permissions + : []; + const rolePermissions = Array.isArray(user?.app_role?.permissions) + ? user.app_role.permissions + : []; + const mappedRolePermissions = Array.isArray(user?.app_role_permissions) + ? user.app_role_permissions + : []; + + return ( + customPermissions.length > 0 || + rolePermissions.length > 0 || + mappedRolePermissions.length > 0 + ); + } + + static async canUserAccessPrivateProductionPresentation( + user, + slug, + options = {}, + ) { + const project = await this.getProjectBySlug(slug, options); + if (!project) return false; + if (project.production_presentation_visibility !== 'private') return true; + + if (this.userHasAnyPermission(user)) { + return true; + } + + if (!user?.id) return false; + + const access = await db.production_presentation_access.findOne({ + where: { + projectId: project.id, + userId: user.id, + }, + transaction: options.transaction, + }); + + return Boolean(access); + } + + static async getAllowedPrivateProductionSlugs(user, options = {}) { + if (!user?.id || this.userHasAnyPermission(user)) return []; + + const accessRows = await db.production_presentation_access.findAll({ + where: { userId: user.id }, + include: [ + { + association: 'project', + attributes: ['slug', 'production_presentation_visibility'], + where: { production_presentation_visibility: 'private' }, + required: true, + }, + ], + transaction: options.transaction, + }); + + return accessRows + .map((row) => { + const plain = + typeof row.get === 'function' ? row.get({ plain: true }) : row; + return plain.project?.slug; + }) + .filter(Boolean); + } + + static async listPrivateProductionPresentations(options = {}) { + const projects = await db.projects.findAll({ + where: { + production_presentation_visibility: 'private', + }, + attributes: ['id', 'name', 'slug'], + order: [['name', 'ASC']], + transaction: options.transaction, + }); + + return projects.map((project) => { + const plain = + typeof project.get === 'function' + ? project.get({ plain: true }) + : project; + return { + id: plain.id, + label: `${plain.name} (${plain.slug})`, + name: plain.name, + slug: plain.slug, + }; + }); + } +} + +module.exports = RuntimePresentationAccessService; diff --git a/backend/src/services/users.js b/backend/src/services/users.js index e92bac9..a02e86a 100644 --- a/backend/src/services/users.js +++ b/backend/src/services/users.js @@ -4,6 +4,7 @@ const { createEntityService } = require('../factories/service.factory'); const ValidationError = require('./notifications/errors/validation'); const config = require('../config'); const AuthService = require('./auth'); +const { logger } = require('../utils/logger'); // Generate base service from factory const BaseUsersService = createEntityService(UsersDBApi, { @@ -15,6 +16,66 @@ const BaseUsersService = createEntityService(UsersDBApi, { * Extends factory-generated service with custom user logic */ class UsersService extends BaseUsersService { + static normalizeIdArray(value) { + if (!Array.isArray(value)) return []; + return value + .map((item) => { + if (typeof item === 'string') return item; + if (item && typeof item === 'object') return item.id || item.value; + return null; + }) + .filter(Boolean); + } + + static async createProductionPresentationAccessForPublicUser({ + user, + data, + currentUser, + transaction, + }) { + if (!user?.id) return; + + await db.production_presentation_access.destroy({ + where: { userId: user.id }, + transaction, + }); + + const selectedProjectIds = this.normalizeIdArray( + data.allowed_private_production_project_ids, + ); + + if (!selectedProjectIds.length || !data.app_role) return; + + const role = await db.roles.findByPk(data.app_role, { transaction }); + if (role?.name !== 'Public') return; + + const privateProjects = await db.projects.findAll({ + where: { + id: { + [db.Sequelize.Op.in]: selectedProjectIds, + }, + production_presentation_visibility: 'private', + }, + attributes: ['id'], + transaction, + }); + + if (!privateProjects.length) return; + + const now = new Date(); + await db.production_presentation_access.bulkCreate( + privateProjects.map((project) => ({ + projectId: project.id, + userId: user.id, + createdById: currentUser?.id || null, + updatedById: currentUser?.id || null, + createdAt: now, + updatedAt: now, + })), + { ignoreDuplicates: true, transaction }, + ); + } + /** * Create user with email validation and optional invitation */ @@ -27,17 +88,46 @@ class UsersService extends BaseUsersService { throw new ValidationError('iam.errors.emailRequired'); } - const existingUser = await UsersDBApi.findBy({ email }, { transaction }); - if (existingUser) { + const existingUser = await db.users.findOne({ + where: { email }, + paranoid: false, + transaction, + }); + + if (existingUser && !existingUser.deletedAt) { throw new ValidationError('iam.errors.userAlreadyExists'); } - await UsersDBApi.create({ data }, { currentUser, transaction }); + let user; + + if (existingUser?.deletedAt) { + await existingUser.restore({ transaction }); + user = await UsersDBApi.update(existingUser.id, data, { + currentUser, + transaction, + }); + } else { + user = await UsersDBApi.create({ data }, { currentUser, transaction }); + } + + await this.createProductionPresentationAccessForPublicUser({ + user, + data, + currentUser, + transaction, + }); await transaction.commit(); // Send invitation email after successful commit if (sendInvitationEmails) { - AuthService.sendPasswordResetEmail(email, 'invitation', host); + AuthService.sendPasswordResetEmail(email, 'invitation', host).catch( + (error) => { + logger.error( + { err: error, email }, + 'Failed to send user invitation email', + ); + }, + ); } } catch (error) { await transaction.rollback(); diff --git a/frontend/next.config.mjs b/frontend/next.config.mjs index fc51dfc..f809ca0 100644 --- a/frontend/next.config.mjs +++ b/frontend/next.config.mjs @@ -1,20 +1,26 @@ /** * @type {import('next').NextConfig} */ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; import withSerwistInit from '@serwist/next'; const output = process.env.NEXT_OUTPUT || undefined; +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const isDevelopment = process.env.NODE_ENV === 'development'; -// Configure Serwist for service worker generation +// Configure Serwist for production service worker generation. const withSerwist = withSerwistInit({ swSrc: 'src/sw.ts', swDest: 'public/sw.js', - disable: process.env.NODE_ENV === 'development', + disable: isDevelopment, + maximumFileSizeToCacheInBytes: 5 * 1024 * 1024, }); const nextConfig = { trailingSlash: true, - distDir: 'build', + distDir: isDevelopment ? '.next' : 'build', + outputFileTracingRoot: __dirname, output, basePath: '', devIndicators: { @@ -42,4 +48,4 @@ const nextConfig = { }, }; -export default withSerwist(nextConfig); +export default isDevelopment ? nextConfig : withSerwist(nextConfig); diff --git a/frontend/src/components/DataGrid/configBuilderFactory.tsx b/frontend/src/components/DataGrid/configBuilderFactory.tsx index 6b7f41f..840ea6e 100644 --- a/frontend/src/components/DataGrid/configBuilderFactory.tsx +++ b/frontend/src/components/DataGrid/configBuilderFactory.tsx @@ -2,7 +2,6 @@ import React from 'react'; import axios from 'axios'; import { GridColDef, - GridRowParams, GridRenderCellParams, GridSingleSelectColDef, } from '@mui/x-data-grid'; @@ -75,21 +74,21 @@ async function fetchRelationOptions( function getFormatter( col: ColumnMetadata, -): ((params: { value: unknown }) => string) | undefined { +): ((value: unknown) => string) | undefined { if (col.valueFormatter) { const customFormatter = col.valueFormatter; - return ({ value }) => customFormatter(value); + return (value) => customFormatter(value); } switch (col.type) { case 'boolean': - return ({ value }) => dataFormatter.booleanFormatter(value); + return (value) => dataFormatter.booleanFormatter(value); case 'date': - return ({ value }) => dataFormatter.dateFormatter(value); + return (value) => dataFormatter.dateFormatter(value); case 'datetime': - return ({ value }) => dataFormatter.dateTimeFormatter(value); + return (value) => dataFormatter.dateTimeFormatter(value); case 'relation': - return ({ value }) => { + return (value) => { const formatter = dataFormatter[ `${col.entityRef}OneListFormatter` as keyof typeof dataFormatter @@ -99,7 +98,7 @@ function getFormatter( : String(value || ''); }; case 'relationMany': - return ({ value }) => { + return (value) => { const formatter = dataFormatter[ `${col.entityRef}ManyListFormatter` as keyof typeof dataFormatter @@ -222,12 +221,13 @@ function buildActionsColumn( ): GridColDef { return { field: 'actions', - type: 'actions' as const, minWidth: 30, + sortable: false, + filterable: false, headerClassName: 'datagrid--header', cellClassName: 'datagrid--cell', - getActions: (params: GridRowParams) => [ -
- The SaaS platform to design, manage, and publish offline-ready - interactive tours for physical venues. -
-© 2026 {title}. All rights reserved
-
- Use{' '}
- setLogin(e.currentTarget)}
- >
- admin@flatlogic.com
-
- {' / '}
- 88dbeaf8
- {' / '}
- to login as Admin
-
- Use{' '}
- setLogin(e.currentTarget)}
- >
- client@hello.com
-
- {' / '}
- c3baadeda5c6
- {' / '}
- to login as User
-
- Don't have an account yet?{' '} - - New Account - -