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",
"build": "vite build",
"typecheck": "tsc --noEmit",
"test": "npm run typecheck",
"test": "npm run typecheck && tsx --test src/pages/admin/api.test.ts",
"preview": "vite preview",
"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 { QueryClientProvider } from "@tanstack/react-query";
import { Toaster } from "@/components/ui/toaster";
@ -48,12 +48,15 @@ function Router() {
}
function App() {
const [location] = useLocation();
const isAdminRoute = location.startsWith("/admin");
return (
<QueryClientProvider client={queryClient}>
<TooltipProvider>
<Toaster />
<Router />
<ChatWidget />
{!isAdminRoute && <ChatWidget />}
</TooltipProvider>
</QueryClientProvider>
);

View File

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

View File

@ -1,7 +1,10 @@
import type { ReactNode } from "react";
import { useState, type ReactNode } from "react";
import { Link, useLocation } from "wouter";
import Navigation from "@/components/landing/Navigation";
import Footer from "@/components/landing/Footer";
import { ShieldCheck } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { apiRequest } from "@/lib/queryClient";
import type { AdminAccessResponse } from "./types";
const navItems = [
{ href: "/admin", label: "Overview" },
@ -11,45 +14,86 @@ const navItems = [
{ 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 [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 (
<div className="min-h-screen text-foreground">
<Navigation />
<main className="pt-24 pb-24">
<div className="max-w-6xl mx-auto px-6">
<div className="flex flex-col gap-6 lg:flex-row">
<aside className="lg:w-60">
<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 Control Plane
</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:text-foreground hover:bg-muted/40"
}`}
>
{item.label}
</a>
</Link>
);
})}
</nav>
</div>
</aside>
<section className="flex-1">{children}</section>
<div className="min-h-screen bg-[#05070d] text-foreground">
<header className="border-b border-border/60 bg-background/90 backdrop-blur">
<div className="mx-auto flex max-w-7xl items-center justify-between px-6 py-4">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-xl border border-primary/30 bg-primary/10 text-primary">
<ShieldCheck className="h-5 w-5" />
</div>
<div>
<p className="text-sm font-semibold text-foreground">QuantFortune Admin</p>
<p className="text-xs text-muted-foreground">Protected control plane</p>
</div>
</div>
<div className="flex items-center gap-3">
<Badge variant="secondary">{adminUser.role}</Badge>
<div className="hidden text-right sm:block">
<p className="text-sm font-medium text-foreground">{adminUser.username}</p>
<p className="text-xs text-muted-foreground">{adminUser.id}</p>
</div>
<Link href="/">
<a className="text-sm text-muted-foreground transition hover:text-foreground">Back to site</a>
</Link>
<Button variant="outline" size="sm" disabled={isLoggingOut} onClick={handleLogout}>
{isLoggingOut ? "Signing out..." : "Sign out"}
</Button>
</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>
<Footer />
</div>
);
}

View File

@ -1,7 +1,9 @@
import { useQuery } from "@tanstack/react-query";
import { AlertCircle, RotateCw, ShieldCheck } from "lucide-react";
import { Bar, BarChart, ResponsiveContainer, XAxis, YAxis } from "recharts";
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";
const StatCard = ({ label, value }: { label: string; value: number | string }) => (
@ -11,12 +13,30 @@ const StatCard = ({ label, value }: { label: string; value: number | string }) =
</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() {
const overviewQuery = useQuery<OverviewResponse>({
queryKey: ["admin/overview"],
queryFn: async () => {
const res = await apiRequest("GET", "admin/overview");
return res.json();
const data = await fetchAdminJson<OverviewResponse>("admin/overview");
if (!data) {
throw new Error("No overview response returned.");
}
return data;
},
});
@ -26,19 +46,45 @@ export default function AdminOverview() {
if (overviewQuery.isError) {
return (
<div className="rounded-2xl border border-destructive/30 bg-destructive/5 p-4 text-sm text-destructive">
{overviewQuery.error instanceof Error
? overviewQuery.error.message
: "Failed to load admin overview."}
<div className="rounded-2xl border border-destructive/30 bg-destructive/5 p-5 text-sm text-destructive">
<div className="flex items-start gap-3">
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0" />
<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>
);
}
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 needsAttention =
data.error_runs > 0 ||
data.unresolved_orders > 0 ||
data.blocked_strategies > 0 ||
data.open_support_tickets > 0;
const runStatusData = [
{ name: "RUNNING", value: data.running_runs },
@ -52,15 +98,45 @@ export default function AdminOverview() {
{ 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 (
<div className="space-y-6">
<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>
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground">Overview</p>
<h2 className="text-2xl font-semibold">System health snapshot</h2>
</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>
@ -71,6 +147,9 @@ export default function AdminOverview() {
<StatCard label="Running Runs" value={data.running_runs} />
<StatCard label="Paper Runs" value={data.paper_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 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 { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { apiRequest, resolveApiUrl } from "@/lib/queryClient";
import { apiRequest } from "@/lib/queryClient";
import AdminLayout from "./AdminLayout";
import AdminOverview from "./AdminOverview";
import AdminUsers from "./AdminUsers";
@ -13,8 +13,14 @@ import AdminRunDetail from "./AdminRunDetail";
import AdminInvariants from "./AdminInvariants";
import AdminSupportTickets from "./AdminSupportTickets";
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 = {
email: string;
@ -23,6 +29,8 @@ type LoginForm = {
export default function AdminPage() {
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 [loginError, setLoginError] = useState<string | null>(null);
const [loginPending, setLoginPending] = useState(false);
@ -30,33 +38,21 @@ export default function AdminPage() {
const [, runParams] = useRoute("/admin/runs/:runId");
const checkAccess = useCallback(async () => {
setAccessState("loading");
setAccessError(null);
try {
const res = await fetch(resolveApiUrl("admin/overview"), {
credentials: "include",
headers: {
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) {
const access = await fetchAdminJson<AdminAccessResponse>("admin/access");
if (!access) {
setAdminUser(null);
setAccessState("forbidden");
return;
}
setAdminUser(access);
setAccessState("allowed");
} catch {
setAccessState("forbidden");
} catch (error) {
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.");
return;
}
setLoginPending(true);
setLoginError(null);
try {
@ -78,8 +75,8 @@ export default function AdminPage() {
password: loginForm.password,
});
await checkAccess();
} catch (err) {
const message = err instanceof Error ? err.message : "Login failed";
} catch (error) {
const message = error instanceof Error ? error.message : "Login failed";
setLoginError(message);
setAccessState("unauthenticated");
} finally {
@ -88,12 +85,36 @@ export default function AdminPage() {
};
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") {
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="space-y-2">
<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 }))}
/>
</div>
{loginError && <p className="text-xs text-destructive">{loginError}</p>}
<Button type="submit" className="w-full" disabled={loginPending}>
{loginPending ? "Signing in..." : "Log in"}
</Button>
{(loginError || accessError) && (
<p className="text-xs text-destructive">{loginError || accessError}</p>
)}
<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>
</div>
</div>
@ -136,19 +164,30 @@ export default function AdminPage() {
if (accessState === "forbidden") {
return (
<div className="min-h-screen flex items-center justify-center text-muted-foreground">
<div className="space-y-3 text-center">
<h1 className="text-xl font-semibold text-foreground">Admin access required</h1>
<p className="text-sm">
Your account is signed in but does not have admin privileges.
<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 text-center shadow-lg">
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground">Forbidden</p>
<h1 className="mt-2 text-2xl font-semibold text-foreground">Admin access required</h1>
<p className="mt-2 text-sm text-muted-foreground">
Your account is signed in, but it does not have admin privileges.
</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>
);
}
if (!adminUser) {
return null;
}
return (
<AdminLayout>
<AdminLayout adminUser={adminUser}>
<Switch>
<Route path="/admin" component={AdminOverview} />
<Route path="/admin/users" component={AdminUsers} />

View File

@ -1,14 +1,16 @@
import { useQuery } from "@tanstack/react-query";
import { Link } from "wouter";
import { apiRequest } from "@/lib/queryClient";
import type { RunDetailResponse } from "./types";
import { fetchAdminJson, getAdminErrorMessage } from "./api";
export default function AdminRunDetail({ runId }: { runId: string }) {
const detailQuery = useQuery<RunDetailResponse>({
const detailQuery = useQuery<RunDetailResponse | null>({
queryKey: ["admin/runs", runId],
queryFn: async () => {
const res = await apiRequest("GET", `admin/runs/${runId}`);
return res.json();
const data = await fetchAdminJson<RunDetailResponse>(`admin/runs/${runId}`, {
treat404AsNull: true,
});
return data;
},
});
@ -19,7 +21,7 @@ export default function AdminRunDetail({ runId }: { runId: string }) {
if (detailQuery.isError) {
return (
<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>
);
}
@ -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">
<p className="text-sm font-semibold">Ledger Events</p>
<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}>
<span className="text-foreground">{String(evt.event)}</span>{" "}
<span>{String(evt.timestamp ?? "")}</span>
@ -112,7 +114,7 @@ export default function AdminRunDetail({ runId }: { runId: string }) {
</tr>
</thead>
<tbody className="divide-y divide-border/60">
{orders.map((order, idx) => (
{orders.map((order: RunDetailResponse["orders"][number], idx: number) => (
<tr key={idx}>
<td className="px-2 py-1">{String(order.id)}</td>
<td className="px-2 py-1">{String(order.symbol)}</td>
@ -134,7 +136,7 @@ export default function AdminRunDetail({ runId }: { runId: string }) {
</tr>
</thead>
<tbody className="divide-y divide-border/60">
{trades.map((trade, idx) => (
{trades.map((trade: RunDetailResponse["trades"][number], idx: number) => (
<tr key={idx}>
<td className="px-2 py-1">{String(trade.id)}</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 { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { apiRequest } from "@/lib/queryClient";
import type { RunsResponse } from "./types";
import { fetchAdminJson, getAdminErrorMessage } from "./api";
export default function AdminRuns() {
const [page, setPage] = useState(1);
@ -26,8 +26,11 @@ export default function AdminRuns() {
const runsQuery = useQuery<RunsResponse>({
queryKey: ["admin/runs", page, status, mode, userId],
queryFn: async () => {
const res = await apiRequest("GET", `admin/runs?${queryString}`);
return res.json();
const data = await fetchAdminJson<RunsResponse>(`admin/runs?${queryString}`);
if (!data) {
throw new Error("No admin runs response returned.");
}
return data;
},
});
@ -38,7 +41,7 @@ export default function AdminRuns() {
if (runsQuery.isError) {
return (
<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>
);
}

View File

@ -1,44 +1,35 @@
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 { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { toast } from "@/hooks/use-toast";
type Ticket = {
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[];
};
import type { SupportTicketsResponse } from "./types";
import { fetchAdminJson, getAdminErrorMessage } from "./api";
export default function AdminSupportTickets() {
const queryClient = useQueryClient();
const { data, isLoading, isError, error } = useQuery<TicketsResponse>({
const { data, isLoading, isError, error } = useQuery<SupportTicketsResponse>({
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 confirmed = window.confirm("Delete this ticket? This cannot be undone.");
if (!confirmed) return;
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" });
queryClient.invalidateQueries({ queryKey: ["admin/support-tickets"] });
} 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) {
return (
<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>
);
}

View File

@ -1,14 +1,16 @@
import { useQuery } from "@tanstack/react-query";
import { Link } from "wouter";
import { apiRequest } from "@/lib/queryClient";
import type { UserDetailResponse } from "./types";
import { fetchAdminJson, getAdminErrorMessage } from "./api";
export default function AdminUserDetail({ userId }: { userId: string }) {
const detailQuery = useQuery<UserDetailResponse>({
const detailQuery = useQuery<UserDetailResponse | null>({
queryKey: ["admin/users", userId],
queryFn: async () => {
const res = await apiRequest("GET", `admin/users/${userId}`);
return res.json();
const data = await fetchAdminJson<UserDetailResponse>(`admin/users/${userId}`, {
treat404AsNull: true,
});
return data;
},
});
@ -19,7 +21,7 @@ export default function AdminUserDetail({ userId }: { userId: string }) {
if (detailQuery.isError) {
return (
<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>
);
}
@ -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">
<p className="text-sm font-semibold">Recent Runs</p>
<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">
<Link href={`/admin/runs/${run.run_id}`}>
<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">
<p className="text-sm font-semibold">Recent Events</p>
<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">
<span className="text-foreground">{evt.event}</span>{" "}
<span>({evt.source})</span>{" "}

View File

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

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

View File

@ -7,6 +7,13 @@ export type TopError = {
run_id?: string | null;
};
export type AdminAccessResponse = {
id: string;
username: string;
role: "ADMIN" | "SUPER_ADMIN";
can_manage_admins: boolean;
};
export type OverviewResponse = {
total_users: number;
users_logged_in_last_24h: number;
@ -19,6 +26,9 @@ export type OverviewResponse = {
orders_last_24h: number;
trades_last_24h: number;
sip_executed_last_24h: number;
unresolved_orders: number;
blocked_strategies: number;
open_support_tickets: number;
top_errors: TopError[];
};
@ -124,3 +134,21 @@ export type HardResetResponse = {
deleted: Record<string, 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[];
};