Loading panel/src/App.tsx +17 −6 Changes for panel/src/App.tsx: 17 added lines, 6 removed lines. Original line number Diff line number Diff line Loading @@ -7,6 +7,8 @@ import { AppShell } from "@/components/AppShell"; import { RequireAdmin } from "@/components/RequireAdmin"; import { RequireAuth } from "@/components/RequireAuth"; import { RequireOwner } from "@/components/RequireOwner"; import { ValidParam } from "@/components/ValidParam"; import { SERVER_NAME_PARAM, USER_ID_PARAM } from "@/lib/params"; import { Login } from "@/pages/Login"; import { Setup } from "@/pages/Setup"; import { Dashboard } from "@/pages/Dashboard"; Loading Loading @@ -70,11 +72,18 @@ export default function App() { {/* User-Side — app-tier */} <Route index element={<Dashboard />} /> <Route path="servers" element={<ServersPage />} /> <Route path="servers/:name" element={<ServerConsole />} /> <Route path="servers/:name/players" element={<ServerPlayers />} /> <Route path="servers/:name/backups" element={<ServerBackups />} /> <Route path="servers/:name/files" element={<ServerFiles />} /> <Route path="servers/:name/luckperms" element={<ServerLuckPerms />} /> {/* A :name that is not a server name (a crafted link carrying "/", "?" or "..") renders "not found" before any request. */} <Route path="servers/:name" element={<ValidParam param="name" pattern={SERVER_NAME_PARAM} />} > <Route index element={<ServerConsole />} /> <Route path="players" element={<ServerPlayers />} /> <Route path="backups" element={<ServerBackups />} /> <Route path="files" element={<ServerFiles />} /> <Route path="luckperms" element={<ServerLuckPerms />} /> </Route> <Route path="submissions" element={<MySubmissionsPage />} /> <Route path="account" element={<Account />} /> Loading @@ -90,7 +99,9 @@ export default function App() { {/* Owner-gated: user management (one level above admin). */} <Route element={<RequireOwner />}> <Route path="users" element={<UsersPage />} /> <Route path="users/:id" element={<UserDetailPage />} /> <Route path="users/:id" element={<ValidParam param="id" pattern={USER_ID_PARAM} />}> <Route index element={<UserDetailPage />} /> </Route> </Route> </Route> Loading panel/src/components/AppShell.tsx +34 −2 Changes for panel/src/components/AppShell.tsx: 34 added lines, 2 removed lines. Original line number Diff line number Diff line import { useEffect, useState } from "react"; import { NavLink, Outlet, useLocation } from "react-router-dom"; import { Cat, Globe, Sun, Moon, LogOut } from "lucide-react"; import { Cat, Globe, Sun, Moon, LogOut, WifiOff } from "lucide-react"; import { useTranslation } from "react-i18next"; import * as SelectPrimitive from "@radix-ui/react-select"; import { cn } from "@/lib/utils"; Loading @@ -8,7 +8,8 @@ import { useTier } from "@/lib/tier"; import { visibleSections, type NavSection } from "@/lib/nav"; import { Select, SelectContent, SelectItem } from "@/components/ui/select"; import { useTheme } from "@/lib/theme"; import { api } from "@/lib/api"; import { api, CONNECTION_EVENT, isConnectionLost } from "@/lib/api"; import { Button } from "@/components/ui/button"; import { ErrorBoundary } from "@/components/ErrorBoundary"; function SectionGroup({ section, isFirst }: { section: NavSection; isFirst: boolean }) { Loading Loading @@ -137,6 +138,36 @@ function ThemeToggle() { ); } // ConnectionBanner shows while API calls get no response at all (api.ts // CONNECTION_EVENT): the network is down, or Cloudflare Access sent the call to // its login page. It clears on the next call that gets through (the pages' // polling makes one soon); an expired Access sign-in only passes on a full page // load, hence the reload button. function ConnectionBanner() { const { t } = useTranslation("common"); const [lost, setLost] = useState(isConnectionLost); useEffect(() => { const onChange = (e: Event) => setLost(!(e as CustomEvent<{ ok: boolean }>).detail.ok); window.addEventListener(CONNECTION_EVENT, onChange); return () => window.removeEventListener(CONNECTION_EVENT, onChange); }, []); if (!lost) return null; return ( <div role="alert" className="flex flex-wrap items-center justify-between gap-2 border-b border-amber-500/30 bg-amber-500/10 px-4 py-2 text-sm text-amber-800 dark:text-amber-200" > <span className="flex min-w-0 items-center gap-2"> <WifiOff className="h-4 w-4 shrink-0" /> {t("connection_lost")} </span> <Button size="sm" variant="outline" onClick={() => window.location.reload()}> {t("reload_page")} </Button> </div> ); } export function AppShell() { const { isAdmin, isOwner } = useTier(); const { pathname } = useLocation(); Loading Loading @@ -190,6 +221,7 @@ export function AppShell() { </aside> <div className="flex min-w-0 flex-1 flex-col h-full overflow-y-auto"> <ConnectionBanner /> <header className="flex h-14 items-center justify-between border-b border-border px-4 md:hidden"> <div className="flex items-center gap-2"> <Cat className="h-5 w-5 text-primary" /> Loading panel/src/components/RequireAuth.tsx +11 −4 Changes for panel/src/components/RequireAuth.tsx: 11 added lines, 4 removed lines. Original line number Diff line number Diff line import { Navigate, Outlet } from "react-router-dom"; import { Navigate, Outlet, useLocation } from "react-router-dom"; import { Loader2 } from "lucide-react"; import { useTranslation } from "react-i18next"; import { useTier } from "@/lib/tier"; Loading @@ -7,7 +7,9 @@ import { useTier } from "@/lib/tier"; // and everything under it). It encodes the three-way verdict from the tier model: // // loading → a full-screen spinner (never flash login during boot /me) // unauthenticated → /login (a genuine 401: no/expired session) // unauthenticated → /login (a genuine 401: no/expired session), carrying // the page to return to (?next=) and, when a session // ended under an open page, state.sessionEnded // otherwise → render the app (<Outlet/>) // // The "otherwise" branch deliberately includes the graded-Zero-Trust degraded case Loading @@ -15,8 +17,9 @@ import { useTier } from "@/lib/tier"; // app still renders User-Side, exactly as before local auth existed. Only a true // 401 bounces to /login. Every admin route remains independently server-guarded. export function RequireAuth() { const { loading, unauthenticated } = useTier(); const { loading, unauthenticated, sessionEnded } = useTier(); const { t } = useTranslation("common"); const location = useLocation(); if (loading) { return ( Loading @@ -26,6 +29,10 @@ export function RequireAuth() { </div> ); } if (unauthenticated) return <Navigate to="/login" replace />; if (unauthenticated) { const back = location.pathname + location.search; const to = back === "/" ? "/login" : `/login?next=${encodeURIComponent(back)}`; return <Navigate to={to} replace state={sessionEnded ? { sessionEnded: true } : undefined} />; } return <Outlet />; } panel/src/components/States.tsx +17 −1 Changes for panel/src/components/States.tsx: 17 added lines, 1 removed line. Original line number Diff line number Diff line import { Loader2, AlertTriangle, Inbox, ShieldX, Construction, Moon } from "lucide-react"; import { Loader2, AlertTriangle, Inbox, ShieldX, Construction, Moon, SearchX } from "lucide-react"; import { Link } from "react-router-dom"; import { useTranslation } from "react-i18next"; import { Button } from "@/components/ui/button"; Loading Loading @@ -71,6 +71,22 @@ export function NotAuthorized() { ); } export function NotFound() { const { t } = useTranslation("common"); return ( <div className="mx-auto flex max-w-md flex-col items-center justify-center gap-3 py-24 text-center"> <SearchX className="h-8 w-8 text-muted-foreground" /> <div> <p className="font-medium">{t("not_found_title")}</p> <p className="mt-1 text-sm text-muted-foreground">{t("not_found_body")}</p> </div> <Link to="/" className="text-sm font-medium text-primary hover:underline"> {t("back_to_dashboard")} </Link> </div> ); } /** * PendingBackend is an HONEST placeholder for a surface whose backend read does * not exist yet (DESIGN-WEB-3SIDES non-goal #3: "a page with no real backend Loading panel/src/components/ValidParam.tsx 0 → 100644 +10 −0 Changes for panel/src/components/ValidParam.tsx: 10 added lines, 0 removed lines. Original line number Diff line number Diff line import { Outlet, useParams } from "react-router-dom"; import { NotFound } from "@/components/States"; // ValidParam is a layout route that renders its children only when one URL // parameter has the expected shape (lib/params.ts), and "not found" otherwise. export function ValidParam({ param, pattern }: { param: string; pattern: RegExp }) { const value = useParams()[param]; if (value === undefined || !pattern.test(value)) return <NotFound />; return <Outlet />; } Loading
panel/src/App.tsx +17 −6 Changes for panel/src/App.tsx: 17 added lines, 6 removed lines. Original line number Diff line number Diff line Loading @@ -7,6 +7,8 @@ import { AppShell } from "@/components/AppShell"; import { RequireAdmin } from "@/components/RequireAdmin"; import { RequireAuth } from "@/components/RequireAuth"; import { RequireOwner } from "@/components/RequireOwner"; import { ValidParam } from "@/components/ValidParam"; import { SERVER_NAME_PARAM, USER_ID_PARAM } from "@/lib/params"; import { Login } from "@/pages/Login"; import { Setup } from "@/pages/Setup"; import { Dashboard } from "@/pages/Dashboard"; Loading Loading @@ -70,11 +72,18 @@ export default function App() { {/* User-Side — app-tier */} <Route index element={<Dashboard />} /> <Route path="servers" element={<ServersPage />} /> <Route path="servers/:name" element={<ServerConsole />} /> <Route path="servers/:name/players" element={<ServerPlayers />} /> <Route path="servers/:name/backups" element={<ServerBackups />} /> <Route path="servers/:name/files" element={<ServerFiles />} /> <Route path="servers/:name/luckperms" element={<ServerLuckPerms />} /> {/* A :name that is not a server name (a crafted link carrying "/", "?" or "..") renders "not found" before any request. */} <Route path="servers/:name" element={<ValidParam param="name" pattern={SERVER_NAME_PARAM} />} > <Route index element={<ServerConsole />} /> <Route path="players" element={<ServerPlayers />} /> <Route path="backups" element={<ServerBackups />} /> <Route path="files" element={<ServerFiles />} /> <Route path="luckperms" element={<ServerLuckPerms />} /> </Route> <Route path="submissions" element={<MySubmissionsPage />} /> <Route path="account" element={<Account />} /> Loading @@ -90,7 +99,9 @@ export default function App() { {/* Owner-gated: user management (one level above admin). */} <Route element={<RequireOwner />}> <Route path="users" element={<UsersPage />} /> <Route path="users/:id" element={<UserDetailPage />} /> <Route path="users/:id" element={<ValidParam param="id" pattern={USER_ID_PARAM} />}> <Route index element={<UserDetailPage />} /> </Route> </Route> </Route> Loading
panel/src/components/AppShell.tsx +34 −2 Changes for panel/src/components/AppShell.tsx: 34 added lines, 2 removed lines. Original line number Diff line number Diff line import { useEffect, useState } from "react"; import { NavLink, Outlet, useLocation } from "react-router-dom"; import { Cat, Globe, Sun, Moon, LogOut } from "lucide-react"; import { Cat, Globe, Sun, Moon, LogOut, WifiOff } from "lucide-react"; import { useTranslation } from "react-i18next"; import * as SelectPrimitive from "@radix-ui/react-select"; import { cn } from "@/lib/utils"; Loading @@ -8,7 +8,8 @@ import { useTier } from "@/lib/tier"; import { visibleSections, type NavSection } from "@/lib/nav"; import { Select, SelectContent, SelectItem } from "@/components/ui/select"; import { useTheme } from "@/lib/theme"; import { api } from "@/lib/api"; import { api, CONNECTION_EVENT, isConnectionLost } from "@/lib/api"; import { Button } from "@/components/ui/button"; import { ErrorBoundary } from "@/components/ErrorBoundary"; function SectionGroup({ section, isFirst }: { section: NavSection; isFirst: boolean }) { Loading Loading @@ -137,6 +138,36 @@ function ThemeToggle() { ); } // ConnectionBanner shows while API calls get no response at all (api.ts // CONNECTION_EVENT): the network is down, or Cloudflare Access sent the call to // its login page. It clears on the next call that gets through (the pages' // polling makes one soon); an expired Access sign-in only passes on a full page // load, hence the reload button. function ConnectionBanner() { const { t } = useTranslation("common"); const [lost, setLost] = useState(isConnectionLost); useEffect(() => { const onChange = (e: Event) => setLost(!(e as CustomEvent<{ ok: boolean }>).detail.ok); window.addEventListener(CONNECTION_EVENT, onChange); return () => window.removeEventListener(CONNECTION_EVENT, onChange); }, []); if (!lost) return null; return ( <div role="alert" className="flex flex-wrap items-center justify-between gap-2 border-b border-amber-500/30 bg-amber-500/10 px-4 py-2 text-sm text-amber-800 dark:text-amber-200" > <span className="flex min-w-0 items-center gap-2"> <WifiOff className="h-4 w-4 shrink-0" /> {t("connection_lost")} </span> <Button size="sm" variant="outline" onClick={() => window.location.reload()}> {t("reload_page")} </Button> </div> ); } export function AppShell() { const { isAdmin, isOwner } = useTier(); const { pathname } = useLocation(); Loading Loading @@ -190,6 +221,7 @@ export function AppShell() { </aside> <div className="flex min-w-0 flex-1 flex-col h-full overflow-y-auto"> <ConnectionBanner /> <header className="flex h-14 items-center justify-between border-b border-border px-4 md:hidden"> <div className="flex items-center gap-2"> <Cat className="h-5 w-5 text-primary" /> Loading
panel/src/components/RequireAuth.tsx +11 −4 Changes for panel/src/components/RequireAuth.tsx: 11 added lines, 4 removed lines. Original line number Diff line number Diff line import { Navigate, Outlet } from "react-router-dom"; import { Navigate, Outlet, useLocation } from "react-router-dom"; import { Loader2 } from "lucide-react"; import { useTranslation } from "react-i18next"; import { useTier } from "@/lib/tier"; Loading @@ -7,7 +7,9 @@ import { useTier } from "@/lib/tier"; // and everything under it). It encodes the three-way verdict from the tier model: // // loading → a full-screen spinner (never flash login during boot /me) // unauthenticated → /login (a genuine 401: no/expired session) // unauthenticated → /login (a genuine 401: no/expired session), carrying // the page to return to (?next=) and, when a session // ended under an open page, state.sessionEnded // otherwise → render the app (<Outlet/>) // // The "otherwise" branch deliberately includes the graded-Zero-Trust degraded case Loading @@ -15,8 +17,9 @@ import { useTier } from "@/lib/tier"; // app still renders User-Side, exactly as before local auth existed. Only a true // 401 bounces to /login. Every admin route remains independently server-guarded. export function RequireAuth() { const { loading, unauthenticated } = useTier(); const { loading, unauthenticated, sessionEnded } = useTier(); const { t } = useTranslation("common"); const location = useLocation(); if (loading) { return ( Loading @@ -26,6 +29,10 @@ export function RequireAuth() { </div> ); } if (unauthenticated) return <Navigate to="/login" replace />; if (unauthenticated) { const back = location.pathname + location.search; const to = back === "/" ? "/login" : `/login?next=${encodeURIComponent(back)}`; return <Navigate to={to} replace state={sessionEnded ? { sessionEnded: true } : undefined} />; } return <Outlet />; }
panel/src/components/States.tsx +17 −1 Changes for panel/src/components/States.tsx: 17 added lines, 1 removed line. Original line number Diff line number Diff line import { Loader2, AlertTriangle, Inbox, ShieldX, Construction, Moon } from "lucide-react"; import { Loader2, AlertTriangle, Inbox, ShieldX, Construction, Moon, SearchX } from "lucide-react"; import { Link } from "react-router-dom"; import { useTranslation } from "react-i18next"; import { Button } from "@/components/ui/button"; Loading Loading @@ -71,6 +71,22 @@ export function NotAuthorized() { ); } export function NotFound() { const { t } = useTranslation("common"); return ( <div className="mx-auto flex max-w-md flex-col items-center justify-center gap-3 py-24 text-center"> <SearchX className="h-8 w-8 text-muted-foreground" /> <div> <p className="font-medium">{t("not_found_title")}</p> <p className="mt-1 text-sm text-muted-foreground">{t("not_found_body")}</p> </div> <Link to="/" className="text-sm font-medium text-primary hover:underline"> {t("back_to_dashboard")} </Link> </div> ); } /** * PendingBackend is an HONEST placeholder for a surface whose backend read does * not exist yet (DESIGN-WEB-3SIDES non-goal #3: "a page with no real backend Loading
panel/src/components/ValidParam.tsx 0 → 100644 +10 −0 Changes for panel/src/components/ValidParam.tsx: 10 added lines, 0 removed lines. Original line number Diff line number Diff line import { Outlet, useParams } from "react-router-dom"; import { NotFound } from "@/components/States"; // ValidParam is a layout route that renders its children only when one URL // parameter has the expected shape (lib/params.ts), and "not found" otherwise. export function ValidParam({ param, pattern }: { param: string; pattern: RegExp }) { const value = useParams()[param]; if (value === undefined || !pattern.test(value)) return <NotFound />; return <Outlet />; }