1 Commits

Author SHA1 Message Date
9d137a40d3 fix: enforce read-only restrictions for viewer role across 5 pages
Audit and fix viewer (read-only) user permissions:
- Dashboard: hide health score refresh buttons
- Accounts: hide investment edit icons
- Invoices: hide Apply Late Fees and Generate Invoices buttons
- Capital Planning: disable drag-and-drop, hide grip handles and edit buttons
- Investment Planning: hide AI Recommendations refresh button

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 09:59:20 -04:00
6 changed files with 60 additions and 52 deletions

View File

@@ -13,16 +13,6 @@ export class JournalEntriesService {
async findAll(filters: { from?: string; to?: string; accountId?: string; type?: string }) { async findAll(filters: { from?: string; to?: string; accountId?: string; type?: string }) {
let sql = ` let sql = `
SELECT je.*, SELECT je.*,
CASE
WHEN SUM(CASE WHEN a.account_type IN ('income','expense') THEN 1 ELSE 0 END) > 0
THEN COALESCE(SUM(CASE WHEN a.account_type IN ('income','expense') THEN jel.debit ELSE 0 END), 0)
ELSE COALESCE(SUM(jel.debit), 0)
END as total_debit,
CASE
WHEN SUM(CASE WHEN a.account_type IN ('income','expense') THEN 1 ELSE 0 END) > 0
THEN COALESCE(SUM(CASE WHEN a.account_type IN ('income','expense') THEN jel.credit ELSE 0 END), 0)
ELSE COALESCE(SUM(jel.credit), 0)
END as total_credit,
json_agg(json_build_object( json_agg(json_build_object(
'id', jel.id, 'account_id', jel.account_id, 'id', jel.id, 'account_id', jel.account_id,
'debit', jel.debit, 'credit', jel.credit, 'memo', jel.memo, 'debit', jel.debit, 'credit', jel.credit, 'memo', jel.memo,

View File

@@ -587,7 +587,7 @@ export function AccountsPage() {
{investments.filter(i => i.is_active).length > 0 && ( {investments.filter(i => i.is_active).length > 0 && (
<> <>
<Divider label="Investment Accounts" labelPosition="center" my="xs" /> <Divider label="Investment Accounts" labelPosition="center" my="xs" />
<InvestmentMiniTable investments={investments.filter(i => i.is_active)} onEdit={handleEditInvestment} /> <InvestmentMiniTable investments={investments.filter(i => i.is_active)} onEdit={handleEditInvestment} isReadOnly={isReadOnly} />
</> </>
)} )}
</Stack> </Stack>
@@ -605,7 +605,7 @@ export function AccountsPage() {
{operatingInvestments.length > 0 && ( {operatingInvestments.length > 0 && (
<> <>
<Divider label="Operating Investment Accounts" labelPosition="center" my="xs" /> <Divider label="Operating Investment Accounts" labelPosition="center" my="xs" />
<InvestmentMiniTable investments={operatingInvestments} onEdit={handleEditInvestment} /> <InvestmentMiniTable investments={operatingInvestments} onEdit={handleEditInvestment} isReadOnly={isReadOnly} />
</> </>
)} )}
</Stack> </Stack>
@@ -623,7 +623,7 @@ export function AccountsPage() {
{reserveInvestments.length > 0 && ( {reserveInvestments.length > 0 && (
<> <>
<Divider label="Reserve Investment Accounts" labelPosition="center" my="xs" /> <Divider label="Reserve Investment Accounts" labelPosition="center" my="xs" />
<InvestmentMiniTable investments={reserveInvestments} onEdit={handleEditInvestment} /> <InvestmentMiniTable investments={reserveInvestments} onEdit={handleEditInvestment} isReadOnly={isReadOnly} />
</> </>
)} )}
</Stack> </Stack>
@@ -1087,9 +1087,11 @@ function AccountTable({
function InvestmentMiniTable({ function InvestmentMiniTable({
investments, investments,
onEdit, onEdit,
isReadOnly = false,
}: { }: {
investments: Investment[]; investments: Investment[];
onEdit: (inv: Investment) => void; onEdit: (inv: Investment) => void;
isReadOnly?: boolean;
}) { }) {
const totalPrincipal = investments.reduce((s, i) => s + parseFloat(i.principal || '0'), 0); const totalPrincipal = investments.reduce((s, i) => s + parseFloat(i.principal || '0'), 0);
const totalValue = investments.reduce( const totalValue = investments.reduce(
@@ -1132,7 +1134,7 @@ function InvestmentMiniTable({
<Table.Th ta="right">Maturity Value</Table.Th> <Table.Th ta="right">Maturity Value</Table.Th>
<Table.Th>Maturity Date</Table.Th> <Table.Th>Maturity Date</Table.Th>
<Table.Th ta="right">Days Remaining</Table.Th> <Table.Th ta="right">Days Remaining</Table.Th>
<Table.Th></Table.Th> {!isReadOnly && <Table.Th></Table.Th>}
</Table.Tr> </Table.Tr>
</Table.Thead> </Table.Thead>
<Table.Tbody> <Table.Tbody>
@@ -1182,13 +1184,15 @@ function InvestmentMiniTable({
'-' '-'
)} )}
</Table.Td> </Table.Td>
<Table.Td> {!isReadOnly && (
<Tooltip label="Edit investment"> <Table.Td>
<ActionIcon variant="subtle" onClick={() => onEdit(inv)}> <Tooltip label="Edit investment">
<IconEdit size={16} /> <ActionIcon variant="subtle" onClick={() => onEdit(inv)}>
</ActionIcon> <IconEdit size={16} />
</Tooltip> </ActionIcon>
</Table.Td> </Tooltip>
</Table.Td>
)}
</Table.Tr> </Table.Tr>
))} ))}
</Table.Tbody> </Table.Tbody>

View File

@@ -72,9 +72,10 @@ interface KanbanCardProps {
project: Project; project: Project;
onEdit: (p: Project) => void; onEdit: (p: Project) => void;
onDragStart: (e: DragEvent<HTMLDivElement>, project: Project) => void; onDragStart: (e: DragEvent<HTMLDivElement>, project: Project) => void;
isReadOnly?: boolean;
} }
function KanbanCard({ project, onEdit, onDragStart }: KanbanCardProps) { function KanbanCard({ project, onEdit, onDragStart, isReadOnly }: KanbanCardProps) {
const plannedLabel = formatPlannedDate(project.planned_date); const plannedLabel = formatPlannedDate(project.planned_date);
// For projects in the Future bucket with a specific year, show the year // For projects in the Future bucket with a specific year, show the year
const currentYear = new Date().getFullYear(); const currentYear = new Date().getFullYear();
@@ -86,21 +87,23 @@ function KanbanCard({ project, onEdit, onDragStart }: KanbanCardProps) {
padding="sm" padding="sm"
radius="md" radius="md"
withBorder withBorder
draggable draggable={!isReadOnly}
onDragStart={(e) => onDragStart(e, project)} onDragStart={!isReadOnly ? (e) => onDragStart(e, project) : undefined}
style={{ cursor: 'grab', userSelect: 'none' }} style={{ cursor: isReadOnly ? 'default' : 'grab', userSelect: 'none' }}
mb="xs" mb="xs"
> >
<Group justify="space-between" wrap="nowrap" mb={4}> <Group justify="space-between" wrap="nowrap" mb={4}>
<Group gap={6} wrap="nowrap" style={{ overflow: 'hidden' }}> <Group gap={6} wrap="nowrap" style={{ overflow: 'hidden' }}>
<IconGripVertical size={14} style={{ flexShrink: 0, color: 'var(--mantine-color-dimmed)' }} /> {!isReadOnly && <IconGripVertical size={14} style={{ flexShrink: 0, color: 'var(--mantine-color-dimmed)' }} />}
<Text fw={600} size="sm" truncate> <Text fw={600} size="sm" truncate>
{project.name} {project.name}
</Text> </Text>
</Group> </Group>
<ActionIcon variant="subtle" size="sm" onClick={() => onEdit(project)}> {!isReadOnly && (
<IconEdit size={14} /> <ActionIcon variant="subtle" size="sm" onClick={() => onEdit(project)}>
</ActionIcon> <IconEdit size={14} />
</ActionIcon>
)}
</Group> </Group>
<Group gap={6} mb={6}> <Group gap={6} mb={6}>
@@ -148,11 +151,12 @@ interface KanbanColumnProps {
isDragOver: boolean; isDragOver: boolean;
onDragOverHandler: (e: DragEvent<HTMLDivElement>, year: number) => void; onDragOverHandler: (e: DragEvent<HTMLDivElement>, year: number) => void;
onDragLeave: () => void; onDragLeave: () => void;
isReadOnly?: boolean;
} }
function KanbanColumn({ function KanbanColumn({
year, projects, onEdit, onDragStart, onDrop, year, projects, onEdit, onDragStart, onDrop,
isDragOver, onDragOverHandler, onDragLeave, isDragOver, onDragOverHandler, onDragLeave, isReadOnly,
}: KanbanColumnProps) { }: KanbanColumnProps) {
const totalEst = projects.reduce((s, p) => s + parseFloat(p.estimated_cost || '0'), 0); const totalEst = projects.reduce((s, p) => s + parseFloat(p.estimated_cost || '0'), 0);
const isFuture = year === FUTURE_YEAR; const isFuture = year === FUTURE_YEAR;
@@ -178,9 +182,9 @@ function KanbanColumn({
border: isDragOver ? '2px dashed var(--mantine-color-blue-4)' : undefined, border: isDragOver ? '2px dashed var(--mantine-color-blue-4)' : undefined,
transition: 'background-color 150ms ease, border 150ms ease', transition: 'background-color 150ms ease, border 150ms ease',
}} }}
onDragOver={(e) => onDragOverHandler(e, year)} onDragOver={!isReadOnly ? (e) => onDragOverHandler(e, year) : undefined}
onDragLeave={onDragLeave} onDragLeave={!isReadOnly ? onDragLeave : undefined}
onDrop={(e) => onDrop(e, year)} onDrop={!isReadOnly ? (e) => onDrop(e, year) : undefined}
> >
<Group justify="space-between" mb="sm"> <Group justify="space-between" mb="sm">
<Title order={5}>{yearLabel(year)}</Title> <Title order={5}>{yearLabel(year)}</Title>
@@ -199,7 +203,7 @@ function KanbanColumn({
<Box style={{ flex: 1, minHeight: 60 }}> <Box style={{ flex: 1, minHeight: 60 }}>
{projects.length === 0 ? ( {projects.length === 0 ? (
<Text size="xs" c="dimmed" ta="center" py="lg"> <Text size="xs" c="dimmed" ta="center" py="lg">
Drop projects here {isReadOnly ? 'No projects' : 'Drop projects here'}
</Text> </Text>
) : useWideLayout ? ( ) : useWideLayout ? (
<div style={{ <div style={{
@@ -208,12 +212,12 @@ function KanbanColumn({
gap: 'var(--mantine-spacing-xs)', gap: 'var(--mantine-spacing-xs)',
}}> }}>
{projects.map((p) => ( {projects.map((p) => (
<KanbanCard key={p.id} project={p} onEdit={onEdit} onDragStart={onDragStart} /> <KanbanCard key={p.id} project={p} onEdit={onEdit} onDragStart={onDragStart} isReadOnly={isReadOnly} />
))} ))}
</div> </div>
) : ( ) : (
projects.map((p) => ( projects.map((p) => (
<KanbanCard key={p.id} project={p} onEdit={onEdit} onDragStart={onDragStart} /> <KanbanCard key={p.id} project={p} onEdit={onEdit} onDragStart={onDragStart} isReadOnly={isReadOnly} />
)) ))
)} )}
</Box> </Box>
@@ -595,6 +599,7 @@ export function CapitalProjectsPage() {
isDragOver={dragOverYear === year} isDragOver={dragOverYear === year}
onDragOverHandler={handleDragOver} onDragOverHandler={handleDragOver}
onDragLeave={handleDragLeave} onDragLeave={handleDragLeave}
isReadOnly={isReadOnly}
/> />
); );
})} })}

View File

@@ -18,7 +18,7 @@ import {
} from '@tabler/icons-react'; } from '@tabler/icons-react';
import { useState, useCallback } from 'react'; import { useState, useCallback } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useAuthStore } from '../../stores/authStore'; import { useAuthStore, useIsReadOnly } from '../../stores/authStore';
import api from '../../services/api'; import api from '../../services/api';
interface HealthScore { interface HealthScore {
@@ -311,6 +311,7 @@ interface DashboardData {
export function DashboardPage() { export function DashboardPage() {
const currentOrg = useAuthStore((s) => s.currentOrg); const currentOrg = useAuthStore((s) => s.currentOrg);
const isReadOnly = useIsReadOnly();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
// Track whether a refresh is in progress (per score type) for async polling // Track whether a refresh is in progress (per score type) for async polling
@@ -424,7 +425,7 @@ export function DashboardPage() {
</ThemeIcon> </ThemeIcon>
} }
isRefreshing={operatingRefreshing} isRefreshing={operatingRefreshing}
onRefresh={handleRefreshOperating} onRefresh={!isReadOnly ? handleRefreshOperating : undefined}
lastFailed={!!healthScores?.operating_last_failed} lastFailed={!!healthScores?.operating_last_failed}
/> />
<HealthScoreCard <HealthScoreCard
@@ -436,7 +437,7 @@ export function DashboardPage() {
</ThemeIcon> </ThemeIcon>
} }
isRefreshing={reserveRefreshing} isRefreshing={reserveRefreshing}
onRefresh={handleRefreshReserve} onRefresh={!isReadOnly ? handleRefreshReserve : undefined}
lastFailed={!!healthScores?.reserve_last_failed} lastFailed={!!healthScores?.reserve_last_failed}
/> />
</SimpleGrid> </SimpleGrid>

View File

@@ -36,6 +36,7 @@ import {
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { notifications } from '@mantine/notifications'; import { notifications } from '@mantine/notifications';
import api from '../../services/api'; import api from '../../services/api';
import { useIsReadOnly } from '../../stores/authStore';
// ── Types ── // ── Types ──
@@ -347,6 +348,7 @@ function RecommendationsDisplay({
export function InvestmentPlanningPage() { export function InvestmentPlanningPage() {
const [ratesExpanded, setRatesExpanded] = useState(true); const [ratesExpanded, setRatesExpanded] = useState(true);
const [isTriggering, setIsTriggering] = useState(false); const [isTriggering, setIsTriggering] = useState(false);
const isReadOnly = useIsReadOnly();
// Load financial snapshot on mount // Load financial snapshot on mount
const { data: snapshot, isLoading: snapshotLoading } = useQuery<FinancialSnapshot>({ const { data: snapshot, isLoading: snapshotLoading } = useQuery<FinancialSnapshot>({
@@ -696,15 +698,17 @@ export function InvestmentPlanningPage() {
</Text> </Text>
</div> </div>
</Group> </Group>
<Button {!isReadOnly && (
leftSection={<IconSparkles size={16} />} <Button
onClick={handleTriggerAI} leftSection={<IconSparkles size={16} />}
loading={isProcessing} onClick={handleTriggerAI}
variant="gradient" loading={isProcessing}
gradient={{ from: 'grape', to: 'violet' }} variant="gradient"
> gradient={{ from: 'grape', to: 'violet' }}
{aiResult ? 'Refresh Recommendations' : 'Get AI Recommendations'} >
</Button> {aiResult ? 'Refresh Recommendations' : 'Get AI Recommendations'}
</Button>
)}
</Group> </Group>
{/* Processing State */} {/* Processing State */}

View File

@@ -9,6 +9,7 @@ import { notifications } from '@mantine/notifications';
import { IconSend, IconInfoCircle, IconCheck, IconX } from '@tabler/icons-react'; import { IconSend, IconInfoCircle, IconCheck, IconX } from '@tabler/icons-react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '../../services/api'; import api from '../../services/api';
import { useIsReadOnly } from '../../stores/authStore';
interface Invoice { interface Invoice {
id: string; invoice_number: string; unit_number: string; unit_id: string; id: string; invoice_number: string; unit_number: string; unit_id: string;
@@ -64,6 +65,7 @@ export function InvoicesPage() {
const [preview, setPreview] = useState<Preview | null>(null); const [preview, setPreview] = useState<Preview | null>(null);
const [previewLoading, setPreviewLoading] = useState(false); const [previewLoading, setPreviewLoading] = useState(false);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const isReadOnly = useIsReadOnly();
const { data: invoices = [], isLoading } = useQuery<Invoice[]>({ const { data: invoices = [], isLoading } = useQuery<Invoice[]>({
queryKey: ['invoices'], queryKey: ['invoices'],
@@ -124,10 +126,12 @@ export function InvoicesPage() {
<Stack> <Stack>
<Group justify="space-between"> <Group justify="space-between">
<Title order={2}>Invoices</Title> <Title order={2}>Invoices</Title>
<Group> {!isReadOnly && (
<Button variant="outline" onClick={() => lateFeesMutation.mutate()} loading={lateFeesMutation.isPending}>Apply Late Fees</Button> <Group>
<Button leftSection={<IconSend size={16} />} onClick={openBulk}>Generate Invoices</Button> <Button variant="outline" onClick={() => lateFeesMutation.mutate()} loading={lateFeesMutation.isPending}>Apply Late Fees</Button>
</Group> <Button leftSection={<IconSend size={16} />} onClick={openBulk}>Generate Invoices</Button>
</Group>
)}
</Group> </Group>
<Group> <Group>
<Card withBorder p="sm"><Text size="xs" c="dimmed">Total Invoices</Text><Text fw={700}>{invoices.length}</Text></Card> <Card withBorder p="sm"><Text size="xs" c="dimmed">Total Invoices</Text><Text fw={700}>{invoices.length}</Text></Card>