feat(theming): settings-store, nav merge util and radius tokens

Phases 0-2 of docs/website/THEMING_AND_NAV.md. Groundwork only: no admin UI,
no consumer wiring, and an instance that never touches the new settings keys
renders exactly as it does today.

Phase 0 - settings store:
- settingsDb.remove() and DELETE /api/v1/admin/settings/:key, the "reset to
  default" primitive. Defaults for these keys live in BRAND_* env, theme.css
  and the hardcoded NAV arrays, so reset has to delete the row rather than
  store a copy of the default. Allowlisted to the five theming/nav keys plus
  hero_layout_draft, admin-only, idempotent.
- GET /api/v1/settings/nav behind requireAuth with no role gate. AdminLayout
  renders for editors and moderators and PlayerPortalLayout for players, and
  none of them can read GET /admin/settings, so without this their nav
  override would silently never apply.
- A fifth router group for it: /public is anonymous, /admin/settings is
  adminOnly, /player is self-scoped data. This is configuration that needs a
  login.
- parseJsonSetting() in utils/settingsJson.js. settings.value is TEXT, so
  every JSON key arrives as a string; malformed or wrong-shaped reads as
  absent, never as an error and never half-applied.
- theme_visual / brand_assets / nav_public join PUBLIC_KEYS; nav_admin and
  nav_player deliberately do not.

Phase 1 - client/src/lib/navOverrides.js, the pure merge util. Presentation
only: it can set label/order/hidden and (grouped navs) group, and nothing
else. It cannot introduce a `to`, cannot touch roles/feature, and hidden:false
cannot un-hide anything - the existing filters run afterward, unchanged, and
remain the boundary.

Phase 2 - promoted 23 border-radius literals in theme.css to four tokens at
today's values (14x8px, 4x999px, 4x10px, 1x12px). The 7px/6px editor chrome
and the two 50% circles stay literal. --shadow-card and --panel-grad were
already tokens.

Tests: 16 new server tests, 20 new client tests. The route-manifest guard now
also asserts /settings/** sits behind requireAuth. Swagger and both route
artifacts regenerated.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-07 18:15:29 -05:00
parent d765280e28
commit ec0036ce6d
18 changed files with 1042 additions and 28 deletions

View File

@@ -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,

View File

@@ -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