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

@ -27,9 +27,39 @@ export async function requireAuth(request, reply) {
}
}
// Capabilities arrive as "<app>:<cap>" strings in the JWT. Store the bare
// cap slugs for this app (e.g. "finalise") plus admin status.
const prefix = `${APP_SLUG}:`
let caps
if (Array.isArray(payload.caps)) {
caps = payload.caps.filter(c => c.startsWith(prefix)).map(c => c.slice(prefix.length))
} else {
// Legacy token issued before granular capabilities existed. Reproduce the
// old behaviour: full access except settings (which was is_admin-gated).
// These users get precise capabilities the next time they log in.
caps = ['count', 'finalise', 'reports', 'floats']
}
request.user = {
email: payload.sub,
name: payload.name,
is_admin: payload.is_admin ?? false,
caps,
}
}
// Returns true if the authenticated user holds the given capability.
// Admins implicitly hold every capability.
export function hasCap(request, cap) {
return request.user?.is_admin === true || request.user?.caps?.includes(cap) === true
}
// Fastify preHandler factory — reject the request unless the user holds `cap`.
// Use after requireAuth: { preHandler: [requireAuth, requireCap('finalise')] }
export function requireCap(cap) {
return async (request, reply) => {
if (!hasCap(request, cap)) {
return reply.status(403).send({ error: `Missing capability: ${cap}` })
}
}
}

View file

