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:
parent
43c332be9b
commit
be670f724d
13 changed files with 117 additions and 52 deletions
|
|
@ -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}` })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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' })
|
||||
|
||||
|
|
|
|||
|
|
@ -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' })
|
||||
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
{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>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue