added ability to make production presentations private
This commit is contained in:
parent
b43c1cd5b4
commit
222d08fbca
@ -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 |
|
||||
|
||||
@ -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];
|
||||
|
||||
@ -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 = {};
|
||||
|
||||
|
||||
@ -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 = {};
|
||||
|
||||
|
||||
@ -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 = {};
|
||||
|
||||
|
||||
@ -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 = [];
|
||||
|
||||
@ -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 = {};
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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";',
|
||||
);
|
||||
},
|
||||
};
|
||||
@ -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'
|
||||
)
|
||||
`);
|
||||
},
|
||||
};
|
||||
60
backend/src/db/models/production_presentation_access.js
Normal file
60
backend/src/db/models/production_presentation_access.js
Normal 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;
|
||||
};
|
||||
@ -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: {
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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);
|
||||
}),
|
||||
);
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -7,6 +7,7 @@ const PUBLIC_RUNTIME_ENTITY_FIELDS = {
|
||||
'logo_url',
|
||||
'favicon_url',
|
||||
'og_image_url',
|
||||
'production_presentation_visibility',
|
||||
],
|
||||
tour_pages: [
|
||||
'id',
|
||||
|
||||
@ -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 }),
|
||||
|
||||
@ -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);
|
||||
};
|
||||
|
||||
59
backend/src/routes/runtime-access.js
Normal file
59
backend/src/routes/runtime-access.js
Normal 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;
|
||||
@ -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);
|
||||
}),
|
||||
);
|
||||
|
||||
@ -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 });
|
||||
|
||||
|
||||
121
backend/src/services/runtime-presentation-access.js
Normal file
121
backend/src/services/runtime-presentation-access.js
Normal 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;
|
||||
@ -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();
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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>
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -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) =>
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -10,7 +10,6 @@
|
||||
@import '_calendar.css';
|
||||
@import '_select-dropdown.css';
|
||||
@import '_theme.css';
|
||||
@import '_rich-text.css';
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════════
|
||||
Custom Font Declarations
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
@ -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(() => {
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>;
|
||||
};
|
||||
|
||||
@ -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't have an account yet?{' '}
|
||||
<Link className={`${textColor}`} href={'/register'}>
|
||||
New Account
|
||||
</Link>
|
||||
</p>
|
||||
</Form>
|
||||
</Formik>
|
||||
</CardBox>
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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'>
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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>;
|
||||
};
|
||||
@ -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>
|
||||
|
||||
@ -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',
|
||||
};
|
||||
|
||||
@ -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: [],
|
||||
};
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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[] = [
|
||||
|
||||
@ -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 };
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user