Merge pull request 'feat(theming): nav wiring and the admin nav builder (phases 6-8)' (#124) from feat/theming-nav-phase-6-8 into edge
Reviewed-on: #124
This commit is contained in:
62
client/package-lock.json
generated
62
client/package-lock.json
generated
@@ -8,6 +8,9 @@
|
||||
"name": "runic-gateway-client",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^8.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@tiptap/extension-image": "^2.27.2",
|
||||
"@tiptap/extension-link": "^2.27.2",
|
||||
"@tiptap/extension-text-align": "^2.27.2",
|
||||
@@ -306,6 +309,59 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/accessibility": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
|
||||
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/core": {
|
||||
"version": "6.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
|
||||
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@dnd-kit/accessibility": "^3.1.1",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/sortable": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-8.0.0.tgz",
|
||||
"integrity": "sha512-U3jk5ebVXe1Lr7c2wU7SBZjcWdQP+j7peHJfCspnA81enlu88Mgd7CC8Q+pub9ubP7eKVETzJW+IBAhsqbSu/g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@dnd-kit/core": "^6.1.0",
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/utilities": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
|
||||
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
|
||||
@@ -2488,6 +2544,12 @@
|
||||
"@popperjs/core": "^2.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/uc.micro": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz",
|
||||
|
||||
@@ -10,6 +10,9 @@
|
||||
"test": "node --test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^8.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@tiptap/extension-image": "^2.27.2",
|
||||
"@tiptap/extension-link": "^2.27.2",
|
||||
"@tiptap/extension-text-align": "^2.27.2",
|
||||
|
||||
@@ -42,6 +42,7 @@ import PageBuilder from './routes/admin/views/PageBuilder.jsx'
|
||||
import WikiAdmin from './routes/admin/views/WikiAdmin.jsx'
|
||||
import HeroEditor from './routes/admin/views/HeroEditor.jsx'
|
||||
import AppearanceAdmin from './routes/admin/views/AppearanceAdmin.jsx'
|
||||
import NavEditor from './routes/admin/views/NavEditor.jsx'
|
||||
import SettingsAdmin from './routes/admin/views/SettingsAdmin.jsx'
|
||||
import ActivityAdmin from './routes/admin/views/ActivityAdmin.jsx'
|
||||
import BotActivityAdmin from './routes/admin/views/BotActivityAdmin.jsx'
|
||||
@@ -151,6 +152,17 @@ export default function App() {
|
||||
</RoleGate>
|
||||
}
|
||||
/>
|
||||
{/* Same reasoning as Appearance: the nav overrides are an admin-only
|
||||
settings key, so the route carries the same RoleGate as the
|
||||
sidebar entry that reaches it. */}
|
||||
<Route
|
||||
path="navigation"
|
||||
element={
|
||||
<RoleGate roles={['admin']}>
|
||||
<NavEditor />
|
||||
</RoleGate>
|
||||
}
|
||||
/>
|
||||
<Route path="settings" element={<SettingsAdmin />} />
|
||||
<Route
|
||||
path="moderation"
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { useMemo } from 'react'
|
||||
import { Link, NavLink } from 'react-router-dom'
|
||||
import MoonDot from './MoonDot.jsx'
|
||||
import BrandLogo from './BrandLogo.jsx'
|
||||
import { useAuth } from '../contexts/AuthContext.jsx'
|
||||
import { useSite } from '../contexts/SiteContext.jsx'
|
||||
import { useShardFeatures, canSee } from '../lib/useShardFeatures.js'
|
||||
import { applyNavOverrides } from '../lib/navOverrides.js'
|
||||
import { parseJsonSetting } from '../lib/settingsJson.js'
|
||||
|
||||
// One consistent top nav for the whole public site. Every page gets the same
|
||||
// main links plus an auth-aware entry on the right (Sign in / My Account / Admin).
|
||||
@@ -12,7 +15,11 @@ import { useShardFeatures, canSee } from '../lib/useShardFeatures.js'
|
||||
// to a higher audience (Admin -> Shard Visibility). They are hidden when this
|
||||
// viewer can't reach them, so we never render a link that would 403. The gate
|
||||
// itself is server-side; this is only about not advertising a dead end.
|
||||
const NAV = [
|
||||
//
|
||||
// Exported because Admin -> Navigation edits this list. It stays declared here,
|
||||
// with this component as its owner: the editor may only relabel, reorder and
|
||||
// hide what it finds, and `to`/`feature` are never its to change (§7).
|
||||
export const NAV = [
|
||||
{ label: 'Home', to: '/', end: true },
|
||||
{ label: 'News', to: '/site/news' },
|
||||
{ label: 'Screenshots', to: '/site/screenshots' },
|
||||
@@ -39,9 +46,21 @@ const linkStyle = ({ isActive }) => ({
|
||||
|
||||
export default function SiteHeader() {
|
||||
const { user, loading } = useAuth()
|
||||
const { siteTitle } = useSite()
|
||||
const { siteTitle, settings } = useSite()
|
||||
const shardFeatures = useShardFeatures()
|
||||
const nav = NAV.filter((item) => !item.feature || canSee(shardFeatures, item.feature))
|
||||
|
||||
// An admin may relabel, reorder and hide these entries from Admin →
|
||||
// Navigation (THEMING_AND_NAV.md §7). Two things about the order here:
|
||||
//
|
||||
// • the override merge runs FIRST and the feature filter after it, so the
|
||||
// filter stays the boundary — an override cannot un-hide a shard surface
|
||||
// this viewer may not see, whatever it says;
|
||||
// • with no stored row, applyNavOverrides returns NAV itself, so an
|
||||
// untouched instance renders exactly what it renders today.
|
||||
const nav = useMemo(() => {
|
||||
const merged = applyNavOverrides(NAV, parseJsonSetting(settings.nav_public))
|
||||
return merged.filter((item) => !item.feature || canSee(shardFeatures, item.feature))
|
||||
}, [settings.nav_public, shardFeatures])
|
||||
|
||||
// Where the auth entry points: staff → admin, player → portal, else sign in.
|
||||
let account
|
||||
|
||||
@@ -57,20 +57,73 @@ function byOrder(items) {
|
||||
})
|
||||
}
|
||||
|
||||
// Apply label/hidden/order to one flat list. Returns visible items only, with
|
||||
// the sort key parked on `__order` for byOrder to consume.
|
||||
function mergeItems(items, entries) {
|
||||
// Apply label/hidden/order to one flat list, with the sort key parked on
|
||||
// `__order` for byOrder to consume.
|
||||
//
|
||||
// `keepHidden` is what the admin editor needs and the site must not have: the
|
||||
// editor has to render a hidden row in its right place so it can be un-hidden,
|
||||
// while a layout must simply not render it. Same merge either way, so the two
|
||||
// can never disagree about where an item sits.
|
||||
function mergeItems(items, entries, keepHidden = false) {
|
||||
const out = []
|
||||
for (const item of items) {
|
||||
const o = entries.get(item.to)
|
||||
if (o?.hidden) continue
|
||||
if (o?.hidden && !keepHidden) continue
|
||||
// Spread the base item first so `to`, `roles`, `feature`, `icon` and `end`
|
||||
// survive verbatim — the override only ever lands on `label`.
|
||||
out.push({ ...item, ...(o?.label ? { label: o.label } : {}), __order: o?.order })
|
||||
out.push({
|
||||
...item,
|
||||
...(o?.label ? { label: o.label } : {}),
|
||||
...(keepHidden ? { defaultLabel: item.label, hidden: o?.hidden === true } : {}),
|
||||
__order: o?.order,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// The stored overrides, cleaned and keyed, plus the group titles the base nav
|
||||
// declares. Shared by the merge and the editor so both read a row the same way.
|
||||
function readOverrides(baseNav, overrides, grouped) {
|
||||
const groupTitles = new Set(
|
||||
grouped ? baseNav.map((g) => g.title).filter((t) => typeof t === 'string') : [],
|
||||
)
|
||||
const entries = new Map()
|
||||
if (!overrides || typeof overrides !== 'object' || Array.isArray(overrides)) return { entries, groupTitles }
|
||||
// Keyed by `to`, and only for a `to` the base nav actually declares. An
|
||||
// override for a route that no longer exists is dropped here, so deleting a
|
||||
// route in code can never leave a dangling override that does something
|
||||
// unexpected later.
|
||||
const known = new Set(
|
||||
grouped ? baseNav.flatMap((g) => g.items.map((i) => i.to)) : baseNav.map((i) => i.to),
|
||||
)
|
||||
for (const [to, raw] of Object.entries(overrides)) {
|
||||
if (!known.has(to)) continue
|
||||
const entry = cleanEntry(raw, groupTitles)
|
||||
if (entry && Object.keys(entry).length > 0) entries.set(to, entry)
|
||||
}
|
||||
return { entries, groupTitles }
|
||||
}
|
||||
|
||||
// Move items whose override names a different existing section. Groups keep
|
||||
// their coded order — only membership and within-group order move.
|
||||
function regroup(baseNav, entries) {
|
||||
const moved = new Map() // destination title → items pulled in from elsewhere
|
||||
const kept = baseNav.map((g) => {
|
||||
const items = []
|
||||
for (const item of g.items) {
|
||||
const o = entries.get(item.to)
|
||||
if (o?.group && o.group !== g.title) {
|
||||
if (!moved.has(o.group)) moved.set(o.group, [])
|
||||
moved.get(o.group).push(item)
|
||||
continue
|
||||
}
|
||||
items.push(item)
|
||||
}
|
||||
return { ...g, items }
|
||||
})
|
||||
return { kept, moved }
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Array} baseNav the hardcoded nav — the source of truth for `to`,
|
||||
* `roles`, `feature`, `icon` and `end`
|
||||
@@ -86,43 +139,13 @@ export function applyNavOverrides(baseNav, overrides) {
|
||||
if (!overrides || typeof overrides !== 'object' || Array.isArray(overrides)) return baseNav
|
||||
|
||||
const grouped = isGrouped(baseNav)
|
||||
const groupTitles = new Set(
|
||||
grouped ? baseNav.map((g) => g.title).filter((t) => typeof t === 'string') : [],
|
||||
)
|
||||
|
||||
// Keyed by `to`, and only for a `to` the base nav actually declares. An
|
||||
// override for a route that no longer exists is dropped here, so deleting a
|
||||
// route in code can never leave a dangling override that does something
|
||||
// unexpected later.
|
||||
const known = new Set(
|
||||
grouped ? baseNav.flatMap((g) => g.items.map((i) => i.to)) : baseNav.map((i) => i.to),
|
||||
)
|
||||
const entries = new Map()
|
||||
for (const [to, raw] of Object.entries(overrides)) {
|
||||
if (!known.has(to)) continue
|
||||
const entry = cleanEntry(raw, groupTitles)
|
||||
if (entry && Object.keys(entry).length > 0) entries.set(to, entry)
|
||||
}
|
||||
const { entries } = readOverrides(baseNav, overrides, grouped)
|
||||
if (entries.size === 0) return baseNav
|
||||
|
||||
if (!grouped) return byOrder(mergeItems(baseNav, entries))
|
||||
|
||||
// Grouped: an item may also be moved into another *existing* titled section.
|
||||
// Groups keep their coded order — only membership and within-group order move.
|
||||
const moved = new Map() // destination title → items pulled in from elsewhere
|
||||
const kept = baseNav.map((g) => {
|
||||
const items = []
|
||||
for (const item of g.items) {
|
||||
const o = entries.get(item.to)
|
||||
if (o?.group && o.group !== g.title) {
|
||||
if (!moved.has(o.group)) moved.set(o.group, [])
|
||||
moved.get(o.group).push(item)
|
||||
continue
|
||||
}
|
||||
items.push(item)
|
||||
}
|
||||
return { ...g, items }
|
||||
})
|
||||
const { kept, moved } = regroup(baseNav, entries)
|
||||
|
||||
return kept
|
||||
.map((g) => ({
|
||||
@@ -135,4 +158,109 @@ export function applyNavOverrides(baseNav, overrides) {
|
||||
.filter((g) => g.items.length > 0)
|
||||
}
|
||||
|
||||
// ── The admin editor's round trip ────────────────────────────────────────
|
||||
//
|
||||
// Two functions, inverse to each other, kept in this file rather than beside the
|
||||
// editor screen so the thing that *writes* an override and the thing that
|
||||
// *applies* one can never drift: the rows the admin drags are produced by the
|
||||
// same merge the site renders, hidden ones included.
|
||||
|
||||
/**
|
||||
* The base nav plus its stored overrides, as editable rows — always in the
|
||||
* grouped shape, so one editor handles both navs.
|
||||
*
|
||||
* Unlike applyNavOverrides this keeps hidden rows (marked `hidden: true`, so
|
||||
* they can be un-hidden) and keeps empty groups (so something can be moved back
|
||||
* into one). Each row carries `defaultLabel`, which is what "reset this label"
|
||||
* restores and what the input shows as its placeholder.
|
||||
*
|
||||
* @param {Array} baseNav the hardcoded nav, flat or grouped
|
||||
* @param {object|null} overrides the parsed settings JSON
|
||||
* @returns {Array<{title: string|null, items: Array}>}
|
||||
*/
|
||||
export function buildNavRows(baseNav, overrides) {
|
||||
if (!Array.isArray(baseNav) || baseNav.length === 0) return []
|
||||
const grouped = isGrouped(baseNav)
|
||||
const { entries } = readOverrides(baseNav, overrides, grouped)
|
||||
|
||||
if (!grouped) {
|
||||
return [{ title: null, items: byOrder(mergeItems(baseNav, entries, true)) }]
|
||||
}
|
||||
const { kept, moved } = regroup(baseNav, entries)
|
||||
return kept.map((g) => ({
|
||||
...g,
|
||||
title: g.title ?? null,
|
||||
items: byOrder(mergeItems([...g.items, ...(moved.get(g.title) || [])], entries, true)),
|
||||
}))
|
||||
}
|
||||
|
||||
// Did the admin actually move anything? Comparing the edited sequence with the
|
||||
// coded one is what decides whether orders are written at all: an admin who only
|
||||
// renamed an item should not pin the position of every other one, or a route
|
||||
// added in code later would land in an arbitrary place.
|
||||
//
|
||||
// The base side is restricted to the rows the editor is actually holding: §8.1
|
||||
// filters the palette to what this admin can themselves see, and an item that
|
||||
// their role or a shard feature kept off the screen is not a reorder.
|
||||
function orderMatchesBase(groups, baseNav) {
|
||||
const flatten = (gs) => gs.flatMap((g) => g.items.map((i) => `${g.title ?? ''}::${i.to}`))
|
||||
const base = isGrouped(baseNav)
|
||||
? baseNav.map((g) => ({ title: g.title ?? null, items: g.items }))
|
||||
: [{ title: null, items: baseNav }]
|
||||
const shown = new Set(groups.flatMap((g) => g.items.map((i) => i.to)))
|
||||
const a = flatten(groups)
|
||||
const b = flatten(base.map((g) => ({ ...g, items: g.items.filter((i) => shown.has(i.to)) })))
|
||||
return a.length === b.length && a.every((v, i) => v === b[i])
|
||||
}
|
||||
|
||||
/**
|
||||
* The rows the admin has been editing, back as an overrides object to store.
|
||||
* Only differences from the code default are written — a field that matches the
|
||||
* default is absent, so the row stays a small statement of intent rather than a
|
||||
* snapshot of the nav.
|
||||
*
|
||||
* @param {Array} groups the editor's groups, in their current order
|
||||
* @param {Array} baseNav the hardcoded nav these rows came from
|
||||
* @param {object|null} stored the overrides as loaded, so entries for items
|
||||
* this admin could not see (role- or feature-gated out of their palette) are
|
||||
* carried through rather than silently dropped on save
|
||||
* @returns {object} the overrides to store — `{}` when nothing differs
|
||||
*/
|
||||
export function buildNavOverrides(groups, baseNav, stored = null) {
|
||||
if (!Array.isArray(groups) || !Array.isArray(baseNav)) return {}
|
||||
const grouped = isGrouped(baseNav)
|
||||
const baseItems = new Map(
|
||||
(grouped ? baseNav.flatMap((g) => g.items.map((i) => [i, g.title ?? null])) : baseNav.map((i) => [i, null])).map(
|
||||
([item, title]) => [item.to, { label: item.label, group: title }],
|
||||
),
|
||||
)
|
||||
|
||||
const out = {}
|
||||
// Carry through what this admin's palette never showed them. An entry for a
|
||||
// `to` the base nav no longer declares is NOT carried: dropping it is the
|
||||
// cleanup, and applyNavOverrides ignores it anyway.
|
||||
const shown = new Set(groups.flatMap((g) => g.items.map((i) => i.to)))
|
||||
if (stored && typeof stored === 'object' && !Array.isArray(stored)) {
|
||||
for (const [to, entry] of Object.entries(stored)) {
|
||||
if (!shown.has(to) && baseItems.has(to) && entry && typeof entry === 'object') out[to] = entry
|
||||
}
|
||||
}
|
||||
|
||||
const writeOrder = !orderMatchesBase(groups, baseNav)
|
||||
for (const group of groups) {
|
||||
group.items.forEach((row, index) => {
|
||||
const base = baseItems.get(row.to)
|
||||
if (!base) return
|
||||
const entry = {}
|
||||
const label = typeof row.label === 'string' ? row.label.trim() : ''
|
||||
if (label && label !== base.label) entry.label = label
|
||||
if (row.hidden === true) entry.hidden = true
|
||||
if (grouped && (group.title ?? null) !== base.group && group.title) entry.group = group.title
|
||||
if (writeOrder) entry.order = index
|
||||
if (Object.keys(entry).length > 0) out[row.to] = entry
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
export default applyNavOverrides
|
||||
|
||||
32
client/src/lib/settingsJson.js
Normal file
32
client/src/lib/settingsJson.js
Normal file
@@ -0,0 +1,32 @@
|
||||
// Parse a JSON-valued settings row, client side.
|
||||
//
|
||||
// The counterpart to server/src/utils/settingsJson.js, and deliberately the same
|
||||
// three lines of judgement: `settings.value` is TEXT, so theme_visual,
|
||||
// brand_assets and the three nav_* keys all arrive as strings, and a malformed
|
||||
// or wrong-shaped one must read as **absent** — the surface falls back to its
|
||||
// BRAND_* env / theme.css / hardcoded NAV default — never as an error and never
|
||||
// as a half-applied object.
|
||||
//
|
||||
// THEMING_AND_NAV.md §4.4 planned this "with its first consumer"; that consumer
|
||||
// is the public header reading nav_public. `parseLayout` in heroLayout.js keeps
|
||||
// its own version check because it validates a shape, not just a shape's kind.
|
||||
|
||||
/**
|
||||
* @param {string|null|undefined} str the raw stored value
|
||||
* @returns {object|null} the parsed object, or null when absent/malformed
|
||||
*/
|
||||
export function parseJsonSetting(str) {
|
||||
if (typeof str !== 'string' || str === '') return null
|
||||
let parsed
|
||||
try {
|
||||
parsed = JSON.parse(str)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
// Only plain objects. A stored `null`, `4`, `"x"` or array is as unusable to
|
||||
// every consumer of these keys as a syntax error is.
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null
|
||||
return parsed
|
||||
}
|
||||
|
||||
export default parseJsonSetting
|
||||
56
client/src/lib/useNavOverrides.js
Normal file
56
client/src/lib/useNavOverrides.js
Normal file
@@ -0,0 +1,56 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { api } from '../api/client.js'
|
||||
import { parseJsonSetting } from './settingsJson.js'
|
||||
|
||||
// The nav overrides for the two authenticated layouts (THEMING_AND_NAV.md §4.2).
|
||||
//
|
||||
// `nav_public` rides along in the public settings payload, but `nav_admin` and
|
||||
// `nav_player` deliberately do not: an anonymous visitor has no use for either,
|
||||
// and the admin nav's labels describe the shape of the admin surface. Their
|
||||
// owners read them from GET /api/v1/settings/nav, which any signed-in account
|
||||
// may call — AdminLayout renders for editors and moderators, who cannot reach
|
||||
// GET /admin/settings at all.
|
||||
//
|
||||
// Failing quiet is the whole posture: a request that errors, a malformed row and
|
||||
// "not fetched yet" are the same state to the caller, `{}`, which
|
||||
// applyNavOverrides turns into the coded nav. A sidebar must never blink empty
|
||||
// because a settings call was slow.
|
||||
|
||||
// One module-level copy, so the second layout to mount renders the nav it
|
||||
// already knows rather than flashing the coded one, and so the nav editor can
|
||||
// push its save into the sidebar the admin is looking at without a reload.
|
||||
let cache = {}
|
||||
const subscribers = new Set()
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const data = await api.navSettings()
|
||||
cache = {
|
||||
nav_admin: parseJsonSetting(data?.nav_admin),
|
||||
nav_player: parseJsonSetting(data?.nav_player),
|
||||
}
|
||||
subscribers.forEach((fn) => fn(cache))
|
||||
} catch {
|
||||
/* the coded nav is the fallback, and it is already on screen */
|
||||
}
|
||||
return cache
|
||||
}
|
||||
|
||||
/** Re-read the rows after a save, so the live sidebar catches up at once. */
|
||||
export function refreshNavOverrides() {
|
||||
return load()
|
||||
}
|
||||
|
||||
export function useNavOverrides() {
|
||||
const [overrides, setOverrides] = useState(cache)
|
||||
|
||||
useEffect(() => {
|
||||
subscribers.add(setOverrides)
|
||||
load()
|
||||
return () => subscribers.delete(setOverrides)
|
||||
}, [])
|
||||
|
||||
return overrides
|
||||
}
|
||||
|
||||
export default useNavOverrides
|
||||
@@ -1,9 +1,11 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { NavLink, Outlet, useNavigate, useLocation } from 'react-router-dom'
|
||||
import MoonDot from '../../components/MoonDot.jsx'
|
||||
import BrandLogo from '../../components/BrandLogo.jsx'
|
||||
import { useAuth } from '../../contexts/AuthContext.jsx'
|
||||
import { useSite } from '../../contexts/SiteContext.jsx'
|
||||
import { applyNavOverrides } from '../../lib/navOverrides.js'
|
||||
import { useNavOverrides } from '../../lib/useNavOverrides.js'
|
||||
|
||||
// Small inline stroke icons (16px, currentColor) — same style as ProviderIcon.
|
||||
// One shared frame keeps them terse; each item just supplies its path(s).
|
||||
@@ -39,6 +41,7 @@ const IconBot = () => <Icon><rect x="4" y="8" width="16" height="11" rx="2" /><p
|
||||
const IconPulse = () => <Icon><path d="M3 12h3l2 6 4-14 2 8h7" /></Icon>
|
||||
const IconUser = () => <Icon><circle cx="12" cy="8" r="4" /><path d="M4 21a8 8 0 0 1 16 0" /></Icon>
|
||||
const IconShard = () => <Icon><path d="M12 2l7 6-7 14-7-14z" /><path d="M5 8h14" /></Icon>
|
||||
const IconNav = () => <Icon><path d="M4 6h16M4 12h16M4 18h10" /><circle cx="18" cy="18" r="2.5" /></Icon>
|
||||
const IconPalette = () => <Icon><path d="M12 3a9 9 0 1 0 0 18 2 2 0 0 0 1.6-3.2 2 2 0 0 1 1.6-3.2H18a3 3 0 0 0 3-3 9 9 0 0 0-9-8.6z" /><circle cx="7.5" cy="11.5" r="1" /><circle cx="10.5" cy="7.5" r="1" /><circle cx="15" cy="8.5" r="1" /></Icon>
|
||||
|
||||
// Nav is grouped into collapsible categories. A group with no `title` renders
|
||||
@@ -46,7 +49,11 @@ const IconPalette = () => <Icon><path d="M12 3a9 9 0 1 0 0 18 2 2 0 0 0 1.6-3.2
|
||||
// (when present) matches server-side enforcement so the sidebar never shows a
|
||||
// link that would 403; an item without `roles` is visible to everyone.
|
||||
// Moderators are further confined to just their section + account (see below).
|
||||
const NAV = [
|
||||
//
|
||||
// Exported because Admin -> Navigation edits this list. It stays declared here:
|
||||
// the editor may relabel, reorder, hide and regroup, and `roles` is never its to
|
||||
// touch (§7) — navItemVisibleTo below is the filter that still decides.
|
||||
export const NAV = [
|
||||
{
|
||||
items: [
|
||||
{ to: '/admin', label: 'Dashboard', end: true, icon: IconHome, roles: ['admin', 'editor', 'moderator'] },
|
||||
@@ -77,6 +84,7 @@ const NAV = [
|
||||
{ to: '/admin/invites', label: 'Invites', icon: IconUsers, roles: ['admin'] },
|
||||
{ to: '/admin/settings', label: 'Settings', icon: IconGear, roles: ['admin'] },
|
||||
{ to: '/admin/appearance', label: 'Appearance', icon: IconPalette, roles: ['admin'] },
|
||||
{ to: '/admin/navigation', label: 'Navigation', icon: IconNav, roles: ['admin'] },
|
||||
{ to: '/admin/hero', label: 'Hero Editor', icon: IconHero, roles: ['admin'] },
|
||||
{ to: '/admin/auth-providers', label: 'Authentication', icon: IconKey, roles: ['admin'] },
|
||||
{ to: '/admin/discord-bot', label: 'Discord Bot', icon: IconBot, roles: ['admin'] },
|
||||
@@ -96,6 +104,35 @@ const NAV = [
|
||||
|
||||
const COLLAPSE_KEY = 'admin.nav.collapsed'
|
||||
|
||||
// Moderators only get the moderation section (Discord + in-game ops) + their
|
||||
// own account security.
|
||||
const MOD_PATHS = ['/admin/moderation', '/admin/moderation/appeals', '/admin/shard-ops', '/admin/houses', '/admin/account']
|
||||
|
||||
// The one row an override may never hide: the nav editor itself, which is the
|
||||
// only screen that can un-hide anything. The write path already refuses it
|
||||
// (server/src/utils/navOverrides.js) and the editor's own toggle is disabled —
|
||||
// this is the third guard, and the one that also covers a row edited straight
|
||||
// in the database. Cheap, and it makes "cannot be hidden" true without
|
||||
// qualification.
|
||||
const UNHIDEABLE = '/admin/navigation'
|
||||
|
||||
function keepEditorReachable(overrides) {
|
||||
const entry = overrides?.[UNHIDEABLE]
|
||||
if (!entry || entry.hidden !== true) return overrides
|
||||
const { hidden, ...rest } = entry
|
||||
return { ...overrides, [UNHIDEABLE]: rest }
|
||||
}
|
||||
|
||||
// Who may see a sidebar row. The single authority for that question: the layout
|
||||
// applies it after the override merge (overrides are presentation, this is the
|
||||
// boundary — §7), and Admin -> Navigation applies it to build its palette, so an
|
||||
// admin is never offered a row they cannot themselves see (§8.1).
|
||||
export function navItemVisibleTo(item, role) {
|
||||
if (item.roles && !item.roles.includes(role)) return false
|
||||
if (role === 'moderator') return MOD_PATHS.includes(item.to)
|
||||
return true
|
||||
}
|
||||
|
||||
const TITLES = {
|
||||
'/admin': 'Dashboard',
|
||||
'/admin/posts': 'Posts',
|
||||
@@ -108,6 +145,7 @@ const TITLES = {
|
||||
'/admin/houses': 'House Registry',
|
||||
'/admin/settings': 'Site Settings',
|
||||
'/admin/appearance': 'Appearance',
|
||||
'/admin/navigation': 'Navigation',
|
||||
'/admin/activity': 'Activity Log',
|
||||
'/admin/bot-activity': 'Web Bot Activity',
|
||||
'/admin/discord-bot': 'Discord Bot',
|
||||
@@ -145,6 +183,7 @@ const navBtnBase = {
|
||||
export default function AdminLayout() {
|
||||
const { user, logout } = useAuth()
|
||||
const { mode, siteTitle } = useSite()
|
||||
const navOverrides = useNavOverrides()
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const title = TITLES[location.pathname] || sectionTitle(location.pathname)
|
||||
@@ -152,20 +191,21 @@ export default function AdminLayout() {
|
||||
const wide = location.pathname === '/admin/hero'
|
||||
const modeDot = mode === 'live' ? 'var(--mode-live)' : 'var(--mode-maint)'
|
||||
|
||||
// Moderators only get the moderation section (Discord + in-game ops) + their
|
||||
// own account security.
|
||||
const isModerator = user?.role === 'moderator'
|
||||
const MOD_PATHS = ['/admin/moderation', '/admin/moderation/appeals', '/admin/shard-ops', '/admin/houses', '/admin/account']
|
||||
const visible = (item) => {
|
||||
if (item.roles && !item.roles.includes(user?.role)) return false
|
||||
if (isModerator) return MOD_PATHS.includes(item.to)
|
||||
return true
|
||||
}
|
||||
// Drop items the current role can't see, then drop any now-empty group so an
|
||||
// empty category header never renders.
|
||||
const navGroups = NAV
|
||||
.map((g) => ({ ...g, items: g.items.filter(visible) }))
|
||||
.filter((g) => g.items.length > 0)
|
||||
|
||||
// An admin may relabel, reorder, hide and regroup these rows from Admin →
|
||||
// Navigation. The merge runs FIRST and the role filter after it, so the filter
|
||||
// stays the boundary: an override cannot show a moderator a row their role
|
||||
// gate hides, whatever it says. With no stored row applyNavOverrides returns
|
||||
// NAV itself and this is exactly the code that ran before the feature.
|
||||
const navGroups = useMemo(
|
||||
() =>
|
||||
applyNavOverrides(NAV, keepEditorReachable(navOverrides.nav_admin))
|
||||
.map((g) => ({ ...g, items: g.items.filter((item) => navItemVisibleTo(item, user?.role)) }))
|
||||
// Drop any now-empty group so an empty category header never renders.
|
||||
.filter((g) => g.items.length > 0),
|
||||
[navOverrides.nav_admin, user?.role],
|
||||
)
|
||||
|
||||
// Accordion: track which titled categories are collapsed. Persist across
|
||||
// reloads; default all-open. The group holding the active route auto-opens.
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from 'react'
|
||||
import { Loading, ErrorState } from '../../../components/PageState.jsx'
|
||||
import { api } from '../../../api/client.js'
|
||||
import { useSite } from '../../../contexts/SiteContext.jsx'
|
||||
import { parseJsonSetting } from '../../../lib/settingsJson.js'
|
||||
import BrandAssetsPanel from './BrandAssetsPanel.jsx'
|
||||
|
||||
// Admin · Appearance — the theme and brand-asset halves of
|
||||
@@ -82,27 +83,13 @@ export default function AppearanceAdmin() {
|
||||
.then(([opts, all]) => {
|
||||
if (!active) return
|
||||
setOptions(opts)
|
||||
// The stored value is a JSON string (settings.value is TEXT). Malformed
|
||||
// reads as absent, exactly as the server treats it — the form then shows
|
||||
// the shipped default rather than an error.
|
||||
let parsed = null
|
||||
try {
|
||||
const raw = all.theme_visual
|
||||
parsed = raw ? JSON.parse(raw) : null
|
||||
} catch {
|
||||
parsed = null
|
||||
}
|
||||
// The stored values are JSON strings (settings.value is TEXT), and a
|
||||
// malformed one reads as absent exactly as the server treats it — the
|
||||
// form then shows the shipped default rather than an error.
|
||||
const parsed = parseJsonSetting(all.theme_visual)
|
||||
setStored(Boolean(all.theme_visual))
|
||||
// Same fail-safe parse as the theme: a malformed row reads as absent, so
|
||||
// the panel shows the env defaults rather than an error.
|
||||
let parsedAssets = null
|
||||
try {
|
||||
parsedAssets = all.brand_assets ? JSON.parse(all.brand_assets) : null
|
||||
} catch {
|
||||
parsedAssets = null
|
||||
}
|
||||
setAssets(parsedAssets && typeof parsedAssets === 'object' && !Array.isArray(parsedAssets) ? parsedAssets : {})
|
||||
if (parsed && typeof parsed === 'object') {
|
||||
setAssets(parseJsonSetting(all.brand_assets) || {})
|
||||
if (parsed) {
|
||||
setPreset(parsed.preset || 'runic-gateway')
|
||||
setCustom({
|
||||
colors: parsed.custom?.colors || {},
|
||||
|
||||
465
client/src/routes/admin/views/NavEditor.jsx
Normal file
465
client/src/routes/admin/views/NavEditor.jsx
Normal file
@@ -0,0 +1,465 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { DndContext, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors } from '@dnd-kit/core'
|
||||
import {
|
||||
SortableContext,
|
||||
arrayMove,
|
||||
sortableKeyboardCoordinates,
|
||||
useSortable,
|
||||
verticalListSortingStrategy,
|
||||
} from '@dnd-kit/sortable'
|
||||
import { CSS } from '@dnd-kit/utilities'
|
||||
|
||||
import { Loading, ErrorState } from '../../../components/PageState.jsx'
|
||||
import { api } from '../../../api/client.js'
|
||||
import { useAuth } from '../../../contexts/AuthContext.jsx'
|
||||
import { useSite } from '../../../contexts/SiteContext.jsx'
|
||||
import { useShardFeatures, canSee } from '../../../lib/useShardFeatures.js'
|
||||
import { buildNavRows, buildNavOverrides } from '../../../lib/navOverrides.js'
|
||||
import { parseJsonSetting } from '../../../lib/settingsJson.js'
|
||||
import { refreshNavOverrides } from '../../../lib/useNavOverrides.js'
|
||||
import { NAV as PUBLIC_NAV } from '../../../components/SiteHeader.jsx'
|
||||
import { NAV as ADMIN_NAV, navItemVisibleTo } from '../AdminLayout.jsx'
|
||||
import { NAV as PLAYER_NAV } from '../../player/PlayerPortalLayout.jsx'
|
||||
|
||||
// Admin · Navigation — phases 6-8 of docs/website/THEMING_AND_NAV.md.
|
||||
//
|
||||
// The three navs stay declared in code, each in the component that renders it;
|
||||
// this screen writes an override *layer* over them (§7). It can relabel,
|
||||
// reorder, hide and — on the admin sidebar — move a row into another existing
|
||||
// section, and nothing else. It cannot introduce a route and it cannot touch a
|
||||
// `roles` or `feature` gate, so the filters in the layouts still decide who sees
|
||||
// what, and they run after the merge.
|
||||
//
|
||||
// Three things shape the screen:
|
||||
//
|
||||
// • The palette is filtered to the editing admin's OWN visible rows (§8.1) —
|
||||
// the base array run through their role and this shard's feature gates. An
|
||||
// admin cannot drag in, and so can never accidentally advertise, something
|
||||
// they cannot see themselves. An override on a row they cannot see is
|
||||
// carried through their save untouched rather than quietly reset.
|
||||
// • The rows come from the same merge the site renders (buildNavRows), hidden
|
||||
// ones included, so the editor cannot show an order the nav does not use.
|
||||
// • Saving writes a settings row; "reset" DELETES it. Absence of the row is
|
||||
// what selects the coded default, so reset cannot store a copy of it — and a
|
||||
// save whose result is empty deletes the row for the same reason (§4.1).
|
||||
|
||||
// The nav editor's own row. Hiding it would remove the only screen that can
|
||||
// un-hide it, so its eye toggle is disabled here and the server drops `hidden`
|
||||
// on it as well (server/src/utils/navOverrides.js) — a hand-written row cannot
|
||||
// do what the UI refuses.
|
||||
const SELF = '/admin/navigation'
|
||||
|
||||
const TABS = [
|
||||
{ key: 'nav_public', label: 'Public site', hint: 'The header on every public page.' },
|
||||
{ key: 'nav_admin', label: 'Admin', hint: 'This sidebar. Rows can also move between sections.' },
|
||||
{ key: 'nav_player', label: 'Player portal', hint: 'The sidebar a signed-in player sees.' },
|
||||
]
|
||||
|
||||
function DragHandle({ attributes, listeners, disabled }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="sans"
|
||||
aria-label="Reorder"
|
||||
disabled={disabled}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
style={{
|
||||
border: 'none',
|
||||
background: 'transparent',
|
||||
color: 'var(--dim)',
|
||||
cursor: disabled ? 'default' : 'grab',
|
||||
padding: '2px 4px',
|
||||
touchAction: 'none',
|
||||
}}
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" focusable="false">
|
||||
<circle cx="9" cy="6" r="1.6" />
|
||||
<circle cx="15" cy="6" r="1.6" />
|
||||
<circle cx="9" cy="12" r="1.6" />
|
||||
<circle cx="15" cy="12" r="1.6" />
|
||||
<circle cx="9" cy="18" r="1.6" />
|
||||
<circle cx="15" cy="18" r="1.6" />
|
||||
</svg>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function EyeIcon({ off }) {
|
||||
return (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" focusable="false">
|
||||
<path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7z" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
{off && <path d="M3 3l18 18" />}
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function Row({ row, groupTitles, currentGroup, baseGroup, onChange, onMoveGroup }) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: row.to })
|
||||
const renamed = row.label !== row.defaultLabel
|
||||
const locked = row.to === SELF
|
||||
|
||||
return (
|
||||
<li
|
||||
ref={setNodeRef}
|
||||
style={{
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
padding: '7px 10px',
|
||||
borderRadius: 'var(--radius-input)',
|
||||
border: '1px solid var(--line)',
|
||||
background: isDragging ? 'var(--blue)' : 'var(--panel-flat)',
|
||||
opacity: row.hidden ? 0.55 : 1,
|
||||
listStyle: 'none',
|
||||
}}
|
||||
>
|
||||
<DragHandle attributes={attributes} listeners={listeners} />
|
||||
<input
|
||||
className="input"
|
||||
value={row.label}
|
||||
placeholder={row.defaultLabel}
|
||||
maxLength={64}
|
||||
onChange={(e) => onChange({ ...row, label: e.target.value })}
|
||||
aria-label={`Label for ${row.defaultLabel}`}
|
||||
style={{ flex: '1 1 auto', minWidth: 120, padding: '5px 8px', fontSize: '0.84rem' }}
|
||||
/>
|
||||
{/* The route, for orientation — it is what the override is keyed by. Fixed
|
||||
and truncating rather than flexible: /admin/moderation/appeals would
|
||||
otherwise wrap and squeeze the label input it sits beside. */}
|
||||
<code
|
||||
className="sans dim"
|
||||
title={row.to}
|
||||
style={{
|
||||
flex: '0 0 auto',
|
||||
width: 130,
|
||||
fontSize: '0.7rem',
|
||||
opacity: 0.75,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
textAlign: 'right',
|
||||
}}
|
||||
>
|
||||
{row.to}
|
||||
</code>
|
||||
{renamed && (
|
||||
<button
|
||||
type="button"
|
||||
className="sans"
|
||||
title="Use the coded label again"
|
||||
onClick={() => onChange({ ...row, label: row.defaultLabel })}
|
||||
style={{ border: 'none', background: 'transparent', color: 'var(--accent)', fontSize: '0.72rem', cursor: 'pointer', padding: 0 }}
|
||||
>
|
||||
reset
|
||||
</button>
|
||||
)}
|
||||
{groupTitles.length > 0 && (
|
||||
<select
|
||||
className="select"
|
||||
value={currentGroup ?? ''}
|
||||
onChange={(e) => onMoveGroup(row.to, e.target.value || null)}
|
||||
aria-label={`Section for ${row.defaultLabel}`}
|
||||
style={{ flex: '0 0 auto', width: 130, padding: '4px 6px', fontSize: '0.76rem' }}
|
||||
>
|
||||
{/* "(no section)" is offered only to a row that is coded into one of
|
||||
the untitled groups (Dashboard, Account) — for anything else it is
|
||||
a move that cannot be stored: an override names an existing titled
|
||||
section or nothing at all (§6.4), so picking it would silently do
|
||||
nothing. Such a row can still move OUT and back, which clears the
|
||||
override rather than storing a null group. */}
|
||||
{baseGroup === null && <option value="">(no section)</option>}
|
||||
{groupTitles.map((t) => (
|
||||
<option key={t} value={t}>
|
||||
{t}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="sans"
|
||||
disabled={locked}
|
||||
title={
|
||||
locked
|
||||
? 'This screen is the only way back — it cannot be hidden'
|
||||
: row.hidden
|
||||
? 'Currently hidden. Show it again'
|
||||
: 'Hide from this nav'
|
||||
}
|
||||
aria-pressed={row.hidden}
|
||||
onClick={() => onChange({ ...row, hidden: !row.hidden })}
|
||||
style={{
|
||||
border: '1px solid var(--line)',
|
||||
borderRadius: 'var(--radius-input)',
|
||||
background: 'transparent',
|
||||
color: locked ? 'var(--dim)' : row.hidden ? 'var(--accent)' : 'var(--muted)',
|
||||
cursor: locked ? 'not-allowed' : 'pointer',
|
||||
padding: '4px 6px',
|
||||
display: 'flex',
|
||||
opacity: locked ? 0.5 : 1,
|
||||
}}
|
||||
>
|
||||
<EyeIcon off={row.hidden} />
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
export default function NavEditor() {
|
||||
const { user } = useAuth()
|
||||
const { refresh: refreshSite } = useSite()
|
||||
const shardFeatures = useShardFeatures()
|
||||
const [tab, setTab] = useState('nav_public')
|
||||
// Per nav: the editable groups, the overrides as loaded (so a row this admin
|
||||
// cannot see survives their save), and whether a settings row exists at all.
|
||||
const [state, setState] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [saved, setSaved] = useState('')
|
||||
const [dirty, setDirty] = useState({})
|
||||
|
||||
// The palette: each base nav, filtered to what THIS admin can see (§8.1). The
|
||||
// public nav's gates are the shard-feature ones; the admin nav's are roles.
|
||||
// The player portal has no gates at all.
|
||||
const palettes = useMemo(
|
||||
() => ({
|
||||
nav_public: PUBLIC_NAV.filter((item) => !item.feature || canSee(shardFeatures, item.feature)),
|
||||
nav_admin: ADMIN_NAV.map((g) => ({ ...g, items: g.items.filter((i) => navItemVisibleTo(i, user?.role)) })).filter(
|
||||
(g) => g.items.length > 0,
|
||||
),
|
||||
nav_player: PLAYER_NAV,
|
||||
}),
|
||||
[shardFeatures, user?.role],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
api.admin
|
||||
.getSettings()
|
||||
.then((all) => {
|
||||
if (!active) return
|
||||
const next = {}
|
||||
for (const { key } of TABS) {
|
||||
const stored = parseJsonSetting(all[key])
|
||||
next[key] = { stored, hasRow: Boolean(all[key]), groups: buildNavRows(palettes[key], stored) }
|
||||
}
|
||||
setState(next)
|
||||
})
|
||||
.catch(() => active && setError('Could not load the navigation settings.'))
|
||||
.finally(() => active && setLoading(false))
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
// Loaded once; the palettes settle before the fetch resolves in practice, and
|
||||
// re-running on a feature flip would discard the admin's unsaved edits.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
|
||||
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
|
||||
)
|
||||
|
||||
if (loading) return <Loading />
|
||||
if (error && !state) return <ErrorState message={error} />
|
||||
|
||||
const current = state[tab]
|
||||
const groupTitles = current.groups.map((g) => g.title).filter(Boolean)
|
||||
// Where each row is declared in code, so the section dropdown can offer only
|
||||
// the destinations an override is able to express.
|
||||
const baseGroups = new Map(
|
||||
(Array.isArray(palettes[tab]) && palettes[tab][0]?.items
|
||||
? palettes[tab].flatMap((g) => g.items.map((i) => [i.to, g.title ?? null]))
|
||||
: []),
|
||||
)
|
||||
|
||||
function mutate(updater) {
|
||||
setState((s) => ({ ...s, [tab]: { ...s[tab], groups: updater(s[tab].groups) } }))
|
||||
setDirty((d) => ({ ...d, [tab]: true }))
|
||||
setSaved('')
|
||||
}
|
||||
|
||||
const onRowChange = (next) =>
|
||||
mutate((groups) => groups.map((g) => ({ ...g, items: g.items.map((i) => (i.to === next.to ? next : i)) })))
|
||||
|
||||
// Sections change by dropdown, not by dragging: a drag that could land in
|
||||
// another list is a lot of interaction surface for something an admin does
|
||||
// once, and this keeps every drag a simple reorder. The row goes to the end of
|
||||
// its new section, where it is visible and can then be dragged into place.
|
||||
const onMoveGroup = (to, title) =>
|
||||
mutate((groups) => {
|
||||
const moving = groups.flatMap((g) => g.items).find((i) => i.to === to)
|
||||
if (!moving) return groups
|
||||
return groups.map((g) => {
|
||||
if ((g.title ?? null) === title) return { ...g, items: [...g.items.filter((i) => i.to !== to), moving] }
|
||||
return { ...g, items: g.items.filter((i) => i.to !== to) }
|
||||
})
|
||||
})
|
||||
|
||||
const onDragEnd = (groupIndex) => (event) => {
|
||||
const { active, over } = event
|
||||
if (!over || active.id === over.id) return
|
||||
mutate((groups) =>
|
||||
groups.map((g, i) => {
|
||||
if (i !== groupIndex) return g
|
||||
const from = g.items.findIndex((it) => it.to === active.id)
|
||||
const to = g.items.findIndex((it) => it.to === over.id)
|
||||
if (from < 0 || to < 0) return g
|
||||
return { ...g, items: arrayMove(g.items, from, to) }
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
// Push a save into whatever is rendering that nav right now, so the admin sees
|
||||
// what they just did: the header re-reads the public settings, the two
|
||||
// authenticated sidebars re-read /settings/nav.
|
||||
async function propagate(key) {
|
||||
if (key === 'nav_public') await refreshSite()
|
||||
else await refreshNavOverrides()
|
||||
}
|
||||
|
||||
async function save() {
|
||||
setBusy(true)
|
||||
setError('')
|
||||
try {
|
||||
const overrides = buildNavOverrides(current.groups, palettes[tab], current.stored)
|
||||
const empty = Object.keys(overrides).length === 0
|
||||
// Nothing differs from the code default, so there is nothing to store —
|
||||
// and a row that says nothing would still read as "this nav was
|
||||
// customised". Delete it instead (§2, §4.1).
|
||||
if (empty) await api.admin.resetSetting(tab)
|
||||
else await api.admin.updateSettings({ [tab]: overrides })
|
||||
setState((s) => ({
|
||||
...s,
|
||||
[tab]: { ...s[tab], stored: empty ? null : overrides, hasRow: !empty },
|
||||
}))
|
||||
setDirty((d) => ({ ...d, [tab]: false }))
|
||||
setSaved(tab)
|
||||
await propagate(tab)
|
||||
} catch (err) {
|
||||
setError(err.message || 'Could not save this navigation.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function resetNav() {
|
||||
setBusy(true)
|
||||
setError('')
|
||||
try {
|
||||
await api.admin.resetSetting(tab)
|
||||
setState((s) => ({ ...s, [tab]: { stored: null, hasRow: false, groups: buildNavRows(palettes[tab], null) } }))
|
||||
setDirty((d) => ({ ...d, [tab]: false }))
|
||||
setSaved('')
|
||||
await propagate(tab)
|
||||
} catch (err) {
|
||||
setError(err.message || 'Could not reset this navigation.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const activeTab = TABS.find((t) => t.key === tab)
|
||||
|
||||
return (
|
||||
<section style={{ maxWidth: 860, display: 'flex', flexDirection: 'column', gap: 22 }}>
|
||||
<p className="sans dim" style={{ margin: 0, fontSize: '0.82rem', lineHeight: 1.7 }}>
|
||||
Rename, reorder and hide the entries in each navigation. The pages themselves are unchanged — this
|
||||
only decides what is advertised, and it can never show anyone a link their role or this shard’s
|
||||
visibility settings would hide.
|
||||
</p>
|
||||
|
||||
{/* ── Tabs ───────────────────────────────────────────────── */}
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
|
||||
{TABS.map((t) => (
|
||||
<button
|
||||
key={t.key}
|
||||
type="button"
|
||||
className="sans"
|
||||
onClick={() => {
|
||||
setTab(t.key)
|
||||
setSaved('')
|
||||
}}
|
||||
aria-pressed={tab === t.key}
|
||||
style={{
|
||||
padding: '8px 14px',
|
||||
borderRadius: 'var(--radius-input)',
|
||||
border: `1px solid ${tab === t.key ? 'var(--accent)' : 'var(--line)'}`,
|
||||
background: tab === t.key ? 'var(--blue)' : 'transparent',
|
||||
color: tab === t.key ? 'var(--ink)' : 'var(--muted)',
|
||||
cursor: 'pointer',
|
||||
fontSize: '0.86rem',
|
||||
}}
|
||||
>
|
||||
{t.label}
|
||||
{dirty[t.key] && <span style={{ color: 'var(--accent)' }}> •</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<span className="sans dim" style={{ fontSize: '0.76rem' }}>
|
||||
{activeTab.hint}{' '}
|
||||
{current.hasRow
|
||||
? 'This nav has saved overrides.'
|
||||
: 'This nav has never been customised, so it renders exactly as coded.'}
|
||||
</span>
|
||||
|
||||
{/* ── Rows ───────────────────────────────────────────────── */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 18 }}>
|
||||
{current.groups.map((group, groupIndex) => (
|
||||
<div key={group.title ?? `group-${groupIndex}`}>
|
||||
{group.title && <span className="field-label">{group.title}</span>}
|
||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={onDragEnd(groupIndex)}>
|
||||
<SortableContext items={group.items.map((i) => i.to)} strategy={verticalListSortingStrategy}>
|
||||
<ul style={{ display: 'flex', flexDirection: 'column', gap: 6, margin: '8px 0 0', padding: 0 }}>
|
||||
{group.items.map((row) => (
|
||||
<Row
|
||||
key={row.to}
|
||||
row={row}
|
||||
groupTitles={groupTitles}
|
||||
currentGroup={group.title ?? null}
|
||||
baseGroup={baseGroups.get(row.to) ?? null}
|
||||
onChange={onRowChange}
|
||||
onMoveGroup={onMoveGroup}
|
||||
/>
|
||||
))}
|
||||
{group.items.length === 0 && (
|
||||
<li className="sans dim" style={{ fontSize: '0.76rem', listStyle: 'none', padding: '6px 2px' }}>
|
||||
Empty — this section is not rendered until something is moved into it.
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<button onClick={save} disabled={busy} className="btn btn-primary btn-sq">
|
||||
{busy ? 'Saving…' : 'Save navigation'}
|
||||
</button>
|
||||
<button
|
||||
onClick={resetNav}
|
||||
disabled={busy || !current.hasRow}
|
||||
className="pill"
|
||||
title={current.hasRow ? 'Delete the saved overrides for this nav' : 'Nothing to reset'}
|
||||
>
|
||||
Reset to default
|
||||
</button>
|
||||
{saved === tab && <span className="sans" style={{ color: '#7fd0a4', fontSize: '0.85rem' }}>Saved.</span>}
|
||||
{error && <span className="sans" style={{ color: '#d98b84', fontSize: '0.85rem' }}>{error}</span>}
|
||||
</div>
|
||||
|
||||
<p className="sans dim" style={{ margin: 0, fontSize: '0.76rem', lineHeight: 1.7 }}>
|
||||
Only entries you can see yourself are listed. Anything hidden from you by your role or by Shard
|
||||
Visibility keeps whatever it was already set to.
|
||||
</p>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -1,8 +1,11 @@
|
||||
import { useMemo } from 'react'
|
||||
import { NavLink, Outlet, useNavigate, useLocation } from 'react-router-dom'
|
||||
import MoonDot from '../../components/MoonDot.jsx'
|
||||
import BrandLogo from '../../components/BrandLogo.jsx'
|
||||
import { useAuth } from '../../contexts/AuthContext.jsx'
|
||||
import { useSite } from '../../contexts/SiteContext.jsx'
|
||||
import { applyNavOverrides } from '../../lib/navOverrides.js'
|
||||
import { useNavOverrides } from '../../lib/useNavOverrides.js'
|
||||
|
||||
// Shared shell for the logged-in player portal. Uses the same sidebar shell as
|
||||
// Admin (icon nav, sticky content header, footer sign-out) so the two logged-in
|
||||
@@ -31,7 +34,11 @@ const IconUser = () => <Icon><circle cx="12" cy="8" r="4" /><path d="M4 21a8 8 0
|
||||
const IconGear = () => <Icon><circle cx="12" cy="12" r="3" /><path d="M12 2v3M12 19v3M2 12h3M19 12h3M4.9 4.9l2.1 2.1M17 17l2.1 2.1M19.1 4.9L17 7M7 17l-2.1 2.1" /></Icon>
|
||||
const IconShield = () => <Icon><path d="M12 3l7 3v5c0 5-3.5 8-7 10-3.5-2-7-5-7-10V6z" /><path d="M9 12l2 2 4-4" /></Icon>
|
||||
|
||||
const NAV = [
|
||||
// Exported because Admin -> Navigation edits this list. It stays declared here;
|
||||
// the editor may only relabel, reorder and hide what it finds (§7). No row
|
||||
// carries a gate — every player sees all three — so the merged result is what
|
||||
// renders, with no filter after it.
|
||||
export const NAV = [
|
||||
{ to: '/player', label: 'Characters', end: true, icon: IconUser },
|
||||
{ to: '/account/appeals', label: 'Appeals', icon: IconShield },
|
||||
{ to: '/account', label: 'Account', end: true, icon: IconGear },
|
||||
@@ -61,6 +68,8 @@ const navBtnBase = {
|
||||
export default function PlayerPortalLayout() {
|
||||
const { user, logout } = useAuth()
|
||||
const { siteTitle } = useSite()
|
||||
const navOverrides = useNavOverrides()
|
||||
const nav = useMemo(() => applyNavOverrides(NAV, navOverrides.nav_player), [navOverrides.nav_player])
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const title =
|
||||
@@ -100,7 +109,7 @@ export default function PlayerPortalLayout() {
|
||||
</div>
|
||||
|
||||
<nav style={{ flex: 1, padding: '14px 12px', display: 'flex', flexDirection: 'column', gap: 4, overflowY: 'auto' }}>
|
||||
{NAV.map((n) => (
|
||||
{nav.map((n) => (
|
||||
<NavLink
|
||||
key={n.to}
|
||||
to={n.to}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import { applyNavOverrides } from '../src/lib/navOverrides.js'
|
||||
import { applyNavOverrides, buildNavRows, buildNavOverrides } from '../src/lib/navOverrides.js'
|
||||
|
||||
// The nav-override merge (docs/website/THEMING_AND_NAV.md §7.1) — the one piece
|
||||
// of this feature with real correctness risk, so it is tested in isolation from
|
||||
@@ -201,3 +201,150 @@ test('a non-array base nav yields an empty nav rather than throwing', () => {
|
||||
test('an empty base nav stays empty', () => {
|
||||
assert.deepEqual(applyNavOverrides([], { '/': { label: 'Home' } }), [])
|
||||
})
|
||||
|
||||
// ── The editor's round trip (phase 7) ─────────────────────────────────────
|
||||
//
|
||||
// buildNavRows and buildNavOverrides are inverse, and the property that matters
|
||||
// is that the editor and the site agree: the rows an admin drags come out of the
|
||||
// same merge the layouts render, hidden ones included.
|
||||
|
||||
const rowLabels = (groups) => groups.map((g) => [g.title, g.items.map((i) => i.label)])
|
||||
|
||||
test('rows with no override are the coded nav, in code order', () => {
|
||||
const rows = buildNavRows(FLAT, null)
|
||||
assert.deepEqual(rowLabels(rows), [[null, ['Home', 'News', 'Wiki', 'Shard']]])
|
||||
assert.equal(rows[0].items.every((i) => i.hidden === false), true)
|
||||
})
|
||||
|
||||
test('a flat nav becomes one untitled group, so one editor handles both shapes', () => {
|
||||
assert.equal(buildNavRows(FLAT, null).length, 1)
|
||||
assert.equal(buildNavRows(GROUPED, null).length, 3)
|
||||
})
|
||||
|
||||
test('rows keep hidden items, in place and marked — the site drops them', () => {
|
||||
const overrides = { '/site/news': { hidden: true } }
|
||||
// The layout must not render it...
|
||||
assert.deepEqual(labels(applyNavOverrides(FLAT, overrides)), ['Home', 'Wiki', 'Shard'])
|
||||
// ...while the editor must, or there is no way to un-hide it.
|
||||
const rows = buildNavRows(FLAT, overrides)[0].items
|
||||
assert.deepEqual(rows.map((i) => i.label), ['Home', 'News', 'Wiki', 'Shard'])
|
||||
assert.equal(rows[1].hidden, true)
|
||||
assert.equal(rows[0].hidden, false)
|
||||
})
|
||||
|
||||
test('rows carry the coded label alongside the overridden one', () => {
|
||||
const rows = buildNavRows(FLAT, { '/site/news': { label: 'Announcements' } })[0].items
|
||||
assert.equal(rows[1].label, 'Announcements')
|
||||
assert.equal(rows[1].defaultLabel, 'News')
|
||||
})
|
||||
|
||||
test('rows show the same order the site renders', () => {
|
||||
const overrides = { '/wiki': { order: 0 }, '/': { order: 1 } }
|
||||
assert.deepEqual(labels(applyNavOverrides(FLAT, overrides)), ['Wiki', 'Home', 'News', 'Shard'])
|
||||
assert.deepEqual(rowLabels(buildNavRows(FLAT, overrides)), [[null, ['Wiki', 'Home', 'News', 'Shard']]])
|
||||
})
|
||||
|
||||
test('rows keep an emptied group so something can be moved back into it', () => {
|
||||
// applyNavOverrides drops a group whose every item is hidden; the editor must
|
||||
// still show the header, or the section is unreachable forever.
|
||||
const overrides = { '/admin/posts': { hidden: true }, '/admin/wiki': { hidden: true } }
|
||||
assert.equal(applyNavOverrides(GROUPED, overrides).some((g) => g.title === 'Content'), false)
|
||||
assert.equal(buildNavRows(GROUPED, overrides).some((g) => g.title === 'Content'), true)
|
||||
})
|
||||
|
||||
test('an untouched editor saves nothing at all', () => {
|
||||
// Opening the screen and pressing Save must not pin the position of every
|
||||
// item — the caller deletes the row when this comes back empty.
|
||||
assert.deepEqual(buildNavOverrides(buildNavRows(FLAT, null), FLAT), {})
|
||||
assert.deepEqual(buildNavOverrides(buildNavRows(GROUPED, null), GROUPED), {})
|
||||
})
|
||||
|
||||
test('a rename alone writes a label and no orders', () => {
|
||||
const groups = buildNavRows(FLAT, null)
|
||||
groups[0].items[1].label = 'Announcements'
|
||||
assert.deepEqual(buildNavOverrides(groups, FLAT), { '/site/news': { label: 'Announcements' } })
|
||||
})
|
||||
|
||||
test('a label typed back to the coded one is not stored as an override', () => {
|
||||
const groups = buildNavRows(FLAT, { '/site/news': { label: 'Announcements' } })
|
||||
groups[0].items[1].label = 'News'
|
||||
assert.deepEqual(buildNavOverrides(groups, FLAT), {})
|
||||
// Whitespace-only reads as "use the default" too.
|
||||
groups[0].items[1].label = ' '
|
||||
assert.deepEqual(buildNavOverrides(groups, FLAT), {})
|
||||
})
|
||||
|
||||
test('hiding alone writes hidden and no orders', () => {
|
||||
const groups = buildNavRows(FLAT, null)
|
||||
groups[0].items[3].hidden = true
|
||||
assert.deepEqual(buildNavOverrides(groups, FLAT), { '/site/shard': { hidden: true } })
|
||||
})
|
||||
|
||||
test('reordering writes an order for every row in the list', () => {
|
||||
// §7.1: explicit and implicit sort keys share one number line, so a partial
|
||||
// set of orders is the stale-row case rather than something the editor makes.
|
||||
const groups = buildNavRows(FLAT, null)
|
||||
const [home] = groups[0].items.splice(0, 1)
|
||||
groups[0].items.push(home)
|
||||
assert.deepEqual(buildNavOverrides(groups, FLAT), {
|
||||
'/site/news': { order: 0 },
|
||||
'/wiki': { order: 1 },
|
||||
'/site/shard': { order: 2 },
|
||||
'/': { order: 3 },
|
||||
})
|
||||
})
|
||||
|
||||
test('the round trip is stable: save, reload, save again yields the same thing', () => {
|
||||
const groups = buildNavRows(FLAT, null)
|
||||
groups[0].items.reverse()
|
||||
groups[0].items[0].label = 'The Shard'
|
||||
const first = buildNavOverrides(groups, FLAT)
|
||||
const second = buildNavOverrides(buildNavRows(FLAT, first), FLAT)
|
||||
assert.deepEqual(second, first)
|
||||
// And it renders what the editor showed.
|
||||
assert.deepEqual(labels(applyNavOverrides(FLAT, first)), ['The Shard', 'Wiki', 'News', 'Home'])
|
||||
})
|
||||
|
||||
test('moving an item to another section writes group, and moving it back clears it', () => {
|
||||
const groups = buildNavRows(GROUPED, null)
|
||||
const [posts] = groups[1].items.splice(0, 1)
|
||||
groups[2].items.push(posts)
|
||||
const saved = buildNavOverrides(groups, GROUPED)
|
||||
assert.equal(saved['/admin/posts'].group, 'System')
|
||||
assert.deepEqual(groupLabels(applyNavOverrides(GROUPED, saved)), [
|
||||
[null, ['Dashboard']],
|
||||
['Content', ['Wiki']],
|
||||
['System', ['Settings', 'Users', 'Posts']],
|
||||
])
|
||||
const back = buildNavRows(GROUPED, saved)
|
||||
const [moved] = back[2].items.splice(2, 1)
|
||||
back[1].items.unshift(moved)
|
||||
assert.equal(buildNavOverrides(back, GROUPED)['/admin/posts'], undefined)
|
||||
})
|
||||
|
||||
test('an override for an item outside this admin’s palette survives a save', () => {
|
||||
// §8.1 filters the editor to what the editing admin can themselves see. An
|
||||
// item filtered out has no row, and must not be quietly reset by their save.
|
||||
const visible = buildNavRows(FLAT, { '/site/shard': { hidden: true } }).map((g) => ({
|
||||
...g,
|
||||
items: g.items.filter((i) => !i.feature),
|
||||
}))
|
||||
const stored = { '/site/shard': { hidden: true }, '/site/news': { label: 'Old' } }
|
||||
const out = buildNavOverrides(visible, FLAT, stored)
|
||||
assert.deepEqual(out['/site/shard'], { hidden: true })
|
||||
// The rows they *could* see still win over what was stored.
|
||||
assert.equal(out['/site/news'], undefined)
|
||||
})
|
||||
|
||||
test('a stored entry for a route the code no longer declares is dropped on save', () => {
|
||||
const groups = buildNavRows(FLAT, null)
|
||||
const out = buildNavOverrides(groups, FLAT, { '/site/gone': { label: 'Ghost' } })
|
||||
assert.deepEqual(out, {})
|
||||
})
|
||||
|
||||
test('degenerate input yields an empty result rather than throwing', () => {
|
||||
assert.deepEqual(buildNavRows(null, {}), [])
|
||||
assert.deepEqual(buildNavRows([], {}), [])
|
||||
assert.deepEqual(buildNavOverrides(null, FLAT), {})
|
||||
assert.deepEqual(buildNavOverrides([], null), {})
|
||||
})
|
||||
|
||||
28
client/test/settingsJson.test.js
Normal file
28
client/test/settingsJson.test.js
Normal file
@@ -0,0 +1,28 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import { parseJsonSetting } from '../src/lib/settingsJson.js'
|
||||
|
||||
// The client counterpart to the server's parseJsonSetting. The property that
|
||||
// matters is the fail-safe one: anything unusable reads as **absent**, so the
|
||||
// consumer falls back to its coded default rather than rendering an error or a
|
||||
// half-applied object (THEMING_AND_NAV.md §4.4).
|
||||
|
||||
test('absent, empty and malformed values read as absent', () => {
|
||||
for (const bad of [undefined, null, '', '{', 'not json', 4, {}, []]) {
|
||||
assert.equal(parseJsonSetting(bad), null, `${JSON.stringify(bad)} should read as absent`)
|
||||
}
|
||||
})
|
||||
|
||||
test('valid JSON that is not a plain object reads as absent', () => {
|
||||
// A stored `null`, number, string or array is as unusable to every consumer of
|
||||
// these keys as a syntax error is.
|
||||
for (const bad of ['null', '4', '"x"', '[]', '[{"to":"/"}]', 'true']) {
|
||||
assert.equal(parseJsonSetting(bad), null, `${bad} should read as absent`)
|
||||
}
|
||||
})
|
||||
|
||||
test('a well-formed object is returned as parsed', () => {
|
||||
assert.deepEqual(parseJsonSetting('{"/site/news":{"order":2}}'), { '/site/news': { order: 2 } })
|
||||
assert.deepEqual(parseJsonSetting('{}'), {})
|
||||
})
|
||||
@@ -14,6 +14,7 @@ const { cleanBody } = require('../../../utils/sanitizeHtml')
|
||||
const { parseJsonSetting } = require('../../../utils/settingsJson')
|
||||
const { validateThemeVisual } = require('../../../utils/themeResolve')
|
||||
const { validateBrandAssets, resolveBrandAssets } = require('../../../utils/brandAssets')
|
||||
const { validateNavOverrides, resolveNavOverrides, NAV_KEYS } = require('../../../utils/navOverrides')
|
||||
const htmlShell = require('../../../utils/htmlShell')
|
||||
|
||||
const log = require('../../../utils/logger')('admin')
|
||||
@@ -566,6 +567,23 @@ async function updateSettings(req, res) {
|
||||
if (!check.ok) return res.status(400).json({ message: check.message })
|
||||
updates.brand_assets = JSON.stringify(resolveBrandAssets(parsed))
|
||||
}
|
||||
// The three nav rows are JSON too, and without this they would reach
|
||||
// settingsDb.set as objects and be stored as the string "[object Object]".
|
||||
// Shape only — whether a key names a route the nav actually declares is the
|
||||
// client's question, and utils/navOverrides.js says why. Resolved on the way
|
||||
// in so the stored row carries no dead fields, and so `hidden` can never land
|
||||
// on the nav editor's own row.
|
||||
for (const key of NAV_KEYS) {
|
||||
if (!(key in updates)) continue
|
||||
const raw = updates[key]
|
||||
const parsed = typeof raw === 'string' ? parseJsonSetting(raw) : raw
|
||||
if (typeof raw === 'string' && parsed === null) {
|
||||
return res.status(400).json({ message: `${key} must be a JSON object` })
|
||||
}
|
||||
const check = validateNavOverrides(parsed, key)
|
||||
if (!check.ok) return res.status(400).json({ message: check.message })
|
||||
updates[key] = JSON.stringify(resolveNavOverrides(parsed, key))
|
||||
}
|
||||
try {
|
||||
await settings.setMany(updates, req.user.id)
|
||||
// The HTML shell is templated from brand_assets and theme_visual, and is
|
||||
|
||||
@@ -34,6 +34,7 @@ settingsRouter.put(
|
||||
// #swagger.tags = ['Admin · Settings']
|
||||
// #swagger.summary = 'Update site settings (admin only)'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.description = 'Writes the given keys. The JSON-valued theming keys (theme_visual, brand_assets, nav_public, nav_admin, nav_player) accept an object or its stringified form, are validated strictly with the offending field named in the 400, and are stored stringified with unusable fields dropped. Nav overrides carry only label/order/hidden/group; whether a key names a route the nav declares is settled client-side at merge time.'
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", additionalProperties: true, description: "An object of key/value settings." } } } } */
|
||||
/* #swagger.responses[200] = { description: 'Updated settings', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||||
/* #swagger.responses[400] = { description: 'Body must be an object of key/value settings', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
|
||||
148
server/src/utils/navOverrides.js
Normal file
148
server/src/utils/navOverrides.js
Normal file
@@ -0,0 +1,148 @@
|
||||
// Navigation overrides — the `nav_public` / `nav_admin` / `nav_player` rows.
|
||||
//
|
||||
// { "/site/news": { "label": "Announcements", "order": 2 },
|
||||
// "/site/market": { "hidden": true },
|
||||
// "/admin/houses": { "order": 1, "group": "Moderation" } }
|
||||
//
|
||||
// Keyed by an item's existing `to`; every field is optional and an absent one
|
||||
// falls back to the code default (docs/website/THEMING_AND_NAV.md §6.4). The
|
||||
// merge itself happens on the client — client/src/lib/navOverrides.js — and the
|
||||
// role/feature filters in the layouts run *after* it, so this layer is
|
||||
// presentation and never authorization (§7).
|
||||
//
|
||||
// **What this module cannot check, deliberately: whether a `to` exists.** The
|
||||
// three base NAV arrays are client constants (SiteHeader.jsx, AdminLayout.jsx,
|
||||
// PlayerPortalLayout.jsx). Shipping a copy of them to the server would create a
|
||||
// second source of truth for navigation that drifts the first time a route is
|
||||
// added, and it would buy nothing: `applyNavOverrides` already drops an entry
|
||||
// whose `to` the base array does not declare, which is the right place for it —
|
||||
// deleting a route in code stops mattering immediately, with no migration and no
|
||||
// stale row doing something unexpected later. So the server validates *shape*
|
||||
// and the client owns *membership*.
|
||||
//
|
||||
// Same strict-on-write / forgiving-on-read asymmetry as the theme and the brand
|
||||
// assets (utils/themeResolve.js, utils/brandAssets.js): a bad write is rejected
|
||||
// with the offending key named, while a bad stored value is dropped entry by
|
||||
// entry so one hand-edited row does not cost the admin the rest of their nav.
|
||||
|
||||
// The four overridable fields. `group` is only meaningful on the grouped admin
|
||||
// nav, but accepting it everywhere costs nothing — the merge util drops a group
|
||||
// the base nav does not declare, and the flat navs declare none at all.
|
||||
const FIELDS = ['label', 'order', 'hidden', 'group']
|
||||
|
||||
// Bounds. None of these is a security control on its own — the row is written by
|
||||
// an admin and rendered as text by React — they keep a single settings row from
|
||||
// growing without limit, and they are what makes "the admin nav has 21 items"
|
||||
// the shape this store is sized for.
|
||||
const MAX_ENTRIES = 200
|
||||
const MAX_PATH = 128
|
||||
const MAX_LABEL = 64
|
||||
const MAX_GROUP = 64
|
||||
|
||||
// The one item an override may never hide: the nav editor itself. An admin who
|
||||
// hid it would lose the only screen that can un-hide it, and "type the URL from
|
||||
// memory" is not a recovery path. Enforced here as well as in the editor's UI so
|
||||
// a hand-written row cannot do it either.
|
||||
const UNHIDEABLE = { nav_admin: ['/admin/navigation'] }
|
||||
|
||||
/**
|
||||
* Is this a usable key — that is, something that could be a `to` in a nav array?
|
||||
* An app-internal path: absolute, same-origin, no scheme and no whitespace.
|
||||
* Whether it *is* one of the declared routes is the client's question (above).
|
||||
* @param {unknown} value
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isNavPath(value) {
|
||||
if (typeof value !== 'string' || value.length === 0 || value.length > MAX_PATH) return false
|
||||
if (!value.startsWith('/')) return false
|
||||
// `//host` is protocol-relative and would leave the origin despite looking
|
||||
// like a path; whitespace and quotes have no business in a route.
|
||||
if (value.startsWith('//') || /[\s<>"'\\]/.test(value)) return false
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a nav-override object for WRITING. Strict: names the offending key.
|
||||
* @param {unknown} value the parsed object, or null to clear every override
|
||||
* @param {string} [key] which nav row this is, for the messages
|
||||
* @returns {{ok: true} | {ok: false, message: string}}
|
||||
*/
|
||||
function validateNavOverrides(value, key = 'nav') {
|
||||
if (value === null || value === undefined) return { ok: true }
|
||||
if (typeof value !== 'object' || Array.isArray(value)) {
|
||||
return { ok: false, message: `${key} must be a JSON object` }
|
||||
}
|
||||
const entries = Object.entries(value)
|
||||
if (entries.length > MAX_ENTRIES) {
|
||||
return { ok: false, message: `${key} may hold at most ${MAX_ENTRIES} entries` }
|
||||
}
|
||||
for (const [to, entry] of entries) {
|
||||
if (!isNavPath(to)) {
|
||||
return { ok: false, message: `${key} key '${to}' must be an app path such as /site/news` }
|
||||
}
|
||||
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
|
||||
return { ok: false, message: `${key}['${to}'] must be an object` }
|
||||
}
|
||||
for (const [field, fieldValue] of Object.entries(entry)) {
|
||||
if (!FIELDS.includes(field)) {
|
||||
return { ok: false, message: `Unknown nav field '${field}' on ${key}['${to}']` }
|
||||
}
|
||||
if (field === 'label' && (typeof fieldValue !== 'string' || fieldValue.length > MAX_LABEL)) {
|
||||
return { ok: false, message: `${key}['${to}'].label must be text of at most ${MAX_LABEL} characters` }
|
||||
}
|
||||
if (field === 'group' && (typeof fieldValue !== 'string' || fieldValue.length > MAX_GROUP)) {
|
||||
return { ok: false, message: `${key}['${to}'].group must be text of at most ${MAX_GROUP} characters` }
|
||||
}
|
||||
if (field === 'order' && (typeof fieldValue !== 'number' || !Number.isFinite(fieldValue))) {
|
||||
return { ok: false, message: `${key}['${to}'].order must be a number` }
|
||||
}
|
||||
// `hidden: false` is not an error — it is simply the default, and the
|
||||
// editor sends it while a row is being edited. It is dropped below, never
|
||||
// stored, because hiding is subtractive only (§7): a stored `false` could
|
||||
// read as "force visible" to a later reader, and nothing may un-hide.
|
||||
if (field === 'hidden' && typeof fieldValue !== 'boolean') {
|
||||
return { ok: false, message: `${key}['${to}'].hidden must be true or false` }
|
||||
}
|
||||
}
|
||||
}
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep only the entries and fields that would actually do something. Serves both
|
||||
* directions, like resolveBrandAssets:
|
||||
*
|
||||
* • writing — an admin who cleared every override stores nothing, and the
|
||||
* caller deletes the row instead, so "a row exists" keeps meaning "this nav
|
||||
* was customised" (§4.1);
|
||||
* • reading — a hand-edited entry is dropped and its neighbours kept.
|
||||
*
|
||||
* @param {object|null} value an object, or a parseJsonSetting result
|
||||
* @param {string} [key] the settings key, so the un-hideable rule can apply
|
||||
* @returns {object} a new object, `{}` when nothing survives
|
||||
*/
|
||||
function resolveNavOverrides(value, key = 'nav') {
|
||||
const out = {}
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return out
|
||||
const unhideable = UNHIDEABLE[key] || []
|
||||
for (const [to, entry] of Object.entries(value)) {
|
||||
if (!isNavPath(to) || !entry || typeof entry !== 'object' || Array.isArray(entry)) continue
|
||||
const clean = {}
|
||||
// A label that is only whitespace is not a label — it would render an
|
||||
// unclickable-looking gap — so it falls back to the coded one.
|
||||
if (typeof entry.label === 'string' && entry.label.trim() && entry.label.length <= MAX_LABEL) {
|
||||
clean.label = entry.label.trim()
|
||||
}
|
||||
if (typeof entry.order === 'number' && Number.isFinite(entry.order)) clean.order = entry.order
|
||||
// Only the literal `true` is stored: `hidden: false` is the default and
|
||||
// carrying it would suggest an override that can un-hide something.
|
||||
if (entry.hidden === true && !unhideable.includes(to)) clean.hidden = true
|
||||
if (typeof entry.group === 'string' && entry.group.trim() && entry.group.length <= MAX_GROUP) {
|
||||
clean.group = entry.group.trim()
|
||||
}
|
||||
if (Object.keys(clean).length > 0) out[to] = clean
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
module.exports = { validateNavOverrides, resolveNavOverrides, NAV_KEYS: ['nav_public', 'nav_admin', 'nav_player'] }
|
||||
@@ -3463,7 +3463,7 @@
|
||||
"Admin · Settings"
|
||||
],
|
||||
"summary": "Update site settings (admin only)",
|
||||
"description": "",
|
||||
"description": "Writes the given keys. The JSON-valued theming keys (theme_visual, brand_assets, nav_public, nav_admin, nav_player) accept an object or its stringified form, are validated strictly with the offending field named in the 400, and are stored stringified with unusable fields dropped. Nav overrides carry only label/order/hidden/group; whether a key names a route the nav declares is settled client-side at merge time.",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Updated settings",
|
||||
|
||||
171
server/test/navOverrides.test.js
Normal file
171
server/test/navOverrides.test.js
Normal file
@@ -0,0 +1,171 @@
|
||||
// Point the DB at a closed port before anything builds the pool — this file only
|
||||
// exercises pure functions, but requiring the util pulls in nothing else.
|
||||
process.env.DB_HOST = '127.0.0.1'
|
||||
process.env.DB_PORT = '59999'
|
||||
|
||||
const { test } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
// Phases 6-8 of docs/website/THEMING_AND_NAV.md: the server half of the nav
|
||||
// overrides. The merge itself is the client's (client/src/lib/navOverrides.js,
|
||||
// tested there); this module decides only what may be *stored*, and the two
|
||||
// properties worth locking are the asymmetry — strict on write, forgiving on
|
||||
// read — and the two things a stored row may never carry: a field that is not
|
||||
// one of the four, and `hidden` on the nav editor's own row.
|
||||
const { validateNavOverrides, resolveNavOverrides, NAV_KEYS } = require('../src/utils/navOverrides')
|
||||
|
||||
// ── validateNavOverrides — strict, and names what it rejected ──────────
|
||||
|
||||
test('null and undefined are valid — that is how every override is cleared', () => {
|
||||
assert.equal(validateNavOverrides(null).ok, true)
|
||||
assert.equal(validateNavOverrides(undefined).ok, true)
|
||||
})
|
||||
|
||||
test('an empty object is valid — the caller deletes the row instead of storing it', () => {
|
||||
assert.equal(validateNavOverrides({}).ok, true)
|
||||
})
|
||||
|
||||
test('a non-object is rejected under the key it was written to', () => {
|
||||
for (const bad of [4, 'x', true, [], [{ to: '/' }]]) {
|
||||
const check = validateNavOverrides(bad, 'nav_public')
|
||||
assert.equal(check.ok, false, `${JSON.stringify(bad)} should be rejected`)
|
||||
assert.match(check.message, /nav_public must be a JSON object/)
|
||||
}
|
||||
})
|
||||
|
||||
test('a key that is not an app path is rejected and named', () => {
|
||||
for (const bad of [
|
||||
'site/news', // relative
|
||||
'//evil.example/x', // protocol-relative: looks like a path, leaves the origin
|
||||
'https://evil.example', // scheme
|
||||
'/site/ news', // whitespace
|
||||
'/site/"news"', // quotes
|
||||
'',
|
||||
]) {
|
||||
const check = validateNavOverrides({ [bad]: { order: 1 } }, 'nav_public')
|
||||
assert.equal(check.ok, false, `'${bad}' should be rejected as a key`)
|
||||
assert.match(check.message, /must be an app path/)
|
||||
}
|
||||
})
|
||||
|
||||
test('a valid app path is accepted as a key even when no nav declares it', () => {
|
||||
// Membership is the client's question: applyNavOverrides drops an unknown `to`
|
||||
// at merge time, so a route deleted in code needs no migration here.
|
||||
assert.equal(validateNavOverrides({ '/site/gone': { order: 3 } }).ok, true)
|
||||
})
|
||||
|
||||
test('an entry that is not an object is rejected', () => {
|
||||
for (const bad of ['x', 4, null, []]) {
|
||||
const check = validateNavOverrides({ '/site/news': bad }, 'nav_public')
|
||||
assert.equal(check.ok, false, `${JSON.stringify(bad)} should be rejected as an entry`)
|
||||
assert.match(check.message, /must be an object/)
|
||||
}
|
||||
})
|
||||
|
||||
test('an unknown field is rejected rather than silently ignored', () => {
|
||||
// Storing a value that will never apply is a bad admin experience, and a
|
||||
// field nobody validates is where a future `to`/`roles` would try to sneak in.
|
||||
for (const field of ['to', 'roles', 'feature', 'icon', 'end', 'href']) {
|
||||
const check = validateNavOverrides({ '/site/news': { [field]: 'x' } }, 'nav_public')
|
||||
assert.equal(check.ok, false, `'${field}' should be rejected`)
|
||||
assert.match(check.message, new RegExp(`Unknown nav field '${field}'`))
|
||||
}
|
||||
})
|
||||
|
||||
test('each of the four fields is type-checked and named on failure', () => {
|
||||
const cases = [
|
||||
[{ label: 4 }, /label must be text/],
|
||||
[{ label: 'x'.repeat(65) }, /label must be text/],
|
||||
[{ group: 4 }, /group must be text/],
|
||||
[{ group: 'x'.repeat(65) }, /group must be text/],
|
||||
[{ order: '1' }, /order must be a number/],
|
||||
[{ order: Number.NaN }, /order must be a number/],
|
||||
[{ order: Number.POSITIVE_INFINITY }, /order must be a number/],
|
||||
[{ hidden: 'true' }, /hidden must be true or false/],
|
||||
[{ hidden: 1 }, /hidden must be true or false/],
|
||||
]
|
||||
for (const [entry, pattern] of cases) {
|
||||
const check = validateNavOverrides({ '/site/news': entry }, 'nav_public')
|
||||
assert.equal(check.ok, false, `${JSON.stringify(entry)} should be rejected`)
|
||||
assert.match(check.message, pattern)
|
||||
assert.match(check.message, /\/site\/news/)
|
||||
}
|
||||
})
|
||||
|
||||
test('all four fields together are accepted', () => {
|
||||
const check = validateNavOverrides({
|
||||
'/admin/houses': { label: 'Houses', order: 2, hidden: true, group: 'Moderation' },
|
||||
})
|
||||
assert.equal(check.ok, true)
|
||||
})
|
||||
|
||||
test('an absurd number of entries is refused', () => {
|
||||
const many = {}
|
||||
for (let i = 0; i < 201; i += 1) many[`/site/p${i}`] = { order: i }
|
||||
const check = validateNavOverrides(many, 'nav_public')
|
||||
assert.equal(check.ok, false)
|
||||
assert.match(check.message, /at most 200 entries/)
|
||||
})
|
||||
|
||||
// ── resolveNavOverrides — forgiving, and drops what would do nothing ───
|
||||
|
||||
test('unusable entries are dropped and their neighbours kept', () => {
|
||||
const out = resolveNavOverrides({
|
||||
'/site/news': { label: 'Announcements' },
|
||||
'not-a-path': { label: 'Ignored' },
|
||||
'/site/wiki': 'garbage',
|
||||
'/site/market': { hidden: true },
|
||||
})
|
||||
assert.deepEqual(out, {
|
||||
'/site/news': { label: 'Announcements' },
|
||||
'/site/market': { hidden: true },
|
||||
})
|
||||
})
|
||||
|
||||
test('a label is trimmed, and a whitespace-only label falls back to the coded one', () => {
|
||||
assert.deepEqual(resolveNavOverrides({ '/x': { label: ' News ' } }), { '/x': { label: 'News' } })
|
||||
assert.deepEqual(resolveNavOverrides({ '/x': { label: ' ' } }), {})
|
||||
})
|
||||
|
||||
test('hidden: false is never stored — hiding is subtractive only', () => {
|
||||
// A stored `false` could read to a later consumer as "force visible", and
|
||||
// nothing in this layer may un-hide a role- or feature-gated item (§7).
|
||||
assert.deepEqual(resolveNavOverrides({ '/x': { hidden: false } }), {})
|
||||
assert.deepEqual(resolveNavOverrides({ '/x': { hidden: false, order: 2 } }), { '/x': { order: 2 } })
|
||||
})
|
||||
|
||||
test('the nav editor cannot be hidden, even by a hand-written row', () => {
|
||||
// Hiding /admin/navigation would remove the only screen that can un-hide it.
|
||||
const out = resolveNavOverrides({ '/admin/navigation': { hidden: true, order: 9 } }, 'nav_admin')
|
||||
assert.deepEqual(out, { '/admin/navigation': { order: 9 } })
|
||||
// An entry that carried nothing else disappears entirely rather than storing
|
||||
// an empty object.
|
||||
assert.deepEqual(resolveNavOverrides({ '/admin/navigation': { hidden: true } }, 'nav_admin'), {})
|
||||
})
|
||||
|
||||
test('the un-hideable rule is scoped to the admin nav', () => {
|
||||
// The same path in another row is meaningless, but it is also not special:
|
||||
// the rule protects the admin sidebar, which is the nav that renders it.
|
||||
assert.deepEqual(resolveNavOverrides({ '/admin/navigation': { hidden: true } }, 'nav_public'), {
|
||||
'/admin/navigation': { hidden: true },
|
||||
})
|
||||
})
|
||||
|
||||
test('an entry left with no usable field is dropped, so `{}` is never stored', () => {
|
||||
assert.deepEqual(resolveNavOverrides({ '/x': {}, '/y': { label: 4 } }), {})
|
||||
})
|
||||
|
||||
test('order survives as a number, including zero and negatives', () => {
|
||||
const out = resolveNavOverrides({ '/a': { order: 0 }, '/b': { order: -3 }, '/c': { order: 1.5 } })
|
||||
assert.deepEqual(out, { '/a': { order: 0 }, '/b': { order: -3 }, '/c': { order: 1.5 } })
|
||||
})
|
||||
|
||||
test('a non-object resolves to {} rather than throwing', () => {
|
||||
for (const bad of [null, undefined, 'x', 4, []]) {
|
||||
assert.deepEqual(resolveNavOverrides(bad), {})
|
||||
}
|
||||
})
|
||||
|
||||
test('NAV_KEYS names the three rows the controller validates', () => {
|
||||
assert.deepEqual(NAV_KEYS, ['nav_public', 'nav_admin', 'nav_player'])
|
||||
})
|
||||
@@ -272,6 +272,100 @@ test('an invalid theme_visual is rejected and nothing is written', async () => {
|
||||
}
|
||||
})
|
||||
|
||||
// ── PUT /admin/settings — the nav rows (phases 6-8) ───────────────────────
|
||||
//
|
||||
// The nav keys reach the same validate-then-stringify block. Without it they
|
||||
// would fall through to settingsDb.set as objects and be stored as the string
|
||||
// "[object Object]" — a row that parses as absent forever, silently.
|
||||
|
||||
test('a valid nav override is stored stringified', async () => {
|
||||
signInAs({ id: 1, username: 'a', role: 'admin', status: 'active' })
|
||||
const written = {}
|
||||
settingsDb.set = async (key, value) => {
|
||||
written[key] = value
|
||||
}
|
||||
settingsDb.getAll = async () => []
|
||||
const app = await startApp((a) => a.use('/api/v1/admin/settings', requireAuth, settingsRouter))
|
||||
try {
|
||||
const nav = { '/site/news': { label: 'Announcements', order: 1 }, '/site/market': { hidden: true } }
|
||||
const res = await fetch(`${app.url}/api/v1/admin/settings`, {
|
||||
method: 'PUT',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ nav_public: nav }),
|
||||
})
|
||||
assert.equal(res.status, 200)
|
||||
assert.equal(typeof written.nav_public, 'string')
|
||||
assert.notEqual(written.nav_public, '[object Object]')
|
||||
assert.deepEqual(JSON.parse(written.nav_public), nav)
|
||||
} finally {
|
||||
settingsDb.set = originals.set
|
||||
await app.close()
|
||||
}
|
||||
})
|
||||
|
||||
test('an invalid nav override is rejected, named, and nothing is written', async () => {
|
||||
signInAs({ id: 1, username: 'a', role: 'admin', status: 'active' })
|
||||
settingsDb.set = () => assert.fail('an invalid nav override must not be stored')
|
||||
const app = await startApp((a) => a.use('/api/v1/admin/settings', requireAuth, settingsRouter))
|
||||
try {
|
||||
const bad = [
|
||||
{ '//evil.example/x': { order: 1 } }, // protocol-relative key
|
||||
{ '/site/news': { roles: ['admin'] } }, // a gate is not overridable
|
||||
{ '/site/news': { to: '/elsewhere' } }, // an override cannot introduce a route
|
||||
{ '/site/news': { order: 'first' } },
|
||||
{ '/site/news': 'hidden' },
|
||||
'not json',
|
||||
]
|
||||
for (const nav_public of bad) {
|
||||
const res = await fetch(`${app.url}/api/v1/admin/settings`, {
|
||||
method: 'PUT',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ nav_public }),
|
||||
})
|
||||
assert.equal(res.status, 400, JSON.stringify(nav_public))
|
||||
const body = await res.json()
|
||||
assert.match(body.message, /nav_public|nav field/)
|
||||
}
|
||||
} finally {
|
||||
settingsDb.set = originals.set
|
||||
await app.close()
|
||||
}
|
||||
})
|
||||
|
||||
test('the write path drops hidden on the nav editor and never stores hidden: false', async () => {
|
||||
signInAs({ id: 1, username: 'a', role: 'admin', status: 'active' })
|
||||
const written = {}
|
||||
settingsDb.set = async (key, value) => {
|
||||
written[key] = value
|
||||
}
|
||||
settingsDb.getAll = async () => []
|
||||
const app = await startApp((a) => a.use('/api/v1/admin/settings', requireAuth, settingsRouter))
|
||||
try {
|
||||
const res = await fetch(`${app.url}/api/v1/admin/settings`, {
|
||||
method: 'PUT',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
nav_admin: {
|
||||
'/admin/navigation': { hidden: true, order: 3 },
|
||||
'/admin/posts': { hidden: false, label: 'Blog' },
|
||||
'/admin/wiki': { hidden: true },
|
||||
},
|
||||
}),
|
||||
})
|
||||
assert.equal(res.status, 200)
|
||||
// The editor keeps its order but not its hiding; a `hidden: false` is the
|
||||
// default, so it is dropped rather than stored as an un-hide instruction.
|
||||
assert.deepEqual(JSON.parse(written.nav_admin), {
|
||||
'/admin/navigation': { order: 3 },
|
||||
'/admin/posts': { label: 'Blog' },
|
||||
'/admin/wiki': { hidden: true },
|
||||
})
|
||||
} finally {
|
||||
settingsDb.set = originals.set
|
||||
await app.close()
|
||||
}
|
||||
})
|
||||
|
||||
// ── GET /settings/theme-options — the catalog the admin form is built from ──
|
||||
|
||||
test('GET /settings/theme/options serves the catalog to an authenticated caller', async () => {
|
||||
|
||||
Reference in New Issue
Block a user