@ -38,14 +38,14 @@ await app.register(floatRoutes)
await app.register(settingsRoutes)
// File upload for cash up receipt attachments
import { requireAuth } from './auth.js'
import { requireAuth, requireCap } from './auth.js'
import { pool } from './db.js'
import { createWriteStream } from 'fs'
import { mkdir } from 'fs/promises'
import { randomUUID } from 'crypto'
import { extname } from 'path'
app.post('/api/attachments/upload/:cash_up_id', { preHandler: requireAuth }, async (req, reply) => {
app.post('/api/attachments/upload/:cash_up_id', { preHandler: [requireAuth, requireCap('count')] }, async (req, reply) => {
const cashUpId = parseInt(req.params.cash_up_id)
const { rows } = await pool.query('SELECT id FROM cash_ups WHERE id = $1', [cashUpId])
if (!rows.length) return reply.status(404).send({ error: 'Cash up not found' })
@ -89,7 +89,7 @@ app.post('/api/attachments/upload/:cash_up_id', { preHandler: requireAuth }, asy
return ins.rows[0]
})
app.delete('/api/attachments/:id', { preHandler: requireAuth }, async (req, reply) => {
app.delete('/api/attachments/:id', { preHandler: [requireAuth, requireCap('count')] }, async (req, reply) => {
const { rows } = await pool.query('SELECT * FROM cash_count_attachments WHERE id = $1', [req.params.id])
if (!rows.length) return reply.status(404).send({ error: 'Not found' })

View file

@ -1,5 +1,5 @@
import { pool } from '../db.js'
import { requireAuth } from '../auth.js'
import { requireAuth, hasCap } from '../auth.js'
export async function cashupRoutes(app) {
app.addHook('preHandler', requireAuth)
@ -38,6 +38,12 @@ export async function cashupRoutes(app) {
if (!session_date) return reply.status(400).send({ error: 'session_date required' })
if (!['draft', 'final'].includes(status)) return reply.status(400).send({ error: 'invalid status' })
// Draft save needs 'count'; finalising needs 'finalise'.
if (!hasCap(req, 'count')) return reply.status(403).send({ error: 'Missing capability: count' })
if (status === 'final' && !hasCap(req, 'finalise')) {
return reply.status(403).send({ error: 'Missing capability: finalise' })
}
let totalFloat = 0, totalCash = 0
for (const d of denominations) {
if (d.count_type === 'float') totalFloat += parseFloat(d.total_amount || 0)
@ -100,8 +106,9 @@ export async function cashupRoutes(app) {
return { message: status === 'final' ? 'Cash up submitted.' : 'Saved as draft.', cash_up_id: cashUpId }
})
// DELETE /api/cashup/:id
// DELETE /api/cashup/:id (requires cashup:finalise)
app.delete('/api/cashup/:id', async (req, reply) => {
if (!hasCap(req, 'finalise')) return reply.status(403).send({ error: 'Missing capability: finalise' })
const { rows } = await pool.query('SELECT status FROM cash_ups WHERE id = $1', [req.params.id])
if (!rows.length) return reply.status(404).send({ error: 'Not found' })
if (rows[0].status === 'final') return reply.status(409).send({ error: 'Cannot delete a finalised cash up' })
@ -109,8 +116,9 @@ export async function cashupRoutes(app) {
return { message: 'Deleted' }
})
// POST /api/cashup/bulk-finalize
// POST /api/cashup/bulk-finalize (requires cashup:finalise)
app.post('/api/cashup/bulk-finalize', async (req, reply) => {
if (!hasCap(req, 'finalise')) return reply.status(403).send({ error: 'Missing capability: finalise' })
const { ids } = req.body
if (!Array.isArray(ids) || !ids.length) return reply.status(400).send({ error: 'ids required' })

View file

@ -1,8 +1,9 @@
import { pool } from '../db.js'
import { requireAuth } from '../auth.js'
import { requireAuth, requireCap } from '../auth.js'
export async function floatRoutes(app) {
app.addHook('preHandler', requireAuth)
app.addHook('preHandler', requireCap('floats'))
// POST /api/floats/save
// body: { count_type, count_date, denominations, receipts?, total_counted, total_receipts?, target_amount?, variance?, notes }

View file

@ -1,5 +1,5 @@
import { pool } from '../db.js'
import { requireAuth } from '../auth.js'
import { requireAuth, requireCap } from '../auth.js'
import {
fetchPaymentsByDate,
calculatePaymentTotals,
@ -9,6 +9,7 @@ import {
export async function newbookRoutes(app) {
app.addHook('preHandler', requireAuth)
app.addHook('preHandler', requireCap('count'))
// POST /api/newbook/payments { date: 'YYYY-MM-DD' }
app.post('/api/newbook/payments', async (req, reply) => {

View file

@ -1,5 +1,5 @@
import { pool } from '../db.js'
import { requireAuth } from '../auth.js'
import { requireAuth, requireCap } from '../auth.js'
import {
fetchPaymentsByDateRange,
fetchDailyAuditSummary,
@ -25,6 +25,7 @@ function buildDateRange(startDate, numDays) {
export async function reportRoutes(app) {
app.addHook('preHandler', requireAuth)
app.addHook('preHandler', requireCap('reports'))
// POST /api/reports/multiday { start_date, num_days }
app.post('/api/reports/multiday', async (req, reply) => {

View file

@ -1,5 +1,5 @@
import { pool } from '../db.js'
import { requireAuth } from '../auth.js'
import { requireAuth, hasCap } from '../auth.js'
import { testConnection, fetchGlAccountsGrouped } from '../lib/newbook.js'
const ALL_KEYS = [
@ -16,9 +16,9 @@ export async function settingsRoutes(app) {
return Object.fromEntries(rows.map(r => [r.key, r.value]))
})
// PUT /api/settings (admin only)
// PUT /api/settings (requires cashup:settings)
app.put('/api/settings', async (req, reply) => {
if (!req.user.is_admin) return reply.status(403).send({ error: 'Admin only' })
if (!hasCap(req, 'settings')) return reply.status(403).send({ error: 'Missing capability: settings' })
for (const [key, value] of Object.entries(req.body)) {
if (!ALL_KEYS.includes(key)) continue
await pool.query(
@ -29,16 +29,16 @@ export async function settingsRoutes(app) {
return { message: 'Settings saved.' }
})
// POST /api/settings/test-connection (admin only) — proxies to settings service
// POST /api/settings/test-connection (requires cashup:settings) — proxies to settings service
app.post('/api/settings/test-connection', async (req, reply) => {
if (!req.user.is_admin) return reply.status(403).send({ error: 'Admin only' })
if (!hasCap(req, 'settings')) return reply.status(403).send({ error: 'Missing capability: settings' })
const result = await testConnection()
return result
})
// POST /api/settings/refresh-gl-accounts (admin only)
// POST /api/settings/refresh-gl-accounts (requires cashup:settings)
app.post('/api/settings/refresh-gl-accounts', async (req, reply) => {
if (!req.user.is_admin) return reply.status(403).send({ error: 'Admin only' })
if (!hasCap(req, 'settings')) return reply.status(403).send({ error: 'Missing capability: settings' })
let groups
try {

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 {