A nav item says which permission its page needs
All checks were successful
CI / naming (push) Successful in 3s
CI / frontend (push) Successful in 12s
CI / migrations-mysql (push) Successful in 39s
CI / backend (push) Successful in 3m43s

The sidebar hides what the caller cannot reach - that is what makes it useful
to a guest, and what stops a member with a sparse role being offered links that
can only answer 403. It reads the permission off the nav item to decide.

Only four items ever declared one. Machines, PCs, Printers, Network, USB,
Knowledge Base, Notifications, Calendar, Dashboard, Map, Applications and
Reports declared nothing, so the filter had nothing to go on: granting the
guest role machines.view opened the API and left the sidebar empty, which reads
as the grant not working.

Every item now names its read permission - in the plugins, in the core
navigation endpoint, and in the frontend's fallback list, which is what renders
when the endpoint is unreachable and would otherwise disagree with it.
This commit is contained in:
cproudlock
2026-08-27 14:50:52 -04:00
parent afce24379c
commit 4e59fe30b2
12 changed files with 45 additions and 16 deletions

View File

@@ -115,6 +115,15 @@ ADR-007 and ADR-002.
### Fixed
- **The sidebar offered pages the reader could not open, and hid ones they
could.** A nav item is hidden from anybody who does not hold its permission,
but only four items ever declared one - so the filter did nothing for the
rest, and a signed-out visitor granted `machines.view` still saw an empty
sidebar. Every item now names the read permission its page needs, in the
plugins, in the core navigation endpoint and in the frontend's fallback list.
A signed-in member with a sparse role will notice the same correction: a link
that could only answer 403 no longer appears.
- **Every report offers to email itself, not just two of them.** `POST
/api/reports/email` has always taken any `{columns, rows}`, but the button was
on the toner and warranty pages only - because those two happen to be their

View File

@@ -187,18 +187,18 @@ const iconMap = {
// Default navigation (used as fallback if API fails)
const defaultNav = [
{ name: 'Dashboard', icon: 'layout-dashboard', route: '/', position: 0 },
{ name: 'Notifications', icon: 'bell', route: '/notifications', position: 5 },
{ name: 'Calendar', icon: 'calendar', route: '/calendar', position: 6 },
{ name: 'Map', icon: 'map', route: '/map', position: 4 },
{ name: 'Machines', icon: 'cog', route: '/machines', position: 10, section: 'assets' },
{ name: 'PCs', icon: 'desktop', route: '/pcs', position: 15, section: 'assets' },
{ name: 'Network', icon: 'network-wired', route: '/network', position: 18, section: 'assets' },
{ name: 'Printers', icon: 'printer', route: '/printers', position: 20, section: 'assets' },
{ name: 'USB Devices', icon: 'usb', route: '/usb', position: 25, section: 'assets' },
{ name: 'Applications', icon: 'app-window', route: '/applications', position: 30, section: 'information' },
{ name: 'Knowledge Base', icon: 'book-open', route: '/knowledgebase', position: 35, section: 'information' },
{ name: 'Reports', icon: 'bar-chart-3', route: '/reports', position: 40, section: 'information' },
{ name: 'Dashboard', icon: 'layout-dashboard', route: '/', position: 0, permission: 'assets.view' },
{ name: 'Notifications', icon: 'bell', route: '/notifications', position: 5, permission: 'notifications.view' },
{ name: 'Calendar', icon: 'calendar', route: '/calendar', position: 6, permission: 'notifications.view' },
{ name: 'Map', icon: 'map', route: '/map', position: 4, permission: 'assets.view' },
{ name: 'Machines', icon: 'cog', route: '/machines', position: 10, section: 'assets', permission: 'machines.view' },
{ name: 'PCs', icon: 'desktop', route: '/pcs', position: 15, section: 'assets', permission: 'computers.view' },
{ name: 'Network', icon: 'network-wired', route: '/network', position: 18, section: 'assets', permission: 'network.view' },
{ name: 'Printers', icon: 'printer', route: '/printers', position: 20, section: 'assets', permission: 'printers.view' },
{ name: 'USB Devices', icon: 'usb', route: '/usb', position: 25, section: 'assets', permission: 'usb.view' },
{ name: 'Applications', icon: 'app-window', route: '/applications', position: 30, section: 'information', permission: 'applications.view' },
{ name: 'Knowledge Base', icon: 'book-open', route: '/knowledgebase', position: 35, section: 'information', permission: 'kb.view' },
{ name: 'Reports', icon: 'bar-chart-3', route: '/reports', position: 40, section: 'information', permission: 'reports.view' },
]
// The canonical sidebar sections, in the order they render. A nav item DECLARES

View File

@@ -1652,6 +1652,7 @@ class ComputersPlugin(BasePlugin):
'name': 'PCs',
'icon': 'desktop',
'route': '/pcs',
'permission': 'computers.view',
'position': 15,
'section': 'assets',
},

