feat(theming): wire the three navs and add the admin nav builder
Phases 6-8 of docs/website/THEMING_AND_NAV.md. The public header, the admin sidebar and the player portal now read their override row, and /admin/navigation writes them: rename, reorder by drag, hide, and — on the admin sidebar — move a row into another existing section. The merge always runs BEFORE the role and shard-feature filters in the layouts, which are unchanged and remain the boundary. An override is presentation: it cannot introduce a route, cannot touch a `roles` or `feature` gate, and a stored `hidden: false` on a gated item shows nobody anything. The design scoped these phases as client work, but the server had no way to store a nav row: updateSettings validates and stringifies theme_visual and brand_assets and lets everything else through, so a nav object would have been written as "[object Object]" and read as absent for ever. utils/navOverrides.js mirrors utils/brandAssets.js — strict on write with the offending key named, forgiving on read. It validates shape only; whether a `to` exists is settled client-side at merge time, because the base NAV arrays are client constants and a server-side copy would be a second source of truth that drifts. The nav editor cannot be hidden — its own toggle is disabled, the write path drops `hidden` on that one `to`, and AdminLayout strips it again before merging, which also covers a row edited straight in the database. Orders are written only when the sequence actually differs from the code's, and the comparison is restricted to the rows the editing admin can see, so renaming one item does not pin the position of every other one and a role- or feature-gated item missing from their palette is not mistaken for a reorder. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -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