diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 0b42f8b..db7ca5b 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -1,9 +1,11 @@ import { Module, MiddlewareConsumer, NestModule } from '@nestjs/common'; +import { APP_GUARD } from '@nestjs/core'; import { ConfigModule, ConfigService } from '@nestjs/config'; import { TypeOrmModule } from '@nestjs/typeorm'; import { AppController } from './app.controller'; import { DatabaseModule } from './database/database.module'; import { TenantMiddleware } from './database/tenant.middleware'; +import { WriteAccessGuard } from './common/guards/write-access.guard'; import { AuthModule } from './modules/auth/auth.module'; import { OrganizationsModule } from './modules/organizations/organizations.module'; import { UsersModule } from './modules/users/users.module'; @@ -64,6 +66,12 @@ import { InvestmentPlanningModule } from './modules/investment-planning/investme InvestmentPlanningModule, ], controllers: [AppController], + providers: [ + { + provide: APP_GUARD, + useClass: WriteAccessGuard, + }, + ], }) export class AppModule implements NestModule { configure(consumer: MiddlewareConsumer) { diff --git a/backend/src/common/decorators/allow-viewer.decorator.ts b/backend/src/common/decorators/allow-viewer.decorator.ts new file mode 100644 index 0000000..ff09442 --- /dev/null +++ b/backend/src/common/decorators/allow-viewer.decorator.ts @@ -0,0 +1,4 @@ +import { SetMetadata } from '@nestjs/common'; + +export const ALLOW_VIEWER_KEY = 'allowViewer'; +export const AllowViewer = () => SetMetadata(ALLOW_VIEWER_KEY, true); diff --git a/backend/src/common/guards/write-access.guard.ts b/backend/src/common/guards/write-access.guard.ts new file mode 100644 index 0000000..bd12619 --- /dev/null +++ b/backend/src/common/guards/write-access.guard.ts @@ -0,0 +1,34 @@ +import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { ALLOW_VIEWER_KEY } from '../decorators/allow-viewer.decorator'; + +@Injectable() +export class WriteAccessGuard implements CanActivate { + constructor(private reflector: Reflector) {} + + canActivate(context: ExecutionContext): boolean { + const request = context.switchToHttp().getRequest(); + const method = request.method; + + // Allow all read methods + if (['GET', 'HEAD', 'OPTIONS'].includes(method)) return true; + + // If no user on request (unauthenticated endpoints like login/register), allow + const user = request.user; + if (!user) return true; + + // Check for @AllowViewer() exemption on handler or class + const allowViewer = this.reflector.getAllAndOverride(ALLOW_VIEWER_KEY, [ + context.getHandler(), + context.getClass(), + ]); + if (allowViewer) return true; + + // Block viewer role from write operations + if (user.role === 'viewer') { + throw new ForbiddenException('Read-only users cannot modify data'); + } + + return true; + } +} diff --git a/backend/src/modules/auth/auth.controller.ts b/backend/src/modules/auth/auth.controller.ts index cc8cdaa..66c3356 100644 --- a/backend/src/modules/auth/auth.controller.ts +++ b/backend/src/modules/auth/auth.controller.ts @@ -14,6 +14,7 @@ import { RegisterDto } from './dto/register.dto'; import { LoginDto } from './dto/login.dto'; import { SwitchOrgDto } from './dto/switch-org.dto'; import { JwtAuthGuard } from './guards/jwt-auth.guard'; +import { AllowViewer } from '../../common/decorators/allow-viewer.decorator'; @ApiTags('auth') @Controller('auth') @@ -47,6 +48,7 @@ export class AuthController { @ApiOperation({ summary: 'Mark the how-to intro as seen for the current user' }) @ApiBearerAuth() @UseGuards(JwtAuthGuard) + @AllowViewer() async markIntroSeen(@Request() req: any) { await this.authService.markIntroSeen(req.user.sub); return { success: true }; @@ -56,6 +58,7 @@ export class AuthController { @ApiOperation({ summary: 'Switch active organization' }) @ApiBearerAuth() @UseGuards(JwtAuthGuard) + @AllowViewer() async switchOrg(@Request() req: any, @Body() dto: SwitchOrgDto) { const ip = req.headers['x-forwarded-for'] || req.ip; const ua = req.headers['user-agent']; diff --git a/backend/src/modules/investment-planning/investment-planning.controller.ts b/backend/src/modules/investment-planning/investment-planning.controller.ts index fe43509..7b996b0 100644 --- a/backend/src/modules/investment-planning/investment-planning.controller.ts +++ b/backend/src/modules/investment-planning/investment-planning.controller.ts @@ -1,6 +1,7 @@ import { Controller, Get, Post, UseGuards, Req } from '@nestjs/common'; import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { AllowViewer } from '../../common/decorators/allow-viewer.decorator'; import { InvestmentPlanningService } from './investment-planning.service'; @ApiTags('investment-planning') @@ -36,6 +37,7 @@ export class InvestmentPlanningController { @Post('recommendations') @ApiOperation({ summary: 'Get AI-powered investment recommendations' }) + @AllowViewer() getRecommendations(@Req() req: any) { return this.service.getAIRecommendations(req.user?.sub, req.user?.orgId); } diff --git a/frontend/src/components/layout/AppLayout.tsx b/frontend/src/components/layout/AppLayout.tsx index bebedac..cb6fd4a 100644 --- a/frontend/src/components/layout/AppLayout.tsx +++ b/frontend/src/components/layout/AppLayout.tsx @@ -32,6 +32,8 @@ export function AppLayout() { // Only run for non-impersonating users with an org selected, on dashboard if (isImpersonating || !currentOrg || !user) return; if (!location.pathname.startsWith('/dashboard')) return; + // Read-only users (viewers) skip onboarding entirely + if (currentOrg.role === 'viewer') return; if (user.hasSeenIntro === false || user.hasSeenIntro === undefined) { // Delay to ensure DOM elements are rendered for tour targeting @@ -40,7 +42,7 @@ export function AppLayout() { } else if (currentOrg.settings?.onboardingComplete !== true) { setShowWizard(true); } - }, [user?.hasSeenIntro, currentOrg?.id, currentOrg?.settings?.onboardingComplete, isImpersonating, location.pathname]); + }, [user?.hasSeenIntro, currentOrg?.id, currentOrg?.role, currentOrg?.settings?.onboardingComplete, isImpersonating, location.pathname]); const handleTourComplete = () => { setShowTour(false); diff --git a/frontend/src/pages/accounts/AccountsPage.tsx b/frontend/src/pages/accounts/AccountsPage.tsx index cde937a..f2861a7 100644 --- a/frontend/src/pages/accounts/AccountsPage.tsx +++ b/frontend/src/pages/accounts/AccountsPage.tsx @@ -40,6 +40,7 @@ import { } from '@tabler/icons-react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import api from '../../services/api'; +import { useIsReadOnly } from '../../stores/authStore'; const INVESTMENT_TYPES = ['inv_cd', 'inv_money_market', 'inv_treasury', 'inv_savings', 'inv_brokerage']; @@ -126,6 +127,7 @@ export function AccountsPage() { const [filterType, setFilterType] = useState(null); const [showArchived, setShowArchived] = useState(false); const queryClient = useQueryClient(); + const isReadOnly = useIsReadOnly(); // ── Accounts query ── const { data: accounts = [], isLoading } = useQuery({ @@ -502,9 +504,11 @@ export function AccountsPage() { onChange={(e) => setShowArchived(e.currentTarget.checked)} size="sm" /> - + {!isReadOnly && ( + + )} @@ -578,7 +582,7 @@ export function AccountsPage() { onArchive={archiveMutation.mutate} onSetPrimary={(id) => setPrimaryMutation.mutate(id)} onAdjustBalance={handleAdjustBalance} - + isReadOnly={isReadOnly} /> {investments.filter(i => i.is_active).length > 0 && ( <> @@ -596,7 +600,7 @@ export function AccountsPage() { onArchive={archiveMutation.mutate} onSetPrimary={(id) => setPrimaryMutation.mutate(id)} onAdjustBalance={handleAdjustBalance} - + isReadOnly={isReadOnly} /> {operatingInvestments.length > 0 && ( <> @@ -614,7 +618,7 @@ export function AccountsPage() { onArchive={archiveMutation.mutate} onSetPrimary={(id) => setPrimaryMutation.mutate(id)} onAdjustBalance={handleAdjustBalance} - + isReadOnly={isReadOnly} /> {reserveInvestments.length > 0 && ( <> @@ -632,7 +636,7 @@ export function AccountsPage() { onArchive={archiveMutation.mutate} onSetPrimary={(id) => setPrimaryMutation.mutate(id)} onAdjustBalance={handleAdjustBalance} - + isReadOnly={isReadOnly} isArchivedView /> @@ -934,6 +938,7 @@ function AccountTable({ onArchive, onSetPrimary, onAdjustBalance, + isReadOnly = false, isArchivedView = false, }: { accounts: Account[]; @@ -941,6 +946,7 @@ function AccountTable({ onArchive: (a: Account) => void; onSetPrimary: (id: string) => void; onAdjustBalance: (a: Account) => void; + isReadOnly?: boolean; isArchivedView?: boolean; }) { const hasRates = accounts.some((a) => a.interest_rate && parseFloat(a.interest_rate) > 0); @@ -1029,42 +1035,44 @@ function AccountTable({ {a.is_1099_reportable ? 1099 : ''} - - {!a.is_system && ( - - onSetPrimary(a.id)} - > - {a.is_primary ? : } + {!isReadOnly && ( + + {!a.is_system && ( + + onSetPrimary(a.id)} + > + {a.is_primary ? : } + + + )} + {!a.is_system && ( + + onAdjustBalance(a)}> + + + + )} + + onEdit(a)}> + - )} - {!a.is_system && ( - - onAdjustBalance(a)}> - - - - )} - - onEdit(a)}> - - - - {!a.is_system && ( - - onArchive(a)} - > - {a.is_active ? : } - - - )} - + {!a.is_system && ( + + onArchive(a)} + > + {a.is_active ? : } + + + )} + + )} ); diff --git a/frontend/src/pages/assessment-groups/AssessmentGroupsPage.tsx b/frontend/src/pages/assessment-groups/AssessmentGroupsPage.tsx index f83d542..6e90dbe 100644 --- a/frontend/src/pages/assessment-groups/AssessmentGroupsPage.tsx +++ b/frontend/src/pages/assessment-groups/AssessmentGroupsPage.tsx @@ -11,6 +11,7 @@ import { } from '@tabler/icons-react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import api from '../../services/api'; +import { useIsReadOnly } from '../../stores/authStore'; interface AssessmentGroup { id: string; @@ -52,6 +53,7 @@ export function AssessmentGroupsPage() { const [opened, { open, close }] = useDisclosure(false); const [editing, setEditing] = useState(null); const queryClient = useQueryClient(); + const isReadOnly = useIsReadOnly(); const { data: groups = [], isLoading } = useQuery({ queryKey: ['assessment-groups'], @@ -156,9 +158,11 @@ export function AssessmentGroupsPage() { Assessment Groups Manage property types with different assessment rates and frequencies - + {!isReadOnly && ( + + )} @@ -274,28 +278,30 @@ export function AssessmentGroupsPage() { - - + {!isReadOnly && ( + + + !g.is_default && setDefaultMutation.mutate(g.id)} + disabled={g.is_default} + > + {g.is_default ? : } + + + handleEdit(g)}> + + !g.is_default && setDefaultMutation.mutate(g.id)} - disabled={g.is_default} + color={g.is_active ? 'gray' : 'green'} + onClick={() => archiveMutation.mutate(g)} > - {g.is_default ? : } + - - handleEdit(g)}> - - - archiveMutation.mutate(g)} - > - - - + + )} ))} diff --git a/frontend/src/pages/budgets/BudgetsPage.tsx b/frontend/src/pages/budgets/BudgetsPage.tsx index 1033531..d4bb354 100644 --- a/frontend/src/pages/budgets/BudgetsPage.tsx +++ b/frontend/src/pages/budgets/BudgetsPage.tsx @@ -7,6 +7,7 @@ import { notifications } from '@mantine/notifications'; import { IconDeviceFloppy, IconUpload, IconDownload, IconInfoCircle } from '@tabler/icons-react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import api from '../../services/api'; +import { useIsReadOnly } from '../../stores/authStore'; interface BudgetLine { account_id: string; @@ -96,6 +97,7 @@ export function BudgetsPage() { const [budgetData, setBudgetData] = useState([]); const queryClient = useQueryClient(); const fileInputRef = useRef(null); + const isReadOnly = useIsReadOnly(); const { isLoading } = useQuery({ queryKey: ['budgets', year], @@ -257,24 +259,26 @@ export function BudgetsPage() { > Download Template - - - + {!isReadOnly && (<> + + + + )} @@ -394,6 +398,7 @@ export function BudgetsPage() { hideControls decimalScale={2} min={0} + disabled={isReadOnly} styles={{ input: { textAlign: 'right', fontFamily: 'monospace' } }} /> diff --git a/frontend/src/pages/capital-projects/CapitalProjectsPage.tsx b/frontend/src/pages/capital-projects/CapitalProjectsPage.tsx index e7f67e1..746c2ec 100644 --- a/frontend/src/pages/capital-projects/CapitalProjectsPage.tsx +++ b/frontend/src/pages/capital-projects/CapitalProjectsPage.tsx @@ -14,6 +14,7 @@ import { import { useNavigate } from 'react-router-dom'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import api from '../../services/api'; +import { useIsReadOnly } from '../../stores/authStore'; // --------------------------------------------------------------------------- // Types & constants @@ -215,6 +216,7 @@ export function CapitalProjectsPage() { const [dragOverYear, setDragOverYear] = useState(null); const printModeRef = useRef(false); const queryClient = useQueryClient(); + const isReadOnly = useIsReadOnly(); // ---- Data fetching ---- @@ -511,9 +513,9 @@ export function CapitalProjectsPage() { {formatPlannedDate(p.planned_date) || '-'} - handleEdit(p)}> + {!isReadOnly && handleEdit(p)}> - + } ))} diff --git a/frontend/src/pages/investments/InvestmentsPage.tsx b/frontend/src/pages/investments/InvestmentsPage.tsx index e73d511..b174873 100644 --- a/frontend/src/pages/investments/InvestmentsPage.tsx +++ b/frontend/src/pages/investments/InvestmentsPage.tsx @@ -10,6 +10,7 @@ import { notifications } from '@mantine/notifications'; import { IconPlus, IconEdit } from '@tabler/icons-react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import api from '../../services/api'; +import { useIsReadOnly } from '../../stores/authStore'; interface Investment { id: string; name: string; institution: string; account_number_last4: string; @@ -25,6 +26,7 @@ export function InvestmentsPage() { const [opened, { open, close }] = useDisclosure(false); const [editing, setEditing] = useState(null); const queryClient = useQueryClient(); + const isReadOnly = useIsReadOnly(); const { data: investments = [], isLoading } = useQuery({ queryKey: ['investments'], @@ -95,7 +97,7 @@ export function InvestmentsPage() { Investment Accounts - + {!isReadOnly && } Total Principal{fmt(totalPrincipal)} @@ -139,7 +141,7 @@ export function InvestmentsPage() { ) : '-'} {inv.maturity_date ? new Date(inv.maturity_date).toLocaleDateString() : '-'} - handleEdit(inv)}> + {!isReadOnly && handleEdit(inv)}>} ))} {investments.length === 0 && No investments yet} diff --git a/frontend/src/pages/monthly-actuals/MonthlyActualsPage.tsx b/frontend/src/pages/monthly-actuals/MonthlyActualsPage.tsx index 615578e..e01b319 100644 --- a/frontend/src/pages/monthly-actuals/MonthlyActualsPage.tsx +++ b/frontend/src/pages/monthly-actuals/MonthlyActualsPage.tsx @@ -9,6 +9,7 @@ import { } from '@tabler/icons-react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import api from '../../services/api'; +import { useIsReadOnly } from '../../stores/authStore'; import { AttachmentPanel } from '../../components/attachments/AttachmentPanel'; interface ActualLine { @@ -64,6 +65,7 @@ export function MonthlyActualsPage() { const [editedAmounts, setEditedAmounts] = useState>({}); const [savedJEId, setSavedJEId] = useState(null); const queryClient = useQueryClient(); + const isReadOnly = useIsReadOnly(); const yearOptions = Array.from({ length: 5 }, (_, i) => { const y = new Date().getFullYear() - 2 + i; @@ -204,6 +206,7 @@ export function MonthlyActualsPage() { hideControls decimalScale={2} allowNegative + disabled={isReadOnly} styles={{ input: { textAlign: 'right', fontFamily: 'monospace' } }} /> @@ -229,14 +232,16 @@ export function MonthlyActualsPage() { v && setMonth(v)} w={150} /> - + {!isReadOnly && ( + + )} diff --git a/frontend/src/pages/org-members/OrgMembersPage.tsx b/frontend/src/pages/org-members/OrgMembersPage.tsx index 7738dcb..7fbf75e 100644 --- a/frontend/src/pages/org-members/OrgMembersPage.tsx +++ b/frontend/src/pages/org-members/OrgMembersPage.tsx @@ -13,7 +13,7 @@ import { } from '@tabler/icons-react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import api from '../../services/api'; -import { useAuthStore } from '../../stores/authStore'; +import { useAuthStore, useIsReadOnly } from '../../stores/authStore'; interface OrgMember { id: string; @@ -52,6 +52,7 @@ export function OrgMembersPage() { const [editingMember, setEditingMember] = useState(null); const queryClient = useQueryClient(); const { user, currentOrg } = useAuthStore(); + const isReadOnly = useIsReadOnly(); const { data: members = [], isLoading } = useQuery({ queryKey: ['org-members'], @@ -162,9 +163,11 @@ export function OrgMembersPage() { Organization Members Manage who has access to {currentOrg?.name} - + {!isReadOnly && ( + + )} @@ -259,20 +262,22 @@ export function OrgMembersPage() { {member.lastLoginAt ? new Date(member.lastLoginAt).toLocaleDateString() : 'Never'} - - - handleEditRole(member)}> - - - - {member.userId !== user?.id && ( - - handleRemove(member)}> - + {!isReadOnly && ( + + + handleEditRole(member)}> + - )} - + {member.userId !== user?.id && ( + + handleRemove(member)}> + + + + )} + + )} ))} diff --git a/frontend/src/pages/payments/PaymentsPage.tsx b/frontend/src/pages/payments/PaymentsPage.tsx index 20f8836..7e5f074 100644 --- a/frontend/src/pages/payments/PaymentsPage.tsx +++ b/frontend/src/pages/payments/PaymentsPage.tsx @@ -10,6 +10,7 @@ import { notifications } from '@mantine/notifications'; import { IconPlus } from '@tabler/icons-react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import api from '../../services/api'; +import { useIsReadOnly } from '../../stores/authStore'; interface Payment { id: string; unit_id: string; unit_number: string; invoice_id: string; @@ -20,6 +21,7 @@ interface Payment { export function PaymentsPage() { const [opened, { open, close }] = useDisclosure(false); const queryClient = useQueryClient(); + const isReadOnly = useIsReadOnly(); const { data: payments = [], isLoading } = useQuery({ queryKey: ['payments'], @@ -74,7 +76,7 @@ export function PaymentsPage() { Payments - + {!isReadOnly && } diff --git a/frontend/src/pages/projects/ProjectsPage.tsx b/frontend/src/pages/projects/ProjectsPage.tsx index 4abfc36..92f0159 100644 --- a/frontend/src/pages/projects/ProjectsPage.tsx +++ b/frontend/src/pages/projects/ProjectsPage.tsx @@ -12,6 +12,7 @@ import { IconPlus, IconEdit, IconUpload, IconDownload, IconLock, IconLockOpen } import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import api from '../../services/api'; import { parseCSV, downloadBlob } from '../../utils/csv'; +import { useIsReadOnly } from '../../stores/authStore'; // --------------------------------------------------------------------------- // Types & constants @@ -78,6 +79,7 @@ export function ProjectsPage() { const [editing, setEditing] = useState(null); const queryClient = useQueryClient(); const fileInputRef = useRef(null); + const isReadOnly = useIsReadOnly(); // ---- Data fetching ---- @@ -331,14 +333,16 @@ export function ProjectsPage() { - - - + {!isReadOnly && (<> + + + + )} @@ -451,9 +455,11 @@ export function ProjectsPage() { {formatDate(p.planned_date)} - handleEdit(p)}> - - + {!isReadOnly && ( + handleEdit(p)}> + + + )} ))} diff --git a/frontend/src/pages/reserves/ReservesPage.tsx b/frontend/src/pages/reserves/ReservesPage.tsx index 4a8c275..1f75b20 100644 --- a/frontend/src/pages/reserves/ReservesPage.tsx +++ b/frontend/src/pages/reserves/ReservesPage.tsx @@ -11,6 +11,7 @@ import { notifications } from '@mantine/notifications'; import { IconPlus, IconEdit } from '@tabler/icons-react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import api from '../../services/api'; +import { useIsReadOnly } from '../../stores/authStore'; interface ReserveComponent { id: string; name: string; category: string; description: string; @@ -26,6 +27,7 @@ export function ReservesPage() { const [opened, { open, close }] = useDisclosure(false); const [editing, setEditing] = useState(null); const queryClient = useQueryClient(); + const isReadOnly = useIsReadOnly(); const { data: components = [], isLoading } = useQuery({ queryKey: ['reserve-components'], @@ -89,7 +91,7 @@ export function ReservesPage() { Reserve Components - + {!isReadOnly && } @@ -139,7 +141,7 @@ export function ReservesPage() { {c.condition_rating}/10 - handleEdit(c)}> + {!isReadOnly && handleEdit(c)}>} ); })} diff --git a/frontend/src/pages/transactions/TransactionsPage.tsx b/frontend/src/pages/transactions/TransactionsPage.tsx index b1a1073..8957e1d 100644 --- a/frontend/src/pages/transactions/TransactionsPage.tsx +++ b/frontend/src/pages/transactions/TransactionsPage.tsx @@ -12,6 +12,7 @@ import { IconPlus, IconEye, IconCheck, IconX, IconTrash, IconShieldCheck } from import { AttachmentPanel } from '../../components/attachments/AttachmentPanel'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import api from '../../services/api'; +import { useIsReadOnly } from '../../stores/authStore'; interface JournalEntryLine { id?: string; @@ -48,6 +49,7 @@ export function TransactionsPage() { const [opened, { open, close }] = useDisclosure(false); const [viewId, setViewId] = useState(null); const queryClient = useQueryClient(); + const isReadOnly = useIsReadOnly(); const { data: entries = [], isLoading } = useQuery({ queryKey: ['journal-entries'], @@ -164,9 +166,11 @@ export function TransactionsPage() { Journal Entries - + {!isReadOnly && ( + + )}
@@ -216,14 +220,14 @@ export function TransactionsPage() { - {!e.is_posted && !e.is_void && ( + {!isReadOnly && !e.is_posted && !e.is_void && ( postMutation.mutate(e.id)}> )} - {e.is_posted && !e.is_void && ( + {!isReadOnly && e.is_posted && !e.is_void && ( voidMutation.mutate(e.id)}> diff --git a/frontend/src/pages/units/UnitsPage.tsx b/frontend/src/pages/units/UnitsPage.tsx index 4739c10..c27d0e3 100644 --- a/frontend/src/pages/units/UnitsPage.tsx +++ b/frontend/src/pages/units/UnitsPage.tsx @@ -10,6 +10,7 @@ import { IconPlus, IconEdit, IconSearch, IconTrash, IconInfoCircle, IconUpload, import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import api from '../../services/api'; import { parseCSV, downloadBlob } from '../../utils/csv'; +import { useIsReadOnly } from '../../stores/authStore'; interface Unit { id: string; @@ -42,6 +43,7 @@ export function UnitsPage() { const [deleteConfirm, setDeleteConfirm] = useState(null); const queryClient = useQueryClient(); const fileInputRef = useRef(null); + const isReadOnly = useIsReadOnly(); const { data: units = [], isLoading } = useQuery({ queryKey: ['units'], @@ -163,18 +165,20 @@ export function UnitsPage() { - - - {hasGroups ? ( - - ) : ( - - - - )} + {!isReadOnly && (<> + + + {hasGroups ? ( + + ) : ( + + + + )} + )} @@ -224,16 +228,18 @@ export function UnitsPage() { {u.status} - - handleEdit(u)}> - - - - setDeleteConfirm(u)}> - + {!isReadOnly && ( + + handleEdit(u)}> + - - + + setDeleteConfirm(u)}> + + + + + )} ))} diff --git a/frontend/src/pages/vendors/VendorsPage.tsx b/frontend/src/pages/vendors/VendorsPage.tsx index 3e38800..6516a71 100644 --- a/frontend/src/pages/vendors/VendorsPage.tsx +++ b/frontend/src/pages/vendors/VendorsPage.tsx @@ -10,6 +10,7 @@ import { notifications } from '@mantine/notifications'; import { IconPlus, IconEdit, IconSearch, IconUpload, IconDownload } from '@tabler/icons-react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import api from '../../services/api'; +import { useIsReadOnly } from '../../stores/authStore'; import { parseCSV, downloadBlob } from '../../utils/csv'; interface Vendor { @@ -25,6 +26,7 @@ export function VendorsPage() { const [search, setSearch] = useState(''); const queryClient = useQueryClient(); const fileInputRef = useRef(null); + const isReadOnly = useIsReadOnly(); const { data: vendors = [], isLoading } = useQuery({ queryKey: ['vendors'], @@ -117,12 +119,14 @@ export function VendorsPage() { - - - + {!isReadOnly && (<> + + + + )} } @@ -146,7 +150,7 @@ export function VendorsPage() { {v.is_1099_eligible && 1099} {v.last_negotiated ? new Date(v.last_negotiated).toLocaleDateString() : '-'} ${parseFloat(v.ytd_payments || '0').toFixed(2)} - handleEdit(v)}> + {!isReadOnly && handleEdit(v)}>} ))} {filtered.length === 0 && No vendors yet} diff --git a/frontend/src/stores/authStore.ts b/frontend/src/stores/authStore.ts index 3851e62..9475e7c 100644 --- a/frontend/src/stores/authStore.ts +++ b/frontend/src/stores/authStore.ts @@ -42,6 +42,9 @@ interface AuthState { logout: () => void; } +/** Hook to check if the current user has read-only (viewer) access */ +export const useIsReadOnly = () => useAuthStore((s) => s.currentOrg?.role === 'viewer'); + export const useAuthStore = create()( persist( (set, get) => ({