View File

@@ -68,6 +68,7 @@ class KnowledgeBasePlugin(BasePlugin):
'name': 'Knowledge Base',
'icon': 'book-open',
'route': '/knowledgebase',
'permission': 'kb.view',
'position': 35,
'section': 'information',
},

View File

@@ -231,6 +231,7 @@ class MachinesPlugin(BasePlugin):
'name': 'Machines',
'icon': 'cog',
'route': '/machines',
'permission': 'machines.view',
'position': 10,
'section': 'assets',
},

View File

@@ -73,6 +73,7 @@ class MeasuringToolsPlugin(BasePlugin):
'name': 'Measuring Tools',
'icon': 'ruler',
'route': '/measuringtools',
'permission': 'measuringtools.view',
'position': 22,
'section': 'assets',
},
@@ -86,6 +87,7 @@ class MeasuringToolsPlugin(BasePlugin):
'description': 'Measuring tools bucketed by calibration status',
'category': 'compliance',
'route': '/reports/calibration',
'permission': 'measuringtools.view',
},
]

View File

@@ -207,6 +207,7 @@ class NetworkPlugin(BasePlugin):
'name': 'Network',
'icon': 'network-wired',
'route': '/network',
'permission': 'network.view',
'position': 18,
'section': 'assets',
},

View File

@@ -232,12 +232,14 @@ class NotificationsPlugin(BasePlugin):
'name': 'Notifications',
'icon': 'bell',
'route': '/notifications',
'permission': 'notifications.view',
'position': 5,
},
{
'name': 'Calendar',
'icon': 'calendar',
'route': '/calendar',
'permission': 'notifications.view',
'position': 6,
},
]

View File

@@ -385,6 +385,7 @@ class PrintersPlugin(BasePlugin):
'name': 'Printers',
'icon': 'printer',
'route': '/printers',
'permission': 'printers.view',
'position': 20,
'section': 'assets',
},

View File

@@ -185,6 +185,7 @@ class USBPlugin(BasePlugin):
'name': 'USB Devices',
'icon': 'usb',
'route': '/usb',
'permission': 'usb.view',
'position': 25,
'section': 'assets',
},

View File

@@ -57,6 +57,7 @@ class WarrantyPlugin(BasePlugin):
'name': 'Warranties',
'icon': 'shield',
'route': '/warranties',
'permission': 'warranty.view',
'position': 9,
},
]
@@ -69,6 +70,7 @@ class WarrantyPlugin(BasePlugin):
'description': 'Assets bucketed by coverage: expired, expiring soon, active',
'category': 'warranty',
'route': '/reports/warranty',
'permission': 'warranty.view',
},
]

View File

@@ -155,9 +155,14 @@ def get_navigation():
all_items = []
# Core navigation items (always present)
# Every item names the permission its page needs, so the filter below can
# hide what the caller cannot reach - a link that only leads to a 401 is
# worse than no link.
all_items.extend([
{'name': 'Dashboard', 'icon': 'layout-dashboard', 'route': '/', 'position': 0},
{'name': 'Map', 'icon': 'map', 'route': '/map', 'position': 4},
{'name': 'Dashboard', 'icon': 'layout-dashboard', 'route': '/',
'position': 0, 'permission': 'assets.view'},
{'name': 'Map', 'icon': 'map', 'route': '/map', 'position': 4,
'permission': 'assets.view'},
])
# Collect navigation items from enabled plugins. Disabling persists to the
@@ -181,8 +186,11 @@ def get_navigation():
# Add core information section items
all_items.extend([
{'name': 'Applications', 'icon': 'app-window', 'route': '/applications', 'position': 30, 'section': 'information'},
{'name': 'Reports', 'icon': 'bar-chart-3', 'route': '/reports', 'position': 40, 'section': 'information'},
{'name': 'Applications', 'icon': 'app-window', 'route': '/applications',
'position': 30, 'section': 'information',
'permission': 'applications.view'},
{'name': 'Reports', 'icon': 'bar-chart-3', 'route': '/reports',
'position': 40, 'section': 'information', 'permission': 'reports.view'},
])
# Sort by position