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:
@@ -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>
|
||||
))}
|
||||
|
||||
Reference in New Issue
Block a user