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

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