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**
|
Frontend runs on **http://localhost:3000**
|
||||||
|
|
||||||
### Default Login
|
### Login
|
||||||
|
|
||||||
After seeding, login with credentials configured in `backend/.env`:
|
The login page does not display or prefill seeded credentials in any environment.
|
||||||
- Email: `ADMIN_EMAIL` (default: admin@flatlogic.com)
|
After seeding, use the credentials configured in backend environment/config values.
|
||||||
- Password: `ADMIN_PASS` (default: 88dbeaf8)
|
|
||||||
|
|
||||||
## Project Structure
|
## Project Structure
|
||||||
|
|
||||||
@ -161,7 +160,6 @@ Base URL: `http://localhost:8080/api`
|
|||||||
| Endpoint | Description |
|
| Endpoint | Description |
|
||||||
|----------|-------------|
|
|----------|-------------|
|
||||||
| `POST /auth/signin/local` | Login |
|
| `POST /auth/signin/local` | Login |
|
||||||
| `POST /auth/signup` | Register |
|
|
||||||
| `GET /auth/me` | Current user |
|
| `GET /auth/me` | Current user |
|
||||||
| `GET /projects` | List projects |
|
| `GET /projects` | List projects |
|
||||||
| `POST /publish/save-to-stage` | Copy dev → stage |
|
| `POST /publish/save-to-stage` | Copy dev → stage |
|
||||||
|
|||||||
@ -306,8 +306,8 @@ class GenericDBApi {
|
|||||||
static async findAll(filter = {}, options = {}) {
|
static async findAll(filter = {}, options = {}) {
|
||||||
filter = filter || {};
|
filter = filter || {};
|
||||||
const limit = filter.limit || 0;
|
const limit = filter.limit || 0;
|
||||||
const currentPage = +filter.page || 0;
|
const currentPage = Number(filter.page) || 0;
|
||||||
const offset = currentPage * limit;
|
const offset = Math.max(currentPage - 1, 0) * limit;
|
||||||
|
|
||||||
let where = {};
|
let where = {};
|
||||||
let include = [...this.FIND_ALL_INCLUDES];
|
let include = [...this.FIND_ALL_INCLUDES];
|
||||||
|
|||||||
@ -88,8 +88,8 @@ class Project_audio_tracksDBApi extends GenericDBApi {
|
|||||||
static async findAll(filter = {}, options = {}) {
|
static async findAll(filter = {}, options = {}) {
|
||||||
filter = filter || {};
|
filter = filter || {};
|
||||||
const limit = filter.limit || 0;
|
const limit = filter.limit || 0;
|
||||||
const currentPage = +filter.page || 0;
|
const currentPage = Number(filter.page) || 0;
|
||||||
const offset = currentPage * limit;
|
const offset = Math.max(currentPage - 1, 0) * limit;
|
||||||
|
|
||||||
let where = {};
|
let where = {};
|
||||||
|
|
||||||
|
|||||||
@ -104,8 +104,8 @@ class Project_element_defaultsDBApi extends GenericDBApi {
|
|||||||
static async findAll(filter = {}, options = {}) {
|
static async findAll(filter = {}, options = {}) {
|
||||||
filter = filter || {};
|
filter = filter || {};
|
||||||
const limit = filter.limit || 0;
|
const limit = filter.limit || 0;
|
||||||
const currentPage = +filter.page || 0;
|
const currentPage = Number(filter.page) || 0;
|
||||||
const offset = currentPage * limit;
|
const offset = Math.max(currentPage - 1, 0) * limit;
|
||||||
|
|
||||||
let where = {};
|
let where = {};
|
||||||
|
|
||||||
|
|||||||
@ -166,8 +166,8 @@ class Project_transition_settingsDBApi extends GenericDBApi {
|
|||||||
static async findAll(filter = {}, options = {}) {
|
static async findAll(filter = {}, options = {}) {
|
||||||
filter = filter || {};
|
filter = filter || {};
|
||||||
const limit = filter.limit || 0;
|
const limit = filter.limit || 0;
|
||||||
const currentPage = +filter.page || 0;
|
const currentPage = Number(filter.page) || 0;
|
||||||
const offset = currentPage * limit;
|
const offset = Math.max(currentPage - 1, 0) * limit;
|
||||||
|
|
||||||
let where = {};
|
let where = {};
|
||||||
|
|
||||||
|
|||||||
@ -23,6 +23,7 @@ class ProjectsDBApi extends GenericDBApi {
|
|||||||
'logo_url',
|
'logo_url',
|
||||||
'favicon_url',
|
'favicon_url',
|
||||||
'og_image_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,
|
'og_image_url' in data ? data.og_image_url || null : undefined,
|
||||||
design_width: 'design_width' in data ? data.design_width : undefined,
|
design_width: 'design_width' in data ? data.design_width : undefined,
|
||||||
design_height: 'design_height' in data ? data.design_height : 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() {
|
static get ALL_INCLUDES() {
|
||||||
return [
|
return [
|
||||||
{ association: 'project_memberships_project' },
|
{ association: 'project_memberships_project' },
|
||||||
|
{ association: 'production_presentation_access_project' },
|
||||||
{ association: 'assets_project' },
|
{ association: 'assets_project' },
|
||||||
{ association: 'presigned_url_requests_project' },
|
{ association: 'presigned_url_requests_project' },
|
||||||
{ association: 'tour_pages_project' },
|
{ association: 'tour_pages_project' },
|
||||||
@ -128,8 +134,8 @@ class ProjectsDBApi extends GenericDBApi {
|
|||||||
static async findAll(filter = {}, options = {}) {
|
static async findAll(filter = {}, options = {}) {
|
||||||
filter = filter || {};
|
filter = filter || {};
|
||||||
const limit = filter.limit || 0;
|
const limit = filter.limit || 0;
|
||||||
const currentPage = +filter.page || 0;
|
const currentPage = Number(filter.page) || 0;
|
||||||
const offset = currentPage * limit;
|
const offset = Math.max(currentPage - 1, 0) * limit;
|
||||||
|
|
||||||
let where = {};
|
let where = {};
|
||||||
let include = [];
|
let include = [];
|
||||||
|
|||||||
@ -165,8 +165,8 @@ class Tour_pagesDBApi extends GenericDBApi {
|
|||||||
static async findAll(filter = {}, options = {}) {
|
static async findAll(filter = {}, options = {}) {
|
||||||
filter = filter || {};
|
filter = filter || {};
|
||||||
const limit = filter.limit || 0;
|
const limit = filter.limit || 0;
|
||||||
const currentPage = +filter.page || 0;
|
const currentPage = Number(filter.page) || 0;
|
||||||
const offset = currentPage * limit;
|
const offset = Math.max(currentPage - 1, 0) * limit;
|
||||||
|
|
||||||
let where = {};
|
let where = {};
|
||||||
|
|
||||||
|
|||||||
@ -54,6 +54,7 @@ module.exports = class UsersDBApi {
|
|||||||
static async create(data, options) {
|
static async create(data, options) {
|
||||||
const currentUser = (options && options.currentUser) || { id: null };
|
const currentUser = (options && options.currentUser) || { id: null };
|
||||||
const transaction = (options && options.transaction) || undefined;
|
const transaction = (options && options.transaction) || undefined;
|
||||||
|
const password = data.data.password || crypto.randomBytes(20).toString('hex');
|
||||||
|
|
||||||
const users = await db.users.create(
|
const users = await db.users.create(
|
||||||
{
|
{
|
||||||
@ -65,7 +66,7 @@ module.exports = class UsersDBApi {
|
|||||||
email: data.data.email || null,
|
email: data.data.email || null,
|
||||||
disabled: data.data.disabled || false,
|
disabled: data.data.disabled || false,
|
||||||
|
|
||||||
password: data.data.password || null,
|
password: bcrypt.hashSync(password, config.bcrypt.saltRounds),
|
||||||
emailVerified: data.data.emailVerified || true,
|
emailVerified: data.data.emailVerified || true,
|
||||||
|
|
||||||
emailVerificationToken: data.data.emailVerificationToken || null,
|
emailVerificationToken: data.data.emailVerificationToken || null,
|
||||||
@ -74,7 +75,7 @@ module.exports = class UsersDBApi {
|
|||||||
passwordResetToken: data.data.passwordResetToken || null,
|
passwordResetToken: data.data.passwordResetToken || null,
|
||||||
passwordResetTokenExpiresAt:
|
passwordResetTokenExpiresAt:
|
||||||
data.data.passwordResetTokenExpiresAt || null,
|
data.data.passwordResetTokenExpiresAt || null,
|
||||||
provider: data.data.provider || null,
|
provider: data.data.provider || config.providers.LOCAL,
|
||||||
importHash: data.data.importHash || null,
|
importHash: data.data.importHash || null,
|
||||||
createdById: currentUser.id,
|
createdById: currentUser.id,
|
||||||
updatedById: currentUser.id,
|
updatedById: currentUser.id,
|
||||||
@ -128,7 +129,10 @@ module.exports = class UsersDBApi {
|
|||||||
email: item.email || null,
|
email: item.email || null,
|
||||||
disabled: item.disabled || false,
|
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,
|
emailVerified: item.emailVerified || false,
|
||||||
|
|
||||||
emailVerificationToken: item.emailVerificationToken || null,
|
emailVerificationToken: item.emailVerificationToken || null,
|
||||||
@ -136,7 +140,7 @@ module.exports = class UsersDBApi {
|
|||||||
item.emailVerificationTokenExpiresAt || null,
|
item.emailVerificationTokenExpiresAt || null,
|
||||||
passwordResetToken: item.passwordResetToken || null,
|
passwordResetToken: item.passwordResetToken || null,
|
||||||
passwordResetTokenExpiresAt: item.passwordResetTokenExpiresAt || null,
|
passwordResetTokenExpiresAt: item.passwordResetTokenExpiresAt || null,
|
||||||
provider: item.provider || null,
|
provider: item.provider || config.providers.LOCAL,
|
||||||
importHash: item.importHash || null,
|
importHash: item.importHash || null,
|
||||||
createdById: currentUser.id,
|
createdById: currentUser.id,
|
||||||
updatedById: currentUser.id,
|
updatedById: currentUser.id,
|
||||||
@ -352,6 +356,7 @@ module.exports = class UsersDBApi {
|
|||||||
association: 'app_role',
|
association: 'app_role',
|
||||||
include: [{ association: 'permissions' }],
|
include: [{ association: 'permissions' }],
|
||||||
},
|
},
|
||||||
|
{ association: 'custom_permissions' },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -373,9 +378,9 @@ module.exports = class UsersDBApi {
|
|||||||
const limit = filter.limit || 0;
|
const limit = filter.limit || 0;
|
||||||
let offset = 0;
|
let offset = 0;
|
||||||
let where = {};
|
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 appRoleTerms = filter.app_role ? filter.app_role.split('|') : [];
|
||||||
const appRoleValidUuids = Utils.filterValidUuids(appRoleTerms);
|
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,
|
defaultValue: 1080,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
production_presentation_visibility: {
|
||||||
|
type: DataTypes.ENUM('public', 'private'),
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 'public',
|
||||||
|
},
|
||||||
|
|
||||||
// Note: transition_settings moved to project_transition_settings table
|
// Note: transition_settings moved to project_transition_settings table
|
||||||
// for environment-aware storage (dev, stage, production)
|
// for environment-aware storage (dev, stage, production)
|
||||||
|
|
||||||
@ -95,6 +101,16 @@ module.exports = function (sequelize, DataTypes) {
|
|||||||
onUpdate: 'CASCADE',
|
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, {
|
db.projects.hasMany(db.assets, {
|
||||||
as: 'assets_project',
|
as: 'assets_project',
|
||||||
foreignKey: {
|
foreignKey: {
|
||||||
|
|||||||
@ -127,6 +127,16 @@ module.exports = function (sequelize, DataTypes) {
|
|||||||
onUpdate: 'CASCADE',
|
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, {
|
db.users.hasMany(db.presigned_url_requests, {
|
||||||
as: 'presigned_url_requests_user',
|
as: 'presigned_url_requests_user',
|
||||||
foreignKey: {
|
foreignKey: {
|
||||||
|
|||||||
@ -20,7 +20,7 @@ function createEntityRouter(entityName, Service, DBApi, options = {}) {
|
|||||||
req.body.data,
|
req.body.data,
|
||||||
req.currentUser,
|
req.currentUser,
|
||||||
true,
|
true,
|
||||||
link.host,
|
link.origin,
|
||||||
);
|
);
|
||||||
res.status(200).send(payload);
|
res.status(200).send(payload);
|
||||||
}),
|
}),
|
||||||
@ -33,7 +33,7 @@ function createEntityRouter(entityName, Service, DBApi, options = {}) {
|
|||||||
req.headers.referer ||
|
req.headers.referer ||
|
||||||
`${req.protocol}://${req.hostname}${req.originalUrl}`;
|
`${req.protocol}://${req.hostname}${req.originalUrl}`;
|
||||||
const link = new URL(referer);
|
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);
|
res.status(200).send(true);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
@ -56,11 +56,13 @@ const project_transition_settingsRoutes = require('./routes/project_transition_s
|
|||||||
|
|
||||||
const publishRoutes = require('./routes/publish');
|
const publishRoutes = require('./routes/publish');
|
||||||
const runtimeContextRoutes = require('./routes/runtime-context');
|
const runtimeContextRoutes = require('./routes/runtime-context');
|
||||||
|
const runtimeAccessRoutes = require('./routes/runtime-access');
|
||||||
const { runtimeContextMiddleware } = require('./middlewares/runtime-context');
|
const { runtimeContextMiddleware } = require('./middlewares/runtime-context');
|
||||||
const {
|
const {
|
||||||
blockNonPublicRuntimeListEndpoints,
|
blockNonPublicRuntimeListEndpoints,
|
||||||
sanitizePublicRuntimeListResponse,
|
sanitizePublicRuntimeListResponse,
|
||||||
} = require('./middlewares/runtime-public');
|
} = require('./middlewares/runtime-public');
|
||||||
|
const RuntimePresentationAccessService = require('./services/runtime-presentation-access');
|
||||||
|
|
||||||
const getBaseUrl = (url) => {
|
const getBaseUrl = (url) => {
|
||||||
if (!url) return '';
|
if (!url) return '';
|
||||||
@ -147,21 +149,73 @@ app.use(bodyParser.json({ limit: '50mb' }));
|
|||||||
app.use(bodyParser.urlencoded({ extended: true, limit: '50mb' }));
|
app.use(bodyParser.urlencoded({ extended: true, limit: '50mb' }));
|
||||||
app.use(runtimeContextMiddleware);
|
app.use(runtimeContextMiddleware);
|
||||||
|
|
||||||
const requireRuntimeReadOrAuth = (req, res, next) => {
|
const requireRuntimeReadOrAuth = async (req, res, next) => {
|
||||||
const headerEnvironment = req.runtimeContext?.headerEnvironment;
|
try {
|
||||||
const isReadOnlyRequest = ['GET', 'OPTIONS'].includes(req.method);
|
const headerEnvironment = req.runtimeContext?.headerEnvironment;
|
||||||
const hasAuthHeader = Boolean(req.headers.authorization);
|
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).
|
// Only production is public. Stage requires authentication (workspace for review).
|
||||||
const isPublicEnvironment = headerEnvironment === 'production';
|
const isPublicEnvironment = headerEnvironment === 'production';
|
||||||
|
|
||||||
if (isPublicEnvironment && isReadOnlyRequest && !hasAuthHeader) {
|
if (!isPublicEnvironment || !isReadOnlyRequest) {
|
||||||
req.isRuntimePublicRequest = true;
|
req.isRuntimePublicRequest = false;
|
||||||
return next();
|
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)
|
// 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/auth', authRoutes);
|
||||||
app.use('/api/runtime-context', runtimeContextRoutes);
|
app.use('/api/runtime-context', runtimeContextRoutes);
|
||||||
|
app.use('/api/runtime-access', runtimeAccessRoutes);
|
||||||
|
|
||||||
app.use('/api/users', jwtAuth, usersRoutes);
|
app.use('/api/users', jwtAuth, usersRoutes);
|
||||||
|
|
||||||
|
|||||||
@ -175,17 +175,6 @@ const authLimiter = createRateLimiter({
|
|||||||
skipFailedRequests: false, // Count failed attempts
|
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
|
* Password reset limiter - Prevent password reset abuse
|
||||||
* 5 requests per hour per IP
|
* 5 requests per hour per IP
|
||||||
@ -259,7 +248,6 @@ module.exports = {
|
|||||||
createRateLimiter,
|
createRateLimiter,
|
||||||
createAuthenticatedRateLimiter,
|
createAuthenticatedRateLimiter,
|
||||||
authLimiter,
|
authLimiter,
|
||||||
signupLimiter,
|
|
||||||
passwordResetLimiter,
|
passwordResetLimiter,
|
||||||
apiLimiter,
|
apiLimiter,
|
||||||
uploadLimiter,
|
uploadLimiter,
|
||||||
|
|||||||
@ -7,6 +7,7 @@ const PUBLIC_RUNTIME_ENTITY_FIELDS = {
|
|||||||
'logo_url',
|
'logo_url',
|
||||||
'favicon_url',
|
'favicon_url',
|
||||||
'og_image_url',
|
'og_image_url',
|
||||||
|
'production_presentation_visibility',
|
||||||
],
|
],
|
||||||
tour_pages: [
|
tour_pages: [
|
||||||
'id',
|
'id',
|
||||||
|
|||||||
@ -5,10 +5,10 @@ const config = require('../config');
|
|||||||
const AuthService = require('../services/auth');
|
const AuthService = require('../services/auth');
|
||||||
const ForbiddenError = require('../services/notifications/errors/forbidden');
|
const ForbiddenError = require('../services/notifications/errors/forbidden');
|
||||||
const EmailSender = require('../services/email');
|
const EmailSender = require('../services/email');
|
||||||
|
const RuntimePresentationAccessService = require('../services/runtime-presentation-access');
|
||||||
const wrapAsync = require('../helpers').wrapAsync;
|
const wrapAsync = require('../helpers').wrapAsync;
|
||||||
const {
|
const {
|
||||||
authLimiter: signinLimiter,
|
authLimiter: signinLimiter,
|
||||||
signupLimiter,
|
|
||||||
passwordResetLimiter,
|
passwordResetLimiter,
|
||||||
} = require('../middlewares/rateLimiter');
|
} = require('../middlewares/rateLimiter');
|
||||||
|
|
||||||
@ -138,15 +138,21 @@ router.post(
|
|||||||
router.get(
|
router.get(
|
||||||
'/me',
|
'/me',
|
||||||
passport.authenticate('jwt', { session: false }),
|
passport.authenticate('jwt', { session: false }),
|
||||||
(req, res) => {
|
wrapAsync(async (req, res) => {
|
||||||
if (!req.currentUser || !req.currentUser.id) {
|
if (!req.currentUser || !req.currentUser.id) {
|
||||||
throw new ForbiddenError();
|
throw new ForbiddenError();
|
||||||
}
|
}
|
||||||
|
|
||||||
const payload = req.currentUser;
|
const payload = {
|
||||||
|
...req.currentUser,
|
||||||
|
allowedPrivateProductionSlugs:
|
||||||
|
await RuntimePresentationAccessService.getAllowedPrivateProductionSlugs(
|
||||||
|
req.currentUser,
|
||||||
|
),
|
||||||
|
};
|
||||||
delete payload.password;
|
delete payload.password;
|
||||||
res.status(200).send(payload);
|
res.status(200).send(payload);
|
||||||
},
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
router.put(
|
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(
|
router.put(
|
||||||
'/profile',
|
'/profile',
|
||||||
passport.authenticate('jwt', { session: false }),
|
passport.authenticate('jwt', { session: false }),
|
||||||
|
|||||||
@ -1,9 +1,11 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const passport = require('passport');
|
const passport = require('passport');
|
||||||
|
const db = require('../db/models');
|
||||||
const Project_transition_settingsService = require('../services/project_transition_settings');
|
const Project_transition_settingsService = require('../services/project_transition_settings');
|
||||||
const Project_transition_settingsDBApi = require('../db/api/project_transition_settings');
|
const Project_transition_settingsDBApi = require('../db/api/project_transition_settings');
|
||||||
const { wrapAsync, commonErrorHandler, isUuidV4 } = require('../helpers');
|
const { wrapAsync, commonErrorHandler, isUuidV4 } = require('../helpers');
|
||||||
const { checkCrudPermissions } = require('../middlewares/check-permissions');
|
const { checkCrudPermissions } = require('../middlewares/check-permissions');
|
||||||
|
const RuntimePresentationAccessService = require('../services/runtime-presentation-access');
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const jwtAuth = passport.authenticate('jwt', { session: false });
|
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.
|
* Middleware: Production GET is public, everything else requires JWT.
|
||||||
* Determines public access from URL path, not headers.
|
* 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 { environment } = req.params;
|
||||||
const isProduction = environment === 'production';
|
const isProduction = environment === 'production';
|
||||||
const isReadOnly = ['GET', 'OPTIONS'].includes(req.method);
|
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
|
// Public access for production GET
|
||||||
return next();
|
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
|
// Require JWT for non-production or write operations
|
||||||
return jwtAuth(req, res, next);
|
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.body.data,
|
||||||
req.currentUser,
|
req.currentUser,
|
||||||
true,
|
true,
|
||||||
link.host,
|
link.origin,
|
||||||
);
|
);
|
||||||
res.status(200).send(payload);
|
res.status(200).send(payload);
|
||||||
}),
|
}),
|
||||||
@ -182,7 +182,7 @@ router.post(
|
|||||||
req.headers.referer ||
|
req.headers.referer ||
|
||||||
`${req.protocol}://${req.hostname}${req.originalUrl}`;
|
`${req.protocol}://${req.hostname}${req.originalUrl}`;
|
||||||
const link = new URL(referer);
|
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);
|
res.status(200).send(true);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
@ -11,62 +11,6 @@ const config = require('../config');
|
|||||||
const helpers = require('../helpers');
|
const helpers = require('../helpers');
|
||||||
|
|
||||||
class Auth {
|
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) {
|
static async signin(email, password) {
|
||||||
const user = await UsersDBApi.findBy({ email });
|
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 ValidationError = require('./notifications/errors/validation');
|
||||||
const config = require('../config');
|
const config = require('../config');
|
||||||
const AuthService = require('./auth');
|
const AuthService = require('./auth');
|
||||||
|
const { logger } = require('../utils/logger');
|
||||||
|
|
||||||
// Generate base service from factory
|
// Generate base service from factory
|
||||||
const BaseUsersService = createEntityService(UsersDBApi, {
|
const BaseUsersService = createEntityService(UsersDBApi, {
|
||||||
@ -15,6 +16,66 @@ const BaseUsersService = createEntityService(UsersDBApi, {
|
|||||||
* Extends factory-generated service with custom user logic
|
* Extends factory-generated service with custom user logic
|
||||||
*/
|
*/
|
||||||
class UsersService extends BaseUsersService {
|
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
|
* Create user with email validation and optional invitation
|
||||||
*/
|
*/
|
||||||
@ -27,17 +88,46 @@ class UsersService extends BaseUsersService {
|
|||||||
throw new ValidationError('iam.errors.emailRequired');
|
throw new ValidationError('iam.errors.emailRequired');
|
||||||
}
|
}
|
||||||
|
|
||||||
const existingUser = await UsersDBApi.findBy({ email }, { transaction });
|
const existingUser = await db.users.findOne({
|
||||||
if (existingUser) {
|
where: { email },
|
||||||
|
paranoid: false,
|
||||||
|
transaction,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingUser && !existingUser.deletedAt) {
|
||||||
throw new ValidationError('iam.errors.userAlreadyExists');
|
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();
|
await transaction.commit();
|
||||||
|
|
||||||
// Send invitation email after successful commit
|
// Send invitation email after successful commit
|
||||||
if (sendInvitationEmails) {
|
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) {
|
} catch (error) {
|
||||||
await transaction.rollback();
|
await transaction.rollback();
|
||||||
|
|||||||
@ -1,20 +1,26 @@
|
|||||||
/**
|
/**
|
||||||
* @type {import('next').NextConfig}
|
* @type {import('next').NextConfig}
|
||||||
*/
|
*/
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
import withSerwistInit from '@serwist/next';
|
import withSerwistInit from '@serwist/next';
|
||||||
|
|
||||||
const output = process.env.NEXT_OUTPUT || undefined;
|
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({
|
const withSerwist = withSerwistInit({
|
||||||
swSrc: 'src/sw.ts',
|
swSrc: 'src/sw.ts',
|
||||||
swDest: 'public/sw.js',
|
swDest: 'public/sw.js',
|
||||||
disable: process.env.NODE_ENV === 'development',
|
disable: isDevelopment,
|
||||||
|
maximumFileSizeToCacheInBytes: 5 * 1024 * 1024,
|
||||||
});
|
});
|
||||||
|
|
||||||
const nextConfig = {
|
const nextConfig = {
|
||||||
trailingSlash: true,
|
trailingSlash: true,
|
||||||
distDir: 'build',
|
distDir: isDevelopment ? '.next' : 'build',
|
||||||
|
outputFileTracingRoot: __dirname,
|
||||||
output,
|
output,
|
||||||
basePath: '',
|
basePath: '',
|
||||||
devIndicators: {
|
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 axios from 'axios';
|
||||||
import {
|
import {
|
||||||
GridColDef,
|
GridColDef,
|
||||||
GridRowParams,
|
|
||||||
GridRenderCellParams,
|
GridRenderCellParams,
|
||||||
GridSingleSelectColDef,
|
GridSingleSelectColDef,
|
||||||
} from '@mui/x-data-grid';
|
} from '@mui/x-data-grid';
|
||||||
@ -75,21 +74,21 @@ async function fetchRelationOptions(
|
|||||||
|
|
||||||
function getFormatter(
|
function getFormatter(
|
||||||
col: ColumnMetadata,
|
col: ColumnMetadata,
|
||||||
): ((params: { value: unknown }) => string) | undefined {
|
): ((value: unknown) => string) | undefined {
|
||||||
if (col.valueFormatter) {
|
if (col.valueFormatter) {
|
||||||
const customFormatter = col.valueFormatter;
|
const customFormatter = col.valueFormatter;
|
||||||
return ({ value }) => customFormatter(value);
|
return (value) => customFormatter(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
switch (col.type) {
|
switch (col.type) {
|
||||||
case 'boolean':
|
case 'boolean':
|
||||||
return ({ value }) => dataFormatter.booleanFormatter(value);
|
return (value) => dataFormatter.booleanFormatter(value);
|
||||||
case 'date':
|
case 'date':
|
||||||
return ({ value }) => dataFormatter.dateFormatter(value);
|
return (value) => dataFormatter.dateFormatter(value);
|
||||||
case 'datetime':
|
case 'datetime':
|
||||||
return ({ value }) => dataFormatter.dateTimeFormatter(value);
|
return (value) => dataFormatter.dateTimeFormatter(value);
|
||||||
case 'relation':
|
case 'relation':
|
||||||
return ({ value }) => {
|
return (value) => {
|
||||||
const formatter =
|
const formatter =
|
||||||
dataFormatter[
|
dataFormatter[
|
||||||
`${col.entityRef}OneListFormatter` as keyof typeof dataFormatter
|
`${col.entityRef}OneListFormatter` as keyof typeof dataFormatter
|
||||||
@ -99,7 +98,7 @@ function getFormatter(
|
|||||||
: String(value || '');
|
: String(value || '');
|
||||||
};
|
};
|
||||||
case 'relationMany':
|
case 'relationMany':
|
||||||
return ({ value }) => {
|
return (value) => {
|
||||||
const formatter =
|
const formatter =
|
||||||
dataFormatter[
|
dataFormatter[
|
||||||
`${col.entityRef}ManyListFormatter` as keyof typeof dataFormatter
|
`${col.entityRef}ManyListFormatter` as keyof typeof dataFormatter
|
||||||
@ -222,12 +221,13 @@ function buildActionsColumn(
|
|||||||
): GridColDef {
|
): GridColDef {
|
||||||
return {
|
return {
|
||||||
field: 'actions',
|
field: 'actions',
|
||||||
type: 'actions' as const,
|
|
||||||
minWidth: 30,
|
minWidth: 30,
|
||||||
|
sortable: false,
|
||||||
|
filterable: false,
|
||||||
headerClassName: 'datagrid--header',
|
headerClassName: 'datagrid--header',
|
||||||
cellClassName: 'datagrid--cell',
|
cellClassName: 'datagrid--cell',
|
||||||
getActions: (params: GridRowParams) => [
|
renderCell: (params: GridRenderCellParams) => (
|
||||||
<div key={params?.row?.id}>
|
<div>
|
||||||
<ListActionsPopover
|
<ListActionsPopover
|
||||||
onDelete={onDelete}
|
onDelete={onDelete}
|
||||||
itemId={params?.row?.id}
|
itemId={params?.row?.id}
|
||||||
@ -235,8 +235,8 @@ function buildActionsColumn(
|
|||||||
pathView={`/${entityPath}/${entityPath}-view/?id=${params?.row?.id}`}
|
pathView={`/${entityPath}/${entityPath}-view/?id=${params?.row?.id}`}
|
||||||
hasUpdatePermission={hasUpdatePermission}
|
hasUpdatePermission={hasUpdatePermission}
|
||||||
/>
|
/>
|
||||||
</div>,
|
</div>
|
||||||
],
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -6,6 +6,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import Head from 'next/head';
|
import Head from 'next/head';
|
||||||
|
import { useRouter } from 'next/router';
|
||||||
import React, {
|
import React, {
|
||||||
ReactElement,
|
ReactElement,
|
||||||
useCallback,
|
useCallback,
|
||||||
@ -58,6 +59,7 @@ import {
|
|||||||
} from '../lib/navigationHelpers';
|
} from '../lib/navigationHelpers';
|
||||||
import { useTransitionSettings } from '../hooks/useTransitionSettings';
|
import { useTransitionSettings } from '../hooks/useTransitionSettings';
|
||||||
import { useAppSelector, useAppDispatch } from '../stores/hooks';
|
import { useAppSelector, useAppDispatch } from '../stores/hooks';
|
||||||
|
import { logoutUser } from '../stores/authSlice';
|
||||||
import { fetch as fetchGlobalTransitionDefaults } from '../stores/global_transition_defaults/globalTransitionDefaultsSlice';
|
import { fetch as fetchGlobalTransitionDefaults } from '../stores/global_transition_defaults/globalTransitionDefaultsSlice';
|
||||||
import {
|
import {
|
||||||
fetchByProjectAndEnv as fetchProjectTransitionSettings,
|
fetchByProjectAndEnv as fetchProjectTransitionSettings,
|
||||||
@ -89,22 +91,27 @@ export default function RuntimePresentation({
|
|||||||
environment,
|
environment,
|
||||||
}: RuntimePresentationProps) {
|
}: RuntimePresentationProps) {
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
|
const router = useRouter();
|
||||||
const globalTransitionDefaults = useAppSelector(
|
const globalTransitionDefaults = useAppSelector(
|
||||||
(state) => state.global_transition_defaults.data,
|
(state) => state.global_transition_defaults.data,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Use shared hook for loading project and pages data
|
// Use shared hook for loading project and pages data
|
||||||
// Note: We can't fetch project transition settings until we have the project ID
|
// 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,
|
projectSlug,
|
||||||
environment,
|
environment,
|
||||||
apiHeaders: {
|
apiHeaders: runtimeApiHeaders,
|
||||||
'X-Runtime-Project-Slug': projectSlug,
|
});
|
||||||
'X-Runtime-Environment': environment,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// Fetch global transition defaults on mount (public endpoint, no auth needed)
|
// Fetch global transition defaults on mount (public endpoint, no auth needed)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -115,10 +122,30 @@ export default function RuntimePresentation({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (project?.id) {
|
if (project?.id) {
|
||||||
dispatch(
|
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)
|
// Select project transition settings from store (environment-aware)
|
||||||
const projectTransitionSettingsEntity = useAppSelector((state) =>
|
const projectTransitionSettingsEntity = useAppSelector((state) =>
|
||||||
|
|||||||
@ -9,6 +9,7 @@ export const SelectField = ({
|
|||||||
itemRef,
|
itemRef,
|
||||||
showField,
|
showField,
|
||||||
disabled,
|
disabled,
|
||||||
|
onOptionChange,
|
||||||
}) => {
|
}) => {
|
||||||
const [value, setValue] = useState(null);
|
const [value, setValue] = useState(null);
|
||||||
const PAGE_SIZE = 100;
|
const PAGE_SIZE = 100;
|
||||||
@ -29,6 +30,7 @@ export const SelectField = ({
|
|||||||
const handleChange = (option) => {
|
const handleChange = (option) => {
|
||||||
form.setFieldValue(field.name, option?.value || null);
|
form.setFieldValue(field.name, option?.value || null);
|
||||||
setValue(option);
|
setValue(option);
|
||||||
|
onOptionChange?.(option || null);
|
||||||
};
|
};
|
||||||
|
|
||||||
async function callApi(
|
async function callApi(
|
||||||
|
|||||||
@ -2,6 +2,17 @@ import React, { useEffect, useId, useState } from 'react';
|
|||||||
import { AsyncPaginate } from 'react-select-async-paginate';
|
import { AsyncPaginate } from 'react-select-async-paginate';
|
||||||
import axios from 'axios';
|
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 = ({
|
export const SelectFieldMany = ({
|
||||||
options,
|
options,
|
||||||
field,
|
field,
|
||||||
@ -14,24 +25,40 @@ export const SelectFieldMany = ({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (field.value?.[0] && typeof field.value[0] !== 'string') {
|
if (field.value?.[0] && typeof field.value[0] !== 'string') {
|
||||||
form.setFieldValue(
|
const normalizedValue = field.value.map((el) => el.id);
|
||||||
field.name,
|
const isAlreadyNormalized =
|
||||||
field.value.map((el) => el.id),
|
Array.isArray(field.value) &&
|
||||||
);
|
areStringArraysEqual(field.value, normalizedValue);
|
||||||
|
|
||||||
|
if (!isAlreadyNormalized) {
|
||||||
|
form.setFieldValue(field.name, normalizedValue, false);
|
||||||
|
}
|
||||||
} else if (!field.value || field.value.length === 0) {
|
} else if (!field.value || field.value.length === 0) {
|
||||||
setValue([]);
|
setValue((currentValue) =>
|
||||||
|
currentValue.length === 0 ? currentValue : [],
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}, [field.name, field.value, form]);
|
}, [field.name, field.value, form]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (options) {
|
if (Array.isArray(options) && options.length > 0) {
|
||||||
setValue(options.map((el) => ({ value: el.id, label: el[showField] })));
|
const selectedOptions = options.map((el) => ({
|
||||||
form.setFieldValue(
|
value: el.id,
|
||||||
field.name,
|
label: el[showField],
|
||||||
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) => ({
|
const mapResponseToValuesAndLabels = (data) => ({
|
||||||
value: data.id,
|
value: data.id,
|
||||||
|
|||||||
@ -10,7 +10,6 @@
|
|||||||
@import '_calendar.css';
|
@import '_calendar.css';
|
||||||
@import '_select-dropdown.css';
|
@import '_select-dropdown.css';
|
||||||
@import '_theme.css';
|
@import '_theme.css';
|
||||||
@import '_rich-text.css';
|
|
||||||
|
|
||||||
/* ═══════════════════════════════════════════════════════════════════
|
/* ═══════════════════════════════════════════════════════════════════
|
||||||
Custom Font Declarations
|
Custom Font Declarations
|
||||||
|
|||||||
@ -47,6 +47,8 @@ export interface UsePageDataLoaderResult {
|
|||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
/** Error message if loading failed */
|
/** Error message if loading failed */
|
||||||
error: string;
|
error: string;
|
||||||
|
/** HTTP status associated with the error, when available */
|
||||||
|
errorStatus: number | null;
|
||||||
/** Reload the data (optionally preserving current page selection) */
|
/** Reload the data (optionally preserving current page selection) */
|
||||||
reload: (preservePageId?: string) => Promise<void>;
|
reload: (preservePageId?: string) => Promise<void>;
|
||||||
/** Initially selected page ID */
|
/** Initially selected page ID */
|
||||||
@ -91,6 +93,7 @@ export function usePageDataLoader({
|
|||||||
const [pages, setPages] = useState<RuntimePage[]>([]);
|
const [pages, setPages] = useState<RuntimePage[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
const [errorStatus, setErrorStatus] = useState<number | null>(null);
|
||||||
const [selectedInitialPageId, setSelectedInitialPageId] = useState('');
|
const [selectedInitialPageId, setSelectedInitialPageId] = useState('');
|
||||||
|
|
||||||
// Memoize API config to prevent unnecessary reloads
|
// Memoize API config to prevent unnecessary reloads
|
||||||
@ -122,6 +125,7 @@ export function usePageDataLoader({
|
|||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setError('');
|
setError('');
|
||||||
|
setErrorStatus(null);
|
||||||
|
|
||||||
let foundProject: RuntimeProject | null = null;
|
let foundProject: RuntimeProject | null = null;
|
||||||
|
|
||||||
@ -204,14 +208,23 @@ export function usePageDataLoader({
|
|||||||
response?: { status?: number; data?: { message?: string } };
|
response?: { status?: number; data?: { message?: string } };
|
||||||
message?: string;
|
message?: string;
|
||||||
};
|
};
|
||||||
|
const status = axiosError?.response?.status ?? null;
|
||||||
|
|
||||||
|
setErrorStatus(status);
|
||||||
|
|
||||||
// Handle authentication errors
|
// Handle authentication errors
|
||||||
if (axiosError?.response?.status === 401) {
|
if (status === 401) {
|
||||||
setError('Your session has expired. Please sign in again.');
|
setError('Authentication is required to view this presentation.');
|
||||||
logger.error('Unauthorized request during data load');
|
logger.error('Unauthorized request during data load');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (status === 403) {
|
||||||
|
setError('You do not have access to this presentation.');
|
||||||
|
logger.error('Forbidden request during data load');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const message =
|
const message =
|
||||||
axiosError?.response?.data?.message ||
|
axiosError?.response?.data?.message ||
|
||||||
axiosError?.message ||
|
axiosError?.message ||
|
||||||
@ -247,6 +260,7 @@ export function usePageDataLoader({
|
|||||||
pages,
|
pages,
|
||||||
isLoading,
|
isLoading,
|
||||||
error,
|
error,
|
||||||
|
errorStatus,
|
||||||
reload: loadData,
|
reload: loadData,
|
||||||
initialPageId: selectedInitialPageId,
|
initialPageId: selectedInitialPageId,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -146,6 +146,24 @@ export default function LayoutAuthenticated({
|
|||||||
if (!hasPermission(currentUser, permission)) router.push('/error');
|
if (!hasPermission(currentUser, permission)) router.push('/error');
|
||||||
}, [currentUser, permission]);
|
}, [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';
|
const isConstructorFullscreen = router.pathname === '/constructor';
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@ -94,7 +94,8 @@ axios.interceptors.response.use(
|
|||||||
|
|
||||||
// Redirect to login if not already there
|
// Redirect to login if not already there
|
||||||
if (!window.location.pathname.includes('/login')) {
|
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
|
// Register service worker for PWA offline support
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (
|
if (typeof window === 'undefined' || !('serviceWorker' in navigator)) {
|
||||||
typeof window !== 'undefined' &&
|
return;
|
||||||
'serviceWorker' in navigator &&
|
}
|
||||||
process.env.NODE_ENV === 'production'
|
|
||||||
) {
|
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
|
navigator.serviceWorker
|
||||||
.register('/sw.js')
|
.register('/sw.js')
|
||||||
.then((registration) => {
|
.then((registration) => {
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton
|
|||||||
import BaseIcon from '../components/BaseIcon';
|
import BaseIcon from '../components/BaseIcon';
|
||||||
import { getPageTitle } from '../config';
|
import { getPageTitle } from '../config';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
import { useRouter } from 'next/router';
|
||||||
|
|
||||||
import { hasPermission } from '../helpers/userPermissions';
|
import { hasPermission } from '../helpers/userPermissions';
|
||||||
import { fetchWidgets } from '../stores/roles/rolesSlice';
|
import { fetchWidgets } from '../stores/roles/rolesSlice';
|
||||||
@ -75,6 +76,7 @@ const DashboardCard = ({
|
|||||||
|
|
||||||
const Dashboard = () => {
|
const Dashboard = () => {
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
|
const router = useRouter();
|
||||||
const iconsColor = useAppSelector((state) => state.style.iconsColor);
|
const iconsColor = useAppSelector((state) => state.style.iconsColor);
|
||||||
const corners = useAppSelector((state) => state.style.corners);
|
const corners = useAppSelector((state) => state.style.corners);
|
||||||
const cardsStyle = useAppSelector((state) => state.style.cardsStyle);
|
const cardsStyle = useAppSelector((state) => state.style.cardsStyle);
|
||||||
@ -119,6 +121,13 @@ const Dashboard = () => {
|
|||||||
// Get entities visible to current user
|
// Get entities visible to current user
|
||||||
const visibleEntities = getVisibleEntities();
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<Head>
|
<Head>
|
||||||
|
|||||||
@ -1,64 +1,27 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import type { ReactElement } from 'react';
|
import type { ReactElement } from 'react';
|
||||||
import Head from 'next/head';
|
import Head from 'next/head';
|
||||||
import BaseButton from '../components/BaseButton';
|
import { useRouter } from 'next/router';
|
||||||
import CardBox from '../components/CardBox';
|
|
||||||
import SectionFullScreen from '../components/SectionFullScreen';
|
|
||||||
import LayoutGuest from '../layouts/Guest';
|
import LayoutGuest from '../layouts/Guest';
|
||||||
import BaseButtons from '../components/BaseButtons';
|
|
||||||
import { getPageTitle } from '../config';
|
import { getPageTitle } from '../config';
|
||||||
import CardBoxComponentTitle from '../components/CardBoxComponentTitle';
|
|
||||||
|
|
||||||
export default function Starter() {
|
export default function HomeRedirect() {
|
||||||
const title = 'Shimahara Visual';
|
const router = useRouter();
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const token =
|
||||||
|
sessionStorage.getItem('token') || localStorage.getItem('token');
|
||||||
|
|
||||||
|
router.replace(token ? '/projects/projects-list' : '/login');
|
||||||
|
}, [router]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<Head>
|
||||||
style={{
|
<title>{getPageTitle('Redirecting')}</title>
|
||||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
</Head>
|
||||||
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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Starter.getLayout = function getLayout(page: ReactElement) {
|
HomeRedirect.getLayout = function getLayout(page: ReactElement) {
|
||||||
return <LayoutGuest>{page}</LayoutGuest>;
|
return <LayoutGuest>{page}</LayoutGuest>;
|
||||||
};
|
};
|
||||||
|
|||||||
@ -3,7 +3,7 @@ import type { ReactElement } from 'react';
|
|||||||
import Head from 'next/head';
|
import Head from 'next/head';
|
||||||
import CardBox from '../components/CardBox';
|
import CardBox from '../components/CardBox';
|
||||||
import BaseIcon from '../components/BaseIcon';
|
import BaseIcon from '../components/BaseIcon';
|
||||||
import { mdiInformation, mdiEye, mdiEyeOff } from '@mdi/js';
|
import { mdiEye, mdiEyeOff } from '@mdi/js';
|
||||||
import SectionFullScreen from '../components/SectionFullScreen';
|
import SectionFullScreen from '../components/SectionFullScreen';
|
||||||
import LayoutGuest from '../layouts/Guest';
|
import LayoutGuest from '../layouts/Guest';
|
||||||
import { Field, Form, Formik } from 'formik';
|
import { Field, Form, Formik } from 'formik';
|
||||||
@ -23,7 +23,6 @@ export default function Login() {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const textColor = useAppSelector((state) => state.style.linkColor);
|
const textColor = useAppSelector((state) => state.style.linkColor);
|
||||||
const iconsColor = useAppSelector((state) => state.style.iconsColor);
|
|
||||||
const notify = (type, msg) => toast(msg, { type });
|
const notify = (type, msg) => toast(msg, { type });
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
const {
|
const {
|
||||||
@ -33,9 +32,9 @@ export default function Login() {
|
|||||||
token,
|
token,
|
||||||
notify: notifyState,
|
notify: notifyState,
|
||||||
} = useAppSelector((state) => state.auth);
|
} = useAppSelector((state) => state.auth);
|
||||||
const [initialValues, setInitialValues] = React.useState({
|
const [initialValues] = React.useState({
|
||||||
email: 'admin@flatlogic.com',
|
email: '',
|
||||||
password: '88dbeaf8',
|
password: '',
|
||||||
remember: true,
|
remember: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -51,9 +50,15 @@ export default function Login() {
|
|||||||
// Redirect to dashboard if user is logged in
|
// Redirect to dashboard if user is logged in
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (currentUser?.id) {
|
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
|
// Show error message if there is one
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -79,14 +84,6 @@ export default function Login() {
|
|||||||
await dispatch(loginUser(rest));
|
await dispatch(loginUser(rest));
|
||||||
};
|
};
|
||||||
|
|
||||||
const setLogin = (target: HTMLElement) => {
|
|
||||||
setInitialValues((prev) => ({
|
|
||||||
...prev,
|
|
||||||
email: target.innerText.trim(),
|
|
||||||
password: target.dataset.password ?? '',
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Head>
|
<Head>
|
||||||
@ -96,50 +93,8 @@ export default function Login() {
|
|||||||
<SectionFullScreen bg='violet'>
|
<SectionFullScreen bg='violet'>
|
||||||
<div className='flex min-h-screen w-full'>
|
<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'>
|
<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>
|
<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>
|
||||||
|
|
||||||
<CardBox className='w-full md:w-3/5 lg:w-2/3'>
|
<CardBox className='w-full md:w-3/5 lg:w-2/3'>
|
||||||
@ -199,13 +154,6 @@ export default function Login() {
|
|||||||
disabled={isFetching}
|
disabled={isFetching}
|
||||||
/>
|
/>
|
||||||
</BaseButtons>
|
</BaseButtons>
|
||||||
<br />
|
|
||||||
<p className={'text-center'}>
|
|
||||||
Don't have an account yet?{' '}
|
|
||||||
<Link className={`${textColor}`} href={'/register'}>
|
|
||||||
New Account
|
|
||||||
</Link>
|
|
||||||
</p>
|
|
||||||
</Form>
|
</Form>
|
||||||
</Formik>
|
</Formik>
|
||||||
</CardBox>
|
</CardBox>
|
||||||
|
|||||||
@ -29,9 +29,7 @@ import { useRouter } from 'next/router';
|
|||||||
import { findMe } from '../stores/authSlice';
|
import { findMe } from '../stores/authSlice';
|
||||||
|
|
||||||
const EditUsers = () => {
|
const EditUsers = () => {
|
||||||
const { currentUser, isFetching, token } = useAppSelector(
|
const { currentUser } = useAppSelector((state) => state.auth);
|
||||||
(state) => state.auth,
|
|
||||||
);
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const notify = (type, msg) => toast(msg, { type });
|
const notify = (type, msg) => toast(msg, { type });
|
||||||
@ -46,6 +44,9 @@ const EditUsers = () => {
|
|||||||
password: '',
|
password: '',
|
||||||
};
|
};
|
||||||
const [initialValues, setInitialValues] = useState(initVals);
|
const [initialValues, setInitialValues] = useState(initVals);
|
||||||
|
const avatarUrl = Array.isArray(currentUser?.avatar)
|
||||||
|
? currentUser.avatar[0]?.publicUrl
|
||||||
|
: null;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (currentUser?.id && typeof currentUser === 'object') {
|
if (currentUser?.id && typeof currentUser === 'object') {
|
||||||
@ -80,12 +81,12 @@ const EditUsers = () => {
|
|||||||
{''}
|
{''}
|
||||||
</SectionTitleLineWithButton>
|
</SectionTitleLineWithButton>
|
||||||
<CardBox>
|
<CardBox>
|
||||||
{currentUser?.avatar[0]?.publicUrl && (
|
{avatarUrl && (
|
||||||
<div className={'grid grid-cols-6 gap-4 mb-4'}>
|
<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'>
|
<div className='col-span-1 w-80 h-80 overflow-hidden border-2 rounded-full inline-flex items-center justify-center mb-8'>
|
||||||
<Image
|
<Image
|
||||||
className='w-80 h-80 max-w-full max-h-full object-cover object-center'
|
className='w-80 h-80 max-w-full max-h-full object-cover object-center'
|
||||||
src={`${currentUser?.avatar[0]?.publicUrl}`}
|
src={avatarUrl}
|
||||||
alt='Avatar'
|
alt='Avatar'
|
||||||
width={320}
|
width={320}
|
||||||
height={320}
|
height={320}
|
||||||
|
|||||||
@ -38,6 +38,7 @@ const initVals = {
|
|||||||
og_image_url: '',
|
og_image_url: '',
|
||||||
design_width: CANVAS_CONFIG.defaults.width as number,
|
design_width: CANVAS_CONFIG.defaults.width as number,
|
||||||
design_height: CANVAS_CONFIG.defaults.height as number,
|
design_height: CANVAS_CONFIG.defaults.height as number,
|
||||||
|
production_presentation_visibility: 'public' as 'public' | 'private',
|
||||||
is_deleted: false,
|
is_deleted: false,
|
||||||
deleted_at_time: new Date(),
|
deleted_at_time: new Date(),
|
||||||
};
|
};
|
||||||
@ -142,6 +143,10 @@ const EditProjectsPage = () => {
|
|||||||
og_image_url: String(projectData.og_image_url || ''),
|
og_image_url: String(projectData.og_image_url || ''),
|
||||||
design_width: width,
|
design_width: width,
|
||||||
design_height: height,
|
design_height: height,
|
||||||
|
production_presentation_visibility:
|
||||||
|
projectData.production_presentation_visibility === 'private'
|
||||||
|
? 'private'
|
||||||
|
: 'public',
|
||||||
is_deleted: Boolean(projectData.is_deleted),
|
is_deleted: Boolean(projectData.is_deleted),
|
||||||
deleted_at_time: projectData.deleted_at_time
|
deleted_at_time: projectData.deleted_at_time
|
||||||
? new Date(projectData.deleted_at_time as string)
|
? new Date(projectData.deleted_at_time as string)
|
||||||
@ -160,6 +165,8 @@ const EditProjectsPage = () => {
|
|||||||
og_image_url: data.og_image_url,
|
og_image_url: data.og_image_url,
|
||||||
design_width: data.design_width,
|
design_width: data.design_width,
|
||||||
design_height: data.design_height,
|
design_height: data.design_height,
|
||||||
|
production_presentation_visibility:
|
||||||
|
data.production_presentation_visibility,
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@ -350,6 +357,20 @@ const EditProjectsPage = () => {
|
|||||||
</a>
|
</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 />
|
<BaseDivider />
|
||||||
|
|
||||||
<FormField label='Design Canvas Preset'>
|
<FormField label='Design Canvas Preset'>
|
||||||
|
|||||||
@ -32,6 +32,7 @@ const initialValues = {
|
|||||||
logo_url: '',
|
logo_url: '',
|
||||||
favicon_url: '',
|
favicon_url: '',
|
||||||
og_image_url: '',
|
og_image_url: '',
|
||||||
|
production_presentation_visibility: 'public',
|
||||||
is_deleted: false,
|
is_deleted: false,
|
||||||
deleted_at_time: '',
|
deleted_at_time: '',
|
||||||
};
|
};
|
||||||
@ -94,6 +95,20 @@ const ProjectsNew = () => {
|
|||||||
<Field name='og_image_url' placeholder='OG Image URL' />
|
<Field name='og_image_url' placeholder='OG Image URL' />
|
||||||
</FormField>
|
</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'>
|
<FormField label='Is Deleted' labelFor='is_deleted'>
|
||||||
<Field
|
<Field
|
||||||
name='is_deleted'
|
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[],
|
avatar: [] as string[],
|
||||||
app_role: '',
|
app_role: '',
|
||||||
custom_permissions: [] as string[],
|
custom_permissions: [] as string[],
|
||||||
|
allowed_private_production_project_ids: [] as string[],
|
||||||
};
|
};
|
||||||
|
|
||||||
const UsersNew = () => {
|
const UsersNew = () => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
|
const [selectedRoleLabel, setSelectedRoleLabel] = React.useState('');
|
||||||
|
|
||||||
const handleSubmit = async (data: typeof initialValues) => {
|
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');
|
await router.push('/users/users-list');
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -66,6 +73,7 @@ const UsersNew = () => {
|
|||||||
initialValues={initialValues}
|
initialValues={initialValues}
|
||||||
onSubmit={(values) => handleSubmit(values)}
|
onSubmit={(values) => handleSubmit(values)}
|
||||||
>
|
>
|
||||||
|
{({ setFieldValue }) => (
|
||||||
<Form>
|
<Form>
|
||||||
<FormField label='First Name'>
|
<FormField label='First Name'>
|
||||||
<Field name='firstName' placeholder='First Name' />
|
<Field name='firstName' placeholder='First Name' />
|
||||||
@ -110,9 +118,34 @@ const UsersNew = () => {
|
|||||||
component={SelectField}
|
component={SelectField}
|
||||||
options={[]}
|
options={[]}
|
||||||
itemRef='roles'
|
itemRef='roles'
|
||||||
|
onOptionChange={(option) => {
|
||||||
|
const label = option?.label || '';
|
||||||
|
setSelectedRoleLabel(label);
|
||||||
|
if (label !== 'Public') {
|
||||||
|
setFieldValue(
|
||||||
|
'allowed_private_production_project_ids',
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</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
|
<FormField
|
||||||
label='Custom Permissions'
|
label='Custom Permissions'
|
||||||
labelFor='custom_permissions'
|
labelFor='custom_permissions'
|
||||||
@ -139,6 +172,7 @@ const UsersNew = () => {
|
|||||||
/>
|
/>
|
||||||
</BaseButtons>
|
</BaseButtons>
|
||||||
</Form>
|
</Form>
|
||||||
|
)}
|
||||||
</Formik>
|
</Formik>
|
||||||
</CardBox>
|
</CardBox>
|
||||||
</SectionMain>
|
</SectionMain>
|
||||||
|
|||||||
@ -20,6 +20,9 @@ export const projectSchema = z.object({
|
|||||||
.max(5000, 'Description too long')
|
.max(5000, 'Description too long')
|
||||||
.optional()
|
.optional()
|
||||||
.or(z.literal('')),
|
.or(z.literal('')),
|
||||||
|
production_presentation_visibility: z
|
||||||
|
.enum(['public', 'private'])
|
||||||
|
.default('public'),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type ProjectFormData = z.infer<typeof projectSchema>;
|
export type ProjectFormData = z.infer<typeof projectSchema>;
|
||||||
@ -28,4 +31,5 @@ export const projectInitialValues: ProjectFormData = {
|
|||||||
name: '',
|
name: '',
|
||||||
slug: '',
|
slug: '',
|
||||||
description: '',
|
description: '',
|
||||||
|
production_presentation_visibility: 'public',
|
||||||
};
|
};
|
||||||
|
|||||||
@ -31,6 +31,7 @@ export const userCreateSchema = z.object({
|
|||||||
avatar: z.array(z.unknown()).optional(),
|
avatar: z.array(z.unknown()).optional(),
|
||||||
app_role: z.unknown().optional().nullable(),
|
app_role: z.unknown().optional().nullable(),
|
||||||
custom_permissions: z.array(z.unknown()).optional(),
|
custom_permissions: z.array(z.unknown()).optional(),
|
||||||
|
allowed_private_production_project_ids: z.array(z.unknown()).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
// User update schema (password optional)
|
// User update schema (password optional)
|
||||||
@ -60,6 +61,7 @@ export const userUpdateSchema = z.object({
|
|||||||
avatar: z.array(z.unknown()).optional(),
|
avatar: z.array(z.unknown()).optional(),
|
||||||
app_role: z.unknown().optional().nullable(),
|
app_role: z.unknown().optional().nullable(),
|
||||||
custom_permissions: z.array(z.unknown()).optional(),
|
custom_permissions: z.array(z.unknown()).optional(),
|
||||||
|
allowed_private_production_project_ids: z.array(z.unknown()).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Infer TypeScript types from schemas
|
// Infer TypeScript types from schemas
|
||||||
@ -77,4 +79,5 @@ export const userInitialValues: UserUpdateFormData = {
|
|||||||
avatar: [],
|
avatar: [],
|
||||||
app_role: null,
|
app_role: null,
|
||||||
custom_permissions: [],
|
custom_permissions: [],
|
||||||
|
allowed_private_production_project_ids: [],
|
||||||
};
|
};
|
||||||
|
|||||||
@ -251,6 +251,7 @@ export function createEntitySlice<T extends BaseEntity>(
|
|||||||
});
|
});
|
||||||
builder.addCase(deleteItemsByIds.fulfilled, (state) => {
|
builder.addCase(deleteItemsByIds.fulfilled, (state) => {
|
||||||
state.loading = false;
|
state.loading = false;
|
||||||
|
state.refetch = true;
|
||||||
fulfilledNotify(state, `${pluralDisplayName} has been deleted`);
|
fulfilledNotify(state, `${pluralDisplayName} has been deleted`);
|
||||||
});
|
});
|
||||||
builder.addCase(deleteItemsByIds.rejected, (state, action) => {
|
builder.addCase(deleteItemsByIds.rejected, (state, action) => {
|
||||||
@ -265,6 +266,7 @@ export function createEntitySlice<T extends BaseEntity>(
|
|||||||
});
|
});
|
||||||
builder.addCase(deleteItem.fulfilled, (state) => {
|
builder.addCase(deleteItem.fulfilled, (state) => {
|
||||||
state.loading = false;
|
state.loading = false;
|
||||||
|
state.refetch = true;
|
||||||
fulfilledNotify(state, `${displayName} has been deleted`);
|
fulfilledNotify(state, `${displayName} has been deleted`);
|
||||||
});
|
});
|
||||||
builder.addCase(deleteItem.rejected, (state, action) => {
|
builder.addCase(deleteItem.rejected, (state, action) => {
|
||||||
@ -279,6 +281,7 @@ export function createEntitySlice<T extends BaseEntity>(
|
|||||||
});
|
});
|
||||||
builder.addCase(create.fulfilled, (state) => {
|
builder.addCase(create.fulfilled, (state) => {
|
||||||
state.loading = false;
|
state.loading = false;
|
||||||
|
state.refetch = true;
|
||||||
fulfilledNotify(state, `${displayName} has been created`);
|
fulfilledNotify(state, `${displayName} has been created`);
|
||||||
});
|
});
|
||||||
builder.addCase(create.rejected, (state, action) => {
|
builder.addCase(create.rejected, (state, action) => {
|
||||||
@ -293,6 +296,7 @@ export function createEntitySlice<T extends BaseEntity>(
|
|||||||
});
|
});
|
||||||
builder.addCase(update.fulfilled, (state) => {
|
builder.addCase(update.fulfilled, (state) => {
|
||||||
state.loading = false;
|
state.loading = false;
|
||||||
|
state.refetch = true;
|
||||||
fulfilledNotify(state, `${displayName} has been updated`);
|
fulfilledNotify(state, `${displayName} has been updated`);
|
||||||
});
|
});
|
||||||
builder.addCase(update.rejected, (state, action) => {
|
builder.addCase(update.rejected, (state, action) => {
|
||||||
@ -307,6 +311,7 @@ export function createEntitySlice<T extends BaseEntity>(
|
|||||||
});
|
});
|
||||||
builder.addCase(uploadCsv.fulfilled, (state) => {
|
builder.addCase(uploadCsv.fulfilled, (state) => {
|
||||||
state.loading = false;
|
state.loading = false;
|
||||||
|
state.refetch = true;
|
||||||
fulfilledNotify(state, `${pluralDisplayName} has been uploaded`);
|
fulfilledNotify(state, `${pluralDisplayName} has been uploaded`);
|
||||||
});
|
});
|
||||||
builder.addCase(uploadCsv.rejected, (state, action) => {
|
builder.addCase(uploadCsv.rejected, (state, action) => {
|
||||||
|
|||||||
@ -12,12 +12,6 @@ export const loginSteps: IntroStep[] = [
|
|||||||
position: 'auto',
|
position: 'auto',
|
||||||
tooltipClass: ' good-img',
|
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[] = [
|
export const appSteps: IntroStep[] = [
|
||||||
|
|||||||
@ -58,15 +58,20 @@ function buildKey(projectId: string, environment: string): string {
|
|||||||
*/
|
*/
|
||||||
export const fetchByProjectAndEnv = createAsyncThunk<
|
export const fetchByProjectAndEnv = createAsyncThunk<
|
||||||
{ key: string; data: ProjectTransitionSettingsEntity | null },
|
{ key: string; data: ProjectTransitionSettingsEntity | null },
|
||||||
{ projectId: string; environment: 'dev' | 'stage' | 'production' },
|
{
|
||||||
|
projectId: string;
|
||||||
|
environment: 'dev' | 'stage' | 'production';
|
||||||
|
apiHeaders?: Record<string, string>;
|
||||||
|
},
|
||||||
{ rejectValue: ApiError }
|
{ rejectValue: ApiError }
|
||||||
>(
|
>(
|
||||||
'project_transition_settings/fetchByProjectAndEnv',
|
'project_transition_settings/fetchByProjectAndEnv',
|
||||||
async ({ projectId, environment }, { rejectWithValue }) => {
|
async ({ projectId, environment, apiHeaders }, { rejectWithValue }) => {
|
||||||
const key = buildKey(projectId, environment);
|
const key = buildKey(projectId, environment);
|
||||||
try {
|
try {
|
||||||
const result = await axios.get<ProjectTransitionSettingsEntity | null>(
|
const result = await axios.get<ProjectTransitionSettingsEntity | null>(
|
||||||
`project-transition-settings/project/${projectId}/env/${environment}`,
|
`project-transition-settings/project/${projectId}/env/${environment}`,
|
||||||
|
{ headers: apiHeaders || {} },
|
||||||
);
|
);
|
||||||
// API returns null if no settings exist (use global defaults)
|
// API returns null if no settings exist (use global defaults)
|
||||||
return { key, data: result.data };
|
return { key, data: result.data };
|
||||||
|
|||||||
@ -24,6 +24,8 @@ export interface User extends BaseEntity {
|
|||||||
avatar?: ImageFile[];
|
avatar?: ImageFile[];
|
||||||
app_role?: Role | null;
|
app_role?: Role | null;
|
||||||
custom_permissions?: PermissionEntity[];
|
custom_permissions?: PermissionEntity[];
|
||||||
|
allowedPrivateProductionSlugs?: string[];
|
||||||
|
allowed_private_production_project_ids?: string[];
|
||||||
password?: string;
|
password?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -48,6 +50,7 @@ export interface Project extends BaseEntity {
|
|||||||
og_image_url?: string;
|
og_image_url?: string;
|
||||||
design_width?: number;
|
design_width?: number;
|
||||||
design_height?: number;
|
design_height?: number;
|
||||||
|
production_presentation_visibility?: 'public' | 'private';
|
||||||
// Note: transition_settings is now stored in project_transition_settings table
|
// Note: transition_settings is now stored in project_transition_settings table
|
||||||
// with environment awareness (dev, stage, production)
|
// with environment awareness (dev, stage, production)
|
||||||
is_deleted?: boolean;
|
is_deleted?: boolean;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user