diff --git a/client/src/components/Layout/BottomNav.tsx b/client/src/components/Layout/BottomNav.tsx index 86bb2e565..67ed52736 100644 --- a/client/src/components/Layout/BottomNav.tsx +++ b/client/src/components/Layout/BottomNav.tsx @@ -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 = { 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) diff --git a/client/src/components/Layout/Navbar.tsx b/client/src/components/Layout/Navbar.tsx index 3a316d4e3..f0aaa51e4 100644 --- a/client/src/components/Layout/Navbar.tsx +++ b/client/src/components/Layout/Navbar.tsx @@ -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 = { 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 ( diff --git a/client/src/managed/index.test.ts b/client/src/managed/index.test.ts new file mode 100644 index 000000000..089169a20 --- /dev/null +++ b/client/src/managed/index.test.ts @@ -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 => ({ + 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']) + }) +}) diff --git a/client/src/managed/index.tsx b/client/src/managed/index.tsx index f746eed88..4f11922b2 100644 --- a/client/src/managed/index.tsx +++ b/client/src/managed/index.tsx @@ -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. */