Fix admin dashboard access and error handling

This commit is contained in:
Thigazhezhilan J 2026-04-10 00:36:46 +05:30
parent b3b798ff2f
commit ccaa363d48
15 changed files with 568 additions and 152 deletions

View File

@ -7,7 +7,7 @@
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"test": "npm run typecheck", "test": "npm run typecheck && tsx --test src/pages/admin/api.test.ts",
"preview": "vite preview", "preview": "vite preview",
"start": "vite --host 0.0.0.0 --port 3001" "start": "vite --host 0.0.0.0 --port 3001"
}, },

View File

@ -1,4 +1,4 @@
import { Switch, Route } from "wouter"; import { Switch, Route, useLocation } from "wouter";
import { queryClient } from "./lib/queryClient"; import { queryClient } from "./lib/queryClient";
import { QueryClientProvider } from "@tanstack/react-query"; import { QueryClientProvider } from "@tanstack/react-query";
import { Toaster } from "@/components/ui/toaster"; import { Toaster } from "@/components/ui/toaster";
@ -48,12 +48,15 @@ function Router() {
} }
function App() { function App() {
const [location] = useLocation();
const isAdminRoute = location.startsWith("/admin");
return ( return (
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<TooltipProvider> <TooltipProvider>
<Toaster /> <Toaster />
<Router /> <Router />
<ChatWidget /> {!isAdminRoute && <ChatWidget />}
</TooltipProvider> </TooltipProvider>
</QueryClientProvider> </QueryClientProvider>
); );

View File

@ -1,13 +1,16 @@
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { apiRequest } from "@/lib/queryClient";
import type { InvariantsResponse } from "./types"; import type { InvariantsResponse } from "./types";
import { fetchAdminJson, getAdminErrorMessage } from "./api";
export default function AdminInvariants() { export default function AdminInvariants() {
const invariantsQuery = useQuery<InvariantsResponse>({ const invariantsQuery = useQuery<InvariantsResponse>({
queryKey: ["admin/health/invariants"], queryKey: ["admin/health/invariants"],
queryFn: async () => { queryFn: async () => {
const res = await apiRequest("GET", "admin/health/invariants"); const data = await fetchAdminJson<InvariantsResponse>("admin/health/invariants");
return res.json(); if (!data) {
throw new Error("No invariant response returned.");
}
return data;
}, },
}); });
@ -18,9 +21,7 @@ export default function AdminInvariants() {
if (invariantsQuery.isError) { if (invariantsQuery.isError) {
return ( return (
<div className="rounded-2xl border border-destructive/30 bg-destructive/5 p-4 text-sm text-destructive"> <div className="rounded-2xl border border-destructive/30 bg-destructive/5 p-4 text-sm text-destructive">
{invariantsQuery.error instanceof Error {getAdminErrorMessage(invariantsQuery.error, "Failed to load invariant data.")}
? invariantsQuery.error.message
: "Failed to load invariant data."}
</div> </div>
); );
} }

View File

@ -1,7 +1,10 @@
import type { ReactNode } from "react"; import { useState, type ReactNode } from "react";
import { Link, useLocation } from "wouter"; import { Link, useLocation } from "wouter";
import Navigation from "@/components/landing/Navigation"; import { ShieldCheck } from "lucide-react";
import Footer from "@/components/landing/Footer"; import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { apiRequest } from "@/lib/queryClient";
import type { AdminAccessResponse } from "./types";
const navItems = [ const navItems = [
{ href: "/admin", label: "Overview" }, { href: "/admin", label: "Overview" },
@ -11,45 +14,86 @@ const navItems = [
{ href: "/admin/invariants", label: "Invariants" }, { href: "/admin/invariants", label: "Invariants" },
]; ];
export default function AdminLayout({ children }: { children: ReactNode }) { type AdminLayoutProps = {
adminUser: AdminAccessResponse;
children: ReactNode;
};
export default function AdminLayout({ adminUser, children }: AdminLayoutProps) {
const [location] = useLocation(); const [location] = useLocation();
const [isLoggingOut, setIsLoggingOut] = useState(false);
const handleLogout = async () => {
setIsLoggingOut(true);
try {
await apiRequest("POST", "logout");
} catch {
// Best effort logout. Always return to the public landing page.
} finally {
globalThis.location.assign("/");
}
};
return ( return (
<div className="min-h-screen text-foreground"> <div className="min-h-screen bg-[#05070d] text-foreground">
<Navigation /> <header className="border-b border-border/60 bg-background/90 backdrop-blur">
<main className="pt-24 pb-24"> <div className="mx-auto flex max-w-7xl items-center justify-between px-6 py-4">
<div className="max-w-6xl mx-auto px-6"> <div className="flex items-center gap-3">
<div className="flex flex-col gap-6 lg:flex-row"> <div className="flex h-10 w-10 items-center justify-center rounded-xl border border-primary/30 bg-primary/10 text-primary">
<aside className="lg:w-60"> <ShieldCheck className="h-5 w-5" />
<div className="rounded-2xl border border-border/60 bg-card/70 p-4 shadow-sm"> </div>
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground"> <div>
Admin Control Plane <p className="text-sm font-semibold text-foreground">QuantFortune Admin</p>
</p> <p className="text-xs text-muted-foreground">Protected control plane</p>
<nav className="mt-4 flex flex-col gap-2 text-sm"> </div>
{navItems.map((item) => { </div>
const active = location === item.href;
return ( <div className="flex items-center gap-3">
<Link key={item.href} href={item.href}> <Badge variant="secondary">{adminUser.role}</Badge>
<a <div className="hidden text-right sm:block">
className={`rounded-lg px-3 py-2 transition ${ <p className="text-sm font-medium text-foreground">{adminUser.username}</p>
active <p className="text-xs text-muted-foreground">{adminUser.id}</p>
? "bg-primary text-primary-foreground" </div>
: "text-muted-foreground hover:text-foreground hover:bg-muted/40" <Link href="/">
}`} <a className="text-sm text-muted-foreground transition hover:text-foreground">Back to site</a>
> </Link>
{item.label} <Button variant="outline" size="sm" disabled={isLoggingOut} onClick={handleLogout}>
</a> {isLoggingOut ? "Signing out..." : "Sign out"}
</Link> </Button>
);
})}
</nav>
</div>
</aside>
<section className="flex-1">{children}</section>
</div> </div>
</div> </div>
</header>
<main className="pb-12 pt-8">
<div className="mx-auto flex max-w-7xl flex-col gap-6 px-6 lg:flex-row">
<aside className="lg:w-64">
<div className="rounded-2xl border border-border/60 bg-card/70 p-4 shadow-sm">
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground">
Admin Sections
</p>
<nav className="mt-4 flex flex-col gap-2 text-sm">
{navItems.map((item) => {
const active = location === item.href;
return (
<Link key={item.href} href={item.href}>
<a
className={`rounded-lg px-3 py-2 transition ${
active
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:bg-muted/40 hover:text-foreground"
}`}
>
{item.label}
</a>
</Link>
);
})}
</nav>
</div>
</aside>
<section className="flex-1">{children}</section>
</div>
</main> </main>
<Footer />
</div> </div>
); );
} }

View File

@ -1,7 +1,9 @@
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { AlertCircle, RotateCw, ShieldCheck } from "lucide-react";
import { Bar, BarChart, ResponsiveContainer, XAxis, YAxis } from "recharts"; import { Bar, BarChart, ResponsiveContainer, XAxis, YAxis } from "recharts";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { apiRequest } from "@/lib/queryClient"; import { Button } from "@/components/ui/button";
import { fetchAdminJson, getAdminErrorMessage } from "./api";
import type { OverviewResponse } from "./types"; import type { OverviewResponse } from "./types";
const StatCard = ({ label, value }: { label: string; value: number | string }) => ( const StatCard = ({ label, value }: { label: string; value: number | string }) => (
@ -11,12 +13,30 @@ const StatCard = ({ label, value }: { label: string; value: number | string }) =
</div> </div>
); );
function isOverviewEmpty(data: OverviewResponse) {
return (
data.total_users === 0 &&
data.total_runs === 0 &&
data.users_logged_in_last_24h === 0 &&
data.orders_last_24h === 0 &&
data.trades_last_24h === 0 &&
data.sip_executed_last_24h === 0 &&
data.unresolved_orders === 0 &&
data.blocked_strategies === 0 &&
data.open_support_tickets === 0 &&
data.top_errors.length === 0
);
}
export default function AdminOverview() { export default function AdminOverview() {
const overviewQuery = useQuery<OverviewResponse>({ const overviewQuery = useQuery<OverviewResponse>({
queryKey: ["admin/overview"], queryKey: ["admin/overview"],
queryFn: async () => { queryFn: async () => {
const res = await apiRequest("GET", "admin/overview"); const data = await fetchAdminJson<OverviewResponse>("admin/overview");
return res.json(); if (!data) {
throw new Error("No overview response returned.");
}
return data;
}, },
}); });
@ -26,19 +46,45 @@ export default function AdminOverview() {
if (overviewQuery.isError) { if (overviewQuery.isError) {
return ( return (
<div className="rounded-2xl border border-destructive/30 bg-destructive/5 p-4 text-sm text-destructive"> <div className="rounded-2xl border border-destructive/30 bg-destructive/5 p-5 text-sm text-destructive">
{overviewQuery.error instanceof Error <div className="flex items-start gap-3">
? overviewQuery.error.message <AlertCircle className="mt-0.5 h-4 w-4 shrink-0" />
: "Failed to load admin overview."} <div className="space-y-3">
<div>
<p className="font-semibold">Admin overview unavailable</p>
<p className="mt-1 text-sm text-destructive/90">
{getAdminErrorMessage(overviewQuery.error, "Failed to load admin overview.")}
</p>
</div>
<Button
variant="outline"
size="sm"
className="border-destructive/30 bg-transparent text-destructive hover:bg-destructive/10"
onClick={() => void overviewQuery.refetch()}
>
<RotateCw className="mr-2 h-4 w-4" />
Retry
</Button>
</div>
</div>
</div> </div>
); );
} }
if (!overviewQuery.data) { if (!overviewQuery.data) {
return <div className="text-sm text-muted-foreground">No data.</div>; return (
<div className="rounded-2xl border border-border/60 bg-card/70 p-6 text-sm text-muted-foreground shadow-sm">
No admin metrics yet.
</div>
);
} }
const data = overviewQuery.data; const data = overviewQuery.data;
const needsAttention =
data.error_runs > 0 ||
data.unresolved_orders > 0 ||
data.blocked_strategies > 0 ||
data.open_support_tickets > 0;
const runStatusData = [ const runStatusData = [
{ name: "RUNNING", value: data.running_runs }, { name: "RUNNING", value: data.running_runs },
@ -52,15 +98,45 @@ export default function AdminOverview() {
{ name: "SIP", value: data.sip_executed_last_24h }, { name: "SIP", value: data.sip_executed_last_24h },
]; ];
if (isOverviewEmpty(data)) {
return (
<div className="rounded-2xl border border-border/60 bg-card/70 p-6 shadow-sm">
<div className="flex items-center gap-3">
<ShieldCheck className="h-5 w-5 text-primary" />
<div>
<p className="text-sm font-semibold text-foreground">No admin metrics yet</p>
<p className="text-sm text-muted-foreground">
The admin API is reachable, but no users, runs, or support activity have been recorded yet.
</p>
</div>
</div>
<div className="mt-4">
<Button variant="outline" size="sm" onClick={() => void overviewQuery.refetch()}>
<RotateCw className="mr-2 h-4 w-4" />
Retry
</Button>
</div>
</div>
);
}
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="rounded-2xl border border-border/60 bg-card/70 p-6 shadow-sm"> <div className="rounded-2xl border border-border/60 bg-card/70 p-6 shadow-sm">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between gap-4">
<div> <div>
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground">Overview</p> <p className="text-xs uppercase tracking-[0.2em] text-muted-foreground">Overview</p>
<h2 className="text-2xl font-semibold">System health snapshot</h2> <h2 className="text-2xl font-semibold">System health snapshot</h2>
</div> </div>
<Badge variant="secondary">Admin</Badge> <div className="flex items-center gap-2">
<Badge variant={needsAttention ? "secondary" : "default"}>
{needsAttention ? "Needs attention" : "Healthy"}
</Badge>
<Button variant="outline" size="sm" onClick={() => void overviewQuery.refetch()}>
<RotateCw className="mr-2 h-4 w-4" />
Retry
</Button>
</div>
</div> </div>
</div> </div>
@ -71,6 +147,9 @@ export default function AdminOverview() {
<StatCard label="Running Runs" value={data.running_runs} /> <StatCard label="Running Runs" value={data.running_runs} />
<StatCard label="Paper Runs" value={data.paper_runs_count} /> <StatCard label="Paper Runs" value={data.paper_runs_count} />
<StatCard label="Live Runs" value={data.live_runs_count} /> <StatCard label="Live Runs" value={data.live_runs_count} />
<StatCard label="Unresolved Orders" value={data.unresolved_orders} />
<StatCard label="Blocked Strategies" value={data.blocked_strategies} />
<StatCard label="Open Support Tickets" value={data.open_support_tickets} />
</div> </div>
<div className="grid gap-4 lg:grid-cols-2"> <div className="grid gap-4 lg:grid-cols-2">

View File

@ -3,7 +3,7 @@ import { Route, Switch, useRoute } from "wouter";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { apiRequest, resolveApiUrl } from "@/lib/queryClient"; import { apiRequest } from "@/lib/queryClient";
import AdminLayout from "./AdminLayout"; import AdminLayout from "./AdminLayout";
import AdminOverview from "./AdminOverview"; import AdminOverview from "./AdminOverview";
import AdminUsers from "./AdminUsers"; import AdminUsers from "./AdminUsers";
@ -13,8 +13,14 @@ import AdminRunDetail from "./AdminRunDetail";
import AdminInvariants from "./AdminInvariants"; import AdminInvariants from "./AdminInvariants";
import AdminSupportTickets from "./AdminSupportTickets"; import AdminSupportTickets from "./AdminSupportTickets";
import NotFound from "@/pages/not-found"; import NotFound from "@/pages/not-found";
import {
fetchAdminJson,
getAdminAccessState,
getAdminErrorMessage,
} from "./api";
import type { AdminAccessResponse } from "./types";
type AccessState = "loading" | "allowed" | "unauthenticated" | "forbidden"; type AccessState = "loading" | "allowed" | "unauthenticated" | "forbidden" | "unavailable";
type LoginForm = { type LoginForm = {
email: string; email: string;
@ -23,6 +29,8 @@ type LoginForm = {
export default function AdminPage() { export default function AdminPage() {
const [accessState, setAccessState] = useState<AccessState>("loading"); const [accessState, setAccessState] = useState<AccessState>("loading");
const [accessError, setAccessError] = useState<string | null>(null);
const [adminUser, setAdminUser] = useState<AdminAccessResponse | null>(null);
const [loginForm, setLoginForm] = useState<LoginForm>({ email: "", password: "" }); const [loginForm, setLoginForm] = useState<LoginForm>({ email: "", password: "" });
const [loginError, setLoginError] = useState<string | null>(null); const [loginError, setLoginError] = useState<string | null>(null);
const [loginPending, setLoginPending] = useState(false); const [loginPending, setLoginPending] = useState(false);
@ -30,33 +38,21 @@ export default function AdminPage() {
const [, runParams] = useRoute("/admin/runs/:runId"); const [, runParams] = useRoute("/admin/runs/:runId");
const checkAccess = useCallback(async () => { const checkAccess = useCallback(async () => {
setAccessState("loading");
setAccessError(null);
try { try {
const res = await fetch(resolveApiUrl("admin/overview"), { const access = await fetchAdminJson<AdminAccessResponse>("admin/access");
credentials: "include", if (!access) {
headers: { setAdminUser(null);
Accept: "application/json",
},
});
if (res.status === 401) {
setAccessState("unauthenticated");
return;
}
if (res.status === 403) {
setAccessState("forbidden");
return;
}
const contentType = (res.headers.get("content-type") || "").toLowerCase();
if (res.ok && contentType.includes("text/html")) {
setAccessState("forbidden");
return;
}
if (!res.ok) {
setAccessState("forbidden"); setAccessState("forbidden");
return; return;
} }
setAdminUser(access);
setAccessState("allowed"); setAccessState("allowed");
} catch { } catch (error) {
setAccessState("forbidden"); setAdminUser(null);
setAccessError(getAdminErrorMessage(error, "Cannot load admin access."));
setAccessState(getAdminAccessState(error));
} }
}, []); }, []);
@ -70,6 +66,7 @@ export default function AdminPage() {
setLoginError("Email and password are required."); setLoginError("Email and password are required.");
return; return;
} }
setLoginPending(true); setLoginPending(true);
setLoginError(null); setLoginError(null);
try { try {
@ -78,8 +75,8 @@ export default function AdminPage() {
password: loginForm.password, password: loginForm.password,
}); });
await checkAccess(); await checkAccess();
} catch (err) { } catch (error) {
const message = err instanceof Error ? err.message : "Login failed"; const message = error instanceof Error ? error.message : "Login failed";
setLoginError(message); setLoginError(message);
setAccessState("unauthenticated"); setAccessState("unauthenticated");
} finally { } finally {
@ -88,12 +85,36 @@ export default function AdminPage() {
}; };
if (accessState === "loading") { if (accessState === "loading") {
return <div className="min-h-screen text-muted-foreground">Loading admin...</div>; return (
<div className="flex min-h-screen items-center justify-center bg-[#05070d] px-4 text-sm text-muted-foreground">
Checking admin access...
</div>
);
}
if (accessState === "unavailable") {
return (
<div className="flex min-h-screen items-center justify-center bg-[#05070d] px-4">
<div className="w-full max-w-lg rounded-2xl border border-border/60 bg-card/80 p-6 shadow-lg">
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground">Admin service</p>
<h1 className="mt-2 text-2xl font-semibold">Admin service unavailable</h1>
<p className="mt-2 text-sm text-muted-foreground">
{accessError || "Cannot reach the admin API right now."}
</p>
<div className="mt-6 flex gap-3">
<Button onClick={() => void checkAccess()}>Retry</Button>
<Button variant="outline" onClick={() => globalThis.location.assign("/")}>
Back to site
</Button>
</div>
</div>
</div>
);
} }
if (accessState === "unauthenticated") { if (accessState === "unauthenticated") {
return ( return (
<div className="min-h-screen flex items-center justify-center bg-background px-4"> <div className="flex min-h-screen items-center justify-center bg-[#05070d] px-4">
<div className="w-full max-w-md rounded-2xl border border-border/60 bg-card/80 p-6 shadow-lg space-y-4"> <div className="w-full max-w-md rounded-2xl border border-border/60 bg-card/80 p-6 shadow-lg space-y-4">
<div className="space-y-2"> <div className="space-y-2">
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground">Admin login</p> <p className="text-xs uppercase tracking-[0.2em] text-muted-foreground">Admin login</p>
@ -124,10 +145,17 @@ export default function AdminPage() {
onChange={(e) => setLoginForm((prev) => ({ ...prev, password: e.target.value }))} onChange={(e) => setLoginForm((prev) => ({ ...prev, password: e.target.value }))}
/> />
</div> </div>
{loginError && <p className="text-xs text-destructive">{loginError}</p>} {(loginError || accessError) && (
<Button type="submit" className="w-full" disabled={loginPending}> <p className="text-xs text-destructive">{loginError || accessError}</p>
{loginPending ? "Signing in..." : "Log in"} )}
</Button> <div className="flex gap-3">
<Button type="submit" className="flex-1" disabled={loginPending}>
{loginPending ? "Signing in..." : "Log in"}
</Button>
<Button type="button" variant="outline" onClick={() => globalThis.location.assign("/")}>
Back
</Button>
</div>
</form> </form>
</div> </div>
</div> </div>
@ -136,19 +164,30 @@ export default function AdminPage() {
if (accessState === "forbidden") { if (accessState === "forbidden") {
return ( return (
<div className="min-h-screen flex items-center justify-center text-muted-foreground"> <div className="flex min-h-screen items-center justify-center bg-[#05070d] px-4">
<div className="space-y-3 text-center"> <div className="w-full max-w-lg rounded-2xl border border-border/60 bg-card/80 p-6 text-center shadow-lg">
<h1 className="text-xl font-semibold text-foreground">Admin access required</h1> <p className="text-xs uppercase tracking-[0.2em] text-muted-foreground">Forbidden</p>
<p className="text-sm"> <h1 className="mt-2 text-2xl font-semibold text-foreground">Admin access required</h1>
Your account is signed in but does not have admin privileges. <p className="mt-2 text-sm text-muted-foreground">
Your account is signed in, but it does not have admin privileges.
</p> </p>
<div className="mt-6 flex justify-center gap-3">
<Button variant="outline" onClick={() => globalThis.location.assign("/")}>
Back to site
</Button>
<Button onClick={() => void checkAccess()}>Retry</Button>
</div>
</div> </div>
</div> </div>
); );
} }
if (!adminUser) {
return null;
}
return ( return (
<AdminLayout> <AdminLayout adminUser={adminUser}>
<Switch> <Switch>
<Route path="/admin" component={AdminOverview} /> <Route path="/admin" component={AdminOverview} />
<Route path="/admin/users" component={AdminUsers} /> <Route path="/admin/users" component={AdminUsers} />

View File

@ -1,14 +1,16 @@
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { Link } from "wouter"; import { Link } from "wouter";
import { apiRequest } from "@/lib/queryClient";
import type { RunDetailResponse } from "./types"; import type { RunDetailResponse } from "./types";
import { fetchAdminJson, getAdminErrorMessage } from "./api";
export default function AdminRunDetail({ runId }: { runId: string }) { export default function AdminRunDetail({ runId }: { runId: string }) {
const detailQuery = useQuery<RunDetailResponse>({ const detailQuery = useQuery<RunDetailResponse | null>({
queryKey: ["admin/runs", runId], queryKey: ["admin/runs", runId],
queryFn: async () => { queryFn: async () => {
const res = await apiRequest("GET", `admin/runs/${runId}`); const data = await fetchAdminJson<RunDetailResponse>(`admin/runs/${runId}`, {
return res.json(); treat404AsNull: true,
});
return data;
}, },
}); });
@ -19,7 +21,7 @@ export default function AdminRunDetail({ runId }: { runId: string }) {
if (detailQuery.isError) { if (detailQuery.isError) {
return ( return (
<div className="rounded-2xl border border-destructive/30 bg-destructive/5 p-4 text-sm text-destructive"> <div className="rounded-2xl border border-destructive/30 bg-destructive/5 p-4 text-sm text-destructive">
{detailQuery.error instanceof Error ? detailQuery.error.message : "Failed to load run."} {getAdminErrorMessage(detailQuery.error, "Failed to load run.")}
</div> </div>
); );
} }
@ -91,7 +93,7 @@ export default function AdminRunDetail({ runId }: { runId: string }) {
<div className="rounded-2xl border border-border/60 bg-card/70 p-6 shadow-sm"> <div className="rounded-2xl border border-border/60 bg-card/70 p-6 shadow-sm">
<p className="text-sm font-semibold">Ledger Events</p> <p className="text-sm font-semibold">Ledger Events</p>
<div className="mt-3 space-y-2 text-xs text-muted-foreground"> <div className="mt-3 space-y-2 text-xs text-muted-foreground">
{ledger_events.map((evt, idx) => ( {ledger_events.map((evt: RunDetailResponse["ledger_events"][number], idx: number) => (
<div key={idx}> <div key={idx}>
<span className="text-foreground">{String(evt.event)}</span>{" "} <span className="text-foreground">{String(evt.event)}</span>{" "}
<span>{String(evt.timestamp ?? "")}</span> <span>{String(evt.timestamp ?? "")}</span>
@ -112,7 +114,7 @@ export default function AdminRunDetail({ runId }: { runId: string }) {
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-border/60"> <tbody className="divide-y divide-border/60">
{orders.map((order, idx) => ( {orders.map((order: RunDetailResponse["orders"][number], idx: number) => (
<tr key={idx}> <tr key={idx}>
<td className="px-2 py-1">{String(order.id)}</td> <td className="px-2 py-1">{String(order.id)}</td>
<td className="px-2 py-1">{String(order.symbol)}</td> <td className="px-2 py-1">{String(order.symbol)}</td>
@ -134,7 +136,7 @@ export default function AdminRunDetail({ runId }: { runId: string }) {
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-border/60"> <tbody className="divide-y divide-border/60">
{trades.map((trade, idx) => ( {trades.map((trade: RunDetailResponse["trades"][number], idx: number) => (
<tr key={idx}> <tr key={idx}>
<td className="px-2 py-1">{String(trade.id)}</td> <td className="px-2 py-1">{String(trade.id)}</td>
<td className="px-2 py-1">{String(trade.symbol)}</td> <td className="px-2 py-1">{String(trade.symbol)}</td>

View File

@ -3,8 +3,8 @@ import { useQuery } from "@tanstack/react-query";
import { Link } from "wouter"; import { Link } from "wouter";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { apiRequest } from "@/lib/queryClient";
import type { RunsResponse } from "./types"; import type { RunsResponse } from "./types";
import { fetchAdminJson, getAdminErrorMessage } from "./api";
export default function AdminRuns() { export default function AdminRuns() {
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
@ -26,8 +26,11 @@ export default function AdminRuns() {
const runsQuery = useQuery<RunsResponse>({ const runsQuery = useQuery<RunsResponse>({
queryKey: ["admin/runs", page, status, mode, userId], queryKey: ["admin/runs", page, status, mode, userId],
queryFn: async () => { queryFn: async () => {
const res = await apiRequest("GET", `admin/runs?${queryString}`); const data = await fetchAdminJson<RunsResponse>(`admin/runs?${queryString}`);
return res.json(); if (!data) {
throw new Error("No admin runs response returned.");
}
return data;
}, },
}); });
@ -38,7 +41,7 @@ export default function AdminRuns() {
if (runsQuery.isError) { if (runsQuery.isError) {
return ( return (
<div className="rounded-2xl border border-destructive/30 bg-destructive/5 p-4 text-sm text-destructive"> <div className="rounded-2xl border border-destructive/30 bg-destructive/5 p-4 text-sm text-destructive">
{runsQuery.error instanceof Error ? runsQuery.error.message : "Failed to load runs."} {getAdminErrorMessage(runsQuery.error, "Failed to load runs.")}
</div> </div>
); );
} }

View File

@ -1,44 +1,35 @@
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { apiRequest, getQueryFn } from "@/lib/queryClient";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { toast } from "@/hooks/use-toast"; import { toast } from "@/hooks/use-toast";
import type { SupportTicketsResponse } from "./types";
type Ticket = { import { fetchAdminJson, getAdminErrorMessage } from "./api";
ticket_id: string;
name: string;
email: string;
subject: string;
message: string;
status: string;
created_at?: string | null;
updated_at?: string | null;
};
type TicketsResponse = {
page: number;
page_size: number;
total: number;
tickets: Ticket[];
};
export default function AdminSupportTickets() { export default function AdminSupportTickets() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { data, isLoading, isError, error } = useQuery<TicketsResponse>({ const { data, isLoading, isError, error } = useQuery<SupportTicketsResponse>({
queryKey: ["admin/support-tickets"], queryKey: ["admin/support-tickets"],
queryFn: getQueryFn<TicketsResponse>({ on401: "throw" }), queryFn: async () => {
const result = await fetchAdminJson<SupportTicketsResponse>("admin/support-tickets");
if (!result) {
throw new Error("No support ticket response returned.");
}
return result;
},
}); });
const handleDelete = async (ticketId: string) => { const handleDelete = async (ticketId: string) => {
const confirmed = window.confirm("Delete this ticket? This cannot be undone."); const confirmed = window.confirm("Delete this ticket? This cannot be undone.");
if (!confirmed) return; if (!confirmed) return;
try { try {
await apiRequest("DELETE", `admin/support-tickets/${ticketId}`); await fetchAdminJson<{ ticket_id: string; deleted: boolean }>(`admin/support-tickets/${ticketId}`, {
method: "DELETE",
});
toast({ title: "Ticket deleted" }); toast({ title: "Ticket deleted" });
queryClient.invalidateQueries({ queryKey: ["admin/support-tickets"] }); queryClient.invalidateQueries({ queryKey: ["admin/support-tickets"] });
} catch (err: any) { } catch (err: any) {
toast({ title: "Delete failed", description: err?.message || "Try again." }); toast({ title: "Delete failed", description: getAdminErrorMessage(err, "Try again.") });
} }
}; };
@ -49,7 +40,7 @@ export default function AdminSupportTickets() {
if (isError) { if (isError) {
return ( return (
<div className="rounded-2xl border border-destructive/30 bg-destructive/5 p-4 text-sm text-destructive"> <div className="rounded-2xl border border-destructive/30 bg-destructive/5 p-4 text-sm text-destructive">
{error instanceof Error ? error.message : "Failed to load support tickets."} {getAdminErrorMessage(error, "Failed to load support tickets.")}
</div> </div>
); );
} }

View File

@ -1,14 +1,16 @@
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { Link } from "wouter"; import { Link } from "wouter";
import { apiRequest } from "@/lib/queryClient";
import type { UserDetailResponse } from "./types"; import type { UserDetailResponse } from "./types";
import { fetchAdminJson, getAdminErrorMessage } from "./api";
export default function AdminUserDetail({ userId }: { userId: string }) { export default function AdminUserDetail({ userId }: { userId: string }) {
const detailQuery = useQuery<UserDetailResponse>({ const detailQuery = useQuery<UserDetailResponse | null>({
queryKey: ["admin/users", userId], queryKey: ["admin/users", userId],
queryFn: async () => { queryFn: async () => {
const res = await apiRequest("GET", `admin/users/${userId}`); const data = await fetchAdminJson<UserDetailResponse>(`admin/users/${userId}`, {
return res.json(); treat404AsNull: true,
});
return data;
}, },
}); });
@ -19,7 +21,7 @@ export default function AdminUserDetail({ userId }: { userId: string }) {
if (detailQuery.isError) { if (detailQuery.isError) {
return ( return (
<div className="rounded-2xl border border-destructive/30 bg-destructive/5 p-4 text-sm text-destructive"> <div className="rounded-2xl border border-destructive/30 bg-destructive/5 p-4 text-sm text-destructive">
{detailQuery.error instanceof Error ? detailQuery.error.message : "Failed to load user."} {getAdminErrorMessage(detailQuery.error, "Failed to load user.")}
</div> </div>
); );
} }
@ -63,7 +65,7 @@ export default function AdminUserDetail({ userId }: { userId: string }) {
<div className="rounded-2xl border border-border/60 bg-card/70 p-6 shadow-sm"> <div className="rounded-2xl border border-border/60 bg-card/70 p-6 shadow-sm">
<p className="text-sm font-semibold">Recent Runs</p> <p className="text-sm font-semibold">Recent Runs</p>
<div className="mt-4 space-y-2"> <div className="mt-4 space-y-2">
{runs.map((run) => ( {runs.map((run: UserDetailResponse["runs"][number]) => (
<div key={run.run_id} className="flex items-center justify-between text-xs"> <div key={run.run_id} className="flex items-center justify-between text-xs">
<Link href={`/admin/runs/${run.run_id}`}> <Link href={`/admin/runs/${run.run_id}`}>
<a className="text-primary hover:underline">{run.run_id}</a> <a className="text-primary hover:underline">{run.run_id}</a>
@ -78,7 +80,7 @@ export default function AdminUserDetail({ userId }: { userId: string }) {
<div className="rounded-2xl border border-border/60 bg-card/70 p-6 shadow-sm"> <div className="rounded-2xl border border-border/60 bg-card/70 p-6 shadow-sm">
<p className="text-sm font-semibold">Recent Events</p> <p className="text-sm font-semibold">Recent Events</p>
<div className="mt-4 space-y-2"> <div className="mt-4 space-y-2">
{events.map((evt, idx) => ( {events.map((evt: UserDetailResponse["events"][number], idx: number) => (
<div key={`${evt.ts}-${idx}`} className="text-xs text-muted-foreground"> <div key={`${evt.ts}-${idx}`} className="text-xs text-muted-foreground">
<span className="text-foreground">{evt.event}</span>{" "} <span className="text-foreground">{evt.event}</span>{" "}
<span>({evt.source})</span>{" "} <span>({evt.source})</span>{" "}

View File

@ -12,10 +12,11 @@ import {
DialogTitle, DialogTitle,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
import { apiRequest, getQueryFn } from "@/lib/queryClient"; import { getQueryFn } from "@/lib/queryClient";
import RoleBadge from "./components/RoleBadge"; import RoleBadge from "./components/RoleBadge";
import RoleActions from "./components/RoleActions"; import RoleActions from "./components/RoleActions";
import type { DeleteUserResponse, HardResetResponse, UserSummary, UsersResponse } from "./types"; import type { DeleteUserResponse, HardResetResponse, UserSummary, UsersResponse } from "./types";
import { fetchAdminJson, getAdminErrorMessage } from "./api";
export default function AdminUsers() { export default function AdminUsers() {
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
@ -44,8 +45,11 @@ export default function AdminUsers() {
const usersQuery = useQuery<UsersResponse>({ const usersQuery = useQuery<UsersResponse>({
queryKey: ["admin/users", page, query], queryKey: ["admin/users", page, query],
queryFn: async () => { queryFn: async () => {
const res = await apiRequest("GET", `admin/users?${queryString}`); const data = await fetchAdminJson<UsersResponse>(`admin/users?${queryString}`);
return res.json(); if (!data) {
throw new Error("No admin users response returned.");
}
return data;
}, },
}); });
@ -64,17 +68,17 @@ export default function AdminUsers() {
setIsDeleting(true); setIsDeleting(true);
setDeleteError(null); setDeleteError(null);
try { try {
const res = await apiRequest( const result = await fetchAdminJson<DeleteUserResponse>(
"DELETE",
`admin/users/${deleteTarget.user_id}?hard=true`, `admin/users/${deleteTarget.user_id}?hard=true`,
{ method: "DELETE" },
); );
(await res.json()) as DeleteUserResponse; void result;
setDeleteTarget(null); setDeleteTarget(null);
setConfirmChecked(false); setConfirmChecked(false);
await queryClient.invalidateQueries({ queryKey: ["admin/users"] }); await queryClient.invalidateQueries({ queryKey: ["admin/users"] });
await queryClient.invalidateQueries({ queryKey: ["admin/overview"] }); await queryClient.invalidateQueries({ queryKey: ["admin/overview"] });
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : "Delete failed"; const message = getAdminErrorMessage(err, "Delete failed.");
setDeleteError(message); setDeleteError(message);
} finally { } finally {
setIsDeleting(false); setIsDeleting(false);
@ -88,17 +92,17 @@ export default function AdminUsers() {
setIsResetting(true); setIsResetting(true);
setResetError(null); setResetError(null);
try { try {
const res = await apiRequest( const result = await fetchAdminJson<HardResetResponse>(
"POST",
`admin/users/${resetTarget.user_id}/hard-reset`, `admin/users/${resetTarget.user_id}/hard-reset`,
{ method: "POST" },
); );
(await res.json()) as HardResetResponse; void result;
setResetTarget(null); setResetTarget(null);
setResetChecked(false); setResetChecked(false);
await queryClient.invalidateQueries({ queryKey: ["admin/users"] }); await queryClient.invalidateQueries({ queryKey: ["admin/users"] });
await queryClient.invalidateQueries({ queryKey: ["admin/overview"] }); await queryClient.invalidateQueries({ queryKey: ["admin/overview"] });
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : "Reset failed"; const message = getAdminErrorMessage(err, "Reset failed.");
setResetError(message); setResetError(message);
} finally { } finally {
setIsResetting(false); setIsResetting(false);
@ -112,7 +116,7 @@ export default function AdminUsers() {
if (usersQuery.isError) { if (usersQuery.isError) {
return ( return (
<div className="rounded-2xl border border-destructive/30 bg-destructive/5 p-4 text-sm text-destructive"> <div className="rounded-2xl border border-destructive/30 bg-destructive/5 p-4 text-sm text-destructive">
{usersQuery.error instanceof Error ? usersQuery.error.message : "Failed to load users."} {getAdminErrorMessage(usersQuery.error, "Failed to load users.")}
</div> </div>
); );
} }

View File

@ -0,0 +1,97 @@
import test, { afterEach } from "node:test";
import assert from "node:assert/strict";
import { AdminApiError, fetchAdminJson, getAdminAccessState, getAdminErrorMessage } from "./api";
type MockFetch = typeof fetch;
const originalFetch = globalThis.fetch;
function installFetchMock(mock: MockFetch) {
Object.defineProperty(globalThis, "fetch", {
configurable: true,
value: mock,
});
}
afterEach(() => {
installFetchMock(originalFetch);
});
test("fetchAdminJson includes cookie credentials", async () => {
let seenCredentials: RequestCredentials | undefined;
installFetchMock(async (_input, init) => {
seenCredentials = init?.credentials;
return new Response(JSON.stringify({ ok: true }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
});
const response = await fetchAdminJson<{ ok: boolean }>("admin/access");
assert.equal(seenCredentials, "include");
assert.deepEqual(response, { ok: true });
});
test("fetchAdminJson classifies 401 as unauthorized", async () => {
installFetchMock(async () => new Response(JSON.stringify({ detail: "Not authenticated" }), {
status: 401,
headers: { "Content-Type": "application/json" },
}));
await assert.rejects(
() => fetchAdminJson("admin/access"),
(error: unknown) =>
error instanceof AdminApiError &&
error.kind === "unauthorized" &&
getAdminAccessState(error) === "unauthenticated" &&
getAdminErrorMessage(error) === "Session expired. Log in again.",
);
});
test("fetchAdminJson classifies 403 as forbidden", async () => {
installFetchMock(async () => new Response(JSON.stringify({ detail: "Admin access required" }), {
status: 403,
headers: { "Content-Type": "application/json" },
}));
await assert.rejects(
() => fetchAdminJson("admin/overview"),
(error: unknown) =>
error instanceof AdminApiError &&
error.kind === "forbidden" &&
getAdminAccessState(error) === "forbidden" &&
getAdminErrorMessage(error) === "Admin access required.",
);
});
test("fetchAdminJson classifies 5xx html as unavailable", async () => {
installFetchMock(async () => new Response("<html><body>bad gateway</body></html>", {
status: 502,
headers: { "Content-Type": "text/html" },
}));
await assert.rejects(
() => fetchAdminJson("admin/overview"),
(error: unknown) =>
error instanceof AdminApiError &&
error.kind === "unavailable" &&
getAdminErrorMessage(error) === "Admin service unavailable.",
);
});
test("fetchAdminJson classifies network errors distinctly", async () => {
installFetchMock(async () => {
throw new TypeError("Failed to fetch");
});
await assert.rejects(
() => fetchAdminJson("admin/overview"),
(error: unknown) =>
error instanceof AdminApiError &&
error.kind === "network" &&
getAdminAccessState(error) === "unavailable" &&
getAdminErrorMessage(error) === "Cannot reach server.",
);
});

123
src/pages/admin/api.ts Normal file
View File

@ -0,0 +1,123 @@
import { resolveApiUrl } from "../../lib/queryClient";
export type AdminAccessState = "allowed" | "unauthenticated" | "forbidden" | "unavailable";
export type AdminErrorKind =
| "unauthorized"
| "forbidden"
| "not_found"
| "server"
| "unavailable"
| "network"
| "unexpected";
type AdminRequestOptions = {
method?: string;
body?: unknown;
treat404AsNull?: boolean;
};
function looksLikeHtml(value: string) {
return /<!doctype html|<html[\s>]|<head[\s>]|<body[\s>]/i.test(value);
}
export class AdminApiError extends Error {
kind: AdminErrorKind;
status?: number;
constructor(kind: AdminErrorKind, message: string, status?: number) {
super(message);
this.kind = kind;
this.status = status;
}
}
export function getAdminErrorMessage(error: unknown, fallback = "Admin request failed.") {
if (error instanceof AdminApiError) {
switch (error.kind) {
case "unauthorized":
return "Session expired. Log in again.";
case "forbidden":
return "Admin access required.";
case "unavailable":
return "Admin service unavailable.";
case "network":
return "Cannot reach server.";
case "not_found":
return "Requested admin resource was not found.";
case "server":
return "Admin request failed.";
default:
return error.message || fallback;
}
}
if (error instanceof Error && error.message) {
return error.message;
}
return fallback;
}
export function getAdminAccessState(error: unknown): AdminAccessState {
if (error instanceof AdminApiError) {
if (error.kind === "unauthorized") {
return "unauthenticated";
}
if (error.kind === "forbidden") {
return "forbidden";
}
}
return "unavailable";
}
export async function fetchAdminJson<T>(path: string, options: AdminRequestOptions = {}): Promise<T | null> {
const method = options.method || "GET";
let response: Response;
try {
response = await fetch(resolveApiUrl(path), {
method,
credentials: "include",
headers: {
Accept: "application/json",
...(options.body !== undefined ? { "Content-Type": "application/json" } : {}),
},
body: options.body !== undefined ? JSON.stringify(options.body) : undefined,
});
} catch {
throw new AdminApiError("network", "Cannot reach server.");
}
const contentType = (response.headers.get("content-type") || "").toLowerCase();
const bodyText = response.status === 204 ? "" : (await response.clone().text()).trim();
if (response.status === 401) {
throw new AdminApiError("unauthorized", "Session expired. Log in again.", 401);
}
if (response.status === 403) {
throw new AdminApiError("forbidden", "Admin access required.", 403);
}
if (response.status === 404 && options.treat404AsNull) {
return null;
}
if (response.status === 404) {
throw new AdminApiError("not_found", "Requested admin resource was not found.", 404);
}
if (response.status >= 500) {
throw new AdminApiError("unavailable", "Admin service unavailable.", response.status);
}
if (!response.ok) {
throw new AdminApiError("server", bodyText || "Admin request failed.", response.status);
}
if (bodyText && looksLikeHtml(bodyText)) {
throw new AdminApiError("unavailable", "Admin service unavailable.", response.status);
}
if (!contentType.includes("application/json")) {
if (!bodyText) {
return null;
}
throw new AdminApiError("unexpected", "Unexpected admin response.", response.status);
}
return (await response.json()) as T;
}

View File

@ -8,8 +8,8 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { apiRequest } from "@/lib/queryClient";
import type { UserSummary } from "../types"; import type { UserSummary } from "../types";
import { fetchAdminJson, getAdminErrorMessage } from "../api";
type RoleAction = "make-admin" | "revoke-admin" | "make-super-admin"; type RoleAction = "make-admin" | "revoke-admin" | "make-super-admin";
@ -56,14 +56,14 @@ export default function RoleActions({ target, isSelf, isSuperAdmin, onUpdated }:
setIsWorking(true); setIsWorking(true);
setError(null); setError(null);
try { try {
await apiRequest( await fetchAdminJson(
"POST",
`admin/users/${target.user_id}/${actionEndpoint[action]}`, `admin/users/${target.user_id}/${actionEndpoint[action]}`,
{ method: "POST" },
); );
onUpdated(); onUpdated();
setAction(null); setAction(null);
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : "Role update failed"; const message = getAdminErrorMessage(err, "Role update failed.");
setError(message); setError(message);
} finally { } finally {
setIsWorking(false); setIsWorking(false);

View File

@ -7,6 +7,13 @@ export type TopError = {
run_id?: string | null; run_id?: string | null;
}; };
export type AdminAccessResponse = {
id: string;
username: string;
role: "ADMIN" | "SUPER_ADMIN";
can_manage_admins: boolean;
};
export type OverviewResponse = { export type OverviewResponse = {
total_users: number; total_users: number;
users_logged_in_last_24h: number; users_logged_in_last_24h: number;
@ -19,6 +26,9 @@ export type OverviewResponse = {
orders_last_24h: number; orders_last_24h: number;
trades_last_24h: number; trades_last_24h: number;
sip_executed_last_24h: number; sip_executed_last_24h: number;
unresolved_orders: number;
blocked_strategies: number;
open_support_tickets: number;
top_errors: TopError[]; top_errors: TopError[];
}; };
@ -124,3 +134,21 @@ export type HardResetResponse = {
deleted: Record<string, number>; deleted: Record<string, number>;
audit_id: number; audit_id: number;
}; };
export type SupportTicketSummary = {
ticket_id: string;
name: string;
email: string;
subject: string;
message: string;
status: string;
created_at?: string | null;
updated_at?: string | null;
};
export type SupportTicketsResponse = {
page: number;
page_size: number;
total: number;
tickets: SupportTicketSummary[];
};