fix(panel): 路径参数逐段编码并拒绝点段,非 JSON 响应保留状态码,会话过期带回跳送回登录页,连不上时显示横幅

This commit is contained in:
Lemon-miaow committed 2026-09-25 09:34:15 +08:00
1 parent e0fa0268e0
commit 6595a2c581
20 files changed
+635 -141

No files matched your search

+34 -2
View File
@@ -1,6 +1,6 @@
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";
@@ -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 }) {
@@ -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();
@@ -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" />
+11 -4
View File
@@ -1,4 +1,4 @@
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";
@@ -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
@@ -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 (
@@ -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 />;
}
+17 -1
View File
@@ -1,4 +1,4 @@
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";
@@ -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
+10
View File
@@ -0,0 +1,10 @@
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 />;
}