Enforce granular capabilities across cashup

Backend (server-side enforcement, not just UI):
- auth.js: read caps from JWT; hasCap() + requireCap() helpers;
  legacy-token fallback (full access minus settings) so existing
  sessions keep working until re-login
- finalise: submit final, delete draft, bulk-finalise, attachments
- reports: multiday report, cash summary, debtors
- floats: float management + safe count
- settings: settings mutations (was is_admin)
- count: draft save, newbook fetch

Frontend:
- can(user, cap) helper; User.caps from /verify
- Nav items, routes and actions (Submit Final, delete, bulk-finalise)
  gated on capabilities; non-finalisers see a draft-only hint

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-07-02 14:19:30 +00:00
parent 43c332be9b
commit be670f724d
13 changed files with 117 additions and 52 deletions

View file

@ -8,21 +8,28 @@ import { FloatManagement } from './pages/FloatManagement'
import { SafeCount } from './pages/SafeCount'
import { CashSummary } from './pages/CashSummary'
import { SettingsPage } from './pages/Settings'
import type { User } from './types'
import { can, type User, type CashupCap } from './types'
function AppRoutes({ user }: { user: User }) {
// Landing route: first section the user can actually reach.
const home = can(user, 'count') ? '/daily' : '/history'
// Redirect to home if the user lacks the capability for a route.
const guard = (cap: CashupCap, el: React.ReactNode) =>
can(user, cap) ? el : <Navigate to={home} replace />
return (
<Layout user={user}>
<Routes>
<Route path="/" element={<Navigate to="/daily" replace />} />
<Route path="/daily" element={<DailyCashUp user={user} />} />
<Route path="/history" element={<History />} />
<Route path="/report" element={<MultiDayReport />} />
<Route path="/floats/*" element={<FloatManagement />} />
<Route path="/safe/*" element={<SafeCount />} />
<Route path="/summary" element={<CashSummary />} />
<Route path="/settings" element={<SettingsPage user={user} />} />
<Route path="*" element={<Navigate to="/daily" replace />} />
<Route path="/" element={<Navigate to={home} replace />} />
<Route path="/daily" element={guard('count', <DailyCashUp user={user} />)} />
<Route path="/history" element={<History user={user} />} />
<Route path="/report" element={guard('reports', <MultiDayReport />)} />
<Route path="/floats/*" element={guard('floats', <FloatManagement />)} />
<Route path="/safe/*" element={guard('floats', <SafeCount />)} />
<Route path="/summary" element={guard('reports', <CashSummary />)} />
<Route path="/settings" element={guard('settings', <SettingsPage user={user} />)} />
<Route path="*" element={<Navigate to={home} replace />} />
</Routes>
</Layout>
)

View file

@ -2,21 +2,22 @@ import { NavLink, useNavigate } from 'react-router-dom'
import {
Banknote, ClipboardList, BarChart2, Wallet, Vault, FileText, Settings, LogOut,
} from 'lucide-react'
import type { User } from '../types'
import { can, type User, type CashupCap } from '../types'
interface Props {
user: User
children: React.ReactNode
}
const navItems = [
{ to: '/daily', label: 'Daily Cash Up', icon: Banknote },
// `cap` gates the nav item's visibility; undefined = always shown (app access is enough).
const navItems: { to: string; label: string; icon: typeof Banknote; cap?: CashupCap }[] = [
{ to: '/daily', label: 'Daily Cash Up', icon: Banknote, cap: 'count' },
{ to: '/history', label: 'History', icon: ClipboardList },
{ to: '/report', label: 'Weekly Report', icon: BarChart2 },
{ to: '/floats', label: 'Float Management', icon: Wallet },
{ to: '/safe', label: 'Safe Count', icon: Vault },
{ to: '/summary', label: 'Cash Summary', icon: FileText },
{ to: '/settings',label: 'Settings', icon: Settings },
{ to: '/report', label: 'Weekly Report', icon: BarChart2, cap: 'reports' },
{ to: '/floats', label: 'Float Management', icon: Wallet, cap: 'floats' },
{ to: '/safe', label: 'Safe Count', icon: Vault, cap: 'floats' },
{ to: '/summary', label: 'Cash Summary', icon: FileText, cap: 'reports' },
{ to: '/settings',label: 'Settings', icon: Settings, cap: 'settings' },
]
export function Layout({ user, children }: Props) {
@ -45,7 +46,7 @@ export function Layout({ user, children }: Props) {
</div>
<div style={{ flex: 1, padding: '0.5rem 0', overflowY: 'auto' }}>
{navItems.map(({ to, label, icon: Icon }) => (
{navItems.filter(item => !item.cap || can(user, item.cap)).map(({ to, label, icon: Icon }) => (
<NavLink key={to} to={to} style={({ isActive }) => ({
display: 'flex', alignItems: 'center', gap: '0.625rem',
padding: '0.625rem 1rem', textDecoration: 'none',

View file

@ -3,7 +3,7 @@ import { RefreshCw, Save, CheckCircle, Loader, Camera, FileText, X } from 'lucid
import { api, uploadAttachment } from '../api'
import { PageHeader, Card, Btn, StatusBadge } from '../components/Layout'
import {
GBP_DENOMINATIONS, fmtGBP, today,
GBP_DENOMINATIONS, fmtGBP, today, can,
type User, type CashUp, type Denomination, type CardMachine,
type PaymentTotals, type ReconciliationRow, type TillPayment, type Attachment,
type TransactionBreakdown, type TransactionItem,
@ -35,7 +35,8 @@ interface Props { user: User }
// flow: checking → empty (no record) | editing (draft) | locked (final)
type PageState = 'checking' | 'empty' | 'editing' | 'locked'
export function DailyCashUp({ user: _user }: Props) {
export function DailyCashUp({ user }: Props) {
const canFinalise = can(user, 'finalise')
const [date, setDate] = useState(today())
const [pageState, setPageState] = useState<PageState>('checking')
const [cashUp, setCashUp] = useState<CashUp | null>(null)
@ -465,15 +466,21 @@ export function DailyCashUp({ user: _user }: Props) {
{/* Action buttons */}
{pageState === 'editing' && (
<div style={{ display: 'flex', gap: '0.75rem' }}>
<div style={{ display: 'flex', gap: '0.75rem', alignItems: 'center' }}>
<Btn onClick={() => save('draft')} disabled={saving} variant="secondary">
<Save size={14} style={{ marginRight: '0.4rem' }} />
{saving ? 'Saving…' : 'Save Draft'}
</Btn>
<Btn onClick={() => save('final')} disabled={saving}>
<CheckCircle size={14} style={{ marginRight: '0.4rem' }} />
{saving ? 'Submitting…' : 'Submit Final'}
</Btn>
{canFinalise ? (
<Btn onClick={() => save('final')} disabled={saving}>
<CheckCircle size={14} style={{ marginRight: '0.4rem' }} />
{saving ? 'Submitting…' : 'Submit Final'}
</Btn>
) : (
<span style={{ fontSize: '0.8rem', color: 'var(--text-mid)' }}>
Save as draft a manager with finalise permission will submit it.
</span>
)}
</div>
)}

View file

@ -2,11 +2,12 @@ import { useState, useEffect, useCallback } from 'react'
import { useNavigate } from 'react-router-dom'
import { api } from '../api'
import { PageHeader, Card, Btn, StatusBadge } from '../components/Layout'
import { fmtGBP, today } from '../types'
import type { CashUp } from '../types'
import { fmtGBP, today, can } from '../types'
import type { CashUp, User } from '../types'
export function History() {
export function History({ user }: { user: User }) {
const navigate = useNavigate()
const canFinalise = can(user, 'finalise')
const [rows, setRows] = useState<CashUp[]>([])
const [total, setTotal] = useState(0)
const [offset, setOffset] = useState(0)
@ -99,7 +100,7 @@ export function History() {
<input type="date" value={to} onChange={e => { setTo(e.target.value); setOffset(0) }} style={inpSt} />
</div>
<Btn onClick={() => { setOffset(0); load() }} small>Filter</Btn>
{selected.size > 0 && (
{canFinalise && selected.size > 0 && (
<Btn onClick={bulkFinalize} small>Finalise {selected.size} selected</Btn>
)}
</Card>
@ -129,7 +130,7 @@ export function History() {
{rows.map(row => (
<tr key={row.id} style={{ borderBottom: '1px solid var(--card-border)' }}>
<td style={tdSt}>
{row.status === 'draft' && (
{canFinalise && row.status === 'draft' && (
<input type="checkbox" checked={selected.has(row.id)}
onChange={() => toggleSelect(row.id)} />
)}
@ -151,7 +152,7 @@ export function History() {
onClick={() => navigate(`/daily?date=${row.session_date.slice(0, 10)}`)}>
{row.status === 'draft' ? 'Edit' : 'View'}
</Btn>
{row.status === 'draft' && (
{canFinalise && row.status === 'draft' && (
<Btn small variant="danger" onClick={() => deleteDraft(row.id)}>Delete</Btn>
)}
</td>

View file

@ -1,7 +1,7 @@
import { useState, useEffect } from 'react'
import { api } from '../api'
import { PageHeader, Card, Btn } from '../components/Layout'
import type { User } from '../types'
import { can, type User } from '../types'
interface SettingsData {
default_report_days: string
@ -110,7 +110,7 @@ export function SettingsPage({ user }: { user: User }) {
style={{ color: 'var(--gold)' }}>Settings service</a>.
</p>
</div>
{user.is_admin && (
{can(user, 'settings') && (
<Btn onClick={testConnection} disabled={testing} variant="secondary" small>
{testing ? 'Testing…' : 'Test Connection'}
</Btn>
@ -136,8 +136,8 @@ export function SettingsPage({ user }: { user: User }) {
</div>
</Card>
{/* Sales breakdown GL columns — admin only */}
{user.is_admin && (
{/* Sales breakdown GL columns — requires settings capability */}
{can(user, 'settings') && (
<Card style={{ marginBottom: '1rem' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }}>
<h2 style={{ fontSize: '1rem', fontWeight: 700 }}>Sales Breakdown Columns</h2>

View file

@ -1,7 +1,15 @@
export type CashupCap = 'count' | 'finalise' | 'reports' | 'floats' | 'settings'
export interface User {
email: string
name: string
is_admin: boolean
caps: CashupCap[]
}
// Admins implicitly hold every capability.
export function can(user: User, cap: CashupCap): boolean {
return user.is_admin || (user.caps?.includes(cap) ?? false)
}
export interface CashUp {