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

@@ -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"
/>
<Button leftSection={<IconPlus size={16} />} onClick={handleNew}>
Add Account
</Button>
{!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,42 +1035,44 @@ function AccountTable({
{a.is_1099_reportable ? <Badge size="xs" color="yellow">1099</Badge> : ''}
</Table.Td>
<Table.Td>
<Group gap={4}>
{!a.is_system && (
<Tooltip label={a.is_primary ? 'Primary account' : 'Set as Primary'}>
<ActionIcon
variant="subtle"
color="yellow"
onClick={() => onSetPrimary(a.id)}
>
{a.is_primary ? <IconStarFilled size={16} /> : <IconStar size={16} />}
{!isReadOnly && (
<Group gap={4}>
{!a.is_system && (
<Tooltip label={a.is_primary ? 'Primary account' : 'Set as Primary'}>
<ActionIcon
variant="subtle"
color="yellow"
onClick={() => onSetPrimary(a.id)}
>
{a.is_primary ? <IconStarFilled size={16} /> : <IconStar size={16} />}
</ActionIcon>
</Tooltip>
)}
{!a.is_system && (
<Tooltip label="Adjust Balance">
<ActionIcon variant="subtle" color="blue" onClick={() => onAdjustBalance(a)}>
<IconAdjustments size={16} />
</ActionIcon>
</Tooltip>
)}
<Tooltip label="Edit account">
<ActionIcon variant="subtle" onClick={() => onEdit(a)}>
<IconEdit size={16} />
</ActionIcon>
</Tooltip>
)}
{!a.is_system && (
<Tooltip label="Adjust Balance">
<ActionIcon variant="subtle" color="blue" onClick={() => onAdjustBalance(a)}>
<IconAdjustments size={16} />
</ActionIcon>
</Tooltip>
)}
<Tooltip label="Edit account">
<ActionIcon variant="subtle" onClick={() => onEdit(a)}>
<IconEdit size={16} />
</ActionIcon>
</Tooltip>
{!a.is_system && (
<Tooltip label={a.is_active ? 'Archive account' : 'Restore account'}>
<ActionIcon
variant="subtle"
color={a.is_active ? 'gray' : 'green'}
onClick={() => onArchive(a)}
>
{a.is_active ? <IconArchive size={16} /> : <IconArchiveOff size={16} />}
</ActionIcon>
</Tooltip>
)}
</Group>
{!a.is_system && (
<Tooltip label={a.is_active ? 'Archive account' : 'Restore account'}>
<ActionIcon
variant="subtle"
color={a.is_active ? 'gray' : 'green'}
onClick={() => onArchive(a)}
>
{a.is_active ? <IconArchive size={16} /> : <IconArchiveOff size={16} />}
</ActionIcon>
</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>
<Button leftSection={<IconPlus size={16} />} onClick={handleNew}>
Add Group
</Button>
{!isReadOnly && (
<Button leftSection={<IconPlus size={16} />} onClick={handleNew}>
Add Group
</Button>
)}
</Group>
<SimpleGrid cols={{ base: 1, sm: 2, md: 4 }}>
@@ -274,28 +278,30 @@ export function AssessmentGroupsPage() {
</Badge>
</Table.Td>
<Table.Td>
<Group gap={4}>
<Tooltip label={g.is_default ? 'Default group' : 'Set as default'}>
{!isReadOnly && (
<Group gap={4}>
<Tooltip label={g.is_default ? 'Default group' : 'Set as default'}>
<ActionIcon
variant="subtle"
color={g.is_default ? 'yellow' : 'gray'}
onClick={() => !g.is_default && setDefaultMutation.mutate(g.id)}
disabled={g.is_default}
>
{g.is_default ? <IconStarFilled size={16} /> : <IconStar size={16} />}
</ActionIcon>
</Tooltip>
<ActionIcon variant="subtle" onClick={() => handleEdit(g)}>
<IconEdit size={16} />
</ActionIcon>
<ActionIcon
variant="subtle"
color={g.is_default ? 'yellow' : 'gray'}
onClick={() => !g.is_default && setDefaultMutation.mutate(g.id)}
disabled={g.is_default}
color={g.is_active ? 'gray' : 'green'}
onClick={() => archiveMutation.mutate(g)}
>
{g.is_default ? <IconStarFilled size={16} /> : <IconStar size={16} />}
<IconArchive size={16} />
</ActionIcon>
</Tooltip>
<ActionIcon variant="subtle" onClick={() => handleEdit(g)}>
<IconEdit size={16} />
</ActionIcon>
<ActionIcon
variant="subtle"
color={g.is_active ? 'gray' : 'green'}
onClick={() => archiveMutation.mutate(g)}
>
<IconArchive size={16} />
</ActionIcon>
</Group>
</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,24 +259,26 @@ export function BudgetsPage() {
>
Download Template
</Button>
<Button
variant="outline"
leftSection={<IconUpload size={16} />}
onClick={handleImportCSV}
loading={importMutation.isPending}
>
Import CSV
</Button>
<input
type="file"
ref={fileInputRef}
style={{ display: 'none' }}
accept=".csv,.txt"
onChange={handleFileChange}
/>
<Button leftSection={<IconDeviceFloppy size={16} />} onClick={() => saveMutation.mutate()} loading={saveMutation.isPending}>
Save Budget
</Button>
{!isReadOnly && (<>
<Button
variant="outline"
leftSection={<IconUpload size={16} />}
onClick={handleImportCSV}
loading={importMutation.isPending}
>
Import CSV
</Button>
<input
type="file"
ref={fileInputRef}
style={{ display: 'none' }}
accept=".csv,.txt"
onChange={handleFileChange}
/>
<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,14 +232,16 @@ 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} />
<Button
leftSection={<IconDeviceFloppy size={16} />}
onClick={() => saveMutation.mutate()}
loading={saveMutation.isPending}
disabled={lines.length === 0}
>
{hasChanges ? 'Save & Reconcile' : 'Save Actuals'}
</Button>
{!isReadOnly && (
<Button
leftSection={<IconDeviceFloppy size={16} />}
onClick={() => saveMutation.mutate()}
loading={saveMutation.isPending}
disabled={lines.length === 0}
>
{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>
<Button leftSection={<IconUserPlus size={16} />} onClick={openAdd}>
Add Member
</Button>
{!isReadOnly && (
<Button leftSection={<IconUserPlus size={16} />} onClick={openAdd}>
Add Member
</Button>
)}
</Group>
<SimpleGrid cols={{ base: 1, sm: 3 }}>
@@ -259,20 +262,22 @@ export function OrgMembersPage() {
{member.lastLoginAt ? new Date(member.lastLoginAt).toLocaleDateString() : 'Never'}
</Table.Td>
<Table.Td>
<Group gap={4}>
<Tooltip label="Change role">
<ActionIcon variant="subtle" onClick={() => handleEditRole(member)}>
<IconEdit size={16} />
</ActionIcon>
</Tooltip>
{member.userId !== user?.id && (
<Tooltip label="Remove member">
<ActionIcon variant="subtle" color="red" onClick={() => handleRemove(member)}>
<IconTrash size={16} />
{!isReadOnly && (
<Group gap={4}>
<Tooltip label="Change role">
<ActionIcon variant="subtle" onClick={() => handleEditRole(member)}>
<IconEdit size={16} />
</ActionIcon>
</Tooltip>
)}
</Group>
{member.userId !== user?.id && (
<Tooltip label="Remove member">
<ActionIcon variant="subtle" color="red" onClick={() => handleRemove(member)}>
<IconTrash size={16} />
</ActionIcon>
</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,14 +333,16 @@ export function ProjectsPage() {
<Button variant="light" leftSection={<IconDownload size={16} />} onClick={handleExport} disabled={projects.length === 0}>
Export CSV
</Button>
<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={handleNew}>
+ Add Project
</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={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>
<ActionIcon variant="subtle" onClick={() => handleEdit(p)}>
<IconEdit size={16} />
</ActionIcon>
{!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>
<Button leftSection={<IconPlus size={16} />} onClick={open}>
New Entry
</Button>
{!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,18 +165,20 @@ export function UnitsPage() {
<Button variant="light" leftSection={<IconDownload size={16} />} onClick={handleExport} disabled={units.length === 0}>
Export CSV
</Button>
<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} />
{hasGroups ? (
<Button leftSection={<IconPlus size={16} />} onClick={handleNew}>Add Unit</Button>
) : (
<Tooltip label="Create an assessment group first">
<Button leftSection={<IconPlus size={16} />} disabled>Add Unit</Button>
</Tooltip>
)}
{!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} />
{hasGroups ? (
<Button leftSection={<IconPlus size={16} />} onClick={handleNew}>Add Unit</Button>
) : (
<Tooltip label="Create an assessment group first">
<Button leftSection={<IconPlus size={16} />} disabled>Add Unit</Button>
</Tooltip>
)}
</>)}
</Group>
</Group>
@@ -224,16 +228,18 @@ export function UnitsPage() {
</Table.Td>
<Table.Td><Badge color={u.status === 'active' ? 'green' : 'gray'} size="sm">{u.status}</Badge></Table.Td>
<Table.Td>
<Group gap={4}>
<ActionIcon variant="subtle" onClick={() => handleEdit(u)}>
<IconEdit size={16} />
</ActionIcon>
<Tooltip label="Delete unit">
<ActionIcon variant="subtle" color="red" onClick={() => setDeleteConfirm(u)}>
<IconTrash size={16} />
{!isReadOnly && (
<Group gap={4}>
<ActionIcon variant="subtle" onClick={() => handleEdit(u)}>
<IconEdit size={16} />
</ActionIcon>
</Tooltip>
</Group>
<Tooltip label="Delete unit">
<ActionIcon variant="subtle" color="red" onClick={() => setDeleteConfirm(u)}>
<IconTrash size={16} />
</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>
<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>
{!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) => ({