The eighteen routes of docs/website/TEAMS.md §2.11, their OpenAPI annotations,
and the staff screen that drives them.
Two rules shape the read model. Hidden means absent from every public surface --
the index, the lookup and the roster alike, and a hidden Team 404s
indistinguishably from one that does not exist, because "absent" includes not
confirming it is there. And staleness is surfaced rather than silent: every
public payload carries { configured, stale, lastSyncAt }, so a page can say how
recently the projection was confirmed instead of presenting stale data as
current.
The public roster withholds both the member key and the user id -- one is a
game-internal identifier, the other names a site account. `linked` answers the
only question a public page has without publishing which account. The module's
per-audience field projection is phase 3's; this is a conservative core one.
The §2.9 gate is enforced per REQUEST, not per route. A moderator may call all
eighteen; three of them mean something different when they do, and the server
decides from the role it re-validates on every request rather than from a token
claim. The client has no "file as request" argument to get wrong.
Found by booting the real server against the real database, and not by any test:
**the index and the by-slug lookup disagreed about what exists.** listPublic was
keyed on a registered team provider while findBySlug is not, so with no module
installed `/teams` returned an empty list while `/teams/:slug/members` served a
full roster -- the index denying a Team that direct URLs answered for in full.
The rows are core's and they outlive the module that filled them: an uninstalled
module leaves a projection that is unmaintained, not one that stopped existing,
and `configured: false` is how a client learns that. The read side no longer
takes the provider into account at all. There is now a test named for the
property.
Also verified live: the public routes answer anonymously, an unknown and a hidden
slug both 404, the player and admin tiers 401 an anonymous caller, a seeded
roster projects correctly, and the reconciler logs that it is staying idle with
no provider registered rather than failing a boot.
Process obligations, all done: #swagger.* annotations on every route, `npm run
swagger` regenerated (18 paths in the spec, no dangling $refs, and the schemas
they reference added), `npm run routes:manifest` regenerated -- additions only,
184 public routes -- and BACKEND_DESIGN.md updated across the schema section and
all three tier tables.
Admin -> Teams follows the ModulesAdmin precedent: everything that decides what a
row SAYS lives in lib/teamAdmin.js, which is plain JS with tests, and the view
renders it. That split earns itself here specifically -- the screen's job is to
make "the shard has no Teams" and "core has not been able to ask for two hours"
impossible to confuse, and those two produce the same empty table. The four
freshness states are named and tested for exactly that reason, and the last
provider error is shown verbatim rather than paraphrased.
The button labels follow the caller's role: a moderator sees "Request publish",
so the pending result is not a surprise. Hiding is offered to everyone with no
gate, matching the server.
Server 894 passed, client 206 passed, client build clean. 17 route tests, 20
client display tests.
Refs docs/website/TEAMS.md §2.11, Part 12 phase 2
Co-Authored-By: Claude <noreply@anthropic.com>
239 lines
12 KiB
JavaScript
239 lines
12 KiB
JavaScript
import { Routes, Route, Navigate, Outlet } from 'react-router-dom'
|
|
import { AuthProvider } from './contexts/AuthContext.jsx'
|
|
import { SiteProvider } from './contexts/SiteContext.jsx'
|
|
import MaintenanceGate from './components/MaintenanceGate.jsx'
|
|
import RequireAuth from './components/RequireAuth.jsx'
|
|
import RequirePlayer from './components/RequirePlayer.jsx'
|
|
import RoleGate from './components/RoleGate.jsx'
|
|
import { routesFor } from './modules/registry.js'
|
|
import { ModuleFeaturesProvider } from './modules/features.jsx'
|
|
|
|
// Public
|
|
import Portal from './routes/public/Portal.jsx'
|
|
import Website from './routes/public/Website.jsx'
|
|
import News from './routes/public/News.jsx'
|
|
import Screenshots from './routes/public/Screenshots.jsx'
|
|
import FiveOnFriday from './routes/public/FiveOnFriday.jsx'
|
|
import Newsletter from './routes/public/Newsletter.jsx'
|
|
import NewsletterIssue from './routes/public/NewsletterIssue.jsx'
|
|
import About from './routes/public/About.jsx'
|
|
import Status from './routes/public/Status.jsx'
|
|
import Wiki from './routes/wiki/Wiki.jsx'
|
|
import WikiArticle from './routes/wiki/WikiArticle.jsx'
|
|
import CmsPage from './routes/public/CmsPage.jsx'
|
|
|
|
// Admin
|
|
import AdminLogin from './routes/admin/AdminLogin.jsx'
|
|
import AdminLayout from './routes/admin/AdminLayout.jsx'
|
|
import Dashboard from './routes/admin/views/Dashboard.jsx'
|
|
import PostsAdmin from './routes/admin/views/PostsAdmin.jsx'
|
|
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'
|
|
import DiscordBotAdmin from './routes/admin/views/DiscordBotAdmin.jsx'
|
|
import AuthProvidersAdmin from './routes/admin/views/AuthProvidersAdmin.jsx'
|
|
import UsersAdmin from './routes/admin/views/UsersAdmin.jsx'
|
|
import UserDetail from './routes/admin/views/UserDetail.jsx'
|
|
import InvitesAdmin from './routes/admin/views/InvitesAdmin.jsx'
|
|
import ModulesAdmin from './routes/admin/views/ModulesAdmin.jsx'
|
|
import TeamsAdmin from './routes/admin/views/TeamsAdmin.jsx'
|
|
import AccountAdmin from './routes/admin/views/AccountAdmin.jsx'
|
|
import Moderation from './routes/admin/views/Moderation.jsx'
|
|
import ModerationUser from './routes/admin/views/ModerationUser.jsx'
|
|
import Appeals from './routes/admin/views/Appeals.jsx'
|
|
|
|
// Player portal
|
|
import PlayerLogin from './routes/player/PlayerLogin.jsx'
|
|
import PlayerRegister from './routes/player/PlayerRegister.jsx'
|
|
import ForgotPassword from './routes/player/ForgotPassword.jsx'
|
|
import ResetPassword from './routes/player/ResetPassword.jsx'
|
|
import AcceptInvite from './routes/player/AcceptInvite.jsx'
|
|
import PlayerPortalLayout, { PlayerIndex } from './routes/player/PlayerPortalLayout.jsx'
|
|
import PlayerAccount from './routes/player/PlayerAccount.jsx'
|
|
import PlayerAppeals from './routes/player/PlayerAppeals.jsx'
|
|
|
|
export default function App() {
|
|
return (
|
|
<AuthProvider>
|
|
<SiteProvider>
|
|
{/* Inside the auth and site contexts, because a feature provider is a
|
|
hook that may well read either — a live-status one does, indirectly,
|
|
by asking an endpoint whose answer depends on the session. Outside the
|
|
routes, so the nav in every layout is filtered by the same gate and
|
|
the provider hooks are called once for the whole app rather than
|
|
once per screen. */}
|
|
<ModuleFeaturesProvider>
|
|
<Routes>
|
|
{/* Landing hero — always public, even in maintenance mode. The hero is
|
|
itself the pre-launch "coming soon" page, so it sits outside the
|
|
MaintenanceGate and every visitor sees it regardless of auth/site mode. */}
|
|
<Route path="/" element={<Portal />} />
|
|
|
|
{/* Rest of the public site — gated by maintenance mode (admins preview through it) */}
|
|
<Route
|
|
element={
|
|
<MaintenanceGate>
|
|
<Outlet />
|
|
</MaintenanceGate>
|
|
}
|
|
>
|
|
<Route path="/site" element={<Website />} />
|
|
<Route path="/site/news" element={<News />} />
|
|
<Route path="/site/screenshots" element={<Screenshots />} />
|
|
<Route path="/site/five-on-friday" element={<FiveOnFriday />} />
|
|
<Route path="/site/newsletter" element={<Newsletter />} />
|
|
<Route path="/site/newsletter/:id" element={<NewsletterIssue />} />
|
|
<Route path="/site/about" element={<About />} />
|
|
<Route path="/site/status" element={<Status />} />
|
|
<Route path="/wiki" element={<Wiki />} />
|
|
<Route path="/wiki/:slug" element={<WikiArticle />} />
|
|
{/* Installed modules' public pages, namespaced `/<id>/…` — the
|
|
registry prefixes the segment, so a module cannot spell its way
|
|
out of it (docs/website/MODULE_API.md §3.3). Declared before the
|
|
CMS catch-all below: React Router ranks a static segment over a
|
|
dynamic one, so the order is not what saves us, but keeping the
|
|
two adjacent makes the relationship visible to whoever adds the
|
|
next route here. */}
|
|
{routesFor('public').map((r) => (
|
|
<Route key={r.path} path={`/${r.path}`} element={r.element} />
|
|
))}
|
|
{/* CMS pages: top-level /:slug, matched only after the named routes
|
|
above (React Router ranks static routes over this dynamic one). */}
|
|
<Route path="/:slug" element={<CmsPage />} />
|
|
</Route>
|
|
|
|
{/* Draft-preview link (token-gated). Outside the maintenance gate so a
|
|
preview link works regardless of site mode. */}
|
|
<Route path="/preview/:id/:token" element={<CmsPage preview />} />
|
|
|
|
{/* Admin */}
|
|
<Route path="/admin/login" element={<AdminLogin />} />
|
|
<Route
|
|
path="/admin"
|
|
element={
|
|
<RequireAuth>
|
|
<AdminLayout />
|
|
</RequireAuth>
|
|
}
|
|
>
|
|
<Route index element={<Dashboard />} />
|
|
<Route path="posts" element={<PostsAdmin />} />
|
|
<Route path="pages" element={<PagesAdmin />} />
|
|
<Route path="pages/new" element={<PageBuilder />} />
|
|
<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"
|
|
element={
|
|
<RoleGate roles={['admin', 'moderator']}>
|
|
<Outlet />
|
|
</RoleGate>
|
|
}
|
|
>
|
|
<Route index element={<Moderation />} />
|
|
<Route path="user/:discordId" element={<ModerationUser />} />
|
|
<Route path="appeals" element={<Appeals />} />
|
|
</Route>
|
|
<Route path="activity" element={<ActivityAdmin />} />
|
|
<Route path="bot-activity" element={<BotActivityAdmin />} />
|
|
<Route path="discord-bot" element={<DiscordBotAdmin />} />
|
|
<Route path="auth-providers" element={<AuthProvidersAdmin />} />
|
|
<Route path="users" element={<UsersAdmin />} />
|
|
<Route path="users/:id" element={<UserDetail />} />
|
|
<Route path="invites" element={<InvitesAdmin />} />
|
|
{/* Core's own screen, and it has to be: it is how a module reaches
|
|
the volume in the first place. Declared here with the rest of
|
|
core's routes, above the module-supplied ones below. */}
|
|
<Route path="modules" element={<ModulesAdmin />} />
|
|
{/* Staff-wide, like the moderation queues: the gate on the three
|
|
actions that publish a game-written name is applied per request
|
|
on the server, from the caller's live role (TEAMS.md 2.9). */}
|
|
<Route path="teams" element={<TeamsAdmin />} />
|
|
<Route path="account" element={<AccountAdmin />} />
|
|
{/* Installed modules' admin pages, at /admin/<id>/…, already inside
|
|
RequireAuth + AdminLayout. A module cannot supply its own auth
|
|
wrapper — only an optional { roles }, which core applies as the
|
|
same RoleGate its own routes above use, so the sidebar and the
|
|
route table cannot disagree about who may see what. Before the
|
|
`*` redirect, which would otherwise swallow every one of them. */}
|
|
{routesFor('admin').map((r) => (
|
|
<Route
|
|
key={r.path}
|
|
path={r.path}
|
|
element={r.gate ? <RoleGate roles={r.gate.roles}>{r.element}</RoleGate> : r.element}
|
|
/>
|
|
))}
|
|
<Route path="*" element={<Navigate to="/admin" replace />} />
|
|
</Route>
|
|
|
|
{/* Player portal */}
|
|
<Route path="/account/login" element={<PlayerLogin />} />
|
|
<Route path="/account/register" element={<PlayerRegister />} />
|
|
<Route path="/account/forgot" element={<ForgotPassword />} />
|
|
<Route path="/account/reset/:token" element={<ResetPassword />} />
|
|
<Route path="/invite/:token" element={<AcceptInvite />} />
|
|
<Route
|
|
element={
|
|
<RequirePlayer>
|
|
<PlayerPortalLayout />
|
|
</RequirePlayer>
|
|
}
|
|
>
|
|
{/* The portal index resolves to the first nav row this viewer can
|
|
reach rather than naming a page: `PlayerCharacters` was a UO
|
|
page and left with the client half (MODULE_SYSTEM.md §2.7.1).
|
|
With the UO module installed that is still Characters. */}
|
|
<Route path="/player" element={<PlayerIndex />} />
|
|
<Route path="/account" element={<PlayerAccount />} />
|
|
<Route path="/account/appeals" element={<PlayerAppeals />} />
|
|
{/* Installed modules' player-portal pages, at /player/<id>/…. This
|
|
group's own routes are absolute (its layout route has no path),
|
|
so the prefix is written here rather than inherited — the one
|
|
place the three areas do not read alike. */}
|
|
{routesFor('player').map((r) => (
|
|
<Route
|
|
key={r.path}
|
|
path={`/player/${r.path}`}
|
|
element={r.gate ? <RoleGate roles={r.gate.roles}>{r.element}</RoleGate> : r.element}
|
|
/>
|
|
))}
|
|
</Route>
|
|
|
|
<Route path="*" element={<Navigate to="/" replace />} />
|
|
</Routes>
|
|
</ModuleFeaturesProvider>
|
|
</SiteProvider>
|
|
</AuthProvider>
|
|
)
|
|
}
|