mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
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:
@@ -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)
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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'])
|
||||
})
|
||||
})
|
||||
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user