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

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