Merge pull request 'feat(theming): settings-store, nav merge util and radius tokens (phases 0-2)' (#121) from feat/theming-nav-phase-0-2 into edge
Reviewed-on: #121
This commit is contained in:
138
client/src/lib/navOverrides.js
Normal file
138
client/src/lib/navOverrides.js
Normal file
@@ -0,0 +1,138 @@
|
||||
// Apply an admin's stored navigation overrides to a hardcoded NAV array.
|
||||
//
|
||||
// The three navs (public header, admin sidebar, player portal) stay declared in
|
||||
// code; this layer only reorders, relabels and hides what is already there.
|
||||
// See docs/website/THEMING_AND_NAV.md §7.
|
||||
//
|
||||
// **This is presentation, never authorization.** The override can carry
|
||||
// `label`, `order`, `hidden` and — admin nav only — `group`, and nothing else.
|
||||
// It cannot introduce a `to`, and it cannot touch `roles`, `feature`, `icon` or
|
||||
// `end`, so the existing role/feature filters in SiteHeader and AdminLayout run
|
||||
// *after* this merge, unchanged, and remain the actual boundary. An override
|
||||
// saying `hidden: false` on a role-gated item still shows nothing to a viewer
|
||||
// whose role check fails: hiding is subtractive here, never additive.
|
||||
//
|
||||
// Fail-safe throughout: anything unrecognized — an unknown `to`, a non-string
|
||||
// label, a group that does not exist — is ignored rather than rejected, so a
|
||||
// stale or hand-edited settings row degrades to the code default instead of
|
||||
// rendering a broken nav.
|
||||
|
||||
// Two shapes are supported, because two exist:
|
||||
// flat [{ to, label, ... }] — public header, player portal
|
||||
// grouped [{ title?, items: [{ to, label, ... }] }] — admin sidebar
|
||||
function isGrouped(nav) {
|
||||
return nav.length > 0 && nav.every((g) => g && Array.isArray(g.items))
|
||||
}
|
||||
|
||||
// A stored override entry is usable only field by field: a bad `label` must not
|
||||
// discard a good `order` alongside it.
|
||||
function cleanEntry(raw, groupTitles) {
|
||||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null
|
||||
const out = {}
|
||||
if (typeof raw.label === 'string' && raw.label.trim()) out.label = raw.label.trim()
|
||||
if (typeof raw.order === 'number' && Number.isFinite(raw.order)) out.order = raw.order
|
||||
if (raw.hidden === true) out.hidden = true
|
||||
// `group` may only name a section the base nav already declares. Anything else
|
||||
// — a renamed group, a typo, an invented category — is dropped, so an item can
|
||||
// never land in a header that does not exist.
|
||||
if (typeof raw.group === 'string' && groupTitles.has(raw.group)) out.group = raw.group
|
||||
return out
|
||||
}
|
||||
|
||||
// Sort by effective order, where an item the admin never reordered keeps its
|
||||
// index in the base array as its key. Two tie-breaks, in order: an explicit
|
||||
// order beats a coincidental index (the admin said "first", so first), and two
|
||||
// explicit orders stay in code order (the sort is stable).
|
||||
//
|
||||
// In practice the editor writes an order for every item in a list, the way
|
||||
// drag-and-drop reordering does, so ties are the stale-row case rather than the
|
||||
// normal one. They still have to resolve predictably.
|
||||
function byOrder(items) {
|
||||
return items
|
||||
.map((item, index) => ({ item, key: item.__order ?? index, explicit: item.__order !== undefined }))
|
||||
.sort((a, b) => a.key - b.key || Number(b.explicit) - Number(a.explicit))
|
||||
.map(({ item }) => {
|
||||
const { __order, ...rest } = item
|
||||
return rest
|
||||
})
|
||||
}
|
||||
|
||||
// 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) {
|
||||
const out = []
|
||||
for (const item of items) {
|
||||
const o = entries.get(item.to)
|
||||
if (o?.hidden) 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 })
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Array} baseNav the hardcoded nav — the source of truth for `to`,
|
||||
* `roles`, `feature`, `icon` and `end`
|
||||
* @param {object|null} overrides the parsed settings JSON, keyed by `to`, or
|
||||
* null when the admin never touched this nav
|
||||
* @returns {Array} a new array of the same shape, or `baseNav` itself when there
|
||||
* is nothing to apply
|
||||
*/
|
||||
export function applyNavOverrides(baseNav, overrides) {
|
||||
if (!Array.isArray(baseNav)) return []
|
||||
// The untouched path, and the one that matters most: no row, a malformed row,
|
||||
// or a row with nothing usable in it all render the nav exactly as coded.
|
||||
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)
|
||||
}
|
||||
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 }
|
||||
})
|
||||
|
||||
return kept
|
||||
.map((g) => ({
|
||||
...g,
|
||||
items: byOrder(mergeItems([...g.items, ...(moved.get(g.title) || [])], entries)),
|
||||
}))
|
||||
// A group whose every item was hidden must not leave an orphaned header.
|
||||
// AdminLayout drops empty groups again after its own role filter; doing it
|
||||
// here too keeps the util correct on its own.
|
||||
.filter((g) => g.items.length > 0)
|
||||
}
|
||||
|
||||
export default applyNavOverrides
|
||||
@@ -24,6 +24,22 @@
|
||||
|
||||
--shadow-card: 0 14px 34px rgba(0, 0, 0, 0.3);
|
||||
--panel-grad: linear-gradient(180deg, var(--panel-a), var(--panel-b));
|
||||
|
||||
/* Corner radius, by the kind of surface rather than by the pixel value, so a
|
||||
theme preset can restyle all of them at once (see
|
||||
docs/website/THEMING_AND_NAV.md §4.7). Seeded at the values already in use
|
||||
— this promotion is a no-op, and every existing instance must keep looking
|
||||
exactly as it does today.
|
||||
|
||||
Deliberately four tokens, not three: .card/.panel are 10px and .panel-flat
|
||||
is 12px, so collapsing them would have restyled every card on every
|
||||
install. The 7px (.rte-btn) and 6px (.rte-linkmenu-item) values stay
|
||||
literals — interior editor chrome, not brand surface — as do the 50%
|
||||
circles, which are shapes rather than radii. */
|
||||
--radius-pill: 999px;
|
||||
--radius-panel: 12px;
|
||||
--radius-card: 10px;
|
||||
--radius-input: 8px;
|
||||
}
|
||||
|
||||
* {
|
||||
@@ -99,7 +115,7 @@ a {
|
||||
flex-direction: column;
|
||||
padding: 24px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
border-radius: var(--radius-card);
|
||||
text-decoration: none;
|
||||
color: var(--ink);
|
||||
background: var(--panel-grad);
|
||||
@@ -123,19 +139,19 @@ a.card:focus-visible {
|
||||
}
|
||||
.panel {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
border-radius: var(--radius-card);
|
||||
background: var(--panel-grad);
|
||||
}
|
||||
.panel-flat {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
border-radius: var(--radius-panel);
|
||||
overflow: hidden;
|
||||
background: var(--panel-flat);
|
||||
}
|
||||
.note {
|
||||
border: 1px solid var(--line);
|
||||
border-left: 3px solid var(--accent);
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius-input);
|
||||
background: rgba(19, 36, 60, 0.4);
|
||||
padding: 18px 22px;
|
||||
color: var(--muted);
|
||||
@@ -168,7 +184,7 @@ a.card:focus-visible {
|
||||
/* ===== Pills / buttons ===== */
|
||||
.pill {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
border-radius: var(--radius-pill);
|
||||
padding: 7px 14px;
|
||||
color: var(--muted);
|
||||
background: rgba(11, 22, 48, 0.5);
|
||||
@@ -186,7 +202,7 @@ a.card:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
.btn {
|
||||
border-radius: 999px;
|
||||
border-radius: var(--radius-pill);
|
||||
padding: 12px 26px;
|
||||
font-family: var(--sans);
|
||||
font-size: 0.92rem;
|
||||
@@ -214,7 +230,7 @@ a.card:focus-visible {
|
||||
background: var(--blue);
|
||||
}
|
||||
.btn-sq {
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius-input);
|
||||
padding: 10px 18px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
@@ -230,7 +246,7 @@ button[disabled] {
|
||||
.select {
|
||||
width: 100%;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius-input);
|
||||
padding: 11px 14px;
|
||||
background: var(--bg);
|
||||
color: var(--ink);
|
||||
@@ -312,7 +328,7 @@ button[disabled] {
|
||||
}
|
||||
.prose img {
|
||||
max-width: 100%;
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius-input);
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
|
||||
@@ -320,7 +336,7 @@ button[disabled] {
|
||||
.rte {
|
||||
position: relative;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius-input);
|
||||
background: var(--bg);
|
||||
}
|
||||
.rte:focus-within {
|
||||
@@ -407,7 +423,7 @@ button[disabled] {
|
||||
width: min(360px, calc(100% - 20px));
|
||||
padding: 10px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius-input);
|
||||
background: var(--panel-a);
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
@@ -449,7 +465,7 @@ button[disabled] {
|
||||
display: inline-block;
|
||||
padding: 3px 10px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
border-radius: var(--radius-pill);
|
||||
background: rgba(127, 153, 189, 0.1);
|
||||
color: var(--accent);
|
||||
font-family: var(--sans);
|
||||
@@ -494,7 +510,7 @@ button[disabled] {
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius-input);
|
||||
background: var(--panel-flat);
|
||||
color: var(--text);
|
||||
text-align: left;
|
||||
@@ -532,7 +548,7 @@ button[disabled] {
|
||||
overflow-y: auto;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius-input);
|
||||
background: var(--bg);
|
||||
}
|
||||
.diff-add {
|
||||
@@ -603,7 +619,7 @@ button[disabled] {
|
||||
vertical-align: middle;
|
||||
}
|
||||
.badge {
|
||||
border-radius: 999px;
|
||||
border-radius: var(--radius-pill);
|
||||
padding: 3px 11px;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
@@ -780,7 +796,7 @@ button[disabled] {
|
||||
}
|
||||
.page-image img {
|
||||
max-width: 100%;
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius-input);
|
||||
border: 1px solid var(--line);
|
||||
display: block;
|
||||
}
|
||||
@@ -863,7 +879,7 @@ button[disabled] {
|
||||
}
|
||||
.pb-column-editor {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius-input);
|
||||
padding: 12px;
|
||||
background: var(--panel-flat, transparent);
|
||||
}
|
||||
@@ -881,7 +897,7 @@ button[disabled] {
|
||||
}
|
||||
.pb-subblock {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius-input);
|
||||
padding: 10px;
|
||||
margin-top: 10px;
|
||||
background: var(--bg);
|
||||
@@ -919,7 +935,7 @@ button[disabled] {
|
||||
border: 1px solid #6e3b38;
|
||||
background: rgba(110, 59, 56, 0.16);
|
||||
color: #e6a9a3;
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius-input);
|
||||
padding: 10px 14px;
|
||||
margin-top: 14px;
|
||||
font-size: 0.86rem;
|
||||
@@ -928,7 +944,7 @@ button[disabled] {
|
||||
border: 1px solid var(--accent);
|
||||
background: var(--blue);
|
||||
color: var(--accent-bright);
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius-input);
|
||||
padding: 8px 14px;
|
||||
margin-top: 14px;
|
||||
font-size: 0.86rem;
|
||||
@@ -960,7 +976,7 @@ button[disabled] {
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
border: 1px dashed var(--line);
|
||||
border-radius: 10px;
|
||||
border-radius: var(--radius-card);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.pb-canvas {
|
||||
@@ -970,7 +986,7 @@ button[disabled] {
|
||||
}
|
||||
.pb-block-card {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
border-radius: var(--radius-card);
|
||||
background: var(--panel-flat, transparent);
|
||||
}
|
||||
.pb-block-card.is-dragging {
|
||||
@@ -1082,7 +1098,7 @@ button[disabled] {
|
||||
border: 1px solid var(--accent);
|
||||
background: var(--blue);
|
||||
color: var(--accent-bright);
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius-input);
|
||||
padding: 8px 14px;
|
||||
margin-bottom: 20px;
|
||||
font-size: 0.85rem;
|
||||
|
||||
203
client/test/navOverrides.test.js
Normal file
203
client/test/navOverrides.test.js
Normal file
@@ -0,0 +1,203 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import { applyNavOverrides } 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
|
||||
// React. Two properties matter above all others:
|
||||
//
|
||||
// 1. No override, or a useless one, renders the coded nav untouched.
|
||||
// 2. The override cannot add a route, cannot touch a role/feature gate, and
|
||||
// cannot un-hide anything. It is presentation only.
|
||||
|
||||
const FLAT = [
|
||||
{ label: 'Home', to: '/', end: true },
|
||||
{ label: 'News', to: '/site/news' },
|
||||
{ label: 'Wiki', to: '/wiki' },
|
||||
{ label: 'Shard', to: '/site/shard', feature: 'status' },
|
||||
]
|
||||
|
||||
const GROUPED = [
|
||||
{ items: [{ to: '/admin', label: 'Dashboard', end: true, roles: ['admin', 'editor', 'moderator'] }] },
|
||||
{
|
||||
title: 'Content',
|
||||
items: [
|
||||
{ to: '/admin/posts', label: 'Posts', roles: ['admin', 'editor'] },
|
||||
{ to: '/admin/wiki', label: 'Wiki', roles: ['admin', 'editor'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'System',
|
||||
items: [
|
||||
{ to: '/admin/settings', label: 'Settings', roles: ['admin'] },
|
||||
{ to: '/admin/users', label: 'Users', roles: ['admin'] },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const labels = (nav) => nav.map((i) => i.label)
|
||||
const groupLabels = (nav) => nav.map((g) => [g.title ?? null, g.items.map((i) => i.label)])
|
||||
|
||||
// ── The untouched path ────────────────────────────────────────────────────
|
||||
|
||||
// Most instances will never set these keys. Absence must be a true no-op, and
|
||||
// cheap: the same array reference back means no needless re-render either.
|
||||
test('no override returns the base nav unchanged', () => {
|
||||
for (const overrides of [null, undefined, '', 0, [], 'not an object']) {
|
||||
assert.equal(applyNavOverrides(FLAT, overrides), FLAT)
|
||||
}
|
||||
})
|
||||
|
||||
test('an override with nothing usable in it returns the base nav unchanged', () => {
|
||||
assert.equal(applyNavOverrides(FLAT, {}), FLAT)
|
||||
// Every field here is unusable: unknown route, blank label, non-numeric order,
|
||||
// hidden as a string rather than the boolean true.
|
||||
assert.equal(
|
||||
applyNavOverrides(FLAT, {
|
||||
'/does/not/exist': { label: 'Ghost', hidden: true },
|
||||
'/wiki': { label: ' ', order: 'first', hidden: 'yes' },
|
||||
}),
|
||||
FLAT,
|
||||
)
|
||||
})
|
||||
|
||||
// ── The security boundary ─────────────────────────────────────────────────
|
||||
|
||||
// The single most important negative case: the override layer must never be a
|
||||
// way to introduce a route into a nav.
|
||||
test('an unknown `to` is ignored, never added', () => {
|
||||
const out = applyNavOverrides(FLAT, { '/admin/secret': { label: 'Secret', order: 0 } })
|
||||
assert.equal(out.length, FLAT.length)
|
||||
assert.ok(!out.some((i) => i.to === '/admin/secret'))
|
||||
})
|
||||
|
||||
test('roles, feature, icon, end and to survive the merge verbatim', () => {
|
||||
const out = applyNavOverrides(FLAT, {
|
||||
'/site/shard': { label: 'Server Status', roles: ['player'], feature: null, to: '/evil' },
|
||||
})
|
||||
const shard = out.find((i) => i.to === '/site/shard')
|
||||
assert.equal(shard.label, 'Server Status') // the one thing an override may set
|
||||
assert.equal(shard.feature, 'status') // gate untouched
|
||||
assert.equal(shard.roles, undefined) // and not invented
|
||||
assert.ok(!out.some((i) => i.to === '/evil'))
|
||||
})
|
||||
|
||||
test('hidden:false cannot un-hide anything — hiding is subtractive only', () => {
|
||||
// The item is still present after the merge; whether it renders is decided by
|
||||
// the caller's own role/feature filter, which this layer cannot reach.
|
||||
const out = applyNavOverrides(GROUPED, { '/admin/settings': { hidden: false } })
|
||||
assert.equal(out, GROUPED, 'a no-op override leaves the base nav alone')
|
||||
})
|
||||
|
||||
// ── Flat navs: label, order, hidden ───────────────────────────────────────
|
||||
|
||||
test('label overrides only the labelled item', () => {
|
||||
const out = applyNavOverrides(FLAT, { '/site/news': { label: 'Announcements' } })
|
||||
assert.deepEqual(labels(out), ['Home', 'Announcements', 'Wiki', 'Shard'])
|
||||
})
|
||||
|
||||
test('hidden drops the item', () => {
|
||||
const out = applyNavOverrides(FLAT, { '/wiki': { hidden: true } })
|
||||
assert.deepEqual(labels(out), ['Home', 'News', 'Shard'])
|
||||
})
|
||||
|
||||
// An item the admin never reordered keeps its position in the coded array, so
|
||||
// setting one order does not scramble the rest.
|
||||
test('order moves one item and leaves the others in code order', () => {
|
||||
const out = applyNavOverrides(FLAT, { '/wiki': { order: -1 } })
|
||||
assert.deepEqual(labels(out), ['Wiki', 'Home', 'News', 'Shard'])
|
||||
})
|
||||
|
||||
test('two items given the same order keep their code order (stable sort)', () => {
|
||||
const out = applyNavOverrides(FLAT, { '/site/news': { order: 0 }, '/wiki': { order: 0 } })
|
||||
// News before Wiki — the tie resolves to the coded order, not to insertion
|
||||
// order in the settings JSON. Both precede Home, whose 0 is only its index.
|
||||
assert.deepEqual(labels(out), ['News', 'Wiki', 'Home', 'Shard'])
|
||||
})
|
||||
|
||||
// An explicit order and an untouched item's index share one number line, so
|
||||
// they can collide. "Put this first" has to actually mean first.
|
||||
test('an explicit order beats an untouched item that merely sits at that index', () => {
|
||||
const out = applyNavOverrides(FLAT, { '/wiki': { order: 0 } })
|
||||
assert.deepEqual(labels(out), ['Wiki', 'Home', 'News', 'Shard'])
|
||||
})
|
||||
|
||||
test('the merge does not mutate the base nav', () => {
|
||||
const before = JSON.stringify(FLAT)
|
||||
applyNavOverrides(FLAT, { '/wiki': { label: 'Library', order: 0, hidden: false } })
|
||||
assert.equal(JSON.stringify(FLAT), before)
|
||||
})
|
||||
|
||||
test('no internal sort key leaks into the returned items', () => {
|
||||
const out = applyNavOverrides(FLAT, { '/wiki': { order: 1 } })
|
||||
for (const item of out) assert.ok(!('__order' in item), 'sort key must not be rendered')
|
||||
})
|
||||
|
||||
// ── Grouped (admin) navs ──────────────────────────────────────────────────
|
||||
|
||||
test('label and order apply within a group', () => {
|
||||
const out = applyNavOverrides(GROUPED, {
|
||||
'/admin/wiki': { label: 'Knowledge Base', order: 0 },
|
||||
})
|
||||
assert.deepEqual(groupLabels(out), [
|
||||
[null, ['Dashboard']],
|
||||
['Content', ['Knowledge Base', 'Posts']],
|
||||
['System', ['Settings', 'Users']],
|
||||
])
|
||||
})
|
||||
|
||||
test('group moves an item into another existing section', () => {
|
||||
const out = applyNavOverrides(GROUPED, { '/admin/users': { group: 'Content' } })
|
||||
assert.deepEqual(groupLabels(out), [
|
||||
[null, ['Dashboard']],
|
||||
['Content', ['Posts', 'Wiki', 'Users']],
|
||||
['System', ['Settings']],
|
||||
])
|
||||
})
|
||||
|
||||
// A group that does not exist must not conjure a header. Groups are chosen from
|
||||
// a dropdown of existing titles in the editor; this is the stale-row guard.
|
||||
test('a group that is not an existing title is ignored', () => {
|
||||
const out = applyNavOverrides(GROUPED, { '/admin/users': { group: 'Danger Zone' } })
|
||||
assert.deepEqual(groupLabels(out), [
|
||||
[null, ['Dashboard']],
|
||||
['Content', ['Posts', 'Wiki']],
|
||||
['System', ['Settings', 'Users']],
|
||||
])
|
||||
})
|
||||
|
||||
test('a moved item can be ordered in its new group', () => {
|
||||
const out = applyNavOverrides(GROUPED, { '/admin/users': { group: 'Content', order: -1 } })
|
||||
assert.deepEqual(groupLabels(out)[1], ['Content', ['Users', 'Posts', 'Wiki']])
|
||||
})
|
||||
|
||||
test('hiding every item in a group leaves no orphaned header', () => {
|
||||
const out = applyNavOverrides(GROUPED, {
|
||||
'/admin/settings': { hidden: true },
|
||||
'/admin/users': { hidden: true },
|
||||
})
|
||||
assert.deepEqual(groupLabels(out), [
|
||||
[null, ['Dashboard']],
|
||||
['Content', ['Posts', 'Wiki']],
|
||||
])
|
||||
})
|
||||
|
||||
test('group ordering itself is not overridable — sections stay in code order', () => {
|
||||
const out = applyNavOverrides(GROUPED, { '/admin/settings': { order: -99 } })
|
||||
assert.deepEqual(
|
||||
out.map((g) => g.title ?? null),
|
||||
[null, 'Content', 'System'],
|
||||
)
|
||||
})
|
||||
|
||||
// ── Degenerate input ──────────────────────────────────────────────────────
|
||||
|
||||
test('a non-array base nav yields an empty nav rather than throwing', () => {
|
||||
assert.deepEqual(applyNavOverrides(null, { '/': { hidden: true } }), [])
|
||||
assert.deepEqual(applyNavOverrides(undefined, null), [])
|
||||
})
|
||||
|
||||
test('an empty base nav stays empty', () => {
|
||||
assert.deepEqual(applyNavOverrides([], { '/': { label: 'Home' } }), [])
|
||||
})
|
||||
@@ -616,6 +616,15 @@
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "DELETE",
|
||||
"path": "/api/v1/admin/settings/:key",
|
||||
"handlers": 2,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/shard/account",
|
||||
@@ -2136,6 +2145,15 @@
|
||||
"gates": [
|
||||
"siteMode"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/settings/nav",
|
||||
"handlers": 1,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
}
|
||||
],
|
||||
"internal": [
|
||||
|
||||
@@ -249,6 +249,10 @@
|
||||
"method": "PUT",
|
||||
"path": "/api/v1/admin/settings"
|
||||
},
|
||||
{
|
||||
"method": "DELETE",
|
||||
"path": "/api/v1/admin/settings/:key"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/shard/account"
|
||||
@@ -892,6 +896,10 @@
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/wiki/tags"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/settings/nav"
|
||||
}
|
||||
],
|
||||
"internal": [
|
||||
|
||||
@@ -22,4 +22,12 @@ async function seedDefault(key, value) {
|
||||
await query('INSERT IGNORE INTO settings (`key`, value) VALUES (?, ?)', [key, value])
|
||||
}
|
||||
|
||||
module.exports = { getAll, get, set, seedDefault }
|
||||
// Delete a settings row. "Reset to defaults" for the theming/nav keys is the
|
||||
// *absence* of a row, not a stored copy of the defaults — see
|
||||
// docs/website/THEMING_AND_NAV.md §2. Deleting a key that was never set is a
|
||||
// no-op, so reset is idempotent.
|
||||
async function remove(key) {
|
||||
await query('DELETE FROM settings WHERE `key` = ?', [key])
|
||||
}
|
||||
|
||||
module.exports = { getAll, get, set, seedDefault, remove }
|
||||
|
||||
@@ -10,8 +10,27 @@ const PUBLIC_KEYS = [
|
||||
'contact_email',
|
||||
'site_title',
|
||||
'hero_layout', // portal hero composition (JSON). Draft key stays admin-only.
|
||||
'theme_visual', // preset/custom colors, fonts, radii (JSON). See THEMING_AND_NAV.md §6.1.
|
||||
'brand_assets', // uploaded logo/hero/favicon overrides (JSON). §6.3.
|
||||
'nav_public', // public site nav overrides (JSON). §6.4.
|
||||
]
|
||||
|
||||
// Admin-configurable theming & navigation (docs/website/THEMING_AND_NAV.md).
|
||||
// All five are JSON strings and all five are ABSENT by default — no migration
|
||||
// seeds them. Absence of the row, not an empty value, is what makes a surface
|
||||
// fall back to BRAND_* env / the hardcoded theme.css / the hardcoded NAV arrays.
|
||||
//
|
||||
// nav_admin and nav_player are deliberately not public: an anonymous visitor has
|
||||
// no use for either, and the admin nav's labels describe the shape of the admin
|
||||
// surface. They are read by their owners through GET /api/v1/settings/nav (§4.2).
|
||||
const THEMING_KEYS = ['theme_visual', 'brand_assets', 'nav_public', 'nav_admin', 'nav_player']
|
||||
|
||||
// Keys a reset may delete. An explicit allowlist, not "any key": DELETE on an
|
||||
// arbitrary key would let a bad request drop site_mode or the uo-link config,
|
||||
// whose absence means something else entirely. hero_layout_draft is included
|
||||
// because discarding a draft is the same operation.
|
||||
const DELETABLE_KEYS = [...THEMING_KEYS, 'hero_layout_draft']
|
||||
|
||||
// Player self-registration mode. Stored under the 'player_registration' key.
|
||||
// NOTE: the raw value is never exposed publicly — getPublic() derives boolean
|
||||
// availability flags from it instead (see below).
|
||||
@@ -96,6 +115,10 @@ async function set(key, value, updatedBy = null) {
|
||||
return settingsDb.set(key, value, updatedBy)
|
||||
}
|
||||
|
||||
async function remove(key) {
|
||||
return settingsDb.remove(key)
|
||||
}
|
||||
|
||||
async function setMany(obj, updatedBy = null) {
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
await settingsDb.set(key, value, updatedBy)
|
||||
@@ -155,6 +178,19 @@ async function getPublic() {
|
||||
return out
|
||||
}
|
||||
|
||||
// The two nav-override keys their own audiences need but cannot read from
|
||||
// GET /admin/settings (admin-only, while AdminLayout renders for editors and
|
||||
// moderators and PlayerPortalLayout renders for players — THEMING_AND_NAV.md
|
||||
// §4.2). Values are returned as stored: raw JSON strings, or null when the
|
||||
// admin never overrode that nav.
|
||||
async function getNav() {
|
||||
const all = await getAll()
|
||||
return {
|
||||
nav_admin: all.nav_admin ?? null,
|
||||
nav_player: all.nav_player ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
// The client-facing ntfy base URL (no trailing slash), or null when unset.
|
||||
function publicNtfyUrl() {
|
||||
const explicit = (process.env.NTFY_PUBLIC_URL || '').trim()
|
||||
@@ -169,11 +205,15 @@ function publicNtfyUrl() {
|
||||
module.exports = {
|
||||
get,
|
||||
set,
|
||||
remove,
|
||||
setMany,
|
||||
getAll,
|
||||
getPublic,
|
||||
getNav,
|
||||
getInstanceName,
|
||||
PUBLIC_KEYS,
|
||||
THEMING_KEYS,
|
||||
DELETABLE_KEYS,
|
||||
REGISTRATION_KEY,
|
||||
REGISTRATION_MODES,
|
||||
getRegistrationMode,
|
||||
|
||||
@@ -539,6 +539,33 @@ async function updateSettings(req, res) {
|
||||
}
|
||||
}
|
||||
|
||||
// Delete one settings row — the "reset to defaults" primitive.
|
||||
//
|
||||
// For the theming/nav keys, defaults live in BRAND_* env, theme.css and the
|
||||
// hardcoded NAV arrays; the *absence* of the row is what selects them
|
||||
// (docs/website/THEMING_AND_NAV.md §2). Resetting therefore has to delete, not
|
||||
// store a copy of the defaults, or the next change to a default would not reach
|
||||
// an instance that had ever pressed reset.
|
||||
//
|
||||
// The key allowlist is the point of the route: an unrestricted DELETE would let
|
||||
// a stray request drop site_mode or the uo-link config, where absence means
|
||||
// something else entirely. Deleting a key that is not set succeeds — reset is
|
||||
// idempotent and the UI should not have to know whether a row exists.
|
||||
async function deleteSetting(req, res) {
|
||||
const { key } = req.params
|
||||
if (!settings.DELETABLE_KEYS.includes(key)) {
|
||||
return res.status(400).json({ message: 'Setting is not resettable' })
|
||||
}
|
||||
try {
|
||||
await settings.remove(key)
|
||||
await activity.log({ req, action: 'settings.reset', detail: { key } })
|
||||
return res.json({ message: 'Setting reset to default' })
|
||||
} catch (err) {
|
||||
log.error('deleteSetting', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// ── Activity log ──────────────────────────────────────────────────────
|
||||
async function listActivity(req, res) {
|
||||
const limit = Math.min(Number(req.query.limit) || 50, 200)
|
||||
@@ -764,6 +791,7 @@ module.exports = {
|
||||
deleteWikiCategory,
|
||||
getSettings,
|
||||
updateSettings,
|
||||
deleteSetting,
|
||||
listActivity,
|
||||
listUsers,
|
||||
createUser,
|
||||
|
||||
@@ -41,5 +41,22 @@ settingsRouter.put(
|
||||
adminOnly,
|
||||
ctrl.updateSettings,
|
||||
)
|
||||
// Reset one setting to its default by deleting the row. Only the keys whose
|
||||
// default lives outside the store (theming, nav, hero draft) are deletable —
|
||||
// the controller holds the allowlist.
|
||||
settingsRouter.delete(
|
||||
'/:key',
|
||||
// #swagger.tags = ['Admin · Settings']
|
||||
// #swagger.summary = 'Reset one setting to its default (admin only)'
|
||||
// #swagger.description = 'Deletes the settings row so the surface falls back to its BRAND_* env / theme.css / hardcoded default. Restricted to the resettable keys (theme_visual, brand_assets, nav_public, nav_admin, nav_player, hero_layout_draft). Idempotent: resetting a key that was never set succeeds.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.parameters['key'] = { in: 'path', required: true, description: 'Settings key to reset', schema: { type: 'string' } } */
|
||||
/* #swagger.responses[200] = { description: 'Setting reset', content: { "application/json": { schema: { $ref: "#/components/schemas/Message" } } } } */
|
||||
/* #swagger.responses[400] = { description: 'Setting is not resettable', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
adminOnly,
|
||||
ctrl.deleteSetting,
|
||||
)
|
||||
|
||||
module.exports = settingsRouter
|
||||
|
||||
33
server/src/router/v1/settings/index.js
Normal file
33
server/src/router/v1/settings/index.js
Normal file
@@ -0,0 +1,33 @@
|
||||
// /api/v1/settings — settings any *authenticated* account needs to read, whoever
|
||||
// they are.
|
||||
//
|
||||
// A fifth group alongside /auth, /public, /admin and /player, and deliberately
|
||||
// not folded into any of them:
|
||||
//
|
||||
// - /public is anonymous, and the admin nav's labels describe the shape of the
|
||||
// admin surface — that belongs behind a login.
|
||||
// - /admin is `staffOnly` + `requireRole('admin')` on settings, but AdminLayout
|
||||
// renders for editors and moderators too, so they could never read their own
|
||||
// nav overrides from there (docs/website/THEMING_AND_NAV.md §4.2).
|
||||
// - /player is self-service data scoped to req.user.id. These rows are
|
||||
// site-wide configuration that happens to need a login, not anything about
|
||||
// the caller.
|
||||
//
|
||||
// Group gate: authenticated only, no role restriction — staff and players alike
|
||||
// read their own layout's nav. It lives here, ahead of every mount, so a route
|
||||
// added later cannot ship ungated.
|
||||
|
||||
const express = require('express')
|
||||
|
||||
const { requireAuth } = require('../../../auth/session.middleware')
|
||||
const noindex = require('../../../middleware/noindex')
|
||||
|
||||
const navRouter = require('./nav.router')
|
||||
|
||||
const settingsRouter = express.Router()
|
||||
|
||||
settingsRouter.use(noindex, requireAuth)
|
||||
|
||||
settingsRouter.use('/nav', navRouter)
|
||||
|
||||
module.exports = settingsRouter
|
||||
17
server/src/router/v1/settings/nav.controller.js
Normal file
17
server/src/router/v1/settings/nav.controller.js
Normal file
@@ -0,0 +1,17 @@
|
||||
const settings = require('../../../model/settings/settings.model')
|
||||
const log = require('../../../utils/logger')
|
||||
|
||||
// The nav overrides for the two authenticated layouts. Values are the raw stored
|
||||
// JSON strings (settings.value is TEXT) or null; the caller parses them with the
|
||||
// same fail-safe posture as every other JSON setting — malformed reads as
|
||||
// absent, and absent means the hardcoded NAV array is used unchanged.
|
||||
async function getNav(req, res) {
|
||||
try {
|
||||
return res.json(await settings.getNav())
|
||||
} catch (err) {
|
||||
log.error('getNav', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { getNav }
|
||||
28
server/src/router/v1/settings/nav.router.js
Normal file
28
server/src/router/v1/settings/nav.router.js
Normal file
@@ -0,0 +1,28 @@
|
||||
// Settings · Nav — the admin-sidebar and player-portal nav overrides, readable
|
||||
// by the accounts those navs are rendered for.
|
||||
//
|
||||
// Mounted at /api/v1/settings/nav by settings/index.js, which already applied
|
||||
// `noindex, requireAuth`. No role gate on purpose: an editor, a moderator and a
|
||||
// player each need the override for the layout they see, and the payload is
|
||||
// presentation-only — label/order/hidden/group over items the reader's own
|
||||
// role/feature filter still gets the final say on
|
||||
// (docs/website/THEMING_AND_NAV.md §7).
|
||||
|
||||
const express = require('express')
|
||||
|
||||
const ctrl = require('./nav.controller')
|
||||
|
||||
const navRouter = express.Router()
|
||||
|
||||
navRouter.get(
|
||||
'/',
|
||||
// #swagger.tags = ['Settings']
|
||||
// #swagger.summary = 'Nav overrides for the admin and player layouts'
|
||||
// #swagger.description = 'Returns the stored nav_admin and nav_player overrides as raw JSON strings (null when the admin never overrode that nav). Any authenticated account may read them: AdminLayout renders for editors and moderators, PlayerPortalLayout for players, and none of them can read GET /admin/settings. Presentation-only — the role/feature filters in the layouts still decide what is actually shown.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'Nav overrides', content: { "application/json": { schema: { $ref: "#/components/schemas/NavSettings" } } } } */
|
||||
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
ctrl.getNav,
|
||||
)
|
||||
|
||||
module.exports = navRouter
|
||||
@@ -6,11 +6,18 @@ const authRouter = require('./auth')
|
||||
const publicRouter = require('./public')
|
||||
const adminRouter = require('./admin')
|
||||
const playerRouter = require('./player')
|
||||
const settingsRouter = require('./settings')
|
||||
|
||||
v1Router.use('/auth', authRouter)
|
||||
v1Router.use('/public', publicRouter)
|
||||
v1Router.use('/admin', adminRouter)
|
||||
v1Router.use('/player', playerRouter)
|
||||
// Site-wide settings that need a login but no particular role — currently the
|
||||
// nav overrides the admin and player layouts read for themselves. Not /public
|
||||
// (the admin nav's labels describe the admin surface), not /admin (editors and
|
||||
// moderators render AdminLayout but are not admins), not /player (this is
|
||||
// configuration, not self-scoped data). See settings/index.js.
|
||||
v1Router.use('/settings', settingsRouter)
|
||||
// NOTE: /internal is intentionally NOT mounted here. Those routes return the
|
||||
// decrypted Discord bot token and must never share the public listener that
|
||||
// Pangolin proxies. They live on a separate, unpublished port via
|
||||
|
||||
35
server/src/utils/settingsJson.js
Normal file
35
server/src/utils/settingsJson.js
Normal file
@@ -0,0 +1,35 @@
|
||||
// Parse a JSON-valued settings row.
|
||||
//
|
||||
// `settings.value` is TEXT (db/schema.sql), so every JSON-shaped key —
|
||||
// hero_layout, and now theme_visual / brand_assets / nav_* — is stored
|
||||
// stringified and arrives as a string. Consumers must parse it, and the parse
|
||||
// has to be fail-safe: a malformed or wrong-shaped value is treated as
|
||||
// **absent** (the surface falls back to its BRAND_* env / theme.css / NAV
|
||||
// default), never as an error and never as a half-applied object. That is the
|
||||
// same posture parseLayout already takes on the client
|
||||
// (client/src/lib/heroLayout.js).
|
||||
//
|
||||
// See docs/website/THEMING_AND_NAV.md §4.4.
|
||||
|
||||
/**
|
||||
* @param {string|null|undefined} str the raw stored value
|
||||
* @param {(value: unknown) => boolean} [validator] shape check; anything it
|
||||
* rejects is treated as absent
|
||||
* @returns {object|null} the parsed object, or null when absent/malformed
|
||||
*/
|
||||
function parseJsonSetting(str, validator) {
|
||||
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
|
||||
if (validator && !validator(parsed)) return null
|
||||
return parsed
|
||||
}
|
||||
|
||||
module.exports = { parseJsonSetting }
|
||||
@@ -60,6 +60,10 @@
|
||||
"name": "Player · Appeals",
|
||||
"description": "Player-submitted moderation appeals"
|
||||
},
|
||||
{
|
||||
"name": "Settings",
|
||||
"description": "Site-wide settings any authenticated account may read (nav overrides)"
|
||||
},
|
||||
{
|
||||
"name": "Admin · Dashboard",
|
||||
"description": "Dashboard summary and site mode"
|
||||
@@ -3528,6 +3532,79 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/admin/settings/{key}": {
|
||||
"delete": {
|
||||
"tags": [
|
||||
"Admin · Settings"
|
||||
],
|
||||
"summary": "Reset one setting to its default (admin only)",
|
||||
"description": "Deletes the settings row so the surface falls back to its BRAND_* env / theme.css / hardcoded default. Restricted to the resettable keys (theme_visual, brand_assets, nav_public, nav_admin, nav_player, hero_layout_draft). Idempotent: resetting a key that was never set succeeds.",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "key",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "Settings key to reset"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Setting reset",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Message"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Setting is not resettable",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Not authenticated",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Admin role required",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"cookieAuth": []
|
||||
},
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/admin/shard/account": {
|
||||
"post": {
|
||||
"tags": [
|
||||
@@ -12650,6 +12727,51 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/settings/nav": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Settings"
|
||||
],
|
||||
"summary": "Nav overrides for the admin and player layouts",
|
||||
"description": "Returns the stored nav_admin and nav_player overrides as raw JSON strings (null when the admin never overrode that nav). Any authenticated account may read them: AdminLayout renders for editors and moderators, PlayerPortalLayout for players, and none of them can read GET /admin/settings. Presentation-only — the role/feature filters in the layouts still decide what is actually shown.",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Nav overrides",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/NavSettings"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Not authenticated",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden"
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"cookieAuth": []
|
||||
},
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
@@ -17801,6 +17923,55 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"NavSettings": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "object"
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"example": "Nav overrides for the two authenticated layouts (GET /settings/nav). Each value is the stored JSON **string** — settings.value is TEXT — or null when that nav was never overridden. Parse fail-safe: treat malformed as absent and fall back to the hardcoded nav."
|
||||
},
|
||||
"properties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"nav_admin": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "string"
|
||||
},
|
||||
"nullable": {
|
||||
"type": "boolean",
|
||||
"example": true
|
||||
},
|
||||
"example": {
|
||||
"type": "string",
|
||||
"example": "{\"/admin/posts\":{\"label\":\"Blog Posts\",\"order\":10}}"
|
||||
}
|
||||
}
|
||||
},
|
||||
"nav_player": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "string"
|
||||
},
|
||||
"nullable": {
|
||||
"type": "boolean",
|
||||
"example": true
|
||||
},
|
||||
"example": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"DeletedId": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -59,6 +59,7 @@ const doc = {
|
||||
{ name: 'Player', description: 'Self-service player accounts (register, credentials, 2FA, linked identities)' },
|
||||
{ name: 'Player · Shard', description: 'Link an in-game account and read its roster / vendors (uo-link)' },
|
||||
{ name: 'Player · Appeals', description: 'Player-submitted moderation appeals' },
|
||||
{ name: 'Settings', description: 'Site-wide settings any authenticated account may read (nav overrides)' },
|
||||
{ name: 'Admin · Dashboard', description: 'Dashboard summary and site mode' },
|
||||
{ name: 'Admin · Posts', description: 'News / five-on-friday / newsletter / screenshots + uploads' },
|
||||
{ name: 'Admin · Wiki', description: 'Wiki pages, categories, tags and revisions' },
|
||||
@@ -778,6 +779,19 @@ const doc = {
|
||||
},
|
||||
additionalProperties: true,
|
||||
},
|
||||
NavSettings: {
|
||||
type: 'object',
|
||||
description:
|
||||
'Nav overrides for the two authenticated layouts (GET /settings/nav). Each value is the stored JSON **string** — settings.value is TEXT — or null when that nav was never overridden. Parse fail-safe: treat malformed as absent and fall back to the hardcoded nav.',
|
||||
properties: {
|
||||
nav_admin: {
|
||||
type: 'string',
|
||||
nullable: true,
|
||||
example: '{"/admin/posts":{"label":"Blog Posts","order":10}}',
|
||||
},
|
||||
nav_player: { type: 'string', nullable: true, example: null },
|
||||
},
|
||||
},
|
||||
// Delete/mutation acknowledgements — each echoes the affected resource key
|
||||
// or a boolean flag rather than a { message } string.
|
||||
DeletedId: {
|
||||
|
||||
@@ -51,15 +51,14 @@ test('the manifest only inventories API surface, never static mounts', () => {
|
||||
}
|
||||
})
|
||||
|
||||
test('every /admin and /player route still sits behind the shared auth gate', () => {
|
||||
test('every /admin, /player and /settings route still sits behind the shared auth gate', () => {
|
||||
// Router-level `use()` gates do not appear in an individual route's own stack, so a
|
||||
// capability router extracted from admin.routes.js without re-applying the gate would
|
||||
// silently publish authenticated endpoints. Names are only a hint — `requireRole(...)`
|
||||
// returns an anonymous arrow and cannot be seen here — but a *missing* requireAuth is
|
||||
// unambiguous.
|
||||
const gated = collected.public.filter(
|
||||
(r) => r.path.startsWith('/api/v1/admin/') || r.path.startsWith('/api/v1/player/'),
|
||||
)
|
||||
const AUTHENTICATED_GROUPS = ['/api/v1/admin/', '/api/v1/player/', '/api/v1/settings/']
|
||||
const gated = collected.public.filter((r) => AUTHENTICATED_GROUPS.some((p) => r.path.startsWith(p)))
|
||||
assert.ok(gated.length > 100, 'expected the gated surface to be found')
|
||||
for (const route of gated) {
|
||||
assert.ok(
|
||||
|
||||
234
server/test/settingsTheming.test.js
Normal file
234
server/test/settingsTheming.test.js
Normal file
@@ -0,0 +1,234 @@
|
||||
// Point the DB at a closed port BEFORE anything builds the pool. Every model
|
||||
// call below is monkeypatched, so no query runs; db.close() releases the pool so
|
||||
// the process exits cleanly.
|
||||
process.env.DB_HOST = '127.0.0.1'
|
||||
process.env.DB_PORT = '59999'
|
||||
|
||||
const { test, after, afterEach } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
// Phase 0 of the admin theming & navigation feature
|
||||
// (docs/website/THEMING_AND_NAV.md): the settings-store groundwork the rest of
|
||||
// the feature is built on. Three things are load-bearing enough to lock here —
|
||||
// the reset-by-delete allowlist, who may read the nav overrides, and the
|
||||
// fail-safe JSON parse — plus the guarantee that registering the new keys did
|
||||
// not change what an untouched instance serves.
|
||||
const { startApp } = require('./_helper')
|
||||
const settingsRouter = require('../src/router/v1/admin/settings.router')
|
||||
const navSettingsRouter = require('../src/router/v1/settings')
|
||||
const settingsDb = require('../src/model/settings/settings.db')
|
||||
const settings = require('../src/model/settings/settings.model')
|
||||
const { parseJsonSetting } = require('../src/utils/settingsJson')
|
||||
const sessionService = require('../src/auth/session.service')
|
||||
// The admin group applies `noindex, isLoggedIn, staffOnly` before mounting the
|
||||
// settings router, and requireRole reads the req.user that requireAuth attaches.
|
||||
// Mounting the router bare would 403 every caller for the wrong reason.
|
||||
const { requireAuth } = require('../src/auth/session.middleware')
|
||||
const users = require('../src/model/users/users.model')
|
||||
const activity = require('../src/model/activity/activity.model')
|
||||
const db = require('../src/utils/db')
|
||||
|
||||
after(() => db.close())
|
||||
|
||||
const originals = {
|
||||
validateSession: sessionService.validateSession,
|
||||
isSessionRevoked: sessionService.isSessionRevoked,
|
||||
sessionMeta: sessionService.sessionMeta,
|
||||
getById: users.getById,
|
||||
getAll: settingsDb.getAll,
|
||||
remove: settingsDb.remove,
|
||||
set: settingsDb.set,
|
||||
log: activity.log,
|
||||
}
|
||||
afterEach(() => {
|
||||
Object.assign(sessionService, {
|
||||
validateSession: originals.validateSession,
|
||||
isSessionRevoked: originals.isSessionRevoked,
|
||||
sessionMeta: originals.sessionMeta,
|
||||
})
|
||||
users.getById = originals.getById
|
||||
settingsDb.getAll = originals.getAll
|
||||
settingsDb.remove = originals.remove
|
||||
activity.log = originals.log
|
||||
})
|
||||
|
||||
// Sign every request in as the given DB user (role decides the gate outcome).
|
||||
function signInAs(user) {
|
||||
sessionService.validateSession = () => ({ userId: user.id, sessionId: 's1', createdAt: Date.now(), authMethod: 'jwt' })
|
||||
sessionService.isSessionRevoked = async () => false
|
||||
sessionService.sessionMeta = () => ({})
|
||||
users.getById = async () => user
|
||||
activity.log = async () => {}
|
||||
}
|
||||
|
||||
// ── DELETE /admin/settings/:key — reset is delete, and only for some keys ──
|
||||
|
||||
test('resetting a theming key deletes its row', async () => {
|
||||
signInAs({ id: 1, username: 'a', role: 'admin', status: 'active' })
|
||||
const deleted = []
|
||||
settingsDb.remove = async (key) => deleted.push(key)
|
||||
const app = await startApp((a) => a.use('/api/v1/admin/settings', requireAuth, settingsRouter))
|
||||
try {
|
||||
for (const key of settings.THEMING_KEYS) {
|
||||
const res = await fetch(`${app.url}/api/v1/admin/settings/${key}`, { method: 'DELETE' })
|
||||
assert.equal(res.status, 200, `${key} should be resettable`)
|
||||
}
|
||||
assert.deepEqual(deleted, settings.THEMING_KEYS)
|
||||
} finally {
|
||||
await app.close()
|
||||
}
|
||||
})
|
||||
|
||||
// The whole "no migration seeds defaults" principle (§2) rests on this: reset
|
||||
// must not write a stored copy of the defaults, or a later change to a default
|
||||
// would never reach an instance that once pressed reset.
|
||||
test('reset never writes a value, only deletes', async () => {
|
||||
signInAs({ id: 1, username: 'a', role: 'admin', status: 'active' })
|
||||
settingsDb.remove = async () => {}
|
||||
settingsDb.set = () => assert.fail('reset must not write a settings row')
|
||||
const app = await startApp((a) => a.use('/api/v1/admin/settings', requireAuth, settingsRouter))
|
||||
try {
|
||||
const res = await fetch(`${app.url}/api/v1/admin/settings/theme_visual`, { method: 'DELETE' })
|
||||
assert.equal(res.status, 200)
|
||||
} finally {
|
||||
settingsDb.set = originals.set
|
||||
await app.close()
|
||||
}
|
||||
})
|
||||
|
||||
// An unrestricted DELETE would let a stray request drop site_mode or the
|
||||
// uo-link config, where an absent row means something else entirely.
|
||||
test('a key outside the allowlist is rejected and nothing is deleted', async () => {
|
||||
signInAs({ id: 1, username: 'a', role: 'admin', status: 'active' })
|
||||
settingsDb.remove = async () => assert.fail('must not delete a non-resettable key')
|
||||
const app = await startApp((a) => a.use('/api/v1/admin/settings', requireAuth, settingsRouter))
|
||||
try {
|
||||
for (const key of ['site_mode', 'uo_link_token', 'player_registration', 'hero_layout']) {
|
||||
const res = await fetch(`${app.url}/api/v1/admin/settings/${key}`, { method: 'DELETE' })
|
||||
assert.equal(res.status, 400, `${key} must not be resettable`)
|
||||
}
|
||||
} finally {
|
||||
await app.close()
|
||||
}
|
||||
})
|
||||
|
||||
// Reset is idempotent: the UI resets without first knowing whether a row exists.
|
||||
test('resetting a key that was never set still succeeds', async () => {
|
||||
signInAs({ id: 1, username: 'a', role: 'admin', status: 'active' })
|
||||
settingsDb.remove = async () => {} // DELETE of a missing row affects 0 rows
|
||||
const app = await startApp((a) => a.use('/api/v1/admin/settings', requireAuth, settingsRouter))
|
||||
try {
|
||||
const res = await fetch(`${app.url}/api/v1/admin/settings/nav_public`, { method: 'DELETE' })
|
||||
assert.equal(res.status, 200)
|
||||
} finally {
|
||||
await app.close()
|
||||
}
|
||||
})
|
||||
|
||||
test('reset is admin-only — an editor is refused', async () => {
|
||||
signInAs({ id: 2, username: 'e', role: 'editor', status: 'active' })
|
||||
settingsDb.remove = async () => assert.fail('an editor must not reset a setting')
|
||||
const app = await startApp((a) => a.use('/api/v1/admin/settings', requireAuth, settingsRouter))
|
||||
try {
|
||||
const res = await fetch(`${app.url}/api/v1/admin/settings/theme_visual`, { method: 'DELETE' })
|
||||
assert.equal(res.status, 403)
|
||||
} finally {
|
||||
await app.close()
|
||||
}
|
||||
})
|
||||
|
||||
// ── GET /settings/nav — the reason this endpoint exists at all ─────────────
|
||||
|
||||
// AdminLayout renders for editors and moderators, PlayerPortalLayout for
|
||||
// players, and none of them can read GET /admin/settings. Without this route
|
||||
// their nav override would silently never apply (§4.2).
|
||||
for (const role of ['admin', 'editor', 'moderator', 'player']) {
|
||||
test(`GET /settings/nav is readable by an authenticated ${role}`, async () => {
|
||||
signInAs({ id: 7, username: 'u', role, status: 'active' })
|
||||
settingsDb.getAll = async () => [
|
||||
{ key: 'nav_admin', value: '{"/admin/posts":{"label":"Blog Posts"}}' },
|
||||
{ key: 'nav_player', value: '{"/portal/characters":{"hidden":true}}' },
|
||||
]
|
||||
const app = await startApp((a) => a.use('/api/v1/settings', navSettingsRouter))
|
||||
try {
|
||||
const res = await fetch(`${app.url}/api/v1/settings/nav`)
|
||||
assert.equal(res.status, 200, `${role} should reach the handler, got ${res.status}`)
|
||||
const body = await res.json()
|
||||
assert.equal(body.nav_admin, '{"/admin/posts":{"label":"Blog Posts"}}')
|
||||
assert.equal(body.nav_player, '{"/portal/characters":{"hidden":true}}')
|
||||
} finally {
|
||||
await app.close()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
test('GET /settings/nav rejects an anonymous caller', async () => {
|
||||
sessionService.validateSession = () => null
|
||||
const app = await startApp((a) => a.use('/api/v1/settings', navSettingsRouter))
|
||||
try {
|
||||
const res = await fetch(`${app.url}/api/v1/settings/nav`)
|
||||
assert.equal(res.status, 401)
|
||||
} finally {
|
||||
await app.close()
|
||||
}
|
||||
})
|
||||
|
||||
test('GET /settings/nav returns null for a nav that was never overridden', async () => {
|
||||
signInAs({ id: 7, username: 'u', role: 'player', status: 'active' })
|
||||
settingsDb.getAll = async () => []
|
||||
const app = await startApp((a) => a.use('/api/v1/settings', navSettingsRouter))
|
||||
try {
|
||||
const res = await fetch(`${app.url}/api/v1/settings/nav`)
|
||||
assert.deepEqual(await res.json(), { nav_admin: null, nav_player: null })
|
||||
} finally {
|
||||
await app.close()
|
||||
}
|
||||
})
|
||||
|
||||
// ── getPublic(): the new keys appear only when a row exists ───────────────
|
||||
|
||||
test('an untouched instance exposes none of the new keys publicly', async () => {
|
||||
settingsDb.getAll = async () => []
|
||||
const pub = await settings.getPublic()
|
||||
for (const key of settings.THEMING_KEYS) {
|
||||
assert.equal(pub[key], undefined, `${key} must be absent, not empty`)
|
||||
}
|
||||
})
|
||||
|
||||
test('theme_visual / brand_assets / nav_public are public once set; nav_admin / nav_player never are', async () => {
|
||||
settingsDb.getAll = async () => [
|
||||
{ key: 'theme_visual', value: '{"preset":"modern"}' },
|
||||
{ key: 'brand_assets', value: '{"logo":"/uploads/a.png"}' },
|
||||
{ key: 'nav_public', value: '{"/news":{"order":1}}' },
|
||||
{ key: 'nav_admin', value: '{"/admin/posts":{"hidden":true}}' },
|
||||
{ key: 'nav_player', value: '{"/portal":{"label":"Home"}}' },
|
||||
]
|
||||
const pub = await settings.getPublic()
|
||||
assert.equal(pub.theme_visual, '{"preset":"modern"}')
|
||||
assert.equal(pub.brand_assets, '{"logo":"/uploads/a.png"}')
|
||||
assert.equal(pub.nav_public, '{"/news":{"order":1}}')
|
||||
// The admin nav's labels describe the shape of the admin surface, and an
|
||||
// anonymous visitor has no use for either — they stay behind /settings/nav.
|
||||
assert.equal(pub.nav_admin, undefined)
|
||||
assert.equal(pub.nav_player, undefined)
|
||||
})
|
||||
|
||||
// ── parseJsonSetting: malformed reads as absent, never as an error ─────────
|
||||
|
||||
test('parseJsonSetting returns null for absent, empty and malformed values', () => {
|
||||
for (const input of [null, undefined, '', '{', 'not json', '[]', '"str"', '4', 'null']) {
|
||||
assert.equal(parseJsonSetting(input), null, `${JSON.stringify(input)} should read as absent`)
|
||||
}
|
||||
})
|
||||
|
||||
test('parseJsonSetting returns the parsed object for a well-formed value', () => {
|
||||
assert.deepEqual(parseJsonSetting('{"preset":"modern"}'), { preset: 'modern' })
|
||||
})
|
||||
|
||||
// A wrong-shaped value must fall back to the default whole, never partially —
|
||||
// half a theme applied is worse than no theme applied.
|
||||
test('parseJsonSetting treats a validator rejection as absent', () => {
|
||||
const isThemeVisual = (v) => typeof v.preset === 'string'
|
||||
assert.equal(parseJsonSetting('{"custom":{}}', isThemeVisual), null)
|
||||
assert.deepEqual(parseJsonSetting('{"preset":"fantasy"}', isThemeVisual), { preset: 'fantasy' })
|
||||
})
|
||||
Reference in New Issue
Block a user