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
|
|
@ -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>
|
||||
<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>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -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