RBAC: Enforce read-only viewer role across backend and frontend

- Add global WriteAccessGuard that blocks POST/PUT/PATCH/DELETE for viewer role
- Add @AllowViewer() decorator for endpoints viewers need (switch-org, intro-seen, AI recommendations)
- Add useIsReadOnly hook to auth store for frontend role checks
- Hide write UI (add/edit/delete/import buttons, inline editors) in all 13 data pages for viewers
- Disable inline NumberInputs on Budgets and Monthly Actuals pages for viewers
- Skip onboarding wizard for viewer role users

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-01 09:18:32 -05:00
parent 07347a644f
commit c92eb1b57b
20 changed files with 269 additions and 156 deletions

View File

@@ -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) {

View File

@@ -0,0 +1,4 @@
import { SetMetadata } from '@nestjs/common';
export const ALLOW_VIEWER_KEY = 'allowViewer';
export const AllowViewer = () => SetMetadata(ALLOW_VIEWER_KEY, true);

View File

@@ -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<boolean>(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;
}
}

View File

@@ -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'];

View File

@@ -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);
}

View File

@@ -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);

View File

@@ -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<string | null>(null);
const [showArchived, setShowArchived] = useState(false);
const queryClient = useQueryClient();
const isReadOnly = useIsReadOnly();
// ── Accounts query ──
const { data: accounts = [], isLoading } = useQuery<Account[]>({
@@ -502,9 +504,11 @@ export function AccountsPage() {
onChange={(e) => setShowArchived(e.currentTarget.checked)}
size="sm"
/>
{!isReadOnly && (
<Button leftSection={<IconPlus size={16} />} onClick={handleNew}>
Add Account
</Button>
)}
</Group>
</Group>
@@ -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
/>
</Tabs.Panel>
@@ -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,6 +1035,7 @@ function AccountTable({
{a.is_1099_reportable ? <Badge size="xs" color="yellow">1099</Badge> : ''}
</Table.Td>
<Table.Td>
{!isReadOnly && (
<Group gap={4}>
{!a.is_system && (
<Tooltip label={a.is_primary ? 'Primary account' : 'Set as Primary'}>
@@ -1065,6 +1072,7 @@ function AccountTable({
</Tooltip>
)}
</Group>
)}
</Table.Td>
</Table.Tr>
);

View File

@@ -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<AssessmentGroup | null>(null);
const queryClient = useQueryClient();
const isReadOnly = useIsReadOnly();
const { data: groups = [], isLoading } = useQuery<AssessmentGroup[]>({
queryKey: ['assessment-groups'],
@@ -156,9 +158,11 @@ export function AssessmentGroupsPage() {
<Title order={2}>Assessment Groups</Title>
<Text c="dimmed" size="sm">Manage property types with different assessment rates and frequencies</Text>
</div>
{!isReadOnly && (
<Button leftSection={<IconPlus size={16} />} onClick={handleNew}>
Add Group
</Button>
)}
</Group>
<SimpleGrid cols={{ base: 1, sm: 2, md: 4 }}>
@@ -274,6 +278,7 @@ export function AssessmentGroupsPage() {
</Badge>
</Table.Td>
<Table.Td>
{!isReadOnly && (
<Group gap={4}>
<Tooltip label={g.is_default ? 'Default group' : 'Set as default'}>
<ActionIcon
@@ -296,6 +301,7 @@ export function AssessmentGroupsPage() {
<IconArchive size={16} />
</ActionIcon>
</Group>
)}
</Table.Td>
</Table.Tr>
))}

View File

@@ -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<BudgetLine[]>([]);
const queryClient = useQueryClient();
const fileInputRef = useRef<HTMLInputElement>(null);
const isReadOnly = useIsReadOnly();
const { isLoading } = useQuery<BudgetLine[]>({
queryKey: ['budgets', year],
@@ -257,6 +259,7 @@ export function BudgetsPage() {
>
Download Template
</Button>
{!isReadOnly && (<>
<Button
variant="outline"
leftSection={<IconUpload size={16} />}
@@ -275,6 +278,7 @@ export function BudgetsPage() {
<Button leftSection={<IconDeviceFloppy size={16} />} onClick={() => saveMutation.mutate()} loading={saveMutation.isPending}>
Save Budget
</Button>
</>)}
</Group>
</Group>
@@ -394,6 +398,7 @@ export function BudgetsPage() {
hideControls
decimalScale={2}
min={0}
disabled={isReadOnly}
styles={{ input: { textAlign: 'right', fontFamily: 'monospace' } }}
/>
</Table.Td>

View File

@@ -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<number | null>(null);
const printModeRef = useRef(false);
const queryClient = useQueryClient();
const isReadOnly = useIsReadOnly();
// ---- Data fetching ----
@@ -511,9 +513,9 @@ export function CapitalProjectsPage() {
</Table.Td>
<Table.Td>{formatPlannedDate(p.planned_date) || '-'}</Table.Td>
<Table.Td>
<ActionIcon variant="subtle" onClick={() => handleEdit(p)}>
{!isReadOnly && <ActionIcon variant="subtle" onClick={() => handleEdit(p)}>
<IconEdit size={16} />
</ActionIcon>
</ActionIcon>}
</Table.Td>
</Table.Tr>
))}

View File

@@ -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<Investment | null>(null);
const queryClient = useQueryClient();
const isReadOnly = useIsReadOnly();
const { data: investments = [], isLoading } = useQuery<Investment[]>({
queryKey: ['investments'],
@@ -95,7 +97,7 @@ export function InvestmentsPage() {
<Stack>
<Group justify="space-between">
<Title order={2}>Investment Accounts</Title>
<Button leftSection={<IconPlus size={16} />} onClick={() => { setEditing(null); form.reset(); open(); }}>Add Investment</Button>
{!isReadOnly && <Button leftSection={<IconPlus size={16} />} onClick={() => { setEditing(null); form.reset(); open(); }}>Add Investment</Button>}
</Group>
<SimpleGrid cols={{ base: 1, sm: 3, lg: 5 }}>
<Card withBorder p="md"><Text size="xs" c="dimmed">Total Principal</Text><Text fw={700} size="xl">{fmt(totalPrincipal)}</Text></Card>
@@ -139,7 +141,7 @@ export function InvestmentsPage() {
) : '-'}
</Table.Td>
<Table.Td>{inv.maturity_date ? new Date(inv.maturity_date).toLocaleDateString() : '-'}</Table.Td>
<Table.Td><ActionIcon variant="subtle" onClick={() => handleEdit(inv)}><IconEdit size={16} /></ActionIcon></Table.Td>
<Table.Td>{!isReadOnly && <ActionIcon variant="subtle" onClick={() => handleEdit(inv)}><IconEdit size={16} /></ActionIcon>}</Table.Td>
</Table.Tr>
))}
{investments.length === 0 && <Table.Tr><Table.Td colSpan={11}><Text ta="center" c="dimmed" py="lg">No investments yet</Text></Table.Td></Table.Tr>}

View File

@@ -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<Record<string, number>>({});
const [savedJEId, setSavedJEId] = useState<string | null>(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' } }}
/>
</Table.Td>
@@ -229,6 +232,7 @@ export function MonthlyActualsPage() {
<Group>
<Select data={yearOptions} value={year} onChange={(v) => v && setYear(v)} w={100} />
<Select data={monthOptions} value={month} onChange={(v) => v && setMonth(v)} w={150} />
{!isReadOnly && (
<Button
leftSection={<IconDeviceFloppy size={16} />}
onClick={() => saveMutation.mutate()}
@@ -237,6 +241,7 @@ export function MonthlyActualsPage() {
>
{hasChanges ? 'Save & Reconcile' : 'Save Actuals'}
</Button>
)}
</Group>
</Group>

View File

@@ -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<OrgMember | null>(null);
const queryClient = useQueryClient();
const { user, currentOrg } = useAuthStore();
const isReadOnly = useIsReadOnly();
const { data: members = [], isLoading } = useQuery<OrgMember[]>({
queryKey: ['org-members'],
@@ -162,9 +163,11 @@ export function OrgMembersPage() {
<Title order={2}>Organization Members</Title>
<Text c="dimmed" size="sm">Manage who has access to {currentOrg?.name}</Text>
</div>
{!isReadOnly && (
<Button leftSection={<IconUserPlus size={16} />} onClick={openAdd}>
Add Member
</Button>
)}
</Group>
<SimpleGrid cols={{ base: 1, sm: 3 }}>
@@ -259,6 +262,7 @@ export function OrgMembersPage() {
{member.lastLoginAt ? new Date(member.lastLoginAt).toLocaleDateString() : 'Never'}
</Table.Td>
<Table.Td>
{!isReadOnly && (
<Group gap={4}>
<Tooltip label="Change role">
<ActionIcon variant="subtle" onClick={() => handleEditRole(member)}>
@@ -273,6 +277,7 @@ export function OrgMembersPage() {
</Tooltip>
)}
</Group>
)}
</Table.Td>
</Table.Tr>
))}

View File

@@ -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<Payment[]>({
queryKey: ['payments'],
@@ -74,7 +76,7 @@ export function PaymentsPage() {
<Stack>
<Group justify="space-between">
<Title order={2}>Payments</Title>
<Button leftSection={<IconPlus size={16} />} onClick={open}>Record Payment</Button>
{!isReadOnly && <Button leftSection={<IconPlus size={16} />} onClick={open}>Record Payment</Button>}
</Group>
<Table striped highlightOnHover>
<Table.Thead>

View File

@@ -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<Project | null>(null);
const queryClient = useQueryClient();
const fileInputRef = useRef<HTMLInputElement>(null);
const isReadOnly = useIsReadOnly();
// ---- Data fetching ----
@@ -331,6 +333,7 @@ export function ProjectsPage() {
<Button variant="light" leftSection={<IconDownload size={16} />} onClick={handleExport} disabled={projects.length === 0}>
Export CSV
</Button>
{!isReadOnly && (<>
<Button variant="light" leftSection={<IconUpload size={16} />} onClick={() => fileInputRef.current?.click()}
loading={importMutation.isPending}>
Import CSV
@@ -339,6 +342,7 @@ export function ProjectsPage() {
<Button leftSection={<IconPlus size={16} />} onClick={handleNew}>
+ Add Project
</Button>
</>)}
</Group>
</Group>
@@ -451,9 +455,11 @@ export function ProjectsPage() {
</Table.Td>
<Table.Td>{formatDate(p.planned_date)}</Table.Td>
<Table.Td>
{!isReadOnly && (
<ActionIcon variant="subtle" onClick={() => handleEdit(p)}>
<IconEdit size={16} />
</ActionIcon>
)}
</Table.Td>
</Table.Tr>
))}

View File

@@ -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<ReserveComponent | null>(null);
const queryClient = useQueryClient();
const isReadOnly = useIsReadOnly();
const { data: components = [], isLoading } = useQuery<ReserveComponent[]>({
queryKey: ['reserve-components'],
@@ -89,7 +91,7 @@ export function ReservesPage() {
<Stack>
<Group justify="space-between">
<Title order={2}>Reserve Components</Title>
<Button leftSection={<IconPlus size={16} />} onClick={() => { setEditing(null); form.reset(); open(); }}>Add Component</Button>
{!isReadOnly && <Button leftSection={<IconPlus size={16} />} onClick={() => { setEditing(null); form.reset(); open(); }}>Add Component</Button>}
</Group>
<SimpleGrid cols={{ base: 1, sm: 3 }}>
<Card withBorder p="md">
@@ -139,7 +141,7 @@ export function ReservesPage() {
{c.condition_rating}/10
</Badge>
</Table.Td>
<Table.Td><ActionIcon variant="subtle" onClick={() => handleEdit(c)}><IconEdit size={16} /></ActionIcon></Table.Td>
<Table.Td>{!isReadOnly && <ActionIcon variant="subtle" onClick={() => handleEdit(c)}><IconEdit size={16} /></ActionIcon>}</Table.Td>
</Table.Tr>
);
})}

View File

@@ -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<string | null>(null);
const queryClient = useQueryClient();
const isReadOnly = useIsReadOnly();
const { data: entries = [], isLoading } = useQuery<JournalEntry[]>({
queryKey: ['journal-entries'],
@@ -164,9 +166,11 @@ export function TransactionsPage() {
<Stack>
<Group justify="space-between">
<Title order={2}>Journal Entries</Title>
{!isReadOnly && (
<Button leftSection={<IconPlus size={16} />} onClick={open}>
New Entry
</Button>
)}
</Group>
<Table striped highlightOnHover>
@@ -216,14 +220,14 @@ export function TransactionsPage() {
<IconEye size={16} />
</ActionIcon>
</Tooltip>
{!e.is_posted && !e.is_void && (
{!isReadOnly && !e.is_posted && !e.is_void && (
<Tooltip label="Post">
<ActionIcon variant="subtle" color="green" onClick={() => postMutation.mutate(e.id)}>
<IconCheck size={16} />
</ActionIcon>
</Tooltip>
)}
{e.is_posted && !e.is_void && (
{!isReadOnly && e.is_posted && !e.is_void && (
<Tooltip label="Void">
<ActionIcon variant="subtle" color="red" onClick={() => voidMutation.mutate(e.id)}>
<IconX size={16} />

View File

@@ -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<Unit | null>(null);
const queryClient = useQueryClient();
const fileInputRef = useRef<HTMLInputElement>(null);
const isReadOnly = useIsReadOnly();
const { data: units = [], isLoading } = useQuery<Unit[]>({
queryKey: ['units'],
@@ -163,6 +165,7 @@ export function UnitsPage() {
<Button variant="light" leftSection={<IconDownload size={16} />} onClick={handleExport} disabled={units.length === 0}>
Export CSV
</Button>
{!isReadOnly && (<>
<Button variant="light" leftSection={<IconUpload size={16} />} onClick={() => fileInputRef.current?.click()}
loading={importMutation.isPending}>
Import CSV
@@ -175,6 +178,7 @@ export function UnitsPage() {
<Button leftSection={<IconPlus size={16} />} disabled>Add Unit</Button>
</Tooltip>
)}
</>)}
</Group>
</Group>
@@ -224,6 +228,7 @@ export function UnitsPage() {
</Table.Td>
<Table.Td><Badge color={u.status === 'active' ? 'green' : 'gray'} size="sm">{u.status}</Badge></Table.Td>
<Table.Td>
{!isReadOnly && (
<Group gap={4}>
<ActionIcon variant="subtle" onClick={() => handleEdit(u)}>
<IconEdit size={16} />
@@ -234,6 +239,7 @@ export function UnitsPage() {
</ActionIcon>
</Tooltip>
</Group>
)}
</Table.Td>
</Table.Tr>
))}

View File

@@ -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<HTMLInputElement>(null);
const isReadOnly = useIsReadOnly();
const { data: vendors = [], isLoading } = useQuery<Vendor[]>({
queryKey: ['vendors'],
@@ -117,12 +119,14 @@ export function VendorsPage() {
<Button variant="light" leftSection={<IconDownload size={16} />} onClick={handleExport} disabled={vendors.length === 0}>
Export CSV
</Button>
{!isReadOnly && (<>
<Button variant="light" leftSection={<IconUpload size={16} />} onClick={() => fileInputRef.current?.click()}
loading={importMutation.isPending}>
Import CSV
</Button>
<input type="file" ref={fileInputRef} accept=".csv,.txt" style={{ display: 'none' }} onChange={handleFileChange} />
<Button leftSection={<IconPlus size={16} />} onClick={() => { setEditing(null); form.reset(); open(); }}>Add Vendor</Button>
</>)}
</Group>
</Group>
<TextInput placeholder="Search vendors..." leftSection={<IconSearch size={16} />}
@@ -146,7 +150,7 @@ export function VendorsPage() {
<Table.Td>{v.is_1099_eligible && <Badge color="orange" size="sm">1099</Badge>}</Table.Td>
<Table.Td>{v.last_negotiated ? new Date(v.last_negotiated).toLocaleDateString() : '-'}</Table.Td>
<Table.Td ta="right" ff="monospace">${parseFloat(v.ytd_payments || '0').toFixed(2)}</Table.Td>
<Table.Td><ActionIcon variant="subtle" onClick={() => handleEdit(v)}><IconEdit size={16} /></ActionIcon></Table.Td>
<Table.Td>{!isReadOnly && <ActionIcon variant="subtle" onClick={() => handleEdit(v)}><IconEdit size={16} /></ActionIcon>}</Table.Td>
</Table.Tr>
))}
{filtered.length === 0 && <Table.Tr><Table.Td colSpan={8}><Text ta="center" c="dimmed" py="lg">No vendors yet</Text></Table.Td></Table.Tr>}

View File

@@ -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<AuthState>()(
persist(
(set, get) => ({