Replace in-app Access Control with JWT cap enforcement

The in-app page-restriction system (admin-only toggles in Settings) was
nav-hiding only and duplicated functionality already covered by JWT caps
in the main stack auth service. All 9 pages in the restriction list were
already gated in Layout.tsx by existing caps.

Backend: add router-level requireCap() to enforce caps at the API layer:
- reports.py: Depends(require_cap("view"))
- logbook.py: Depends(require_cap("logbook"))
- search.py: Depends(require_cap("invoices"))

Frontend: remove the Access Control settings section entirely:
- Drop pageRestrictions query, restrictedPages/accessSaveMessage state,
  savePageRestrictionsMutation, isSectionAccessible helper
- Remove 'access' from SettingsSection type and sidebarItems
- Strip restrictPath from all sidebar items (no longer needed)

Access management is now fully centralised in the main stack auth service.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-07-12 23:17:22 +00:00
parent 1564a66283
commit 6f6e16c88f
4 changed files with 29 additions and 246 deletions

View file

@ -21,7 +21,7 @@ from models.logbook import (
) )
# from models.products import Product # TODO: Add Product model # from models.products import Product # TODO: Add Product model
router = APIRouter(prefix="/logbook", tags=["Logbook"]) router = APIRouter(prefix="/logbook", tags=["Logbook"], dependencies=[Depends(require_cap("logbook"))])
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)

View file

@ -19,7 +19,7 @@ from models.newbook import NewbookDailyRevenue, NewbookGLAccount, NewbookDailyOc
from models.cost_distribution import CostDistribution, CostDistributionEntry, DistributionStatus from models.cost_distribution import CostDistribution, CostDistributionEntry, DistributionStatus
from auth import get_current_user, require_cap from auth import get_current_user, require_cap
router = APIRouter() router = APIRouter(dependencies=[Depends(require_cap("view"))])
class RevenueEntryCreate(BaseModel): class RevenueEntryCreate(BaseModel):

View file

@ -19,7 +19,7 @@ from models.settings import KitchenSettings
from auth import get_current_user, require_cap from auth import get_current_user, require_cap
from services.price_history import PriceHistoryService from services.price_history import PriceHistoryService
router = APIRouter(prefix="/api/search", tags=["search"]) router = APIRouter(prefix="/api/search", tags=["search"], dependencies=[Depends(require_cap("invoices"))])
# ============ Response Models ============ # ============ Response Models ============

View file

