241 client tests pass (224 before).
**The forum panel becomes a forum.** It was "Announcements" with one composer;
it now has two, because phase 5 split one server capability into two: `canPost`
means "may open a discussion" and every participant may — a granted guest with no
game character included, which is path 3 doing its job — while `canAnnounce` is
the leader-only half `canPost` used to carry alone. Threads gain replies, an edit
control, per-post moderation and a report control, all still inside the one slot
the module declares, still navigating by `?thread=`.
**Almost nothing here is the client's decision, and the file says so.** `canPost`,
`canAnnounce`, `canReply` and each post's `canEdit`/`editableUntil` are read, not
computed. The one local judgement is a ticking clock that WITHDRAWS an edit offer
whose deadline passed while the page sat open — it can never grant one, because a
time-bounded permission must not take its clock from the party it bounds. That
asymmetry is the first thing client/test/teamForum.test.js asserts.
The panel's pure parts moved to `lib/teamForum.js` so they can be tested without a
browser, following teamActivity.js and teamAdmin.js. Two of them are subtler than
they look:
* `stripToText` decodes entities AFTER stripping tags, and `&` last of all.
Decoding first turns an author's literal "<script>" into a real tag the
strip pass then deletes — silently losing text that was never dangerous.
* `threadSummary` counts REPLIES, which is one fewer than `postCount`. Showing
the raw count tells a reader a brand-new thread already has one reply.
**Three admin surfaces.** The forum settings screen gains the edit-window field
(0 = posts permanent once written). The reports queue is a new screen beside
Appeals — under moderation rather than under Teams, because a staffer working a
queue should have one place to work and `target_type` is deliberately open-ended,
so the next reportable thing arrives as a row rather than as another nav entry.
Its copy tells a member where a report lands and that reporting changes nothing,
because a member who expects a post to vanish and watches it stay reports it
again. There is no leader-facing view and there is not meant to be.
And the per-Team forum moderation ledger finally renders: the route and
`api.admin.teamForumModeration()` have both existed since phase 4 with nothing
calling them, which made `actor_role` — the column that keeps a leader's ordinary
housekeeping distinguishable from a staff intervention — readable only from a DB
client.
Co-Authored-By: Claude <noreply@anthropic.com>
241 lines
12 KiB
JavaScript
241 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'
|
|
import ContentReports from './routes/admin/views/ContentReports.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 path="reports" element={<ContentReports />} />
|
|
</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>
|
|
)
|
|
}
|