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:
@@ -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 }
|
||||
Reference in New Issue
Block a user