@ -106,7 +106,7 @@ interface RoomCategory {
display_order: number display_order: number
} }
type SettingsSection = 'account' | 'access' | 'display' | 'azure' | 'email' | 'inbox' | 'dext' | 'newbook' | 'resos' | 'sambapos' | 'kds' | 'budget' | 'kitchen' | 'suppliers' | 'search' | 'nextcloud' | 'backup' | 'food_flags' | 'allergen_keywords' | 'ingredient_categories' | 'recipe_sections' | 'dish_courses' | 'api_access' | 'llm' | 'data' type SettingsSection = 'account' | 'display' | 'azure' | 'email' | 'inbox' | 'dext' | 'newbook' | 'resos' | 'sambapos' | 'kds' | 'budget' | 'kitchen' | 'suppliers' | 'search' | 'nextcloud' | 'backup' | 'food_flags' | 'allergen_keywords' | 'ingredient_categories' | 'recipe_sections' | 'dish_courses' | 'api_access' | 'llm' | 'data'
interface SambaPOSSettingsData { interface SambaPOSSettingsData {
sambapos_db_host: string | null sambapos_db_host: string | null
@ -274,7 +274,7 @@ interface ImapSyncStats {
} }
export default function Settings() { export default function Settings() {
const { user, token, logout, restrictedPages: authRestrictedPages } = useAuth() const { user, token, logout } = useAuth()
const queryClient = useQueryClient() const queryClient = useQueryClient()
const [activeSection, setActiveSection] = useState<SettingsSection>('account') const [activeSection, setActiveSection] = useState<SettingsSection>('account')
@ -738,19 +738,6 @@ export default function Settings() {
enabled: !!token, enabled: !!token,
}) })
// Fetch page restrictions
const { data: pageRestrictions } = useQuery<{ restricted_pages: string[] }>({
queryKey: ['page-restrictions'],
queryFn: async () => {
const res = await fetch('/kitchen/api/settings/page-restrictions', {
headers: { Authorization: `Bearer ${token}` },
})
if (!res.ok) throw new Error('Failed to fetch page restrictions')
return res.json()
},
enabled: !!token,
})
// Fetch Nextcloud settings // Fetch Nextcloud settings
const { data: nextcloudSettings } = useQuery<NextcloudSettingsData>({ const { data: nextcloudSettings } = useQuery<NextcloudSettingsData>({
queryKey: ['nextcloud-settings'], queryKey: ['nextcloud-settings'],
@ -869,17 +856,6 @@ export default function Settings() {
enabled: !!token && !!user?.is_admin && activeSection === 'inbox', enabled: !!token && !!user?.is_admin && activeSection === 'inbox',
}) })
// State for page restrictions
const [restrictedPages, setRestrictedPages] = useState<Set<string>>(new Set())
const [accessSaveMessage, setAccessSaveMessage] = useState<string | null>(null)
// Populate restricted pages from settings
useEffect(() => {
if (pageRestrictions?.restricted_pages) {
setRestrictedPages(new Set(pageRestrictions.restricted_pages))
}
}, [pageRestrictions])
useEffect(() => { useEffect(() => {
if (settings) { if (settings) {
setAzureEndpoint(settings.azure_endpoint || '') setAzureEndpoint(settings.azure_endpoint || '')
@ -1641,29 +1617,6 @@ export default function Settings() {
}, },
}) })
const savePageRestrictionsMutation = useMutation({
mutationFn: async (pages: string[]) => {
const res = await fetch('/kitchen/api/settings/page-restrictions', {
method: 'PATCH',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ restricted_pages: pages }),
})
if (!res.ok) throw new Error('Failed to save page restrictions')
return res.json()
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['page-restrictions'] })
setAccessSaveMessage('Page restrictions saved successfully')
setTimeout(() => setAccessSaveMessage(null), 3000)
},
onError: (error) => {
setAccessSaveMessage(`Error: ${error.message}`)
},
})
const reprocessMutation = useMutation({ const reprocessMutation = useMutation({
mutationFn: async () => { mutationFn: async () => {
const res = await fetch('/kitchen/api/invoices/reprocess-all', { const res = await fetch('/kitchen/api/invoices/reprocess-all', {
@ -2678,39 +2631,31 @@ export default function Settings() {
return <div style={styles.loading}>Loading settings...</div> return <div style={styles.loading}>Loading settings...</div>
} }
// Helper to check if a settings section is accessible const sidebarItems: { id: SettingsSection; label: string; adminOnly?: boolean; href?: string }[] = [
const isSectionAccessible = (restrictPath?: string) => {
if (!restrictPath) return true
if (user?.is_admin) return true
return !authRestrictedPages.includes(restrictPath)
}
const sidebarItems: { id: SettingsSection; label: string; adminOnly?: boolean; href?: string; restrictPath?: string }[] = [
{ id: 'account', label: 'Account' }, { id: 'account', label: 'Account' },
{ id: 'access', label: 'Access Control', adminOnly: true, restrictPath: '/settings-access' }, { id: 'display', label: 'Display' },
{ id: 'display', label: 'Display', restrictPath: '/settings-display' }, { id: 'azure', label: 'Azure OCR' },
{ id: 'azure', label: 'Azure OCR', restrictPath: '/settings-azure' }, { id: 'email', label: 'Email Configuration' },
{ id: 'email', label: 'Email Configuration', restrictPath: '/settings-email' }, { id: 'inbox', label: 'Email Inbox', adminOnly: true },
{ id: 'inbox', label: 'Email Inbox', adminOnly: true, restrictPath: '/settings-inbox' }, { id: 'dext', label: 'Dext Integration' },
{ id: 'dext', label: 'Dext Integration', restrictPath: '/settings-dext' }, { id: 'newbook', label: 'Newbook PMS' },
{ id: 'newbook', label: 'Newbook PMS', restrictPath: '/settings-newbook' }, { id: 'resos', label: 'Resos Bookings' },
{ id: 'resos', label: 'Resos Bookings', restrictPath: '/settings-resos' }, { id: 'sambapos', label: 'SambaPOS EPOS' },
{ id: 'sambapos', label: 'SambaPOS EPOS', restrictPath: '/settings-sambapos' }, { id: 'kds', label: 'Kitchen Display' },
{ id: 'kds', label: 'Kitchen Display', restrictPath: '/settings-kds' }, { id: 'budget', label: 'Spend Budget' },
{ id: 'budget', label: 'Spend Budget', restrictPath: '/settings-budget' }, { id: 'kitchen', label: 'Kitchen Details' },
{ id: 'kitchen', label: 'Kitchen Details', restrictPath: '/settings-kitchen' }, { id: 'suppliers', label: 'Suppliers' },
{ id: 'suppliers', label: 'Suppliers', restrictPath: '/settings-suppliers' }, { id: 'search', label: 'Search & Pricing' },
{ id: 'search', label: 'Search & Pricing', restrictPath: '/settings-search' }, { id: 'nextcloud', label: 'Nextcloud Storage' },
{ id: 'nextcloud', label: 'Nextcloud Storage', restrictPath: '/settings-nextcloud' }, { id: 'backup', label: 'Backup & Restore' },
{ id: 'backup', label: 'Backup & Restore', restrictPath: '/settings-backup' }, { id: 'food_flags', label: 'Food Flags' },
{ id: 'food_flags', label: 'Food Flags', restrictPath: '/settings-food-flags' }, { id: 'allergen_keywords', label: 'Allergen Keywords' },
{ id: 'allergen_keywords', label: 'Allergen Keywords', restrictPath: '/settings-food-flags' }, { id: 'ingredient_categories', label: 'Ingredient Categories' },
{ id: 'ingredient_categories', label: 'Ingredient Categories', restrictPath: '/settings-ingredient-categories' }, { id: 'recipe_sections', label: 'Recipe Sections' },
{ id: 'recipe_sections', label: 'Recipe Sections', restrictPath: '/settings-recipe-sections' }, { id: 'dish_courses', label: 'Dish Courses' },
{ id: 'dish_courses', label: 'Dish Courses', restrictPath: '/settings-dish-courses' }, { id: 'api_access', label: 'API Access' },
{ id: 'api_access', label: 'API Access', restrictPath: '/settings-api-access' }, { id: 'llm', label: 'AI Features', adminOnly: true }, // LLM FEATURE
{ id: 'llm', label: 'AI Features', adminOnly: true, restrictPath: '/settings-llm' }, // LLM FEATURE { id: 'data', label: 'Data Management' },
{ id: 'data', label: 'Data Management', restrictPath: '/settings-data' },
] ]
return ( return (
@ -2721,7 +2666,6 @@ export default function Settings() {
<nav style={styles.nav}> <nav style={styles.nav}>
{sidebarItems.map((item) => { {sidebarItems.map((item) => {
if (item.adminOnly && !user?.is_admin) return null if (item.adminOnly && !user?.is_admin) return null
if (!isSectionAccessible(item.restrictPath)) return null
if (item.href) { if (item.href) {
return ( return (
<a <a
@ -2861,167 +2805,6 @@ export default function Settings() {
</div> </div>
)} )}
{/* Access Control Section (Admin Only) */}
{activeSection === 'access' && user?.is_admin && (
<div style={styles.section}>
<h2 style={styles.sectionTitle}>Access Control</h2>
<p style={styles.hint}>
Restrict pages to admin users only. Non-admin users will not see restricted pages in the navigation.
</p>
{/* Invoice & Data Block */}
<div style={styles.settingsBlock}>
<h3 style={styles.blockTitle}>Invoice & Data</h3>
<div style={styles.checkboxGroup}>
{[
{ path: '/upload', label: 'Upload Invoices' },
{ path: '/invoices', label: 'Invoice List & Disputes' },
{ path: '/purchases', label: 'Purchase Chart' },
{ path: '/logbook', label: 'Allowance Logbook' },
].map(({ path, label }) => (
<label key={path} style={styles.checkboxLabel}>
<input
type="checkbox"
checked={restrictedPages.has(path)}
onChange={() => {
const newSet = new Set(restrictedPages)
if (newSet.has(path)) {
newSet.delete(path)
} else {
newSet.add(path)
}
setRestrictedPages(newSet)
}}
/>
{label}
</label>
))}
</div>
</div>
{/* Reports Block */}
<div style={styles.settingsBlock}>
<h3 style={styles.blockTitle}>Reports</h3>
<div style={styles.checkboxGroup}>
{[
{ path: '/gp-report', label: 'Kitchen Flash & Purchase Reports' },
{ path: '/newbook', label: 'Newbook Data' },
{ path: '/resos', label: 'Resos Bookings & Stats' },
].map(({ path, label }) => (
<label key={path} style={styles.checkboxLabel}>
<input
type="checkbox"
checked={restrictedPages.has(path)}
onChange={() => {
const newSet = new Set(restrictedPages)
if (newSet.has(path)) {
newSet.delete(path)
} else {
newSet.add(path)
}
setRestrictedPages(newSet)
}}
/>
{label}
</label>
))}
</div>
</div>
{/* Search & Tools Block */}
<div style={styles.settingsBlock}>
<h3 style={styles.blockTitle}>Search & Tools</h3>
<div style={styles.checkboxGroup}>
{[
{ path: '/search', label: 'Search (Invoices, Line Items, Definitions)' },
{ path: '/kds', label: 'Kitchen Display System (KDS)' },
].map(({ path, label }) => (
<label key={path} style={styles.checkboxLabel}>
<input
type="checkbox"
checked={restrictedPages.has(path)}
onChange={() => {
const newSet = new Set(restrictedPages)
if (newSet.has(path)) {
newSet.delete(path)
} else {
newSet.add(path)
}
setRestrictedPages(newSet)
}}
/>
{label}
</label>
))}
</div>
</div>
{/* Settings Sections Block */}
<div style={styles.settingsBlock}>
<h3 style={styles.blockTitle}>Settings Sections</h3>
<div style={styles.checkboxGroup}>
{[
{ path: '/settings', label: 'Settings Page (entire page)' },
{ path: '/settings-access', label: 'Access Control' },
{ path: '/settings-display', label: 'Display Settings' },
{ path: '/settings-azure', label: 'Azure OCR' },
{ path: '/settings-email', label: 'Email Configuration' },
{ path: '/settings-inbox', label: 'Email Inbox' },
{ path: '/settings-dext', label: 'Dext Integration' },
{ path: '/settings-newbook', label: 'Newbook PMS' },
{ path: '/settings-resos', label: 'Resos Bookings' },
{ path: '/settings-sambapos', label: 'SambaPOS EPOS' },
{ path: '/settings-kds', label: 'Kitchen Display' },
{ path: '/settings-suppliers', label: 'Suppliers' },
{ path: '/settings-search', label: 'Search & Pricing' },
{ path: '/settings-nextcloud', label: 'Nextcloud Storage' },
{ path: '/settings-backup', label: 'Backup & Restore' },
{ path: '/settings-data', label: 'Data Management' },
].map(({ path, label }) => (
<label key={path} style={styles.checkboxLabel}>
<input
type="checkbox"
checked={restrictedPages.has(path)}
onChange={() => {
const newSet = new Set(restrictedPages)
if (newSet.has(path)) {
newSet.delete(path)
} else {
newSet.add(path)
}
setRestrictedPages(newSet)
}}
/>
{label}
</label>
))}
</div>
</div>
{/* Save Button - outside blocks */}
<div style={{ marginTop: '1.5rem' }}>
<button
onClick={() => savePageRestrictionsMutation.mutate(Array.from(restrictedPages))}
style={styles.saveBtn}
disabled={savePageRestrictionsMutation.isPending}
>
{savePageRestrictionsMutation.isPending ? 'Saving...' : 'Save Restrictions'}
</button>
</div>
{accessSaveMessage && (
<div style={{ ...styles.statusMessage, background: accessSaveMessage.startsWith('Error') ? '#fee' : '#efe', marginTop: '1rem' }}>
{accessSaveMessage}
</div>
)}
<div style={{ marginTop: '1.5rem', padding: '1rem', background: '#f8f9fa', borderRadius: '8px' }}>
<strong>Note:</strong> Restricting a page will hide it from non-admin users. Admin users always have access to all pages.
The Dashboard is always accessible to all users.
</div>
</div>
)}
{/* Display Section */} {/* Display Section */}
{activeSection === 'display' && ( {activeSection === 'display' && (
<div style={styles.section}> <div style={styles.section}>