From be670f724ddcf81a434fc2782b09e9e701d6de45 Mon Sep 17 00:00:00 2001 From: jtricerolph Date: Thu, 2 Jul 2026 14:19:30 +0000 Subject: [PATCH] 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) --- backend/src/auth.js | 30 ++++++++++++++++++++++++++++++ backend/src/index.js | 6 +++--- backend/src/routes/cashup.js | 14 +++++++++++--- backend/src/routes/floats.js | 3 ++- backend/src/routes/newbook.js | 3 ++- backend/src/routes/reports.js | 3 ++- backend/src/routes/settings.js | 14 +++++++------- frontend/src/App.tsx | 27 +++++++++++++++++---------- frontend/src/components/Layout.tsx | 19 ++++++++++--------- frontend/src/pages/DailyCashUp.tsx | 21 ++++++++++++++------- frontend/src/pages/History.tsx | 13 +++++++------ frontend/src/pages/Settings.tsx | 8 ++++---- frontend/src/types.ts | 8 ++++++++ 13 files changed, 117 insertions(+), 52 deletions(-) diff --git a/backend/src/auth.js b/backend/src/auth.js index d358875..2efc5a1 100644 --- a/backend/src/auth.js +++ b/backend/src/auth.js @@ -27,9 +27,39 @@ export async function requireAuth(request, reply) { } } + // Capabilities arrive as ":" 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}` }) + } } } diff --git a/backend/src/index.js b/backend/src/index.js index 170092b..63c0b5f 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -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' }) diff --git a/backend/src/routes/cashup.js b/backend/src/routes/cashup.js index 3638f0e..41f5262 100644 --- a/backend/src/routes/cashup.js +++ b/backend/src/routes/cashup.js @@ -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' }) diff --git a/backend/src/routes/floats.js b/backend/src/routes/floats.js index 24e06bb..c26b56c 100644 --- a/backend/src/routes/floats.js +++ b/backend/src/routes/floats.js @@ -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 } diff --git a/backend/src/routes/newbook.js b/backend/src/routes/newbook.js index 269529c..3d30259 100644 --- a/backend/src/routes/newbook.js +++ b/backend/src/routes/newbook.js @@ -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) => { diff --git a/backend/src/routes/reports.js b/backend/src/routes/reports.js index 760c5d3..602b861 100644 --- a/backend/src/routes/reports.js +++ b/backend/src/routes/reports.js @@ -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) => { diff --git a/backend/src/routes/settings.js b/backend/src/routes/settings.js index a2e3542..a4a3a5c 100644 --- a/backend/src/routes/settings.js +++ b/backend/src/routes/settings.js @@ -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 { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 9d047d7..d111b0d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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 : + return ( - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> + } /> + )} /> + } /> + )} /> + )} /> + )} /> + )} /> + )} /> + } /> ) diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index d179df0..5ee9b75 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -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) {
- {navItems.map(({ to, label, icon: Icon }) => ( + {navItems.filter(item => !item.cap || can(user, item.cap)).map(({ to, label, icon: Icon }) => ( ({ display: 'flex', alignItems: 'center', gap: '0.625rem', padding: '0.625rem 1rem', textDecoration: 'none', diff --git a/frontend/src/pages/DailyCashUp.tsx b/frontend/src/pages/DailyCashUp.tsx index 0a62506..78eabbd 100644 --- a/frontend/src/pages/DailyCashUp.tsx +++ b/frontend/src/pages/DailyCashUp.tsx @@ -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('checking') const [cashUp, setCashUp] = useState(null) @@ -465,15 +466,21 @@ export function DailyCashUp({ user: _user }: Props) { {/* Action buttons */} {pageState === 'editing' && ( -
+
save('draft')} disabled={saving} variant="secondary"> {saving ? 'Saving…' : 'Save Draft'} - save('final')} disabled={saving}> - - {saving ? 'Submitting…' : 'Submit Final'} - + {canFinalise ? ( + save('final')} disabled={saving}> + + {saving ? 'Submitting…' : 'Submit Final'} + + ) : ( + + Save as draft — a manager with finalise permission will submit it. + + )}
)} diff --git a/frontend/src/pages/History.tsx b/frontend/src/pages/History.tsx index e06a2e0..caf21e0 100644 --- a/frontend/src/pages/History.tsx +++ b/frontend/src/pages/History.tsx @@ -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([]) const [total, setTotal] = useState(0) const [offset, setOffset] = useState(0) @@ -99,7 +100,7 @@ export function History() { { setTo(e.target.value); setOffset(0) }} style={inpSt} />
{ setOffset(0); load() }} small>Filter - {selected.size > 0 && ( + {canFinalise && selected.size > 0 && ( Finalise {selected.size} selected )} @@ -129,7 +130,7 @@ export function History() { {rows.map(row => ( - {row.status === 'draft' && ( + {canFinalise && row.status === 'draft' && ( 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'} - {row.status === 'draft' && ( + {canFinalise && row.status === 'draft' && ( deleteDraft(row.id)}>Delete )} diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index a79068e..dcd97d4 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -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.

- {user.is_admin && ( + {can(user, 'settings') && ( {testing ? 'Testing…' : 'Test Connection'} @@ -136,8 +136,8 @@ export function SettingsPage({ user }: { user: User }) { - {/* Sales breakdown GL columns — admin only */} - {user.is_admin && ( + {/* Sales breakdown GL columns — requires settings capability */} + {can(user, 'settings') && (

Sales Breakdown Columns

diff --git a/frontend/src/types.ts b/frontend/src/types.ts index e894182..d41a728 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -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 {