feat(client): let a managed nav entry be admin-only

The attachment point hands the two nav bars a static list, so anything attached
there showed up for every user of the instance. What belongs there is the
operator's side of the install, and a trip's other travellers are on the instance
as guests of the person who runs it — an entry about how it is run is noise to
them.

`adminOnly` is optional and absent means "for everyone", so nothing that exists
today changes meaning. Both nav bars go through the same exported filter rather
than each writing the rule, since a rule written twice is a rule that will differ.

Still inert here: the list is empty, and the tests pin that an empty list stays
empty for either kind of user.
This commit is contained in:
Maurice
2026-08-15 17:02:28 +02:00
committed by Maurice
parent 05315ef7d9
commit 7c378c574f
4 changed files with 80 additions and 4 deletions
+4 -2
View File
@@ -6,7 +6,8 @@ import { useTranslation } from '../../i18n'
import { LayoutGrid, CalendarDays, Globe, Compass, Bookmark, Plus } from 'lucide-react'
import type { LucideIcon } from 'lucide-react'
import { resolvePluginIcon } from '../shared/PluginIcon'
import { managedNavItems } from '../../managed'
import { useAuthStore } from '../../store/authStore'
import { visibleManagedNavItems } from '../../managed'
const ADDON_NAV: Record<string, { icon: LucideIcon; labelKey: string }> = {
vacay: { icon: CalendarDays, labelKey: 'admin.addons.catalog.vacay.name' },
@@ -60,6 +61,7 @@ export default function BottomNav() {
const pagePlugins = usePluginStore(s => s.plugins).filter(p => p.type === 'page')
const location = useLocation()
const create = useCreateAction()
const isAdmin = useAuthStore(s => s.user?.role === 'admin')
const items: NavItem[] = [
{ to: '/dashboard', label: t('nav.myTrips'), icon: LayoutGrid },
@@ -69,7 +71,7 @@ export default function BottomNav() {
}),
...pagePlugins.map(p => ({ to: `/plugins/${p.id}`, label: p.name, icon: resolvePluginIcon(p.icon) })),
// Empty in this repository — see client/src/managed.
...managedNavItems.map(m => ({ to: m.path, label: m.label, icon: m.Icon })),
...visibleManagedNavItems(isAdmin).map(m => ({ to: m.path, label: m.label, icon: m.Icon })),
]
// Split the items so the raised "+" sits dead centre.
const splitAt = Math.ceil(items.length / 2)
+2 -2
View File
@@ -10,7 +10,7 @@ import { Plane, LogOut, Settings, ChevronDown, Shield, ArrowLeft, Users, Moon, S
import type { LucideIcon } from 'lucide-react'
import InAppNotificationBell from './InAppNotificationBell.tsx'
import { resolvePluginIcon } from '../shared/PluginIcon'
import { managedNavItems } from '../../managed'
import { visibleManagedNavItems } from '../../managed'
const ADDON_ICONS: Record<string, LucideIcon> = { CalendarDays, Briefcase, Globe, Compass, Bookmark }
@@ -154,7 +154,7 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
...globalAddons.map(a => ({ id: a.id, path: `/${a.id}`, label: getAddonName(a), Icon: ADDON_ICONS[a.icon] || CalendarDays })),
...pagePlugins.map(p => ({ id: `plugin:${p.id}`, path: `/plugins/${p.id}`, label: p.name, Icon: resolvePluginIcon(p.icon) })),
// Empty in this repository — see client/src/managed.
...managedNavItems.map(m => ({ id: `managed:${m.id}`, path: m.path, label: m.label, Icon: m.Icon }))
...visibleManagedNavItems(user?.role === 'admin').map(m => ({ id: `managed:${m.id}`, path: m.path, label: m.label, Icon: m.Icon }))
].map(tab => {
const isActive = location.pathname === tab.path
return (
+59
View File
@@ -0,0 +1,59 @@
/**
* The attachment point is empty here, and the filter over it is the one piece of
* logic it does carry — so it is the one piece worth testing.
*
* The emptiness matters on its own: this is the seam where an install can attach
* its own screens, and the promise to everyone else is that a build of this
* repository carries none of them.
*/
import { describe, it, expect } from 'vitest'
import { Briefcase } from 'lucide-react'
import { managedNavItems, managedRoutes, visibleManagedNavItems, type ManagedNavItem } from './index'
describe('managed attachment point', () => {
it('FE-MANAGED-001: ships empty, so the public build registers nothing', () => {
expect(managedRoutes).toEqual([])
expect(managedNavItems).toEqual([])
})
it('FE-MANAGED-002: an empty list stays empty for either kind of user', () => {
// The nav bars call this unconditionally. If it ever returned anything here,
// every install would grow an entry nobody asked for.
expect(visibleManagedNavItems(true)).toEqual([])
expect(visibleManagedNavItems(false)).toEqual([])
})
})
describe('visibleManagedNavItems', () => {
const item = (over: Partial<ManagedNavItem>): ManagedNavItem => ({
id: 'x',
path: '/x',
label: 'X',
Icon: Briefcase,
...over,
})
// The function is exported and takes its list implicitly, so exercise the rule
// directly rather than mutating the module constant.
const apply = (items: ManagedNavItem[], isAdmin: boolean) =>
items.filter((i) => !i.adminOnly || isAdmin).map((i) => i.id)
it('FE-MANAGED-003: an adminOnly entry is offered to an admin and nobody else', () => {
const items = [item({ id: 'billing', adminOnly: true })]
expect(apply(items, true)).toEqual(['billing'])
expect(apply(items, false)).toEqual([])
})
it('FE-MANAGED-004: without the flag an entry is for everyone', () => {
// Absent means "for everyone", not "for nobody" — a missing flag must not
// silently hide a screen somebody attached on purpose.
const items = [item({ id: 'open' }), item({ id: 'explicit', adminOnly: false })]
expect(apply(items, false)).toEqual(['open', 'explicit'])
})
it('FE-MANAGED-005: mixed lists keep their order', () => {
const items = [item({ id: 'a' }), item({ id: 'b', adminOnly: true }), item({ id: 'c' })]
expect(apply(items, true)).toEqual(['a', 'b', 'c'])
expect(apply(items, false)).toEqual(['a', 'c'])
})
})
+15
View File
@@ -38,6 +38,21 @@ export interface ManagedNavItem {
label: string
/** lucide-react, like every other icon in the app — see the theme README. */
Icon: LucideIcon
/**
* Hide the entry from everyone but an admin.
*
* Not cosmetic. What attaches here is the operator's side of the install, and
* a trip's other travellers are on this instance as guests of the person who
* runs it — showing them an entry about how it is run is noise at best and
* confusing at worst. The route behind it still checks for itself; this only
* decides who is offered the door.
*/
adminOnly?: boolean
}
/** Filter for the two nav bars. Exported so both apply the same rule. */
export function visibleManagedNavItems(isAdmin: boolean): ManagedNavItem[] {
return managedNavItems.filter((item) => !item.adminOnly || isAdmin)
}
/** Extra protected routes. Spread into the router next to the built-in ones. */