From 249c697601174838ebf9122db829671c63e57fb0 Mon Sep 17 00:00:00 2001 From: Flatlogic Bot Date: Sun, 22 Feb 2026 22:54:00 +0000 Subject: [PATCH] Autosave: 20260222-225400 --- backend/src/db/api/activity_feed_items.js | 4 + backend/src/db/api/form_field_choices.js | 4 + backend/src/db/api/form_fields.js | 4 + backend/src/db/api/form_submissions.js | 4 + backend/src/db/api/form_templates.js | 4 + backend/src/db/api/locations.js | 4 + backend/src/db/api/organizations.js | 29 +- backend/src/db/api/projects.js | 46 +- backend/src/db/api/reports.js | 4 + backend/src/db/api/submission_values.js | 4 + backend/src/db/api/trial_types.js | 4 + backend/src/db/api/trials.js | 4 + backend/src/db/api/users.js | 101 +-- backend/src/db/migrations/1771796084954.js | 83 ++ backend/src/db/migrations/1771796084955.js | 56 ++ backend/src/db/migrations/1771796084956.js | 46 ++ .../20260222000000-add-tenant-to-users.js | 18 + .../20260222000001-link-admin-to-tenant.js | 13 + backend/src/db/models/organizations.js | 33 +- backend/src/db/models/users.js | 157 +--- .../db/seeders/20231127130745-sample-data.js | 208 ++--- backend/src/middlewares/check-permissions.js | 17 +- backend/src/services/auth.js | 1 + frontend/src/components/AsideMenuLayer.tsx | 6 +- frontend/src/components/NavBar.tsx | 26 +- frontend/src/components/NavBarItem.tsx | 5 +- frontend/src/interfaces/index.ts | 5 +- frontend/src/layouts/Authenticated.tsx | 223 +++--- frontend/src/menuAside.ts | 175 ++-- frontend/src/menuNavBar.ts | 70 +- frontend/src/pages/dashboard.tsx | 747 +++++------------- frontend/src/pages/index.tsx | 325 ++++---- frontend/src/pages/login.tsx | 12 +- frontend/src/pages/search.tsx | 9 +- .../pages/settings/company-preferences.tsx | 181 +++++ 35 files changed, 1372 insertions(+), 1260 deletions(-) create mode 100644 backend/src/db/migrations/1771796084954.js create mode 100644 backend/src/db/migrations/1771796084955.js create mode 100644 backend/src/db/migrations/1771796084956.js create mode 100644 backend/src/db/migrations/20260222000000-add-tenant-to-users.js create mode 100644 backend/src/db/migrations/20260222000001-link-admin-to-tenant.js create mode 100644 frontend/src/pages/settings/company-preferences.tsx diff --git a/backend/src/db/api/activity_feed_items.js b/backend/src/db/api/activity_feed_items.js index b53ed65..518a8c0 100644 --- a/backend/src/db/api/activity_feed_items.js +++ b/backend/src/db/api/activity_feed_items.js @@ -348,6 +348,10 @@ module.exports = class Activity_feed_itemsDBApi { where.organizationsId = options.currentUser.organizationsId; } } + + if (!globalAccess && options?.currentUser?.tenantId) { + where.tenantId = options.currentUser.tenantId; + } offset = currentPage * limit; diff --git a/backend/src/db/api/form_field_choices.js b/backend/src/db/api/form_field_choices.js index 82d68aa..aa6e385 100644 --- a/backend/src/db/api/form_field_choices.js +++ b/backend/src/db/api/form_field_choices.js @@ -298,6 +298,10 @@ module.exports = class Form_field_choicesDBApi { where.organizationsId = options.currentUser.organizationsId; } } + + if (!globalAccess && options?.currentUser?.tenantId) { + where.tenantId = options.currentUser.tenantId; + } offset = currentPage * limit; diff --git a/backend/src/db/api/form_fields.js b/backend/src/db/api/form_fields.js index f33543c..efff798 100644 --- a/backend/src/db/api/form_fields.js +++ b/backend/src/db/api/form_fields.js @@ -362,6 +362,10 @@ module.exports = class Form_fieldsDBApi { where.organizationsId = options.currentUser.organizationsId; } } + + if (!globalAccess && options?.currentUser?.tenantId) { + where.tenantId = options.currentUser.tenantId; + } offset = currentPage * limit; diff --git a/backend/src/db/api/form_submissions.js b/backend/src/db/api/form_submissions.js index 04ca85b..2ec268c 100644 --- a/backend/src/db/api/form_submissions.js +++ b/backend/src/db/api/form_submissions.js @@ -412,6 +412,10 @@ module.exports = class Form_submissionsDBApi { where.organizationsId = options.currentUser.organizationsId; } } + + if (!globalAccess && options?.currentUser?.tenantId) { + where.tenantId = options.currentUser.tenantId; + } offset = currentPage * limit; diff --git a/backend/src/db/api/form_templates.js b/backend/src/db/api/form_templates.js index 2b5dc55..82c58eb 100644 --- a/backend/src/db/api/form_templates.js +++ b/backend/src/db/api/form_templates.js @@ -314,6 +314,10 @@ module.exports = class Form_templatesDBApi { where.organizationsId = options.currentUser.organizationsId; } } + + if (!globalAccess && options?.currentUser?.tenantId) { + where.tenantId = options.currentUser.tenantId; + } offset = currentPage * limit; diff --git a/backend/src/db/api/locations.js b/backend/src/db/api/locations.js index 080c702..c46ea4d 100644 --- a/backend/src/db/api/locations.js +++ b/backend/src/db/api/locations.js @@ -349,6 +349,10 @@ module.exports = class LocationsDBApi { where.organizationsId = options.currentUser.organizationsId; } } + + if (!globalAccess && options?.currentUser?.tenantId) { + where.tenantId = options.currentUser.tenantId; + } offset = currentPage * limit; diff --git a/backend/src/db/api/organizations.js b/backend/src/db/api/organizations.js index a991ba1..7b0420b 100644 --- a/backend/src/db/api/organizations.js +++ b/backend/src/db/api/organizations.js @@ -1,4 +1,3 @@ - const db = require('../models'); const FileDBApi = require('./file'); const crypto = require('crypto'); @@ -25,6 +24,12 @@ module.exports = class OrganizationsDBApi { || null , + address: data.address || null, + city: data.city || null, + state: data.state || null, + country: data.country || null, + zip: data.zip || null, + navOrientation: data.navOrientation || 'side', importHash: data.importHash || null, createdById: currentUser.id, @@ -55,7 +60,13 @@ module.exports = class OrganizationsDBApi { || null , - + address: item.address || null, + city: item.city || null, + state: item.state || null, + country: item.country || null, + zip: item.zip || null, + navOrientation: item.navOrientation || 'side', + importHash: item.importHash || null, createdById: currentUser.id, updatedById: currentUser.id, @@ -74,9 +85,9 @@ module.exports = class OrganizationsDBApi { static async update(id, data, options) { const currentUser = (options && options.currentUser) || {id: null}; const transaction = (options && options.transaction) || undefined; - const globalAccess = currentUser.app_role?.globalAccess; + // const globalAccess = currentUser.app_role?.globalAccess; - const organizations = await db.organizations.findByPk(id, {}, {transaction}); + const organizations = await db.organizations.findByPk(id, { transaction }); @@ -84,6 +95,12 @@ module.exports = class OrganizationsDBApi { const updatePayload = {}; if (data.name !== undefined) updatePayload.name = data.name; + if (data.address !== undefined) updatePayload.address = data.address; + if (data.city !== undefined) updatePayload.city = data.city; + if (data.state !== undefined) updatePayload.state = data.state; + if (data.country !== undefined) updatePayload.country = data.country; + if (data.zip !== undefined) updatePayload.zip = data.zip; + if (data.navOrientation !== undefined) updatePayload.navOrientation = data.navOrientation; updatePayload.updatedById = currentUser.id; @@ -153,8 +170,7 @@ module.exports = class OrganizationsDBApi { const transaction = (options && options.transaction) || undefined; const organizations = await db.organizations.findOne( - { where }, - { transaction }, + { where, transaction }, ); if (!organizations) { @@ -408,4 +424,3 @@ module.exports = class OrganizationsDBApi { }; - diff --git a/backend/src/db/api/projects.js b/backend/src/db/api/projects.js index 7003cc9..4412d03 100644 --- a/backend/src/db/api/projects.js +++ b/backend/src/db/api/projects.js @@ -1,4 +1,3 @@ - const db = require('../models'); const FileDBApi = require('./file'); const crypto = require('crypto'); @@ -149,9 +148,6 @@ module.exports = class ProjectsDBApi { data[i].attachments, options, ); - } - - for (let i = 0; i < projects.length; i++) { await FileDBApi.replaceRelationFiles( { belongsTo: db.projects.getTableName(), @@ -324,22 +320,12 @@ module.exports = class ProjectsDBApi { - - - output.trials_project = await projects.getTrials_project({ transaction }); - - - - - - - output.tenant = await projects.getTenant({ transaction }); @@ -350,6 +336,11 @@ module.exports = class ProjectsDBApi { }); + output.organizations = await projects.getOrganizations({ + transaction + }); + + output.attachments = await projects.getAttachments({ transaction }); @@ -389,6 +380,10 @@ module.exports = class ProjectsDBApi { where.organizationsId = options.currentUser.organizationsId; } } + + if (!globalAccess && options?.currentUser?.tenantId) { + where.tenantId = options.currentUser.tenantId; + } offset = currentPage * limit; @@ -556,10 +551,30 @@ module.exports = class ProjectsDBApi { + if (filter.tenant) { + const listItems = filter.tenant.split('|').map(item => { + return Utils.uuid(item) + }); + where = { + ...where, + tenantId: {[Op.or]: listItems} + }; + } + if (filter.location) { + const listItems = filter.location.split('|').map(item => { + return Utils.uuid(item) + }); + + where = { + ...where, + locationId: {[Op.or]: listItems} + }; + } + if (filter.organizations) { const listItems = filter.organizations.split('|').map(item => { return Utils.uuid(item) @@ -672,5 +687,4 @@ module.exports = class ProjectsDBApi { } -}; - +}; \ No newline at end of file diff --git a/backend/src/db/api/reports.js b/backend/src/db/api/reports.js index 5add70d..f1d5d49 100644 --- a/backend/src/db/api/reports.js +++ b/backend/src/db/api/reports.js @@ -335,6 +335,10 @@ module.exports = class ReportsDBApi { where.organizationsId = options.currentUser.organizationsId; } } + + if (!globalAccess && options?.currentUser?.tenantId) { + where.tenantId = options.currentUser.tenantId; + } offset = currentPage * limit; diff --git a/backend/src/db/api/submission_values.js b/backend/src/db/api/submission_values.js index 273eaf9..850f118 100644 --- a/backend/src/db/api/submission_values.js +++ b/backend/src/db/api/submission_values.js @@ -405,6 +405,10 @@ module.exports = class Submission_valuesDBApi { where.organizationsId = options.currentUser.organizationsId; } } + + if (!globalAccess && options?.currentUser?.tenantId) { + where.tenantId = options.currentUser.tenantId; + } offset = currentPage * limit; diff --git a/backend/src/db/api/trial_types.js b/backend/src/db/api/trial_types.js index 2ae4fb1..214f66e 100644 --- a/backend/src/db/api/trial_types.js +++ b/backend/src/db/api/trial_types.js @@ -284,6 +284,10 @@ module.exports = class Trial_typesDBApi { where.organizationsId = options.currentUser.organizationsId; } } + + if (!globalAccess && options?.currentUser?.tenantId) { + where.tenantId = options.currentUser.tenantId; + } offset = currentPage * limit; diff --git a/backend/src/db/api/trials.js b/backend/src/db/api/trials.js index 70b75c1..6a3be2e 100644 --- a/backend/src/db/api/trials.js +++ b/backend/src/db/api/trials.js @@ -434,6 +434,10 @@ module.exports = class TrialsDBApi { where.organizationsId = options.currentUser.organizationsId; } } + + if (!globalAccess && options?.currentUser?.tenantId) { + where.tenantId = options.currentUser.tenantId; + } offset = currentPage * limit; diff --git a/backend/src/db/api/users.js b/backend/src/db/api/users.js index 0ab5122..2d53808 100644 --- a/backend/src/db/api/users.js +++ b/backend/src/db/api/users.js @@ -1,4 +1,3 @@ - const db = require('../models'); const FileDBApi = require('./file'); const crypto = require('crypto'); @@ -116,7 +115,11 @@ module.exports = class UsersDBApi { transaction, }); - + if (data.data.tenant !== undefined) { + await users.setTenant(data.data.tenant || null, { + transaction, + }); + } await users.setCustom_permissions(data.data.custom_permissions || [], { @@ -329,6 +332,12 @@ module.exports = class UsersDBApi { { transaction } ); } + + if (data.tenant !== undefined) { + await users.setTenant(data.tenant || null, { + transaction, + }); + } @@ -404,73 +413,41 @@ module.exports = class UsersDBApi { static async findBy(where, options) { const transaction = (options && options.transaction) || undefined; - const users = await db.users.findOne( - { where }, - { transaction }, - ); + const user = await db.users.findOne({ + where, + transaction, + include: [ + { model: db.roles, as: "app_role" }, + { model: db.organizations, as: "organizations" }, + { model: db.tenants, as: "tenant" }, + { model: db.file, as: "avatar" }, + { model: db.permissions, as: "custom_permissions" } + ] + }); - if (!users) { - return users; + if (!user) { + return user; } - const output = users.get({plain: true}); + const output = user.get({ plain: true }); - - - - - - - - - - - - - - output.form_submissions_submitted_by_user = await users.getForm_submissions_submitted_by_user({ - transaction - }); - - - - output.activity_feed_items_actor_user = await users.getActivity_feed_items_actor_user({ - transaction - }); - - - output.reports_created_by_user = await users.getReports_created_by_user({ - transaction - }); - - - - output.avatar = await users.getAvatar({ - transaction - }); - - - output.app_role = await users.getApp_role({ - transaction - }); - - if (output.app_role) { - output.app_role_permissions = await output.app_role.getPermissions({ + if (user.app_role) { + output.app_role_permissions = await user.app_role.getPermissions({ transaction, }); } - - - output.custom_permissions = await users.getCustom_permissions({ + + output.form_submissions_submitted_by_user = await user.getForm_submissions_submitted_by_user({ transaction }); - - - output.organizations = await users.getOrganizations({ + + output.activity_feed_items_actor_user = await user.getActivity_feed_items_actor_user({ + transaction + }); + + output.reports_created_by_user = await user.getReports_created_by_user({ transaction }); - - return output; } @@ -528,6 +505,11 @@ module.exports = class UsersDBApi { }, + { + model: db.tenants, + as: 'tenant', + }, + { model: db.permissions, @@ -1007,5 +989,4 @@ module.exports = class UsersDBApi { -}; - +}; \ No newline at end of file diff --git a/backend/src/db/migrations/1771796084954.js b/backend/src/db/migrations/1771796084954.js new file mode 100644 index 0000000..5734512 --- /dev/null +++ b/backend/src/db/migrations/1771796084954.js @@ -0,0 +1,83 @@ + +module.exports = { + async up(queryInterface, Sequelize) { + const transaction = await queryInterface.sequelize.transaction(); + try { + await queryInterface.addColumn( + 'organizations', + 'address', + { + type: Sequelize.DataTypes.TEXT, + allowNull: true, + }, + { transaction } + ); + await queryInterface.addColumn( + 'organizations', + 'city', + { + type: Sequelize.DataTypes.TEXT, + allowNull: true, + }, + { transaction } + ); + await queryInterface.addColumn( + 'organizations', + 'state', + { + type: Sequelize.DataTypes.TEXT, + allowNull: true, + }, + { transaction } + ); + await queryInterface.addColumn( + 'organizations', + 'country', + { + type: Sequelize.DataTypes.TEXT, + allowNull: true, + }, + { transaction } + ); + await queryInterface.addColumn( + 'organizations', + 'zip', + { + type: Sequelize.DataTypes.TEXT, + allowNull: true, + }, + { transaction } + ); + await queryInterface.addColumn( + 'organizations', + 'navOrientation', + { + type: Sequelize.DataTypes.TEXT, + allowNull: false, + defaultValue: 'side', + }, + { transaction } + ); + await transaction.commit(); + } catch (err) { + await transaction.rollback(); + throw err; + } + }, + + async down(queryInterface, Sequelize) { + const transaction = await queryInterface.sequelize.transaction(); + try { + await queryInterface.removeColumn('organizations', 'address', { transaction }); + await queryInterface.removeColumn('organizations', 'city', { transaction }); + await queryInterface.removeColumn('organizations', 'state', { transaction }); + await queryInterface.removeColumn('organizations', 'country', { transaction }); + await queryInterface.removeColumn('organizations', 'zip', { transaction }); + await queryInterface.removeColumn('organizations', 'navOrientation', { transaction }); + await transaction.commit(); + } catch (err) { + await transaction.rollback(); + throw err; + } + } +}; diff --git a/backend/src/db/migrations/1771796084955.js b/backend/src/db/migrations/1771796084955.js new file mode 100644 index 0000000..cdc830a --- /dev/null +++ b/backend/src/db/migrations/1771796084955.js @@ -0,0 +1,56 @@ +module.exports = { + async up(queryInterface, Sequelize) { + const createdAt = new Date(); + const updatedAt = new Date(); + + const roleIds = [ + '21a13835-6fd7-4672-b383-c609e9d5da87', // Administrator + '7fd7feec-5dd7-490a-bedc-361ebe87415e', // Tenant Owner + '61b8b77a-5114-458a-bb16-fd554d4f4557', // Global Operations Lead + '89ecce95-8ac5-4ebb-bdb2-7746fe7e4690', // Research Manager + ]; + + const permissionIds = [ + '80485725-0adf-4309-a2ee-177870dcc3ee', // READ_ORGANIZATIONS + 'a3d8dfaf-6c52-42d5-a2f3-e7e19c593e3d', // UPDATE_ORGANIZATIONS + ]; + + const rolesPermissions = []; + + for (const roleId of roleIds) { + for (const permissionId of permissionIds) { + rolesPermissions.push({ + createdAt, + updatedAt, + roles_permissionsId: roleId, + permissionId: permissionId, + }); + } + } + + // Use a transaction and UPSERT-like behavior or just check existence + // To keep it simple, we use queryInterface.bulkInsert but it might fail on duplicates if we are not careful. + // However, we checked and they are missing. + + await queryInterface.bulkInsert('rolesPermissionsPermissions', rolesPermissions); + }, + + async down(queryInterface, Sequelize) { + const roleIds = [ + '21a13835-6fd7-4672-b383-c609e9d5da87', // Administrator + '7fd7feec-5dd7-490a-bedc-361ebe87415e', // Tenant Owner + '61b8b77a-5114-458a-bb16-fd554d4f4557', // Global Operations Lead + '89ecce95-8ac5-4ebb-bdb2-7746fe7e4690', // Research Manager + ]; + + const permissionIds = [ + '80485725-0adf-4309-a2ee-177870dcc3ee', // READ_ORGANIZATIONS + 'a3d8dfaf-6c52-42d5-a2f3-e7e19c593e3d', // UPDATE_ORGANIZATIONS + ]; + + await queryInterface.bulkDelete('rolesPermissionsPermissions', { + roles_permissionsId: roleIds, + permissionId: permissionIds, + }); + } +}; \ No newline at end of file diff --git a/backend/src/db/migrations/1771796084956.js b/backend/src/db/migrations/1771796084956.js new file mode 100644 index 0000000..25d40a6 --- /dev/null +++ b/backend/src/db/migrations/1771796084956.js @@ -0,0 +1,46 @@ +module.exports = { + async up(queryInterface, Sequelize) { + const roles = await queryInterface.sequelize.query( + `SELECT id, name FROM roles WHERE name IN ('Administrator', 'Tenant Owner', 'Global Operations Lead', 'Research Manager')`, + { type: Sequelize.QueryTypes.SELECT } + ); + + const permissions = await queryInterface.sequelize.query( + `SELECT id, name FROM permissions WHERE name IN ('READ_ORGANIZATIONS', 'UPDATE_ORGANIZATIONS')`, + { type: Sequelize.QueryTypes.SELECT } + ); + + const createdAt = new Date(); + const updatedAt = new Date(); + + const rolesPermissions = []; + + for (const role of roles) { + for (const permission of permissions) { + rolesPermissions.push({ + createdAt, + updatedAt, + roles_permissionsId: role.id, + permissionId: permission.id, + }); + } + } + + // Use a more robust way to insert ignoring duplicates + for (const rp of rolesPermissions) { + await queryInterface.sequelize.query( + `INSERT INTO "rolesPermissionsPermissions" ("createdAt", "updatedAt", "roles_permissionsId", "permissionId") + VALUES (?, ?, ?, ?) + ON CONFLICT ("roles_permissionsId", "permissionId") DO NOTHING`, + { + replacements: [rp.createdAt, rp.updatedAt, rp.roles_permissionsId, rp.permissionId], + type: Sequelize.QueryTypes.INSERT + } + ); + } + }, + + async down(queryInterface, Sequelize) { + // No need for a down migration that removes permissions as it might remove more than intended if not careful + } +}; diff --git a/backend/src/db/migrations/20260222000000-add-tenant-to-users.js b/backend/src/db/migrations/20260222000000-add-tenant-to-users.js new file mode 100644 index 0000000..dc89531 --- /dev/null +++ b/backend/src/db/migrations/20260222000000-add-tenant-to-users.js @@ -0,0 +1,18 @@ +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.addColumn('users', 'tenantId', { + type: Sequelize.DataTypes.UUID, + references: { + model: 'tenants', + key: 'id', + }, + allowNull: true, + onUpdate: 'CASCADE', + onDelete: 'SET NULL', + }); + }, + + async down(queryInterface, Sequelize) { + await queryInterface.removeColumn('users', 'tenantId'); + }, +}; diff --git a/backend/src/db/migrations/20260222000001-link-admin-to-tenant.js b/backend/src/db/migrations/20260222000001-link-admin-to-tenant.js new file mode 100644 index 0000000..7686265 --- /dev/null +++ b/backend/src/db/migrations/20260222000001-link-admin-to-tenant.js @@ -0,0 +1,13 @@ +module.exports = { + async up(queryInterface, Sequelize) { + const [tenants] = await queryInterface.sequelize.query("SELECT id FROM tenants WHERE name = 'Green Valley Farms' LIMIT 1;"); + if (tenants && tenants.length > 0) { + const tenantId = tenants[0].id; + await queryInterface.sequelize.query("UPDATE users SET \"tenantId\" = '" + tenantId + "' WHERE email = 'admin@flatlogic.com';"); + } + }, + + async down(queryInterface, Sequelize) { + await queryInterface.sequelize.query("UPDATE users SET \"tenantId\" = NULL WHERE email = 'admin@flatlogic.com';"); + }, +}; diff --git a/backend/src/db/models/organizations.js b/backend/src/db/models/organizations.js index 062b9d3..15247f6 100644 --- a/backend/src/db/models/organizations.js +++ b/backend/src/db/models/organizations.js @@ -14,11 +14,34 @@ module.exports = function(sequelize, DataTypes) { primaryKey: true, }, -name: { + name: { type: DataTypes.TEXT, - - + }, + address: { + type: DataTypes.TEXT, + }, + + city: { + type: DataTypes.TEXT, + }, + + state: { + type: DataTypes.TEXT, + }, + + country: { + type: DataTypes.TEXT, + }, + + zip: { + type: DataTypes.TEXT, + }, + + navOrientation: { + type: DataTypes.TEXT, + allowNull: false, + defaultValue: 'side', }, importHash: { @@ -180,6 +203,4 @@ name: { return organizations; -}; - - +}; \ No newline at end of file diff --git a/backend/src/db/models/users.js b/backend/src/db/models/users.js index ce0aea9..e5e55d4 100644 --- a/backend/src/db/models/users.js +++ b/backend/src/db/models/users.js @@ -13,97 +13,46 @@ module.exports = function(sequelize, DataTypes) { defaultValue: DataTypes.UUIDV4, primaryKey: true, }, - -firstName: { + firstName: { type: DataTypes.TEXT, - - - }, - -lastName: { + lastName: { type: DataTypes.TEXT, - - - }, - -phoneNumber: { + phoneNumber: { type: DataTypes.TEXT, - - - }, - -email: { + email: { type: DataTypes.TEXT, - - - }, - -disabled: { + disabled: { type: DataTypes.BOOLEAN, - allowNull: false, defaultValue: false, - - - }, - -password: { + password: { type: DataTypes.TEXT, - - - }, - -emailVerified: { + emailVerified: { type: DataTypes.BOOLEAN, - allowNull: false, defaultValue: false, - - - }, - -emailVerificationToken: { + emailVerificationToken: { type: DataTypes.TEXT, - - - }, - -emailVerificationTokenExpiresAt: { + emailVerificationTokenExpiresAt: { type: DataTypes.DATE, - - - }, - -passwordResetToken: { + passwordResetToken: { type: DataTypes.TEXT, - - - }, - -passwordResetTokenExpiresAt: { + passwordResetTokenExpiresAt: { type: DataTypes.DATE, - - - }, - -provider: { + provider: { type: DataTypes.TEXT, - - - }, - importHash: { type: DataTypes.STRING(255), allowNull: true, @@ -118,7 +67,6 @@ provider: { ); users.associate = (db) => { - db.users.belongsToMany(db.permissions, { as: 'custom_permissions', foreignKey: { @@ -137,22 +85,6 @@ provider: { through: 'usersCustom_permissionsPermissions', }); - -/// loop through entities and it's fields, and if ref === current e[name] and create relation has many on parent entity - - - - - - - - - - - - - - db.users.hasMany(db.form_submissions, { as: 'form_submissions_submitted_by_user', foreignKey: { @@ -161,8 +93,6 @@ provider: { constraints: false, }); - - db.users.hasMany(db.activity_feed_items, { as: 'activity_feed_items_actor_user', foreignKey: { @@ -171,7 +101,6 @@ provider: { constraints: false, }); - db.users.hasMany(db.reports, { as: 'reports_created_by_user', foreignKey: { @@ -180,12 +109,6 @@ provider: { constraints: false, }); - - -//end loop - - - db.users.belongsTo(db.roles, { as: 'app_role', foreignKey: { @@ -202,7 +125,13 @@ provider: { constraints: false, }); - + db.users.belongsTo(db.tenants, { + as: 'tenant', + foreignKey: { + name: 'tenantId', + }, + constraints: false, + }); db.users.hasMany(db.file, { as: 'avatar', @@ -214,7 +143,6 @@ provider: { }, }); - db.users.belongsTo(db.users, { as: 'createdBy', }); @@ -224,48 +152,31 @@ provider: { }); }; - - users.beforeCreate((users, options) => { - users = trimStringFields(users); + users.beforeCreate((users, options) => { + users = trimStringFields(users); if (users.provider !== providers.LOCAL && Object.values(providers).indexOf(users.provider) > -1) { - users.emailVerified = true; + users.emailVerified = true; - if (!users.password) { - const password = crypto - .randomBytes(20) - .toString('hex'); - - const hashedPassword = bcrypt.hashSync( - password, - config.bcrypt.saltRounds, - ); - - users.password = hashedPassword - } - } - }); + if (!users.password) { + const password = crypto.randomBytes(20).toString('hex'); + const saltRounds = (config.bcrypt && config.bcrypt.saltRounds) || 10; + const hashedPassword = bcrypt.hashSync(password, saltRounds); + users.password = hashedPassword; + } + } + }); users.beforeUpdate((users, options) => { users = trimStringFields(users); }); - return users; }; - function trimStringFields(users) { - users.email = users.email.trim(); - - users.firstName = users.firstName - ? users.firstName.trim() - : null; - - users.lastName = users.lastName - ? users.lastName.trim() - : null; - + users.email = users.email ? users.email.trim() : ''; + users.firstName = users.firstName ? users.firstName.trim() : null; + users.lastName = users.lastName ? users.lastName.trim() : null; return users; -} - +} \ No newline at end of file diff --git a/backend/src/db/seeders/20231127130745-sample-data.js b/backend/src/db/seeders/20231127130745-sample-data.js index dde1c4c..765728b 100644 --- a/backend/src/db/seeders/20231127130745-sample-data.js +++ b/backend/src/db/seeders/20231127130745-sample-data.js @@ -3529,11 +3529,11 @@ const ReportsData = [ order: [['id', 'ASC']], offset: 0 }); - if (User0?.setOrganization) + if (User0?.setOrganizations) { await User0. - setOrganization(relatedOrganization0); + setOrganizations(relatedOrganization0); } const relatedOrganization1 = await Organizations.findOne({ @@ -3543,11 +3543,11 @@ const ReportsData = [ order: [['id', 'ASC']], offset: 1 }); - if (User1?.setOrganization) + if (User1?.setOrganizations) { await User1. - setOrganization(relatedOrganization1); + setOrganizations(relatedOrganization1); } const relatedOrganization2 = await Organizations.findOne({ @@ -3557,11 +3557,11 @@ const ReportsData = [ order: [['id', 'ASC']], offset: 2 }); - if (User2?.setOrganization) + if (User2?.setOrganizations) { await User2. - setOrganization(relatedOrganization2); + setOrganizations(relatedOrganization2); } const relatedOrganization3 = await Organizations.findOne({ @@ -3571,11 +3571,11 @@ const ReportsData = [ order: [['id', 'ASC']], offset: 3 }); - if (User3?.setOrganization) + if (User3?.setOrganizations) { await User3. - setOrganization(relatedOrganization3); + setOrganizations(relatedOrganization3); } } @@ -3616,11 +3616,11 @@ const ReportsData = [ order: [['id', 'ASC']], offset: 0 }); - if (Tenant0?.setOrganization) + if (Tenant0?.setOrganizations) { await Tenant0. - setOrganization(relatedOrganization0); + setOrganizations(relatedOrganization0); } const relatedOrganization1 = await Organizations.findOne({ @@ -3630,11 +3630,11 @@ const ReportsData = [ order: [['id', 'ASC']], offset: 1 }); - if (Tenant1?.setOrganization) + if (Tenant1?.setOrganizations) { await Tenant1. - setOrganization(relatedOrganization1); + setOrganizations(relatedOrganization1); } const relatedOrganization2 = await Organizations.findOne({ @@ -3644,11 +3644,11 @@ const ReportsData = [ order: [['id', 'ASC']], offset: 2 }); - if (Tenant2?.setOrganization) + if (Tenant2?.setOrganizations) { await Tenant2. - setOrganization(relatedOrganization2); + setOrganizations(relatedOrganization2); } const relatedOrganization3 = await Organizations.findOne({ @@ -3658,11 +3658,11 @@ const ReportsData = [ order: [['id', 'ASC']], offset: 3 }); - if (Tenant3?.setOrganization) + if (Tenant3?.setOrganizations) { await Tenant3. - setOrganization(relatedOrganization3); + setOrganizations(relatedOrganization3); } } @@ -3823,11 +3823,11 @@ const ReportsData = [ order: [['id', 'ASC']], offset: 0 }); - if (Location0?.setOrganization) + if (Location0?.setOrganizations) { await Location0. - setOrganization(relatedOrganization0); + setOrganizations(relatedOrganization0); } const relatedOrganization1 = await Organizations.findOne({ @@ -3837,11 +3837,11 @@ const ReportsData = [ order: [['id', 'ASC']], offset: 1 }); - if (Location1?.setOrganization) + if (Location1?.setOrganizations) { await Location1. - setOrganization(relatedOrganization1); + setOrganizations(relatedOrganization1); } const relatedOrganization2 = await Organizations.findOne({ @@ -3851,11 +3851,11 @@ const ReportsData = [ order: [['id', 'ASC']], offset: 2 }); - if (Location2?.setOrganization) + if (Location2?.setOrganizations) { await Location2. - setOrganization(relatedOrganization2); + setOrganizations(relatedOrganization2); } const relatedOrganization3 = await Organizations.findOne({ @@ -3865,11 +3865,11 @@ const ReportsData = [ order: [['id', 'ASC']], offset: 3 }); - if (Location3?.setOrganization) + if (Location3?.setOrganizations) { await Location3. - setOrganization(relatedOrganization3); + setOrganizations(relatedOrganization3); } } @@ -4030,11 +4030,11 @@ const ReportsData = [ order: [['id', 'ASC']], offset: 0 }); - if (Project0?.setOrganization) + if (Project0?.setOrganizations) { await Project0. - setOrganization(relatedOrganization0); + setOrganizations(relatedOrganization0); } const relatedOrganization1 = await Organizations.findOne({ @@ -4044,11 +4044,11 @@ const ReportsData = [ order: [['id', 'ASC']], offset: 1 }); - if (Project1?.setOrganization) + if (Project1?.setOrganizations) { await Project1. - setOrganization(relatedOrganization1); + setOrganizations(relatedOrganization1); } const relatedOrganization2 = await Organizations.findOne({ @@ -4058,11 +4058,11 @@ const ReportsData = [ order: [['id', 'ASC']], offset: 2 }); - if (Project2?.setOrganization) + if (Project2?.setOrganizations) { await Project2. - setOrganization(relatedOrganization2); + setOrganizations(relatedOrganization2); } const relatedOrganization3 = await Organizations.findOne({ @@ -4072,11 +4072,11 @@ const ReportsData = [ order: [['id', 'ASC']], offset: 3 }); - if (Project3?.setOrganization) + if (Project3?.setOrganizations) { await Project3. - setOrganization(relatedOrganization3); + setOrganizations(relatedOrganization3); } } @@ -4168,11 +4168,11 @@ const ReportsData = [ order: [['id', 'ASC']], offset: 0 }); - if (TrialType0?.setOrganization) + if (TrialType0?.setOrganizations) { await TrialType0. - setOrganization(relatedOrganization0); + setOrganizations(relatedOrganization0); } const relatedOrganization1 = await Organizations.findOne({ @@ -4182,11 +4182,11 @@ const ReportsData = [ order: [['id', 'ASC']], offset: 1 }); - if (TrialType1?.setOrganization) + if (TrialType1?.setOrganizations) { await TrialType1. - setOrganization(relatedOrganization1); + setOrganizations(relatedOrganization1); } const relatedOrganization2 = await Organizations.findOne({ @@ -4196,11 +4196,11 @@ const ReportsData = [ order: [['id', 'ASC']], offset: 2 }); - if (TrialType2?.setOrganization) + if (TrialType2?.setOrganizations) { await TrialType2. - setOrganization(relatedOrganization2); + setOrganizations(relatedOrganization2); } const relatedOrganization3 = await Organizations.findOne({ @@ -4210,11 +4210,11 @@ const ReportsData = [ order: [['id', 'ASC']], offset: 3 }); - if (TrialType3?.setOrganization) + if (TrialType3?.setOrganizations) { await TrialType3. - setOrganization(relatedOrganization3); + setOrganizations(relatedOrganization3); } } @@ -4503,11 +4503,11 @@ const ReportsData = [ order: [['id', 'ASC']], offset: 0 }); - if (Trial0?.setOrganization) + if (Trial0?.setOrganizations) { await Trial0. - setOrganization(relatedOrganization0); + setOrganizations(relatedOrganization0); } const relatedOrganization1 = await Organizations.findOne({ @@ -4517,11 +4517,11 @@ const ReportsData = [ order: [['id', 'ASC']], offset: 1 }); - if (Trial1?.setOrganization) + if (Trial1?.setOrganizations) { await Trial1. - setOrganization(relatedOrganization1); + setOrganizations(relatedOrganization1); } const relatedOrganization2 = await Organizations.findOne({ @@ -4531,11 +4531,11 @@ const ReportsData = [ order: [['id', 'ASC']], offset: 2 }); - if (Trial2?.setOrganization) + if (Trial2?.setOrganizations) { await Trial2. - setOrganization(relatedOrganization2); + setOrganizations(relatedOrganization2); } const relatedOrganization3 = await Organizations.findOne({ @@ -4545,11 +4545,11 @@ const ReportsData = [ order: [['id', 'ASC']], offset: 3 }); - if (Trial3?.setOrganization) + if (Trial3?.setOrganizations) { await Trial3. - setOrganization(relatedOrganization3); + setOrganizations(relatedOrganization3); } } @@ -4647,11 +4647,11 @@ const ReportsData = [ order: [['id', 'ASC']], offset: 0 }); - if (FormTemplate0?.setOrganization) + if (FormTemplate0?.setOrganizations) { await FormTemplate0. - setOrganization(relatedOrganization0); + setOrganizations(relatedOrganization0); } const relatedOrganization1 = await Organizations.findOne({ @@ -4661,11 +4661,11 @@ const ReportsData = [ order: [['id', 'ASC']], offset: 1 }); - if (FormTemplate1?.setOrganization) + if (FormTemplate1?.setOrganizations) { await FormTemplate1. - setOrganization(relatedOrganization1); + setOrganizations(relatedOrganization1); } const relatedOrganization2 = await Organizations.findOne({ @@ -4675,11 +4675,11 @@ const ReportsData = [ order: [['id', 'ASC']], offset: 2 }); - if (FormTemplate2?.setOrganization) + if (FormTemplate2?.setOrganizations) { await FormTemplate2. - setOrganization(relatedOrganization2); + setOrganizations(relatedOrganization2); } const relatedOrganization3 = await Organizations.findOne({ @@ -4689,11 +4689,11 @@ const ReportsData = [ order: [['id', 'ASC']], offset: 3 }); - if (FormTemplate3?.setOrganization) + if (FormTemplate3?.setOrganizations) { await FormTemplate3. - setOrganization(relatedOrganization3); + setOrganizations(relatedOrganization3); } } @@ -4856,11 +4856,11 @@ const ReportsData = [ order: [['id', 'ASC']], offset: 0 }); - if (FormField0?.setOrganization) + if (FormField0?.setOrganizations) { await FormField0. - setOrganization(relatedOrganization0); + setOrganizations(relatedOrganization0); } const relatedOrganization1 = await Organizations.findOne({ @@ -4870,11 +4870,11 @@ const ReportsData = [ order: [['id', 'ASC']], offset: 1 }); - if (FormField1?.setOrganization) + if (FormField1?.setOrganizations) { await FormField1. - setOrganization(relatedOrganization1); + setOrganizations(relatedOrganization1); } const relatedOrganization2 = await Organizations.findOne({ @@ -4884,11 +4884,11 @@ const ReportsData = [ order: [['id', 'ASC']], offset: 2 }); - if (FormField2?.setOrganization) + if (FormField2?.setOrganizations) { await FormField2. - setOrganization(relatedOrganization2); + setOrganizations(relatedOrganization2); } const relatedOrganization3 = await Organizations.findOne({ @@ -4898,11 +4898,11 @@ const ReportsData = [ order: [['id', 'ASC']], offset: 3 }); - if (FormField3?.setOrganization) + if (FormField3?.setOrganizations) { await FormField3. - setOrganization(relatedOrganization3); + setOrganizations(relatedOrganization3); } } @@ -5057,11 +5057,11 @@ const ReportsData = [ order: [['id', 'ASC']], offset: 0 }); - if (FormFieldChoice0?.setOrganization) + if (FormFieldChoice0?.setOrganizations) { await FormFieldChoice0. - setOrganization(relatedOrganization0); + setOrganizations(relatedOrganization0); } const relatedOrganization1 = await Organizations.findOne({ @@ -5071,11 +5071,11 @@ const ReportsData = [ order: [['id', 'ASC']], offset: 1 }); - if (FormFieldChoice1?.setOrganization) + if (FormFieldChoice1?.setOrganizations) { await FormFieldChoice1. - setOrganization(relatedOrganization1); + setOrganizations(relatedOrganization1); } const relatedOrganization2 = await Organizations.findOne({ @@ -5085,11 +5085,11 @@ const ReportsData = [ order: [['id', 'ASC']], offset: 2 }); - if (FormFieldChoice2?.setOrganization) + if (FormFieldChoice2?.setOrganizations) { await FormFieldChoice2. - setOrganization(relatedOrganization2); + setOrganizations(relatedOrganization2); } const relatedOrganization3 = await Organizations.findOne({ @@ -5099,11 +5099,11 @@ const ReportsData = [ order: [['id', 'ASC']], offset: 3 }); - if (FormFieldChoice3?.setOrganization) + if (FormFieldChoice3?.setOrganizations) { await FormFieldChoice3. - setOrganization(relatedOrganization3); + setOrganizations(relatedOrganization3); } } @@ -5390,11 +5390,11 @@ const ReportsData = [ order: [['id', 'ASC']], offset: 0 }); - if (FormSubmission0?.setOrganization) + if (FormSubmission0?.setOrganizations) { await FormSubmission0. - setOrganization(relatedOrganization0); + setOrganizations(relatedOrganization0); } const relatedOrganization1 = await Organizations.findOne({ @@ -5404,11 +5404,11 @@ const ReportsData = [ order: [['id', 'ASC']], offset: 1 }); - if (FormSubmission1?.setOrganization) + if (FormSubmission1?.setOrganizations) { await FormSubmission1. - setOrganization(relatedOrganization1); + setOrganizations(relatedOrganization1); } const relatedOrganization2 = await Organizations.findOne({ @@ -5418,11 +5418,11 @@ const ReportsData = [ order: [['id', 'ASC']], offset: 2 }); - if (FormSubmission2?.setOrganization) + if (FormSubmission2?.setOrganizations) { await FormSubmission2. - setOrganization(relatedOrganization2); + setOrganizations(relatedOrganization2); } const relatedOrganization3 = await Organizations.findOne({ @@ -5432,11 +5432,11 @@ const ReportsData = [ order: [['id', 'ASC']], offset: 3 }); - if (FormSubmission3?.setOrganization) + if (FormSubmission3?.setOrganizations) { await FormSubmission3. - setOrganization(relatedOrganization3); + setOrganizations(relatedOrganization3); } } @@ -5660,11 +5660,11 @@ const ReportsData = [ order: [['id', 'ASC']], offset: 0 }); - if (SubmissionValue0?.setOrganization) + if (SubmissionValue0?.setOrganizations) { await SubmissionValue0. - setOrganization(relatedOrganization0); + setOrganizations(relatedOrganization0); } const relatedOrganization1 = await Organizations.findOne({ @@ -5674,11 +5674,11 @@ const ReportsData = [ order: [['id', 'ASC']], offset: 1 }); - if (SubmissionValue1?.setOrganization) + if (SubmissionValue1?.setOrganizations) { await SubmissionValue1. - setOrganization(relatedOrganization1); + setOrganizations(relatedOrganization1); } const relatedOrganization2 = await Organizations.findOne({ @@ -5688,11 +5688,11 @@ const ReportsData = [ order: [['id', 'ASC']], offset: 2 }); - if (SubmissionValue2?.setOrganization) + if (SubmissionValue2?.setOrganizations) { await SubmissionValue2. - setOrganization(relatedOrganization2); + setOrganizations(relatedOrganization2); } const relatedOrganization3 = await Organizations.findOne({ @@ -5702,11 +5702,11 @@ const ReportsData = [ order: [['id', 'ASC']], offset: 3 }); - if (SubmissionValue3?.setOrganization) + if (SubmissionValue3?.setOrganizations) { await SubmissionValue3. - setOrganization(relatedOrganization3); + setOrganizations(relatedOrganization3); } } @@ -5865,11 +5865,11 @@ const ReportsData = [ order: [['id', 'ASC']], offset: 0 }); - if (ActivityFeedItem0?.setOrganization) + if (ActivityFeedItem0?.setOrganizations) { await ActivityFeedItem0. - setOrganization(relatedOrganization0); + setOrganizations(relatedOrganization0); } const relatedOrganization1 = await Organizations.findOne({ @@ -5879,11 +5879,11 @@ const ReportsData = [ order: [['id', 'ASC']], offset: 1 }); - if (ActivityFeedItem1?.setOrganization) + if (ActivityFeedItem1?.setOrganizations) { await ActivityFeedItem1. - setOrganization(relatedOrganization1); + setOrganizations(relatedOrganization1); } const relatedOrganization2 = await Organizations.findOne({ @@ -5893,11 +5893,11 @@ const ReportsData = [ order: [['id', 'ASC']], offset: 2 }); - if (ActivityFeedItem2?.setOrganization) + if (ActivityFeedItem2?.setOrganizations) { await ActivityFeedItem2. - setOrganization(relatedOrganization2); + setOrganizations(relatedOrganization2); } const relatedOrganization3 = await Organizations.findOne({ @@ -5907,11 +5907,11 @@ const ReportsData = [ order: [['id', 'ASC']], offset: 3 }); - if (ActivityFeedItem3?.setOrganization) + if (ActivityFeedItem3?.setOrganizations) { await ActivityFeedItem3. - setOrganization(relatedOrganization3); + setOrganizations(relatedOrganization3); } } @@ -6068,11 +6068,11 @@ const ReportsData = [ order: [['id', 'ASC']], offset: 0 }); - if (Report0?.setOrganization) + if (Report0?.setOrganizations) { await Report0. - setOrganization(relatedOrganization0); + setOrganizations(relatedOrganization0); } const relatedOrganization1 = await Organizations.findOne({ @@ -6082,11 +6082,11 @@ const ReportsData = [ order: [['id', 'ASC']], offset: 1 }); - if (Report1?.setOrganization) + if (Report1?.setOrganizations) { await Report1. - setOrganization(relatedOrganization1); + setOrganizations(relatedOrganization1); } const relatedOrganization2 = await Organizations.findOne({ @@ -6096,11 +6096,11 @@ const ReportsData = [ order: [['id', 'ASC']], offset: 2 }); - if (Report2?.setOrganization) + if (Report2?.setOrganizations) { await Report2. - setOrganization(relatedOrganization2); + setOrganizations(relatedOrganization2); } const relatedOrganization3 = await Organizations.findOne({ @@ -6110,11 +6110,11 @@ const ReportsData = [ order: [['id', 'ASC']], offset: 3 }); - if (Report3?.setOrganization) + if (Report3?.setOrganizations) { await Report3. - setOrganization(relatedOrganization3); + setOrganizations(relatedOrganization3); } } diff --git a/backend/src/middlewares/check-permissions.js b/backend/src/middlewares/check-permissions.js index 77740c7..be92e24 100644 --- a/backend/src/middlewares/check-permissions.js +++ b/backend/src/middlewares/check-permissions.js @@ -1,4 +1,3 @@ - const ValidationError = require('../services/notifications/errors/validation'); const RolesDBApi = require('../db/api/roles'); @@ -49,6 +48,11 @@ function checkPermissions(permission) { // 2. Check Custom Permissions (only if the user is authenticated) if (currentUser) { + // Check for Super Admin (globalAccess) + if (currentUser.app_role && currentUser.app_role.globalAccess) { + return next(); + } + // Ensure custom_permissions is an array before using find const customPermissions = Array.isArray(currentUser.custom_permissions) ? currentUser.custom_permissions @@ -91,10 +95,12 @@ function checkPermissions(permission) { } // 4. Check Permissions on the "effective" role - // Assume the effectiveRole object (from app_role or RolesDBApi) has a getPermissions() method - // or a 'permissions' property (if permissions are eagerly loaded). let rolePermissions = []; - if (typeof effectiveRole.getPermissions === 'function') { + + // Check if permissions are already available in currentUser (from UsersDBApi.findBy) + if (currentUser && currentUser.app_role_permissions) { + rolePermissions = currentUser.app_role_permissions; + } else if (typeof effectiveRole.getPermissions === 'function') { rolePermissions = await effectiveRole.getPermissions(); // Get permissions asynchronously if the method exists } else if (Array.isArray(effectiveRole.permissions)) { rolePermissions = effectiveRole.permissions; // Or take from property if permissions are pre-loaded @@ -145,5 +151,4 @@ function checkCrudPermissions(name) { module.exports = { checkPermissions, checkCrudPermissions, -}; - +}; \ No newline at end of file diff --git a/backend/src/services/auth.js b/backend/src/services/auth.js index bcc3411..cd2c205 100644 --- a/backend/src/services/auth.js +++ b/backend/src/services/auth.js @@ -1,3 +1,4 @@ +const db = require('../db/models'); const UsersDBApi = require('../db/api/users'); const ValidationError = require('./notifications/errors/validation'); const ForbiddenError = require('./notifications/errors/forbidden'); diff --git a/frontend/src/components/AsideMenuLayer.tsx b/frontend/src/components/AsideMenuLayer.tsx index 8693dea..fa31230 100644 --- a/frontend/src/components/AsideMenuLayer.tsx +++ b/frontend/src/components/AsideMenuLayer.tsx @@ -3,10 +3,8 @@ import { mdiLogout, mdiClose } from '@mdi/js' import BaseIcon from './BaseIcon' import AsideMenuList from './AsideMenuList' import { MenuAsideItem } from '../interfaces' -import { useAppSelector } from '../stores/hooks' +import { useAppDispatch, useAppSelector } from '../stores/hooks' import Link from 'next/link'; - -import { useAppDispatch } from '../stores/hooks'; import { createAsyncThunk } from '@reduxjs/toolkit'; import axios from 'axios'; @@ -91,4 +89,4 @@ export default function AsideMenuLayer({ menu, className = '', ...props }: Props ) -} +} \ No newline at end of file diff --git a/frontend/src/components/NavBar.tsx b/frontend/src/components/NavBar.tsx index c270ae0..625805d 100644 --- a/frontend/src/components/NavBar.tsx +++ b/frontend/src/components/NavBar.tsx @@ -6,14 +6,16 @@ import NavBarItemPlain from './NavBarItemPlain' import NavBarMenuList from './NavBarMenuList' import { MenuNavBarItem } from '../interfaces' import { useAppSelector } from '../stores/hooks'; +import NavBarItem from './NavBarItem' type Props = { menu: MenuNavBarItem[] + leftMenu?: MenuNavBarItem[] className: string children: ReactNode } -export default function NavBar({ menu, className = '', children }: Props) { +export default function NavBar({ menu, leftMenu = [], className = '', children }: Props) { const [isMenuNavBarActive, setIsMenuNavBarActive] = useState(false) const [isScrolled, setIsScrolled] = useState(false); const bgColor = useAppSelector((state) => state.style.bgLayoutColor); @@ -38,7 +40,16 @@ export default function NavBar({ menu, className = '', children }: Props) { className={`${className} top-0 inset-x-0 fixed ${bgColor} h-14 z-30 transition-position w-screen lg:w-auto dark:bg-dark-800`} >
-
{children}
+
+ {children} + {leftMenu.length > 0 && ( +
+ {leftMenu.map((item, index) => ( + + ))} +
+ )} +
@@ -49,9 +60,16 @@ export default function NavBar({ menu, className = '', children }: Props) { isMenuNavBarActive ? 'block' : 'hidden' } flex items-center max-h-screen-menu overflow-y-auto lg:overflow-visible absolute w-screen top-14 left-0 ${bgColor} shadow-lg lg:w-auto lg:flex lg:static lg:shadow-none dark:bg-dark-800`} > - + {/* Mobile menu should include both left and right items */} +
+ +
+ {/* Desktop menu only includes right items */} +
+ +
) -} +} \ No newline at end of file diff --git a/frontend/src/components/NavBarItem.tsx b/frontend/src/components/NavBarItem.tsx index 72935e6..4ced3eb 100644 --- a/frontend/src/components/NavBarItem.tsx +++ b/frontend/src/components/NavBarItem.tsx @@ -1,6 +1,5 @@ -import React, {useEffect, useRef} from 'react' +import React, {useEffect, useRef, useState} from 'react' import Link from 'next/link' -import { useState } from 'react' import { mdiChevronUp, mdiChevronDown } from '@mdi/js' import BaseDivider from './BaseDivider' import BaseIcon from './BaseIcon' @@ -129,4 +128,4 @@ export default function NavBarItem({ item }: Props) { } return
{NavBarItemComponentContents}
-} +} \ No newline at end of file diff --git a/frontend/src/interfaces/index.ts b/frontend/src/interfaces/index.ts index 0c7dd74..dc15fb6 100644 --- a/frontend/src/interfaces/index.ts +++ b/frontend/src/interfaces/index.ts @@ -14,6 +14,7 @@ export type MenuAsideItem = { withDevider?: boolean; menu?: MenuAsideItem[] permissions?: string | string[] + isOrientationTopOnly?: boolean } export type MenuNavBarItem = { @@ -27,6 +28,8 @@ export type MenuNavBarItem = { isToggleLightDark?: boolean isCurrentUser?: boolean menu?: MenuNavBarItem[] + isOrientationTopOnly?: boolean + permissions?: string | string[] } export type ColorKey = 'white' | 'light' | 'contrast' | 'success' | 'danger' | 'warning' | 'info' @@ -106,4 +109,4 @@ export type StyleKey = 'white' | 'basic' export type UserForm = { name: string email: string -} +} \ No newline at end of file diff --git a/frontend/src/layouts/Authenticated.tsx b/frontend/src/layouts/Authenticated.tsx index 1b9907d..56c9125 100644 --- a/frontend/src/layouts/Authenticated.tsx +++ b/frontend/src/layouts/Authenticated.tsx @@ -1,129 +1,168 @@ -import React, { ReactNode, useEffect } from 'react' -import { useState } from 'react' -import jwt from 'jsonwebtoken'; -import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js' +import React, { ReactNode, useState, useMemo } from 'react' +import { useRouter } from 'next/router' +import AsideMenu from '../components/AsideMenu' +import NavBar from '../components/NavBar' +import FooterBar from '../components/FooterBar' import menuAside from '../menuAside' import menuNavBar from '../menuNavBar' -import BaseIcon from '../components/BaseIcon' -import NavBar from '../components/NavBar' -import NavBarItemPlain from '../components/NavBarItemPlain' -import AsideMenu from '../components/AsideMenu' -import FooterBar from '../components/FooterBar' import { useAppDispatch, useAppSelector } from '../stores/hooks' -import Search from '../components/Search'; -import { useRouter } from 'next/router' -import {findMe, logoutUser} from "../stores/authSlice"; - -import {hasPermission} from "../helpers/userPermissions"; - +import { setDarkMode } from '../stores/styleSlice' +import { mdiLoading, mdiMenu } from '@mdi/js' +import BaseIcon from '../components/BaseIcon' +import { findMe } from '../stores/authSlice' +import { useTranslation } from 'next-i18next' +import Search from '../components/Search' +import NavBarItemPlain from '../components/NavBarItemPlain' +import { hasPermission } from '../helpers/userPermissions' type Props = { children: ReactNode - - permission?: string - } -export default function LayoutAuthenticated({ - children, - - permission - -}: Props) { +export default function LayoutAuthenticated({ children }: Props) { const dispatch = useAppDispatch() - const router = useRouter() - const { token, currentUser } = useAppSelector((state) => state.auth) - const bgColor = useAppSelector((state) => state.style.bgLayoutColor); - let localToken - if (typeof window !== 'undefined') { - // Perform localStorage action - localToken = localStorage.getItem('token') - } - - const isTokenValid = () => { - const token = localStorage.getItem('token'); - if (!token) return; - const date = new Date().getTime() / 1000; - const data = jwt.decode(token); - if (!data) return; - return date < data.exp; - }; - - useEffect(() => { - dispatch(findMe()); - if (!isTokenValid()) { - dispatch(logoutUser()); - router.push('/login'); - } - }, [token, localToken]); - - - useEffect(() => { - if (!permission || !currentUser) return; - - if (!hasPermission(currentUser, permission)) router.push('/error'); - }, [currentUser, permission]); - - + const { i18n } = useTranslation() const darkMode = useAppSelector((state) => state.style.darkMode) - const [isAsideMobileExpanded, setIsAsideMobileExpanded] = useState(false) const [isAsideLgActive, setIsAsideLgActive] = useState(false) + const router = useRouter() + const { currentUser, isFetching } = useAppSelector((state) => state.auth) - useEffect(() => { - const handleRouteChangeStart = () => { + const organizations = currentUser?.organizations || currentUser?.organization; + const orgData = Array.isArray(organizations) ? organizations[0] : organizations; + const navOrientation = orgData?.navOrientation || 'side'; + + const filteredMenuAside = useMemo(() => { + return menuAside.filter(item => { + // Filter by permissions + if (item.permissions && !hasPermission(currentUser, item.permissions)) { + return false; + } + // Filter by orientation + if (navOrientation === 'top' && item.isOrientationTopOnly) { + return false; + } + return true; + }).map(item => { + if (item.menu) { + return { + ...item, + menu: item.menu.filter(subItem => { + if (subItem.permissions && !hasPermission(currentUser, subItem.permissions)) { + return false; + } + return true; + }) + } + } + return item; + }); + }, [currentUser, navOrientation]); + + const filteredMenuNavBarRight = useMemo(() => { + return menuNavBar.filter(item => { + // Filter by orientation: only those NOT marked as isOrientationTopOnly go to the right menu + // EXCEPT when we are in side mode, then we don't show isOrientationTopOnly items at all in NavBar + if (navOrientation === 'side' && item.isOrientationTopOnly) { + return false; + } + if (navOrientation === 'top' && item.isOrientationTopOnly) { + return false; + } + return true; + }); + }, [navOrientation]); + + const filteredMenuNavBarLeft = useMemo(() => { + if (navOrientation !== 'top') return []; + return menuNavBar.filter(item => item.isOrientationTopOnly); + }, [navOrientation]); + + React.useEffect(() => { + const handleRouteChange = () => { setIsAsideMobileExpanded(false) setIsAsideLgActive(false) } - router.events.on('routeChangeStart', handleRouteChangeStart) + router.events.on('routeChangeStart', handleRouteChange) - // If the component is unmounted, unsubscribe - // from the event with the `off` method: return () => { - router.events.off('routeChangeStart', handleRouteChangeStart) + router.events.off('routeChangeStart', handleRouteChange) } - }, [router.events, dispatch]) + }, [router.events]) + React.useEffect(() => { + if (localStorage.getItem('token') && (!currentUser || !currentUser.organizations)) { + dispatch(findMe()) + } + }, [currentUser, dispatch]) - const layoutAsidePadding = 'xl:pl-60' + React.useEffect(() => { + if (typeof window !== 'undefined') { + const isDarkMode = darkMode || localStorage.getItem('darkMode') === '1' + dispatch(setDarkMode(isDarkMode)) + } + }, [darkMode, dispatch]) + + React.useEffect(() => { + if (i18n.language !== router.locale) { + i18n.changeLanguage(router.locale) + } + }, [router.locale, i18n]) + + if (isFetching || !currentUser) { + return ( +
+ +
+ ) + } + + const layoutAsidePadding = navOrientation === 'top' ? '' : 'xl:pl-60' return (
- setIsAsideMobileExpanded(!isAsideMobileExpanded)} - > - - - setIsAsideLgActive(true)} - > - - - - - + {navOrientation === 'side' && ( + <> + setIsAsideMobileExpanded(!isAsideMobileExpanded)} + > + + + setIsAsideLgActive(true)} + > + + + + )} +
+ +
- setIsAsideLgActive(false)} - /> + {navOrientation === 'side' && ( + setIsAsideLgActive(false)} + /> + )} {children} - Hand-crafted & Made with ❤️ +
) -} +} \ No newline at end of file diff --git a/frontend/src/menuAside.ts b/frontend/src/menuAside.ts index ae6e49c..d0221ad 100644 --- a/frontend/src/menuAside.ts +++ b/frontend/src/menuAside.ts @@ -4,152 +4,105 @@ import { MenuAsideItem } from './interfaces' const menuAside: MenuAsideItem[] = [ { href: '/dashboard', - icon: icon.mdiViewDashboardOutline, - label: 'Dashboard', - }, - - { - href: '/users/users-list', - label: 'Users', - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - icon: icon.mdiAccountGroup ?? icon.mdiTable, - permissions: 'READ_USERS' - }, - { - href: '/roles/roles-list', - label: 'Roles', - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - icon: icon.mdiShieldAccountVariantOutline ?? icon.mdiTable, - permissions: 'READ_ROLES' - }, - { - href: '/permissions/permissions-list', - label: 'Permissions', - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - icon: icon.mdiShieldAccountOutline ?? icon.mdiTable, - permissions: 'READ_PERMISSIONS' - }, - { - href: '/organizations/organizations-list', - label: 'Organizations', - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - icon: icon.mdiTable ?? icon.mdiTable, - permissions: 'READ_ORGANIZATIONS' + icon: icon.mdiHomeOutline, + label: 'Home', + isOrientationTopOnly: true }, { href: '/tenants/tenants-list', label: 'Tenants', // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore - icon: 'mdiDomain' in icon ? icon['mdiDomain' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable, - permissions: 'READ_TENANTS' - }, - { - href: '/locations/locations-list', - label: 'Locations', - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - icon: 'mdiMapMarker' in icon ? icon['mdiMapMarker' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable, - permissions: 'READ_LOCATIONS' + icon: icon.mdiDomain, + permissions: 'READ_TENANTS', + isOrientationTopOnly: true }, { href: '/projects/projects-list', label: 'Projects', // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore - icon: 'mdiFolderOutline' in icon ? icon['mdiFolderOutline' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable, - permissions: 'READ_PROJECTS' - }, - { - href: '/trial_types/trial_types-list', - label: 'Trial types', - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - icon: 'mdiFlaskOutline' in icon ? icon['mdiFlaskOutline' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable, - permissions: 'READ_TRIAL_TYPES' + icon: icon.mdiFolderOutline, + permissions: 'READ_PROJECTS', + isOrientationTopOnly: true }, { href: '/trials/trials-list', label: 'Trials', // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore - icon: 'mdiTestTube' in icon ? icon['mdiTestTube' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable, - permissions: 'READ_TRIALS' - }, - { - href: '/form_templates/form_templates-list', - label: 'Form templates', - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - icon: 'mdiFormSelect' in icon ? icon['mdiFormSelect' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable, - permissions: 'READ_FORM_TEMPLATES' - }, - { - href: '/form_fields/form_fields-list', - label: 'Form fields', - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - icon: 'mdiFormatListBulleted' in icon ? icon['mdiFormatListBulleted' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable, - permissions: 'READ_FORM_FIELDS' - }, - { - href: '/form_field_choices/form_field_choices-list', - label: 'Form field choices', - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - icon: 'mdiPlaylistEdit' in icon ? icon['mdiPlaylistEdit' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable, - permissions: 'READ_FORM_FIELD_CHOICES' + icon: icon.mdiTestTube, + permissions: 'READ_TRIALS', + isOrientationTopOnly: true }, { href: '/form_submissions/form_submissions-list', - label: 'Form submissions', + label: 'Track-IT', // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore - icon: 'mdiClipboardTextOutline' in icon ? icon['mdiClipboardTextOutline' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable, - permissions: 'READ_FORM_SUBMISSIONS' + icon: icon.mdiClipboardTextOutline, + permissions: 'READ_FORM_SUBMISSIONS', + isOrientationTopOnly: true }, { - href: '/submission_values/submission_values-list', - label: 'Submission values', + href: '/users/users-list', + label: 'Users', // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore - icon: 'mdiDatabaseOutline' in icon ? icon['mdiDatabaseOutline' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable, - permissions: 'READ_SUBMISSION_VALUES' - }, - { - href: '/activity_feed_items/activity_feed_items-list', - label: 'Activity feed items', - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - icon: 'mdiTimelineTextOutline' in icon ? icon['mdiTimelineTextOutline' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable, - permissions: 'READ_ACTIVITY_FEED_ITEMS' + icon: icon.mdiAccountGroup, + permissions: 'READ_USERS', + isOrientationTopOnly: true }, { href: '/reports/reports-list', label: 'Reports', // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore - icon: 'mdiFileChartOutline' in icon ? icon['mdiFileChartOutline' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable, - permissions: 'READ_REPORTS' + icon: icon.mdiFileChartOutline, + permissions: 'READ_REPORTS', + isOrientationTopOnly: true }, { - href: '/profile', - label: 'Profile', - icon: icon.mdiAccountCircle, - }, - - - { - href: '/api-docs', - target: '_blank', - label: 'Swagger API', - icon: icon.mdiFileCode, - permissions: 'READ_API_DOCS' + label: 'Settings', + icon: icon.mdiCogOutline, + menu: [ + { + href: '/settings/company-preferences', + label: 'Company Preferences', + permissions: 'UPDATE_ORGANIZATIONS' + }, + { + href: '/trial_types/trial_types-list', + label: 'Trial Types', + permissions: 'READ_TRIAL_TYPES' + }, + { + href: '/form_templates/form_templates-list', + label: 'Form Templates', + permissions: 'READ_FORM_TEMPLATES' + }, + { + href: '/form_fields/form_fields-list', + label: 'Form Fields', + permissions: 'READ_FORM_FIELDS' + }, + { + href: '/form_field_choices/form_field_choices-list', + label: 'Field Choices', + permissions: 'READ_FORM_FIELD_CHOICES' + }, + { + href: '/locations/locations-list', + label: 'Locations', + permissions: 'READ_LOCATIONS' + }, + { + href: '/submission_values/submission_values-list', + label: 'Data Sets', + permissions: 'READ_SUBMISSION_VALUES' + }, + ] }, ] -export default menuAside +export default menuAside \ No newline at end of file diff --git a/frontend/src/menuNavBar.ts b/frontend/src/menuNavBar.ts index a5dd956..fa04c4e 100644 --- a/frontend/src/menuNavBar.ts +++ b/frontend/src/menuNavBar.ts @@ -1,19 +1,73 @@ import { - mdiMenu, - mdiClockOutline, - mdiCloud, - mdiCrop, mdiAccount, - mdiCogOutline, - mdiEmail, mdiLogout, mdiThemeLightDark, - mdiGithub, - mdiVuejs, + mdiDomain, + mdiFileCode, + mdiAccountCircle, + mdiHomeOutline, + mdiFolderOutline, + mdiTestTube, + mdiClipboardTextOutline, + mdiAccountGroup, + mdiFileChartOutline, + mdiCogOutline } from '@mdi/js' import { MenuNavBarItem } from './interfaces' const menuNavBar: MenuNavBarItem[] = [ + { + href: '/dashboard', + icon: mdiHomeOutline, + label: 'Home', + isOrientationTopOnly: true + }, + { + href: '/projects/projects-list', + label: 'Projects', + icon: mdiFolderOutline, + isOrientationTopOnly: true + }, + { + href: '/trials/trials-list', + label: 'Trials', + icon: mdiTestTube, + isOrientationTopOnly: true + }, + { + href: '/form_submissions/form_submissions-list', + label: 'Track-IT', + icon: mdiClipboardTextOutline, + isOrientationTopOnly: true + }, + { + href: '/users/users-list', + label: 'Users', + icon: mdiAccountGroup, + isOrientationTopOnly: true + }, + { + href: '/reports/reports-list', + label: 'Reports', + icon: mdiFileChartOutline, + isOrientationTopOnly: true + }, + { + href: '/tenants/tenants-list', + label: 'Tenants', + icon: mdiDomain, + }, + { + href: '/profile', + label: 'Profile', + icon: mdiAccountCircle, + }, + { + href: '/api-docs', + target: '_blank', + label: 'Swagger API', + icon: mdiFileCode, + }, { isCurrentUser: true, menu: [ diff --git a/frontend/src/pages/dashboard.tsx b/frontend/src/pages/dashboard.tsx index 5ec622e..07849c2 100644 --- a/frontend/src/pages/dashboard.tsx +++ b/frontend/src/pages/dashboard.tsx @@ -9,13 +9,18 @@ import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton import BaseIcon from "../components/BaseIcon"; import { getPageTitle } from '../config' import Link from "next/link"; +import moment from 'moment'; import { hasPermission } from "../helpers/userPermissions"; import { fetchWidgets } from '../stores/roles/rolesSlice'; import { WidgetCreator } from '../components/WidgetCreator/WidgetCreator'; import { SmartWidget } from '../components/SmartWidget/SmartWidget'; +import UserAvatar from '../components/UserAvatar'; +import CardBox from '../components/CardBox'; import { useAppDispatch, useAppSelector } from '../stores/hooks'; +import { fetch as fetchActivityItems } from '../stores/activity_feed_items/activity_feed_itemsSlice'; + const Dashboard = () => { const dispatch = useAppDispatch(); const iconsColor = useAppSelector((state) => state.style.iconsColor); @@ -24,49 +29,27 @@ const Dashboard = () => { const loadingMessage = 'Loading...'; - - const [users, setUsers] = React.useState(loadingMessage); - const [roles, setRoles] = React.useState(loadingMessage); - const [permissions, setPermissions] = React.useState(loadingMessage); - const [organizations, setOrganizations] = React.useState(loadingMessage); - const [tenants, setTenants] = React.useState(loadingMessage); - const [locations, setLocations] = React.useState(loadingMessage); const [projects, setProjects] = React.useState(loadingMessage); - const [trial_types, setTrial_types] = React.useState(loadingMessage); const [trials, setTrials] = React.useState(loadingMessage); - const [form_templates, setForm_templates] = React.useState(loadingMessage); - const [form_fields, setForm_fields] = React.useState(loadingMessage); - const [form_field_choices, setForm_field_choices] = React.useState(loadingMessage); const [form_submissions, setForm_submissions] = React.useState(loadingMessage); - const [submission_values, setSubmission_values] = React.useState(loadingMessage); - const [activity_feed_items, setActivity_feed_items] = React.useState(loadingMessage); - const [reports, setReports] = React.useState(loadingMessage); + const [users, setUsers] = React.useState(loadingMessage); - - const [widgetsRole, setWidgetsRole] = React.useState({ - role: { value: '', label: '' }, - }); + const { activity_feed_items, loading: activityLoading } = useAppSelector((state) => state.activity_feed_items); const { currentUser } = useAppSelector((state) => state.auth); const { isFetchingQuery } = useAppSelector((state) => state.openAi); - const { rolesWidgets, loading } = useAppSelector((state) => state.roles); - - - const organizationId = currentUser?.organizations?.id; - - async function loadData() { - const entities = ['users','roles','permissions','organizations','tenants','locations','projects','trial_types','trials','form_templates','form_fields','form_field_choices','form_submissions','submission_values','activity_feed_items','reports',]; - const fns = [setUsers,setRoles,setPermissions,setOrganizations,setTenants,setLocations,setProjects,setTrial_types,setTrials,setForm_templates,setForm_fields,setForm_field_choices,setForm_submissions,setSubmission_values,setActivity_feed_items,setReports,]; + + async function loadCounts() { + const entities = ['projects', 'trials', 'form_submissions', 'users']; + const fns = [setProjects, setTrials, setForm_submissions, setUsers]; const requests = entities.map((entity, index) => { - - if(hasPermission(currentUser, `READ_${entity.toUpperCase()}`)) { - return axios.get(`/${entity.toLowerCase()}/count`); - } else { - fns[index](null); - return Promise.resolve({data: {count: null}}); - } - + if (hasPermission(currentUser, `READ_${entity.toUpperCase()}`)) { + return axios.get(`/${entity.toLowerCase()}/count`); + } else { + fns[index](null); + return Promise.resolve({ data: { count: null } }); + } }); Promise.allSettled(requests).then((results) => { @@ -79,537 +62,197 @@ const Dashboard = () => { }); }); } - - async function getWidgets(roleId) { - await dispatch(fetchWidgets(roleId)); - } + React.useEffect(() => { if (!currentUser) return; - loadData().then(); - setWidgetsRole({ role: { value: currentUser?.app_role?.id, label: currentUser?.app_role?.name } }); - }, [currentUser]); + loadCounts().then(); + if (hasPermission(currentUser, 'READ_ACTIVITY_FEED_ITEMS')) { + dispatch(fetchActivityItems({ query: '?limit=10&orderBy=occurred_at_DESC' })); + } + }, [currentUser, dispatch]); - React.useEffect(() => { - if (!currentUser || !widgetsRole?.role?.value) return; - getWidgets(widgetsRole?.role?.value || '').then(); - }, [widgetsRole?.role?.value]); - - return ( - <> - - - {getPageTitle('Overview')} - - - - - {''} - - - {hasPermission(currentUser, 'CREATE_ROLES') && } - {!!rolesWidgets.length && - hasPermission(currentUser, 'CREATE_ROLES') && ( -

- {`${widgetsRole?.role?.label || 'Users'}'s widgets`} -

- )} + const getItemIcon = (type: string) => { + switch (type) { + case 'project_created': return icon.mdiFolderPlusOutline; + case 'trial_created': return icon.mdiFlaskPlusOutline; + case 'submission_created': return icon.mdiClipboardCheckOutline; + case 'user_created': return icon.mdiAccountPlusOutline; + case 'attachment_added': return icon.mdiPaperclip; + default: return icon.mdiBellOutline; + } + } -
- {(isFetchingQuery || loading) && ( -
- {' '} - Loading widgets... -
- )} + return ( + <> + + {getPageTitle('Home')} + + + + {''} + - { rolesWidgets && - rolesWidgets.map((widget) => ( - - ))} -
+ {/* Summary Cards */} +
+ + +
+
+

Projects

+

{projects}

+
+ +
+
+ + + +
+
+

Trials

+

{trials}

+
+ +
+
+ + + +
+
+

Submissions

+

{form_submissions}

+
+ +
+
+ + + +
+
+

Team Members

+

{users}

+
+ +
+
+ +
- {!!rolesWidgets.length &&
} - -
- - - {hasPermission(currentUser, 'READ_USERS') && -
-
-
-
- Users -
-
- {users} -
-
-
- + {/* Activity Feed Section */} +
+
+ + + {activityLoading &&

Loading feed...

} + + {!activityLoading && activity_feed_items?.length === 0 && ( + +

No recent activity found.

+
+ )} + +
+ {activity_feed_items?.map((item: any) => ( + +
+
+ +
+
+
+
+ + {item.actor_user?.firstName} {item.actor_user?.lastName} + + + {item.title} + +
+
+ + {moment(item.occurred_at).fromNow()} +
+
+

+ {item.summary} +

+ + {item.thumbnail && item.thumbnail[0] && ( +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + Activity thumbnail +
+ )} + +
+
+ + + {item.item_type.replace('_', ' ')} + +
+ {item.link_path && ( + + View Details → + + )} +
+
+
+
+ ))}
-
- } - - {hasPermission(currentUser, 'READ_ROLES') && -
-
-
-
- Roles -
-
- {roles} -
-
-
- -
+ +
+ + +
+
+ Active Projects + {projects} +
+
+ Ongoing Trials + {trials} +
+
+ New Submissions + +{form_submissions} +
+
+
+ + + +

+ Trial Tracker is running in multi-tenant mode. + Farm-level data isolation is active for your account. +

+
- } - - {hasPermission(currentUser, 'READ_PERMISSIONS') && -
-
-
-
- Permissions -
-
- {permissions} -
-
-
- -
-
-
- } - - {hasPermission(currentUser, 'READ_ORGANIZATIONS') && -
-
-
-
- Organizations -
-
- {organizations} -
-
-
- -
-
-
- } - - {hasPermission(currentUser, 'READ_TENANTS') && -
-
-
-
- Tenants -
-
- {tenants} -
-
-
- -
-
-
- } - - {hasPermission(currentUser, 'READ_LOCATIONS') && -
-
-
-
- Locations -
-
- {locations} -
-
-
- -
-
-
- } - - {hasPermission(currentUser, 'READ_PROJECTS') && -
-
-
-
- Projects -
-
- {projects} -
-
-
- -
-
-
- } - - {hasPermission(currentUser, 'READ_TRIAL_TYPES') && -
-
-
-
- Trial types -
-
- {trial_types} -
-
-
- -
-
-
- } - - {hasPermission(currentUser, 'READ_TRIALS') && -
-
-
-
- Trials -
-
- {trials} -
-
-
- -
-
-
- } - - {hasPermission(currentUser, 'READ_FORM_TEMPLATES') && -
-
-
-
- Form templates -
-
- {form_templates} -
-
-
- -
-
-
- } - - {hasPermission(currentUser, 'READ_FORM_FIELDS') && -
-
-
-
- Form fields -
-
- {form_fields} -
-
-
- -
-
-
- } - - {hasPermission(currentUser, 'READ_FORM_FIELD_CHOICES') && -
-
-
-
- Form field choices -
-
- {form_field_choices} -
-
-
- -
-
-
- } - - {hasPermission(currentUser, 'READ_FORM_SUBMISSIONS') && -
-
-
-
- Form submissions -
-
- {form_submissions} -
-
-
- -
-
-
- } - - {hasPermission(currentUser, 'READ_SUBMISSION_VALUES') && -
-
-
-
- Submission values -
-
- {submission_values} -
-
-
- -
-
-
- } - - {hasPermission(currentUser, 'READ_ACTIVITY_FEED_ITEMS') && -
-
-
-
- Activity feed items -
-
- {activity_feed_items} -
-
-
- -
-
-
- } - - {hasPermission(currentUser, 'READ_REPORTS') && -
-
-
-
- Reports -
-
- {reports} -
-
-
- -
-
-
- } - - -
- - - ) + + + ) } Dashboard.getLayout = function getLayout(page: ReactElement) { - return {page} + return {page} } export default Dashboard diff --git a/frontend/src/pages/index.tsx b/frontend/src/pages/index.tsx index 6c2cbca..54a4078 100644 --- a/frontend/src/pages/index.tsx +++ b/frontend/src/pages/index.tsx @@ -1,166 +1,187 @@ - -import React, { useEffect, useState } from 'react'; +import React from 'react'; import type { ReactElement } from 'react'; import Head from 'next/head'; import Link from 'next/link'; import BaseButton from '../components/BaseButton'; -import CardBox from '../components/CardBox'; -import SectionFullScreen from '../components/SectionFullScreen'; import LayoutGuest from '../layouts/Guest'; -import BaseDivider from '../components/BaseDivider'; -import BaseButtons from '../components/BaseButtons'; import { getPageTitle } from '../config'; -import { useAppSelector } from '../stores/hooks'; -import CardBoxComponentTitle from "../components/CardBoxComponentTitle"; -import { getPexelsImage, getPexelsVideo } from '../helpers/pexels'; - - -export default function Starter() { - const [illustrationImage, setIllustrationImage] = useState({ - src: undefined, - photographer: undefined, - photographer_url: undefined, - }) - const [illustrationVideo, setIllustrationVideo] = useState({video_files: []}) - const [contentType, setContentType] = useState('image'); - const [contentPosition, setContentPosition] = useState('left'); - const textColor = useAppSelector((state) => state.style.linkColor); +import BaseIcon from '../components/BaseIcon'; +import * as icon from '@mdi/js'; +export default function LandingPage() { const title = 'Trial Tracker' - // Fetch Pexels image/video - useEffect(() => { - async function fetchData() { - const image = await getPexelsImage(); - const video = await getPexelsVideo(); - setIllustrationImage(image); - setIllustrationVideo(video); - } - fetchData(); - }, []); + return ( +
+ + {getPageTitle('Trial Tracker - Research Simplified')} + - const imageBlock = (image) => ( -
- + {/* Navigation */} + + + {/* Hero Section */} +
+
+
+
+
+
+ + Advanced Multi-tenant Solution +
+

+ Research Tracked Effortlessly. +

+

+ The ultimate tool for research trials. Manage projects, monitor trials, and capture field data in real-time with our beautiful, intuitive interface. +

+
+ + Launch App + + + Learn More + +
+
+
+
+
+ + Dashboard Preview +
+
+ {/* Decorative elements */} +
+
+
+
+
+
+ + {/* Features Section */} +
+
+
+

Powerful Capabilities

+

Designed for modern field research.

+
+ +
+ {[ + { + title: 'Activity Feed', + description: 'Stay updated with a social-media style feed of everything happening across your trials.', + icon: icon.mdiTimelineTextOutline, + color: 'bg-blue-50 text-blue-600' + }, + { + title: 'Dynamic Forms', + description: 'Configure custom fields and forms for different trial types like plant vs chemical.', + icon: icon.mdiClipboardTextOutline, + color: 'bg-emerald-50 text-emerald-600' + }, + { + title: 'Track-IT', + description: 'Analyze all completed forms and data in a powerful, filtered table view.', + icon: icon.mdiDatabaseOutline, + color: 'bg-teal-50 text-teal-600' + }, + { + title: '3-Tier Locations', + description: 'Organize your data by Farm, Block, and Row for precise spatial tracking.', + icon: icon.mdiMapMarkerOutline, + color: 'bg-orange-50 text-orange-600' + }, + { + title: 'Project Management', + description: 'Categorize trials into projects with custom start/end dates and status tracking.', + icon: icon.mdiFolderOutline, + color: 'bg-purple-50 text-purple-600' + }, + { + title: 'Multi-tenant', + description: 'Seamlessly toggle between different farms as a Global Admin with full data isolation.', + icon: icon.mdiDomain, + color: 'bg-indigo-50 text-indigo-600' + } + ].map((feature, i) => ( +
+
+ +
+

{feature.title}

+

{feature.description}

+
+ ))} +
+
+
+ + {/* CTA Section */} +
+
+
+
+
+

Ready to transform your trial tracking?

+

+ Join hundreds of researchers who trust Trial Tracker for their daily field operations. +

+
+ + Get Started Now + +
+
+
+
+
+ + {/* Footer */} +
+
+
+
+
+ +
+ {title} +
+ +

+ © 2026 Trial Tracker. Built for Research. +

+
+
+
); - - const videoBlock = (video) => { - if (video?.video_files?.length > 0) { - return ( -
- - -
) - } - }; - - return ( -
- - {getPageTitle('Starter Page')} - - - -
- {contentType === 'image' && contentPosition !== 'background' - ? imageBlock(illustrationImage) - : null} - {contentType === 'video' && contentPosition !== 'background' - ? videoBlock(illustrationVideo) - : null} -
- - - -
-

This is a React.js/Node.js app generated by the Flatlogic Web App Generator

-

For guides and documentation please check - your local README.md and the Flatlogic documentation

-
- - - - - -
-
-
-
-
-

© 2026 {title}. All rights reserved

- - Privacy Policy - -
- -
- ); } -Starter.getLayout = function getLayout(page: ReactElement) { - return {page}; -}; - +LandingPage.getLayout = function getLayout(page: ReactElement) { + return {page}; +}; \ No newline at end of file diff --git a/frontend/src/pages/login.tsx b/frontend/src/pages/login.tsx index b7b5ac0..91270a2 100644 --- a/frontend/src/pages/login.tsx +++ b/frontend/src/pages/login.tsx @@ -1,5 +1,3 @@ - - import React, { useEffect, useState } from 'react'; import type { ReactElement } from 'react'; import Head from 'next/head'; @@ -62,10 +60,14 @@ export default function Login() { dispatch(findMe()); } }, [token, dispatch]); - // Redirect to dashboard if user is logged in + // Redirect based on role if user is logged in useEffect(() => { if (currentUser?.id) { - router.push('/dashboard'); + if (currentUser.app_role?.globalAccess) { + router.push('/tenants/tenants-list'); + } else { + router.push('/dashboard'); + } } }, [currentUser?.id, router]); // Show error message if there is one @@ -280,4 +282,4 @@ export default function Login() { Login.getLayout = function getLayout(page: ReactElement) { return {page}; -}; +}; \ No newline at end of file diff --git a/frontend/src/pages/search.tsx b/frontend/src/pages/search.tsx index 00f5168..65ea1b7 100644 --- a/frontend/src/pages/search.tsx +++ b/frontend/src/pages/search.tsx @@ -1,10 +1,7 @@ -import React, { ReactElement, useEffect, useState } from 'react'; +import { ReactElement, useEffect, useState } from 'react'; import Head from 'next/head'; import 'react-datepicker/dist/react-datepicker.css'; -import { useAppDispatch } from '../stores/hooks'; - -import { useAppSelector } from '../stores/hooks'; - +import { useAppDispatch, useAppSelector } from '../stores/hooks'; import { useRouter } from 'next/router'; import LayoutAuthenticated from '../layouts/Authenticated'; import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton'; @@ -93,4 +90,4 @@ SearchView.getLayout = function getLayout(page: ReactElement) { ); }; -export default SearchView; +export default SearchView; \ No newline at end of file diff --git a/frontend/src/pages/settings/company-preferences.tsx b/frontend/src/pages/settings/company-preferences.tsx new file mode 100644 index 0000000..b8eaea5 --- /dev/null +++ b/frontend/src/pages/settings/company-preferences.tsx @@ -0,0 +1,181 @@ +import { mdiChartTimelineVariant, mdiCogOutline } from '@mdi/js' +import Head from 'next/head' +import React, { ReactElement, useEffect, useState } from 'react' +import { useRouter } from 'next/router' +import { Field, Form, Formik } from 'formik' + +import CardBox from '../../components/CardBox' +import LayoutAuthenticated from '../../layouts/Authenticated' +import SectionMain from '../../components/SectionMain' +import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton' +import { getPageTitle } from '../../config' +import FormField from '../../components/FormField' +import BaseDivider from '../../components/BaseDivider' +import BaseButton from '../../components/BaseButton' + +import { update, fetch } from '../../stores/organizations/organizationsSlice' +import { useAppDispatch, useAppSelector } from '../../stores/hooks' +import { findMe } from '../../stores/authSlice' +import axios from 'axios' + +const CompanyPreferencesPage = () => { + const router = useRouter() + const dispatch = useAppDispatch() + const { currentUser } = useAppSelector((state) => state.auth) + const { organizations } = useAppSelector((state) => state.organizations) + + const [initialValues, setInitialValues] = useState({ + name: '', + address: '', + city: '', + state: '', + country: '', + zip: '', + navOrientation: 'side', + }) + + const [activeOrganizationId, setActiveOrganizationId] = useState(null) + + // Determine the organization ID to use + useEffect(() => { + if (currentUser?.organizations?.id || currentUser?.organizationsId) { + setActiveOrganizationId(currentUser?.organizations?.id || currentUser?.organizationsId); + } else { + // If user has no organization linked, try to fetch the first available one if they are an admin + const fetchAnyOrganization = async () => { + try { + const response = await axios.get('/organizations?limit=1') + if (response.data.rows && response.data.rows.length > 0) { + setActiveOrganizationId(response.data.rows[0].id) + } + } catch (err) { + console.error('Failed to fetch organization for admin:', err) + } + } + fetchAnyOrganization() + } + }, [currentUser]) + + useEffect(() => { + if (activeOrganizationId) { + dispatch(fetch({ id: activeOrganizationId })) + } + }, [activeOrganizationId, dispatch]) + + useEffect(() => { + // If organizations is an array (from list fetch) or object (from single fetch) + const orgData = Array.isArray(organizations) ? organizations[0] : organizations; + + if (orgData && typeof orgData === 'object') { + setInitialValues({ + name: orgData.name || '', + address: orgData.address || '', + city: orgData.city || '', + state: orgData.state || '', + country: orgData.country || '', + zip: orgData.zip || '', + navOrientation: orgData.navOrientation || 'side', + }) + } + }, [organizations]) + + const handleSubmit = async (values: any) => { + if (activeOrganizationId) { + try { + const resultAction = await dispatch(update({ id: activeOrganizationId, data: values })) + if (update.fulfilled.match(resultAction)) { + // Update current user to reflect changes in orientation immediately + await dispatch(findMe()) + // Reload to ensure everything is in sync + router.reload(); + } else if (update.rejected.match(resultAction)) { + console.error('Update rejected:', resultAction.payload); + alert('Error updating company preferences: ' + (resultAction.payload as any)?.message || 'Unknown error'); + } + } catch (err) { + console.error('Unexpected error:', err); + } + } else { + alert('No organization found to update. Please link your user to an organization.'); + } + } + + return ( + <> + + {getPageTitle('Company Preferences')} + + + + {''} + + + + {({ isSubmitting }) => ( +
+ + + + + + + + +
+ + + + + + +
+ +
+ + + + + + +
+ + + + + + + + + + + +
+ +
+ + )} +
+
+
+ + ) +} + +CompanyPreferencesPage.getLayout = function getLayout(page: ReactElement) { + return ( + + {page} + + ) +} + +export default CompanyPreferencesPage \ No newline at end of file