From 716d1e45e3f81924990018c539d1c02b6c643c8f Mon Sep 17 00:00:00 2001 From: Flatlogic Bot Date: Sat, 4 Apr 2026 17:27:21 +0000 Subject: [PATCH] Autosave: 20260404-172721 --- backend/src/db/api/properties.js | 22 +- backend/src/db/api/unit_types.js | 23 +- ...ministrator-user-management-permissions.js | 56 +++ backend/src/routes/properties.js | 35 +- backend/src/routes/unit_types.js | 35 +- backend/src/services/users.js | 92 +++- .../Organizations/CardOrganizations.tsx | 14 +- .../Organizations/ListOrganizations.tsx | 13 +- .../Organizations/TableOrganizations.tsx | 8 +- .../configureOrganizationsCols.tsx | 3 +- frontend/src/components/Users/CardUsers.tsx | 15 +- frontend/src/components/Users/ListUsers.tsx | 18 +- frontend/src/components/Users/TableUsers.tsx | 32 +- .../components/Users/configureUsersCols.tsx | 398 ++++++++---------- frontend/src/helpers/manageableUsers.ts | 33 ++ frontend/src/helpers/organizationTenants.ts | 90 +++- .../organizations/organizations-edit.tsx | 4 +- .../organizations/organizations-view.tsx | 4 +- frontend/src/pages/users/users-edit.tsx | 8 + frontend/src/pages/users/users-list.tsx | 215 ++++------ frontend/src/pages/users/users-table.tsx | 34 +- frontend/src/pages/users/users-view.tsx | 11 +- 22 files changed, 662 insertions(+), 501 deletions(-) create mode 100644 backend/src/db/migrations/20260404171000-grant-administrator-user-management-permissions.js create mode 100644 frontend/src/helpers/manageableUsers.ts diff --git a/backend/src/db/api/properties.js b/backend/src/db/api/properties.js index 56ed5e8..4c5f83a 100644 --- a/backend/src/db/api/properties.js +++ b/backend/src/db/api/properties.js @@ -1,7 +1,6 @@ const db = require('../models'); const FileDBApi = require('./file'); -const crypto = require('crypto'); const Utils = require('../utils'); @@ -188,7 +187,6 @@ module.exports = class PropertiesDBApi { 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 properties = await db.properties.findByPk(id, {}, {transaction}); @@ -451,10 +449,6 @@ module.exports = class PropertiesDBApi { offset = currentPage * limit; - const orderBy = null; - - const transaction = (options && options.transaction) || undefined; - let include = [ { @@ -764,16 +758,14 @@ module.exports = class PropertiesDBApi { } static async findAllAutocomplete(query, limit, offset, globalAccess, organizationId,) { - let where = {}; - - + const filters = []; + if (!globalAccess && organizationId) { - where.organizationId = organizationId; + filters.push({ organizationsId: organizationId }); } - if (query) { - where = { + filters.push({ [Op.or]: [ { ['id']: Utils.uuid(query) }, Utils.ilike( @@ -782,9 +774,13 @@ module.exports = class PropertiesDBApi { query, ), ], - }; + }); } + const where = filters.length > 1 + ? { [Op.and]: filters } + : (filters[0] || {}); + const records = await db.properties.findAll({ attributes: [ 'id', 'name' ], where, diff --git a/backend/src/db/api/unit_types.js b/backend/src/db/api/unit_types.js index 7e12c25..550d6a7 100644 --- a/backend/src/db/api/unit_types.js +++ b/backend/src/db/api/unit_types.js @@ -1,7 +1,5 @@ const db = require('../models'); -const FileDBApi = require('./file'); -const crypto = require('crypto'); const Utils = require('../utils'); @@ -176,7 +174,6 @@ module.exports = class Unit_typesDBApi { 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 unit_types = await db.unit_types.findByPk(id, {}, {transaction}); @@ -404,10 +401,6 @@ module.exports = class Unit_typesDBApi { offset = currentPage * limit; - const orderBy = null; - - const transaction = (options && options.transaction) || undefined; - let include = [ { @@ -771,16 +764,14 @@ module.exports = class Unit_typesDBApi { } static async findAllAutocomplete(query, limit, offset, globalAccess, organizationId,) { - let where = {}; - - + const filters = []; + if (!globalAccess && organizationId) { - where.organizationId = organizationId; + filters.push({ organizationsId: organizationId }); } - if (query) { - where = { + filters.push({ [Op.or]: [ { ['id']: Utils.uuid(query) }, Utils.ilike( @@ -789,9 +780,13 @@ module.exports = class Unit_typesDBApi { query, ), ], - }; + }); } + const where = filters.length > 1 + ? { [Op.and]: filters } + : (filters[0] || {}); + const records = await db.unit_types.findAll({ attributes: [ 'id', 'name' ], where, diff --git a/backend/src/db/migrations/20260404171000-grant-administrator-user-management-permissions.js b/backend/src/db/migrations/20260404171000-grant-administrator-user-management-permissions.js new file mode 100644 index 0000000..46e7420 --- /dev/null +++ b/backend/src/db/migrations/20260404171000-grant-administrator-user-management-permissions.js @@ -0,0 +1,56 @@ +'use strict'; + +const USER_MANAGEMENT_PERMISSIONS = [ + 'CREATE_USERS', + 'READ_USERS', + 'UPDATE_USERS', + 'DELETE_USERS', + 'READ_ROLES', +]; + +module.exports = { + async up(queryInterface) { + const sequelize = queryInterface.sequelize; + const [roles] = await sequelize.query( + `SELECT "id" FROM "roles" WHERE "name" = 'Administrator' LIMIT 1;`, + ); + + if (!roles.length) { + return; + } + + const [permissions] = await sequelize.query( + `SELECT "id", "name" FROM "permissions" WHERE "name" IN (:permissionNames);`, + { + replacements: { permissionNames: USER_MANAGEMENT_PERMISSIONS }, + }, + ); + + const now = new Date(); + + for (const permission of permissions) { + await sequelize.query( + `INSERT INTO "rolesPermissionsPermissions" ("createdAt", "updatedAt", "roles_permissionsId", "permissionId") + SELECT :createdAt, :updatedAt, :roleId, :permissionId + WHERE NOT EXISTS ( + SELECT 1 + FROM "rolesPermissionsPermissions" + WHERE "roles_permissionsId" = :roleId + AND "permissionId" = :permissionId + );`, + { + replacements: { + createdAt: now, + updatedAt: now, + roleId: roles[0].id, + permissionId: permission.id, + }, + }, + ); + } + }, + + async down() { + // Intentionally left blank. This protects live permission assignments. + }, +}; diff --git a/backend/src/routes/properties.js b/backend/src/routes/properties.js index fc78245..c43d894 100644 --- a/backend/src/routes/properties.js +++ b/backend/src/routes/properties.js @@ -5,9 +5,6 @@ const PropertiesService = require('../services/properties'); const PropertiesDBApi = require('../db/api/properties'); const wrapAsync = require('../helpers').wrapAsync; -const config = require('../config'); - - const router = express.Router(); const { parse } = require('json2csv'); @@ -15,8 +12,24 @@ const { parse } = require('json2csv'); const { checkCrudPermissions, + checkPermissions, } = require('../middlewares/check-permissions'); +router.get('/autocomplete', checkPermissions('READ_BOOKING_REQUESTS'), async (req, res) => { + const globalAccess = req.currentUser.app_role.globalAccess; + const organizationId = req.currentUser.organization?.id; + + const payload = await PropertiesDBApi.findAllAutocomplete( + req.query.query, + req.query.limit, + req.query.offset, + globalAccess, + organizationId, + ); + + res.status(200).send(payload); +}); + router.use(checkCrudPermissions('properties')); @@ -394,22 +407,6 @@ router.get('/count', wrapAsync(async (req, res) => { * 500: * description: Some server error */ -router.get('/autocomplete', async (req, res) => { - - const globalAccess = req.currentUser.app_role.globalAccess; - - const organizationId = req.currentUser.organization?.id - - - const payload = await PropertiesDBApi.findAllAutocomplete( - req.query.query, - req.query.limit, - req.query.offset, - globalAccess, organizationId, - ); - - res.status(200).send(payload); -}); /** * @swagger diff --git a/backend/src/routes/unit_types.js b/backend/src/routes/unit_types.js index 94e36a5..fc9c413 100644 --- a/backend/src/routes/unit_types.js +++ b/backend/src/routes/unit_types.js @@ -5,9 +5,6 @@ const Unit_typesService = require('../services/unit_types'); const Unit_typesDBApi = require('../db/api/unit_types'); const wrapAsync = require('../helpers').wrapAsync; -const config = require('../config'); - - const router = express.Router(); const { parse } = require('json2csv'); @@ -15,8 +12,24 @@ const { parse } = require('json2csv'); const { checkCrudPermissions, + checkPermissions, } = require('../middlewares/check-permissions'); +router.get('/autocomplete', checkPermissions('READ_BOOKING_REQUESTS'), async (req, res) => { + const globalAccess = req.currentUser.app_role.globalAccess; + const organizationId = req.currentUser.organization?.id; + + const payload = await Unit_typesDBApi.findAllAutocomplete( + req.query.query, + req.query.limit, + req.query.offset, + globalAccess, + organizationId, + ); + + res.status(200).send(payload); +}); + router.use(checkCrudPermissions('unit_types')); @@ -403,22 +416,6 @@ router.get('/count', wrapAsync(async (req, res) => { * 500: * description: Some server error */ -router.get('/autocomplete', async (req, res) => { - - const globalAccess = req.currentUser.app_role.globalAccess; - - const organizationId = req.currentUser.organization?.id - - - const payload = await Unit_typesDBApi.findAllAutocomplete( - req.query.query, - req.query.limit, - req.query.offset, - globalAccess, organizationId, - ); - - res.status(200).send(payload); -}); /** * @swagger diff --git a/backend/src/services/users.js b/backend/src/services/users.js index 89c4cba..968e76e 100644 --- a/backend/src/services/users.js +++ b/backend/src/services/users.js @@ -1,6 +1,6 @@ const db = require('../db/models'); const UsersDBApi = require('../db/api/users'); -const processFile = require("../middlewares/upload"); +const processFile = require('../middlewares/upload'); const ValidationError = require('./notifications/errors/validation'); const csv = require('csv-parser'); const config = require('../config'); @@ -35,6 +35,32 @@ async function findDefaultRole(transaction) { }); } +function isAdminOrSuperAdmin(currentUser) { + return [config.roles.admin, config.roles.super_admin].includes(currentUser?.app_role?.name); +} + +function isHighTrustRole(roleName) { + return Boolean(roleName) && HIGH_TRUST_ROLE_NAMES.includes(roleName); +} + +async function assertManageableExistingUser(user, currentUser) { + if (!user) { + throw new ValidationError('iam.errors.userNotFound'); + } + + if (!isAdminOrSuperAdmin(currentUser)) { + throw new ValidationError('errors.forbidden.message'); + } + + if (currentUser?.id === user.id) { + throw new ValidationError('iam.errors.deletingHimself'); + } + + if (!currentUser?.app_role?.globalAccess && isHighTrustRole(user?.app_role?.name)) { + throw new ValidationError('errors.forbidden.message'); + } +} + async function normalizeManagedUserPayload(data, currentUser, transaction, existingUser = null) { const payload = { ...data }; const isSuperAdmin = Boolean(currentUser?.app_role?.globalAccess); @@ -57,11 +83,11 @@ async function normalizeManagedUserPayload(data, currentUser, transaction, exist throw new ValidationError('errors.forbidden.message'); } - if (existingUser?.app_role?.name && !isSuperAdmin && HIGH_TRUST_ROLE_NAMES.includes(existingUser.app_role.name)) { + if (existingUser?.app_role?.name && !isSuperAdmin && isHighTrustRole(existingUser.app_role.name)) { throw new ValidationError('errors.forbidden.message'); } - if (selectedRole?.name && !isSuperAdmin && HIGH_TRUST_ROLE_NAMES.includes(selectedRole.name)) { + if (selectedRole?.name && !isSuperAdmin && isHighTrustRole(selectedRole.name)) { throw new ValidationError('errors.forbidden.message'); } @@ -80,8 +106,8 @@ async function normalizeManagedUserPayload(data, currentUser, transaction, exist module.exports = class UsersService { static async create(data, currentUser, sendInvitationEmails = true, host) { const transaction = await db.sequelize.transaction(); - let email = data.email; - let emailsToInvite = []; + const email = data.email; + const emailsToInvite = []; try { if (!email) { @@ -171,6 +197,16 @@ module.exports = class UsersService { throw new ValidationError('iam.errors.userNotFound'); } + if (!currentUser?.app_role?.globalAccess) { + if (currentUser?.id === id) { + throw new ValidationError('errors.forbidden.message'); + } + + if (isHighTrustRole(user?.app_role?.name)) { + throw new ValidationError('errors.forbidden.message'); + } + } + const normalizedPayload = await normalizeManagedUserPayload(data, currentUser, transaction, user); const updatedUser = await UsersDBApi.update( @@ -195,22 +231,9 @@ module.exports = class UsersService { const transaction = await db.sequelize.transaction(); try { - if (currentUser.id === id) { - throw new ValidationError('iam.errors.deletingHimself'); - } - - if (currentUser.app_role?.name !== config.roles.admin && currentUser.app_role?.name !== config.roles.super_admin) { - throw new ValidationError('errors.forbidden.message'); - } - const user = await UsersDBApi.findBy({ id }, { transaction }); - if (!user) { - throw new ValidationError('iam.errors.userNotFound'); - } - if (!currentUser.app_role?.globalAccess && HIGH_TRUST_ROLE_NAMES.includes(user?.app_role?.name)) { - throw new ValidationError('errors.forbidden.message'); - } + await assertManageableExistingUser(user, currentUser); await UsersDBApi.remove(id, { currentUser, @@ -223,4 +246,35 @@ module.exports = class UsersService { throw error; } } + + static async deleteByIds(ids, currentUser) { + const transaction = await db.sequelize.transaction(); + + try { + const normalizedIds = Array.from(new Set((Array.isArray(ids) ? ids : []).filter(Boolean))); + + if (!normalizedIds.length) { + return []; + } + + const users = await Promise.all( + normalizedIds.map((id) => UsersDBApi.findBy({ id }, { transaction })), + ); + + for (const user of users) { + await assertManageableExistingUser(user, currentUser); + } + + const deletedUsers = await UsersDBApi.deleteByIds(normalizedIds, { + currentUser, + transaction, + }); + + await transaction.commit(); + return deletedUsers; + } catch (error) { + await transaction.rollback(); + throw error; + } + } }; diff --git a/frontend/src/components/Organizations/CardOrganizations.tsx b/frontend/src/components/Organizations/CardOrganizations.tsx index 63bbe8f..716528e 100644 --- a/frontend/src/components/Organizations/CardOrganizations.tsx +++ b/frontend/src/components/Organizations/CardOrganizations.tsx @@ -34,10 +34,11 @@ const CardOrganizations = ({ const currentUser = useAppSelector((state) => state.auth.currentUser) const hasUpdatePermission = hasPermission(currentUser, 'UPDATE_ORGANIZATIONS') + const canViewTenants = hasPermission(currentUser, 'READ_TENANTS') const [linkedTenantSummaries, setLinkedTenantSummaries] = useState({}) useEffect(() => { - if (!Array.isArray(organizations) || !organizations.length) { + if (!Array.isArray(organizations) || !organizations.length || !canViewTenants) { setLinkedTenantSummaries({}) return } @@ -60,7 +61,7 @@ const CardOrganizations = ({ return () => { isActive = false } - }, [organizations]) + }, [canViewTenants, organizations]) return (
@@ -70,6 +71,11 @@ const CardOrganizations = ({ organizations.map((item) => { const linkedSummary = linkedTenantSummaries[item.id] const linkedCount = linkedSummary?.count || 0 + const linkedTenantLabel = !canViewTenants + ? 'Tenant access restricted' + : linkedCount + ? `${linkedCount} linked tenant${linkedCount === 1 ? '' : 's'}` + : 'No tenant link' return (
  • - {linkedCount ? `${linkedCount} linked tenant${linkedCount === 1 ? '' : 's'}` : 'No tenant link'} + {linkedTenantLabel}
  • @@ -117,7 +123,7 @@ const CardOrganizations = ({
    diff --git a/frontend/src/components/Organizations/ListOrganizations.tsx b/frontend/src/components/Organizations/ListOrganizations.tsx index 4c31b6e..3052a23 100644 --- a/frontend/src/components/Organizations/ListOrganizations.tsx +++ b/frontend/src/components/Organizations/ListOrganizations.tsx @@ -28,7 +28,7 @@ const ListOrganizations = ({ organizations, loading, onDelete, currentPage, numP const [linkedTenantSummaries, setLinkedTenantSummaries] = useState({}) useEffect(() => { - if (!Array.isArray(organizations) || !organizations.length) { + if (!Array.isArray(organizations) || !organizations.length || !canViewTenants) { setLinkedTenantSummaries({}) return } @@ -51,7 +51,7 @@ const ListOrganizations = ({ organizations, loading, onDelete, currentPage, numP return () => { isActive = false } - }, [organizations]) + }, [canViewTenants, organizations]) return ( <> @@ -61,6 +61,11 @@ const ListOrganizations = ({ organizations, loading, onDelete, currentPage, numP organizations.map((item) => { const linkedSummary = linkedTenantSummaries[item.id] const linkedCount = linkedSummary?.count || 0 + const linkedTenantLabel = !canViewTenants + ? 'Tenant access restricted' + : linkedCount + ? `${linkedCount} linked tenant${linkedCount === 1 ? '' : 's'}` + : 'No tenant link' return (
    @@ -76,7 +81,7 @@ const ListOrganizations = ({ organizations, loading, onDelete, currentPage, numP Organization - {linkedCount ? `${linkedCount} linked tenant${linkedCount === 1 ? '' : 's'}` : 'No tenant link'} + {linkedTenantLabel}
    @@ -91,7 +96,7 @@ const ListOrganizations = ({ organizations, loading, onDelete, currentPage, numP
    diff --git a/frontend/src/components/Organizations/TableOrganizations.tsx b/frontend/src/components/Organizations/TableOrganizations.tsx index 0b03f34..7625319 100644 --- a/frontend/src/components/Organizations/TableOrganizations.tsx +++ b/frontend/src/components/Organizations/TableOrganizations.tsx @@ -35,6 +35,7 @@ const TableSampleOrganizations = ({ filterItems, setFilterItems, filters, showGr const [columns, setColumns] = useState([]); const [selectedRows, setSelectedRows] = useState([]); const [linkedTenantSummaries, setLinkedTenantSummaries] = useState({}); + const canViewTenants = hasPermission(currentUser, 'READ_TENANTS'); const [sortModel, setSortModel] = useState([ { field: '', @@ -175,7 +176,7 @@ const TableSampleOrganizations = ({ filterItems, setFilterItems, filters, showGr useEffect(() => { const organizationRows = Array.isArray(organizations) ? organizations : []; - if (!organizationRows.length) { + if (!organizationRows.length || !canViewTenants) { setLinkedTenantSummaries({}); return; } @@ -198,7 +199,7 @@ const TableSampleOrganizations = ({ filterItems, setFilterItems, filters, showGr return () => { isActive = false; }; - }, [organizations]); + }, [canViewTenants, organizations]); useEffect(() => { if (!currentUser) return; @@ -208,8 +209,9 @@ const TableSampleOrganizations = ({ filterItems, setFilterItems, filters, showGr `organizations`, currentUser, linkedTenantSummaries, + canViewTenants, ).then((newCols) => setColumns(newCols)); - }, [currentUser, linkedTenantSummaries]); + }, [canViewTenants, currentUser, linkedTenantSummaries]); diff --git a/frontend/src/components/Organizations/configureOrganizationsCols.tsx b/frontend/src/components/Organizations/configureOrganizationsCols.tsx index 9cad756..c54549a 100644 --- a/frontend/src/components/Organizations/configureOrganizationsCols.tsx +++ b/frontend/src/components/Organizations/configureOrganizationsCols.tsx @@ -13,6 +13,7 @@ export const loadColumns = async ( _entityName: string, user, linkedTenantSummaries: OrganizationTenantSummaryMap = {}, + canViewTenants = true, ) => { const hasUpdatePermission = hasPermission(user, 'UPDATE_ORGANIZATIONS') @@ -42,7 +43,7 @@ export const loadColumns = async ( ), diff --git a/frontend/src/components/Users/CardUsers.tsx b/frontend/src/components/Users/CardUsers.tsx index d013f7a..8f51332 100644 --- a/frontend/src/components/Users/CardUsers.tsx +++ b/frontend/src/components/Users/CardUsers.tsx @@ -9,6 +9,7 @@ import LoadingSpinner from "../LoadingSpinner"; import Link from 'next/link'; import {hasPermission} from "../../helpers/userPermissions"; +import { canManagePlatformUserFields, canManageUserRecord } from '../../helpers/manageableUsers'; type Props = { @@ -38,7 +39,7 @@ const CardUsers = ({ const currentUser = useAppSelector((state) => state.auth.currentUser); const hasUpdatePermission = hasPermission(currentUser, 'UPDATE_USERS') - + const canManagePlatformFields = canManagePlatformUserFields(currentUser) return (
    @@ -47,7 +48,10 @@ const CardUsers = ({ role='list' className='grid grid-cols-1 gap-x-6 gap-y-8 lg:grid-cols-3 2xl:grid-cols-4 xl:gap-x-8' > - {!loading && users.map((item, index) => ( + {!loading && users.map((item) => { + const canManageUser = hasUpdatePermission && canManageUserRecord(currentUser, item) + + return (
  • @@ -190,7 +194,7 @@ const CardUsers = ({
    Organizations
    - { dataFormatter.organizationsOneListFormatter(item.organizations) } + { canManagePlatformFields ? dataFormatter.organizationsOneListFormatter(item.organizations) : 'Pinned to your workspace' }
    @@ -199,7 +203,8 @@ const CardUsers = ({ - ))} + ) + })} {!loading && users.length === 0 && (

    No data to display

    diff --git a/frontend/src/components/Users/ListUsers.tsx b/frontend/src/components/Users/ListUsers.tsx index dce31a4..a0c494e 100644 --- a/frontend/src/components/Users/ListUsers.tsx +++ b/frontend/src/components/Users/ListUsers.tsx @@ -10,6 +10,7 @@ import LoadingSpinner from "../LoadingSpinner"; import Link from 'next/link'; import {hasPermission} from "../../helpers/userPermissions"; +import { canManagePlatformUserFields, canManageUserRecord } from '../../helpers/manageableUsers'; type Props = { @@ -25,7 +26,8 @@ const ListUsers = ({ users, loading, onDelete, currentPage, numPages, onPageChan const currentUser = useAppSelector((state) => state.auth.currentUser); const hasUpdatePermission = hasPermission(currentUser, 'UPDATE_USERS') - + const canManagePlatformFields = canManagePlatformUserFields(currentUser) + const corners = useAppSelector((state) => state.style.corners); const bgColor = useAppSelector((state) => state.style.cardsColor); @@ -34,7 +36,10 @@ const ListUsers = ({ users, loading, onDelete, currentPage, numPages, onPageChan <>
    {loading && } - {!loading && users.map((item) => ( + {!loading && users.map((item) => { + const canManageUser = hasUpdatePermission && canManageUserRecord(currentUser, item) + + return (
    @@ -116,7 +121,7 @@ const ListUsers = ({ users, loading, onDelete, currentPage, numPages, onPageChan

    Custom Permissions

    -

    { dataFormatter.permissionsManyListFormatter(item.custom_permissions).join(', ')}

    +

    {canManagePlatformFields ? dataFormatter.permissionsManyListFormatter(item.custom_permissions).join(', ') : '—'}

    @@ -124,7 +129,7 @@ const ListUsers = ({ users, loading, onDelete, currentPage, numPages, onPageChan

    Organizations

    -

    { dataFormatter.organizationsOneListFormatter(item.organizations) }

    +

    {canManagePlatformFields ? dataFormatter.organizationsOneListFormatter(item.organizations) : 'Pinned to your workspace'}

    @@ -136,13 +141,14 @@ const ListUsers = ({ users, loading, onDelete, currentPage, numPages, onPageChan pathEdit={`/users/users-edit/?id=${item.id}`} pathView={`/users/users-view/?id=${item.id}`} - hasUpdatePermission={hasUpdatePermission} + hasUpdatePermission={canManageUser} />
    - ))} + ) + })} {!loading && users.length === 0 && (

    No data to display

    diff --git a/frontend/src/components/Users/TableUsers.tsx b/frontend/src/components/Users/TableUsers.tsx index 5de5cd9..0d61011 100644 --- a/frontend/src/components/Users/TableUsers.tsx +++ b/frontend/src/components/Users/TableUsers.tsx @@ -16,6 +16,7 @@ import {loadColumns} from "./configureUsersCols"; import _ from 'lodash'; import dataFormatter from '../../helpers/dataFormatter' import {dataGridStyles} from "../../styles"; +import { canManageUserRecord } from '../../helpers/manageableUsers' @@ -197,8 +198,25 @@ const TableSampleUsers = ({ filterItems, setFilterItems, filters, showGrid }) => }; const onDeleteRows = async (selectedRows) => { - await dispatch(deleteItemsByIds(selectedRows)); + const manageableRowIds = selectedRows.filter((selectedRowId) => + canManageUserRecord( + currentUser, + users.find((user) => user.id === selectedRowId), + ), + ); + + if (!manageableRowIds.length) { + notify('warning', 'Only customer and concierge accounts in your workspace can be deleted here.'); + return; + } + + if (manageableRowIds.length !== selectedRows.length) { + notify('warning', 'Protected users were skipped from bulk delete.'); + } + + await dispatch(deleteItemsByIds(manageableRowIds)); await loadData(0); + setSelectedRows([]); }; const controlClasses = @@ -207,6 +225,7 @@ const TableSampleUsers = ({ filterItems, setFilterItems, filters, showGrid }) => 'dark:bg-slate-800 border'; + const dataGrid = (
    rowHeight={64} sx={dataGridStyles} className={'datagrid--table'} - getRowClassName={() => `datagrid--row`} + getRowClassName={(params) => `datagrid--row ${canManageUserRecord(currentUser, params.row) ? '' : 'opacity-60'}`} rows={users ?? []} columns={columns} initialState={{ @@ -225,10 +244,17 @@ const TableSampleUsers = ({ filterItems, setFilterItems, filters, showGrid }) => }, }} disableRowSelectionOnClick + isRowSelectable={(params) => canManageUserRecord(currentUser, params.row)} + isCellEditable={(params) => Boolean(params.colDef.editable) && canManageUserRecord(currentUser, params.row)} onProcessRowUpdateError={(params) => { - console.log('Error', params); + console.error('Users grid update error:', params); }} processRowUpdate={async (newRow, oldRow) => { + if (!canManageUserRecord(currentUser, oldRow)) { + notify('warning', 'That account is protected and cannot be changed from this workspace.'); + return oldRow; + } + const data = dataFormatter.dataGridEditFormatter(newRow); try { diff --git a/frontend/src/components/Users/configureUsersCols.tsx b/frontend/src/components/Users/configureUsersCols.tsx index dc7e770..0ea4e0c 100644 --- a/frontend/src/components/Users/configureUsersCols.tsx +++ b/frontend/src/components/Users/configureUsersCols.tsx @@ -1,229 +1,181 @@ -import React from 'react'; -import BaseIcon from '../BaseIcon'; -import { mdiEye, mdiTrashCan, mdiPencilOutline } from '@mdi/js'; -import axios from 'axios'; -import { - GridActionsCellItem, - GridRowParams, - GridValueGetterParams, -} from '@mui/x-data-grid'; -import ImageField from '../ImageField'; -import {saveFile} from "../../helpers/fileSaver"; +import React from 'react' +import axios from 'axios' +import { GridRowParams, GridValueGetterParams } from '@mui/x-data-grid' +import ImageField from '../ImageField' import dataFormatter from '../../helpers/dataFormatter' -import DataGridMultiSelect from "../DataGridMultiSelect"; -import ListActionsPopover from '../ListActionsPopover'; +import DataGridMultiSelect from '../DataGridMultiSelect' +import ListActionsPopover from '../ListActionsPopover' +import { hasPermission } from '../../helpers/userPermissions' +import { canManagePlatformUserFields, canManageUserRecord } from '../../helpers/manageableUsers' -import {hasPermission} from "../../helpers/userPermissions"; - -type Params = (id: string) => void; +type Params = (id: string) => void export const loadColumns = async ( - onDelete: Params, - entityName: string, - - user - + onDelete: Params, + entityName: string, + user, ) => { - async function callOptionsApi(entityName: string) { - - if (!hasPermission(user, 'READ_' + entityName.toUpperCase())) return []; - - try { - const data = await axios(`/${entityName}/autocomplete?limit=100`); - return data.data; - } catch (error) { - console.log(error); - return []; - } + const canManagePlatformFields = canManagePlatformUserFields(user) + + async function callOptionsApi(targetEntityName: string) { + if (!hasPermission(user, `READ_${targetEntityName.toUpperCase()}`)) { + return [] } - - const hasUpdatePermission = hasPermission(user, 'UPDATE_USERS') - - return [ - - { - field: 'firstName', - headerName: 'First Name', - flex: 1, - minWidth: 120, - filterable: false, - headerClassName: 'datagrid--header', - cellClassName: 'datagrid--cell', - - - editable: hasUpdatePermission, - - - }, - - { - field: 'lastName', - headerName: 'Last Name', - flex: 1, - minWidth: 120, - filterable: false, - headerClassName: 'datagrid--header', - cellClassName: 'datagrid--cell', - - - editable: hasUpdatePermission, - - - }, - - { - field: 'phoneNumber', - headerName: 'Phone Number', - flex: 1, - minWidth: 120, - filterable: false, - headerClassName: 'datagrid--header', - cellClassName: 'datagrid--cell', - - - editable: hasUpdatePermission, - - - }, - - { - field: 'email', - headerName: 'E-Mail', - flex: 1, - minWidth: 120, - filterable: false, - headerClassName: 'datagrid--header', - cellClassName: 'datagrid--cell', - - - editable: hasUpdatePermission, - - - }, - - { - field: 'disabled', - headerName: 'Disabled', - flex: 1, - minWidth: 120, - filterable: false, - headerClassName: 'datagrid--header', - cellClassName: 'datagrid--cell', - - - editable: hasUpdatePermission, - - type: 'boolean', - - }, - - { - field: 'avatar', - headerName: 'Avatar', - flex: 1, - minWidth: 120, - filterable: false, - headerClassName: 'datagrid--header', - cellClassName: 'datagrid--cell', - - editable: false, - sortable: false, - renderCell: (params: GridValueGetterParams) => ( - - ), - - }, - - { - field: 'app_role', - headerName: 'App Role', - flex: 1, - minWidth: 120, - filterable: false, - headerClassName: 'datagrid--header', - cellClassName: 'datagrid--cell', - - - editable: hasUpdatePermission, - - sortable: false, - type: 'singleSelect', - getOptionValue: (value: any) => value?.id, - getOptionLabel: (value: any) => value?.label, - valueOptions: await callOptionsApi('roles'), - valueGetter: (params: GridValueGetterParams) => - params?.value?.id ?? params?.value, - - }, - - { - field: 'custom_permissions', - headerName: 'Custom Permissions', - flex: 1, - minWidth: 120, - filterable: false, - headerClassName: 'datagrid--header', - cellClassName: 'datagrid--cell', - - editable: false, - sortable: false, - type: 'singleSelect', - valueFormatter: ({ value }) => - dataFormatter.permissionsManyListFormatter(value).join(', '), - renderEditCell: (params) => ( - - ), - - }, - - { - field: 'organizations', - headerName: 'Organizations', - flex: 1, - minWidth: 120, - filterable: false, - headerClassName: 'datagrid--header', - cellClassName: 'datagrid--cell', - - - editable: hasUpdatePermission, - - sortable: false, - type: 'singleSelect', - getOptionValue: (value: any) => value?.id, - getOptionLabel: (value: any) => value?.label, - valueOptions: await callOptionsApi('organizations'), - valueGetter: (params: GridValueGetterParams) => - params?.value?.id ?? params?.value, - - }, - - { - field: 'actions', - type: 'actions', - minWidth: 30, - headerClassName: 'datagrid--header', - cellClassName: 'datagrid--cell', - getActions: (params: GridRowParams) => { - - return [ -
    - -
    , - ] - }, - }, - ]; -}; + + const params = new URLSearchParams({ limit: '100' }) + + if (targetEntityName === 'roles') { + params.set('assignableOnly', 'true') + params.set('includeHighTrust', canManagePlatformFields ? 'true' : 'false') + } + + try { + const data = await axios(`/${targetEntityName}/autocomplete?${params.toString()}`) + return data.data + } catch (error) { + console.error(`Failed to load ${targetEntityName} options for users grid:`, error) + return [] + } + } + + const hasUpdatePermission = hasPermission(user, 'UPDATE_USERS') + const canEditRecord = (record) => hasUpdatePermission && canManageUserRecord(user, record) + + return [ + { + field: 'firstName', + headerName: 'First Name', + flex: 1, + minWidth: 120, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + editable: hasUpdatePermission, + }, + { + field: 'lastName', + headerName: 'Last Name', + flex: 1, + minWidth: 120, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + editable: hasUpdatePermission, + }, + { + field: 'phoneNumber', + headerName: 'Phone Number', + flex: 1, + minWidth: 120, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + editable: hasUpdatePermission, + }, + { + field: 'email', + headerName: 'E-Mail', + flex: 1, + minWidth: 120, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + editable: hasUpdatePermission, + }, + { + field: 'disabled', + headerName: 'Disabled', + flex: 1, + minWidth: 120, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + editable: hasUpdatePermission, + type: 'boolean', + }, + { + field: 'avatar', + headerName: 'Avatar', + flex: 1, + minWidth: 120, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + editable: false, + sortable: false, + renderCell: (params: GridValueGetterParams) => ( + + ), + }, + { + field: 'app_role', + headerName: 'App Role', + flex: 1, + minWidth: 120, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + editable: hasUpdatePermission, + sortable: false, + type: 'singleSelect', + getOptionValue: (value: any) => value?.id, + getOptionLabel: (value: any) => value?.label, + valueOptions: await callOptionsApi('roles'), + valueGetter: (params: GridValueGetterParams) => params?.value?.id ?? params?.value, + }, + { + field: 'custom_permissions', + headerName: 'Custom Permissions', + flex: 1, + minWidth: 160, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + editable: false, + sortable: false, + hide: !canManagePlatformFields, + type: 'singleSelect', + valueFormatter: ({ value }) => dataFormatter.permissionsManyListFormatter(value).join(', '), + renderEditCell: (params) => , + }, + { + field: 'organizations', + headerName: 'Organizations', + flex: 1, + minWidth: 160, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + editable: canManagePlatformFields && hasUpdatePermission, + sortable: false, + hide: !canManagePlatformFields, + type: 'singleSelect', + getOptionValue: (value: any) => value?.id, + getOptionLabel: (value: any) => value?.label, + valueOptions: canManagePlatformFields ? await callOptionsApi('organizations') : [], + valueGetter: (params: GridValueGetterParams) => params?.value?.id ?? params?.value, + }, + { + field: 'actions', + type: 'actions', + minWidth: 30, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + getActions: (params: GridRowParams) => [ +
    + +
    , + ], + }, + ] +} diff --git a/frontend/src/helpers/manageableUsers.ts b/frontend/src/helpers/manageableUsers.ts new file mode 100644 index 0000000..67766df --- /dev/null +++ b/frontend/src/helpers/manageableUsers.ts @@ -0,0 +1,33 @@ +import { getRoleLaneFromUser } from './roleLanes' + +const HIGH_TRUST_ROLE_NAMES = new Set(['Super Administrator', 'Administrator']) + +export const canManagePlatformUserFields = (currentUser?: any) => + getRoleLaneFromUser(currentUser) === 'super_admin' + +export const canManageUserRecord = (currentUser?: any, userRecord?: any) => { + if (!currentUser?.id || !userRecord?.id) { + return false + } + + const currentLane = getRoleLaneFromUser(currentUser) + + if (currentLane === 'super_admin') { + return true + } + + if (currentLane !== 'admin') { + return false + } + + if (currentUser.id === userRecord.id) { + return false + } + + return !HIGH_TRUST_ROLE_NAMES.has(userRecord?.app_role?.name) +} + +export const getUserManagementMessage = (currentUser?: any) => + canManagePlatformUserFields(currentUser) + ? 'You can manage every user account, including platform-level roles and organization assignment.' + : 'You can invite and manage concierge/customer users in your own workspace. Administrator accounts remain protected.' diff --git a/frontend/src/helpers/organizationTenants.ts b/frontend/src/helpers/organizationTenants.ts index a7b7099..52515e8 100644 --- a/frontend/src/helpers/organizationTenants.ts +++ b/frontend/src/helpers/organizationTenants.ts @@ -38,6 +38,55 @@ const normalizeTenantRows = (rows: any): LinkedTenantRecord[] => { })) } +const normalizeOrganizationId = (organizationId: any): string => { + if (typeof organizationId === 'string') { + return organizationId + } + + if (Array.isArray(organizationId)) { + return typeof organizationId[0] === 'string' ? organizationId[0] : '' + } + + if (organizationId && typeof organizationId === 'object') { + return typeof organizationId.id === 'string' ? organizationId.id : '' + } + + return '' +} + +const getTenantLookupErrorMessage = (error: any): string => { + const responseData = error?.response?.data + + if (typeof responseData === 'string') { + return responseData.toLowerCase() + } + + if (responseData && typeof responseData === 'object') { + const candidateMessages = [ + responseData?.message, + responseData?.error, + responseData?.errors?.message, + ].filter((value) => typeof value === 'string') + + if (candidateMessages.length) { + return candidateMessages.join(' ').toLowerCase() + } + } + + if (typeof error?.message === 'string') { + return error.message.toLowerCase() + } + + return '' +} + +const isForbiddenTenantLookup = (error: any) => { + const status = error?.response?.status + const message = getTenantLookupErrorMessage(error) + + return (status === 400 || status === 403) && (message.includes('forbidden') || message.includes('not authorized')) +} + export const getTenantSetupHref = (organizationId?: string, organizationName?: string) => { if (!organizationId) { return '/tenants/tenants-new' @@ -147,32 +196,43 @@ export const mergeEntityOptions = (existingOptions: any[] = [], nextOptions: any } export const loadLinkedTenantSummary = async (organizationId: string): Promise => { - if (!organizationId) { + const normalizedOrganizationId = normalizeOrganizationId(organizationId) + + if (!normalizedOrganizationId) { return emptyOrganizationTenantSummary } - const { data } = await axios.get('/tenants', { - params: { - organizations: organizationId, - limit: 5, - page: 0, - sort: 'asc', - field: 'name', - }, - }) + try { + const { data } = await axios.get('/tenants', { + params: { + organizations: normalizedOrganizationId, + limit: 5, + page: 0, + sort: 'asc', + field: 'name', + }, + }) - const normalizedRows = normalizeTenantRows(data?.rows) + const normalizedRows = normalizeTenantRows(data?.rows) - return { - rows: normalizedRows, - count: typeof data?.count === 'number' ? data.count : normalizedRows.length, + return { + rows: normalizedRows, + count: typeof data?.count === 'number' ? data.count : normalizedRows.length, + } + } catch (error) { + if (isForbiddenTenantLookup(error)) { + console.warn(`Skipping linked tenant summary for organization ${normalizedOrganizationId} because tenant access is not available.`) + return emptyOrganizationTenantSummary + } + + throw error } } export const loadLinkedTenantSummaries = async ( organizationIds: string[], ): Promise => { - const uniqueOrganizationIds = Array.from(new Set(organizationIds.filter(Boolean))) + const uniqueOrganizationIds = Array.from(new Set(organizationIds.map((organizationId) => normalizeOrganizationId(organizationId)).filter(Boolean))) if (!uniqueOrganizationIds.length) { return {} diff --git a/frontend/src/pages/organizations/organizations-edit.tsx b/frontend/src/pages/organizations/organizations-edit.tsx index 0637dd1..1c4f9e5 100644 --- a/frontend/src/pages/organizations/organizations-edit.tsx +++ b/frontend/src/pages/organizations/organizations-edit.tsx @@ -76,7 +76,7 @@ const EditOrganizationsPage = () => { }, [organizations]) useEffect(() => { - if (!organizationId) { + if (!organizationId || !canReadTenants) { setLinkedTenantSummary(emptyOrganizationTenantSummary) return } @@ -100,7 +100,7 @@ const EditOrganizationsPage = () => { return () => { isActive = false } - }, [organizationId]) + }, [canReadTenants, organizationId]) const handleSubmit = async (data) => { const resultAction = await dispatch(update({ id: organizationId, data })) diff --git a/frontend/src/pages/organizations/organizations-view.tsx b/frontend/src/pages/organizations/organizations-view.tsx index a1ba568..e7a4ad6 100644 --- a/frontend/src/pages/organizations/organizations-view.tsx +++ b/frontend/src/pages/organizations/organizations-view.tsx @@ -47,7 +47,7 @@ const OrganizationsView = () => { }, [dispatch, id]); useEffect(() => { - if (!id || typeof id !== 'string') { + if (!id || typeof id !== 'string' || !canReadTenants) { setLinkedTenantSummary(emptyOrganizationTenantSummary) return } @@ -71,7 +71,7 @@ const OrganizationsView = () => { return () => { isActive = false } - }, [id]); + }, [canReadTenants, id]); return ( <> diff --git a/frontend/src/pages/users/users-edit.tsx b/frontend/src/pages/users/users-edit.tsx index 320b2af..19efa01 100644 --- a/frontend/src/pages/users/users-edit.tsx +++ b/frontend/src/pages/users/users-edit.tsx @@ -17,6 +17,7 @@ import { SelectFieldMany } from '../../components/SelectFieldMany' import { SwitchField } from '../../components/SwitchField' import { getPageTitle } from '../../config' import { getRoleLaneFromUser } from '../../helpers/roleLanes' +import { canManageUserRecord } from '../../helpers/manageableUsers' import LayoutAuthenticated from '../../layouts/Authenticated' import { useAppDispatch, useAppSelector } from '../../stores/hooks' import { fetch, update } from '../../stores/users/usersSlice' @@ -77,6 +78,7 @@ const EditUsersPage = () => { assignableOnly: true, includeHighTrust: canManagePlatformAccess, } + const canEditLoadedUser = !users?.id || canManagePlatformAccess || canManageUserRecord(currentUser, users) const handleSubmit = async (values) => { const payload = { ...values } @@ -106,6 +108,11 @@ const EditUsersPage = () => { {introCopy}
    + {!canEditLoadedUser ? ( +
    + This account is protected. Customer administrators can view it, but only a super administrator can edit administrator-level users. +
    + ) : (
    @@ -187,6 +194,7 @@ const EditUsersPage = () => { + )} diff --git a/frontend/src/pages/users/users-list.tsx b/frontend/src/pages/users/users-list.tsx index a90ec62..249ee6c 100644 --- a/frontend/src/pages/users/users-list.tsx +++ b/frontend/src/pages/users/users-list.tsx @@ -1,93 +1,84 @@ import { mdiChartTimelineVariant } from '@mdi/js' import Head from 'next/head' -import { uniqueId } from 'lodash'; -import React, { ReactElement, useState } from 'react' +import { uniqueId } from 'lodash' +import React, { ReactElement, useMemo, useState } from 'react' +import axios from 'axios' + +import BaseButton from '../../components/BaseButton' import CardBox from '../../components/CardBox' -import LayoutAuthenticated from '../../layouts/Authenticated' +import CardBoxModal from '../../components/CardBoxModal' +import DragDropFilePicker from '../../components/DragDropFilePicker' +import TableUsers from '../../components/Users/TableUsers' import SectionMain from '../../components/SectionMain' import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton' import { getPageTitle } from '../../config' -import TableUsers from '../../components/Users/TableUsers' -import BaseButton from '../../components/BaseButton' -import axios from "axios"; -import Link from "next/link"; -import {useAppDispatch, useAppSelector} from "../../stores/hooks"; -import CardBoxModal from "../../components/CardBoxModal"; -import DragDropFilePicker from "../../components/DragDropFilePicker"; -import {setRefetch, uploadCsv} from '../../stores/users/usersSlice'; - - -import {hasPermission} from "../../helpers/userPermissions"; - - +import { canManagePlatformUserFields, getUserManagementMessage } from '../../helpers/manageableUsers' +import { hasPermission } from '../../helpers/userPermissions' +import LayoutAuthenticated from '../../layouts/Authenticated' +import { useAppDispatch, useAppSelector } from '../../stores/hooks' +import { setRefetch, uploadCsv } from '../../stores/users/usersSlice' const UsersTablesPage = () => { - const [filterItems, setFilterItems] = useState([]); - const [csvFile, setCsvFile] = useState(null); - const [isModalActive, setIsModalActive] = useState(false); - const [showTableView, setShowTableView] = useState(false); + const [filterItems, setFilterItems] = useState([]) + const [csvFile, setCsvFile] = useState(null) + const [isModalActive, setIsModalActive] = useState(false) - - const { currentUser } = useAppSelector((state) => state.auth); - + const { currentUser } = useAppSelector((state) => state.auth) + const dispatch = useAppDispatch() + const canManagePlatformAccess = canManagePlatformUserFields(currentUser) + const teamManagementMessage = getUserManagementMessage(currentUser) - const dispatch = useAppDispatch(); + const filters = useMemo( + () => [ + { label: 'First Name', title: 'firstName' }, + { label: 'Last Name', title: 'lastName' }, + { label: 'Phone Number', title: 'phoneNumber' }, + { label: 'E-Mail', title: 'email' }, + { label: 'App Role', title: 'app_role' }, + ...(canManagePlatformAccess ? [{ label: 'Custom Permissions', title: 'custom_permissions' }] : []), + ], + [canManagePlatformAccess], + ) + const hasCreatePermission = currentUser && hasPermission(currentUser, 'CREATE_USERS') - const [filters] = useState([{label: 'First Name', title: 'firstName'},{label: 'Last Name', title: 'lastName'},{label: 'Phone Number', title: 'phoneNumber'},{label: 'E-Mail', title: 'email'}, - - - - - - {label: 'App Role', title: 'app_role'}, - - - - - {label: 'Custom Permissions', title: 'custom_permissions'}, - - ]); - - const hasCreatePermission = currentUser && hasPermission(currentUser, 'CREATE_USERS'); - + const addFilter = () => { + const newItem = { + id: uniqueId(), + fields: { + filterValue: '', + filterValueFrom: '', + filterValueTo: '', + selectedField: filters[0].title, + }, + } - const addFilter = () => { - const newItem = { - id: uniqueId(), - fields: { - filterValue: '', - filterValueFrom: '', - filterValueTo: '', - selectedField: '', - }, - }; - newItem.fields.selectedField = filters[0].title; - setFilterItems([...filterItems, newItem]); - }; + setFilterItems([...filterItems, newItem]) + } - const getUsersCSV = async () => { - const response = await axios({url: '/users?filetype=csv', method: 'GET',responseType: 'blob'}); - const type = response.headers['content-type'] - const blob = new Blob([response.data], { type: type }) - const link = document.createElement('a') - link.href = window.URL.createObjectURL(blob) - link.download = 'usersCSV.csv' - link.click() - }; + const getUsersCSV = async () => { + const response = await axios({ url: '/users?filetype=csv', method: 'GET', responseType: 'blob' }) + const type = response.headers['content-type'] + const blob = new Blob([response.data], { type }) + const link = document.createElement('a') + link.href = window.URL.createObjectURL(blob) + link.download = 'usersCSV.csv' + link.click() + } - const onModalConfirm = async () => { - if (!csvFile) return; - await dispatch(uploadCsv(csvFile)); - dispatch(setRefetch(true)); - setCsvFile(null); - setIsModalActive(false); - }; + const onModalConfirm = async () => { + if (!csvFile) return - const onModalCancel = () => { - setCsvFile(null); - setIsModalActive(false); - }; + await dispatch(uploadCsv(csvFile)) + dispatch(setRefetch(true)) + setCsvFile(null) + setIsModalActive(false) + } + + const onModalCancel = () => { + setCsvFile(null) + setIsModalActive(false) + } return ( <> @@ -95,74 +86,38 @@ const UsersTablesPage = () => { {getPageTitle('Users')} - - {''} + + {''} - - - {hasCreatePermission && } - - - - - {hasCreatePermission && ( - setIsModalActive(true)} - /> - )} - + + {teamManagementMessage} + + + {hasCreatePermission && } + + + + + {hasCreatePermission && setIsModalActive(true)} />} +
    -
    - +
    - - - + + + - - - + + ) } UsersTablesPage.getLayout = function getLayout(page: ReactElement) { - return ( - - {page} - - ) + return {page} } export default UsersTablesPage diff --git a/frontend/src/pages/users/users-table.tsx b/frontend/src/pages/users/users-table.tsx index 88caea1..622b686 100644 --- a/frontend/src/pages/users/users-table.tsx +++ b/frontend/src/pages/users/users-table.tsx @@ -1,7 +1,7 @@ import { mdiChartTimelineVariant } from '@mdi/js' import Head from 'next/head' -import { uniqueId } from 'lodash'; -import React, { ReactElement, useState } from 'react' +import { uniqueId } from 'lodash' +import React, { ReactElement, useMemo, useState } from 'react' import CardBox from '../../components/CardBox' import LayoutAuthenticated from '../../layouts/Authenticated' import SectionMain from '../../components/SectionMain' @@ -10,13 +10,13 @@ import { getPageTitle } from '../../config' import TableUsers from '../../components/Users/TableUsers' import BaseButton from '../../components/BaseButton' import axios from "axios"; -import Link from "next/link"; import {useAppDispatch, useAppSelector} from "../../stores/hooks"; import CardBoxModal from "../../components/CardBoxModal"; import DragDropFilePicker from "../../components/DragDropFilePicker"; import {setRefetch, uploadCsv} from '../../stores/users/usersSlice'; +import { canManagePlatformUserFields, getUserManagementMessage } from '../../helpers/manageableUsers' import {hasPermission} from "../../helpers/userPermissions"; @@ -32,22 +32,17 @@ const UsersTablesPage = () => { const dispatch = useAppDispatch(); + const canManagePlatformAccess = canManagePlatformUserFields(currentUser) + const teamManagementMessage = getUserManagementMessage(currentUser) - - const [filters] = useState([{label: 'First Name', title: 'firstName'},{label: 'Last Name', title: 'lastName'},{label: 'Phone Number', title: 'phoneNumber'},{label: 'E-Mail', title: 'email'}, - - - - - - {label: 'App Role', title: 'app_role'}, - - - - - {label: 'Custom Permissions', title: 'custom_permissions'}, - - ]); + const filters = useMemo(() => ([ + { label: 'First Name', title: 'firstName' }, + { label: 'Last Name', title: 'lastName' }, + { label: 'Phone Number', title: 'phoneNumber' }, + { label: 'E-Mail', title: 'email' }, + { label: 'App Role', title: 'app_role' }, + ...(canManagePlatformAccess ? [{ label: 'Custom Permissions', title: 'custom_permissions' }] : []), + ]), [canManagePlatformAccess]); const hasCreatePermission = currentUser && hasPermission(currentUser, 'CREATE_USERS'); @@ -98,6 +93,9 @@ const UsersTablesPage = () => { {''} + + {teamManagementMessage} + {hasCreatePermission && } diff --git a/frontend/src/pages/users/users-view.tsx b/frontend/src/pages/users/users-view.tsx index 7812663..203b6ff 100644 --- a/frontend/src/pages/users/users-view.tsx +++ b/frontend/src/pages/users/users-view.tsx @@ -21,6 +21,7 @@ import {SwitchField} from "../../components/SwitchField"; import FormField from "../../components/FormField"; import {hasPermission} from "../../helpers/userPermissions"; +import { canManageUserRecord } from '../../helpers/manageableUsers' const UsersView = () => { @@ -34,10 +35,11 @@ const UsersView = () => { const { id } = router.query; function removeLastCharacter(str) { - console.log(str,`str`) return str.slice(0, -1); } + const canEditViewedUser = hasPermission(currentUser, 'UPDATE_USERS') && canManageUserRecord(currentUser, users) + useEffect(() => { dispatch(fetch({ id })); }, [dispatch, id]); @@ -50,13 +52,20 @@ const UsersView = () => { + {canEditViewedUser ? ( + ) : null} + {!canEditViewedUser && hasPermission(currentUser, 'UPDATE_USERS') ? ( +
    + This account is protected. Customer administrators can review it here, but only super administrators can edit or delete administrator-level users. +
    + ) : null}