Fix admin dashboard access and error handling
This commit is contained in:
parent
b3b798ff2f
commit
ccaa363d48
@ -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"
|
||||
},
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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} />
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>{" "}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
97
src/pages/admin/api.test.ts
Normal file
97
src/pages/admin/api.test.ts
Normal 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
123
src/pages/admin/api.ts
Normal 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;
|
||||
}
|
||||
@ -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);
|
||||
|
||||
@ -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[];
|
||||
};
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user