35 files and 5,332 lines out — twelve public pages, seven admin views, two player views, eight components, the two `data/` leaves and the three `lib/` ones, plus the two tests that came with them. §2.7.1's estimate of 51 files / ~3,700 lines was measured differently and is corrected in the docs PR. The seams core keeps, each smaller than what it replaced: Nine rows leave the public header and six leave the admin sidebar, and both lists are now free of `feature` gates and of `IconShard`. `moduleTitle` already handled a module page's heading, so the six TITLES entries and the `/admin/characters` branch of `sectionTitle` simply go. `/player` had `PlayerCharacters` as its index — a UO page — and rather than name a replacement or invent a landing screen it now resolves to the first row of the portal nav this viewer can reach (`firstDestinationFor`, beside `allowedPathsFor` and reading the BASE nav for the same reason: an override is presentation and where everybody lands is behaviour). With the module installed that is still Characters, so a player's first screen after signing in does not change. Deliberately generic and deliberately not in the portal layout — the admin index is the same question with a hardcoded answer, and if the two logged-in areas ever become one this is what serves both. `game_account_signup` goes with the rest of core's UO prose: the mode list, the derived public flag, the validation and a Site Settings field whose help text named Bridge.cfg. The row itself is untouched and module-uo reads it through ctx.settings — the data stays, the semantics move. KNOWN BREAK, accepted by the org lead: the shipped Android app reads `gameAccountSignup` off `/public/settings` (PublicDto.kt:80). The field has a `= false` default so nothing crashes; the app silently stops offering game-account creation until it reads the module's `/public/shard/features` instead. Out of scope here, recorded in the Android plan, and it lands well before this workstream's cutover reaches `main`. 620 server + 161 client tests. Manifest 158 public + 2 internal, unchanged; routes.guards unchanged. The OpenAPI spec loses exactly one property, and only because it was hand-written in swagger.js — regeneration alone would have left the spec documenting a field core no longer returns. Co-Authored-By: Claude <noreply@anthropic.com>
101 lines
4.7 KiB
JavaScript
101 lines
4.7 KiB
JavaScript
// 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
|
|
}
|