The screen slice 1's API was written for: install from a release URL, enable, disable, uninstall, purge, and restart. Admin-only, matching the server, and core's own screen because it is how a module reaches the volume at all. 182 client tests (+21), manifest and OpenAPI unchanged. Everything that decides what a row SAYS and which buttons it offers is in `lib/moduleAdmin.js` -- plain JS, so the DOM-less runner can reach it, the same reason `lib/adminNav.js` is. The JSX renders what it returns. Three sources of truth, and they are allowed to disagree -------------------------------------------------------- The row records what the operator decided and what the last boot did; the loader says what is mounted and answering; the volume says whether there is a directory at all. Picking one and rendering it is simpler and lies. The case that makes it concrete is the one decision 3 creates on purpose: disable a module (its onShutdown runs) and enable it again, and the row says `enabled` while the loader still says `disabled` because nothing can start it before a restart. Neither "Running" nor "Disabled" is true; "Restart to start" is. Two shapes that are deliberately unlike the rest of the panel: the restart is a BANNER, because a restart is a property of the server rather than of a module and an operator who installed three modules should restart once; and purge is offered inside the uninstall flow as a second confirm, because purge.sql lives inside the directory being deleted and there is no later. What the browser found that no test could ----------------------------------------- Installing over a row the previous boot had left `startup_failed` rendered "Failed at the require stage: module directory not present on the volume" one second after the files had been written to the volume -- and, because that branch is not pending, it suppressed the restart banner the install had just told the operator to use. Every unit test passed, because none of them had modelled a stale row plus a fresh install. The fix is a derivation rather than a special case: the loader scans the volume once at require time, so a module that is on the volume now and has no live record arrived after that scan, and everything the row says about it predates the install. That check runs before the failure one. The same class, one place further on: an upgrade leaves the old code loaded, so the row's version is a promise about the next boot. `liveVersion` (slice 1) lets the screen say "Restart to finish upgrading" instead of reporting the new version as running. Verified against a live server and the real published release: pasted the v0.3.0 install-manifest URL, restarted, watched the module register its five mounts and seven streams and its own nav rows appear in the sidebar. Disable ran its onShutdown for real -- the uo-link WebSocket closed, its routes went to 404, and it left /public/modules -- and enable then showed the decision-3 state with the banner. The restart button itself was exercised through its endpoint rather than clicked, because a window.confirm wedges the browser automation. Co-Authored-By: Claude <noreply@anthropic.com>
234 lines
12 KiB
JavaScript
234 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 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 />} />
|
|
<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>
|
|
)
|
|
}
|