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) => [ -
+ renderCell: (params: GridRenderCellParams) => ( +
-
, - ], +
+ ), }; } diff --git a/frontend/src/components/RuntimePresentation.tsx b/frontend/src/components/RuntimePresentation.tsx index 24d1dda..9cd360a 100644 --- a/frontend/src/components/RuntimePresentation.tsx +++ b/frontend/src/components/RuntimePresentation.tsx @@ -6,6 +6,7 @@ */ import Head from 'next/head'; +import { useRouter } from 'next/router'; import React, { ReactElement, useCallback, @@ -58,6 +59,7 @@ import { } from '../lib/navigationHelpers'; import { useTransitionSettings } from '../hooks/useTransitionSettings'; import { useAppSelector, useAppDispatch } from '../stores/hooks'; +import { logoutUser } from '../stores/authSlice'; import { fetch as fetchGlobalTransitionDefaults } from '../stores/global_transition_defaults/globalTransitionDefaultsSlice'; import { fetchByProjectAndEnv as fetchProjectTransitionSettings, @@ -89,22 +91,27 @@ export default function RuntimePresentation({ environment, }: RuntimePresentationProps) { const dispatch = useAppDispatch(); + const router = useRouter(); const globalTransitionDefaults = useAppSelector( (state) => state.global_transition_defaults.data, ); // Use shared hook for loading project and pages data // Note: We can't fetch project transition settings until we have the project ID - const { project, pages, isLoading, error, initialPageId } = usePageDataLoader( - { + const runtimeApiHeaders = useMemo( + () => ({ + 'X-Runtime-Project-Slug': projectSlug, + 'X-Runtime-Environment': environment, + }), + [environment, projectSlug], + ); + + const { project, pages, isLoading, error, errorStatus, initialPageId } = + usePageDataLoader({ projectSlug, environment, - apiHeaders: { - 'X-Runtime-Project-Slug': projectSlug, - 'X-Runtime-Environment': environment, - }, - }, - ); + apiHeaders: runtimeApiHeaders, + }); // Fetch global transition defaults on mount (public endpoint, no auth needed) useEffect(() => { @@ -115,10 +122,30 @@ export default function RuntimePresentation({ useEffect(() => { if (project?.id) { dispatch( - fetchProjectTransitionSettings({ projectId: project.id, environment }), + fetchProjectTransitionSettings({ + projectId: project.id, + environment, + apiHeaders: runtimeApiHeaders, + }), ); } - }, [dispatch, project?.id, environment]); + }, [dispatch, project?.id, environment, runtimeApiHeaders]); + + useEffect(() => { + if ( + environment !== 'production' || + (errorStatus !== 401 && errorStatus !== 403) + ) { + return; + } + + if (errorStatus === 403) { + dispatch(logoutUser()); + } + + const next = `/p/${projectSlug}`; + router.replace(`/login?next=${encodeURIComponent(next)}`); + }, [dispatch, environment, errorStatus, projectSlug, router]); // Select project transition settings from store (environment-aware) const projectTransitionSettingsEntity = useAppSelector((state) => diff --git a/frontend/src/components/SelectField.tsx b/frontend/src/components/SelectField.tsx index f753199..bf9cc41 100644 --- a/frontend/src/components/SelectField.tsx +++ b/frontend/src/components/SelectField.tsx @@ -9,6 +9,7 @@ export const SelectField = ({ itemRef, showField, disabled, + onOptionChange, }) => { const [value, setValue] = useState(null); const PAGE_SIZE = 100; @@ -29,6 +30,7 @@ export const SelectField = ({ const handleChange = (option) => { form.setFieldValue(field.name, option?.value || null); setValue(option); + onOptionChange?.(option || null); }; async function callApi( diff --git a/frontend/src/components/SelectFieldMany.tsx b/frontend/src/components/SelectFieldMany.tsx index 979aebe..4c46d00 100644 --- a/frontend/src/components/SelectFieldMany.tsx +++ b/frontend/src/components/SelectFieldMany.tsx @@ -2,6 +2,17 @@ import React, { useEffect, useId, useState } from 'react'; import { AsyncPaginate } from 'react-select-async-paginate'; import axios from 'axios'; +const areStringArraysEqual = (left = [], right = []) => + left.length === right.length && left.every((value, index) => value === right[index]); + +const areSelectOptionsEqual = (left = [], right = []) => + left.length === right.length && + left.every( + (option, index) => + option?.value === right[index]?.value && + option?.label === right[index]?.label, + ); + export const SelectFieldMany = ({ options, field, @@ -14,24 +25,40 @@ export const SelectFieldMany = ({ useEffect(() => { if (field.value?.[0] && typeof field.value[0] !== 'string') { - form.setFieldValue( - field.name, - field.value.map((el) => el.id), - ); + const normalizedValue = field.value.map((el) => el.id); + const isAlreadyNormalized = + Array.isArray(field.value) && + areStringArraysEqual(field.value, normalizedValue); + + if (!isAlreadyNormalized) { + form.setFieldValue(field.name, normalizedValue, false); + } } else if (!field.value || field.value.length === 0) { - setValue([]); + setValue((currentValue) => + currentValue.length === 0 ? currentValue : [], + ); } }, [field.name, field.value, form]); useEffect(() => { - if (options) { - setValue(options.map((el) => ({ value: el.id, label: el[showField] }))); - form.setFieldValue( - field.name, - options.map((el) => ({ value: el.id, label: el[showField] })), + if (Array.isArray(options) && options.length > 0) { + const selectedOptions = options.map((el) => ({ + value: el.id, + label: el[showField], + })); + const selectedIds = options.map((el) => el.id); + + setValue((currentValue) => + areSelectOptionsEqual(currentValue, selectedOptions) + ? currentValue + : selectedOptions, ); + + if (!areStringArraysEqual(field.value, selectedIds)) { + form.setFieldValue(field.name, selectedIds, false); + } } - }, [options]); + }, [field.name, field.value, form, options, showField]); const mapResponseToValuesAndLabels = (data) => ({ value: data.id, diff --git a/frontend/src/css/main.css b/frontend/src/css/main.css index 0df2ecb..dc84351 100644 --- a/frontend/src/css/main.css +++ b/frontend/src/css/main.css @@ -10,7 +10,6 @@ @import '_calendar.css'; @import '_select-dropdown.css'; @import '_theme.css'; -@import '_rich-text.css'; /* ═══════════════════════════════════════════════════════════════════ Custom Font Declarations diff --git a/frontend/src/hooks/usePageDataLoader.ts b/frontend/src/hooks/usePageDataLoader.ts index c9f1bc9..9860633 100644 --- a/frontend/src/hooks/usePageDataLoader.ts +++ b/frontend/src/hooks/usePageDataLoader.ts @@ -47,6 +47,8 @@ export interface UsePageDataLoaderResult { isLoading: boolean; /** Error message if loading failed */ error: string; + /** HTTP status associated with the error, when available */ + errorStatus: number | null; /** Reload the data (optionally preserving current page selection) */ reload: (preservePageId?: string) => Promise; /** Initially selected page ID */ @@ -91,6 +93,7 @@ export function usePageDataLoader({ const [pages, setPages] = useState([]); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(''); + const [errorStatus, setErrorStatus] = useState(null); const [selectedInitialPageId, setSelectedInitialPageId] = useState(''); // Memoize API config to prevent unnecessary reloads @@ -122,6 +125,7 @@ export function usePageDataLoader({ try { setIsLoading(true); setError(''); + setErrorStatus(null); let foundProject: RuntimeProject | null = null; @@ -204,14 +208,23 @@ export function usePageDataLoader({ response?: { status?: number; data?: { message?: string } }; message?: string; }; + const status = axiosError?.response?.status ?? null; + + setErrorStatus(status); // Handle authentication errors - if (axiosError?.response?.status === 401) { - setError('Your session has expired. Please sign in again.'); + if (status === 401) { + setError('Authentication is required to view this presentation.'); logger.error('Unauthorized request during data load'); return; } + if (status === 403) { + setError('You do not have access to this presentation.'); + logger.error('Forbidden request during data load'); + return; + } + const message = axiosError?.response?.data?.message || axiosError?.message || @@ -247,6 +260,7 @@ export function usePageDataLoader({ pages, isLoading, error, + errorStatus, reload: loadData, initialPageId: selectedInitialPageId, }; diff --git a/frontend/src/layouts/Authenticated.tsx b/frontend/src/layouts/Authenticated.tsx index 629e61d..df33692 100644 --- a/frontend/src/layouts/Authenticated.tsx +++ b/frontend/src/layouts/Authenticated.tsx @@ -146,6 +146,24 @@ export default function LayoutAuthenticated({ if (!hasPermission(currentUser, permission)) router.push('/error'); }, [currentUser, permission]); + useEffect(() => { + if (!isAuthChecked || minimal || !currentUser) return; + + const allowedPrivateSlugs = currentUser.allowedPrivateProductionSlugs || []; + const rolePermissions = currentUser.app_role?.permissions || []; + const customPermissions = currentUser.custom_permissions || []; + const isPresentationOnlyUser = + currentUser.app_role?.name === 'Public' && + rolePermissions.length === 0 && + customPermissions.length === 0; + + if (isPresentationOnlyUser) { + router.replace( + allowedPrivateSlugs[0] ? `/p/${allowedPrivateSlugs[0]}` : '/', + ); + } + }, [currentUser, isAuthChecked, minimal, router]); + const isConstructorFullscreen = router.pathname === '/constructor'; useEffect(() => { diff --git a/frontend/src/pages/_app.tsx b/frontend/src/pages/_app.tsx index 2cf9c64..945f758 100644 --- a/frontend/src/pages/_app.tsx +++ b/frontend/src/pages/_app.tsx @@ -94,7 +94,8 @@ axios.interceptors.response.use( // Redirect to login if not already there if (!window.location.pathname.includes('/login')) { - window.location.href = '/login'; + const next = `${window.location.pathname}${window.location.search}`; + window.location.href = `/login?next=${encodeURIComponent(next)}`; } } } @@ -123,11 +124,36 @@ function MyApp({ Component, pageProps }: AppPropsWithLayout) { // Register service worker for PWA offline support React.useEffect(() => { - if ( - typeof window !== 'undefined' && - 'serviceWorker' in navigator && - process.env.NODE_ENV === 'production' - ) { + if (typeof window === 'undefined' || !('serviceWorker' in navigator)) { + return; + } + + if (process.env.NODE_ENV !== 'production') { + navigator.serviceWorker.getRegistrations().then((registrations) => { + registrations.forEach((registration) => { + registration.unregister(); + }); + }); + + if ('caches' in window) { + caches.keys().then((cacheNames) => { + cacheNames + .filter( + (cacheName) => + cacheName.includes('serwist') || + cacheName.includes('tour-builder') || + cacheName === 'api-cache', + ) + .forEach((cacheName) => { + caches.delete(cacheName); + }); + }); + } + + return; + } + + if (process.env.NODE_ENV === 'production') { navigator.serviceWorker .register('/sw.js') .then((registration) => { diff --git a/frontend/src/pages/dashboard.tsx b/frontend/src/pages/dashboard.tsx index bd89852..0cc69cb 100644 --- a/frontend/src/pages/dashboard.tsx +++ b/frontend/src/pages/dashboard.tsx @@ -8,6 +8,7 @@ import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton import BaseIcon from '../components/BaseIcon'; import { getPageTitle } from '../config'; import Link from 'next/link'; +import { useRouter } from 'next/router'; import { hasPermission } from '../helpers/userPermissions'; import { fetchWidgets } from '../stores/roles/rolesSlice'; @@ -75,6 +76,7 @@ const DashboardCard = ({ const Dashboard = () => { const dispatch = useAppDispatch(); + const router = useRouter(); const iconsColor = useAppSelector((state) => state.style.iconsColor); const corners = useAppSelector((state) => state.style.corners); const cardsStyle = useAppSelector((state) => state.style.cardsStyle); @@ -119,6 +121,13 @@ const Dashboard = () => { // Get entities visible to current user const visibleEntities = getVisibleEntities(); + React.useEffect(() => { + const allowedPrivateSlugs = currentUser?.allowedPrivateProductionSlugs || []; + if (visibleEntities.length > 0 || allowedPrivateSlugs.length === 0) return; + + router.replace(`/p/${allowedPrivateSlugs[0]}`); + }, [currentUser?.allowedPrivateProductionSlugs, router, visibleEntities]); + return ( <> diff --git a/frontend/src/pages/index.tsx b/frontend/src/pages/index.tsx index a734439..8a5f2fe 100644 --- a/frontend/src/pages/index.tsx +++ b/frontend/src/pages/index.tsx @@ -1,64 +1,27 @@ import React from 'react'; import type { ReactElement } from 'react'; import Head from 'next/head'; -import BaseButton from '../components/BaseButton'; -import CardBox from '../components/CardBox'; -import SectionFullScreen from '../components/SectionFullScreen'; +import { useRouter } from 'next/router'; import LayoutGuest from '../layouts/Guest'; -import BaseButtons from '../components/BaseButtons'; import { getPageTitle } from '../config'; -import CardBoxComponentTitle from '../components/CardBoxComponentTitle'; -export default function Starter() { - const title = 'Shimahara Visual'; +export default function HomeRedirect() { + const router = useRouter(); + + React.useEffect(() => { + const token = + sessionStorage.getItem('token') || localStorage.getItem('token'); + + router.replace(token ? '/projects/projects-list' : '/login'); + }, [router]); return ( -
- - {getPageTitle('Welcome')} - - - -
- - -
-

- The SaaS platform to design, manage, and publish offline-ready - interactive tours for physical venues. -

-
- - - - -
-
-
-
-

© 2026 {title}. All rights reserved

-
-
+ + {getPageTitle('Redirecting')} + ); } -Starter.getLayout = function getLayout(page: ReactElement) { +HomeRedirect.getLayout = function getLayout(page: ReactElement) { return {page}; }; diff --git a/frontend/src/pages/login.tsx b/frontend/src/pages/login.tsx index 0dd5256..7f2c122 100644 --- a/frontend/src/pages/login.tsx +++ b/frontend/src/pages/login.tsx @@ -3,7 +3,7 @@ import type { ReactElement } from 'react'; import Head from 'next/head'; import CardBox from '../components/CardBox'; import BaseIcon from '../components/BaseIcon'; -import { mdiInformation, mdiEye, mdiEyeOff } from '@mdi/js'; +import { mdiEye, mdiEyeOff } from '@mdi/js'; import SectionFullScreen from '../components/SectionFullScreen'; import LayoutGuest from '../layouts/Guest'; import { Field, Form, Formik } from 'formik'; @@ -23,7 +23,6 @@ export default function Login() { const router = useRouter(); const dispatch = useAppDispatch(); const textColor = useAppSelector((state) => state.style.linkColor); - const iconsColor = useAppSelector((state) => state.style.iconsColor); const notify = (type, msg) => toast(msg, { type }); const [showPassword, setShowPassword] = useState(false); const { @@ -33,9 +32,9 @@ export default function Login() { token, notify: notifyState, } = useAppSelector((state) => state.auth); - const [initialValues, setInitialValues] = React.useState({ - email: 'admin@flatlogic.com', - password: '88dbeaf8', + const [initialValues] = React.useState({ + email: '', + password: '', remember: true, }); @@ -51,9 +50,15 @@ export default function Login() { // Redirect to dashboard if user is logged in useEffect(() => { if (currentUser?.id) { - router.push('/dashboard'); + const next = router.query.next; + const safeNextPath = + typeof next === 'string' && next.startsWith('/') && !next.startsWith('//') + ? next + : '/dashboard'; + + router.push(safeNextPath); } - }, [currentUser?.id, router]); + }, [currentUser?.id, router, router.query.next]); // Show error message if there is one useEffect(() => { @@ -79,14 +84,6 @@ export default function Login() { await dispatch(loginUser(rest)); }; - const setLogin = (target: HTMLElement) => { - setInitialValues((prev) => ({ - ...prev, - email: target.innerText.trim(), - password: target.dataset.password ?? '', - })); - }; - return (
@@ -96,50 +93,8 @@ export default function Login() {
- +

{title}

- -
-
-

- Use{' '} - setLogin(e.currentTarget)} - > - admin@flatlogic.com - - {' / '} - 88dbeaf8 - {' / '} - to login as Admin -

-

- Use{' '} - setLogin(e.currentTarget)} - > - client@hello.com - - {' / '} - c3baadeda5c6 - {' / '} - to login as User -

-
-
- -
-
@@ -199,13 +154,6 @@ export default function Login() { disabled={isFetching} /> -
-

- Don't have an account yet?{' '} - - New Account - -

diff --git a/frontend/src/pages/profile.tsx b/frontend/src/pages/profile.tsx index 41cafa3..d7d0389 100644 --- a/frontend/src/pages/profile.tsx +++ b/frontend/src/pages/profile.tsx @@ -29,9 +29,7 @@ import { useRouter } from 'next/router'; import { findMe } from '../stores/authSlice'; const EditUsers = () => { - const { currentUser, isFetching, token } = useAppSelector( - (state) => state.auth, - ); + const { currentUser } = useAppSelector((state) => state.auth); const router = useRouter(); const dispatch = useAppDispatch(); const notify = (type, msg) => toast(msg, { type }); @@ -46,6 +44,9 @@ const EditUsers = () => { password: '', }; const [initialValues, setInitialValues] = useState(initVals); + const avatarUrl = Array.isArray(currentUser?.avatar) + ? currentUser.avatar[0]?.publicUrl + : null; useEffect(() => { if (currentUser?.id && typeof currentUser === 'object') { @@ -80,12 +81,12 @@ const EditUsers = () => { {''} - {currentUser?.avatar[0]?.publicUrl && ( + {avatarUrl && (
Avatar { og_image_url: String(projectData.og_image_url || ''), design_width: width, design_height: height, + production_presentation_visibility: + projectData.production_presentation_visibility === 'private' + ? 'private' + : 'public', is_deleted: Boolean(projectData.is_deleted), deleted_at_time: projectData.deleted_at_time ? new Date(projectData.deleted_at_time as string) @@ -160,6 +165,8 @@ const EditProjectsPage = () => { og_image_url: data.og_image_url, design_width: data.design_width, design_height: data.design_height, + production_presentation_visibility: + data.production_presentation_visibility, }; try { @@ -350,6 +357,20 @@ const EditProjectsPage = () => { )} + + + + + + + diff --git a/frontend/src/pages/projects/projects-new.tsx b/frontend/src/pages/projects/projects-new.tsx index 5f88174..0a5e411 100644 --- a/frontend/src/pages/projects/projects-new.tsx +++ b/frontend/src/pages/projects/projects-new.tsx @@ -32,6 +32,7 @@ const initialValues = { logo_url: '', favicon_url: '', og_image_url: '', + production_presentation_visibility: 'public', is_deleted: false, deleted_at_time: '', }; @@ -94,6 +95,20 @@ const ProjectsNew = () => { + + + + + + + toast(msg, { type, position: 'bottom-center' }); - - const handleSubmit = async (value) => { - setLoading(true); - try { - const { data: response } = await axios.post('/auth/signup', value); - await router.push('/login'); - setLoading(false); - notify('success', 'Please check your email for verification link'); - } catch (error) { - setLoading(false); - logger.error( - 'Signup failed:', - error instanceof Error ? error : { error }, - ); - notify('error', 'Something was wrong. Try again'); - } - }; - - return ( - <> - - {getPageTitle('Login')} - - - - - handleSubmit(values)} - > -
- - - - - - - - - - - - - - - - - -
-
-
- - - ); -} - -Register.getLayout = function getLayout(page: ReactElement) { - return {page}; -}; diff --git a/frontend/src/pages/users/users-new.tsx b/frontend/src/pages/users/users-new.tsx index e392093..1dd1148 100644 --- a/frontend/src/pages/users/users-new.tsx +++ b/frontend/src/pages/users/users-new.tsx @@ -37,14 +37,21 @@ const initialValues = { avatar: [] as string[], app_role: '', custom_permissions: [] as string[], + allowed_private_production_project_ids: [] as string[], }; const UsersNew = () => { const router = useRouter(); const dispatch = useAppDispatch(); + const [selectedRoleLabel, setSelectedRoleLabel] = React.useState(''); const handleSubmit = async (data: typeof initialValues) => { - await dispatch(create(data as unknown as Partial)); + const payload = { ...data }; + if (selectedRoleLabel !== 'Public') { + payload.allowed_private_production_project_ids = []; + } + + await dispatch(create(payload as unknown as Partial)); await router.push('/users/users-list'); }; @@ -66,6 +73,7 @@ const UsersNew = () => { initialValues={initialValues} onSubmit={(values) => handleSubmit(values)} > + {({ setFieldValue }) => (
@@ -110,9 +118,34 @@ const UsersNew = () => { component={SelectField} options={[]} itemRef='roles' + onOptionChange={(option) => { + const label = option?.label || ''; + setSelectedRoleLabel(label); + if (label !== 'Public') { + setFieldValue( + 'allowed_private_production_project_ids', + [], + ); + } + }} /> + {selectedRoleLabel === 'Public' && ( + + + + )} + { /> + )} diff --git a/frontend/src/schemas/projectSchema.ts b/frontend/src/schemas/projectSchema.ts index c9f79b3..30c58e8 100644 --- a/frontend/src/schemas/projectSchema.ts +++ b/frontend/src/schemas/projectSchema.ts @@ -20,6 +20,9 @@ export const projectSchema = z.object({ .max(5000, 'Description too long') .optional() .or(z.literal('')), + production_presentation_visibility: z + .enum(['public', 'private']) + .default('public'), }); export type ProjectFormData = z.infer; @@ -28,4 +31,5 @@ export const projectInitialValues: ProjectFormData = { name: '', slug: '', description: '', + production_presentation_visibility: 'public', }; diff --git a/frontend/src/schemas/userSchema.ts b/frontend/src/schemas/userSchema.ts index 0edcb98..4dc497f 100644 --- a/frontend/src/schemas/userSchema.ts +++ b/frontend/src/schemas/userSchema.ts @@ -31,6 +31,7 @@ export const userCreateSchema = z.object({ avatar: z.array(z.unknown()).optional(), app_role: z.unknown().optional().nullable(), custom_permissions: z.array(z.unknown()).optional(), + allowed_private_production_project_ids: z.array(z.unknown()).optional(), }); // User update schema (password optional) @@ -60,6 +61,7 @@ export const userUpdateSchema = z.object({ avatar: z.array(z.unknown()).optional(), app_role: z.unknown().optional().nullable(), custom_permissions: z.array(z.unknown()).optional(), + allowed_private_production_project_ids: z.array(z.unknown()).optional(), }); // Infer TypeScript types from schemas @@ -77,4 +79,5 @@ export const userInitialValues: UserUpdateFormData = { avatar: [], app_role: null, custom_permissions: [], + allowed_private_production_project_ids: [], }; diff --git a/frontend/src/stores/createEntitySlice.ts b/frontend/src/stores/createEntitySlice.ts index 4fafca5..6bfd83e 100644 --- a/frontend/src/stores/createEntitySlice.ts +++ b/frontend/src/stores/createEntitySlice.ts @@ -251,6 +251,7 @@ export function createEntitySlice( }); builder.addCase(deleteItemsByIds.fulfilled, (state) => { state.loading = false; + state.refetch = true; fulfilledNotify(state, `${pluralDisplayName} has been deleted`); }); builder.addCase(deleteItemsByIds.rejected, (state, action) => { @@ -265,6 +266,7 @@ export function createEntitySlice( }); builder.addCase(deleteItem.fulfilled, (state) => { state.loading = false; + state.refetch = true; fulfilledNotify(state, `${displayName} has been deleted`); }); builder.addCase(deleteItem.rejected, (state, action) => { @@ -279,6 +281,7 @@ export function createEntitySlice( }); builder.addCase(create.fulfilled, (state) => { state.loading = false; + state.refetch = true; fulfilledNotify(state, `${displayName} has been created`); }); builder.addCase(create.rejected, (state, action) => { @@ -293,6 +296,7 @@ export function createEntitySlice( }); builder.addCase(update.fulfilled, (state) => { state.loading = false; + state.refetch = true; fulfilledNotify(state, `${displayName} has been updated`); }); builder.addCase(update.rejected, (state, action) => { @@ -307,6 +311,7 @@ export function createEntitySlice( }); builder.addCase(uploadCsv.fulfilled, (state) => { state.loading = false; + state.refetch = true; fulfilledNotify(state, `${pluralDisplayName} has been uploaded`); }); builder.addCase(uploadCsv.rejected, (state, action) => { diff --git a/frontend/src/stores/introSteps.ts b/frontend/src/stores/introSteps.ts index 0a13d28..e650213 100644 --- a/frontend/src/stores/introSteps.ts +++ b/frontend/src/stores/introSteps.ts @@ -12,12 +12,6 @@ export const loginSteps: IntroStep[] = [ position: 'auto', tooltipClass: ' good-img', }, - { - element: '#loginRoles', - intro: - 'Choose your login role to proceed. Experience the app as Admin, or User, or create your own account to get started.', - position: 'auto', - }, ]; export const appSteps: IntroStep[] = [ diff --git a/frontend/src/stores/project_transition_settings/projectTransitionSettingsSlice.ts b/frontend/src/stores/project_transition_settings/projectTransitionSettingsSlice.ts index a66d096..11708f5 100644 --- a/frontend/src/stores/project_transition_settings/projectTransitionSettingsSlice.ts +++ b/frontend/src/stores/project_transition_settings/projectTransitionSettingsSlice.ts @@ -58,15 +58,20 @@ function buildKey(projectId: string, environment: string): string { */ export const fetchByProjectAndEnv = createAsyncThunk< { key: string; data: ProjectTransitionSettingsEntity | null }, - { projectId: string; environment: 'dev' | 'stage' | 'production' }, + { + projectId: string; + environment: 'dev' | 'stage' | 'production'; + apiHeaders?: Record; + }, { rejectValue: ApiError } >( 'project_transition_settings/fetchByProjectAndEnv', - async ({ projectId, environment }, { rejectWithValue }) => { + async ({ projectId, environment, apiHeaders }, { rejectWithValue }) => { const key = buildKey(projectId, environment); try { const result = await axios.get( `project-transition-settings/project/${projectId}/env/${environment}`, + { headers: apiHeaders || {} }, ); // API returns null if no settings exist (use global defaults) return { key, data: result.data }; diff --git a/frontend/src/types/entities.ts b/frontend/src/types/entities.ts index 43a5620..a451b8e 100644 --- a/frontend/src/types/entities.ts +++ b/frontend/src/types/entities.ts @@ -24,6 +24,8 @@ export interface User extends BaseEntity { avatar?: ImageFile[]; app_role?: Role | null; custom_permissions?: PermissionEntity[]; + allowedPrivateProductionSlugs?: string[]; + allowed_private_production_project_ids?: string[]; password?: string; } @@ -48,6 +50,7 @@ export interface Project extends BaseEntity { og_image_url?: string; design_width?: number; design_height?: number; + production_presentation_visibility?: 'public' | 'private'; // Note: transition_settings is now stored in project_transition_settings table // with environment awareness (dev, stage, production) is_deleted?: boolean;