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:
parent
1564a66283
commit
6f6e16c88f
4 changed files with 29 additions and 246 deletions
|
|
@ -106,7 +106,7 @@ interface RoomCategory {
|
|||
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 {
|
||||
sambapos_db_host: string | null
|
||||
|
|
@ -274,7 +274,7 @@ interface ImapSyncStats {
|
|||
}
|
||||
|
||||
export default function Settings() {
|
||||
const { user, token, logout, restrictedPages: authRestrictedPages } = useAuth()
|
||||
const { user, token, logout } = useAuth()
|
||||
const queryClient = useQueryClient()
|
||||
const [activeSection, setActiveSection] = useState<SettingsSection>('account')
|
||||
|
||||
|
|
@ -738,19 +738,6 @@ export default function Settings() {
|
|||
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
|
||||
const { data: nextcloudSettings } = useQuery<NextcloudSettingsData>({
|
||||
queryKey: ['nextcloud-settings'],
|
||||
|
|
@ -869,17 +856,6 @@ export default function Settings() {
|
|||
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(() => {
|
||||
if (settings) {
|
||||
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({
|
||||
mutationFn: async () => {
|
||||
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>
|
||||
}
|
||||
|
||||
// Helper to check if a settings section is accessible
|
||||
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 }[] = [
|
||||
const sidebarItems: { id: SettingsSection; label: string; adminOnly?: boolean; href?: string }[] = [
|
||||
{ id: 'account', label: 'Account' },
|
||||
{ id: 'access', label: 'Access Control', adminOnly: true, restrictPath: '/settings-access' },
|
||||
{ id: 'display', label: 'Display', restrictPath: '/settings-display' },
|
||||
{ id: 'azure', label: 'Azure OCR', restrictPath: '/settings-azure' },
|
||||
{ id: 'email', label: 'Email Configuration', restrictPath: '/settings-email' },
|
||||
{ id: 'inbox', label: 'Email Inbox', adminOnly: true, restrictPath: '/settings-inbox' },
|
||||
{ id: 'dext', label: 'Dext Integration', restrictPath: '/settings-dext' },
|
||||
{ id: 'newbook', label: 'Newbook PMS', restrictPath: '/settings-newbook' },
|
||||
{ id: 'resos', label: 'Resos Bookings', restrictPath: '/settings-resos' },
|
||||
{ id: 'sambapos', label: 'SambaPOS EPOS', restrictPath: '/settings-sambapos' },
|
||||
{ id: 'kds', label: 'Kitchen Display', restrictPath: '/settings-kds' },
|
||||
{ id: 'budget', label: 'Spend Budget', restrictPath: '/settings-budget' },
|
||||
{ id: 'kitchen', label: 'Kitchen Details', restrictPath: '/settings-kitchen' },
|
||||
{ id: 'suppliers', label: 'Suppliers', restrictPath: '/settings-suppliers' },
|
||||
{ id: 'search', label: 'Search & Pricing', restrictPath: '/settings-search' },
|
||||
{ id: 'nextcloud', label: 'Nextcloud Storage', restrictPath: '/settings-nextcloud' },
|
||||
{ id: 'backup', label: 'Backup & Restore', restrictPath: '/settings-backup' },
|
||||
{ id: 'food_flags', label: 'Food Flags', restrictPath: '/settings-food-flags' },
|
||||
{ id: 'allergen_keywords', label: 'Allergen Keywords', restrictPath: '/settings-food-flags' },
|
||||
{ id: 'ingredient_categories', label: 'Ingredient Categories', restrictPath: '/settings-ingredient-categories' },
|
||||
{ id: 'recipe_sections', label: 'Recipe Sections', restrictPath: '/settings-recipe-sections' },
|
||||
{ id: 'dish_courses', label: 'Dish Courses', restrictPath: '/settings-dish-courses' },
|
||||
{ id: 'api_access', label: 'API Access', restrictPath: '/settings-api-access' },
|
||||
{ id: 'llm', label: 'AI Features', adminOnly: true, restrictPath: '/settings-llm' }, // LLM FEATURE
|
||||
{ id: 'data', label: 'Data Management', restrictPath: '/settings-data' },
|
||||
{ id: 'display', label: 'Display' },
|
||||
{ id: 'azure', label: 'Azure OCR' },
|
||||
{ id: 'email', label: 'Email Configuration' },
|
||||
{ id: 'inbox', label: 'Email Inbox', adminOnly: true },
|
||||
{ id: 'dext', label: 'Dext Integration' },
|
||||
{ id: 'newbook', label: 'Newbook PMS' },
|
||||
{ id: 'resos', label: 'Resos Bookings' },
|
||||
{ id: 'sambapos', label: 'SambaPOS EPOS' },
|
||||
{ id: 'kds', label: 'Kitchen Display' },
|
||||
{ id: 'budget', label: 'Spend Budget' },
|
||||
{ id: 'kitchen', label: 'Kitchen Details' },
|
||||
{ id: 'suppliers', label: 'Suppliers' },
|
||||
{ id: 'search', label: 'Search & Pricing' },
|
||||
{ id: 'nextcloud', label: 'Nextcloud Storage' },
|
||||
{ id: 'backup', label: 'Backup & Restore' },
|
||||
{ id: 'food_flags', label: 'Food Flags' },
|
||||
{ id: 'allergen_keywords', label: 'Allergen Keywords' },
|
||||
{ id: 'ingredient_categories', label: 'Ingredient Categories' },
|
||||
{ id: 'recipe_sections', label: 'Recipe Sections' },
|
||||
{ id: 'dish_courses', label: 'Dish Courses' },
|
||||
{ id: 'api_access', label: 'API Access' },
|
||||
{ id: 'llm', label: 'AI Features', adminOnly: true }, // LLM FEATURE
|
||||
{ id: 'data', label: 'Data Management' },
|
||||
]
|
||||
|
||||
return (
|
||||
|
|
@ -2721,7 +2666,6 @@ export default function Settings() {
|
|||
<nav style={styles.nav}>
|
||||
{sidebarItems.map((item) => {
|
||||
if (item.adminOnly && !user?.is_admin) return null
|
||||
if (!isSectionAccessible(item.restrictPath)) return null
|
||||
if (item.href) {
|
||||
return (
|
||||
<a
|
||||
|
|
@ -2861,167 +2805,6 @@ export default function Settings() {
|
|||
</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 */}
|
||||
{activeSection === 'display' && (
|
||||
<div style={styles.section}>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue