// Who may see a row of the admin sidebar, and where that lets them go. // // Plain JS, in its own file, for two reasons. It is shared — AdminLayout renders // by it and Admin -> Navigation builds its palette by it (THEMING_AND_NAV.md // §8.1), and a second copy of this answer is exactly the thing this file exists // to abolish. And it is the closest thing in the client to an authorization // decision, so it belongs somewhere the test runner can reach, which a .jsx file // is not. // // **A row's own `roles` is the whole answer.** Until Phase 2 PR 8 this was // `roles` AND a hardcoded `MOD_PATHS` list of five paths that confined // moderators, AND a third prefix list in the redirect effect that disagreed with // both (docs/website/MODULE_SYSTEM.md §1.4). A module's rows could never be // added to a list core hardcodes, which is what forced the derivation — but the // lists had already drifted from each other without a module in sight. /** * Can a viewer with this role see this row? * * Applied AFTER the override merge in both callers: an override is presentation * and this is the boundary, so an override saying `hidden: false` on a row this * role cannot see still shows nothing (THEMING_AND_NAV.md §7). * * A row with no `roles` is visible to everyone who reached the admin area at * all — that is the self-service case (Account, My Characters), and staff are a * superset of players. */ export function navItemVisibleTo(item, role) { return !item.roles || item.roles.includes(role) } /** * The paths a viewer with this role may reach, derived from the rows they see. * * Takes the BASE nav, never the override-merged one: an override must not be * able to move this boundary in either direction. Hiding a row from a * moderator's sidebar must not also bar them from the page behind it, and * un-hiding one must not admit them to a page their role does not carry. * * @param {Array<{items: Array}>} baseNav the grouped admin nav * @param {string} role * @returns {Array<{to: string, exact: boolean}>} */ export function allowedPathsFor(baseNav, role) { return (Array.isArray(baseNav) ? baseNav : []) .flatMap((g) => g.items || []) .filter((item) => navItemVisibleTo(item, role)) .map((item) => ({ to: item.to, exact: item.end === true })) } /** * Is this pathname one of them? * * A row carrying `end` matches exactly — `/admin` is the dashboard, not a prefix * of the whole admin area, and treating it as one would let every path through. * Every other row also covers its sub-routes, which is what keeps * `/admin/moderation/appeals/12` and a module's detail pages reachable without * anyone listing them. */ export function isAllowedPath(pathname, allowed) { return (allowed || []).some(({ to, exact }) => exact ? pathname === to : pathname === to || pathname.startsWith(`${to}/`), ) } /** * The first place in this nav a viewer with this role can actually go. * * Added in Phase 3 slice 3, for the player portal, whose index route was * `PlayerCharacters` — a UO page. When it left, `/player` had nothing behind it, * and the three ways out were: redirect somewhere fixed, invent a core landing * page, or resolve the index from the nav the viewer already has. This is the * third, and it is the only one that keeps today's behaviour — with the module * installed the first row is still Characters, so a player still lands on their * characters after signing in, and with nothing installed they land on Account. * * **From the BASE nav, never the override-merged one**, the same rule * `allowedPathsFor` follows and for a sharper version of the same reason: an * override is presentation, and a landing page is behaviour. An admin reordering * the sidebar must not silently change where everybody arrives, and — more to * the point — must not be able to move it somewhere a role cannot follow. * * Deliberately generic, and deliberately in this file rather than in the portal * layout. The admin area has the same shape of question (its index is a * hardcoded Dashboard), and the direction of travel is one logged-in area that * shows the right things for the viewer's permissions rather than two that * duplicate each other. When that happens this is the function it needs, and it * already answers for both nav shapes. * * @param {Array} baseNav flat or grouped, before overrides * @param {string} role * @param {string} fallback where to go when the viewer can see nothing at all */ export function firstDestinationFor(baseNav, role, fallback) { const items = (Array.isArray(baseNav) ? baseNav : []).flatMap((entry) => entry && Array.isArray(entry.items) ? entry.items : [entry], ) const first = items.find((item) => item && item.to && navItemVisibleTo(item, role)) return first ? first.to : fallback }