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:
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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>}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
18
frontend/src/pages/vendors/VendorsPage.tsx
vendored
18
frontend/src/pages/vendors/VendorsPage.tsx
vendored
@@ -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>}
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
Reference in New Issue
Block a user