added ability to make production presentations private

This commit is contained in:
Dmitri 2026-06-26 11:49:59 +02:00
parent b43c1cd5b4
commit 222d08fbca
47 changed files with 967 additions and 425 deletions

View File

@ -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 |

View File

@ -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];

View File

@ -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 = {};

View File

@ -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 = {};

View File

@ -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 = {};

View File

@ -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 = [];

View File

@ -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 = {};

View File

@ -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);

View File

@ -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";',
);
},
};

View File

@ -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'
)
`);
},
};

View File

@ -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;
};

View File

@ -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: {

View File

@ -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: {

View File

@ -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);
}),
);

View File

@ -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);

View File

@ -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,

View File

@ -7,6 +7,7 @@ const PUBLIC_RUNTIME_ENTITY_FIELDS = {
'logo_url',
'favicon_url',
'og_image_url',
'production_presentation_visibility',
],
tour_pages: [
'id',

View File

@ -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 }),

View File

@ -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);
};

View File

@ -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;

View File

@ -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);
}),
);

View File

@ -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 });

View File

@ -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;

View File

@ -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();

View File

@ -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);

View File

@ -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) => [
<div key={params?.row?.id}>
renderCell: (params: GridRenderCellParams) => (
<div>
<ListActionsPopover
onDelete={onDelete}
itemId={params?.row?.id}
@ -235,8 +235,8 @@ function buildActionsColumn(
pathView={`/${entityPath}/${entityPath}-view/?id=${params?.row?.id}`}
hasUpdatePermission={hasUpdatePermission}
/>
</div>,
],
</div>
),
};
}

View File

@ -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) =>

View File

@ -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(

View File

@ -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,

View File

@ -10,7 +10,6 @@
@import '_calendar.css';
@import '_select-dropdown.css';
@import '_theme.css';
@import '_rich-text.css';
/*
Custom Font Declarations

View File

@ -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<void>;
/** Initially selected page ID */
@ -91,6 +93,7 @@ export function usePageDataLoader({
const [pages, setPages] = useState<RuntimePage[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState('');
const [errorStatus, setErrorStatus] = useState<number | null>(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,
};

View File

@ -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(() => {

View File

@ -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) => {

View File

@ -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 (
<>
<Head>

View File

@ -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 (
<div
style={{
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
minHeight: '100vh',
display: 'flex',
flexDirection: 'column',
}}
>
<Head>
<title>{getPageTitle('Welcome')}</title>
</Head>
<SectionFullScreen bg='violet'>
<div className='flex items-center justify-center flex-col space-y-4 w-full'>
<CardBox className='w-full md:w-3/5 lg:w-2/3'>
<CardBoxComponentTitle title='Welcome to Shimahara Visual!' />
<div className='space-y-3'>
<p className='text-center text-gray-500'>
The SaaS platform to design, manage, and publish offline-ready
interactive tours for physical venues.
</p>
</div>
<BaseButtons className='justify-center mt-6'>
<BaseButton
href='/login'
label='Sign In'
color='info'
className='w-40'
/>
<BaseButton
href='/projects/projects-list'
label='Admin Console'
color='success'
className='w-40'
/>
</BaseButtons>
</CardBox>
</div>
</SectionFullScreen>
<div className='bg-black text-white flex flex-col text-center justify-center'>
<p className='py-6 text-sm'>© 2026 {title}. All rights reserved</p>
</div>
</div>
<Head>
<title>{getPageTitle('Redirecting')}</title>
</Head>
);
}
Starter.getLayout = function getLayout(page: ReactElement) {
HomeRedirect.getLayout = function getLayout(page: ReactElement) {
return <LayoutGuest>{page}</LayoutGuest>;
};

View File

@ -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 (
<div>
<Head>
@ -96,50 +93,8 @@ export default function Login() {
<SectionFullScreen bg='violet'>
<div className='flex min-h-screen w-full'>
<div className='flex items-center justify-center flex-col space-y-4 w-full lg:w-full'>
<CardBox id='loginRoles' className='w-full md:w-3/5 lg:w-2/3'>
<CardBox className='w-full md:w-3/5 lg:w-2/3'>
<h2 className='text-4xl font-semibold my-4'>{title}</h2>
<div className='flex flex-row text-gray-500 justify-between'>
<div>
<p className='mb-2'>
Use{' '}
<code
className={`cursor-pointer ${textColor} `}
data-password='88dbeaf8'
onClick={(e) => setLogin(e.currentTarget)}
>
admin@flatlogic.com
</code>
{' / '}
<code className={`${textColor}`}>88dbeaf8</code>
{' / '}
to login as Admin
</p>
<p>
Use{' '}
<code
className={`cursor-pointer ${textColor} `}
data-password='c3baadeda5c6'
onClick={(e) => setLogin(e.currentTarget)}
>
client@hello.com
</code>
{' / '}
<code className={`${textColor}`}>c3baadeda5c6</code>
{' / '}
to login as User
</p>
</div>
<div>
<BaseIcon
className={`${iconsColor}`}
w='w-16'
h='h-16'
size={48}
path={mdiInformation}
/>
</div>
</div>
</CardBox>
<CardBox className='w-full md:w-3/5 lg:w-2/3'>
@ -199,13 +154,6 @@ export default function Login() {
disabled={isFetching}
/>
</BaseButtons>
<br />
<p className={'text-center'}>
Don&apos;t have an account yet?{' '}
<Link className={`${textColor}`} href={'/register'}>
New Account
</Link>
</p>
</Form>
</Formik>
</CardBox>

View File

@ -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 = () => {
{''}
</SectionTitleLineWithButton>
<CardBox>
{currentUser?.avatar[0]?.publicUrl && (
{avatarUrl && (
<div className={'grid grid-cols-6 gap-4 mb-4'}>
<div className='col-span-1 w-80 h-80 overflow-hidden border-2 rounded-full inline-flex items-center justify-center mb-8'>
<Image
className='w-80 h-80 max-w-full max-h-full object-cover object-center'
src={`${currentUser?.avatar[0]?.publicUrl}`}
src={avatarUrl}
alt='Avatar'
width={320}
height={320}

View File

@ -38,6 +38,7 @@ const initVals = {
og_image_url: '',
design_width: CANVAS_CONFIG.defaults.width as number,
design_height: CANVAS_CONFIG.defaults.height as number,
production_presentation_visibility: 'public' as 'public' | 'private',
is_deleted: false,
deleted_at_time: new Date(),
};
@ -142,6 +143,10 @@ const EditProjectsPage = () => {
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 = () => {
</a>
)}
<FormField
label='Production Presentation Visibility'
labelFor='production_presentation_visibility'
>
<Field
name='production_presentation_visibility'
id='production_presentation_visibility'
as='select'
>
<option value='public'>Public</option>
<option value='private'>Private</option>
</Field>
</FormField>
<BaseDivider />
<FormField label='Design Canvas Preset'>

View File

@ -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 = () => {
<Field name='og_image_url' placeholder='OG Image URL' />
</FormField>
<FormField
label='Production Presentation Visibility'
labelFor='production_presentation_visibility'
>
<Field
name='production_presentation_visibility'
id='production_presentation_visibility'
as='select'
>
<option value='public'>Public</option>
<option value='private'>Private</option>
</Field>
</FormField>
<FormField label='Is Deleted' labelFor='is_deleted'>
<Field
name='is_deleted'

View File

@ -1,92 +0,0 @@
import React from 'react';
import type { ReactElement } from 'react';
import { ToastContainer, toast } from 'react-toastify';
import Head from 'next/head';
import BaseButton from '../components/BaseButton';
import CardBox from '../components/CardBox';
import SectionFullScreen from '../components/SectionFullScreen';
import LayoutGuest from '../layouts/Guest';
import { Field, Form, Formik } from 'formik';
import FormField from '../components/FormField';
import BaseDivider from '../components/BaseDivider';
import BaseButtons from '../components/BaseButtons';
import { useRouter } from 'next/router';
import { getPageTitle } from '../config';
import axios from 'axios';
import { logger } from '../lib/logger';
export default function Register() {
const [loading, setLoading] = React.useState(false);
const router = useRouter();
const notify = (type, msg) => 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 (
<>
<Head>
<title>{getPageTitle('Login')}</title>
</Head>
<SectionFullScreen bg='violet'>
<CardBox className='w-11/12 md:w-7/12 lg:w-6/12 xl:w-4/12'>
<Formik
initialValues={{
email: '',
password: '',
confirm: '',
}}
onSubmit={(values) => handleSubmit(values)}
>
<Form>
<FormField label='Email' help='Please enter your email'>
<Field type='email' name='email' />
</FormField>
<FormField label='Password' help='Please enter your password'>
<Field type='password' name='password' />
</FormField>
<FormField
label='Confirm Password'
help='Please confirm your password'
>
<Field type='password' name='confirm' />
</FormField>
<BaseDivider />
<BaseButtons>
<BaseButton
type='submit'
label={loading ? 'Loading...' : 'Register'}
color='info'
/>
<BaseButton href={'/login'} label={'Login'} color='info' />
</BaseButtons>
</Form>
</Formik>
</CardBox>
</SectionFullScreen>
<ToastContainer />
</>
);
}
Register.getLayout = function getLayout(page: ReactElement) {
return <LayoutGuest>{page}</LayoutGuest>;
};

View File

@ -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<User>));
const payload = { ...data };
if (selectedRoleLabel !== 'Public') {
payload.allowed_private_production_project_ids = [];
}
await dispatch(create(payload as unknown as Partial<User>));
await router.push('/users/users-list');
};
@ -66,6 +73,7 @@ const UsersNew = () => {
initialValues={initialValues}
onSubmit={(values) => handleSubmit(values)}
>
{({ setFieldValue }) => (
<Form>
<FormField label='First Name'>
<Field name='firstName' placeholder='First Name' />
@ -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',
[],
);
}
}}
/>
</FormField>
{selectedRoleLabel === 'Public' && (
<FormField
label='Allowed Private Production Presentations'
labelFor='allowed_private_production_project_ids'
>
<Field
name='allowed_private_production_project_ids'
id='allowed_private_production_project_ids'
itemRef='runtime-access/private-production-presentations'
options={[]}
component={SelectFieldMany}
/>
</FormField>
)}
<FormField
label='Custom Permissions'
labelFor='custom_permissions'
@ -139,6 +172,7 @@ const UsersNew = () => {
/>
</BaseButtons>
</Form>
)}
</Formik>
</CardBox>
</SectionMain>

View File

@ -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<typeof projectSchema>;
@ -28,4 +31,5 @@ export const projectInitialValues: ProjectFormData = {
name: '',
slug: '',
description: '',
production_presentation_visibility: 'public',
};

View File

@ -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: [],
};

View File

@ -251,6 +251,7 @@ export function createEntitySlice<T extends BaseEntity>(
});
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<T extends BaseEntity>(
});
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<T extends BaseEntity>(
});
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<T extends BaseEntity>(
});
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<T extends BaseEntity>(
});
builder.addCase(uploadCsv.fulfilled, (state) => {
state.loading = false;
state.refetch = true;
fulfilledNotify(state, `${pluralDisplayName} has been uploaded`);
});
builder.addCase(uploadCsv.rejected, (state, action) => {

View File

@ -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[] = [

View File

@ -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<string, string>;
},
{ 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<ProjectTransitionSettingsEntity | null>(
`project-transition-settings/project/${projectId}/env/${environment}`,
{ headers: apiHeaders || {} },
);
// API returns null if no settings exist (use global defaults)
return { key, data: result.data };

View File

@ -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;