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 = { request.user = {
email: payload.sub, email: payload.sub,
name: payload.name, name: payload.name,
is_admin: payload.is_admin ?? false, 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) await app.register(settingsRoutes)
// File upload for cash up receipt attachments // File upload for cash up receipt attachments
import { requireAuth } from './auth.js' import { requireAuth, requireCap } from './auth.js'
import { pool } from './db.js' import { pool } from './db.js'
import { createWriteStream } from 'fs' import { createWriteStream } from 'fs'
import { mkdir } from 'fs/promises' import { mkdir } from 'fs/promises'
import { randomUUID } from 'crypto' import { randomUUID } from 'crypto'
import { extname } from 'path' 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 cashUpId = parseInt(req.params.cash_up_id)
const { rows } = await pool.query('SELECT id FROM cash_ups WHERE id = $1', [cashUpId]) 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' }) 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] 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]) 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' }) if (!rows.length) return reply.status(404).send({ error: 'Not found' })

View file

@ -1,5 +1,5 @@
import { pool } from '../db.js' import { pool } from '../db.js'
import { requireAuth } from '../auth.js' import { requireAuth, hasCap } from '../auth.js'
export async function cashupRoutes(app) { export async function cashupRoutes(app) {
app.addHook('preHandler', requireAuth) 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 (!session_date) return reply.status(400).send({ error: 'session_date required' })
if (!['draft', 'final'].includes(status)) return reply.status(400).send({ error: 'invalid status' }) 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 let totalFloat = 0, totalCash = 0
for (const d of denominations) { for (const d of denominations) {
if (d.count_type === 'float') totalFloat += parseFloat(d.total_amount || 0) 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 } 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) => { 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]) 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.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' }) 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' } 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) => { 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 const { ids } = req.body
if (!Array.isArray(ids) || !ids.length) return reply.status(400).send({ error: 'ids required' }) 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 { pool } from '../db.js'
import { requireAuth } from '../auth.js' import { requireAuth, requireCap } from '../auth.js'
export async function floatRoutes(app) { export async function floatRoutes(app) {
app.addHook('preHandler', requireAuth) app.addHook('preHandler', requireAuth)
app.addHook('preHandler', requireCap('floats'))
// POST /api/floats/save // POST /api/floats/save
// body: { count_type, count_date, denominations, receipts?, total_counted, total_receipts?, target_amount?, variance?, notes } // 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 { pool } from '../db.js'
import { requireAuth } from '../auth.js' import { requireAuth, requireCap } from '../auth.js'
import { import {
fetchPaymentsByDate, fetchPaymentsByDate,
calculatePaymentTotals, calculatePaymentTotals,
@ -9,6 +9,7 @@ import {
export async function newbookRoutes(app) { export async function newbookRoutes(app) {
app.addHook('preHandler', requireAuth) app.addHook('preHandler', requireAuth)
app.addHook('preHandler', requireCap('count'))
// POST /api/newbook/payments { date: 'YYYY-MM-DD' } // POST /api/newbook/payments { date: 'YYYY-MM-DD' }
app.post('/api/newbook/payments', async (req, reply) => { app.post('/api/newbook/payments', async (req, reply) => {

View file

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

View file

@ -1,5 +1,5 @@
import { pool } from '../db.js' import { pool } from '../db.js'
import { requireAuth } from '../auth.js' import { requireAuth, hasCap } from '../auth.js'
import { testConnection, fetchGlAccountsGrouped } from '../lib/newbook.js' import { testConnection, fetchGlAccountsGrouped } from '../lib/newbook.js'
const ALL_KEYS = [ const ALL_KEYS = [
@ -16,9 +16,9 @@ export async function settingsRoutes(app) {
return Object.fromEntries(rows.map(r => [r.key, r.value])) 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) => { 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)) { for (const [key, value] of Object.entries(req.body)) {
if (!ALL_KEYS.includes(key)) continue if (!ALL_KEYS.includes(key)) continue
await pool.query( await pool.query(
@ -29,16 +29,16 @@ export async function settingsRoutes(app) {
return { message: 'Settings saved.' } 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) => { 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() const result = await testConnection()
return result 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) => { 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 let groups
try { try {

View file

@ -8,21 +8,28 @@ import { FloatManagement } from './pages/FloatManagement'
import { SafeCount } from './pages/SafeCount' import { SafeCount } from './pages/SafeCount'
import { CashSummary } from './pages/CashSummary' import { CashSummary } from './pages/CashSummary'
import { SettingsPage } from './pages/Settings' import { SettingsPage } from './pages/Settings'
import type { User } from './types' import { can, type User, type CashupCap } from './types'
function AppRoutes({ user }: { user: User }) { 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 ( return (
<Layout user={user}> <Layout user={user}>
<Routes> <Routes>
<Route path="/" element={<Navigate to="/daily" replace />} /> <Route path="/" element={<Navigate to={home} replace />} />
<Route path="/daily" element={<DailyCashUp user={user} />} /> <Route path="/daily" element={guard('count', <DailyCashUp user={user} />)} />
<Route path="/history" element={<History />} /> <Route path="/history" element={<History user={user} />} />
<Route path="/report" element={<MultiDayReport />} /> <Route path="/report" element={guard('reports', <MultiDayReport />)} />
<Route path="/floats/*" element={<FloatManagement />} /> <Route path="/floats/*" element={guard('floats', <FloatManagement />)} />
<Route path="/safe/*" element={<SafeCount />} /> <Route path="/safe/*" element={guard('floats', <SafeCount />)} />
<Route path="/summary" element={<CashSummary />} /> <Route path="/summary" element={guard('reports', <CashSummary />)} />
<Route path="/settings" element={<SettingsPage user={user} />} /> <Route path="/settings" element={guard('settings', <SettingsPage user={user} />)} />
<Route path="*" element={<Navigate to="/daily" replace />} /> <Route path="*" element={<Navigate to={home} replace />} />
</Routes> </Routes>
</Layout> </Layout>
) )

View file

@ -2,21 +2,22 @@ import { NavLink, useNavigate } from 'react-router-dom'
import { import {
Banknote, ClipboardList, BarChart2, Wallet, Vault, FileText, Settings, LogOut, Banknote, ClipboardList, BarChart2, Wallet, Vault, FileText, Settings, LogOut,
} from 'lucide-react' } from 'lucide-react'
import type { User } from '../types' import { can, type User, type CashupCap } from '../types'
interface Props { interface Props {
user: User user: User
children: React.ReactNode children: React.ReactNode
} }
const navItems = [ // `cap` gates the nav item's visibility; undefined = always shown (app access is enough).
{ to: '/daily', label: 'Daily Cash Up', icon: Banknote }, 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: '/history', label: 'History', icon: ClipboardList },
{ to: '/report', label: 'Weekly Report', icon: BarChart2 }, { to: '/report', label: 'Weekly Report', icon: BarChart2, cap: 'reports' },
{ to: '/floats', label: 'Float Management', icon: Wallet }, { to: '/floats', label: 'Float Management', icon: Wallet, cap: 'floats' },
{ to: '/safe', label: 'Safe Count', icon: Vault }, { to: '/safe', label: 'Safe Count', icon: Vault, cap: 'floats' },
{ to: '/summary', label: 'Cash Summary', icon: FileText }, { to: '/summary', label: 'Cash Summary', icon: FileText, cap: 'reports' },
{ to: '/settings',label: 'Settings', icon: Settings }, { to: '/settings',label: 'Settings', icon: Settings, cap: 'settings' },
] ]
export function Layout({ user, children }: Props) { export function Layout({ user, children }: Props) {
@ -45,7 +46,7 @@ export function Layout({ user, children }: Props) {
</div> </div>
<div style={{ flex: 1, padding: '0.5rem 0', overflowY: 'auto' }}> <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 }) => ({ <NavLink key={to} to={to} style={({ isActive }) => ({
display: 'flex', alignItems: 'center', gap: '0.625rem', display: 'flex', alignItems: 'center', gap: '0.625rem',
padding: '0.625rem 1rem', textDecoration: 'none', 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 { api, uploadAttachment } from '../api'
import { PageHeader, Card, Btn, StatusBadge } from '../components/Layout' import { PageHeader, Card, Btn, StatusBadge } from '../components/Layout'
import { import {
GBP_DENOMINATIONS, fmtGBP, today, GBP_DENOMINATIONS, fmtGBP, today, can,
type User, type CashUp, type Denomination, type CardMachine, type User, type CashUp, type Denomination, type CardMachine,
type PaymentTotals, type ReconciliationRow, type TillPayment, type Attachment, type PaymentTotals, type ReconciliationRow, type TillPayment, type Attachment,
type TransactionBreakdown, type TransactionItem, type TransactionBreakdown, type TransactionItem,
@ -35,7 +35,8 @@ interface Props { user: User }
// flow: checking → empty (no record) | editing (draft) | locked (final) // flow: checking → empty (no record) | editing (draft) | locked (final)
type PageState = 'checking' | 'empty' | 'editing' | 'locked' 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 [date, setDate] = useState(today())
const [pageState, setPageState] = useState<PageState>('checking') const [pageState, setPageState] = useState<PageState>('checking')
const [cashUp, setCashUp] = useState<CashUp | null>(null) const [cashUp, setCashUp] = useState<CashUp | null>(null)
@ -465,15 +466,21 @@ export function DailyCashUp({ user: _user }: Props) {
{/* Action buttons */} {/* Action buttons */}
{pageState === 'editing' && ( {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"> <Btn onClick={() => save('draft')} disabled={saving} variant="secondary">
<Save size={14} style={{ marginRight: '0.4rem' }} /> <Save size={14} style={{ marginRight: '0.4rem' }} />
{saving ? 'Saving…' : 'Save Draft'} {saving ? 'Saving…' : 'Save Draft'}
</Btn> </Btn>
<Btn onClick={() => save('final')} disabled={saving}> {canFinalise ? (
<CheckCircle size={14} style={{ marginRight: '0.4rem' }} /> <Btn onClick={() => save('final')} disabled={saving}>
{saving ? 'Submitting…' : 'Submit Final'} <CheckCircle size={14} style={{ marginRight: '0.4rem' }} />
</Btn> {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> </div>
)} )}

View file

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

View file

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