feat(finance-permissions): implement permission checks for finance data editing
- Added permission checks for editing finance data, including fixed costs and rate cards. - Introduced utility functions to determine user permissions based on roles (admin, project manager). - Updated finance and rate card components to conditionally render UI elements based on user permissions. - Displayed alerts for users with limited access to inform them of their editing capabilities.
This commit is contained in:
@@ -22,6 +22,8 @@ import { useAppDispatch } from '@/hooks/useAppDispatch';
|
|||||||
import { setSelectedTaskId, setShowTaskDrawer, fetchTask } from '@/features/task-drawer/task-drawer.slice';
|
import { setSelectedTaskId, setShowTaskDrawer, fetchTask } from '@/features/task-drawer/task-drawer.slice';
|
||||||
import { useParams } from 'react-router-dom';
|
import { useParams } from 'react-router-dom';
|
||||||
import { parseTimeToSeconds } from '@/utils/timeUtils';
|
import { parseTimeToSeconds } from '@/utils/timeUtils';
|
||||||
|
import { useAuthService } from '@/hooks/useAuth';
|
||||||
|
import { canEditFixedCost } from '@/utils/finance-permissions';
|
||||||
import './finance-table.css';
|
import './finance-table.css';
|
||||||
|
|
||||||
type FinanceTableProps = {
|
type FinanceTableProps = {
|
||||||
@@ -48,6 +50,12 @@ const FinanceTable = ({
|
|||||||
const taskGroups = useAppSelector((state) => state.projectFinances.taskGroups);
|
const taskGroups = useAppSelector((state) => state.projectFinances.taskGroups);
|
||||||
const activeGroup = useAppSelector((state) => state.projectFinances.activeGroup);
|
const activeGroup = useAppSelector((state) => state.projectFinances.activeGroup);
|
||||||
|
|
||||||
|
// Auth and permissions
|
||||||
|
const auth = useAuthService();
|
||||||
|
const currentSession = auth.getCurrentSession();
|
||||||
|
const { project } = useAppSelector((state) => state.projectReducer);
|
||||||
|
const hasEditPermission = canEditFixedCost(currentSession, project);
|
||||||
|
|
||||||
// Update local state when table.tasks or Redux store changes
|
// Update local state when table.tasks or Redux store changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const updatedGroup = taskGroups.find(g => g.group_id === table.group_id);
|
const updatedGroup = taskGroups.find(g => g.group_id === table.group_id);
|
||||||
@@ -110,6 +118,8 @@ const FinanceTable = ({
|
|||||||
return <Typography.Text>{formattedTotals.total_time_logged}</Typography.Text>;
|
return <Typography.Text>{formattedTotals.total_time_logged}</Typography.Text>;
|
||||||
case FinanceTableColumnKeys.ESTIMATED_COST:
|
case FinanceTableColumnKeys.ESTIMATED_COST:
|
||||||
return <Typography.Text>{formatNumber(formattedTotals.estimated_cost)}</Typography.Text>;
|
return <Typography.Text>{formatNumber(formattedTotals.estimated_cost)}</Typography.Text>;
|
||||||
|
case FinanceTableColumnKeys.COST:
|
||||||
|
return <Typography.Text>{formatNumber(formattedTotals.estimated_cost)}</Typography.Text>;
|
||||||
case FinanceTableColumnKeys.FIXED_COST:
|
case FinanceTableColumnKeys.FIXED_COST:
|
||||||
return <Typography.Text>{formatNumber(formattedTotals.fixed_cost)}</Typography.Text>;
|
return <Typography.Text>{formatNumber(formattedTotals.fixed_cost)}</Typography.Text>;
|
||||||
case FinanceTableColumnKeys.TOTAL_BUDGET:
|
case FinanceTableColumnKeys.TOTAL_BUDGET:
|
||||||
@@ -269,7 +279,7 @@ const FinanceTable = ({
|
|||||||
case FinanceTableColumnKeys.ESTIMATED_COST:
|
case FinanceTableColumnKeys.ESTIMATED_COST:
|
||||||
return <Typography.Text>{formatNumber(task.estimated_cost)}</Typography.Text>;
|
return <Typography.Text>{formatNumber(task.estimated_cost)}</Typography.Text>;
|
||||||
case FinanceTableColumnKeys.FIXED_COST:
|
case FinanceTableColumnKeys.FIXED_COST:
|
||||||
return selectedTask?.id === task.id ? (
|
return selectedTask?.id === task.id && hasEditPermission ? (
|
||||||
<InputNumber
|
<InputNumber
|
||||||
value={editingFixedCostValue !== null ? editingFixedCostValue : task.fixed_cost}
|
value={editingFixedCostValue !== null ? editingFixedCostValue : task.fixed_cost}
|
||||||
onChange={(value) => {
|
onChange={(value) => {
|
||||||
@@ -295,12 +305,17 @@ const FinanceTable = ({
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Typography.Text
|
<Typography.Text
|
||||||
style={{ cursor: 'pointer', width: '100%', display: 'block' }}
|
style={{
|
||||||
onClick={(e) => {
|
cursor: hasEditPermission ? 'pointer' : 'default',
|
||||||
|
width: '100%',
|
||||||
|
display: 'block',
|
||||||
|
opacity: hasEditPermission ? 1 : 0.7
|
||||||
|
}}
|
||||||
|
onClick={hasEditPermission ? (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setSelectedTask(task);
|
setSelectedTask(task);
|
||||||
setEditingFixedCostValue(task.fixed_cost);
|
setEditingFixedCostValue(task.fixed_cost);
|
||||||
}}
|
} : undefined}
|
||||||
>
|
>
|
||||||
{formatNumber(task.fixed_cost)}
|
{formatNumber(task.fixed_cost)}
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Button, ConfigProvider, Flex, Select, Typography, message } from 'antd';
|
import { Button, ConfigProvider, Flex, Select, Typography, message, Alert } from 'antd';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useParams } from 'react-router-dom';
|
import { useParams } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
@@ -12,6 +12,8 @@ import { RootState } from '@/app/store';
|
|||||||
import FinanceTableWrapper from './finance-tab/finance-table/finance-table-wrapper';
|
import FinanceTableWrapper from './finance-tab/finance-table/finance-table-wrapper';
|
||||||
import RatecardTable from './ratecard-tab/reatecard-table/ratecard-table';
|
import RatecardTable from './ratecard-tab/reatecard-table/ratecard-table';
|
||||||
import ImportRatecardsDrawer from '@/features/finance/ratecard-drawer/import-ratecards-drawer';
|
import ImportRatecardsDrawer from '@/features/finance/ratecard-drawer/import-ratecards-drawer';
|
||||||
|
import { useAuthService } from '@/hooks/useAuth';
|
||||||
|
import { hasFinanceEditPermission } from '@/utils/finance-permissions';
|
||||||
|
|
||||||
const ProjectViewFinance = () => {
|
const ProjectViewFinance = () => {
|
||||||
const { projectId } = useParams<{ projectId: string }>();
|
const { projectId } = useParams<{ projectId: string }>();
|
||||||
@@ -23,6 +25,11 @@ const ProjectViewFinance = () => {
|
|||||||
const { refreshTimestamp, project } = useAppSelector((state: RootState) => state.projectReducer);
|
const { refreshTimestamp, project } = useAppSelector((state: RootState) => state.projectReducer);
|
||||||
const phaseList = useAppSelector((state) => state.phaseReducer.phaseList);
|
const phaseList = useAppSelector((state) => state.phaseReducer.phaseList);
|
||||||
|
|
||||||
|
// Auth and permissions
|
||||||
|
const auth = useAuthService();
|
||||||
|
const currentSession = auth.getCurrentSession();
|
||||||
|
const hasEditPermission = hasFinanceEditPermission(currentSession, project);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (projectId) {
|
if (projectId) {
|
||||||
dispatch(fetchProjectFinances({ projectId, groupBy: activeGroup }));
|
dispatch(fetchProjectFinances({ projectId, groupBy: activeGroup }));
|
||||||
@@ -146,10 +153,28 @@ const ProjectViewFinance = () => {
|
|||||||
{/* Tab Content */}
|
{/* Tab Content */}
|
||||||
{activeTab === 'finance' ? (
|
{activeTab === 'finance' ? (
|
||||||
<div>
|
<div>
|
||||||
|
{!hasEditPermission && (
|
||||||
|
<Alert
|
||||||
|
message="Limited Access"
|
||||||
|
description="You can view finance data but cannot edit fixed costs. Only project managers, team admins, and team owners can make changes."
|
||||||
|
type="info"
|
||||||
|
showIcon
|
||||||
|
style={{ marginBottom: 16 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<FinanceTableWrapper activeTablesList={taskGroups} loading={loading} />
|
<FinanceTableWrapper activeTablesList={taskGroups} loading={loading} />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Flex vertical gap={8}>
|
<Flex vertical gap={8}>
|
||||||
|
{!hasEditPermission && (
|
||||||
|
<Alert
|
||||||
|
message="Limited Access"
|
||||||
|
description="You can view rate card data but cannot edit rates or manage member assignments. Only project managers, team admins, and team owners can make changes."
|
||||||
|
type="info"
|
||||||
|
showIcon
|
||||||
|
style={{ marginBottom: 16 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<RatecardTable />
|
<RatecardTable />
|
||||||
<Typography.Text
|
<Typography.Text
|
||||||
type="danger"
|
type="danger"
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ import { jobTitlesApiService } from '@/api/settings/job-titles/job-titles.api.se
|
|||||||
import RateCardAssigneeSelector from '@/components/project-ratecard/ratecard-assignee-selector';
|
import RateCardAssigneeSelector from '@/components/project-ratecard/ratecard-assignee-selector';
|
||||||
import { projectsApiService } from '@/api/projects/projects.api.service';
|
import { projectsApiService } from '@/api/projects/projects.api.service';
|
||||||
import { IProjectMemberViewModel } from '@/types/projectMember.types';
|
import { IProjectMemberViewModel } from '@/types/projectMember.types';
|
||||||
|
import { useAuthService } from '@/hooks/useAuth';
|
||||||
|
import { canEditRateCard, canAddMembersToRateCard } from '@/utils/finance-permissions';
|
||||||
import { parse } from 'path';
|
import { parse } from 'path';
|
||||||
|
|
||||||
const RatecardTable: React.FC = () => {
|
const RatecardTable: React.FC = () => {
|
||||||
@@ -31,6 +33,14 @@ const RatecardTable: React.FC = () => {
|
|||||||
const isLoading = useAppSelector((state) => state.projectFinanceRateCard.isLoading);
|
const isLoading = useAppSelector((state) => state.projectFinanceRateCard.isLoading);
|
||||||
const currency = useAppSelector((state) => state.financeReducer.currency).toUpperCase();
|
const currency = useAppSelector((state) => state.financeReducer.currency).toUpperCase();
|
||||||
const rateInputRefs = React.useRef<Array<HTMLInputElement | null>>([]);
|
const rateInputRefs = React.useRef<Array<HTMLInputElement | null>>([]);
|
||||||
|
|
||||||
|
// Auth and permissions
|
||||||
|
const auth = useAuthService();
|
||||||
|
const currentSession = auth.getCurrentSession();
|
||||||
|
const { project } = useAppSelector((state) => state.projectReducer);
|
||||||
|
const hasEditPermission = canEditRateCard(currentSession, project);
|
||||||
|
const canAddMembers = canAddMembersToRateCard(currentSession, project);
|
||||||
|
|
||||||
// Local state for editing
|
// Local state for editing
|
||||||
const [roles, setRoles] = useState<JobRoleType[]>(rolesRedux);
|
const [roles, setRoles] = useState<JobRoleType[]>(rolesRedux);
|
||||||
const [addingRow, setAddingRow] = useState<boolean>(false);
|
const [addingRow, setAddingRow] = useState<boolean>(false);
|
||||||
@@ -238,6 +248,7 @@ const RatecardTable: React.FC = () => {
|
|||||||
type="number"
|
type="number"
|
||||||
value={roles[index]?.rate ?? 0}
|
value={roles[index]?.rate ?? 0}
|
||||||
min={0}
|
min={0}
|
||||||
|
disabled={!hasEditPermission}
|
||||||
style={{
|
style={{
|
||||||
background: 'transparent',
|
background: 'transparent',
|
||||||
border: 'none',
|
border: 'none',
|
||||||
@@ -245,10 +256,12 @@ const RatecardTable: React.FC = () => {
|
|||||||
padding: 0,
|
padding: 0,
|
||||||
width: 80,
|
width: 80,
|
||||||
textAlign: 'right',
|
textAlign: 'right',
|
||||||
|
opacity: hasEditPermission ? 1 : 0.7,
|
||||||
|
cursor: hasEditPermission ? 'text' : 'not-allowed'
|
||||||
}}
|
}}
|
||||||
onChange={(e) => handleRateChange(e.target.value, index)}
|
onChange={hasEditPermission ? (e) => handleRateChange(e.target.value, index) : undefined}
|
||||||
onBlur={(e) => handleRateBlur(e.target.value, index)}
|
onBlur={hasEditPermission ? (e) => handleRateBlur(e.target.value, index) : undefined}
|
||||||
onPressEnter={(e) => handleRateBlur(e.target.value, index)}
|
onPressEnter={hasEditPermission ? (e) => handleRateBlur(e.target.value, index) : undefined}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -265,15 +278,17 @@ const RatecardTable: React.FC = () => {
|
|||||||
) : null;
|
) : null;
|
||||||
})}
|
})}
|
||||||
</Avatar.Group>
|
</Avatar.Group>
|
||||||
<div>
|
{canAddMembers && (
|
||||||
<RateCardAssigneeSelector
|
<div>
|
||||||
projectId={projectId as string}
|
<RateCardAssigneeSelector
|
||||||
selectedMemberIds={memberscol || []}
|
projectId={projectId as string}
|
||||||
onChange={(memberId) => handleMemberChange(memberId, index, record)}
|
selectedMemberIds={memberscol || []}
|
||||||
memberlist={members}
|
onChange={(memberId) => handleMemberChange(memberId, index, record)}
|
||||||
assignedMembers={assignedMembers} // Pass assigned members here
|
memberlist={members}
|
||||||
/>
|
assignedMembers={assignedMembers} // Pass assigned members here
|
||||||
</div>
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -282,14 +297,16 @@ const RatecardTable: React.FC = () => {
|
|||||||
key: 'actions',
|
key: 'actions',
|
||||||
align: 'center',
|
align: 'center',
|
||||||
render: (_: any, record: JobRoleType, index: number) => (
|
render: (_: any, record: JobRoleType, index: number) => (
|
||||||
<Popconfirm
|
hasEditPermission ? (
|
||||||
title={t('deleteConfirm')}
|
<Popconfirm
|
||||||
onConfirm={() => handleDelete(record, index)}
|
title={t('deleteConfirm')}
|
||||||
okText={t('yes')}
|
onConfirm={() => handleDelete(record, index)}
|
||||||
cancelText={t('no')}
|
okText={t('yes')}
|
||||||
>
|
cancelText={t('no')}
|
||||||
<Button type="text" danger icon={<DeleteOutlined />} />
|
>
|
||||||
</Popconfirm>
|
<Button type="text" danger icon={<DeleteOutlined />} />
|
||||||
|
</Popconfirm>
|
||||||
|
) : null
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -315,9 +332,11 @@ const RatecardTable: React.FC = () => {
|
|||||||
loading={isLoading || isLoadingMembers}
|
loading={isLoading || isLoadingMembers}
|
||||||
footer={() => (
|
footer={() => (
|
||||||
<Flex gap={0}>
|
<Flex gap={0}>
|
||||||
<Button type="dashed" onClick={handleAddRole} style={{ width: 'fit-content' }}>
|
{hasEditPermission && (
|
||||||
{t('addRoleButton')}
|
<Button type="dashed" onClick={handleAddRole} style={{ width: 'fit-content' }}>
|
||||||
</Button>
|
{t('addRoleButton')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
{/* <Button
|
{/* <Button
|
||||||
type="primary"
|
type="primary"
|
||||||
icon={<SaveOutlined />}
|
icon={<SaveOutlined />}
|
||||||
|
|||||||
58
worklenz-frontend/src/utils/finance-permissions.ts
Normal file
58
worklenz-frontend/src/utils/finance-permissions.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import { ILocalSession } from '@/types/auth/local-session.types';
|
||||||
|
import { IProjectViewModel } from '@/types/project/projectViewModel.types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if the current user has permission to edit finance data
|
||||||
|
* Only users with project admin, admin or owner roles should be able to:
|
||||||
|
* - Change fixed cost values
|
||||||
|
* - Add members to rate cards
|
||||||
|
* - Change rate per hour values
|
||||||
|
*/
|
||||||
|
export const hasFinanceEditPermission = (
|
||||||
|
currentSession: ILocalSession | null,
|
||||||
|
currentProject?: IProjectViewModel | null
|
||||||
|
): boolean => {
|
||||||
|
if (!currentSession) return false;
|
||||||
|
|
||||||
|
// Team owner or admin always have permission
|
||||||
|
if (currentSession.owner || currentSession.is_admin) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Project manager has permission
|
||||||
|
if (currentProject?.project_manager?.id === currentSession.team_member_id) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if the current user can edit fixed costs
|
||||||
|
*/
|
||||||
|
export const canEditFixedCost = (
|
||||||
|
currentSession: ILocalSession | null,
|
||||||
|
currentProject?: IProjectViewModel | null
|
||||||
|
): boolean => {
|
||||||
|
return hasFinanceEditPermission(currentSession, currentProject);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if the current user can edit rate card data
|
||||||
|
*/
|
||||||
|
export const canEditRateCard = (
|
||||||
|
currentSession: ILocalSession | null,
|
||||||
|
currentProject?: IProjectViewModel | null
|
||||||
|
): boolean => {
|
||||||
|
return hasFinanceEditPermission(currentSession, currentProject);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if the current user can add members to rate cards
|
||||||
|
*/
|
||||||
|
export const canAddMembersToRateCard = (
|
||||||
|
currentSession: ILocalSession | null,
|
||||||
|
currentProject?: IProjectViewModel | null
|
||||||
|
): boolean => {
|
||||||
|
return hasFinanceEditPermission(currentSession, currentProject);
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user