fix(panel): 路径参数逐段编码并拒绝点段,非 JSON 响应保留状态码,会话过期带回跳送回登录页,连不上时显示横幅
This commit is contained in:
20 files changed
+635
-141
No files matched your search
@@ -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" />
|
||||
|
||||
@@ -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 />;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 />;
|
||||
}
|
||||
Reference in new issue
Block a user