Merge pull request 'feat(theming): admin-configurable theme, brand assets and navigation (edge → main)' (#126) from edge into main
Reviewed-on: #126 Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
This commit is contained in:
@@ -1,13 +1,83 @@
|
||||
// Branding for the Discord bot. Mirrors the server's BRAND_* scheme so embeds and
|
||||
// logs carry the instance identity. Kept minimal — the bot only needs the name
|
||||
// and the accent color (as an int for discord.js embeds).
|
||||
//
|
||||
// The accent additionally tracks ADMIN THEMING. An admin who re-themes the site
|
||||
// changes `theme_visual`, which the server resolves into the effective
|
||||
// `brand.accent` on GET /public/settings (docs/website/THEMING_AND_NAV.md
|
||||
// §4.5). This process boots from env and then follows that value, so embeds
|
||||
// don't stay the old color until someone restarts the container.
|
||||
//
|
||||
// Design constraints this satisfies:
|
||||
// • env is always a working answer — a site that is down, unconfigured or
|
||||
// mid-restart never costs the bot its accent, it just keeps the last known
|
||||
// good one;
|
||||
// • reading `brand.accentInt` never awaits and never throws, because it is
|
||||
// read inline while building an embed;
|
||||
// • at most one refresh is ever in flight.
|
||||
require('dotenv').config()
|
||||
|
||||
const name = process.env.BRAND_NAME || 'Runic Gateway'
|
||||
const accentHex = process.env.BRAND_ACCENT_COLOR || '#7f99bd'
|
||||
const accentInt = (() => {
|
||||
const n = parseInt(String(accentHex).replace('#', ''), 16)
|
||||
return Number.isNaN(n) ? 0x7f99bd : n
|
||||
})()
|
||||
const siteApi = require('./site/siteApiClient')
|
||||
const createLogger = require('./utils/logger')
|
||||
|
||||
module.exports = { name, accentHex, accentInt }
|
||||
const log = createLogger('brand')
|
||||
|
||||
const name = process.env.BRAND_NAME || 'Runic Gateway'
|
||||
const ENV_ACCENT = process.env.BRAND_ACCENT_COLOR || '#7f99bd'
|
||||
|
||||
function toInt(hex) {
|
||||
const n = parseInt(String(hex).replace('#', ''), 16)
|
||||
return Number.isNaN(n) ? 0x7f99bd : n
|
||||
}
|
||||
|
||||
// How long a fetched accent is trusted before the next read triggers a refresh.
|
||||
// A theme change reaching Discord within ten minutes is fine; a network call per
|
||||
// embed is not.
|
||||
const TTL_MS = 10 * 60 * 1000
|
||||
|
||||
let accentHex = ENV_ACCENT
|
||||
let accentInt = toInt(ENV_ACCENT)
|
||||
let fetchedAt = 0
|
||||
let inFlight = null
|
||||
|
||||
async function fetchAccent() {
|
||||
const res = await siteApi.getPublicSettings()
|
||||
// Any failure — site down, maintenance, malformed body — leaves the current
|
||||
// value in place. Stamping fetchedAt regardless is deliberate: it stops a
|
||||
// persistently unreachable site from firing a request on every single read.
|
||||
fetchedAt = Date.now()
|
||||
const accent = res.ok ? res.data?.brand?.accent : null
|
||||
if (typeof accent !== 'string' || !/^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i.test(accent)) return
|
||||
if (accent === accentHex) return
|
||||
accentHex = accent
|
||||
accentInt = toInt(accent)
|
||||
log.info('embed accent updated from the site', { accent })
|
||||
}
|
||||
|
||||
// Kick off a refresh if the cached value is stale. Never awaited by a reader —
|
||||
// the current value is returned immediately and the next read sees the new one.
|
||||
function refreshIfStale() {
|
||||
if (inFlight || Date.now() - fetchedAt < TTL_MS) return inFlight
|
||||
inFlight = fetchAccent()
|
||||
.catch((err) => log.warn('accent refresh failed — keeping the current value', { message: err.message }))
|
||||
.finally(() => {
|
||||
inFlight = null
|
||||
})
|
||||
return inFlight
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
name,
|
||||
// Getters, not values: consumers already read `brand.accentInt` inline when
|
||||
// building an embed, so this keeps the accent current with no call-site change.
|
||||
get accentHex() {
|
||||
refreshIfStale()
|
||||
return accentHex
|
||||
},
|
||||
get accentInt() {
|
||||
refreshIfStale()
|
||||
return accentInt
|
||||
},
|
||||
// Awaited once at startup so the first embed of a process is already correct.
|
||||
refreshAccent: () => refreshIfStale() || Promise.resolve(),
|
||||
}
|
||||
|
||||
@@ -21,6 +21,11 @@ async function start() {
|
||||
log.info(`internal API listening on http://${HOST}:${PORT}`)
|
||||
})
|
||||
|
||||
// Pick up the site's effective accent before the first embed can be built.
|
||||
// Best-effort by design: it never rejects, and a site that is not up yet just
|
||||
// leaves the bot on its BRAND_ACCENT_COLOR default until the next read.
|
||||
await brand.refreshAccent()
|
||||
|
||||
await bootstrap()
|
||||
|
||||
setupShutdown(server)
|
||||
|
||||
@@ -31,6 +31,15 @@ async function call(path) {
|
||||
}
|
||||
}
|
||||
|
||||
// The site's public settings, including the brand block. Used for the embed
|
||||
// accent (see brand.js): the admin can theme the site at runtime, and the
|
||||
// server resolves the effective accent into brand.accent, so this is how the
|
||||
// bot's embeds track a theme change instead of being stuck on the value
|
||||
// BRAND_ACCENT_COLOR had when the container started.
|
||||
function getPublicSettings() {
|
||||
return call('/settings')
|
||||
}
|
||||
|
||||
function getNewsPost(idOrSlug) {
|
||||
return call(`/posts/news/${encodeURIComponent(idOrSlug)}`)
|
||||
}
|
||||
@@ -39,4 +48,4 @@ function searchWiki(query) {
|
||||
return call(`/wiki?q=${encodeURIComponent(query)}`)
|
||||
}
|
||||
|
||||
module.exports = { getNewsPost, searchWiki }
|
||||
module.exports = { getPublicSettings, getNewsPost, searchWiki }
|
||||
|
||||
@@ -7,7 +7,16 @@
|
||||
<meta name="description" content="Runic Gateway — an independent private Ultima Online shard. News, screenshots, guides, and community notes." />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Cinzel:wght@500;600;700&display=swap" rel="stylesheet" />
|
||||
<!-- The eight web families behind the admin font shortlist
|
||||
(docs/website/THEMING_AND_NAV.md §5), in one combined css2? request.
|
||||
Static and never built from admin input: the dropdown stores a full
|
||||
font-family stack from a closed set, and only the families actually
|
||||
applied have their binaries fetched. Both hosts are already in the CSP
|
||||
(server/src/config/csp.js), so this needs no policy change. -->
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Cinzel:wght@500;600;700&family=EB+Garamond:ital,wght@0,400;0,600;0,700;1,400&family=IM+Fell+English:ital@0;1&family=Inter:wght@400;600;700&family=Merriweather:ital,wght@0,400;0,700;1,400&family=Playfair+Display:ital,wght@0,400;0,600;0,700;1,400&family=Source+Sans+3:wght@400;600;700&family=Work+Sans:wght@400;600;700&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
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",
|
||||
|
||||
@@ -41,6 +41,8 @@ import PagesAdmin from './routes/admin/views/PagesAdmin.jsx'
|
||||
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'
|
||||
@@ -139,6 +141,28 @@ export default function App() {
|
||||
<Route path="pages/:id" element={<PageBuilder />} />
|
||||
<Route path="wiki" element={<WikiAdmin />} />
|
||||
<Route path="hero" element={<HeroEditor />} />
|
||||
{/* Theme editing writes an admin-only settings key; the route sits
|
||||
behind the same RoleGate as the sidebar entry that reaches it,
|
||||
and PUT/DELETE /admin/settings is admin-only server-side too. */}
|
||||
<Route
|
||||
path="appearance"
|
||||
element={
|
||||
<RoleGate roles={['admin']}>
|
||||
<AppearanceAdmin />
|
||||
</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"
|
||||
|
||||
@@ -97,6 +97,14 @@ export const api = {
|
||||
generateRecoveryCodes: (currentPassword) =>
|
||||
req('/auth/me/account/recovery-codes/generate', { method: 'POST', body: { currentPassword } }),
|
||||
|
||||
// ----- settings (any authenticated account) -----
|
||||
// Nav overrides for the layouts the caller's own role renders, and the theme
|
||||
// catalog the appearance form is built from. A fifth group, not part of
|
||||
// /admin, because AdminLayout renders for editors and moderators too — see
|
||||
// docs/website/THEMING_AND_NAV.md §4.2.
|
||||
navSettings: () => req('/settings/nav'),
|
||||
themeOptions: () => req('/settings/theme/options'),
|
||||
|
||||
// ----- public -----
|
||||
publicSettings: () => req('/public/settings'),
|
||||
status: () => req('/public/status'),
|
||||
@@ -280,6 +288,20 @@ export const api = {
|
||||
deleteWikiCategory: (id) => req(`/admin/wiki/categories/${id}`, { method: 'DELETE' }),
|
||||
getSettings: () => req('/admin/settings'),
|
||||
updateSettings: (obj) => req('/admin/settings', { method: 'PUT', body: obj }),
|
||||
// Reset one setting to its default by deleting the row — the theming/nav
|
||||
// keys and the hero draft only (the server holds the allowlist). Idempotent,
|
||||
// so the caller need not know whether a row exists.
|
||||
resetSetting: (key) => req(`/admin/settings/${encodeURIComponent(key)}`, { method: 'DELETE' }),
|
||||
// Upload one brand asset (logo | hero | favicon) and set it as the override
|
||||
// in the same call → { url, brand_assets }. A separate endpoint from the
|
||||
// generic upload above because the server applies per-slot rules (favicons
|
||||
// are PNG-only and capped small) and writes the settings row itself, so an
|
||||
// upload never leaves a file nothing points at.
|
||||
uploadBrandAsset: (slot, file) => {
|
||||
const fd = new FormData()
|
||||
fd.append('image', file)
|
||||
return req(`/admin/settings/brand-asset/${encodeURIComponent(slot)}`, { method: 'POST', body: fd, raw: true })
|
||||
},
|
||||
activity: (limit = 50) => req(`/admin/activity?limit=${limit}`),
|
||||
botActivity: () => req('/admin/bot-activity'),
|
||||
unbanIp: (ip) => req('/admin/bot-activity/unban', { method: 'POST', body: { ip } }),
|
||||
|
||||
33
client/src/components/BrandLogo.jsx
Normal file
33
client/src/components/BrandLogo.jsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { useSite } from '../contexts/SiteContext.jsx'
|
||||
|
||||
// The instance logo, shown beside the MoonDot wherever the site says its own
|
||||
// name (docs/website/THEMING_AND_NAV.md phase 5).
|
||||
//
|
||||
// Renders NOTHING unless this instance has a logo — `brand.logo` is the uploaded
|
||||
// override or BRAND_LOGO, and its default is the empty string. That is what
|
||||
// keeps an untouched instance byte-for-byte as today: the MoonDot stands alone
|
||||
// exactly as it does now, and the logo is an addition an operator opts into.
|
||||
//
|
||||
// It sits beside the moon rather than replacing it. The moon is the app's own
|
||||
// mark and appears on surfaces (maintenance, login) that must render before the
|
||||
// settings fetch resolves; swapping it out would leave those momentarily blank.
|
||||
//
|
||||
// Deliberately not used for the footer's "powered by Runic Gateway" emblem
|
||||
// (SiteFooter.jsx) — that badge is the project's mark, not the instance's, and
|
||||
// must not follow brand_assets (§4.11).
|
||||
export default function BrandLogo({ height = 22, alt = '', style }) {
|
||||
const { brand, siteTitle } = useSite()
|
||||
if (!brand.logo) return null
|
||||
return (
|
||||
<img
|
||||
src={brand.logo}
|
||||
// Decorative by default: every call site puts the site title in text right
|
||||
// next to it, so alt text here would have a screen reader say the name
|
||||
// twice. A caller that renders the logo alone passes its own alt.
|
||||
alt={alt || ''}
|
||||
aria-hidden={alt ? undefined : true}
|
||||
title={siteTitle}
|
||||
style={{ height, width: 'auto', maxWidth: height * 6, objectFit: 'contain', display: 'block', ...style }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
150
client/src/components/NavDropdown.jsx
Normal file
150
client/src/components/NavDropdown.jsx
Normal file
@@ -0,0 +1,150 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { NavLink, useLocation } from 'react-router-dom'
|
||||
|
||||
// One dropdown section in the public header — a menu an admin created from
|
||||
// Admin → Navigation (THEMING_AND_NAV.md §7, Phase 10).
|
||||
//
|
||||
// It **opens on click, never on hover**. Hover menus are unusable on touch, and
|
||||
// the alternative (make the trigger a link too) means tapping to open navigates
|
||||
// away instead. A section is a container, not a destination, so the trigger has
|
||||
// no `to` at all.
|
||||
//
|
||||
// Everything else here is the keyboard and dismissal contract a menu needs:
|
||||
// Escape closes and returns focus to the trigger, an outside press closes,
|
||||
// navigating closes, and Arrow Up/Down walk the items. `aria-haspopup` +
|
||||
// `aria-expanded` are what let a screen reader announce it as a menu rather than
|
||||
// as a button that mysteriously changes the page.
|
||||
export default function NavDropdown({ label, items, linkStyle }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const wrapRef = useRef(null)
|
||||
const triggerRef = useRef(null)
|
||||
const location = useLocation()
|
||||
|
||||
// The trigger shows the active treatment when the page you are on lives in
|
||||
// this menu — otherwise entering a section makes the header look like nothing
|
||||
// is selected.
|
||||
const holdsActive = items.some((i) => (i.end ? location.pathname === i.to : location.pathname.startsWith(i.to)))
|
||||
|
||||
// Close on navigation. The menu is rendered inside a sticky header that
|
||||
// survives route changes, so nothing else would dismiss it.
|
||||
useEffect(() => setOpen(false), [location.pathname])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return undefined
|
||||
const onKey = (e) => {
|
||||
if (e.key !== 'Escape') return
|
||||
setOpen(false)
|
||||
triggerRef.current?.focus()
|
||||
}
|
||||
// `mousedown`, not `click`: closing on the press means a press that lands on
|
||||
// another trigger opens that one in the same gesture.
|
||||
const onOutside = (e) => {
|
||||
if (!wrapRef.current?.contains(e.target)) setOpen(false)
|
||||
}
|
||||
document.addEventListener('keydown', onKey)
|
||||
document.addEventListener('mousedown', onOutside)
|
||||
return () => {
|
||||
document.removeEventListener('keydown', onKey)
|
||||
document.removeEventListener('mousedown', onOutside)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
// Roving focus with the arrow keys, wrapping at both ends.
|
||||
const onMenuKeyDown = (e) => {
|
||||
if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp') return
|
||||
e.preventDefault()
|
||||
const links = [...(wrapRef.current?.querySelectorAll('[data-menu-item]') || [])]
|
||||
if (links.length === 0) return
|
||||
const at = links.indexOf(document.activeElement)
|
||||
const next = e.key === 'ArrowDown' ? (at + 1) % links.length : (at - 1 + links.length) % links.length
|
||||
links[at === -1 ? 0 : next].focus()
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={wrapRef} style={{ position: 'relative' }} onKeyDown={onMenuKeyDown}>
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
className="pill"
|
||||
aria-haspopup="true"
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
...(holdsActive || open
|
||||
? { background: 'var(--accent)', color: 'var(--bg-deep)', borderColor: 'var(--accent)' }
|
||||
: {}),
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
<svg
|
||||
width="10"
|
||||
height="10"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="3"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
style={{ transform: open ? 'rotate(180deg)' : 'none', transition: 'transform .15s' }}
|
||||
>
|
||||
<path d="M6 9l6 6 6-6" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div
|
||||
role="menu"
|
||||
aria-label={label}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 'calc(100% + 6px)',
|
||||
left: 0,
|
||||
minWidth: 190,
|
||||
// The header wraps, so a menu near the right edge must not push the
|
||||
// page sideways on a narrow screen.
|
||||
maxWidth: 'calc(100vw - 24px)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 2,
|
||||
padding: 6,
|
||||
borderRadius: 'var(--radius-card)',
|
||||
border: '1px solid var(--line)',
|
||||
background: 'var(--panel-flat)',
|
||||
boxShadow: 'var(--shadow-card)',
|
||||
zIndex: 40,
|
||||
}}
|
||||
>
|
||||
{items.map((item) => (
|
||||
<NavLink
|
||||
key={item.kind === 'link' ? item.id : item.to}
|
||||
to={item.to}
|
||||
end={item.end}
|
||||
role="menuitem"
|
||||
data-menu-item=""
|
||||
onClick={() => setOpen(false)}
|
||||
className="sans"
|
||||
style={({ isActive }) => ({
|
||||
padding: '7px 10px',
|
||||
borderRadius: 'var(--radius-input)',
|
||||
fontSize: '0.85rem',
|
||||
textDecoration: 'none',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
...linkStyle({ isActive }),
|
||||
...(isActive ? {} : { color: 'var(--muted)' }),
|
||||
})}
|
||||
>
|
||||
{item.label}
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,8 +1,13 @@
|
||||
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 NavDropdown from './NavDropdown.jsx'
|
||||
import { buildPublicNav, pruneNav } 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).
|
||||
@@ -11,7 +16,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' },
|
||||
@@ -38,9 +47,24 @@ 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, and may group them into dropdown sections alongside links of
|
||||
// their own (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. `pruneNav` applies the same
|
||||
// check inside a section and drops one it leaves empty, so a dropdown
|
||||
// never opens onto nothing;
|
||||
// • with no stored row this is the coded NAV, in code order, so an
|
||||
// untouched instance renders exactly what it renders today.
|
||||
const nav = useMemo(() => {
|
||||
const tree = buildPublicNav(NAV, parseJsonSetting(settings.nav_public))
|
||||
return pruneNav(tree, (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
|
||||
@@ -68,15 +92,20 @@ export default function SiteHeader() {
|
||||
className="display"
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 10, fontSize: '1.2rem', letterSpacing: '0.05em', color: 'var(--accent-bright)', textDecoration: 'none', fontWeight: 600 }}
|
||||
>
|
||||
<BrandLogo height={22} />
|
||||
<MoonDot />
|
||||
{siteTitle}
|
||||
</Link>
|
||||
<nav style={{ display: 'flex', flexWrap: 'wrap', gap: 8, alignItems: 'center' }}>
|
||||
{nav.map((l) => (
|
||||
<NavLink key={l.to} to={l.to} end={l.end} className="pill" style={linkStyle}>
|
||||
{l.label}
|
||||
</NavLink>
|
||||
))}
|
||||
{nav.map((l) =>
|
||||
l.kind === 'section' ? (
|
||||
<NavDropdown key={l.id} label={l.label} items={l.items} linkStyle={linkStyle} />
|
||||
) : (
|
||||
<NavLink key={l.kind === 'link' ? l.id : l.to} to={l.to} end={l.end} className="pill" style={linkStyle}>
|
||||
{l.label}
|
||||
</NavLink>
|
||||
),
|
||||
)}
|
||||
{!loading && (
|
||||
<NavLink
|
||||
to={account.to}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createContext, useContext, useEffect, useState, useCallback, useMemo } from 'react'
|
||||
import { createContext, useContext, useEffect, useRef, useState, useCallback, useMemo } from 'react'
|
||||
import { api } from '../api/client.js'
|
||||
import { applyThemeTokens } from '../lib/themeVars.js'
|
||||
|
||||
const SiteContext = createContext(null)
|
||||
|
||||
@@ -7,11 +8,16 @@ const SiteContext = createContext(null)
|
||||
export function SiteProvider({ children }) {
|
||||
const [settings, setSettings] = useState({})
|
||||
const [loading, setLoading] = useState(true)
|
||||
// Whether a fetch has actually SUCCEEDED, as distinct from `loading` — which
|
||||
// also goes false when the request failed and we fell back to {}. The boot
|
||||
// theme handoff below turns on this distinction.
|
||||
const [settled, setSettled] = useState(false)
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
const data = await api.publicSettings()
|
||||
setSettings(data || {})
|
||||
setSettled(true)
|
||||
} catch {
|
||||
setSettings({})
|
||||
} finally {
|
||||
@@ -25,11 +31,38 @@ export function SiteProvider({ children }) {
|
||||
|
||||
const brand = useMemo(() => settings.brand || {}, [settings])
|
||||
|
||||
// Apply the admin's theme. The whole effective token set is resolved
|
||||
// server-side, so this only writes it and takes back what it wrote before —
|
||||
// see lib/themeVars.js for why the removal half matters. No theme block means
|
||||
// the admin never themed this instance, and the shipped :root stands.
|
||||
const appliedTokens = useRef([])
|
||||
useEffect(() => {
|
||||
appliedTokens.current = applyThemeTokens(document.documentElement.style, settings.theme, appliedTokens.current)
|
||||
// Take over from the shell's boot block. The server injects the same tokens
|
||||
// into <head> so a themed instance does not paint the shipped palette for a
|
||||
// frame first (utils/htmlShell.js); from here on this effect is the
|
||||
// authority, and leaving the block behind would mean a later reset removed
|
||||
// the inline properties only to reveal the stale block underneath.
|
||||
//
|
||||
// Gated on a SUCCESSFUL fetch, not merely a finished one: a failed request
|
||||
// leaves us with no theme at all, and dropping the block then would strip a
|
||||
// themed instance back to the shipped palette for no reason.
|
||||
if (settled) document.getElementById('theme-boot')?.remove()
|
||||
}, [settings.theme, settled])
|
||||
|
||||
// Apply the instance accent color to the CSS variable the theme is built on,
|
||||
// so branding flows to every `var(--accent)` at runtime (no rebuild).
|
||||
// so branding flows to every `var(--accent)` at runtime (no rebuild). This is
|
||||
// the *effective* accent — the admin theme overrides BRAND_ACCENT_COLOR
|
||||
// server-side (docs/website/THEMING_AND_NAV.md §4.5) — so it agrees with the
|
||||
// theme block rather than fighting it.
|
||||
//
|
||||
// Deliberately ordered after the theme effect and re-run on any theme change:
|
||||
// resetting a theme removes --accent from the token map, and this has to be
|
||||
// the write that lands last or an instance with a custom BRAND_ACCENT_COLOR
|
||||
// would drop to the stylesheet's default accent until the next reload.
|
||||
useEffect(() => {
|
||||
if (brand.accent) document.documentElement.style.setProperty('--accent', brand.accent)
|
||||
}, [brand.accent])
|
||||
}, [brand.accent, settings.theme])
|
||||
|
||||
// Memoized so consumers don't re-render on every provider render (brand is a
|
||||
// fresh object each render, which would otherwise churn the context value).
|
||||
|
||||
502
client/src/lib/navOverrides.js
Normal file
502
client/src/lib/navOverrides.js
Normal file
@@ -0,0 +1,502 @@
|
||||
// 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, 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 && !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 } : {}),
|
||||
...(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`
|
||||
* @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 { 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.
|
||||
const { kept, moved } = regroup(baseNav, entries)
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
// ── 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
|
||||
}
|
||||
|
||||
// ── The public header: dropdown sections and added links ────────────────
|
||||
//
|
||||
// Phase 10. The public nav is the one nav an admin can restructure rather than
|
||||
// only reorder: they may create dropdown **sections**, drop coded entries into
|
||||
// them, and add **links** of their own to pages on this site.
|
||||
//
|
||||
// The invariant §7 rests on survives, and it survives structurally rather than
|
||||
// by vigilance: coded entries stay keyed by a `to` the base array must declare,
|
||||
// so an override still cannot invent a route or touch a `roles`/`feature` gate,
|
||||
// while everything that CAN name an arbitrary path lives in `links` where the
|
||||
// path rule is applied. An added link carries no gate of its own and needs none
|
||||
// — the page behind it enforces its own access, so a link to somewhere the
|
||||
// viewer cannot reach 403s exactly as typing the URL would.
|
||||
//
|
||||
// Stored shape (server/src/utils/navOverrides.js is the writer):
|
||||
// { items: {"<to>": {...}}, sections: [{id,label,order}], links: [{id,label,to,order,section}] }
|
||||
// A bare map is still read as the items map — unambiguous, because every item
|
||||
// key is a path and so can never be the string `items`.
|
||||
|
||||
function unwrapPublic(overrides) {
|
||||
if (!overrides || typeof overrides !== 'object' || Array.isArray(overrides)) {
|
||||
return { items: {}, sections: [], links: [] }
|
||||
}
|
||||
const wrapped = overrides.items && typeof overrides.items === 'object' && !Array.isArray(overrides.items)
|
||||
const items = wrapped ? overrides.items : overrides
|
||||
const sections = wrapped && Array.isArray(overrides.sections) ? overrides.sections : []
|
||||
const links = wrapped && Array.isArray(overrides.links) ? overrides.links : []
|
||||
return { items, sections, links }
|
||||
}
|
||||
|
||||
// Forgiving, like every other read here: an entry that is not usable is dropped
|
||||
// and its neighbours kept.
|
||||
function readSections(sections) {
|
||||
const out = []
|
||||
const seen = new Set()
|
||||
for (const s of sections) {
|
||||
if (!s || typeof s !== 'object' || typeof s.id !== 'string' || seen.has(s.id)) continue
|
||||
if (typeof s.label !== 'string' || !s.label.trim()) continue
|
||||
seen.add(s.id)
|
||||
out.push({ id: s.id, label: s.label.trim(), order: typeof s.order === 'number' && Number.isFinite(s.order) ? s.order : undefined })
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function readLinks(links, knownSections) {
|
||||
const out = []
|
||||
const seen = new Set()
|
||||
for (const l of links) {
|
||||
if (!l || typeof l !== 'object' || typeof l.id !== 'string' || seen.has(l.id)) continue
|
||||
if (typeof l.label !== 'string' || !l.label.trim()) continue
|
||||
// Same rule the server writes by. A stored value that would leave the origin
|
||||
// is dropped rather than rendered, so a hand-edited row cannot put an
|
||||
// off-site link in the header.
|
||||
if (typeof l.to !== 'string' || !l.to.startsWith('/') || l.to.startsWith('//') || /[\s<>"'\\]/.test(l.to)) continue
|
||||
seen.add(l.id)
|
||||
out.push({
|
||||
id: l.id,
|
||||
label: l.label.trim(),
|
||||
to: l.to,
|
||||
order: typeof l.order === 'number' && Number.isFinite(l.order) ? l.order : undefined,
|
||||
section: typeof l.section === 'string' && knownSections.has(l.section) ? l.section : null,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* The public nav as a one-level tree of `{kind: 'item' | 'link' | 'section'}`.
|
||||
*
|
||||
* @param {Array} baseNav the hardcoded public NAV — still the only source of
|
||||
* `to`, `feature` and `end` for a coded entry
|
||||
* @param {object|null} overrides the parsed nav_public row
|
||||
* @param {{keepHidden?: boolean}} [opts] the editor keeps hidden entries so
|
||||
* they can be un-hidden, and gets `defaultLabel` for the reset affordance;
|
||||
* the header must not render them at all
|
||||
* @returns {Array}
|
||||
*/
|
||||
export function buildPublicNav(baseNav, overrides, { keepHidden = false } = {}) {
|
||||
if (!Array.isArray(baseNav)) return []
|
||||
const { items, sections: rawSections, links: rawLinks } = unwrapPublic(overrides)
|
||||
|
||||
const sections = readSections(rawSections)
|
||||
const knownSections = new Set(sections.map((s) => s.id))
|
||||
const links = readLinks(rawLinks, knownSections)
|
||||
|
||||
// Coded entries, keyed by a `to` the base array declares. Anything else in the
|
||||
// map is dropped here, exactly as in applyNavOverrides.
|
||||
const known = new Set(baseNav.map((i) => i.to))
|
||||
const entries = new Map()
|
||||
for (const [to, raw] of Object.entries(items)) {
|
||||
if (!known.has(to)) continue
|
||||
const entry = cleanEntry(raw, new Set())
|
||||
if (!entry) continue
|
||||
if (typeof raw?.section === 'string' && knownSections.has(raw.section)) entry.section = raw.section
|
||||
entries.set(to, entry)
|
||||
}
|
||||
|
||||
const nodes = []
|
||||
baseNav.forEach((item, index) => {
|
||||
const o = entries.get(item.to)
|
||||
if (o?.hidden && !keepHidden) return
|
||||
nodes.push({
|
||||
kind: 'item',
|
||||
...item,
|
||||
...(o?.label ? { label: o.label } : {}),
|
||||
...(keepHidden ? { defaultLabel: item.label, hidden: o?.hidden === true } : {}),
|
||||
section: o?.section ?? null,
|
||||
__order: o?.order,
|
||||
__index: index,
|
||||
})
|
||||
})
|
||||
// An admin-created entity with no stored order appends after the coded ones,
|
||||
// in creation order, rather than jumping to the front on a 0 default.
|
||||
let next = baseNav.length
|
||||
for (const section of sections) {
|
||||
nodes.push({ kind: 'section', id: section.id, label: section.label, section: null, __order: section.order, __index: next++ })
|
||||
}
|
||||
for (const link of links) {
|
||||
nodes.push({ kind: 'link', id: link.id, to: link.to, label: link.label, section: link.section, __order: link.order, __index: next++ })
|
||||
}
|
||||
|
||||
const place = (list) =>
|
||||
list
|
||||
.map((n) => ({ n, key: n.__order ?? n.__index, explicit: n.__order !== undefined }))
|
||||
.sort((a, b) => a.key - b.key || Number(b.explicit) - Number(a.explicit))
|
||||
.map(({ n }) => {
|
||||
const { __order, __index, section, ...rest } = n
|
||||
return rest
|
||||
})
|
||||
|
||||
const top = place(nodes.filter((n) => n.kind === 'section' || !n.section))
|
||||
return top.map((node) =>
|
||||
node.kind === 'section'
|
||||
? { ...node, items: place(nodes.filter((n) => n.section === node.id)) }
|
||||
: node,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the caller's visibility gate — and drop a section it leaves empty.
|
||||
*
|
||||
* Kept here rather than in SiteHeader because the empty-dropdown case is the one
|
||||
* with real correctness risk: a section whose every entry is hidden by shard
|
||||
* visibility must not render as a menu that opens onto nothing. The predicate
|
||||
* stays the caller's, so this module still knows nothing about shard features.
|
||||
*
|
||||
* Added links carry no gate, so they are always visible — see the note above.
|
||||
*
|
||||
* @param {Array} tree from buildPublicNav
|
||||
* @param {(item: object) => boolean} isVisible applied to coded items only
|
||||
* @returns {Array}
|
||||
*/
|
||||
export function pruneNav(tree, isVisible) {
|
||||
if (!Array.isArray(tree)) return []
|
||||
const keep = (node) => node.kind !== 'item' || isVisible(node)
|
||||
return tree
|
||||
.map((node) => (node.kind === 'section' ? { ...node, items: (node.items || []).filter(keep) } : node))
|
||||
.filter((node) => (node.kind === 'section' ? node.items.length > 0 : keep(node)))
|
||||
}
|
||||
|
||||
/**
|
||||
* The editor's tree back as a nav_public value to store.
|
||||
*
|
||||
* Returns the **bare items map** when there are no sections and no added links,
|
||||
* so a nav that does not use this feature stores exactly what phases 6-8 stored.
|
||||
*
|
||||
* @param {Array} tree the editor's current tree
|
||||
* @param {Array} baseNav the hardcoded public NAV
|
||||
* @param {object|null} stored as loaded, so an entry for a feature-gated item
|
||||
* this admin could not see survives their save
|
||||
* @returns {object} `{}` when nothing differs from the code default
|
||||
*/
|
||||
export function buildPublicNavOverrides(tree, baseNav, stored = null) {
|
||||
if (!Array.isArray(tree) || !Array.isArray(baseNav)) return {}
|
||||
const baseLabels = new Map(baseNav.map((i) => [i.to, i.label]))
|
||||
const sections = []
|
||||
const links = []
|
||||
const items = {}
|
||||
|
||||
// Flatten to (node, containerId, indexInContainer), which is all the writer
|
||||
// needs: a section's own position is its index in the top-level list.
|
||||
const placed = []
|
||||
tree.forEach((node, index) => {
|
||||
placed.push({ node, section: null, index })
|
||||
if (node.kind === 'section') (node.items || []).forEach((child, i) => placed.push({ node: child, section: node.id, index: i }))
|
||||
})
|
||||
|
||||
// Orders are written whenever this nav has any structure of its own: a section
|
||||
// exists only because the admin put it somewhere, so its position is never
|
||||
// "whatever the code says". Without sections the rule is phase 6-8's — write
|
||||
// orders only if the sequence actually moved.
|
||||
const hasStructure = tree.some((n) => n.kind === 'section' || n.kind === 'link')
|
||||
const shown = new Set(tree.flatMap((n) => (n.kind === 'section' ? (n.items || []) : [n])).filter((n) => n.kind === 'item').map((n) => n.to))
|
||||
const sequence = tree.filter((n) => n.kind === 'item').map((n) => n.to)
|
||||
const baseSequence = baseNav.filter((i) => shown.has(i.to)).map((i) => i.to)
|
||||
const moved = sequence.length !== baseSequence.length || sequence.some((to, i) => to !== baseSequence[i])
|
||||
const writeOrder = hasStructure || moved
|
||||
|
||||
for (const { node, section, index } of placed) {
|
||||
if (node.kind === 'section') {
|
||||
sections.push({ id: node.id, label: (node.label || '').trim() || 'Section', ...(writeOrder ? { order: index } : {}) })
|
||||
continue
|
||||
}
|
||||
if (node.kind === 'link') {
|
||||
links.push({
|
||||
id: node.id,
|
||||
label: (node.label || '').trim() || node.to,
|
||||
to: node.to,
|
||||
...(section ? { section } : {}),
|
||||
...(writeOrder ? { order: index } : {}),
|
||||
})
|
||||
continue
|
||||
}
|
||||
const entry = {}
|
||||
const label = typeof node.label === 'string' ? node.label.trim() : ''
|
||||
if (label && label !== baseLabels.get(node.to)) entry.label = label
|
||||
if (node.hidden === true) entry.hidden = true
|
||||
if (section) entry.section = section
|
||||
if (writeOrder) entry.order = index
|
||||
if (Object.keys(entry).length > 0) items[node.to] = entry
|
||||
}
|
||||
|
||||
// Carry through an entry for a coded item this admin's palette never showed
|
||||
// them (shard-feature gated), so their save does not silently reset it.
|
||||
const { items: storedItems } = unwrapPublic(stored)
|
||||
for (const [to, entry] of Object.entries(storedItems)) {
|
||||
if (!shown.has(to) && baseLabels.has(to) && entry && typeof entry === 'object') items[to] = entry
|
||||
}
|
||||
|
||||
if (sections.length === 0 && links.length === 0) return items
|
||||
const out = { items }
|
||||
if (sections.length) out.sections = sections
|
||||
if (links.length) out.links = links
|
||||
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
|
||||
47
client/src/lib/themeVars.js
Normal file
47
client/src/lib/themeVars.js
Normal file
@@ -0,0 +1,47 @@
|
||||
// Apply the server-resolved theme to the document as CSS custom properties.
|
||||
//
|
||||
// The effective token set is resolved server-side and arrives on
|
||||
// `settings.theme` (see server/src/utils/themeResolve.js). The client's only
|
||||
// job is to write it onto <html> — and, crucially, to take back what it wrote
|
||||
// last time, which is the part with actual logic and the reason this lives in
|
||||
// its own testable module.
|
||||
//
|
||||
// Why removal matters: an admin who resets the theme, or switches from a preset
|
||||
// that sets --bg to one that does not, gets a payload that no longer mentions
|
||||
// that variable. Inline properties are not cleared by writing a smaller object
|
||||
// over them, so without an explicit removeProperty the old value would stick
|
||||
// until a reload. That would make "Reset to defaults" look broken.
|
||||
//
|
||||
// Everything written here is a value the server validated against a closed set
|
||||
// (hex color, curated font stack, bounded px length, listed shadow). The client
|
||||
// deliberately does not re-validate — it would be a second, drifting authority.
|
||||
// It does refuse anything that is not a `--custom-property`, which is the one
|
||||
// check that costs nothing and stops a token map from reaching an ordinary CSS
|
||||
// property.
|
||||
|
||||
const CUSTOM_PROPERTY = /^--[a-zA-Z0-9-_]+$/
|
||||
|
||||
/**
|
||||
* @param {CSSStyleDeclaration} style usually document.documentElement.style
|
||||
* @param {Record<string, string>|null|undefined} tokens the new theme, or
|
||||
* null/absent for "no admin theme" — which clears everything previously set
|
||||
* @param {string[]} [applied] the keys this function wrote last time
|
||||
* @returns {string[]} the keys now applied, to pass back on the next call
|
||||
*/
|
||||
export function applyThemeTokens(style, tokens, applied = []) {
|
||||
const next = []
|
||||
if (tokens && typeof tokens === 'object') {
|
||||
for (const [name, value] of Object.entries(tokens)) {
|
||||
if (!CUSTOM_PROPERTY.test(name) || typeof value !== 'string' || value === '') continue
|
||||
style.setProperty(name, value)
|
||||
next.push(name)
|
||||
}
|
||||
}
|
||||
// Take back only what we set ourselves. Anything else on the element's inline
|
||||
// style belongs to someone else (SiteContext's own --accent line, a future
|
||||
// feature) and is not ours to clear.
|
||||
for (const name of applied) {
|
||||
if (!next.includes(name)) style.removeProperty(name)
|
||||
}
|
||||
return next
|
||||
}
|
||||
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,8 +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).
|
||||
@@ -38,13 +41,19 @@ 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
|
||||
// its items ungrouped (Dashboard at top, Account at bottom). Each item's `roles`
|
||||
// (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'] },
|
||||
@@ -74,6 +83,8 @@ const NAV = [
|
||||
{ to: '/admin/users', label: 'Users', icon: IconUsers, roles: ['admin'] },
|
||||
{ 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'] },
|
||||
@@ -93,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',
|
||||
@@ -104,6 +144,8 @@ const TITLES = {
|
||||
'/admin/shard-ops': 'In-Game Ops',
|
||||
'/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',
|
||||
@@ -141,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)
|
||||
@@ -148,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.
|
||||
@@ -228,6 +272,7 @@ export default function AdminLayout() {
|
||||
}}
|
||||
>
|
||||
<div style={{ padding: '22px 22px 18px', borderBottom: '1px solid var(--line-soft)', display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<BrandLogo height={24} />
|
||||
<MoonDot />
|
||||
<div>
|
||||
<div className="display" style={{ fontSize: '1.02rem', color: 'var(--head)', letterSpacing: '0.03em' }}>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link, useNavigate, useLocation } from 'react-router-dom'
|
||||
import MoonDot from '../../components/MoonDot.jsx'
|
||||
import BrandLogo from '../../components/BrandLogo.jsx'
|
||||
import ProviderIcon from '../../components/ProviderIcon.jsx'
|
||||
import TrustLimitModal from '../../components/security/TrustLimitModal.jsx'
|
||||
import { useAuth } from '../../contexts/AuthContext.jsx'
|
||||
@@ -181,6 +182,10 @@ export default function AdminLogin() {
|
||||
<div style={{ width: '100%', maxWidth: 400 }}>
|
||||
<div style={{ textAlign: 'center', marginBottom: 26 }}>
|
||||
<div style={{ marginBottom: 14 }}>
|
||||
{/* Stacked above the moon rather than beside it: this layout is
|
||||
centered text, and a flex row here would change the block's
|
||||
height on instances with no logo. */}
|
||||
<BrandLogo height={34} style={{ margin: '0 auto 12px' }} />
|
||||
<MoonDot size={15} glow={0.55} />
|
||||
</div>
|
||||
<h1 className="display" style={{ margin: 0, fontSize: '1.7rem', letterSpacing: '0.04em', color: 'var(--head)' }}>
|
||||
|
||||
358
client/src/routes/admin/views/AppearanceAdmin.jsx
Normal file
358
client/src/routes/admin/views/AppearanceAdmin.jsx
Normal file
@@ -0,0 +1,358 @@
|
||||
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
|
||||
// docs/website/THEMING_AND_NAV.md (phases 3-5). The nav builder is phase 7 and
|
||||
// gets its own screen.
|
||||
//
|
||||
// Two things shape this form:
|
||||
//
|
||||
// • Every control is a closed set. The presets, the font shortlist and the
|
||||
// shadow depths all come from GET /settings/theme/options, which is derived
|
||||
// from the same server config the save is validated against — so the form
|
||||
// can never offer a value the server would reject. Nothing here is free
|
||||
// text except the color inputs, which are <input type="color"> and so are
|
||||
// hex by construction.
|
||||
// • Saving means writing a settings row; resetting means DELETING it. Absence
|
||||
// of the row is what selects the shipped default, so "reset" cannot write a
|
||||
// copy of the defaults — see §2.
|
||||
|
||||
// Human labels for the eight editable colors and four radii. The field names
|
||||
// and the CSS variables they drive both come from the server
|
||||
// (colorFields / radiusFields); this only decorates them, and a field with no
|
||||
// label here still renders under its raw name rather than vanishing.
|
||||
const COLOR_LABELS = {
|
||||
bg: 'Background',
|
||||
bgDeep: 'Background (deep)',
|
||||
panelA: 'Panel (top)',
|
||||
panelB: 'Panel (bottom)',
|
||||
accent: 'Accent',
|
||||
accentBright: 'Accent (bright)',
|
||||
ink: 'Ink / headings',
|
||||
text: 'Body text',
|
||||
}
|
||||
const RADIUS_LABELS = {
|
||||
radiusPill: 'Pills & buttons',
|
||||
radiusPanel: 'Flat panels',
|
||||
radiusCard: 'Cards & panels',
|
||||
radiusInput: 'Inputs & notes',
|
||||
}
|
||||
const FONT_LABELS = {
|
||||
serif: 'Body serif',
|
||||
display: 'Display / headings',
|
||||
sans: 'Interface sans',
|
||||
}
|
||||
|
||||
// Strip empty groups so a theme the admin cleared back out is stored as a bare
|
||||
// preset rather than as `{colors:{}, fonts:{}, structure:{}}`. Never null a
|
||||
// field out to "clear" it — remove it (§6.1).
|
||||
function compactCustom(custom) {
|
||||
const out = {}
|
||||
for (const [group, fields] of Object.entries(custom)) {
|
||||
const kept = Object.fromEntries(Object.entries(fields).filter(([, v]) => v !== '' && v != null))
|
||||
if (Object.keys(kept).length) out[group] = kept
|
||||
}
|
||||
return Object.keys(out).length ? out : null
|
||||
}
|
||||
|
||||
export default function AppearanceAdmin() {
|
||||
const { refresh: refreshSite } = useSite()
|
||||
const [options, setOptions] = useState(null)
|
||||
const [preset, setPreset] = useState('runic-gateway')
|
||||
const [custom, setCustom] = useState({ colors: {}, fonts: {}, structure: {} })
|
||||
// Whether a theme_visual row exists at all. Drives the "reset" button and the
|
||||
// "this instance is using the shipped theme" note — an admin needs to be able
|
||||
// to tell "never themed" from "themed to look like the default".
|
||||
const [stored, setStored] = useState(false)
|
||||
// The brand-asset overrides, read in the same settings fetch and then owned by
|
||||
// the panel below (its uploads save on their own, so it does not share this
|
||||
// screen's Save button).
|
||||
const [assets, setAssets] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [saved, setSaved] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
Promise.all([api.themeOptions(), api.admin.getSettings()])
|
||||
.then(([opts, all]) => {
|
||||
if (!active) return
|
||||
setOptions(opts)
|
||||
// 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))
|
||||
setAssets(parseJsonSetting(all.brand_assets) || {})
|
||||
if (parsed) {
|
||||
setPreset(parsed.preset || 'runic-gateway')
|
||||
setCustom({
|
||||
colors: parsed.custom?.colors || {},
|
||||
fonts: parsed.custom?.fonts || {},
|
||||
structure: parsed.custom?.structure || {},
|
||||
})
|
||||
}
|
||||
})
|
||||
.catch(() => active && setError('Could not load the appearance settings.'))
|
||||
.finally(() => active && setLoading(false))
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
// What an unset field currently resolves to: the selected preset's palette,
|
||||
// or the shipped theme when the preset is Custom (which has no base). Lets a
|
||||
// color picker open on the value the admin is actually looking at.
|
||||
const baseTokens = useMemo(() => {
|
||||
if (!options) return {}
|
||||
return options.presets.find((p) => p.id === preset)?.tokens || options.shippedTokens
|
||||
}, [options, preset])
|
||||
|
||||
if (loading) return <Loading />
|
||||
if (error && !options) return <ErrorState message={error} />
|
||||
|
||||
const setField = (group, field) => (value) => {
|
||||
setCustom((c) => ({ ...c, [group]: { ...c[group], [field]: value } }))
|
||||
setSaved(false)
|
||||
}
|
||||
const clearField = (group, field) => () => {
|
||||
setCustom((c) => {
|
||||
const next = { ...c[group] }
|
||||
delete next[field]
|
||||
return { ...c, [group]: next }
|
||||
})
|
||||
setSaved(false)
|
||||
}
|
||||
|
||||
async function save() {
|
||||
setBusy(true)
|
||||
setError('')
|
||||
try {
|
||||
await api.admin.updateSettings({ theme_visual: { preset, custom: compactCustom(custom) } })
|
||||
setStored(true)
|
||||
setSaved(true)
|
||||
// Repull the public settings so the surrounding admin UI re-themes itself
|
||||
// immediately — the admin sees the change they just made.
|
||||
await refreshSite()
|
||||
} catch (err) {
|
||||
setError(err.message || 'Could not save the theme.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function resetAll() {
|
||||
setBusy(true)
|
||||
setError('')
|
||||
try {
|
||||
await api.admin.resetSetting('theme_visual')
|
||||
setPreset('runic-gateway')
|
||||
setCustom({ colors: {}, fonts: {}, structure: {} })
|
||||
setStored(false)
|
||||
setSaved(false)
|
||||
await refreshSite()
|
||||
} catch (err) {
|
||||
setError(err.message || 'Could not reset the theme.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section style={{ maxWidth: 720, display: 'flex', flexDirection: 'column', gap: 26 }}>
|
||||
<p className="sans dim" style={{ margin: 0, fontSize: '0.82rem', lineHeight: 1.7 }}>
|
||||
Colors, fonts and corner radius for the public site, this admin panel and the player portal.
|
||||
{' '}
|
||||
{stored ? (
|
||||
<>This instance has a saved theme. <strong style={{ color: 'var(--muted)' }}>Reset to default</strong> deletes it and returns to the shipped look.</>
|
||||
) : (
|
||||
<>This instance has never been themed, so it uses the shipped look and its <code>BRAND_*</code> accent.</>
|
||||
)}
|
||||
</p>
|
||||
|
||||
{/* ── Preset ─────────────────────────────────────────────── */}
|
||||
<div>
|
||||
<span className="field-label">Preset</span>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 10, marginTop: 8 }}>
|
||||
{options.presets.map((p) => (
|
||||
<button
|
||||
key={p.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setPreset(p.id)
|
||||
setSaved(false)
|
||||
}}
|
||||
className="sans"
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
padding: '10px 14px',
|
||||
borderRadius: 'var(--radius-input)',
|
||||
border: `1px solid ${preset === p.id ? 'var(--accent)' : 'var(--line)'}`,
|
||||
background: preset === p.id ? 'var(--blue)' : 'transparent',
|
||||
color: preset === p.id ? 'var(--ink)' : 'var(--muted)',
|
||||
cursor: 'pointer',
|
||||
fontSize: '0.86rem',
|
||||
}}
|
||||
aria-pressed={preset === p.id}
|
||||
>
|
||||
{p.tokens ? (
|
||||
<span style={{ display: 'flex', borderRadius: 4, overflow: 'hidden', border: '1px solid var(--line)' }}>
|
||||
{['--bg', '--panel-a', '--accent', '--ink'].map((t) => (
|
||||
<span key={t} style={{ width: 11, height: 18, background: p.tokens[t] }} />
|
||||
))}
|
||||
</span>
|
||||
) : (
|
||||
<span style={{ width: 44, height: 18, borderRadius: 4, border: '1px dashed var(--line)' }} />
|
||||
)}
|
||||
{p.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<span className="sans dim" style={{ display: 'block', marginTop: 8, fontSize: '0.76rem' }}>
|
||||
{preset === 'custom'
|
||||
? 'Custom starts from the shipped theme — only the fields you set below change.'
|
||||
: 'A preset sets the whole palette. Anything you set below overrides it, field by field.'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* ── Colors ─────────────────────────────────────────────── */}
|
||||
<div>
|
||||
<span className="field-label">Colors</span>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(210px, 1fr))', gap: 12, marginTop: 8 }}>
|
||||
{options.colorFields.map(({ name, token }) => {
|
||||
const set = custom.colors[name] !== undefined
|
||||
return (
|
||||
<div key={name} style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
{/* <input type="color"> has no empty state, so an unset field
|
||||
shows what it currently resolves to rather than black. */}
|
||||
<input
|
||||
type="color"
|
||||
value={custom.colors[name] || baseTokens[token] || '#000000'}
|
||||
onChange={(e) => setField('colors', name)(e.target.value)}
|
||||
aria-label={COLOR_LABELS[name] || name}
|
||||
style={{ width: 34, height: 30, padding: 0, border: '1px solid var(--line)', borderRadius: 6, background: 'transparent', cursor: 'pointer' }}
|
||||
/>
|
||||
<span className="sans" style={{ flex: 1, fontSize: '0.82rem', color: set ? 'var(--ink)' : 'var(--dim)' }}>
|
||||
{COLOR_LABELS[name] || name}
|
||||
</span>
|
||||
{set && (
|
||||
<button type="button" onClick={clearField('colors', name)} className="sans" title="Follow the preset again" style={linkBtn}>
|
||||
clear
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<span className="sans dim" style={{ display: 'block', marginTop: 8, fontSize: '0.76rem' }}>
|
||||
A color you have not set follows the preset. “Live” and “maintenance” status colors are never themed — green has to keep meaning live.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* ── Fonts ──────────────────────────────────────────────── */}
|
||||
<div>
|
||||
<span className="field-label">Fonts</span>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, marginTop: 8 }}>
|
||||
{Object.keys(options.fonts).map((role) => (
|
||||
<label key={role} style={{ display: 'block' }}>
|
||||
<span className="sans dim" style={{ display: 'block', fontSize: '0.76rem', marginBottom: 4 }}>
|
||||
{FONT_LABELS[role] || role}
|
||||
</span>
|
||||
<select
|
||||
className="select"
|
||||
value={custom.fonts[role] || ''}
|
||||
onChange={(e) => (e.target.value ? setField('fonts', role)(e.target.value) : clearField('fonts', role)())}
|
||||
>
|
||||
<option value="">Follow the preset</option>
|
||||
{options.fonts[role].map((o) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Structure ──────────────────────────────────────────── */}
|
||||
<div>
|
||||
<span className="field-label">Corners & depth</span>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(210px, 1fr))', gap: 12, marginTop: 8 }}>
|
||||
{options.radiusFields.map(({ name, token }) => (
|
||||
<label key={name} style={{ display: 'block' }}>
|
||||
<span className="sans dim" style={{ display: 'block', fontSize: '0.76rem', marginBottom: 4 }}>
|
||||
{RADIUS_LABELS[name] || name}
|
||||
</span>
|
||||
<input
|
||||
className="input"
|
||||
type="number"
|
||||
min="0"
|
||||
max={options.radiusMaxPx}
|
||||
placeholder={(baseTokens[token] || '').replace('px', '')}
|
||||
value={(custom.structure[name] || '').replace('px', '')}
|
||||
onChange={(e) =>
|
||||
e.target.value === ''
|
||||
? clearField('structure', name)()
|
||||
: setField('structure', name)(`${Math.min(Math.max(parseInt(e.target.value, 10) || 0, 0), options.radiusMaxPx)}px`)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<label style={{ display: 'block', marginTop: 12 }}>
|
||||
<span className="sans dim" style={{ display: 'block', fontSize: '0.76rem', marginBottom: 4 }}>
|
||||
Card shadow
|
||||
</span>
|
||||
<select
|
||||
className="select"
|
||||
value={custom.structure.shadowDepth || ''}
|
||||
onChange={(e) => (e.target.value ? setField('structure', 'shadowDepth')(e.target.value) : clearField('structure', 'shadowDepth')())}
|
||||
>
|
||||
<option value="">Follow the preset</option>
|
||||
{options.shadows.map((o) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</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 theme'}
|
||||
</button>
|
||||
<button onClick={resetAll} disabled={busy || !stored} className="pill" title={stored ? 'Delete the saved theme' : 'Nothing to reset'}>
|
||||
Reset to default
|
||||
</button>
|
||||
{saved && <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 }}>
|
||||
The accent reaches the mobile app and the Discord bot too — both theme themselves from this
|
||||
site’s public branding.
|
||||
</p>
|
||||
|
||||
{/* ── Brand assets ───────────────────────────────────────── */}
|
||||
<BrandAssetsPanel initial={assets || {}} />
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
const linkBtn = {
|
||||
border: 'none',
|
||||
background: 'transparent',
|
||||
color: 'var(--accent)',
|
||||
fontSize: '0.72rem',
|
||||
cursor: 'pointer',
|
||||
padding: 0,
|
||||
}
|
||||
213
client/src/routes/admin/views/BrandAssetsPanel.jsx
Normal file
213
client/src/routes/admin/views/BrandAssetsPanel.jsx
Normal file
@@ -0,0 +1,213 @@
|
||||
import { useRef, useState } from 'react'
|
||||
import { api } from '../../../api/client.js'
|
||||
import { useSite } from '../../../contexts/SiteContext.jsx'
|
||||
|
||||
// Admin · Appearance → Brand assets (docs/website/THEMING_AND_NAV.md §6.3).
|
||||
//
|
||||
// Three slots, each an override layer over the matching BRAND_* env value. An
|
||||
// empty slot is not "no image" — it is "whatever this instance was deployed
|
||||
// with", which is why every row shows what it currently resolves to rather than
|
||||
// an empty box.
|
||||
//
|
||||
// Unlike the theme form above, an upload SAVES IMMEDIATELY: the file and the
|
||||
// settings row are written by one request, because an upload that stored a file
|
||||
// and then waited for a Save press would leave litter in /uploads whenever the
|
||||
// admin changed their mind. Clearing a slot is the same deal in reverse.
|
||||
const SLOTS = [
|
||||
{
|
||||
id: 'logo',
|
||||
label: 'Logo',
|
||||
accept: 'image/png,image/jpeg,image/webp,image/avif,image/gif',
|
||||
limit: '1 MB',
|
||||
envVar: 'BRAND_LOGO',
|
||||
help: 'Shown beside the moon in the site header, the admin sidebar and the player portal, and used as the link preview image when a page is shared.',
|
||||
},
|
||||
{
|
||||
id: 'hero',
|
||||
label: 'Hero image',
|
||||
accept: 'image/png,image/jpeg,image/webp,image/avif,image/gif',
|
||||
limit: '8 MB',
|
||||
envVar: 'BRAND_HERO',
|
||||
// §4.9: the hero editor's own background beats this, and an admin who does
|
||||
// not know that files a bug against a working system.
|
||||
help: 'The image behind the portal hero. If the hero editor has its own background image set, that wins over this one.',
|
||||
},
|
||||
{
|
||||
id: 'favicon',
|
||||
label: 'Favicon',
|
||||
accept: 'image/png',
|
||||
limit: '512 KB',
|
||||
envVar: 'BRAND_FAVICON',
|
||||
// §4.10: .ico would mean adding a type to the upload allowlist, and the
|
||||
// stored extension coming from that allowlist is what makes uploads safe.
|
||||
help: 'The browser tab icon. PNG only — a 32×32 or 64×64 square works everywhere.',
|
||||
},
|
||||
]
|
||||
|
||||
export default function BrandAssetsPanel({ initial }) {
|
||||
const { brand, refresh: refreshSite } = useSite()
|
||||
const [assets, setAssets] = useState(initial || {})
|
||||
const [busySlot, setBusySlot] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const inputs = useRef({})
|
||||
|
||||
async function upload(slot, file) {
|
||||
if (!file) return
|
||||
setBusySlot(slot)
|
||||
setError('')
|
||||
try {
|
||||
const res = await api.admin.uploadBrandAsset(slot, file)
|
||||
setAssets(res.brand_assets || {})
|
||||
await refreshSite()
|
||||
} catch (err) {
|
||||
setError(err.message || 'Could not upload that image.')
|
||||
} finally {
|
||||
setBusySlot('')
|
||||
// Let the same file be picked again after a failure — a file input holds
|
||||
// its value, so re-choosing it would fire no change event.
|
||||
if (inputs.current[slot]) inputs.current[slot].value = ''
|
||||
}
|
||||
}
|
||||
|
||||
async function clear(slot) {
|
||||
setBusySlot(slot)
|
||||
setError('')
|
||||
try {
|
||||
const next = { ...assets }
|
||||
delete next[slot]
|
||||
// Clearing the last override deletes the row rather than storing `{}` —
|
||||
// absence of the row is what selects the env defaults (§2), and a stored
|
||||
// empty object would be a different state that means the same thing.
|
||||
if (Object.keys(next).length) await api.admin.updateSettings({ brand_assets: next })
|
||||
else await api.admin.resetSetting('brand_assets')
|
||||
setAssets(next)
|
||||
await refreshSite()
|
||||
} catch (err) {
|
||||
setError(err.message || 'Could not clear that asset.')
|
||||
} finally {
|
||||
setBusySlot('')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<span className="field-label">Brand assets</span>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 14, marginTop: 8 }}>
|
||||
{SLOTS.map((slot) => {
|
||||
const overridden = Boolean(assets[slot.id])
|
||||
// What the site actually uses right now: the override, or the env
|
||||
// value the brand block already resolved for us.
|
||||
const effective = assets[slot.id] || brand[slot.id] || ''
|
||||
return (
|
||||
<div
|
||||
key={slot.id}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
gap: 14,
|
||||
padding: 12,
|
||||
border: '1px solid var(--line)',
|
||||
borderRadius: 'var(--radius-input)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 76,
|
||||
height: 48,
|
||||
flex: '0 0 auto',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
border: '1px solid var(--line-soft)',
|
||||
borderRadius: 6,
|
||||
background: 'var(--bg-deep)',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{effective ? (
|
||||
<img src={effective} alt="" style={{ maxWidth: '100%', maxHeight: '100%', objectFit: 'contain' }} />
|
||||
) : (
|
||||
<span className="sans dim" style={{ fontSize: '0.68rem' }}>
|
||||
none
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div className="sans" style={{ fontSize: '0.86rem', color: 'var(--ink)' }}>
|
||||
{slot.label}
|
||||
</div>
|
||||
<div className="sans dim" style={{ fontSize: '0.74rem', lineHeight: 1.6, marginTop: 2 }}>
|
||||
{slot.help}
|
||||
</div>
|
||||
<div className="sans dim" style={{ fontSize: '0.72rem', marginTop: 6 }}>
|
||||
{overridden ? (
|
||||
<>
|
||||
Uploaded override — <code>{assets[slot.id]}</code>
|
||||
</>
|
||||
) : effective ? (
|
||||
<>
|
||||
Using the deployed default from <code>{slot.envVar}</code>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Not set — <code>{slot.envVar}</code> is empty, so nothing is rendered
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 10, alignItems: 'center', marginTop: 8, flexWrap: 'wrap' }}>
|
||||
<input
|
||||
ref={(el) => {
|
||||
inputs.current[slot.id] = el
|
||||
}}
|
||||
type="file"
|
||||
accept={slot.accept}
|
||||
disabled={Boolean(busySlot)}
|
||||
onChange={(e) => upload(slot.id, e.target.files?.[0])}
|
||||
className="sans"
|
||||
style={{ fontSize: '0.74rem', maxWidth: 240 }}
|
||||
aria-label={`Upload a ${slot.label.toLowerCase()}`}
|
||||
/>
|
||||
<span className="sans dim" style={{ fontSize: '0.7rem' }}>
|
||||
max {slot.limit}
|
||||
</span>
|
||||
{overridden && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => clear(slot.id)}
|
||||
disabled={Boolean(busySlot)}
|
||||
className="sans"
|
||||
title={`Go back to ${slot.envVar}`}
|
||||
style={linkBtn}
|
||||
>
|
||||
{busySlot === slot.id ? 'working…' : 'clear'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{error && (
|
||||
<span className="sans" style={{ display: 'block', marginTop: 8, color: '#d98b84', fontSize: '0.85rem' }}>
|
||||
{error}
|
||||
</span>
|
||||
)}
|
||||
<span className="sans dim" style={{ display: 'block', marginTop: 8, fontSize: '0.76rem' }}>
|
||||
Uploads apply as soon as they finish — there is nothing to save here. The footer’s “powered by
|
||||
Runic Gateway” mark is the project’s badge, not this instance’s, and never changes.
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const linkBtn = {
|
||||
border: 'none',
|
||||
background: 'transparent',
|
||||
color: 'var(--accent)',
|
||||
fontSize: '0.72rem',
|
||||
cursor: 'pointer',
|
||||
padding: 0,
|
||||
}
|
||||
545
client/src/routes/admin/views/NavEditor.jsx
Normal file
545
client/src/routes/admin/views/NavEditor.jsx
Normal file
@@ -0,0 +1,545 @@
|
||||
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, buildPublicNav, buildPublicNavOverrides } from '../../../lib/navOverrides.js'
|
||||
import PublicNavTree from './PublicNavTree.jsx'
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* One editable nav row, shared by all three tabs.
|
||||
*
|
||||
* The destination control is generic because the two navs that have one mean
|
||||
* different things by it: the admin sidebar moves rows between the four coded
|
||||
* sections, the public header between admin-created dropdowns. Both are "pick a
|
||||
* container", so both get one `<select>` rather than cross-container dragging —
|
||||
* which is a lot of interaction surface for something an admin does once.
|
||||
*
|
||||
* @param {Array<{value: string, label: string}>} [destinations] omit for a nav
|
||||
* with no containers (the player portal)
|
||||
* @param {() => void} [onDelete] only an admin-authored link can be deleted;
|
||||
* a coded row is hidden, never removed
|
||||
*/
|
||||
export function Row({ row, id, destinations, destination, onDestination, onChange, onDelete }) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id })
|
||||
const renamed = row.defaultLabel !== undefined && 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 || row.to}
|
||||
maxLength={64}
|
||||
onChange={(e) => onChange({ ...row, label: e.target.value })}
|
||||
aria-label={`Label for ${row.defaultLabel || row.to}`}
|
||||
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>
|
||||
)}
|
||||
{destinations && destinations.length > 0 && (
|
||||
<select
|
||||
className="select"
|
||||
value={destination ?? ''}
|
||||
onChange={(e) => onDestination(e.target.value || null)}
|
||||
aria-label={`Section for ${row.defaultLabel || row.to}`}
|
||||
style={{ flex: '0 0 auto', width: 130, padding: '4px 6px', fontSize: '0.76rem' }}
|
||||
>
|
||||
{destinations.map((d) => (
|
||||
<option key={d.value} value={d.value}>
|
||||
{d.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
{onDelete && (
|
||||
<button
|
||||
type="button"
|
||||
className="sans"
|
||||
title="Remove this link"
|
||||
onClick={onDelete}
|
||||
style={{
|
||||
border: '1px solid var(--line)',
|
||||
borderRadius: 'var(--radius-input)',
|
||||
background: 'transparent',
|
||||
color: 'var(--muted)',
|
||||
cursor: 'pointer',
|
||||
padding: '4px 8px',
|
||||
fontSize: '0.76rem',
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
{/* A coded row is hidden, never removed — the route still exists. An
|
||||
admin-authored link is the opposite: there is nothing to fall back to,
|
||||
so it is deleted instead (the × above). */}
|
||||
{!onDelete && (
|
||||
<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.
|
||||
// The nav as coded, unfiltered. The palette below is what this admin may EDIT;
|
||||
// this is what still EXISTS, and the two are different questions. Saving needs
|
||||
// both: an entry for a row their palette filtered out must be carried through
|
||||
// rather than reset, and only an entry for a route the code no longer declares
|
||||
// at all should be dropped.
|
||||
const fullNavs = { nav_public: PUBLIC_NAV, nav_admin: ADMIN_NAV, nav_player: PLAYER_NAV }
|
||||
|
||||
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])
|
||||
// The public header is a tree (sections are entries in the top-level
|
||||
// order); the other two are the fixed-frame grouped/flat shape.
|
||||
next[key] =
|
||||
key === 'nav_public'
|
||||
? { stored, hasRow: Boolean(all[key]), tree: buildPublicNav(palettes[key], stored, { keepHidden: true }) }
|
||||
: { 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 isPublic = tab === 'nav_public'
|
||||
const groupTitles = isPublic ? [] : 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(
|
||||
(!isPublic && Array.isArray(palettes[tab]) && palettes[tab][0]?.items
|
||||
? palettes[tab].flatMap((g) => g.items.map((i) => [i.to, g.title ?? null]))
|
||||
: []),
|
||||
)
|
||||
// The admin sidebar can only move a row between the four coded sections, and
|
||||
// "(no section)" only for a row coded into an untitled one — for anything else
|
||||
// it is a move an override cannot express (§6.4), so offering it would
|
||||
// silently do nothing.
|
||||
const groupDestinations = (baseGroup) => [
|
||||
...(baseGroup === null ? [{ value: '', label: '(no section)' }] : []),
|
||||
...groupTitles.map((t) => ({ value: t, label: t })),
|
||||
]
|
||||
|
||||
function mutate(updater) {
|
||||
setState((s) => ({ ...s, [tab]: { ...s[tab], groups: updater(s[tab].groups) } }))
|
||||
setDirty((d) => ({ ...d, [tab]: true }))
|
||||
setSaved('')
|
||||
}
|
||||
|
||||
function setTree(tree) {
|
||||
setState((s) => ({ ...s, [tab]: { ...s[tab], tree } }))
|
||||
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 = isPublic
|
||||
? buildPublicNavOverrides(current.tree, fullNavs[tab], current.stored)
|
||||
: buildNavOverrides(current.groups, fullNavs[tab], current.stored)
|
||||
// A wrapper with an empty `items` and no sections/links says nothing
|
||||
// either, so "empty" is about the whole value, not just its key count.
|
||||
const empty =
|
||||
Object.keys(overrides).length === 0 ||
|
||||
(overrides.items !== undefined &&
|
||||
Object.keys(overrides.items).length === 0 &&
|
||||
!overrides.sections?.length &&
|
||||
!overrides.links?.length)
|
||||
// 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]: isPublic
|
||||
? { stored: null, hasRow: false, tree: buildPublicNav(palettes[tab], null, { keepHidden: true }) }
|
||||
: { 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 ───────────────────────────────────────────────── */}
|
||||
{/* The public header gets its own editor: a section there is an entry in
|
||||
the top-level order that an admin created, not a fixed frame the code
|
||||
declares, so it is a tree rather than a list of groups. */}
|
||||
{isPublic ? (
|
||||
<PublicNavTree tree={current.tree} onChange={setTree} />
|
||||
) : (
|
||||
<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}
|
||||
id={row.to}
|
||||
row={row}
|
||||
destinations={groupTitles.length > 0 ? groupDestinations(baseGroups.get(row.to) ?? null) : null}
|
||||
destination={group.title ?? ''}
|
||||
onDestination={(value) => onMoveGroup(row.to, value)}
|
||||
onChange={onRowChange}
|
||||
/>
|
||||
))}
|
||||
{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>
|
||||
)
|
||||
}
|
||||
310
client/src/routes/admin/views/PublicNavTree.jsx
Normal file
310
client/src/routes/admin/views/PublicNavTree.jsx
Normal file
@@ -0,0 +1,310 @@
|
||||
import { 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 Modal from '../../../components/Modal.jsx'
|
||||
import { Row } from './NavEditor.jsx'
|
||||
|
||||
// The Public tab of Admin → Navigation (THEMING_AND_NAV.md §7, Phase 10).
|
||||
//
|
||||
// The public header is the one nav an admin can restructure rather than only
|
||||
// reorder, so it needs its own editor: a **section is itself an entry in the
|
||||
// top-level order**, which the fixed coded sections of the admin sidebar never
|
||||
// are. That is the whole reason this is not the grouped editor with a different
|
||||
// label — there, groups are a fixed frame and only membership moves.
|
||||
//
|
||||
// The tree is `[{kind: 'item' | 'link' | 'section', ...}]`, one level deep, and
|
||||
// comes from the same `buildPublicNav` the header renders, so what an admin
|
||||
// drags is what visitors get.
|
||||
|
||||
const uid = (prefix) => `${prefix}_${Math.random().toString(36).slice(2, 10)}`
|
||||
|
||||
// A path on this site, matching what the server will accept. Checked here so the
|
||||
// admin gets the message while the field is in front of them; the server's 400
|
||||
// stays the backstop, not the first feedback.
|
||||
export function badLinkPath(value) {
|
||||
const v = (value || '').trim()
|
||||
if (!v) return 'Enter a path.'
|
||||
if (/^[a-z][a-z0-9+.-]*:/i.test(v) || v.startsWith('//')) {
|
||||
return 'Links must point somewhere on this site — start with “/”.'
|
||||
}
|
||||
if (!v.startsWith('/')) return 'Start the path with “/”, for example /wiki/new-player-guide.'
|
||||
if (/[\s<>"'\\]/.test(v)) return 'A path cannot contain spaces or quotes.'
|
||||
if (v.length > 128) return 'That path is too long.'
|
||||
return null
|
||||
}
|
||||
|
||||
function SectionCard({ section, index, children, onChange, onDelete }) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: section.id })
|
||||
return (
|
||||
<li
|
||||
ref={setNodeRef}
|
||||
style={{
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
listStyle: 'none',
|
||||
border: '1px solid var(--line)',
|
||||
borderRadius: 'var(--radius-card)',
|
||||
background: isDragging ? 'var(--blue)' : 'transparent',
|
||||
padding: 10,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<button
|
||||
type="button"
|
||||
className="sans"
|
||||
aria-label={`Reorder ${section.label}`}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
style={{ border: 'none', background: 'transparent', color: 'var(--dim)', cursor: '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>
|
||||
<input
|
||||
className="input"
|
||||
value={section.label}
|
||||
maxLength={64}
|
||||
placeholder="Section name"
|
||||
onChange={(e) => onChange({ ...section, label: e.target.value })}
|
||||
aria-label={`Name for section ${index + 1}`}
|
||||
style={{ flex: '1 1 auto', minWidth: 120, padding: '5px 8px', fontSize: '0.84rem', fontWeight: 600 }}
|
||||
/>
|
||||
<span className="sans dim" style={{ fontSize: '0.7rem' }}>dropdown</span>
|
||||
<button
|
||||
type="button"
|
||||
className="sans"
|
||||
title="Delete this section — the entries inside move back out, they are not removed"
|
||||
onClick={onDelete}
|
||||
style={{
|
||||
border: '1px solid var(--line)',
|
||||
borderRadius: 'var(--radius-input)',
|
||||
background: 'transparent',
|
||||
color: 'var(--muted)',
|
||||
cursor: 'pointer',
|
||||
padding: '4px 8px',
|
||||
fontSize: '0.76rem',
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
{children}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
export default function PublicNavTree({ tree, onChange }) {
|
||||
const [adding, setAdding] = useState(null) // {label, to, error} while the modal is open
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
|
||||
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
|
||||
)
|
||||
|
||||
const sections = tree.filter((n) => n.kind === 'section')
|
||||
const destinations = [{ value: '', label: 'Top level' }, ...sections.map((s) => ({ value: s.id, label: s.label || 'Section' }))]
|
||||
const keyOf = (node) => (node.kind === 'item' ? node.to : node.id)
|
||||
|
||||
// Every mutation rebuilds the tree; there is no partial in-place editing, which
|
||||
// keeps "what will be saved" exactly "what is on screen".
|
||||
const replace = (nextTree) => onChange(nextTree)
|
||||
|
||||
const updateNode = (key, next) =>
|
||||
replace(
|
||||
tree.map((node) => {
|
||||
if (keyOf(node) === key) return next
|
||||
if (node.kind !== 'section') return node
|
||||
return { ...node, items: node.items.map((child) => (keyOf(child) === key ? next : child)) }
|
||||
}),
|
||||
)
|
||||
|
||||
// Moving between containers is the dropdown, not a drag. The entry lands at the
|
||||
// end of its destination, where it is visible and can then be dragged home.
|
||||
const moveTo = (key, sectionId) => {
|
||||
let moving = null
|
||||
const stripped = tree
|
||||
.map((node) => {
|
||||
if (node.kind === 'section') {
|
||||
const items = node.items.filter((child) => {
|
||||
if (keyOf(child) !== key) return true
|
||||
moving = child
|
||||
return false
|
||||
})
|
||||
return { ...node, items }
|
||||
}
|
||||
if (keyOf(node) === key) {
|
||||
moving = node
|
||||
return null
|
||||
}
|
||||
return node
|
||||
})
|
||||
.filter(Boolean)
|
||||
if (!moving) return
|
||||
if (!sectionId) return replace([...stripped, moving])
|
||||
return replace(
|
||||
stripped.map((node) => (node.kind === 'section' && node.id === sectionId ? { ...node, items: [...node.items, moving] } : node)),
|
||||
)
|
||||
}
|
||||
|
||||
const addSection = () => replace([...tree, { kind: 'section', id: uid('sec'), label: 'New section', items: [] }])
|
||||
|
||||
// Deleting a section must NOT delete what is inside it: those are coded pages
|
||||
// and the admin's own links, and losing them to a mis-click would be the one
|
||||
// destructive act this screen could commit. They move back to the top level.
|
||||
const deleteSection = (id) => {
|
||||
const section = tree.find((n) => n.kind === 'section' && n.id === id)
|
||||
if (!section) return
|
||||
replace([...tree.filter((n) => keyOf(n) !== id), ...(section.items || [])])
|
||||
}
|
||||
|
||||
const deleteLink = (id) =>
|
||||
replace(
|
||||
tree
|
||||
.filter((n) => keyOf(n) !== id)
|
||||
.map((n) => (n.kind === 'section' ? { ...n, items: n.items.filter((c) => keyOf(c) !== id) } : n)),
|
||||
)
|
||||
|
||||
const submitLink = () => {
|
||||
const error = badLinkPath(adding.to)
|
||||
if (error) return setAdding({ ...adding, error })
|
||||
const label = adding.label.trim()
|
||||
if (!label) return setAdding({ ...adding, error: 'Give the link a name.' })
|
||||
replace([...tree, { kind: 'link', id: uid('lnk'), label, to: adding.to.trim() }])
|
||||
return setAdding(null)
|
||||
}
|
||||
|
||||
const onDragEnd = (containerId) => (event) => {
|
||||
const { active, over } = event
|
||||
if (!over || active.id === over.id) return
|
||||
if (containerId === null) {
|
||||
const from = tree.findIndex((n) => keyOf(n) === active.id)
|
||||
const to = tree.findIndex((n) => keyOf(n) === over.id)
|
||||
if (from < 0 || to < 0) return
|
||||
return replace(arrayMove(tree, from, to))
|
||||
}
|
||||
return replace(
|
||||
tree.map((node) => {
|
||||
if (node.kind !== 'section' || node.id !== containerId) return node
|
||||
const from = node.items.findIndex((c) => keyOf(c) === active.id)
|
||||
const to = node.items.findIndex((c) => keyOf(c) === over.id)
|
||||
if (from < 0 || to < 0) return node
|
||||
return { ...node, items: arrayMove(node.items, from, to) }
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const renderRow = (node, sectionId) => (
|
||||
<Row
|
||||
key={keyOf(node)}
|
||||
id={keyOf(node)}
|
||||
row={node}
|
||||
destinations={destinations}
|
||||
destination={sectionId ?? ''}
|
||||
onDestination={(value) => moveTo(keyOf(node), value)}
|
||||
onChange={(next) => updateNode(keyOf(node), next)}
|
||||
onDelete={node.kind === 'link' ? () => deleteLink(node.id) : undefined}
|
||||
/>
|
||||
)
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={onDragEnd(null)}>
|
||||
<SortableContext items={tree.map(keyOf)} strategy={verticalListSortingStrategy}>
|
||||
<ul style={{ display: 'flex', flexDirection: 'column', gap: 6, margin: 0, padding: 0 }}>
|
||||
{tree.map((node, index) =>
|
||||
node.kind === 'section' ? (
|
||||
<SectionCard
|
||||
key={node.id}
|
||||
section={node}
|
||||
index={index}
|
||||
onChange={(next) => updateNode(node.id, next)}
|
||||
onDelete={() => deleteSection(node.id)}
|
||||
>
|
||||
{/* A nested context, so a drag inside a dropdown reorders that
|
||||
dropdown rather than escaping into the header. */}
|
||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={onDragEnd(node.id)}>
|
||||
<SortableContext items={(node.items || []).map(keyOf)} strategy={verticalListSortingStrategy}>
|
||||
<ul style={{ display: 'flex', flexDirection: 'column', gap: 6, margin: '10px 0 0', padding: '0 0 0 22px' }}>
|
||||
{(node.items || []).map((child) => renderRow(child, node.id))}
|
||||
{(node.items || []).length === 0 && (
|
||||
<li className="sans dim" style={{ fontSize: '0.76rem', listStyle: 'none', padding: '4px 2px' }}>
|
||||
Empty — an empty dropdown is not shown on the site.
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
</SectionCard>
|
||||
) : (
|
||||
renderRow(node, null)
|
||||
),
|
||||
)}
|
||||
</ul>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
|
||||
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap' }}>
|
||||
<button type="button" className="pill" onClick={addSection}>
|
||||
+ Add dropdown section
|
||||
</button>
|
||||
<button type="button" className="pill" onClick={() => setAdding({ label: '', to: '', error: null })}>
|
||||
+ Add link
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{adding && (
|
||||
<Modal
|
||||
title="Add a link"
|
||||
onClose={() => setAdding(null)}
|
||||
width={480}
|
||||
footer={
|
||||
<>
|
||||
<button className="pill" onClick={() => setAdding(null)}>Cancel</button>
|
||||
<button className="btn btn-primary btn-sq" onClick={submitLink}>Add link</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||
<label style={{ display: 'block' }}>
|
||||
<span className="field-label">Name</span>
|
||||
<input
|
||||
className="input"
|
||||
value={adding.label}
|
||||
maxLength={64}
|
||||
placeholder="Player Guide"
|
||||
onChange={(e) => setAdding({ ...adding, label: e.target.value, error: null })}
|
||||
/>
|
||||
</label>
|
||||
<label style={{ display: 'block' }}>
|
||||
<span className="field-label">Path on this site</span>
|
||||
<input
|
||||
className="input"
|
||||
value={adding.to}
|
||||
maxLength={128}
|
||||
placeholder="/wiki/new-player-guide"
|
||||
onChange={(e) => setAdding({ ...adding, to: e.target.value, error: null })}
|
||||
/>
|
||||
</label>
|
||||
<p className="sans dim" style={{ margin: 0, fontSize: '0.76rem', lineHeight: 1.7 }}>
|
||||
Links point somewhere on this site — a wiki page, a custom page, any section of the site.
|
||||
They are not gated: the page itself still decides who may open it, so a link to something
|
||||
restricted behaves exactly as typing its address would.
|
||||
</p>
|
||||
{adding.error && <span className="sans" style={{ color: '#d98b84', fontSize: '0.85rem' }}>{adding.error}</span>}
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +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
|
||||
@@ -30,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 },
|
||||
@@ -60,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 =
|
||||
@@ -86,6 +96,7 @@ export default function PlayerPortalLayout() {
|
||||
}}
|
||||
>
|
||||
<div style={{ padding: '22px 22px 18px', borderBottom: '1px solid var(--line-soft)', display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<BrandLogo height={24} />
|
||||
<MoonDot />
|
||||
<div>
|
||||
<div className="display" style={{ fontSize: '1.02rem', color: 'var(--head)', letterSpacing: '0.03em' }}>
|
||||
@@ -98,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,5 +1,6 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import MoonDot from '../../components/MoonDot.jsx'
|
||||
import BrandLogo from '../../components/BrandLogo.jsx'
|
||||
import { useSite } from '../../contexts/SiteContext.jsx'
|
||||
|
||||
// Centered card layout shared by the player login / register pages. `subtitle`
|
||||
@@ -25,6 +26,10 @@ export default function PlayerShell({ subtitle, children, footer }) {
|
||||
<div style={{ width: '100%', maxWidth: 400 }}>
|
||||
<div style={{ textAlign: 'center', marginBottom: 26 }}>
|
||||
<div style={{ marginBottom: 14 }}>
|
||||
{/* Stacked above the moon rather than beside it: this layout is
|
||||
centered text, and a flex row here would change the block's
|
||||
height on instances with no logo. */}
|
||||
<BrandLogo height={34} style={{ margin: '0 auto 12px' }} />
|
||||
<MoonDot size={15} glow={0.55} />
|
||||
</div>
|
||||
<h1 className="display" style={{ margin: 0, fontSize: '1.7rem', letterSpacing: '0.04em', color: 'var(--head)' }}>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import MoonDot from '../../components/MoonDot.jsx'
|
||||
import BrandLogo from '../../components/BrandLogo.jsx'
|
||||
import { useSite } from '../../contexts/SiteContext.jsx'
|
||||
|
||||
export default function Maintenance() {
|
||||
@@ -28,6 +29,7 @@ export default function Maintenance() {
|
||||
>
|
||||
<div style={{ maxWidth: 640, textShadow: '0 2px 22px rgba(0,0,0,0.85)' }}>
|
||||
<div style={{ marginBottom: 26 }}>
|
||||
<BrandLogo height={40} style={{ margin: '0 auto 16px' }} />
|
||||
<MoonDot size={18} glow={0.6} />
|
||||
</div>
|
||||
<p className="eyebrow" style={{ color: '#c2d2e6', letterSpacing: '0.24em' }}>
|
||||
|
||||
@@ -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,12 +184,20 @@ 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);
|
||||
font-family: var(--sans);
|
||||
font-size: 0.86rem;
|
||||
/* Stated, not inherited. A <button class="pill"> would otherwise take the UA
|
||||
stylesheet's `line-height: normal` — form controls do not inherit it from
|
||||
body — and come out ~7px shorter than an <a class="pill"> beside it. Every
|
||||
other property here is already explicit for the same reason; this was the
|
||||
one gap, and it only became visible once the public header put a button
|
||||
pill (a dropdown trigger) on the same row as the link pills. Matches
|
||||
body's 1.6, so no link pill changes. */
|
||||
line-height: 1.6;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, border-color 0.15s, color 0.15s;
|
||||
@@ -186,7 +210,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 +238,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 +254,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 +336,7 @@ button[disabled] {
|
||||
}
|
||||
.prose img {
|
||||
max-width: 100%;
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius-input);
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
|
||||
@@ -320,7 +344,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 +431,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 +473,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 +518,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 +556,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 +627,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 +804,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 +887,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 +905,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 +943,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 +952,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 +984,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 +994,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 +1106,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;
|
||||
|
||||
544
client/test/navOverrides.test.js
Normal file
544
client/test/navOverrides.test.js
Normal file
@@ -0,0 +1,544 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import {
|
||||
applyNavOverrides,
|
||||
buildNavRows,
|
||||
buildNavOverrides,
|
||||
buildPublicNav,
|
||||
pruneNav,
|
||||
buildPublicNavOverrides,
|
||||
} 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' } }), [])
|
||||
})
|
||||
|
||||
// ── 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), {})
|
||||
})
|
||||
|
||||
// ── The public header: sections and added links (phase 10) ────────────────
|
||||
//
|
||||
// The one nav an admin can restructure rather than only reorder. The invariant
|
||||
// that has to survive is §7's, in its narrower form: a CODED entry still cannot
|
||||
// have its `to` or `feature` touched, and everything that can name an arbitrary
|
||||
// path lives in `links`, where the path rule applies.
|
||||
|
||||
const PUB = [
|
||||
{ label: 'Home', to: '/', end: true },
|
||||
{ label: 'News', to: '/site/news' },
|
||||
{ label: 'Champions', to: '/site/champs', feature: 'champs' },
|
||||
{ label: 'Guilds', to: '/site/guilds', feature: 'guilds' },
|
||||
{ label: 'About', to: '/site/about' },
|
||||
]
|
||||
const shape = (tree) =>
|
||||
tree.map((n) => (n.kind === 'section' ? { [n.label]: n.items.map((i) => i.label) } : n.label))
|
||||
|
||||
test('no override yields the coded header, in code order', () => {
|
||||
assert.deepEqual(shape(buildPublicNav(PUB, null)), ['Home', 'News', 'Champions', 'Guilds', 'About'])
|
||||
assert.deepEqual(shape(buildPublicNav(PUB, {})), ['Home', 'News', 'Champions', 'Guilds', 'About'])
|
||||
})
|
||||
|
||||
test('a phase 6-8 bare map still reads as the items map', () => {
|
||||
// Nothing has shipped, but a row written during review must not become
|
||||
// unreadable just because the wrapper arrived.
|
||||
assert.deepEqual(shape(buildPublicNav(PUB, { '/site/news': { label: 'Announcements' } })), [
|
||||
'Home',
|
||||
'Announcements',
|
||||
'Champions',
|
||||
'Guilds',
|
||||
'About',
|
||||
])
|
||||
})
|
||||
|
||||
const SECTIONED = {
|
||||
items: { '/site/champs': { section: 'sec_aaaa', order: 0 }, '/site/guilds': { section: 'sec_aaaa', order: 1 } },
|
||||
sections: [{ id: 'sec_aaaa', label: 'The World', order: 2 }],
|
||||
links: [{ id: 'lnk_bbbb', label: 'Guide', to: '/wiki/new-player-guide', section: 'sec_aaaa', order: 2 }],
|
||||
}
|
||||
|
||||
test('a section collects its members and sits in the top-level order', () => {
|
||||
assert.deepEqual(shape(buildPublicNav(PUB, SECTIONED)), [
|
||||
'Home',
|
||||
'News',
|
||||
{ 'The World': ['Champions', 'Guilds', 'Guide'] },
|
||||
'About',
|
||||
])
|
||||
})
|
||||
|
||||
test('an added link is kept apart from the coded items', () => {
|
||||
const tree = buildPublicNav(PUB, SECTIONED)
|
||||
const link = tree.find((n) => n.kind === 'section').items.find((i) => i.kind === 'link')
|
||||
assert.equal(link.to, '/wiki/new-player-guide')
|
||||
assert.equal(link.id, 'lnk_bbbb')
|
||||
// It carries no gate of its own — that is the documented contract, and the
|
||||
// page behind it is what actually enforces access.
|
||||
assert.equal(link.feature, undefined)
|
||||
assert.equal(link.roles, undefined)
|
||||
})
|
||||
|
||||
test('an off-origin link is dropped rather than rendered', () => {
|
||||
for (const to of ['https://evil.example', '//evil.example/x', 'javascript:alert(1)', '/x y', '/a"b']) {
|
||||
const tree = buildPublicNav(PUB, { items: {}, links: [{ id: 'lnk_bbbb', label: 'Bad', to }] })
|
||||
assert.equal(
|
||||
tree.some((n) => n.kind === 'link'),
|
||||
false,
|
||||
`${to} should be dropped`,
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
test('an item naming a section that does not exist stays at the top level', () => {
|
||||
const tree = buildPublicNav(PUB, { items: { '/site/champs': { section: 'sec_gone' } } })
|
||||
assert.deepEqual(shape(tree), ['Home', 'News', 'Champions', 'Guilds', 'About'])
|
||||
})
|
||||
|
||||
test('an override still cannot introduce a coded route', () => {
|
||||
const tree = buildPublicNav(PUB, { items: { '/site/secret': { label: 'Secret' } } })
|
||||
assert.equal(
|
||||
tree.some((n) => n.to === '/site/secret'),
|
||||
false,
|
||||
)
|
||||
})
|
||||
|
||||
test('hidden entries are dropped for the site and kept for the editor', () => {
|
||||
const overrides = { items: { '/site/news': { hidden: true } } }
|
||||
assert.equal(shape(buildPublicNav(PUB, overrides)).includes('News'), false)
|
||||
const rows = buildPublicNav(PUB, overrides, { keepHidden: true })
|
||||
assert.equal(rows.find((n) => n.to === '/site/news').hidden, true)
|
||||
})
|
||||
|
||||
// ── pruneNav: the empty dropdown ──────────────────────────────────────────
|
||||
|
||||
test('a section keeps the entries the viewer may see', () => {
|
||||
const tree = buildPublicNav(PUB, SECTIONED)
|
||||
const out = pruneNav(tree, (i) => i.feature !== 'guilds')
|
||||
assert.deepEqual(shape(out), ['Home', 'News', { 'The World': ['Champions', 'Guide'] }, 'About'])
|
||||
})
|
||||
|
||||
test('a section whose every entry is gated out does not render at all', () => {
|
||||
// The case that matters: a dropdown that opens onto nothing is worse than no
|
||||
// dropdown, and shard visibility can empty one at any time.
|
||||
const overrides = {
|
||||
items: { '/site/champs': { section: 'sec_aaaa' }, '/site/guilds': { section: 'sec_aaaa' } },
|
||||
sections: [{ id: 'sec_aaaa', label: 'The World' }],
|
||||
}
|
||||
const tree = buildPublicNav(PUB, overrides)
|
||||
assert.deepEqual(shape(pruneNav(tree, () => true)), [
|
||||
'Home',
|
||||
'News',
|
||||
'About',
|
||||
{ 'The World': ['Champions', 'Guilds'] },
|
||||
])
|
||||
assert.deepEqual(shape(pruneNav(tree, (i) => !i.feature)), ['Home', 'News', 'About'])
|
||||
})
|
||||
|
||||
test('an added link is never pruned — it carries no gate', () => {
|
||||
const tree = buildPublicNav(PUB, { items: {}, links: [{ id: 'lnk_bbbb', label: 'Guide', to: '/wiki/g' }] })
|
||||
assert.equal(
|
||||
pruneNav(tree, () => false).some((n) => n.kind === 'link'),
|
||||
true,
|
||||
)
|
||||
})
|
||||
|
||||
// ── The editor round trip ─────────────────────────────────────────────────
|
||||
|
||||
test('an untouched public editor saves nothing', () => {
|
||||
assert.deepEqual(buildPublicNavOverrides(buildPublicNav(PUB, null, { keepHidden: true }), PUB), {})
|
||||
})
|
||||
|
||||
test('a nav with no sections still stores the plain items map', () => {
|
||||
// Adding this feature changed nothing for a nav that does not use it.
|
||||
const tree = buildPublicNav(PUB, null, { keepHidden: true })
|
||||
tree[1].label = 'Announcements'
|
||||
const out = buildPublicNavOverrides(tree, PUB)
|
||||
assert.deepEqual(out, { '/site/news': { label: 'Announcements' } })
|
||||
assert.equal(out.items, undefined)
|
||||
})
|
||||
|
||||
test('the sectioned round trip is stable and renders what the editor showed', () => {
|
||||
const tree = buildPublicNav(PUB, SECTIONED, { keepHidden: true })
|
||||
const first = buildPublicNavOverrides(tree, PUB)
|
||||
const second = buildPublicNavOverrides(buildPublicNav(PUB, first, { keepHidden: true }), PUB)
|
||||
assert.deepEqual(second, first)
|
||||
assert.deepEqual(shape(buildPublicNav(PUB, first)), [
|
||||
'Home',
|
||||
'News',
|
||||
{ 'The World': ['Champions', 'Guilds', 'Guide'] },
|
||||
'About',
|
||||
])
|
||||
})
|
||||
|
||||
test('deleting a section returns its entries to the top level, never deletes them', () => {
|
||||
// The one destructive act this screen could commit, so it is locked here.
|
||||
const tree = buildPublicNav(PUB, SECTIONED, { keepHidden: true })
|
||||
const section = tree.find((n) => n.kind === 'section')
|
||||
const flattened = [...tree.filter((n) => n.kind !== 'section'), ...section.items]
|
||||
const out = buildPublicNavOverrides(flattened, PUB)
|
||||
const rendered = buildPublicNav(PUB, out)
|
||||
assert.equal(
|
||||
rendered.some((n) => n.kind === 'section'),
|
||||
false,
|
||||
)
|
||||
assert.deepEqual(shape(rendered), ['Home', 'News', 'About', 'Champions', 'Guilds', 'Guide'])
|
||||
})
|
||||
|
||||
test('an override for a feature-gated item outside the palette survives a save', () => {
|
||||
// §8.1 filters the editor to what this admin can see. The rows come from their
|
||||
// palette, but membership is judged against the FULL coded nav — otherwise a
|
||||
// row a shard feature hid from them is indistinguishable from a deleted route,
|
||||
// and their save would silently reset it.
|
||||
const palette = PUB.filter((i) => i.feature !== 'champs')
|
||||
// The editor was opened on a nav that only hides champs — which their palette
|
||||
// does not show them. `stored` additionally carries a label for a row they CAN
|
||||
// see, and which they have since reset.
|
||||
const tree = buildPublicNav(palette, { items: { '/site/champs': { hidden: true } } }, { keepHidden: true })
|
||||
const stored = { items: { '/site/champs': { hidden: true }, '/site/news': { label: 'Old' } } }
|
||||
const out = buildPublicNavOverrides(tree, PUB, stored)
|
||||
assert.deepEqual(out['/site/champs'], { hidden: true }, 'carried: they could not see it')
|
||||
assert.equal(out['/site/news'], undefined, 'not carried: their row is the authority for what they can see')
|
||||
})
|
||||
|
||||
test('a stored entry for a route the code no longer declares is dropped on save', () => {
|
||||
const tree = buildPublicNav(PUB, null, { keepHidden: true })
|
||||
assert.deepEqual(buildPublicNavOverrides(tree, PUB, { items: { '/site/gone': { label: 'Ghost' } } }), {})
|
||||
})
|
||||
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('{}'), {})
|
||||
})
|
||||
100
client/test/themeVars.test.js
Normal file
100
client/test/themeVars.test.js
Normal file
@@ -0,0 +1,100 @@
|
||||
// applyThemeTokens — writing the server-resolved theme onto the document, and
|
||||
// (the part with real logic) taking back exactly what it wrote last time.
|
||||
//
|
||||
// Pure module, exercised against a fake CSSStyleDeclaration: node --test has no
|
||||
// DOM, and the function only ever needs setProperty/removeProperty.
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import { applyThemeTokens } from '../src/lib/themeVars.js'
|
||||
|
||||
// Minimal stand-in for element.style, plus a log of the calls so a test can
|
||||
// assert that a property was *removed* rather than merely absent.
|
||||
function fakeStyle() {
|
||||
const props = new Map()
|
||||
const removed = []
|
||||
return {
|
||||
props,
|
||||
removed,
|
||||
setProperty: (name, value) => props.set(name, value),
|
||||
removeProperty: (name) => {
|
||||
props.delete(name)
|
||||
removed.push(name)
|
||||
},
|
||||
get: (name) => props.get(name),
|
||||
}
|
||||
}
|
||||
|
||||
test('writes each token and reports the keys it applied', () => {
|
||||
const style = fakeStyle()
|
||||
const applied = applyThemeTokens(style, { '--accent': '#c9973f', '--bg': '#1a120b' })
|
||||
assert.equal(style.get('--accent'), '#c9973f')
|
||||
assert.equal(style.get('--bg'), '#1a120b')
|
||||
assert.deepEqual(applied.sort(), ['--accent', '--bg'])
|
||||
})
|
||||
|
||||
// The untouched-instance case: no theme block means the stylesheet's :root
|
||||
// stands and nothing is written at all.
|
||||
test('no theme writes nothing', () => {
|
||||
for (const empty of [null, undefined, {}]) {
|
||||
const style = fakeStyle()
|
||||
const applied = applyThemeTokens(style, empty)
|
||||
assert.equal(style.props.size, 0)
|
||||
assert.deepEqual(applied, [])
|
||||
}
|
||||
})
|
||||
|
||||
test('removes a token that is no longer in the theme', () => {
|
||||
const style = fakeStyle()
|
||||
const first = applyThemeTokens(style, { '--accent': '#c9973f', '--bg': '#1a120b' })
|
||||
const second = applyThemeTokens(style, { '--accent': '#c9973f' }, first)
|
||||
assert.equal(style.get('--accent'), '#c9973f')
|
||||
assert.equal(style.get('--bg'), undefined)
|
||||
assert.deepEqual(style.removed, ['--bg'])
|
||||
assert.deepEqual(second, ['--accent'])
|
||||
})
|
||||
|
||||
// "Reset to defaults" — the case that would look broken without the removal
|
||||
// half: the payload stops mentioning the variables, and the inline values have
|
||||
// to come off for :root to show through again.
|
||||
test('resetting to no theme clears everything previously applied', () => {
|
||||
const style = fakeStyle()
|
||||
const first = applyThemeTokens(style, { '--accent': '#c9973f', '--radius-card': '2px' })
|
||||
const second = applyThemeTokens(style, null, first)
|
||||
assert.equal(style.props.size, 0)
|
||||
assert.deepEqual(style.removed.sort(), ['--accent', '--radius-card'])
|
||||
assert.deepEqual(second, [])
|
||||
})
|
||||
|
||||
// Only ever clears its own keys. SiteContext writes --accent itself from
|
||||
// brand.accent, and a future feature may write others; those are not ours.
|
||||
test('never removes a property it did not apply', () => {
|
||||
const style = fakeStyle()
|
||||
style.setProperty('--accent', '#ff0000') // someone else's write
|
||||
applyThemeTokens(style, { '--bg': '#000000' }, [])
|
||||
assert.equal(style.get('--accent'), '#ff0000')
|
||||
assert.deepEqual(style.removed, [])
|
||||
})
|
||||
|
||||
test('ignores anything that is not a custom property', () => {
|
||||
const style = fakeStyle()
|
||||
const applied = applyThemeTokens(style, { background: 'url(http://evil.example/x)', '--bg': '#000000' })
|
||||
assert.equal(style.get('background'), undefined)
|
||||
assert.deepEqual(applied, ['--bg'])
|
||||
})
|
||||
|
||||
test('ignores non-string and empty values', () => {
|
||||
const style = fakeStyle()
|
||||
const applied = applyThemeTokens(style, { '--a': 4, '--b': null, '--c': '', '--d': '#fff' })
|
||||
assert.deepEqual(applied, ['--d'])
|
||||
})
|
||||
|
||||
// A stale key list must not survive a call that could not write: the next call
|
||||
// still has to know what is actually on the element.
|
||||
test('a token dropped as invalid is removed if it was applied before', () => {
|
||||
const style = fakeStyle()
|
||||
const first = applyThemeTokens(style, { '--bg': '#000000' })
|
||||
const second = applyThemeTokens(style, { '--bg': '' }, first)
|
||||
assert.equal(style.get('--bg'), undefined)
|
||||
assert.deepEqual(second, [])
|
||||
})
|
||||
@@ -616,6 +616,25 @@
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "DELETE",
|
||||
"path": "/api/v1/admin/settings/:key",
|
||||
"handlers": 2,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/settings/brand-asset/:slot",
|
||||
"handlers": 3,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth",
|
||||
"multerMiddleware"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/shard/account",
|
||||
@@ -2136,6 +2155,24 @@
|
||||
"gates": [
|
||||
"siteMode"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/settings/nav",
|
||||
"handlers": 1,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/settings/theme/options",
|
||||
"handlers": 1,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
}
|
||||
],
|
||||
"internal": [
|
||||
|
||||
@@ -249,6 +249,14 @@
|
||||
"method": "PUT",
|
||||
"path": "/api/v1/admin/settings"
|
||||
},
|
||||
{
|
||||
"method": "DELETE",
|
||||
"path": "/api/v1/admin/settings/:key"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/settings/brand-asset/:slot"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/shard/account"
|
||||
@@ -892,6 +900,14 @@
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/wiki/tags"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/settings/nav"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/settings/theme/options"
|
||||
}
|
||||
],
|
||||
"internal": [
|
||||
|
||||
@@ -16,6 +16,7 @@ const brand = require('./config/brand')
|
||||
const csp = require('./config/csp')
|
||||
const { cspReportLimiter } = require('./middleware/rateLimit')
|
||||
const createLogger = require('./utils/logger')
|
||||
const htmlShell = require('./utils/htmlShell')
|
||||
const { applyTrustProxy, trustProxyDebug } = require('./utils/trustProxy')
|
||||
const botScore = require('./middleware/botScore')
|
||||
|
||||
@@ -96,31 +97,6 @@ const htmlEscape = (s) =>
|
||||
(c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]),
|
||||
)
|
||||
|
||||
// Template the built index.html <head> with instance branding (title, meta
|
||||
// description, Open Graph/Twitter, favicon). Done once at boot from BRAND_* env,
|
||||
// so the prebuilt SPA image serves per-instance metadata without a rebuild.
|
||||
function renderIndexHtml(html) {
|
||||
const title = htmlEscape(brand.name)
|
||||
const desc = htmlEscape(brand.description)
|
||||
const tags = [
|
||||
`<meta property="og:title" content="${title}" />`,
|
||||
`<meta property="og:description" content="${desc}" />`,
|
||||
'<meta property="og:type" content="website" />',
|
||||
brand.url ? `<meta property="og:url" content="${htmlEscape(brand.url)}" />` : '',
|
||||
brand.logo ? `<meta property="og:image" content="${htmlEscape(brand.logo)}" />` : '',
|
||||
'<meta name="twitter:card" content="summary_large_image" />',
|
||||
`<meta name="twitter:title" content="${title}" />`,
|
||||
`<meta name="twitter:description" content="${desc}" />`,
|
||||
brand.favicon ? `<link rel="icon" href="${htmlEscape(brand.favicon)}" />` : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n ')
|
||||
return html
|
||||
.replace(/<title>[\s\S]*?<\/title>/i, `<title>${title}</title>`)
|
||||
.replace(/(<meta\s+name="description"\s+content=")[\s\S]*?("\s*\/?>)/i, `$1${desc}$2`)
|
||||
.replace(/<\/head>/i, ` ${tags}\n </head>`)
|
||||
}
|
||||
|
||||
// Uploaded images — always served, even during maintenance. Force nosniff so a
|
||||
// stored file is never interpreted as anything other than its declared type
|
||||
// (defense in depth alongside helmet's global X-Content-Type-Options, and in
|
||||
@@ -204,9 +180,23 @@ if (fs.existsSync(BRAND_DIR)) {
|
||||
if (fs.existsSync(path.join(CLIENT_DIST, 'index.html'))) {
|
||||
// Serve a branded copy of the index.html shell for every SPA route; assets keep
|
||||
// their own cache-friendly static handler.
|
||||
const indexHtml = renderIndexHtml(fs.readFileSync(path.join(CLIENT_DIST, 'index.html'), 'utf8'))
|
||||
//
|
||||
// The shell is templated from BRAND_* env *and* the admin's brand_assets /
|
||||
// theme_visual rows, so it is rendered lazily and cached rather than built once
|
||||
// at boot: see utils/htmlShell.js for the caching, the invalidation and why a
|
||||
// DB fault still serves a page.
|
||||
htmlShell.init(fs.readFileSync(path.join(CLIENT_DIST, 'index.html'), 'utf8'))
|
||||
app.use(express.static(CLIENT_DIST, { index: false }))
|
||||
app.get('*', (req, res) => res.type('html').send(indexHtml))
|
||||
app.get('*', async (req, res, next) => {
|
||||
// htmlShell.get() swallows a settings-read failure itself; the try is for
|
||||
// anything unforeseen, since an async handler that rejects in Express 4
|
||||
// hangs the request instead of reaching the error handler below.
|
||||
try {
|
||||
res.type('html').send(await htmlShell.get())
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
} else {
|
||||
app.get('*', (req, res) =>
|
||||
res
|
||||
|
||||
215
server/src/config/themePresets.js
Normal file
215
server/src/config/themePresets.js
Normal file
@@ -0,0 +1,215 @@
|
||||
// ── Theme presets & the closed sets an admin may choose from ───────────────
|
||||
//
|
||||
// The single authority for admin-configurable theming (docs/website/THEMING_AND_NAV.md
|
||||
// §5-§6). Everything an admin can pick is enumerated here; nothing is free text.
|
||||
//
|
||||
// Why the server owns this rather than theme.css:
|
||||
// The effective token set is resolved server-side and returned by
|
||||
// settings.getPublic() as `theme`, which the SPA writes onto the document as
|
||||
// CSS custom properties. That keeps ONE authority for the override merge
|
||||
// (:root ← preset ← custom), lets brand.accent — a cross-repo contract the
|
||||
// Android app themes itself from — report the same accent the website paints,
|
||||
// and avoids the precedence trap of `[data-theme]` blocks losing to the inline
|
||||
// `--accent` SiteContext already sets on <html>.
|
||||
//
|
||||
// theme.css's `:root` remains the default and is NOT duplicated here beyond
|
||||
// the runic-gateway preset. An instance with no `theme_visual` row gets no
|
||||
// `theme` block at all and renders from :root exactly as it does today.
|
||||
//
|
||||
// Security note: these values end up as CSS custom property values. Every one is
|
||||
// picked from a closed set (a preset id, a shortlist stack, a bounded px length,
|
||||
// a hex color) — see utils/themeResolve.js, which both the write path and the
|
||||
// read path validate through.
|
||||
|
||||
// The three color tokens that are semantic rather than decorative. They mean
|
||||
// "live" and "maintenance" and stay fixed across every preset — green is not a
|
||||
// brand choice. Deliberately absent from every preset block below.
|
||||
const FIXED_TOKENS = ['--mode-live', '--mode-maint']
|
||||
|
||||
// Full palettes. A preset must carry EVERY color token, not just the eight the
|
||||
// admin form exposes: a partial palette leaves e.g. --line and --blue at their
|
||||
// dark-blue :root values, which reads as broken on a warm background.
|
||||
//
|
||||
// --panel-grad is deliberately absent: it is derived (`linear-gradient(180deg,
|
||||
// var(--panel-a), var(--panel-b))`) and must stay derived, or a future light
|
||||
// preset silently inherits a dark gradient.
|
||||
const PRESETS = {
|
||||
// Today's :root, verbatim. Declared as a preset so that switching back to it
|
||||
// after trying another is the same code path as any other choice.
|
||||
'runic-gateway': {
|
||||
label: 'Runic Gateway',
|
||||
tokens: {
|
||||
'--bg': '#0e1318',
|
||||
'--bg-deep': '#0b0f14',
|
||||
'--panel-a': '#192231',
|
||||
'--panel-b': '#141a21',
|
||||
'--panel-flat': '#11161d',
|
||||
'--line': '#2a3544',
|
||||
'--line-soft': '#1d2733',
|
||||
'--accent': '#7f99bd',
|
||||
'--accent-bright': '#cdd9e8',
|
||||
'--ink': '#eef3f8',
|
||||
'--head': '#e6edf6',
|
||||
'--text': '#c4cdd8',
|
||||
'--muted': '#aeb8c4',
|
||||
'--dim': '#6f7d8e',
|
||||
'--blue': '#13243c',
|
||||
'--radius-pill': '999px',
|
||||
'--radius-panel': '12px',
|
||||
'--radius-card': '10px',
|
||||
'--radius-input': '8px',
|
||||
'--shadow-card': '0 14px 34px rgba(0, 0, 0, 0.3)',
|
||||
'--serif': 'Georgia, "Times New Roman", serif',
|
||||
'--display': 'Cinzel, Georgia, serif',
|
||||
'--sans': '"Helvetica Neue", Arial, sans-serif',
|
||||
},
|
||||
},
|
||||
// Flatter, cooler, sans-heavy. Reads as a SaaS dashboard, not fantasy.
|
||||
modern: {
|
||||
label: 'Modern',
|
||||
tokens: {
|
||||
'--bg': '#101114',
|
||||
'--bg-deep': '#0a0a0c',
|
||||
'--panel-a': '#1c1d22',
|
||||
'--panel-b': '#17181c',
|
||||
'--panel-flat': '#141519',
|
||||
'--line': '#2b2d34',
|
||||
'--line-soft': '#212329',
|
||||
'--accent': '#4f8ef7',
|
||||
'--accent-bright': '#a8c8ff',
|
||||
'--ink': '#f2f3f5',
|
||||
'--head': '#f7f8fa',
|
||||
'--text': '#b8bcc4',
|
||||
'--muted': '#a9aeb8',
|
||||
'--dim': '#71767f',
|
||||
'--blue': '#1b2c47',
|
||||
'--radius-pill': '8px',
|
||||
'--radius-panel': '8px',
|
||||
'--radius-card': '6px',
|
||||
'--radius-input': '6px',
|
||||
'--shadow-card': '0 8px 20px rgba(0, 0, 0, 0.25)',
|
||||
'--serif': 'Inter, Arial, sans-serif',
|
||||
'--display': "'Work Sans', Arial, sans-serif",
|
||||
'--sans': 'Inter, Arial, sans-serif',
|
||||
},
|
||||
},
|
||||
// Warmer, higher contrast, carved corners; leans into UO harder.
|
||||
fantasy: {
|
||||
label: 'Fantasy',
|
||||
tokens: {
|
||||
'--bg': '#1a120b',
|
||||
'--bg-deep': '#120c07',
|
||||
'--panel-a': '#2c1f14',
|
||||
'--panel-b': '#241a10',
|
||||
'--panel-flat': '#1f160d',
|
||||
'--line': '#4a3721',
|
||||
'--line-soft': '#33251a',
|
||||
'--accent': '#c9973f',
|
||||
'--accent-bright': '#e8c374',
|
||||
'--ink': '#f3e8d4',
|
||||
'--head': '#f7efe0',
|
||||
'--text': '#d3bfa0',
|
||||
'--muted': '#bfa985',
|
||||
'--dim': '#8a7454',
|
||||
'--blue': '#382613',
|
||||
'--radius-pill': '4px',
|
||||
'--radius-panel': '3px',
|
||||
'--radius-card': '2px',
|
||||
'--radius-input': '2px',
|
||||
'--shadow-card': '0 16px 38px rgba(0, 0, 0, 0.45)',
|
||||
'--serif': "'EB Garamond', Georgia, serif",
|
||||
'--display': 'Cinzel, Georgia, serif',
|
||||
'--sans': "'EB Garamond', Georgia, serif",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// 'custom' is a valid stored preset meaning "no preset base" — :root plus
|
||||
// whatever custom fields are set. It has no palette of its own.
|
||||
const CUSTOM_PRESET = 'custom'
|
||||
const PRESET_IDS = [...Object.keys(PRESETS), CUSTOM_PRESET]
|
||||
|
||||
// The colors the admin form exposes, mapped to their CSS token. Deliberately
|
||||
// the eight of §6.1 rather than all fifteen: the rest are supporting shades a
|
||||
// preset sets coherently but that are not worth (or safe to) hand-picking.
|
||||
const COLOR_FIELDS = {
|
||||
bg: '--bg',
|
||||
bgDeep: '--bg-deep',
|
||||
panelA: '--panel-a',
|
||||
panelB: '--panel-b',
|
||||
accent: '--accent',
|
||||
accentBright: '--accent-bright',
|
||||
ink: '--ink',
|
||||
text: '--text',
|
||||
}
|
||||
|
||||
const RADIUS_FIELDS = {
|
||||
radiusPill: '--radius-pill',
|
||||
radiusPanel: '--radius-panel',
|
||||
radiusCard: '--radius-card',
|
||||
radiusInput: '--radius-input',
|
||||
}
|
||||
|
||||
const FONT_FIELDS = {
|
||||
serif: '--serif',
|
||||
display: '--display',
|
||||
sans: '--sans',
|
||||
}
|
||||
|
||||
// The curated Google Fonts shortlist (§5.1). The dropdown's VALUE is the full
|
||||
// stack exactly as applied, so no string is ever built from admin input and no
|
||||
// Google Fonts URL is ever assembled at runtime — the combined css2? request in
|
||||
// client/index.html is static and covers all eight web families.
|
||||
//
|
||||
// One addition to §5.1's twelve: Georgia in the serif list. The shortlist as
|
||||
// drafted gave the sans role a "current default" option (Arial, byte-identical
|
||||
// to today's --sans) but left the serif role with no way back to today's
|
||||
// `Georgia, "Times New Roman", serif` short of resetting the whole theme. It
|
||||
// pulls in no web family, so §5.2's URL is unchanged.
|
||||
const FONT_OPTIONS = {
|
||||
serif: [
|
||||
{ value: "'EB Garamond', Georgia, serif", label: 'EB Garamond — strongest fantasy/historic' },
|
||||
{ value: 'Merriweather, Georgia, serif', label: 'Merriweather — excellent readability' },
|
||||
{ value: "'Playfair Display', Georgia, serif", label: 'Playfair Display — elegant/editorial' },
|
||||
{ value: "'IM Fell English', Georgia, serif", label: 'IM Fell English — old-world (no bold weight)' },
|
||||
{ value: 'Georgia, "Times New Roman", serif', label: 'Georgia — the shipped default' },
|
||||
],
|
||||
display: [
|
||||
{ value: 'Cinzel, Georgia, serif', label: 'Cinzel — current Runic Gateway identity' },
|
||||
{ value: "'Playfair Display', Georgia, serif", label: 'Playfair Display — elegant alternative' },
|
||||
{ value: "'EB Garamond', Georgia, serif", label: 'EB Garamond — softer/classic' },
|
||||
{ value: "'IM Fell English', Georgia, serif", label: 'IM Fell English — very strong fantasy (no bold weight)' },
|
||||
],
|
||||
sans: [
|
||||
{ value: 'Inter, Arial, sans-serif', label: 'Inter — default modern UI choice' },
|
||||
{ value: "'Work Sans', Arial, sans-serif", label: 'Work Sans — slightly more character' },
|
||||
{ value: "'Source Sans 3', Arial, sans-serif", label: 'Source Sans 3 — extremely readable' },
|
||||
{ value: '"Helvetica Neue", Arial, sans-serif', label: 'Arial — no webfont; the shipped default' },
|
||||
],
|
||||
}
|
||||
|
||||
// Shadow depth, as a closed set for the same reason fonts are: the stored value
|
||||
// is applied verbatim as --shadow-card.
|
||||
const SHADOW_OPTIONS = [
|
||||
{ value: 'none', label: 'None — flat' },
|
||||
{ value: '0 8px 20px rgba(0, 0, 0, 0.25)', label: 'Soft' },
|
||||
{ value: '0 14px 34px rgba(0, 0, 0, 0.3)', label: 'Default' },
|
||||
{ value: '0 18px 44px rgba(0, 0, 0, 0.45)', label: 'Deep' },
|
||||
]
|
||||
|
||||
// Corner radius is a number, not a shortlist, so it is bounded instead: an
|
||||
// integer count of px from 0 to 999 (999 being the pill).
|
||||
const RADIUS_MAX_PX = 999
|
||||
|
||||
module.exports = {
|
||||
PRESETS,
|
||||
PRESET_IDS,
|
||||
CUSTOM_PRESET,
|
||||
FIXED_TOKENS,
|
||||
COLOR_FIELDS,
|
||||
RADIUS_FIELDS,
|
||||
FONT_FIELDS,
|
||||
FONT_OPTIONS,
|
||||
SHADOW_OPTIONS,
|
||||
RADIUS_MAX_PX,
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
const settingsDb = require('./settings.db')
|
||||
const brand = require('../../config/brand')
|
||||
const { parseJsonSetting } = require('../../utils/settingsJson')
|
||||
const { resolveThemeTokens } = require('../../utils/themeResolve')
|
||||
const { resolveBrandAssets } = require('../../utils/brandAssets')
|
||||
|
||||
// Keys safe to expose on the public site.
|
||||
const PUBLIC_KEYS = [
|
||||
@@ -10,8 +13,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 +118,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)
|
||||
@@ -124,9 +150,29 @@ async function getPublic() {
|
||||
// the final say when the call is made). Lets the portal show/hide the form.
|
||||
const gsMode = GAME_SIGNUP_MODES.includes(all[GAME_SIGNUP_KEY]) ? all[GAME_SIGNUP_KEY] : 'disabled'
|
||||
out.gameAccountSignup = GAME_SIGNUP_OFFER.includes(gsMode)
|
||||
// Instance branding (BRAND_* env defaults). The two admin-editable settings —
|
||||
// site title and contact email — override the env value when set, so existing
|
||||
// installs keep their DB-configured name; everything else comes from env.
|
||||
// The effective CSS custom properties for the admin's theme, or absent when
|
||||
// no theme_visual row exists (or nothing in it was usable). The SPA writes
|
||||
// these onto <html>; absence means it writes nothing and theme.css's :root
|
||||
// stands, which is what keeps an untouched instance byte-for-byte as today.
|
||||
// Resolution — :root ← preset ← custom — happens here rather than in CSS so
|
||||
// there is one authority and brand.accent below can report the same value the
|
||||
// site actually paints. See THEMING_AND_NAV.md §6.
|
||||
const theme = resolveThemeTokens(all.theme_visual)
|
||||
if (theme) out.theme = theme
|
||||
// Uploaded brand-asset overrides (§6.3), resolved here so every consumer of
|
||||
// the brand block — the SPA, the Android app, the Discord bot — picks them up
|
||||
// through the one contract. Forgiving on read like the theme: a slot holding
|
||||
// something we would not emit as a URL is dropped and its neighbours kept.
|
||||
const brandAssets = resolveBrandAssets(parseJsonSetting(all.brand_assets))
|
||||
// Instance branding (BRAND_* env defaults). The admin-editable settings —
|
||||
// site title, contact email, and now the theme accent and uploaded assets —
|
||||
// override the env value when set, so existing installs keep their
|
||||
// DB-configured name; everything else comes from env.
|
||||
//
|
||||
// brand.accent is a CROSS-REPO CONTRACT: the Android app themes its whole
|
||||
// Material palette from it (BrandDto → RunicGatewayTheme) and the Discord bot
|
||||
// colors its embeds from it. Resolving the effective accent here is what lets
|
||||
// both track admin theming with no client change.
|
||||
out.brand = {
|
||||
name: out.site_title || brand.name,
|
||||
shortName: brand.shortName,
|
||||
@@ -134,10 +180,10 @@ async function getPublic() {
|
||||
description: brand.description,
|
||||
contactEmail: out.contact_email || brand.contactEmail,
|
||||
url: brand.url,
|
||||
accent: brand.accent,
|
||||
logo: brand.logo,
|
||||
hero: brand.hero,
|
||||
favicon: brand.favicon,
|
||||
accent: theme?.['--accent'] || brand.accent,
|
||||
logo: brandAssets.logo || brand.logo,
|
||||
hero: brandAssets.hero || brand.hero,
|
||||
favicon: brandAssets.favicon || brand.favicon,
|
||||
}
|
||||
// Push-notification relay (M7). The client-facing ntfy base URL the app's
|
||||
// embedded distributor registers its device topic against; null when push is
|
||||
@@ -155,6 +201,41 @@ async function getPublic() {
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* What the HTML shell needs, resolved exactly as getPublic() resolves it: the
|
||||
* effective favicon and logo, plus the theme token map for the boot <style>
|
||||
* block. Kept here rather than in utils/htmlShell.js so there is one authority
|
||||
* for "which asset wins", and so the shell can never disagree with the payload
|
||||
* the SPA fetches a moment later.
|
||||
*
|
||||
* Throws on a DB fault — the caller (utils/htmlShell.js) decides what a failure
|
||||
* means for the page, and for it the answer is "serve the env-only shell".
|
||||
*
|
||||
* @returns {Promise<{logo: string, favicon: string, theme: object|null}>}
|
||||
*/
|
||||
async function getShellBrand() {
|
||||
const all = await getAll()
|
||||
const assets = resolveBrandAssets(parseJsonSetting(all.brand_assets))
|
||||
return {
|
||||
logo: assets.logo || brand.logo,
|
||||
favicon: assets.favicon || brand.favicon,
|
||||
theme: resolveThemeTokens(all.theme_visual),
|
||||
}
|
||||
}
|
||||
|
||||
// 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 +250,16 @@ function publicNtfyUrl() {
|
||||
module.exports = {
|
||||
get,
|
||||
set,
|
||||
remove,
|
||||
setMany,
|
||||
getAll,
|
||||
getPublic,
|
||||
getShellBrand,
|
||||
getNav,
|
||||
getInstanceName,
|
||||
PUBLIC_KEYS,
|
||||
THEMING_KEYS,
|
||||
DELETABLE_KEYS,
|
||||
REGISTRATION_KEY,
|
||||
REGISTRATION_MODES,
|
||||
getRegistrationMode,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
const fs = require('fs')
|
||||
|
||||
const posts = require('../../../model/posts/posts.model')
|
||||
const wiki = require('../../../model/wiki/wiki.model')
|
||||
const settings = require('../../../model/settings/settings.model')
|
||||
@@ -9,6 +11,11 @@ const announceJobs = require('../../../model/announceJobs/announceJobs.model')
|
||||
const newsGump = require('../../../utils/newsGump')
|
||||
const pushDispatch = require('../../../utils/pushDispatch')
|
||||
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')
|
||||
|
||||
@@ -529,8 +536,61 @@ async function updateSettings(req, res) {
|
||||
if (typeof updates.homepage_teaser === 'string') {
|
||||
updates.homepage_teaser = cleanBody(updates.homepage_teaser)
|
||||
}
|
||||
// theme_visual is JSON whose values become CSS custom properties, so every
|
||||
// one has to come from the closed sets in config/themePresets.js. The read
|
||||
// path drops anything invalid anyway (THEMING_AND_NAV.md §4.4), but silently
|
||||
// storing a value that will never apply is a bad admin experience — reject it
|
||||
// with the offending field named instead. Accepts an object or the stringified
|
||||
// form, and stores it stringified either way, since settings.value is TEXT.
|
||||
if ('theme_visual' in updates) {
|
||||
const raw = updates.theme_visual
|
||||
const parsed = typeof raw === 'string' ? parseJsonSetting(raw) : raw
|
||||
if (typeof raw === 'string' && parsed === null) {
|
||||
return res.status(400).json({ message: 'theme_visual must be a JSON object' })
|
||||
}
|
||||
const check = validateThemeVisual(parsed)
|
||||
if (!check.ok) return res.status(400).json({ message: check.message })
|
||||
updates.theme_visual = JSON.stringify(parsed)
|
||||
}
|
||||
// brand_assets holds the only settings values written straight into HTML the
|
||||
// browser then fetches (an <img src>, a <link rel="icon">, an og:image), so
|
||||
// the accepted shape is narrow — see utils/brandAssets.js. Cleared slots are
|
||||
// dropped rather than stored as null, keeping "a field is absent" the single
|
||||
// meaning of "falls back to BRAND_* env".
|
||||
if ('brand_assets' in updates) {
|
||||
const raw = updates.brand_assets
|
||||
const parsed = typeof raw === 'string' ? parseJsonSetting(raw) : raw
|
||||
if (typeof raw === 'string' && parsed === null) {
|
||||
return res.status(400).json({ message: 'brand_assets must be a JSON object' })
|
||||
}
|
||||
const check = validateBrandAssets(parsed)
|
||||
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
|
||||
// cached per process (utils/htmlShell.js) — a write that can change it has
|
||||
// to say so, or the favicon an admin just uploaded appears only after the
|
||||
// cache's TTL.
|
||||
if ('brand_assets' in updates || 'theme_visual' in updates) htmlShell.invalidate()
|
||||
await activity.log({ req, action: 'settings.update', detail: { keys: Object.keys(updates) } })
|
||||
return res.json(await settings.getAll())
|
||||
} catch (err) {
|
||||
@@ -539,6 +599,115 @@ async function updateSettings(req, res) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Brand assets ──────────────────────────────────────────────────────
|
||||
//
|
||||
// Per-slot rules applied on top of the shared multer allowlist. The allowlist
|
||||
// itself is never widened (§9: "no second upload path with weaker validation") —
|
||||
// these only ever tighten it:
|
||||
//
|
||||
// • favicon — PNG only. .ico would mean adding a new type to MIME_EXT, and the
|
||||
// fact that the stored extension comes from that map is exactly what makes
|
||||
// the upload path safe (§4.10). Every browser this app supports takes a PNG
|
||||
// icon. Small cap: a favicon is a handful of KB.
|
||||
// • logo — a header mark next to the site title, not a page image.
|
||||
// • hero — a full-bleed background, so it keeps the shared ceiling.
|
||||
//
|
||||
// The cap is checked after multer has written the file rather than by a second
|
||||
// multer instance: one upload config, one allowlist, and the oversized file is
|
||||
// unlinked before we answer.
|
||||
const ASSET_RULES = {
|
||||
logo: { maxBytes: 1024 * 1024, mimetypes: null, label: 'Logo' },
|
||||
hero: { maxBytes: 8 * 1024 * 1024, mimetypes: null, label: 'Hero image' },
|
||||
favicon: { maxBytes: 512 * 1024, mimetypes: ['image/png'], label: 'Favicon' },
|
||||
}
|
||||
|
||||
const prettyBytes = (n) => (n >= 1024 * 1024 ? `${Math.round(n / (1024 * 1024))} MB` : `${Math.round(n / 1024)} KB`)
|
||||
|
||||
// Best-effort cleanup of a file we have decided not to keep. A failure here is
|
||||
// a stray file in /uploads, not something the caller can act on.
|
||||
async function discardUpload(file) {
|
||||
try {
|
||||
await fs.promises.unlink(file.path)
|
||||
} catch (err) {
|
||||
log.error('discardUpload', err)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /admin/settings/brand-asset/:slot — upload one brand asset and point the
|
||||
* brand_assets row at it in the same call.
|
||||
*
|
||||
* One call rather than "upload, then PUT the settings row": a half-completed
|
||||
* save would otherwise leave a file in /uploads that nothing references, and the
|
||||
* per-slot rules above need the slot at upload time anyway. Admin-only, matching
|
||||
* the gate on the settings it writes — POST /admin/uploads is reachable by
|
||||
* editors, who have no business changing the site's identity.
|
||||
*/
|
||||
async function uploadBrandAsset(req, res) {
|
||||
const { slot } = req.params
|
||||
const rules = ASSET_RULES[slot]
|
||||
if (!rules) {
|
||||
if (req.file) await discardUpload(req.file)
|
||||
return res.status(400).json({ message: `Unknown brand asset '${slot}'` })
|
||||
}
|
||||
if (!req.file) return res.status(400).json({ message: 'No file uploaded' })
|
||||
if (rules.mimetypes && !rules.mimetypes.includes(req.file.mimetype)) {
|
||||
await discardUpload(req.file)
|
||||
return res.status(400).json({ message: `${rules.label} must be a PNG image` })
|
||||
}
|
||||
if (req.file.size > rules.maxBytes) {
|
||||
await discardUpload(req.file)
|
||||
return res.status(400).json({ message: `${rules.label} must be ${prettyBytes(rules.maxBytes)} or smaller` })
|
||||
}
|
||||
|
||||
const url = `/uploads/${req.file.filename}`
|
||||
try {
|
||||
// Read-modify-write the row: uploading a logo must not clear a hero the
|
||||
// admin set earlier (§6.3). Resolved on the way in, so a hand-edited row
|
||||
// with one bad slot does not block setting another.
|
||||
const current = resolveBrandAssets(parseJsonSetting(await settings.get('brand_assets')))
|
||||
const next = { ...current, [slot]: url }
|
||||
await settings.set('brand_assets', JSON.stringify(next), req.user.id)
|
||||
htmlShell.invalidate()
|
||||
await activity.log({ req, action: 'settings.brandAsset', detail: { slot, url } })
|
||||
return res.status(201).json({ url, brand_assets: next })
|
||||
} catch (err) {
|
||||
log.error('uploadBrandAsset', err)
|
||||
// The row is the point of the call; a stored file nothing points at is
|
||||
// litter, so it goes back out with the error.
|
||||
await discardUpload(req.file)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
if (key === 'brand_assets' || key === 'theme_visual') htmlShell.invalidate()
|
||||
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 +933,9 @@ module.exports = {
|
||||
deleteWikiCategory,
|
||||
getSettings,
|
||||
updateSettings,
|
||||
deleteSetting,
|
||||
uploadBrandAsset,
|
||||
ASSET_RULES,
|
||||
listActivity,
|
||||
listUsers,
|
||||
createUser,
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
const express = require('express')
|
||||
|
||||
const ctrl = require('./admin.controller')
|
||||
const { upload } = require('./imageUpload')
|
||||
const { requireRole } = require('../../../utils/auth')
|
||||
|
||||
const settingsRouter = express.Router()
|
||||
@@ -33,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 key coded entries by their existing route and carry only label/order/hidden/group/section; whether a key names a route the nav declares is settled client-side at merge time. nav_public may additionally carry admin-created dropdown `sections` and admin-authored `links` — the only place an arbitrary path may be named, and therefore restricted to same-origin paths (no scheme, no protocol-relative host). Sections and links are dropped for the other two navs, which cannot render them.'
|
||||
/* #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" } } } } */
|
||||
@@ -41,5 +43,42 @@ settingsRouter.put(
|
||||
adminOnly,
|
||||
ctrl.updateSettings,
|
||||
)
|
||||
// Upload one brand asset (logo/hero/favicon) and point brand_assets at it in the
|
||||
// same call — see the controller for why it is one call and not "upload, then
|
||||
// PUT". Uses the shared multer config (one upload directory, one mimetype
|
||||
// allowlist); the per-slot PNG rule and size caps are applied in the handler.
|
||||
settingsRouter.post(
|
||||
'/brand-asset/:slot',
|
||||
// #swagger.tags = ['Admin · Settings']
|
||||
// #swagger.summary = 'Upload a brand asset and set it as the override (admin only)'
|
||||
// #swagger.description = 'Stores the image and writes the brand_assets settings row in one call, so an upload never leaves an unreferenced file. Favicons must be PNG (max 512 KB); logos max 1 MB; heroes max 8 MB. Absent slots keep falling back to the BRAND_* env defaults — uploading a logo does not clear a hero.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.parameters['slot'] = { in: 'path', required: true, description: 'Which asset to replace', schema: { type: 'string', enum: ['logo', 'hero', 'favicon'] } } */
|
||||
/* #swagger.requestBody = { required: true, content: { "multipart/form-data": { schema: { type: "object", properties: { image: { type: "string", format: "binary" } } } } } } */
|
||||
/* #swagger.responses[201] = { description: 'Stored file URL and the updated overrides', content: { "application/json": { schema: { type: "object", properties: { url: { type: "string", example: "/uploads/1712345678901-ab12cd34.png" }, brand_assets: { type: "object", properties: { logo: { type: "string" }, hero: { type: "string" }, favicon: { type: "string" } } } } } } } } */
|
||||
/* #swagger.responses[400] = { description: 'No file, unknown slot, disallowed type, or over the slot size cap', 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,
|
||||
upload.single('image'),
|
||||
ctrl.uploadBrandAsset,
|
||||
)
|
||||
// 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
|
||||
|
||||
35
server/src/router/v1/settings/index.js
Normal file
35
server/src/router/v1/settings/index.js
Normal file
@@ -0,0 +1,35 @@
|
||||
// /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 themeRouter = require('./theme.router')
|
||||
|
||||
const settingsRouter = express.Router()
|
||||
|
||||
settingsRouter.use(noindex, requireAuth)
|
||||
|
||||
settingsRouter.use('/nav', navRouter)
|
||||
settingsRouter.use('/theme', themeRouter)
|
||||
|
||||
module.exports = settingsRouter
|
||||
22
server/src/router/v1/settings/nav.controller.js
Normal file
22
server/src/router/v1/settings/nav.controller.js
Normal file
@@ -0,0 +1,22 @@
|
||||
const settings = require('../../../model/settings/settings.model')
|
||||
|
||||
// The logger module exports a FACTORY — calling it is what yields {error, warn,
|
||||
// info, debug}. Using the factory directly makes `log.error` undefined, which
|
||||
// would turn a DB fault into a TypeError thrown inside the catch (no response
|
||||
// sent, request left hanging) instead of a 500.
|
||||
const log = require('../../../utils/logger')('settings')
|
||||
|
||||
// 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
|
||||
17
server/src/router/v1/settings/theme.controller.js
Normal file
17
server/src/router/v1/settings/theme.controller.js
Normal file
@@ -0,0 +1,17 @@
|
||||
const { themeOptions } = require('../../../utils/themeResolve')
|
||||
|
||||
// The theme catalog the admin appearance form builds its controls from: the
|
||||
// presets and their swatches, the curated font shortlist, the shadow depths,
|
||||
// and which color and radius fields are editable.
|
||||
//
|
||||
// Served rather than duplicated in client code so the options the form OFFERS
|
||||
// can never drift from the ones validateThemeVisual() ACCEPTS — a drift shows
|
||||
// up as an admin picking a font and the save 400ing for no visible reason.
|
||||
//
|
||||
// Static: derived from config/themePresets.js with no DB read, so there is
|
||||
// nothing here to fail and no error branch to write.
|
||||
function getThemeOptions(req, res) {
|
||||
return res.json(themeOptions())
|
||||
}
|
||||
|
||||
module.exports = { getThemeOptions }
|
||||
26
server/src/router/v1/settings/theme.router.js
Normal file
26
server/src/router/v1/settings/theme.router.js
Normal file
@@ -0,0 +1,26 @@
|
||||
// Settings · Theme — the closed sets the admin appearance form is built from.
|
||||
//
|
||||
// Mounted at /api/v1/settings/theme by settings/index.js, which already applied
|
||||
// `noindex, requireAuth`. No role gate is added here for the same reason the
|
||||
// group has none: it is a static catalog of presets and font names, not
|
||||
// configuration and not anything about the caller. The route that WRITES a
|
||||
// theme is PUT /api/v1/admin/settings, which is admin-only.
|
||||
|
||||
const express = require('express')
|
||||
|
||||
const ctrl = require('./theme.controller')
|
||||
|
||||
const themeRouter = express.Router()
|
||||
|
||||
themeRouter.get(
|
||||
'/options',
|
||||
// #swagger.tags = ['Settings']
|
||||
// #swagger.summary = 'Theme presets and the curated option lists'
|
||||
// #swagger.description = 'The closed sets an admin may choose from when theming the site: the three presets (with swatch colors), the curated Google Fonts shortlist per role, the shadow depths, and the editable color/radius field names. Served so the admin form can never offer a value the server would reject. Static — no database read.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'Theme option catalog', content: { "application/json": { schema: { $ref: "#/components/schemas/ThemeOptions" } } } } */
|
||||
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
ctrl.getThemeOptions,
|
||||
)
|
||||
|
||||
module.exports = themeRouter
|
||||
@@ -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
|
||||
|
||||
95
server/src/utils/brandAssets.js
Normal file
95
server/src/utils/brandAssets.js
Normal file
@@ -0,0 +1,95 @@
|
||||
// Uploaded brand-asset overrides — the `brand_assets` settings row.
|
||||
//
|
||||
// { "logo": "/uploads/1234-ab.png", "hero": null, "favicon": null }
|
||||
//
|
||||
// Each field, once set, holds a stored upload URL; a null or absent field falls
|
||||
// back to brand.logo / brand.hero / brand.favicon from BRAND_* env. Uploading a
|
||||
// logo does not force the admin to also pick a hero
|
||||
// (docs/website/THEMING_AND_NAV.md §6.3).
|
||||
//
|
||||
// These values are the only part of the settings store that is written straight
|
||||
// into HTML the browser then fetches — an <img src>, a <link rel="icon">, an
|
||||
// og:image. So the accepted shape is deliberately narrow: a same-origin path
|
||||
// under one of the three directories this app serves, and nothing else. No
|
||||
// scheme, no protocol-relative `//host`, no `..`. The upload route only ever
|
||||
// produces `/uploads/…`, so the other two prefixes exist for an admin who wants
|
||||
// to point at an asset already baked into the image or mounted at /brand.
|
||||
//
|
||||
// Same strict-on-write / forgiving-on-read asymmetry as the theme
|
||||
// (utils/themeResolve.js): a bad write is rejected with the field named, while a
|
||||
// bad *stored* value is dropped field by field so a hand-edited row degrades to
|
||||
// the env default instead of rendering a broken page.
|
||||
|
||||
// The three overridable assets, in the order the admin UI shows them.
|
||||
const SLOTS = ['logo', 'hero', 'favicon']
|
||||
|
||||
// Directories this server actually serves: /uploads (UPLOAD_DIR), /brand
|
||||
// (BRAND_DIR, optional) and /assets (the built SPA's static files).
|
||||
const ALLOWED_PREFIXES = ['/uploads/', '/brand/', '/assets/']
|
||||
|
||||
/**
|
||||
* Is this a value we are willing to emit as a URL into the page?
|
||||
* @param {unknown} value
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isSafeAssetPath(value) {
|
||||
if (typeof value !== 'string' || value === '') return false
|
||||
// A leading `//` is protocol-relative and would load from another origin
|
||||
// despite looking like a path; `..` could climb out of the served directory.
|
||||
if (value.startsWith('//') || value.includes('..')) return false
|
||||
// Whitespace and control characters have no place in a stored path and are the
|
||||
// raw material for `javascript:` smuggling past a naive prefix check.
|
||||
if (/[\s<>"'\\]/.test(value)) return false
|
||||
return ALLOWED_PREFIXES.some((prefix) => value.startsWith(prefix))
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a brand_assets object for WRITING. Strict: names the offending field.
|
||||
* @param {unknown} value the parsed object (or null to clear every slot)
|
||||
* @returns {{ok: true} | {ok: false, message: string}}
|
||||
*/
|
||||
function validateBrandAssets(value) {
|
||||
if (value === null || value === undefined) return { ok: true }
|
||||
if (typeof value !== 'object' || Array.isArray(value)) {
|
||||
return { ok: false, message: 'brand_assets must be a JSON object' }
|
||||
}
|
||||
for (const [slot, url] of Object.entries(value)) {
|
||||
if (!SLOTS.includes(slot)) {
|
||||
return { ok: false, message: `Unknown brand asset '${slot}'` }
|
||||
}
|
||||
// null/'' is how a slot is cleared back to the env default — allowed, and
|
||||
// stripped by the caller so the stored row never carries dead fields.
|
||||
if (url === null || url === '') continue
|
||||
if (!isSafeAssetPath(url)) {
|
||||
return {
|
||||
ok: false,
|
||||
message: `brand_assets.${slot} must be an uploaded path under /uploads/, /brand/ or /assets/`,
|
||||
}
|
||||
}
|
||||
}
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep only the slots that hold a usable path. Serves both directions on
|
||||
* purpose:
|
||||
*
|
||||
* • writing — an admin who removes their logo stores `{}` (and the caller
|
||||
* deletes the row entirely) rather than a row full of nulls, which would
|
||||
* read as "set to nothing" rather than "never set";
|
||||
* • reading — an unusable stored field is dropped and its neighbours kept, so
|
||||
* one bad slot cannot cost the admin the other two.
|
||||
*
|
||||
* @param {object|null} value an object, or a parseJsonSetting result
|
||||
* @returns {{logo?: string, hero?: string, favicon?: string}}
|
||||
*/
|
||||
function resolveBrandAssets(value) {
|
||||
const out = {}
|
||||
if (!value || typeof value !== 'object') return out
|
||||
for (const slot of SLOTS) {
|
||||
if (isSafeAssetPath(value[slot])) out[slot] = value[slot]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
module.exports = { SLOTS, ALLOWED_PREFIXES, isSafeAssetPath, validateBrandAssets, resolveBrandAssets }
|
||||
187
server/src/utils/htmlShell.js
Normal file
187
server/src/utils/htmlShell.js
Normal file
@@ -0,0 +1,187 @@
|
||||
// The SPA's HTML shell: index.html templated with this instance's branding.
|
||||
//
|
||||
// This used to be a one-liner at module load in app.js — read the built
|
||||
// index.html, template it from BRAND_* env, serve that one string forever. The
|
||||
// admin-configurable brand assets (docs/website/THEMING_AND_NAV.md §4.3) make
|
||||
// the favicon and OG image settings-driven, which is a lifecycle change rather
|
||||
// than an `await`: the shell now depends on a row that can change while the
|
||||
// process runs.
|
||||
//
|
||||
// Three properties this module exists to guarantee:
|
||||
//
|
||||
// • It is a cached string in the steady state. A settings read per page view
|
||||
// would put the database on the critical path of every SPA route, including
|
||||
// during an outage where the API is already degraded.
|
||||
// • A DB fault never fails the page. A read error renders the env-only shell —
|
||||
// exactly what the code did before this feature — and that fallback is
|
||||
// cached like any other, so an outage cannot turn every page view into a
|
||||
// failing query.
|
||||
// • With no brand_assets and no theme_visual row it is BYTE-IDENTICAL to what
|
||||
// app.js served before. That is an acceptance criterion of §9, and the
|
||||
// reason the theme <style> block and the asset overrides are appended only
|
||||
// when they exist rather than always emitted with default values.
|
||||
//
|
||||
// Invalidation is explicit — the settings controller calls invalidate() after a
|
||||
// successful write to brand_assets or theme_visual — with a TTL as a safety net.
|
||||
// The cache is per process: in a scaled deployment the process that handled the
|
||||
// write is the only one that learns of it, so without the TTL every other worker
|
||||
// would serve the old favicon until the next restart.
|
||||
|
||||
const brand = require('../config/brand')
|
||||
|
||||
// How long a rendered shell is trusted without an explicit invalidation. Short
|
||||
// enough that a second process converges on its own, long enough that this is
|
||||
// still one render per process per five minutes rather than one per request.
|
||||
const TTL_MS = 5 * 60 * 1000
|
||||
|
||||
// A stored theme reaches the browser twice: in this block, and again as inline
|
||||
// properties once the SPA has fetched /public/settings. The block exists purely
|
||||
// so a themed instance does not paint the shipped palette for one frame first;
|
||||
// the client drops it (by id) as soon as it has the authoritative payload — see
|
||||
// contexts/SiteContext.jsx.
|
||||
const THEME_STYLE_ID = 'theme-boot'
|
||||
|
||||
// Belt and braces over the theme validators. Every token name comes from a fixed
|
||||
// map and every value from a closed set (hex color, curated font stack, bounded
|
||||
// px, listed shadow), so nothing that reaches here can carry markup today. These
|
||||
// two patterns make that a property of the HTML writer rather than of a validator
|
||||
// three modules away that someone may one day loosen.
|
||||
const SAFE_TOKEN_NAME = /^--[a-zA-Z0-9-_]+$/
|
||||
const SAFE_TOKEN_VALUE = /^[a-zA-Z0-9 ,.()#%_'"/-]+$/
|
||||
|
||||
let template = null // the built index.html, read once
|
||||
let cached = null // { html, at }
|
||||
let inflight = null // de-dupes a burst of requests on a cold cache
|
||||
let generation = 0 // bumped by invalidate(); an in-flight render checks it
|
||||
|
||||
// Escape user/brand text for safe interpolation into the HTML shell.
|
||||
function htmlEscape(s) {
|
||||
return String(s).replace(
|
||||
/[&<>"']/g,
|
||||
(c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* An uploaded asset path is always relative (`/uploads/…`), but og:image is read
|
||||
* off-site by scrapers that handle a relative URL poorly. Absolutize it against
|
||||
* BRAND_URL when we have one.
|
||||
*
|
||||
* Env values pass through untouched even when relative: the shell an instance
|
||||
* gets today is the operator's choice and must not change just because this
|
||||
* module now exists.
|
||||
*/
|
||||
function absolutize(url) {
|
||||
if (!brand.url || !url.startsWith('/')) return url
|
||||
return `${brand.url.replace(/\/+$/, '')}${url}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the shell. Pure — every input is a parameter, so a test can assert the
|
||||
* byte-identical property without a database.
|
||||
*
|
||||
* @param {string} html the built index.html
|
||||
* @param {{logo?: string, favicon?: string, theme?: object|null}} [overrides]
|
||||
* effective brand assets and theme; anything absent falls back to BRAND_* env
|
||||
* @returns {string}
|
||||
*/
|
||||
function render(html, overrides = {}) {
|
||||
const title = htmlEscape(brand.name)
|
||||
const desc = htmlEscape(brand.description)
|
||||
// Effective values: an uploaded override wins over env, absence means env.
|
||||
const logo = overrides.logo ? absolutize(overrides.logo) : brand.logo
|
||||
const favicon = overrides.favicon || brand.favicon
|
||||
const tags = [
|
||||
`<meta property="og:title" content="${title}" />`,
|
||||
`<meta property="og:description" content="${desc}" />`,
|
||||
'<meta property="og:type" content="website" />',
|
||||
brand.url ? `<meta property="og:url" content="${htmlEscape(brand.url)}" />` : '',
|
||||
logo ? `<meta property="og:image" content="${htmlEscape(logo)}" />` : '',
|
||||
'<meta name="twitter:card" content="summary_large_image" />',
|
||||
`<meta name="twitter:title" content="${title}" />`,
|
||||
`<meta name="twitter:description" content="${desc}" />`,
|
||||
favicon ? `<link rel="icon" href="${htmlEscape(favicon)}" />` : '',
|
||||
themeStyleTag(overrides.theme),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n ')
|
||||
return html
|
||||
.replace(/<title>[\s\S]*?<\/title>/i, `<title>${title}</title>`)
|
||||
.replace(/(<meta\s+name="description"\s+content=")[\s\S]*?("\s*\/?>)/i, `$1${desc}$2`)
|
||||
.replace(/<\/head>/i, ` ${tags}\n </head>`)
|
||||
}
|
||||
|
||||
// The admin theme as a :root block, or '' when this instance has never been
|
||||
// themed. Injected last in <head> so it follows the built stylesheet and wins
|
||||
// the equal-specificity tie against theme.css's own :root.
|
||||
function themeStyleTag(theme) {
|
||||
if (!theme || typeof theme !== 'object') return ''
|
||||
const decls = Object.entries(theme)
|
||||
.filter(([name, value]) => SAFE_TOKEN_NAME.test(name) && typeof value === 'string' && SAFE_TOKEN_VALUE.test(value))
|
||||
.map(([name, value]) => `${name}:${value}`)
|
||||
.join(';')
|
||||
return decls ? `<style id="${THEME_STYLE_ID}">:root{${decls}}</style>` : ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Provide the built index.html. Called once at boot by app.js; a separate step
|
||||
* from get() so the file read stays synchronous and startup still fails loudly
|
||||
* if the client build is unreadable.
|
||||
*/
|
||||
function init(html) {
|
||||
template = html
|
||||
cached = null
|
||||
inflight = null
|
||||
generation += 1
|
||||
}
|
||||
|
||||
/** Drop the cached shell. Called after any write that can change it. */
|
||||
function invalidate() {
|
||||
cached = null
|
||||
inflight = null
|
||||
generation += 1
|
||||
}
|
||||
|
||||
/**
|
||||
* The current shell. Renders on a cold or expired cache, otherwise returns the
|
||||
* cached string. Never rejects: a settings read that fails yields the env-only
|
||||
* shell.
|
||||
*
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
async function get() {
|
||||
if (template === null) throw new Error('htmlShell.init() was never called')
|
||||
if (cached && Date.now() - cached.at < TTL_MS) return cached.html
|
||||
if (inflight) return inflight
|
||||
|
||||
const startedAt = generation
|
||||
const run = (async () => {
|
||||
let overrides = {}
|
||||
try {
|
||||
// Required lazily: this module is loaded by app.js at boot, and the
|
||||
// settings model pulls in the DB pool. Requiring it at the top would make
|
||||
// the HTML shell a startup-time dependency of the database.
|
||||
// eslint-disable-next-line global-require
|
||||
const settings = require('../model/settings/settings.model')
|
||||
overrides = await settings.getShellBrand()
|
||||
} catch {
|
||||
// A DB fault must never fail the page (§4.3). Fall back to the env-only
|
||||
// shell — the pre-feature behaviour — and cache it, so an outage does not
|
||||
// mean a failing query per page view.
|
||||
overrides = {}
|
||||
}
|
||||
const html = render(template, overrides)
|
||||
// An invalidation that landed while this read was in flight means the value
|
||||
// we just read may already be stale. Serve it, but do not cache it.
|
||||
if (generation === startedAt) cached = { html, at: Date.now() }
|
||||
// Only retire our own registration: an invalidation during the read may have
|
||||
// already started a newer render, and clearing that one would cost an extra
|
||||
// render on the next request.
|
||||
if (inflight === run) inflight = null
|
||||
return html
|
||||
})()
|
||||
inflight = run
|
||||
return run
|
||||
}
|
||||
|
||||
module.exports = { init, get, invalidate, render, TTL_MS, THEME_STYLE_ID }
|
||||
336
server/src/utils/navOverrides.js
Normal file
336
server/src/utils/navOverrides.js
Normal file
@@ -0,0 +1,336 @@
|
||||
// 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 overridable fields on a CODED item. `group` is only meaningful on the
|
||||
// grouped admin nav and `section` only on the public header, but accepting both
|
||||
// everywhere costs nothing — the merge util drops a group the base nav does not
|
||||
// declare, and a section id no `sections` entry declares.
|
||||
const FIELDS = ['label', 'order', 'hidden', 'group', 'section']
|
||||
|
||||
// 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
|
||||
const MAX_SECTIONS = 12
|
||||
const MAX_LINKS = 40
|
||||
|
||||
// Only the public header supports admin-created dropdown sections and
|
||||
// admin-authored links (THEMING_AND_NAV.md §7, Phase 10). The admin sidebar has
|
||||
// its own coded sections and the player portal is three flat rows, so both keep
|
||||
// the bare items map; `sections`/`links` are dropped for them rather than
|
||||
// rejected, the same posture as every other unusable field here.
|
||||
const SECTIONED_KEYS = ['nav_public']
|
||||
|
||||
// Generated by the editor, never typed. Constrained so a stored id is safe to
|
||||
// use as a React key and as a DOM id fragment without further escaping.
|
||||
const SECTION_ID = /^sec_[a-z0-9]{4,16}$/
|
||||
const LINK_ID = /^lnk_[a-z0-9]{4,16}$/
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a stored value into its three parts.
|
||||
*
|
||||
* The public header grew dropdown sections in Phase 10, so `nav_public` may be
|
||||
* a wrapper — `{ items, sections, links }` — while the other two navs stay the
|
||||
* bare items map phases 6-8 wrote. **A bare map is still read as the items
|
||||
* map**, which is unambiguous because every item key is a path beginning with
|
||||
* `/` and so can never be the string `items`.
|
||||
*
|
||||
* @param {object} value a parsed, non-array object
|
||||
* @returns {{items: object, sections: unknown, links: unknown, wrapped: boolean}}
|
||||
*/
|
||||
function unwrap(value) {
|
||||
const wrapped = value.items && typeof value.items === 'object' && !Array.isArray(value.items)
|
||||
if (!wrapped) return { items: value, sections: undefined, links: undefined, wrapped: false }
|
||||
return { items: value.items, sections: value.sections, links: value.links, wrapped: true }
|
||||
}
|
||||
|
||||
// A section is a dropdown an admin created: a label and a position, no route.
|
||||
// It is never itself a link — it only opens — so there is no `to` to validate.
|
||||
function validateSections(sections, key) {
|
||||
if (sections === undefined || sections === null) return { ok: true }
|
||||
if (!Array.isArray(sections)) return { ok: false, message: `${key}.sections must be an array` }
|
||||
if (sections.length > MAX_SECTIONS) {
|
||||
return { ok: false, message: `${key} may hold at most ${MAX_SECTIONS} sections` }
|
||||
}
|
||||
const seen = new Set()
|
||||
for (const section of sections) {
|
||||
if (!section || typeof section !== 'object' || Array.isArray(section)) {
|
||||
return { ok: false, message: `${key}.sections entries must be objects` }
|
||||
}
|
||||
if (typeof section.id !== 'string' || !SECTION_ID.test(section.id)) {
|
||||
return { ok: false, message: `${key}.sections has an entry with an invalid id` }
|
||||
}
|
||||
if (seen.has(section.id)) {
|
||||
return { ok: false, message: `${key}.sections has a duplicate id '${section.id}'` }
|
||||
}
|
||||
seen.add(section.id)
|
||||
if (typeof section.label !== 'string' || !section.label.trim() || section.label.length > MAX_LABEL) {
|
||||
return { ok: false, message: `${key}.sections['${section.id}'].label must be text of at most ${MAX_LABEL} characters` }
|
||||
}
|
||||
if (section.order !== undefined && (typeof section.order !== 'number' || !Number.isFinite(section.order))) {
|
||||
return { ok: false, message: `${key}.sections['${section.id}'].order must be a number` }
|
||||
}
|
||||
}
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
// A link is the one thing an admin may ADD to a nav, and the only place a `to`
|
||||
// is not required to already exist in code. It is kept in its own array rather
|
||||
// than in `items` on purpose: `items` may only key routes the base array
|
||||
// declares, so an override structurally cannot invent a route, and everything
|
||||
// that CAN name an arbitrary path is here where the path rule is applied.
|
||||
//
|
||||
// A link carries no `roles` or `feature` of its own. It does not need one: the
|
||||
// page behind it enforces its own access, so a link to somewhere the viewer
|
||||
// cannot reach 403s exactly as typing the URL would (§7).
|
||||
function validateLinks(links, key) {
|
||||
if (links === undefined || links === null) return { ok: true }
|
||||
if (!Array.isArray(links)) return { ok: false, message: `${key}.links must be an array` }
|
||||
if (links.length > MAX_LINKS) {
|
||||
return { ok: false, message: `${key} may hold at most ${MAX_LINKS} added links` }
|
||||
}
|
||||
const seen = new Set()
|
||||
for (const link of links) {
|
||||
if (!link || typeof link !== 'object' || Array.isArray(link)) {
|
||||
return { ok: false, message: `${key}.links entries must be objects` }
|
||||
}
|
||||
if (typeof link.id !== 'string' || !LINK_ID.test(link.id)) {
|
||||
return { ok: false, message: `${key}.links has an entry with an invalid id` }
|
||||
}
|
||||
if (seen.has(link.id)) {
|
||||
return { ok: false, message: `${key}.links has a duplicate id '${link.id}'` }
|
||||
}
|
||||
seen.add(link.id)
|
||||
if (typeof link.label !== 'string' || !link.label.trim() || link.label.length > MAX_LABEL) {
|
||||
return { ok: false, message: `${key}.links['${link.id}'].label must be text of at most ${MAX_LABEL} characters` }
|
||||
}
|
||||
// The whole point of the restriction: an added link points somewhere on this
|
||||
// site. No scheme, no `//host` — the nav is not a place to send visitors off
|
||||
// to an origin the operator does not control.
|
||||
if (!isNavPath(link.to)) {
|
||||
return { ok: false, message: `${key}.links['${link.id}'].to must be a path on this site, such as /wiki/new-player-guide` }
|
||||
}
|
||||
if (link.order !== undefined && (typeof link.order !== 'number' || !Number.isFinite(link.order))) {
|
||||
return { ok: false, message: `${key}.links['${link.id}'].order must be a number` }
|
||||
}
|
||||
if (link.section !== undefined && link.section !== null && typeof link.section !== 'string') {
|
||||
return { ok: false, message: `${key}.links['${link.id}'].section must be a section id` }
|
||||
}
|
||||
}
|
||||
return { ok: 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 { items, sections, links } = unwrap(value)
|
||||
if (!items || typeof items !== 'object' || Array.isArray(items)) {
|
||||
return { ok: false, message: `${key}.items must be a JSON object` }
|
||||
}
|
||||
const sectionCheck = validateSections(sections, key)
|
||||
if (!sectionCheck.ok) return sectionCheck
|
||||
const linkCheck = validateLinks(links, key)
|
||||
if (!linkCheck.ok) return linkCheck
|
||||
|
||||
const entries = Object.entries(items)
|
||||
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` }
|
||||
}
|
||||
if (field === 'section' && fieldValue !== null && typeof fieldValue !== 'string') {
|
||||
return { ok: false, message: `${key}['${to}'].section must be a section id` }
|
||||
}
|
||||
// `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.
|
||||
*
|
||||
* Sections and added links are honored only for the navs that can render them
|
||||
* (`nav_public`), and a `section` naming no surviving section falls back to the
|
||||
* top level rather than stranding the item in a dropdown that is not there.
|
||||
*
|
||||
* The return shape mirrors the input: a nav with no sections and no added links
|
||||
* resolves to the bare items map phases 6-8 wrote, so adding this feature
|
||||
* changed nothing at all for a nav that does not use it.
|
||||
*
|
||||
* @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') {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return {}
|
||||
const { items, sections, links } = unwrap(value)
|
||||
if (!items || typeof items !== 'object' || Array.isArray(items)) return {}
|
||||
|
||||
const sectioned = SECTIONED_KEYS.includes(key)
|
||||
const cleanSections = sectioned ? resolveSections(sections) : []
|
||||
const known = new Set(cleanSections.map((s) => s.id))
|
||||
const cleanLinks = sectioned ? resolveLinks(links, known) : []
|
||||
|
||||
const out = resolveItems(items, key, known)
|
||||
if (cleanSections.length === 0 && cleanLinks.length === 0) return out
|
||||
// A section with nothing in it renders as an empty dropdown, so an admin who
|
||||
// emptied one has simply stopped using it — but it is theirs to keep until
|
||||
// they delete it, and the editor is where that happens. Kept here; the
|
||||
// renderer drops it (client/src/lib/navOverrides.js pruneNav).
|
||||
const wrapper = { items: out }
|
||||
if (cleanSections.length) wrapper.sections = cleanSections
|
||||
if (cleanLinks.length) wrapper.links = cleanLinks
|
||||
return wrapper
|
||||
}
|
||||
|
||||
function resolveSections(sections) {
|
||||
const out = []
|
||||
const seen = new Set()
|
||||
if (!Array.isArray(sections)) return out
|
||||
for (const section of sections.slice(0, MAX_SECTIONS)) {
|
||||
if (!section || typeof section !== 'object' || Array.isArray(section)) continue
|
||||
if (typeof section.id !== 'string' || !SECTION_ID.test(section.id) || seen.has(section.id)) continue
|
||||
if (typeof section.label !== 'string' || !section.label.trim() || section.label.length > MAX_LABEL) continue
|
||||
seen.add(section.id)
|
||||
const clean = { id: section.id, label: section.label.trim() }
|
||||
if (typeof section.order === 'number' && Number.isFinite(section.order)) clean.order = section.order
|
||||
out.push(clean)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function resolveLinks(links, knownSections) {
|
||||
const out = []
|
||||
const seen = new Set()
|
||||
if (!Array.isArray(links)) return out
|
||||
for (const link of links.slice(0, MAX_LINKS)) {
|
||||
if (!link || typeof link !== 'object' || Array.isArray(link)) continue
|
||||
if (typeof link.id !== 'string' || !LINK_ID.test(link.id) || seen.has(link.id)) continue
|
||||
if (typeof link.label !== 'string' || !link.label.trim() || link.label.length > MAX_LABEL) continue
|
||||
if (!isNavPath(link.to)) continue
|
||||
seen.add(link.id)
|
||||
const clean = { id: link.id, label: link.label.trim(), to: link.to }
|
||||
if (typeof link.order === 'number' && Number.isFinite(link.order)) clean.order = link.order
|
||||
if (typeof link.section === 'string' && knownSections.has(link.section)) clean.section = link.section
|
||||
out.push(clean)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function resolveItems(items, key, knownSections) {
|
||||
const out = {}
|
||||
const unhideable = UNHIDEABLE[key] || []
|
||||
for (const [to, entry] of Object.entries(items)) {
|
||||
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()
|
||||
}
|
||||
// Only a section that survived resolution: an item pointing at a deleted or
|
||||
// malformed one belongs at the top level, visible, rather than inside a
|
||||
// dropdown that no longer exists.
|
||||
if (typeof entry.section === 'string' && knownSections.has(entry.section)) clean.section = entry.section
|
||||
if (Object.keys(clean).length > 0) out[to] = clean
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
module.exports = { validateNavOverrides, resolveNavOverrides, NAV_KEYS: ['nav_public', 'nav_admin', 'nav_player'] }
|
||||
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 }
|
||||
202
server/src/utils/themeResolve.js
Normal file
202
server/src/utils/themeResolve.js
Normal file
@@ -0,0 +1,202 @@
|
||||
// ── theme_visual: validate on write, resolve on read ───────────────────────
|
||||
//
|
||||
// Two jobs, one closed set of rules (config/themePresets.js):
|
||||
//
|
||||
// validateThemeVisual() the WRITE path. PUT /admin/settings rejects a bad
|
||||
// theme_visual with a 400 rather than storing it, so an
|
||||
// admin gets told why instead of watching a save appear
|
||||
// to succeed and do nothing.
|
||||
// resolveThemeTokens() the READ path. Turns the stored value into the CSS
|
||||
// custom properties settings.getPublic() ships as
|
||||
// `theme`. Fail-safe, per §4.4: anything unrecognized
|
||||
// is dropped field-by-field and the surface falls back
|
||||
// to theme.css's :root — never an error, never a
|
||||
// half-applied palette.
|
||||
//
|
||||
// The write path is the strict one and the read path is the forgiving one on
|
||||
// purpose. Strict-on-write gives feedback; forgiving-on-read means a row
|
||||
// hand-edited in the DB, or written by an older version of this code, degrades
|
||||
// to the shipped default instead of rendering a broken site.
|
||||
//
|
||||
// See docs/website/THEMING_AND_NAV.md §5-§6.
|
||||
|
||||
const {
|
||||
PRESETS,
|
||||
PRESET_IDS,
|
||||
CUSTOM_PRESET,
|
||||
COLOR_FIELDS,
|
||||
RADIUS_FIELDS,
|
||||
FONT_FIELDS,
|
||||
FONT_OPTIONS,
|
||||
SHADOW_OPTIONS,
|
||||
RADIUS_MAX_PX,
|
||||
} = require('../config/themePresets')
|
||||
const { parseJsonSetting } = require('./settingsJson')
|
||||
|
||||
const HEX_COLOR = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/
|
||||
const PX_LENGTH = /^(\d{1,3})px$/
|
||||
|
||||
const SHADOW_VALUES = SHADOW_OPTIONS.map((o) => o.value)
|
||||
const FONT_VALUES = Object.fromEntries(
|
||||
Object.keys(FONT_FIELDS).map((role) => [role, FONT_OPTIONS[role].map((o) => o.value)]),
|
||||
)
|
||||
|
||||
function isPlainObject(v) {
|
||||
return !!v && typeof v === 'object' && !Array.isArray(v)
|
||||
}
|
||||
|
||||
function isColor(v) {
|
||||
return typeof v === 'string' && HEX_COLOR.test(v)
|
||||
}
|
||||
|
||||
// A bounded px length. `0` on its own is not accepted — a radius is always
|
||||
// written with a unit here, which keeps the stored shape uniform.
|
||||
function isRadius(v) {
|
||||
if (typeof v !== 'string') return false
|
||||
const m = PX_LENGTH.exec(v)
|
||||
return !!m && Number(m[1]) <= RADIUS_MAX_PX
|
||||
}
|
||||
|
||||
function isShadow(v) {
|
||||
return typeof v === 'string' && SHADOW_VALUES.includes(v)
|
||||
}
|
||||
|
||||
function isFont(role, v) {
|
||||
return typeof v === 'string' && (FONT_VALUES[role] || []).includes(v)
|
||||
}
|
||||
|
||||
// Per-field check for one custom group. Returns the list of offending field
|
||||
// names, so the write path can say which field was wrong.
|
||||
function checkGroup(group, fields, check) {
|
||||
const bad = []
|
||||
for (const [field, value] of Object.entries(group)) {
|
||||
if (!(field in fields)) {
|
||||
bad.push(field)
|
||||
} else if (!check(field, value)) {
|
||||
bad.push(field)
|
||||
}
|
||||
}
|
||||
return bad
|
||||
}
|
||||
|
||||
/**
|
||||
* Strict shape check for the write path.
|
||||
*
|
||||
* @param {unknown} value the parsed theme_visual object
|
||||
* @returns {{ ok: true } | { ok: false, message: string }}
|
||||
*/
|
||||
function validateThemeVisual(value) {
|
||||
if (!isPlainObject(value)) return { ok: false, message: 'theme_visual must be a JSON object' }
|
||||
|
||||
const keys = Object.keys(value).filter((k) => k !== 'preset' && k !== 'custom')
|
||||
if (keys.length) return { ok: false, message: `theme_visual: unknown field(s) ${keys.join(', ')}` }
|
||||
|
||||
if (!PRESET_IDS.includes(value.preset)) {
|
||||
return { ok: false, message: `theme_visual.preset must be one of ${PRESET_IDS.join(', ')}` }
|
||||
}
|
||||
|
||||
// `custom` is optional and may be explicitly null ("preset only").
|
||||
const custom = value.custom
|
||||
if (custom === undefined || custom === null) return { ok: true }
|
||||
if (!isPlainObject(custom)) return { ok: false, message: 'theme_visual.custom must be an object or null' }
|
||||
|
||||
const groups = Object.keys(custom).filter((g) => !['colors', 'structure', 'fonts'].includes(g))
|
||||
if (groups.length) return { ok: false, message: `theme_visual.custom: unknown group(s) ${groups.join(', ')}` }
|
||||
|
||||
for (const [group, spec] of [
|
||||
['colors', { fields: COLOR_FIELDS, check: (_f, v) => isColor(v) }],
|
||||
['fonts', { fields: FONT_FIELDS, check: (f, v) => isFont(f, v) }],
|
||||
[
|
||||
'structure',
|
||||
{
|
||||
fields: { ...RADIUS_FIELDS, shadowDepth: '--shadow-card' },
|
||||
check: (f, v) => (f === 'shadowDepth' ? isShadow(v) : isRadius(v)),
|
||||
},
|
||||
],
|
||||
]) {
|
||||
const supplied = custom[group]
|
||||
if (supplied === undefined || supplied === null) continue
|
||||
if (!isPlainObject(supplied)) return { ok: false, message: `theme_visual.custom.${group} must be an object` }
|
||||
const bad = checkGroup(supplied, spec.fields, spec.check)
|
||||
if (bad.length) return { ok: false, message: `theme_visual.custom.${group}: invalid value for ${bad.join(', ')}` }
|
||||
}
|
||||
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
// Copy the fields of one custom group that pass their check onto the token map.
|
||||
// Field-by-field: a bad accent does not discard a good bg beside it.
|
||||
function applyGroup(tokens, group, fields, check) {
|
||||
if (!isPlainObject(group)) return
|
||||
for (const [field, token] of Object.entries(fields)) {
|
||||
const value = group[field]
|
||||
if (value !== undefined && check(field, value)) tokens[token] = value
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The effective CSS custom properties for a stored theme_visual value.
|
||||
*
|
||||
* Layered :root ← preset ← custom, per field. `null` means "no row, or nothing
|
||||
* usable in it" — the caller omits the block entirely and the client applies
|
||||
* nothing, which is what makes an untouched instance render byte-for-byte as
|
||||
* today.
|
||||
*
|
||||
* @param {string|object|null|undefined} stored the raw settings value (TEXT) or
|
||||
* an already-parsed object
|
||||
* @returns {Record<string, string>|null}
|
||||
*/
|
||||
function resolveThemeTokens(stored) {
|
||||
const parsed = typeof stored === 'string' ? parseJsonSetting(stored) : isPlainObject(stored) ? stored : null
|
||||
if (!parsed) return null
|
||||
|
||||
// An unrecognized preset id falls back to no base rather than to a guess: the
|
||||
// admin's custom fields still apply on top of :root.
|
||||
const base = PRESETS[parsed.preset]
|
||||
const tokens = base ? { ...base.tokens } : {}
|
||||
|
||||
const custom = parsed.custom
|
||||
if (isPlainObject(custom)) {
|
||||
applyGroup(tokens, custom.colors, COLOR_FIELDS, (_f, v) => isColor(v))
|
||||
applyGroup(tokens, custom.fonts, FONT_FIELDS, (f, v) => isFont(f, v))
|
||||
applyGroup(tokens, custom.structure, RADIUS_FIELDS, (_f, v) => isRadius(v))
|
||||
applyGroup(tokens, custom.structure, { shadowDepth: '--shadow-card' }, (_f, v) => isShadow(v))
|
||||
}
|
||||
|
||||
// A row that parsed but yielded nothing usable (e.g. `{"preset":"custom"}`
|
||||
// with no custom fields) is the same as no row at all to every consumer.
|
||||
return Object.keys(tokens).length ? tokens : null
|
||||
}
|
||||
|
||||
/**
|
||||
* The catalog the admin UI builds its controls from. Served rather than
|
||||
* duplicated client-side so the options offered can never drift from the
|
||||
* options validateThemeVisual() accepts.
|
||||
*/
|
||||
function themeOptions() {
|
||||
return {
|
||||
// Full token maps, not just a swatch: the form shows each control's
|
||||
// *effective* default for the selected preset, so an admin opening the
|
||||
// accent picker on Fantasy sees Fantasy's gold rather than a hardcoded
|
||||
// client-side copy of the shipped palette. `custom` has no map — it means
|
||||
// "no preset base", and the form falls back to the shipped theme, which is
|
||||
// the runic-gateway map.
|
||||
presets: [
|
||||
...Object.entries(PRESETS).map(([id, p]) => ({ id, label: p.label, tokens: p.tokens })),
|
||||
{ id: CUSTOM_PRESET, label: 'Custom', tokens: null },
|
||||
],
|
||||
// Each editable field paired with the CSS variable it drives, so the form
|
||||
// can look its current value up in the preset map above without knowing the
|
||||
// naming convention that relates the two.
|
||||
colorFields: Object.entries(COLOR_FIELDS).map(([name, token]) => ({ name, token })),
|
||||
radiusFields: Object.entries(RADIUS_FIELDS).map(([name, token]) => ({ name, token })),
|
||||
fonts: FONT_OPTIONS,
|
||||
shadows: SHADOW_OPTIONS,
|
||||
radiusMaxPx: RADIUS_MAX_PX,
|
||||
// The shipped default, i.e. what theme.css's :root already declares. What
|
||||
// an unset field actually resolves to when no preset is selected.
|
||||
shippedTokens: PRESETS['runic-gateway'].tokens,
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { validateThemeVisual, resolveThemeTokens, themeOptions }
|
||||
@@ -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"
|
||||
@@ -3459,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 key coded entries by their existing route and carry only label/order/hidden/group/section; whether a key names a route the nav declares is settled client-side at merge time. nav_public may additionally carry admin-created dropdown `sections` and admin-authored `links` — the only place an arbitrary path may be named, and therefore restricted to same-origin paths (no scheme, no protocol-relative host). Sections and links are dropped for the other two navs, which cannot render them.",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Updated settings",
|
||||
@@ -3528,6 +3532,205 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/admin/settings/brand-asset/{slot}": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Admin · Settings"
|
||||
],
|
||||
"summary": "Upload a brand asset and set it as the override (admin only)",
|
||||
"description": "Stores the image and writes the brand_assets settings row in one call, so an upload never leaves an unreferenced file. Favicons must be PNG (max 512 KB); logos max 1 MB; heroes max 8 MB. Absent slots keep falling back to the BRAND_* env defaults — uploading a logo does not clear a hero.",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "slot",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "string"
|
||||
},
|
||||
"enum": {
|
||||
"type": "array",
|
||||
"example": [
|
||||
"logo",
|
||||
"hero",
|
||||
"favicon"
|
||||
],
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Which asset to replace"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"201": {
|
||||
"description": "Stored file URL and the updated overrides",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"url": {
|
||||
"type": "string",
|
||||
"example": "/uploads/1712345678901-ab12cd34.png"
|
||||
},
|
||||
"brand_assets": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"logo": {
|
||||
"type": "string"
|
||||
},
|
||||
"hero": {
|
||||
"type": "string"
|
||||
},
|
||||
"favicon": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "No file, unknown slot, disallowed type, or over the slot size cap",
|
||||
"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": []
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"multipart/form-data": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"image": {
|
||||
"type": "string",
|
||||
"format": "binary"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/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 +12853,96 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/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": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/settings/theme/options": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Settings"
|
||||
],
|
||||
"summary": "Theme presets and the curated option lists",
|
||||
"description": "The closed sets an admin may choose from when theming the site: the three presets (with swatch colors), the curated Google Fonts shortlist per role, the shadow depths, and the editable color/radius field names. Served so the admin form can never offer a value the server would reject. Static — no database read.",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Theme option catalog",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ThemeOptions"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Not authenticated",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden"
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"cookieAuth": []
|
||||
},
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
@@ -17598,7 +17891,7 @@
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"example": "Seed/accent color (hex) for theming."
|
||||
"example": "Seed/accent color (hex) for theming. **Effective** value: the admin theme (theme_visual) wins over BRAND_ACCENT_COLOR, so a client that themes from this tracks admin theming with no change."
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -17615,7 +17908,7 @@
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"example": "Logo URL or site-relative path; empty = no logo."
|
||||
"example": "Logo URL or site-relative path; empty = no logo. An uploaded brand_assets.logo overrides BRAND_LOGO."
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -17632,7 +17925,7 @@
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"example": "Hero image URL or site-relative path."
|
||||
"example": "Hero image URL or site-relative path. An uploaded brand_assets.hero overrides BRAND_HERO."
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -17649,7 +17942,7 @@
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"example": "Favicon URL or site-relative path."
|
||||
"example": "Favicon URL or site-relative path. An uploaded brand_assets.favicon overrides BRAND_FAVICON."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17758,6 +18051,49 @@
|
||||
"brand": {
|
||||
"$ref": "#/components/schemas/Brand"
|
||||
},
|
||||
"theme": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "object"
|
||||
},
|
||||
"nullable": {
|
||||
"type": "boolean",
|
||||
"example": true
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"example": "The effective CSS custom properties for the admin theme, resolved server-side (:root ← preset ← custom). **Absent** when the admin never set a theme, which is what makes an untouched instance render from the shipped stylesheet unchanged. Keys are CSS variable names; every value comes from a closed set (hex color, curated font stack, bounded px length, listed shadow)."
|
||||
},
|
||||
"additionalProperties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"example": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"--accent": {
|
||||
"type": "string",
|
||||
"example": "#c9973f"
|
||||
},
|
||||
"--bg": {
|
||||
"type": "string",
|
||||
"example": "#1a120b"
|
||||
},
|
||||
"--radius-card": {
|
||||
"type": "string",
|
||||
"example": "2px"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"push": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -17801,6 +18137,393 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"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": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"ThemeOptions": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "object"
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"example": "The closed sets an admin may choose from when theming the site (GET /settings/theme-options). Served so the admin form cannot offer a value PUT /admin/settings would reject. Static — derived from the server theme config, not the database."
|
||||
},
|
||||
"properties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"presets": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "array"
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"example": "Selectable presets and their full token maps, so a form can show what an unset field currently resolves to. `custom` has null tokens and means \"no preset base — the shipped theme plus whatever custom fields are set\"."
|
||||
},
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "object"
|
||||
},
|
||||
"properties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "string"
|
||||
},
|
||||
"example": {
|
||||
"type": "string",
|
||||
"example": "fantasy"
|
||||
}
|
||||
}
|
||||
},
|
||||
"label": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "string"
|
||||
},
|
||||
"example": {
|
||||
"type": "string",
|
||||
"example": "Fantasy"
|
||||
}
|
||||
}
|
||||
},
|
||||
"tokens": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "object"
|
||||
},
|
||||
"nullable": {
|
||||
"type": "boolean",
|
||||
"example": true
|
||||
},
|
||||
"additionalProperties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"example": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"--bg": {
|
||||
"type": "string",
|
||||
"example": "#1a120b"
|
||||
},
|
||||
"--accent": {
|
||||
"type": "string",
|
||||
"example": "#c9973f"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"colorFields": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "array"
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"example": "Editable color fields, each paired with the CSS variable it drives."
|
||||
},
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "object"
|
||||
},
|
||||
"properties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "string"
|
||||
},
|
||||
"example": {
|
||||
"type": "string",
|
||||
"example": "accent"
|
||||
}
|
||||
}
|
||||
},
|
||||
"token": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "string"
|
||||
},
|
||||
"example": {
|
||||
"type": "string",
|
||||
"example": "--accent"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"radiusFields": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "array"
|
||||
},
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "object"
|
||||
},
|
||||
"properties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "string"
|
||||
},
|
||||
"example": {
|
||||
"type": "string",
|
||||
"example": "radiusCard"
|
||||
}
|
||||
}
|
||||
},
|
||||
"token": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "string"
|
||||
},
|
||||
"example": {
|
||||
"type": "string",
|
||||
"example": "--radius-card"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"shippedTokens": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "object"
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"example": "What the stylesheet declares by default — the values an unset field resolves to when no preset is selected."
|
||||
},
|
||||
"additionalProperties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"fonts": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "object"
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"example": "Curated Google Fonts shortlist per role. Each option's `value` is the full CSS font-family stack exactly as it will be applied — the stored value, so no stack is ever built from admin input."
|
||||
},
|
||||
"additionalProperties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "array"
|
||||
},
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "object"
|
||||
},
|
||||
"properties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"value": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"label": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"shadows": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "array"
|
||||
},
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "object"
|
||||
},
|
||||
"properties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"value": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"label": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"radiusMaxPx": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "integer"
|
||||
},
|
||||
"example": {
|
||||
"type": "number",
|
||||
"example": 999
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"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' },
|
||||
@@ -747,10 +748,15 @@ const doc = {
|
||||
description: { type: 'string' },
|
||||
contactEmail: { type: 'string', example: '' },
|
||||
url: { type: 'string', example: '' },
|
||||
accent: { type: 'string', example: '#7f99bd', description: 'Seed/accent color (hex) for theming.' },
|
||||
logo: { type: 'string', example: '', description: 'Logo URL or site-relative path; empty = no logo.' },
|
||||
hero: { type: 'string', example: '/assets/img/runic-emblem.png', description: 'Hero image URL or site-relative path.' },
|
||||
favicon: { type: 'string', example: '/assets/img/favicon.ico', description: 'Favicon URL or site-relative path.' },
|
||||
accent: {
|
||||
type: 'string',
|
||||
example: '#7f99bd',
|
||||
description:
|
||||
'Seed/accent color (hex) for theming. **Effective** value: the admin theme (theme_visual) wins over BRAND_ACCENT_COLOR, so a client that themes from this tracks admin theming with no change.',
|
||||
},
|
||||
logo: { type: 'string', example: '', description: 'Logo URL or site-relative path; empty = no logo. An uploaded brand_assets.logo overrides BRAND_LOGO.' },
|
||||
hero: { type: 'string', example: '/assets/img/runic-emblem.png', description: 'Hero image URL or site-relative path. An uploaded brand_assets.hero overrides BRAND_HERO.' },
|
||||
favicon: { type: 'string', example: '/assets/img/favicon.ico', description: 'Favicon URL or site-relative path. An uploaded brand_assets.favicon overrides BRAND_FAVICON.' },
|
||||
},
|
||||
},
|
||||
PublicSettings: {
|
||||
@@ -767,6 +773,14 @@ const doc = {
|
||||
},
|
||||
gameAccountSignup: { type: 'boolean', example: false },
|
||||
brand: { $ref: '#/components/schemas/Brand' },
|
||||
theme: {
|
||||
type: 'object',
|
||||
nullable: true,
|
||||
description:
|
||||
'The effective CSS custom properties for the admin theme, resolved server-side (:root ← preset ← custom). **Absent** when the admin never set a theme, which is what makes an untouched instance render from the shipped stylesheet unchanged. Keys are CSS variable names; every value comes from a closed set (hex color, curated font stack, bounded px length, listed shadow).',
|
||||
additionalProperties: { type: 'string' },
|
||||
example: { '--accent': '#c9973f', '--bg': '#1a120b', '--radius-card': '2px' },
|
||||
},
|
||||
push: {
|
||||
type: 'object',
|
||||
description:
|
||||
@@ -778,6 +792,83 @@ 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 },
|
||||
},
|
||||
},
|
||||
ThemeOptions: {
|
||||
type: 'object',
|
||||
description:
|
||||
'The closed sets an admin may choose from when theming the site (GET /settings/theme-options). Served so the admin form cannot offer a value PUT /admin/settings would reject. Static — derived from the server theme config, not the database.',
|
||||
properties: {
|
||||
presets: {
|
||||
type: 'array',
|
||||
description:
|
||||
'Selectable presets and their full token maps, so a form can show what an unset field currently resolves to. `custom` has null tokens and means "no preset base — the shipped theme plus whatever custom fields are set".',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', example: 'fantasy' },
|
||||
label: { type: 'string', example: 'Fantasy' },
|
||||
tokens: {
|
||||
type: 'object',
|
||||
nullable: true,
|
||||
additionalProperties: { type: 'string' },
|
||||
example: { '--bg': '#1a120b', '--accent': '#c9973f' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
colorFields: {
|
||||
type: 'array',
|
||||
description: 'Editable color fields, each paired with the CSS variable it drives.',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: { name: { type: 'string', example: 'accent' }, token: { type: 'string', example: '--accent' } },
|
||||
},
|
||||
},
|
||||
radiusFields: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: { name: { type: 'string', example: 'radiusCard' }, token: { type: 'string', example: '--radius-card' } },
|
||||
},
|
||||
},
|
||||
shippedTokens: {
|
||||
type: 'object',
|
||||
description: 'What the stylesheet declares by default — the values an unset field resolves to when no preset is selected.',
|
||||
additionalProperties: { type: 'string' },
|
||||
},
|
||||
fonts: {
|
||||
type: 'object',
|
||||
description: 'Curated Google Fonts shortlist per role. Each option\'s `value` is the full CSS font-family stack exactly as it will be applied — the stored value, so no stack is ever built from admin input.',
|
||||
additionalProperties: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: { value: { type: 'string' }, label: { type: 'string' } },
|
||||
},
|
||||
},
|
||||
},
|
||||
shadows: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: { value: { type: 'string' }, label: { type: 'string' } },
|
||||
},
|
||||
},
|
||||
radiusMaxPx: { type: 'integer', example: 999 },
|
||||
},
|
||||
},
|
||||
// Delete/mutation acknowledgements — each echoes the affected resource key
|
||||
// or a boolean flag rather than a { message } string.
|
||||
DeletedId: {
|
||||
|
||||
352
server/test/brandAssets.test.js
Normal file
352
server/test/brandAssets.test.js
Normal file
@@ -0,0 +1,352 @@
|
||||
// Point the DB at a closed port BEFORE the pool is built, and the upload
|
||||
// directory at a throwaway one BEFORE imageUpload.js resolves it — both are read
|
||||
// at require time. Every model call is monkeypatched, so no query runs.
|
||||
process.env.DB_HOST = '127.0.0.1'
|
||||
process.env.DB_PORT = '59999'
|
||||
|
||||
const os = require('os')
|
||||
const path = require('path')
|
||||
const fs = require('fs')
|
||||
|
||||
const UPLOAD_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'rg-brand-assets-'))
|
||||
process.env.UPLOAD_DIR = UPLOAD_DIR
|
||||
|
||||
const { test, after, afterEach } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
// Phase 5 of docs/website/THEMING_AND_NAV.md: the brand-asset overrides. Two
|
||||
// halves are worth locking — what a stored value is allowed to be (these values
|
||||
// are written straight into HTML as URLs) and the upload route's per-slot rules,
|
||||
// which tighten the shared allowlist without ever widening it (§9).
|
||||
const { startApp } = require('./_helper')
|
||||
const { isSafeAssetPath, validateBrandAssets, resolveBrandAssets, SLOTS } = require('../src/utils/brandAssets')
|
||||
const settingsRouter = require('../src/router/v1/admin/settings.router')
|
||||
const settingsDb = require('../src/model/settings/settings.db')
|
||||
const sessionService = require('../src/auth/session.service')
|
||||
const { requireAuth } = require('../src/auth/session.middleware')
|
||||
const users = require('../src/model/users/users.model')
|
||||
const activity = require('../src/model/activity/activity.model')
|
||||
const htmlShell = require('../src/utils/htmlShell')
|
||||
const db = require('../src/utils/db')
|
||||
|
||||
after(() => {
|
||||
db.close()
|
||||
fs.rmSync(UPLOAD_DIR, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
const originals = {
|
||||
validateSession: sessionService.validateSession,
|
||||
isSessionRevoked: sessionService.isSessionRevoked,
|
||||
sessionMeta: sessionService.sessionMeta,
|
||||
getById: users.getById,
|
||||
get: settingsDb.get,
|
||||
set: settingsDb.set,
|
||||
log: activity.log,
|
||||
}
|
||||
afterEach(() => {
|
||||
Object.assign(sessionService, {
|
||||
validateSession: originals.validateSession,
|
||||
isSessionRevoked: originals.isSessionRevoked,
|
||||
sessionMeta: originals.sessionMeta,
|
||||
})
|
||||
users.getById = originals.getById
|
||||
settingsDb.get = originals.get
|
||||
settingsDb.set = originals.set
|
||||
activity.log = originals.log
|
||||
})
|
||||
|
||||
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 () => {}
|
||||
}
|
||||
|
||||
// ── What a stored asset path may be ───────────────────────────────────
|
||||
|
||||
test('only same-origin paths under the directories this server serves are accepted', () => {
|
||||
for (const ok of ['/uploads/1-a.png', '/brand/logo.svg', '/assets/img/runic-emblem.png']) {
|
||||
assert.equal(isSafeAssetPath(ok), true, `${ok} should be accepted`)
|
||||
}
|
||||
const rejected = [
|
||||
'https://evil.example/x.png', // off-origin: an <img src> the operator did not choose
|
||||
'//evil.example/x.png', // protocol-relative — looks like a path, loads off-origin
|
||||
'javascript:alert(1)', // no scheme survives the prefix check, but be explicit
|
||||
'/uploads/../../etc/passwd', // climbing out of the served directory
|
||||
'/uploads/a b.png', // whitespace is the raw material for smuggling
|
||||
'/uploads/"onerror="alert(1)', // quote would break out of the attribute
|
||||
'/etc/passwd', // a path, but not one we serve
|
||||
'uploads/1-a.png', // relative to the current route, not to the origin
|
||||
'',
|
||||
null,
|
||||
42,
|
||||
]
|
||||
for (const bad of rejected) {
|
||||
assert.equal(isSafeAssetPath(bad), false, `${String(bad)} should be rejected`)
|
||||
}
|
||||
})
|
||||
|
||||
// Strict on write: the admin gets told which field is wrong, rather than saving
|
||||
// something that silently never renders.
|
||||
test('a write naming an unknown slot or an unusable path is rejected by field', () => {
|
||||
assert.equal(validateBrandAssets({ logo: '/uploads/a.png', hero: null }).ok, true)
|
||||
assert.equal(validateBrandAssets(null).ok, true) // clearing every slot
|
||||
|
||||
const unknown = validateBrandAssets({ banner: '/uploads/a.png' })
|
||||
assert.equal(unknown.ok, false)
|
||||
assert.match(unknown.message, /banner/)
|
||||
|
||||
const offsite = validateBrandAssets({ favicon: 'https://evil.example/f.png' })
|
||||
assert.equal(offsite.ok, false)
|
||||
assert.match(offsite.message, /favicon/)
|
||||
|
||||
assert.equal(validateBrandAssets(['/uploads/a.png']).ok, false)
|
||||
})
|
||||
|
||||
// Forgiving on read: one hand-edited slot must not cost the admin the other two.
|
||||
test('a bad stored slot is dropped and its neighbours are kept', () => {
|
||||
const resolved = resolveBrandAssets({ logo: '/uploads/a.png', hero: 'https://evil.example/h.png', favicon: null })
|
||||
assert.deepEqual(resolved, { logo: '/uploads/a.png' })
|
||||
})
|
||||
|
||||
test('resolve is also how a cleared slot stops being stored', () => {
|
||||
// '' and null are how the UI clears a slot; neither may survive into the row,
|
||||
// or "the field is absent" would stop being the single meaning of "use env".
|
||||
assert.deepEqual(resolveBrandAssets({ logo: '', hero: null }), {})
|
||||
assert.deepEqual(resolveBrandAssets(null), {})
|
||||
assert.deepEqual(SLOTS, ['logo', 'hero', 'favicon'])
|
||||
})
|
||||
|
||||
// ── POST /admin/settings/brand-asset/:slot ────────────────────────────
|
||||
|
||||
// A 1x1 PNG and a 1x1 GIF, small enough to inline and real enough for multer to
|
||||
// accept by mimetype (which is what the shared allowlist keys off).
|
||||
const PNG = Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
|
||||
'base64',
|
||||
)
|
||||
const GIF = Buffer.from('R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7', 'base64')
|
||||
|
||||
function form(buffer, { filename = 'x.png', type = 'image/png' } = {}) {
|
||||
const fd = new FormData()
|
||||
fd.append('image', new Blob([buffer], { type }), filename)
|
||||
return fd
|
||||
}
|
||||
|
||||
const startSettingsApp = () =>
|
||||
startApp((a) => a.use('/api/v1/admin/settings', requireAuth, settingsRouter))
|
||||
|
||||
const filesInUploadDir = () => fs.readdirSync(UPLOAD_DIR)
|
||||
|
||||
test('uploading a slot stores the file and points brand_assets at it', async () => {
|
||||
signInAs({ id: 1, username: 'a', role: 'admin', status: 'active' })
|
||||
settingsDb.get = async () => null // never set before
|
||||
let stored = null
|
||||
settingsDb.set = async (key, value) => {
|
||||
stored = { key, value }
|
||||
}
|
||||
const before = filesInUploadDir().length
|
||||
const app = await startSettingsApp()
|
||||
try {
|
||||
const res = await fetch(`${app.url}/api/v1/admin/settings/brand-asset/logo`, {
|
||||
method: 'POST',
|
||||
body: form(PNG),
|
||||
})
|
||||
assert.equal(res.status, 201)
|
||||
const body = await res.json()
|
||||
assert.match(body.url, /^\/uploads\/\d+-[0-9a-f]{16}\.png$/)
|
||||
assert.deepEqual(body.brand_assets, { logo: body.url })
|
||||
assert.equal(stored.key, 'brand_assets')
|
||||
assert.deepEqual(JSON.parse(stored.value), { logo: body.url })
|
||||
assert.equal(filesInUploadDir().length, before + 1, 'the file is kept')
|
||||
} finally {
|
||||
await app.close()
|
||||
}
|
||||
})
|
||||
|
||||
// §6.3: uploading a logo does not force the admin to also pick a hero — and must
|
||||
// not silently discard the hero they picked last week.
|
||||
test('an upload merges into the existing overrides rather than replacing them', async () => {
|
||||
signInAs({ id: 1, username: 'a', role: 'admin', status: 'active' })
|
||||
settingsDb.get = async () => JSON.stringify({ hero: '/uploads/existing-hero.png' })
|
||||
let stored = null
|
||||
settingsDb.set = async (key, value) => {
|
||||
stored = value
|
||||
}
|
||||
const app = await startSettingsApp()
|
||||
try {
|
||||
const res = await fetch(`${app.url}/api/v1/admin/settings/brand-asset/favicon`, {
|
||||
method: 'POST',
|
||||
body: form(PNG),
|
||||
})
|
||||
assert.equal(res.status, 201)
|
||||
const saved = JSON.parse(stored)
|
||||
assert.equal(saved.hero, '/uploads/existing-hero.png', 'the hero survives')
|
||||
assert.match(saved.favicon, /^\/uploads\//)
|
||||
} finally {
|
||||
await app.close()
|
||||
}
|
||||
})
|
||||
|
||||
// §4.10: .ico would mean adding a type to MIME_EXT, and the stored extension
|
||||
// coming from that map is what makes the upload path safe. PNG only, and the
|
||||
// rejected file does not stay on disk.
|
||||
test('a favicon that is not a PNG is refused and the file is discarded', async () => {
|
||||
signInAs({ id: 1, username: 'a', role: 'admin', status: 'active' })
|
||||
settingsDb.set = async () => assert.fail('a refused upload must not write the row')
|
||||
const before = filesInUploadDir().length
|
||||
const app = await startSettingsApp()
|
||||
try {
|
||||
const res = await fetch(`${app.url}/api/v1/admin/settings/brand-asset/favicon`, {
|
||||
method: 'POST',
|
||||
body: form(GIF, { filename: 'x.gif', type: 'image/gif' }),
|
||||
})
|
||||
assert.equal(res.status, 400)
|
||||
assert.match((await res.json()).message, /PNG/)
|
||||
assert.equal(filesInUploadDir().length, before, 'no orphan file left behind')
|
||||
} finally {
|
||||
await app.close()
|
||||
}
|
||||
})
|
||||
|
||||
test('a file over the slot cap is refused and discarded', async () => {
|
||||
signInAs({ id: 1, username: 'a', role: 'admin', status: 'active' })
|
||||
settingsDb.set = async () => assert.fail('a refused upload must not write the row')
|
||||
// Valid PNG header, then padding past the favicon's 512 KB cap — the shared
|
||||
// multer limit is 8 MB, so only the per-slot rule can reject this.
|
||||
const big = Buffer.concat([PNG, Buffer.alloc(600 * 1024)])
|
||||
const before = filesInUploadDir().length
|
||||
const app = await startSettingsApp()
|
||||
try {
|
||||
const res = await fetch(`${app.url}/api/v1/admin/settings/brand-asset/favicon`, {
|
||||
method: 'POST',
|
||||
body: form(big),
|
||||
})
|
||||
assert.equal(res.status, 400)
|
||||
assert.match((await res.json()).message, /512 KB or smaller/)
|
||||
assert.equal(filesInUploadDir().length, before)
|
||||
} finally {
|
||||
await app.close()
|
||||
}
|
||||
})
|
||||
|
||||
test('the same file is accepted for a slot with a bigger cap', async () => {
|
||||
signInAs({ id: 1, username: 'a', role: 'admin', status: 'active' })
|
||||
settingsDb.get = async () => null
|
||||
settingsDb.set = async () => {}
|
||||
const big = Buffer.concat([PNG, Buffer.alloc(600 * 1024)])
|
||||
const app = await startSettingsApp()
|
||||
try {
|
||||
const res = await fetch(`${app.url}/api/v1/admin/settings/brand-asset/hero`, {
|
||||
method: 'POST',
|
||||
body: form(big),
|
||||
})
|
||||
assert.equal(res.status, 201)
|
||||
} finally {
|
||||
await app.close()
|
||||
}
|
||||
})
|
||||
|
||||
test('an unknown slot is refused and the file is discarded', async () => {
|
||||
signInAs({ id: 1, username: 'a', role: 'admin', status: 'active' })
|
||||
settingsDb.set = async () => assert.fail('an unknown slot must not write the row')
|
||||
const before = filesInUploadDir().length
|
||||
const app = await startSettingsApp()
|
||||
try {
|
||||
const res = await fetch(`${app.url}/api/v1/admin/settings/brand-asset/banner`, {
|
||||
method: 'POST',
|
||||
body: form(PNG),
|
||||
})
|
||||
assert.equal(res.status, 400)
|
||||
assert.equal(filesInUploadDir().length, before)
|
||||
} finally {
|
||||
await app.close()
|
||||
}
|
||||
})
|
||||
|
||||
// The generic POST /admin/uploads is reachable by editors. The site's identity
|
||||
// is not theirs to change, so this route carries the same admin gate as the
|
||||
// settings row it writes.
|
||||
test('an editor cannot upload a brand asset', async () => {
|
||||
signInAs({ id: 2, username: 'e', role: 'editor', status: 'active' })
|
||||
settingsDb.set = async () => assert.fail('an editor must not write brand_assets')
|
||||
const before = filesInUploadDir().length
|
||||
const app = await startSettingsApp()
|
||||
try {
|
||||
const res = await fetch(`${app.url}/api/v1/admin/settings/brand-asset/logo`, {
|
||||
method: 'POST',
|
||||
body: form(PNG),
|
||||
})
|
||||
assert.equal(res.status, 403)
|
||||
assert.equal(filesInUploadDir().length, before, 'the gate runs before multer writes')
|
||||
} finally {
|
||||
await app.close()
|
||||
}
|
||||
})
|
||||
|
||||
// ── PUT /admin/settings { brand_assets } — how a slot is CLEARED ──────
|
||||
//
|
||||
// There is no per-slot delete route: clearing the logo is a write of the
|
||||
// remaining slots, and clearing the last one is the existing reset-by-delete.
|
||||
|
||||
test('clearing a slot through the settings write drops it from the row', async () => {
|
||||
signInAs({ id: 1, username: 'a', role: 'admin', status: 'active' })
|
||||
let stored = null
|
||||
settingsDb.set = async (key, value) => {
|
||||
stored = value
|
||||
}
|
||||
settingsDb.getAll = async () => []
|
||||
const app = await startSettingsApp()
|
||||
try {
|
||||
const res = await fetch(`${app.url}/api/v1/admin/settings`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ brand_assets: { logo: '/uploads/a.png', hero: null, favicon: '' } }),
|
||||
})
|
||||
assert.equal(res.status, 200)
|
||||
assert.deepEqual(JSON.parse(stored), { logo: '/uploads/a.png' }, 'no null fields survive into the row')
|
||||
} finally {
|
||||
await app.close()
|
||||
}
|
||||
})
|
||||
|
||||
test('a settings write carrying an off-origin asset URL is rejected by field', async () => {
|
||||
signInAs({ id: 1, username: 'a', role: 'admin', status: 'active' })
|
||||
settingsDb.set = async () => assert.fail('an invalid brand_assets must not be stored')
|
||||
const app = await startSettingsApp()
|
||||
try {
|
||||
const res = await fetch(`${app.url}/api/v1/admin/settings`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ brand_assets: { logo: 'https://tracker.example/pixel.png' } }),
|
||||
})
|
||||
assert.equal(res.status, 400)
|
||||
assert.match((await res.json()).message, /brand_assets\.logo/)
|
||||
} finally {
|
||||
await app.close()
|
||||
}
|
||||
})
|
||||
|
||||
test('a successful upload invalidates the cached HTML shell', async () => {
|
||||
signInAs({ id: 1, username: 'a', role: 'admin', status: 'active' })
|
||||
settingsDb.get = async () => null
|
||||
settingsDb.set = async () => {}
|
||||
let invalidated = 0
|
||||
const realInvalidate = htmlShell.invalidate
|
||||
htmlShell.invalidate = () => {
|
||||
invalidated += 1
|
||||
}
|
||||
const app = await startSettingsApp()
|
||||
try {
|
||||
const res = await fetch(`${app.url}/api/v1/admin/settings/brand-asset/logo`, {
|
||||
method: 'POST',
|
||||
body: form(PNG),
|
||||
})
|
||||
assert.equal(res.status, 201)
|
||||
assert.equal(invalidated, 1, 'the favicon an admin just uploaded must not wait for the TTL')
|
||||
} finally {
|
||||
htmlShell.invalidate = realInvalidate
|
||||
await app.close()
|
||||
}
|
||||
})
|
||||
229
server/test/htmlShell.test.js
Normal file
229
server/test/htmlShell.test.js
Normal file
@@ -0,0 +1,229 @@
|
||||
// Point the DB at a closed port before the pool is built; the settings read is
|
||||
// monkeypatched in every test that reaches it, so no query runs.
|
||||
process.env.DB_HOST = '127.0.0.1'
|
||||
process.env.DB_PORT = '59999'
|
||||
// A brand URL, so the og:image absolutization of an uploaded path is exercised
|
||||
// rather than being dead code in the test environment.
|
||||
process.env.BRAND_URL = process.env.BRAND_URL || 'https://shard.example'
|
||||
// BRAND_LOGO defaults to empty (no logo image rendered), which would make the
|
||||
// "og:image still comes from env" assertions below pass vacuously.
|
||||
process.env.BRAND_LOGO = process.env.BRAND_LOGO || '/brand/logo.png'
|
||||
|
||||
const { test, afterEach, after } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
// The cached, settings-aware HTML shell (docs/website/THEMING_AND_NAV.md §4.3).
|
||||
// Three properties are load-bearing enough to lock here: that an untouched
|
||||
// instance gets byte-for-byte the shell it got before this feature existed, that
|
||||
// a DB fault still serves a page, and that the steady state is one cached string
|
||||
// rather than a settings read per page view.
|
||||
const htmlShell = require('../src/utils/htmlShell')
|
||||
const settings = require('../src/model/settings/settings.model')
|
||||
const brand = require('../src/config/brand')
|
||||
const db = require('../src/utils/db')
|
||||
|
||||
after(() => db.close())
|
||||
|
||||
const originalGetShellBrand = settings.getShellBrand
|
||||
afterEach(() => {
|
||||
settings.getShellBrand = originalGetShellBrand
|
||||
})
|
||||
|
||||
// A stand-in for the built client/dist/index.html: the two tags the shell
|
||||
// rewrites plus the stylesheet link the theme block has to follow.
|
||||
const TEMPLATE = `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>Vite App</title>
|
||||
<meta name="description" content="placeholder" />
|
||||
<link rel="stylesheet" href="/assets/index-abc123.css" />
|
||||
</head>
|
||||
<body><div id="root"></div></body>
|
||||
</html>`
|
||||
|
||||
// The shell app.js served BEFORE this phase, reproduced verbatim. The point of
|
||||
// the test is that this string and the new renderer's output are identical for
|
||||
// an instance with no brand_assets and no theme_visual row (§9), so it is copied
|
||||
// rather than imported.
|
||||
function legacyRenderIndexHtml(html) {
|
||||
const htmlEscape = (s) =>
|
||||
String(s).replace(
|
||||
/[&<>"']/g,
|
||||
(c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]),
|
||||
)
|
||||
const title = htmlEscape(brand.name)
|
||||
const desc = htmlEscape(brand.description)
|
||||
const tags = [
|
||||
`<meta property="og:title" content="${title}" />`,
|
||||
`<meta property="og:description" content="${desc}" />`,
|
||||
'<meta property="og:type" content="website" />',
|
||||
brand.url ? `<meta property="og:url" content="${htmlEscape(brand.url)}" />` : '',
|
||||
brand.logo ? `<meta property="og:image" content="${htmlEscape(brand.logo)}" />` : '',
|
||||
'<meta name="twitter:card" content="summary_large_image" />',
|
||||
`<meta name="twitter:title" content="${title}" />`,
|
||||
`<meta name="twitter:description" content="${desc}" />`,
|
||||
brand.favicon ? `<link rel="icon" href="${htmlEscape(brand.favicon)}" />` : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n ')
|
||||
return html
|
||||
.replace(/<title>[\s\S]*?<\/title>/i, `<title>${title}</title>`)
|
||||
.replace(/(<meta\s+name="description"\s+content=")[\s\S]*?("\s*\/?>)/i, `$1${desc}$2`)
|
||||
.replace(/<\/head>/i, ` ${tags}\n </head>`)
|
||||
}
|
||||
|
||||
// ── The byte-identical guarantee (§9) ─────────────────────────────────
|
||||
|
||||
test('with no overrides the shell is byte-identical to the pre-feature one', () => {
|
||||
assert.equal(htmlShell.render(TEMPLATE, {}), legacyRenderIndexHtml(TEMPLATE))
|
||||
})
|
||||
|
||||
test('an empty theme and empty assets are the same as no overrides at all', () => {
|
||||
// A row that parsed to nothing usable resolves to null/undefined rather than
|
||||
// to an empty block, or "reset" would leave a `<style>:root{}` behind forever.
|
||||
assert.equal(htmlShell.render(TEMPLATE, { theme: null }), legacyRenderIndexHtml(TEMPLATE))
|
||||
assert.equal(htmlShell.render(TEMPLATE, { theme: {} }), legacyRenderIndexHtml(TEMPLATE))
|
||||
})
|
||||
|
||||
// ── Brand assets ──────────────────────────────────────────────────────
|
||||
|
||||
test('an uploaded favicon replaces the env one and touches nothing else', () => {
|
||||
const html = htmlShell.render(TEMPLATE, { favicon: '/uploads/1-a.png' })
|
||||
assert.match(html, /<link rel="icon" href="\/uploads\/1-a\.png" \/>/)
|
||||
assert.ok(!html.includes(`href="${brand.favicon}"`), 'the env favicon is gone')
|
||||
// §9: setting only the favicon changes the favicon only.
|
||||
const ogImage = html.match(/<meta property="og:image" content="([^"]*)"/)
|
||||
assert.equal(ogImage ? ogImage[1] : '', brand.logo, 'og:image still resolves from env')
|
||||
})
|
||||
|
||||
test('an uploaded logo becomes og:image, absolutized against BRAND_URL', () => {
|
||||
const html = htmlShell.render(TEMPLATE, { logo: '/uploads/2-b.png' })
|
||||
assert.match(html, /<meta property="og:image" content="https:\/\/shard\.example\/uploads\/2-b\.png" \/>/)
|
||||
})
|
||||
|
||||
test('an env logo is passed through untouched even when relative', () => {
|
||||
// The shell an instance gets today is the operator's choice; only an uploaded
|
||||
// path — which is always relative and is read off-site by scrapers — is made
|
||||
// absolute. Anything else would break the byte-identical guarantee above.
|
||||
const html = htmlShell.render(TEMPLATE, {})
|
||||
const ogImage = html.match(/<meta property="og:image" content="([^"]*)"/)
|
||||
assert.equal(ogImage ? ogImage[1] : '', brand.logo)
|
||||
})
|
||||
|
||||
// ── The theme boot block (removes the first-paint flash) ───────────────
|
||||
|
||||
test('a resolved theme is emitted as a :root block after the stylesheet', () => {
|
||||
const html = htmlShell.render(TEMPLATE, { theme: { '--accent': '#123456', '--bg': '#0b0f14' } })
|
||||
assert.match(html, /<style id="theme-boot">:root\{--accent:#123456;--bg:#0b0f14\}<\/style>/)
|
||||
// Custom properties are equal-specificity, so the later block wins: it must
|
||||
// come after the built stylesheet or a themed instance would paint :root.
|
||||
assert.ok(
|
||||
html.indexOf('theme-boot') > html.indexOf('/assets/index-abc123.css'),
|
||||
'the theme block follows the stylesheet link',
|
||||
)
|
||||
})
|
||||
|
||||
test('a token that could carry markup is dropped, not escaped into the block', () => {
|
||||
const html = htmlShell.render(TEMPLATE, {
|
||||
theme: { '--accent': '#123456', '--x': '</style><script>alert(1)</script>', 'color': 'red' },
|
||||
})
|
||||
assert.match(html, /<style id="theme-boot">:root\{--accent:#123456\}<\/style>/)
|
||||
assert.ok(!html.includes('alert(1)'), 'no injected markup survives')
|
||||
assert.ok(!html.includes('color:red'), 'a non-custom-property name never reaches the block')
|
||||
})
|
||||
|
||||
// ── Caching, invalidation and the DB-fault fallback ────────────────────
|
||||
|
||||
test('the shell is rendered once and then served from cache', async () => {
|
||||
let reads = 0
|
||||
settings.getShellBrand = async () => {
|
||||
reads += 1
|
||||
return { logo: brand.logo, favicon: '/uploads/cached.png', theme: null }
|
||||
}
|
||||
htmlShell.init(TEMPLATE)
|
||||
const first = await htmlShell.get()
|
||||
const second = await htmlShell.get()
|
||||
assert.equal(reads, 1, 'a settings read per page view would put the DB on every route')
|
||||
assert.equal(first, second)
|
||||
assert.match(first, /\/uploads\/cached\.png/)
|
||||
})
|
||||
|
||||
test('a burst of requests on a cold cache does one read', async () => {
|
||||
let reads = 0
|
||||
settings.getShellBrand = async () => {
|
||||
reads += 1
|
||||
await new Promise((r) => setTimeout(r, 5))
|
||||
return { logo: brand.logo, favicon: brand.favicon, theme: null }
|
||||
}
|
||||
htmlShell.init(TEMPLATE)
|
||||
await Promise.all([htmlShell.get(), htmlShell.get(), htmlShell.get()])
|
||||
assert.equal(reads, 1)
|
||||
})
|
||||
|
||||
test('invalidate() makes the next request re-read', async () => {
|
||||
let favicon = '/uploads/old.png'
|
||||
let reads = 0
|
||||
settings.getShellBrand = async () => {
|
||||
reads += 1
|
||||
return { logo: brand.logo, favicon, theme: null }
|
||||
}
|
||||
htmlShell.init(TEMPLATE)
|
||||
assert.match(await htmlShell.get(), /old\.png/)
|
||||
favicon = '/uploads/new.png'
|
||||
assert.match(await htmlShell.get(), /old\.png/, 'still cached until told otherwise')
|
||||
htmlShell.invalidate()
|
||||
assert.match(await htmlShell.get(), /new\.png/)
|
||||
assert.equal(reads, 2)
|
||||
})
|
||||
|
||||
test('the cache expires on its own, so a second process converges', async () => {
|
||||
let favicon = '/uploads/old.png'
|
||||
settings.getShellBrand = async () => ({ logo: brand.logo, favicon, theme: null })
|
||||
htmlShell.init(TEMPLATE)
|
||||
assert.match(await htmlShell.get(), /old\.png/)
|
||||
// Nothing invalidates here: this is the worker that did NOT handle the write.
|
||||
favicon = '/uploads/new.png'
|
||||
const realNow = Date.now
|
||||
Date.now = () => realNow() + htmlShell.TTL_MS + 1
|
||||
try {
|
||||
assert.match(await htmlShell.get(), /new\.png/)
|
||||
} finally {
|
||||
Date.now = realNow
|
||||
}
|
||||
})
|
||||
|
||||
test('a settings read that throws serves the env-only shell instead of failing', async () => {
|
||||
settings.getShellBrand = async () => {
|
||||
throw new Error('ER_CON_COUNT_ERROR')
|
||||
}
|
||||
htmlShell.init(TEMPLATE)
|
||||
assert.equal(await htmlShell.get(), legacyRenderIndexHtml(TEMPLATE))
|
||||
})
|
||||
|
||||
test('the fallback is cached too — an outage is not a query per page view', async () => {
|
||||
let reads = 0
|
||||
settings.getShellBrand = async () => {
|
||||
reads += 1
|
||||
throw new Error('down')
|
||||
}
|
||||
htmlShell.init(TEMPLATE)
|
||||
await htmlShell.get()
|
||||
await htmlShell.get()
|
||||
assert.equal(reads, 1)
|
||||
})
|
||||
|
||||
test('an invalidation during a render is not overwritten by the stale result', async () => {
|
||||
let favicon = '/uploads/old.png'
|
||||
settings.getShellBrand = async () => {
|
||||
const value = favicon
|
||||
await new Promise((r) => setTimeout(r, 10))
|
||||
return { logo: brand.logo, favicon: value, theme: null }
|
||||
}
|
||||
htmlShell.init(TEMPLATE)
|
||||
const inflight = htmlShell.get() // reads 'old'
|
||||
favicon = '/uploads/new.png'
|
||||
htmlShell.invalidate() // the write lands mid-render
|
||||
await inflight
|
||||
assert.match(await htmlShell.get(), /new\.png/, 'the pre-write value must not have been cached')
|
||||
})
|
||||
289
server/test/navOverrides.test.js
Normal file
289
server/test/navOverrides.test.js
Normal file
@@ -0,0 +1,289 @@
|
||||
// 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'])
|
||||
})
|
||||
|
||||
// ── Sections and added links (phase 10) ───────────────────────────────────
|
||||
//
|
||||
// The public header may carry admin-created dropdown sections and links the
|
||||
// admin authored. The invariant that has to survive is structural: `items` may
|
||||
// only key routes the code declares, so it can never introduce one, while
|
||||
// `links` is the one place an arbitrary path may be named — and is therefore
|
||||
// the one place the path rule is applied.
|
||||
|
||||
const WRAPPED = {
|
||||
items: { '/site/champs': { section: 'sec_abcd', order: 0 } },
|
||||
sections: [{ id: 'sec_abcd', label: 'The World', order: 3 }],
|
||||
links: [{ id: 'lnk_wxyz', label: 'Guide', to: '/wiki/new-player-guide', section: 'sec_abcd', order: 1 }],
|
||||
}
|
||||
|
||||
test('a bare items map is still valid and still stored as-is', () => {
|
||||
// Phases 6-8 wrote this shape, and a nav that does not use sections keeps it.
|
||||
assert.equal(validateNavOverrides({ '/site/news': { label: 'N' } }, 'nav_public').ok, true)
|
||||
assert.deepEqual(resolveNavOverrides({ '/site/news': { label: 'N' } }, 'nav_public'), {
|
||||
'/site/news': { label: 'N' },
|
||||
})
|
||||
})
|
||||
|
||||
test('a wrapped value round-trips with its sections and links', () => {
|
||||
assert.equal(validateNavOverrides(WRAPPED, 'nav_public').ok, true)
|
||||
assert.deepEqual(resolveNavOverrides(WRAPPED, 'nav_public'), WRAPPED)
|
||||
})
|
||||
|
||||
test('an added link must point at this site', () => {
|
||||
for (const to of ['https://evil.example', '//evil.example/x', 'javascript:alert(1)', 'wiki/guide', '/a b', '/a"b']) {
|
||||
const check = validateNavOverrides(
|
||||
{ items: {}, links: [{ id: 'lnk_wxyz', label: 'Bad', to }] },
|
||||
'nav_public',
|
||||
)
|
||||
assert.equal(check.ok, false, `${to} should be refused`)
|
||||
assert.match(check.message, /must be a path on this site/)
|
||||
}
|
||||
})
|
||||
|
||||
test('a link to a path that happens to be gated is allowed — the page is the gate', () => {
|
||||
// An added link carries no roles/feature of its own and does not need one: the
|
||||
// route behind it enforces its own access, exactly as typing the URL would.
|
||||
const check = validateNavOverrides(
|
||||
{ items: {}, links: [{ id: 'lnk_wxyz', label: 'Admin', to: '/admin/users' }] },
|
||||
'nav_public',
|
||||
)
|
||||
assert.equal(check.ok, true)
|
||||
})
|
||||
|
||||
test('section and link ids are constrained, and duplicates refused', () => {
|
||||
const bad = [
|
||||
[{ sections: [{ id: 'nope', label: 'X' }] }, /invalid id/],
|
||||
[{ sections: [{ id: 'sec_AB', label: 'X' }] }, /invalid id/],
|
||||
[{ sections: [{ id: 'sec_abcd', label: '' }] }, /label must be text/],
|
||||
[{ sections: [{ id: 'sec_abcd', label: 'A' }, { id: 'sec_abcd', label: 'B' }] }, /duplicate id/],
|
||||
[{ links: [{ id: 'sec_abcd', label: 'X', to: '/x' }] }, /invalid id/],
|
||||
[{ links: [{ id: 'lnk_abcd', label: 'A', to: '/a' }, { id: 'lnk_abcd', label: 'B', to: '/b' }] }, /duplicate id/],
|
||||
]
|
||||
for (const [extra, pattern] of bad) {
|
||||
const check = validateNavOverrides({ items: {}, ...extra }, 'nav_public')
|
||||
assert.equal(check.ok, false, JSON.stringify(extra))
|
||||
assert.match(check.message, pattern)
|
||||
}
|
||||
})
|
||||
|
||||
test('sections and links are bounded', () => {
|
||||
const sections = Array.from({ length: 13 }, (_, i) => ({ id: `sec_a${String(i).padStart(3, '0')}`, label: 'S' }))
|
||||
assert.match(validateNavOverrides({ items: {}, sections }, 'nav_public').message, /at most 12 sections/)
|
||||
const links = Array.from({ length: 41 }, (_, i) => ({ id: `lnk_a${String(i).padStart(3, '0')}`, label: 'L', to: '/x' }))
|
||||
assert.match(validateNavOverrides({ items: {}, links }, 'nav_public').message, /at most 40 added links/)
|
||||
})
|
||||
|
||||
test('sections and links are dropped for the navs that cannot render them', () => {
|
||||
// The admin sidebar has its own coded sections and the player portal is three
|
||||
// flat rows; only the public header supports this.
|
||||
for (const key of ['nav_admin', 'nav_player']) {
|
||||
const out = resolveNavOverrides(WRAPPED, key)
|
||||
assert.equal(out.sections, undefined, key)
|
||||
assert.equal(out.links, undefined, key)
|
||||
// The item survives, minus the section it can no longer belong to.
|
||||
assert.deepEqual(out, { '/site/champs': { order: 0 } })
|
||||
}
|
||||
})
|
||||
|
||||
test('an item or link naming a section that does not exist falls to the top level', () => {
|
||||
const out = resolveNavOverrides(
|
||||
{
|
||||
items: { '/site/champs': { section: 'sec_gone', order: 2 } },
|
||||
sections: [{ id: 'sec_abcd', label: 'Real' }],
|
||||
links: [{ id: 'lnk_wxyz', label: 'L', to: '/x', section: 'sec_gone' }],
|
||||
},
|
||||
'nav_public',
|
||||
)
|
||||
assert.equal(out.items['/site/champs'].section, undefined)
|
||||
assert.equal(out.links[0].section, undefined)
|
||||
})
|
||||
|
||||
test('an unusable section or link is dropped, its neighbours kept', () => {
|
||||
const out = resolveNavOverrides(
|
||||
{
|
||||
items: {},
|
||||
sections: [{ id: 'sec_abcd', label: 'Keep' }, { id: 'bad', label: 'Drop' }],
|
||||
links: [
|
||||
{ id: 'lnk_aaaa', label: 'Keep', to: '/keep' },
|
||||
{ id: 'lnk_bbbb', label: 'Drop', to: 'https://evil.example' },
|
||||
],
|
||||
},
|
||||
'nav_public',
|
||||
)
|
||||
assert.deepEqual(out.sections.map((s) => s.label), ['Keep'])
|
||||
assert.deepEqual(out.links.map((l) => l.label), ['Keep'])
|
||||
})
|
||||
|
||||
test('a wrapper that resolves to nothing usable comes back empty', () => {
|
||||
// The caller deletes the row rather than storing a wrapper that says nothing.
|
||||
assert.deepEqual(resolveNavOverrides({ items: {}, sections: [], links: [] }, 'nav_public'), {})
|
||||
assert.deepEqual(resolveNavOverrides({ items: 'nope' }, 'nav_public'), {})
|
||||
})
|
||||
@@ -56,6 +56,79 @@ test('admin site_title / contact_email override the brand defaults', async () =>
|
||||
assert.equal(pub.brand.accent, brand.accent) // colors still from config
|
||||
})
|
||||
|
||||
// ── Effective theming (THEMING_AND_NAV.md §4.5) ───────────────────────
|
||||
//
|
||||
// brand.accent and the asset fields are a cross-repo contract: the Android app
|
||||
// themes its whole Material palette from brand.accent and the Discord bot
|
||||
// colors its embeds from it. Resolving the EFFECTIVE value here is what lets
|
||||
// both track admin theming with no client change — so these tests are really
|
||||
// about the app and the bot, not about the website.
|
||||
|
||||
test('no theme row leaves the brand block exactly as env defines it', async () => {
|
||||
// Explicitly re-asserted next to the theming cases: this is the acceptance
|
||||
// criterion the whole feature rests on, and it is the assertion a future
|
||||
// change to the resolver would break first.
|
||||
const pub = await settings.getPublic()
|
||||
assert.equal(pub.theme, undefined, 'no theme block at all when untouched')
|
||||
assert.equal(pub.brand.accent, brand.accent)
|
||||
assert.equal(pub.brand.logo, brand.logo)
|
||||
assert.equal(pub.brand.hero, brand.hero)
|
||||
assert.equal(pub.brand.favicon, brand.favicon)
|
||||
})
|
||||
|
||||
test('a theme preset overrides brand.accent and ships the token block', async () => {
|
||||
settingsDb.getAll = async () => [{ key: 'theme_visual', value: JSON.stringify({ preset: 'fantasy' }) }]
|
||||
const pub = await settings.getPublic()
|
||||
assert.equal(pub.brand.accent, '#c9973f', 'the app sees the themed accent, not BRAND_ACCENT_COLOR')
|
||||
assert.equal(pub.theme['--accent'], '#c9973f')
|
||||
assert.equal(pub.theme['--bg'], '#1a120b')
|
||||
// Assets are a different key and must not move with the theme.
|
||||
assert.equal(pub.brand.logo, brand.logo)
|
||||
assert.equal(pub.brand.hero, brand.hero)
|
||||
})
|
||||
|
||||
test('a custom accent beats the preset accent in brand.accent', async () => {
|
||||
settingsDb.getAll = async () => [
|
||||
{ key: 'theme_visual', value: JSON.stringify({ preset: 'fantasy', custom: { colors: { accent: '#123456' } } }) },
|
||||
]
|
||||
const pub = await settings.getPublic()
|
||||
assert.equal(pub.brand.accent, '#123456')
|
||||
assert.equal(pub.theme['--accent'], '#123456')
|
||||
})
|
||||
|
||||
test('a malformed theme row reads as absent, not as an error', async () => {
|
||||
for (const value of ['{oops', '"x"', '{"preset":"parchment"}']) {
|
||||
settingsDb.getAll = async () => [{ key: 'theme_visual', value }]
|
||||
const pub = await settings.getPublic()
|
||||
assert.equal(pub.theme, undefined, value)
|
||||
assert.equal(pub.brand.accent, brand.accent, value)
|
||||
}
|
||||
})
|
||||
|
||||
test('brand_assets overrides one asset without disturbing the others', async () => {
|
||||
settingsDb.getAll = async () => [
|
||||
{ key: 'brand_assets', value: JSON.stringify({ favicon: '/uploads/1234-abcd.png' }) },
|
||||
]
|
||||
const pub = await settings.getPublic()
|
||||
assert.equal(pub.brand.favicon, '/uploads/1234-abcd.png')
|
||||
assert.equal(pub.brand.logo, brand.logo, 'logo still from env')
|
||||
assert.equal(pub.brand.hero, brand.hero, 'hero still from env')
|
||||
})
|
||||
|
||||
test('a malformed brand_assets row falls back to env for every asset', async () => {
|
||||
settingsDb.getAll = async () => [{ key: 'brand_assets', value: 'not json' }]
|
||||
const pub = await settings.getPublic()
|
||||
assert.equal(pub.brand.logo, brand.logo)
|
||||
assert.equal(pub.brand.hero, brand.hero)
|
||||
assert.equal(pub.brand.favicon, brand.favicon)
|
||||
})
|
||||
|
||||
test('the Discord-only integer accent is still never exposed, themed or not', async () => {
|
||||
settingsDb.getAll = async () => [{ key: 'theme_visual', value: JSON.stringify({ preset: 'modern' }) }]
|
||||
const pub = await settings.getPublic()
|
||||
assert.equal(pub.brand.accentInt, undefined)
|
||||
})
|
||||
|
||||
// The push relay block the app's embedded distributor discovers its ntfy base
|
||||
// URL from (M7 Part 2). Null when nothing is configured; NTFY_PUBLIC_URL wins,
|
||||
// else the first NTFY_ALLOWED_ORIGINS entry; NTFY_BASE_URL is never surfaced.
|
||||
|
||||
@@ -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(
|
||||
|
||||
417
server/test/settingsTheming.test.js
Normal file
417
server/test/settingsTheming.test.js
Normal file
@@ -0,0 +1,417 @@
|
||||
// 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)
|
||||
})
|
||||
|
||||
// ── PUT /admin/settings — theme_visual is validated on the way in ──────────
|
||||
//
|
||||
// The read path drops anything invalid anyway, so this is about feedback, not
|
||||
// safety: an admin whose save appears to succeed and then does nothing has no
|
||||
// way to tell what was wrong.
|
||||
|
||||
test('a valid theme_visual 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 theme = { preset: 'fantasy', custom: { colors: { accent: '#123456' } } }
|
||||
const res = await fetch(`${app.url}/api/v1/admin/settings`, {
|
||||
method: 'PUT',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ theme_visual: theme }),
|
||||
})
|
||||
assert.equal(res.status, 200)
|
||||
// settings.value is TEXT — an object body must reach the store stringified.
|
||||
assert.equal(written.theme_visual, JSON.stringify(theme))
|
||||
} finally {
|
||||
settingsDb.set = originals.set
|
||||
await app.close()
|
||||
}
|
||||
})
|
||||
|
||||
test('an invalid theme_visual is rejected and nothing is written', async () => {
|
||||
signInAs({ id: 1, username: 'a', role: 'admin', status: 'active' })
|
||||
settingsDb.set = () => assert.fail('an invalid theme must not be stored')
|
||||
const app = await startApp((a) => a.use('/api/v1/admin/settings', requireAuth, settingsRouter))
|
||||
try {
|
||||
const bad = [
|
||||
{ preset: 'parchment' },
|
||||
{ preset: 'custom', custom: { colors: { accent: 'red' } } },
|
||||
{ preset: 'custom', custom: { fonts: { sans: 'Comic Sans MS' } } },
|
||||
{ preset: 'custom', custom: { structure: { radiusCard: '4em' } } },
|
||||
{ preset: 'custom', custom: { spacing: { unit: '8px' } } },
|
||||
'not json',
|
||||
]
|
||||
for (const theme_visual of bad) {
|
||||
const res = await fetch(`${app.url}/api/v1/admin/settings`, {
|
||||
method: 'PUT',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ theme_visual }),
|
||||
})
|
||||
assert.equal(res.status, 400, JSON.stringify(theme_visual))
|
||||
const body = await res.json()
|
||||
assert.match(body.message, /theme_visual/)
|
||||
}
|
||||
} finally {
|
||||
settingsDb.set = originals.set
|
||||
await app.close()
|
||||
}
|
||||
})
|
||||
|
||||
// ── 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 () => {
|
||||
signInAs({ id: 7, username: 'u', role: 'admin', status: 'active' })
|
||||
const app = await startApp((a) => a.use('/api/v1/settings', navSettingsRouter))
|
||||
try {
|
||||
const res = await fetch(`${app.url}/api/v1/settings/theme/options`)
|
||||
assert.equal(res.status, 200)
|
||||
const body = await res.json()
|
||||
assert.ok(Array.isArray(body.presets) && body.presets.length === 4, 'three presets plus Custom')
|
||||
assert.ok(body.fonts.serif.length && body.fonts.display.length && body.fonts.sans.length)
|
||||
assert.ok(body.shadows.length)
|
||||
assert.ok(body.colorFields.some((f) => f.name === 'accent' && f.token === '--accent'))
|
||||
assert.equal(body.shippedTokens['--accent'], '#7f99bd')
|
||||
} finally {
|
||||
await app.close()
|
||||
}
|
||||
})
|
||||
|
||||
test('GET /settings/theme/options 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/theme/options`)
|
||||
assert.equal(res.status, 401)
|
||||
} finally {
|
||||
await app.close()
|
||||
}
|
||||
})
|
||||
|
||||
// ── 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' })
|
||||
})
|
||||
262
server/test/themeResolve.test.js
Normal file
262
server/test/themeResolve.test.js
Normal file
@@ -0,0 +1,262 @@
|
||||
// theme_visual — the strict write path and the fail-safe read path.
|
||||
//
|
||||
// The two halves are deliberately asymmetric (see utils/themeResolve.js): a
|
||||
// write is rejected with the offending field named, while a read drops bad
|
||||
// fields one at a time and falls back to the shipped :root. These tests lock
|
||||
// that asymmetry, because it is the thing most likely to get "tidied" into a
|
||||
// single shared check later.
|
||||
process.env.DB_HOST = '127.0.0.1'
|
||||
process.env.DB_PORT = '59999'
|
||||
|
||||
const { test } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const { validateThemeVisual, resolveThemeTokens, themeOptions } = require('../src/utils/themeResolve')
|
||||
const { PRESETS, FONT_OPTIONS, SHADOW_OPTIONS } = require('../src/config/themePresets')
|
||||
|
||||
// ── validateThemeVisual (write path) ──────────────────────────────────
|
||||
test('accepts a bare preset choice', () => {
|
||||
for (const preset of ['runic-gateway', 'modern', 'fantasy', 'custom']) {
|
||||
assert.equal(validateThemeVisual({ preset }).ok, true, preset)
|
||||
}
|
||||
assert.equal(validateThemeVisual({ preset: 'fantasy', custom: null }).ok, true)
|
||||
})
|
||||
|
||||
test('rejects an unknown preset', () => {
|
||||
const res = validateThemeVisual({ preset: 'parchment' })
|
||||
assert.equal(res.ok, false)
|
||||
assert.match(res.message, /preset must be one of/)
|
||||
})
|
||||
|
||||
test('rejects a non-object, and unknown top-level fields', () => {
|
||||
for (const bad of [null, 4, 'fantasy', []]) {
|
||||
assert.equal(validateThemeVisual(bad).ok, false)
|
||||
}
|
||||
const res = validateThemeVisual({ preset: 'modern', mode: 'light' })
|
||||
assert.equal(res.ok, false)
|
||||
assert.match(res.message, /unknown field\(s\) mode/)
|
||||
})
|
||||
|
||||
test('accepts custom colors, fonts and structure from the closed sets', () => {
|
||||
const res = validateThemeVisual({
|
||||
preset: 'custom',
|
||||
custom: {
|
||||
colors: { accent: '#c9973f', bg: '#000' },
|
||||
fonts: { display: 'Cinzel, Georgia, serif', sans: 'Inter, Arial, sans-serif' },
|
||||
structure: { radiusCard: '4px', shadowDepth: SHADOW_OPTIONS[0].value },
|
||||
},
|
||||
})
|
||||
assert.equal(res.ok, true, res.message)
|
||||
})
|
||||
|
||||
test('rejects a color that is not a hex literal', () => {
|
||||
// The point of the closed set: a CSS function or keyword never reaches a
|
||||
// custom property value, whatever it would or would not have done there.
|
||||
for (const bad of ['red', 'rgb(1,2,3)', 'url(http://x/y)', '#12345', 'var(--bg)', '#ff0000; x']) {
|
||||
const res = validateThemeVisual({ preset: 'custom', custom: { colors: { accent: bad } } })
|
||||
assert.equal(res.ok, false, bad)
|
||||
assert.match(res.message, /colors: invalid value for accent/)
|
||||
}
|
||||
})
|
||||
|
||||
test('rejects a font stack that is not on the shortlist', () => {
|
||||
const res = validateThemeVisual({ preset: 'custom', custom: { fonts: { sans: 'Comic Sans MS, sans-serif' } } })
|
||||
assert.equal(res.ok, false)
|
||||
assert.match(res.message, /fonts: invalid value for sans/)
|
||||
})
|
||||
|
||||
test('rejects a font offered for a different role', () => {
|
||||
// Cinzel is a display face and is not in the sans list.
|
||||
const res = validateThemeVisual({ preset: 'custom', custom: { fonts: { sans: 'Cinzel, Georgia, serif' } } })
|
||||
assert.equal(res.ok, false)
|
||||
})
|
||||
|
||||
test('rejects an out-of-range or unitless radius', () => {
|
||||
for (const bad of ['1000px', '4', '4em', '-4px', 'calc(4px + 1px)']) {
|
||||
const res = validateThemeVisual({ preset: 'custom', custom: { structure: { radiusCard: bad } } })
|
||||
assert.equal(res.ok, false, bad)
|
||||
}
|
||||
assert.equal(validateThemeVisual({ preset: 'custom', custom: { structure: { radiusCard: '999px' } } }).ok, true)
|
||||
})
|
||||
|
||||
test('rejects a shadow that is not one of the listed depths', () => {
|
||||
const res = validateThemeVisual({ preset: 'custom', custom: { structure: { shadowDepth: '0 0 99px red' } } })
|
||||
assert.equal(res.ok, false)
|
||||
})
|
||||
|
||||
test('rejects unknown groups and unknown fields inside a group', () => {
|
||||
assert.equal(validateThemeVisual({ preset: 'custom', custom: { spacing: { unit: '8px' } } }).ok, false)
|
||||
const res = validateThemeVisual({ preset: 'custom', custom: { colors: { border: '#fff' } } })
|
||||
assert.equal(res.ok, false)
|
||||
assert.match(res.message, /invalid value for border/)
|
||||
})
|
||||
|
||||
// ── resolveThemeTokens (read path) ────────────────────────────────────
|
||||
|
||||
// The acceptance criterion the whole feature rests on: absent means absent, and
|
||||
// the caller writes nothing.
|
||||
test('no row, or an unusable one, resolves to null', () => {
|
||||
for (const nothing of [null, undefined, '', 'not json', '4', '"x"', '[]', '{}', '{"preset":"custom"}']) {
|
||||
assert.equal(resolveThemeTokens(nothing), null, JSON.stringify(nothing))
|
||||
}
|
||||
})
|
||||
|
||||
test('a preset resolves to its full palette', () => {
|
||||
const tokens = resolveThemeTokens(JSON.stringify({ preset: 'fantasy' }))
|
||||
assert.deepEqual(tokens, PRESETS.fantasy.tokens)
|
||||
// Not a partial palette: the supporting shades move with it, or a warm theme
|
||||
// keeps dark-blue borders.
|
||||
assert.equal(tokens['--line'], '#4a3721')
|
||||
assert.equal(tokens['--blue'], '#382613')
|
||||
})
|
||||
|
||||
test('semantic status colors are never themed', () => {
|
||||
for (const preset of Object.values(PRESETS)) {
|
||||
assert.equal('--mode-live' in preset.tokens, false)
|
||||
assert.equal('--mode-maint' in preset.tokens, false)
|
||||
}
|
||||
})
|
||||
|
||||
test('the derived panel gradient is never written as a literal', () => {
|
||||
for (const preset of Object.values(PRESETS)) {
|
||||
assert.equal('--panel-grad' in preset.tokens, false)
|
||||
}
|
||||
})
|
||||
|
||||
test('runic-gateway is exactly the shipped stylesheet values', () => {
|
||||
const t = PRESETS['runic-gateway'].tokens
|
||||
assert.equal(t['--bg'], '#0e1318')
|
||||
assert.equal(t['--accent'], '#7f99bd')
|
||||
assert.equal(t['--radius-pill'], '999px')
|
||||
assert.equal(t['--radius-panel'], '12px')
|
||||
assert.equal(t['--radius-card'], '10px')
|
||||
assert.equal(t['--radius-input'], '8px')
|
||||
assert.equal(t['--serif'], 'Georgia, "Times New Roman", serif')
|
||||
assert.equal(t['--display'], 'Cinzel, Georgia, serif')
|
||||
assert.equal(t['--sans'], '"Helvetica Neue", Arial, sans-serif')
|
||||
})
|
||||
|
||||
// The one place the server duplicates the stylesheet, so the one place that can
|
||||
// drift: switching to runic-gateway after trying another preset must land back
|
||||
// on exactly what :root ships, not on a stale copy of it.
|
||||
test('the runic-gateway preset matches theme.css :root token for token', () => {
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
const cssPath = path.join(__dirname, '..', '..', 'client', 'src', 'styles', 'theme.css')
|
||||
const root = /:root\s*\{([\s\S]*?)\}/.exec(fs.readFileSync(cssPath, 'utf8'))
|
||||
assert.ok(root, 'theme.css has a :root block')
|
||||
const declared = {}
|
||||
for (const line of root[1].split(';')) {
|
||||
const m = /^\s*(--[a-z0-9-]+)\s*:\s*([\s\S]+?)\s*$/i.exec(line.replace(/\/\*[\s\S]*?\*\//g, ''))
|
||||
if (m) declared[m[1]] = m[2]
|
||||
}
|
||||
for (const [token, value] of Object.entries(PRESETS['runic-gateway'].tokens)) {
|
||||
// --shadow-card is the exception: theme.css writes rgba() unspaced and the
|
||||
// preset writes it spaced, which is the same computed value. Compare with
|
||||
// whitespace normalized rather than exempting the token entirely.
|
||||
assert.equal(
|
||||
String(declared[token]).replace(/\s+/g, ''),
|
||||
value.replace(/\s+/g, ''),
|
||||
`${token} drifted from theme.css`,
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
test('custom fields layer on top of the preset, per field', () => {
|
||||
const tokens = resolveThemeTokens(
|
||||
JSON.stringify({ preset: 'fantasy', custom: { colors: { accent: '#ffffff' } } }),
|
||||
)
|
||||
assert.equal(tokens['--accent'], '#ffffff') // overridden
|
||||
assert.equal(tokens['--bg'], PRESETS.fantasy.tokens['--bg']) // untouched
|
||||
assert.equal(tokens['--radius-card'], '2px') // untouched
|
||||
})
|
||||
|
||||
test('custom with no preset base yields only the fields that were set', () => {
|
||||
const tokens = resolveThemeTokens(
|
||||
JSON.stringify({ preset: 'custom', custom: { structure: { radiusCard: '4px' } } }),
|
||||
)
|
||||
assert.deepEqual(tokens, { '--radius-card': '4px' })
|
||||
})
|
||||
|
||||
// Fail-safe, field by field: a hand-edited row degrades to the shipped default
|
||||
// for the bad field only, rather than rendering a broken site or throwing.
|
||||
test('an invalid field is dropped without discarding its neighbours', () => {
|
||||
const tokens = resolveThemeTokens(
|
||||
JSON.stringify({ preset: 'custom', custom: { colors: { accent: 'red', bg: '#000000' } } }),
|
||||
)
|
||||
assert.deepEqual(tokens, { '--bg': '#000000' })
|
||||
})
|
||||
|
||||
test('an unknown preset still applies the custom fields', () => {
|
||||
const tokens = resolveThemeTokens(
|
||||
JSON.stringify({ preset: 'parchment', custom: { colors: { accent: '#ffffff' } } }),
|
||||
)
|
||||
assert.deepEqual(tokens, { '--accent': '#ffffff' })
|
||||
})
|
||||
|
||||
test('accepts an already-parsed object as well as the stored string', () => {
|
||||
assert.deepEqual(resolveThemeTokens({ preset: 'modern' }), PRESETS.modern.tokens)
|
||||
})
|
||||
|
||||
test('every resolved value is a plain string', () => {
|
||||
const tokens = resolveThemeTokens(JSON.stringify({ preset: 'modern' }))
|
||||
for (const [name, value] of Object.entries(tokens)) {
|
||||
assert.match(name, /^--[a-z-]+$/, name)
|
||||
assert.equal(typeof value, 'string', name)
|
||||
}
|
||||
})
|
||||
|
||||
// ── themeOptions (the catalog the admin form is built from) ───────────
|
||||
|
||||
// The reason the catalog is served rather than duplicated client-side: every
|
||||
// option offered must be one validateThemeVisual() accepts.
|
||||
test('every offered font and shadow validates', () => {
|
||||
const opts = themeOptions()
|
||||
for (const [role, options] of Object.entries(opts.fonts)) {
|
||||
for (const o of options) {
|
||||
const res = validateThemeVisual({ preset: 'custom', custom: { fonts: { [role]: o.value } } })
|
||||
assert.equal(res.ok, true, `${role}: ${o.value} — ${res.message || ''}`)
|
||||
}
|
||||
}
|
||||
for (const o of opts.shadows) {
|
||||
const res = validateThemeVisual({ preset: 'custom', custom: { structure: { shadowDepth: o.value } } })
|
||||
assert.equal(res.ok, true, o.value)
|
||||
}
|
||||
})
|
||||
|
||||
test('every offered preset id validates and every field name is editable', () => {
|
||||
const opts = themeOptions()
|
||||
for (const p of opts.presets) {
|
||||
assert.equal(validateThemeVisual({ preset: p.id }).ok, true, p.id)
|
||||
}
|
||||
for (const { name } of opts.colorFields) {
|
||||
assert.equal(validateThemeVisual({ preset: 'custom', custom: { colors: { [name]: '#123456' } } }).ok, true, name)
|
||||
}
|
||||
for (const { name } of opts.radiusFields) {
|
||||
assert.equal(validateThemeVisual({ preset: 'custom', custom: { structure: { [name]: '5px' } } }).ok, true, name)
|
||||
}
|
||||
})
|
||||
|
||||
// The form reads each control's current value out of the preset map by token
|
||||
// name, so every advertised field must actually resolve to one.
|
||||
test('every advertised field names a token the presets declare', () => {
|
||||
const opts = themeOptions()
|
||||
for (const { name, token } of [...opts.colorFields, ...opts.radiusFields]) {
|
||||
assert.ok(token.startsWith('--'), `${name} → ${token}`)
|
||||
assert.ok(token in opts.shippedTokens, `${token} missing from the shipped theme`)
|
||||
for (const p of opts.presets) {
|
||||
if (p.tokens) assert.ok(token in p.tokens, `${token} missing from preset ${p.id}`)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('the shipped default font stacks are reachable from the shortlist', () => {
|
||||
// An admin who customizes fonts must be able to get back to today's look
|
||||
// without resetting the whole theme.
|
||||
const serifValues = FONT_OPTIONS.serif.map((o) => o.value)
|
||||
const sansValues = FONT_OPTIONS.sans.map((o) => o.value)
|
||||
const displayValues = FONT_OPTIONS.display.map((o) => o.value)
|
||||
assert.ok(serifValues.includes('Georgia, "Times New Roman", serif'))
|
||||
assert.ok(sansValues.includes('"Helvetica Neue", Arial, sans-serif'))
|
||||
assert.ok(displayValues.includes('Cinzel, Georgia, serif'))
|
||||
})
|
||||
Reference in New Issue
Block a user