From ccaa363d48742ae0ff792353fd1698b0cca169ab Mon Sep 17 00:00:00 2001 From: Thigazhezhilan J Date: Fri, 10 Apr 2026 00:36:46 +0530 Subject: [PATCH] Fix admin dashboard access and error handling --- package.json | 2 +- src/App.tsx | 7 +- src/pages/admin/AdminInvariants.tsx | 13 ++- src/pages/admin/AdminLayout.tsx | 116 +++++++++++++------ src/pages/admin/AdminOverview.tsx | 99 +++++++++++++++-- src/pages/admin/AdminPage.tsx | 115 ++++++++++++------- src/pages/admin/AdminRunDetail.tsx | 18 +-- src/pages/admin/AdminRuns.tsx | 11 +- src/pages/admin/AdminSupportTickets.tsx | 39 +++---- src/pages/admin/AdminUserDetail.tsx | 16 +-- src/pages/admin/AdminUsers.tsx | 28 +++-- src/pages/admin/api.test.ts | 97 ++++++++++++++++ src/pages/admin/api.ts | 123 +++++++++++++++++++++ src/pages/admin/components/RoleActions.tsx | 8 +- src/pages/admin/types.ts | 28 +++++ 15 files changed, 568 insertions(+), 152 deletions(-) create mode 100644 src/pages/admin/api.test.ts create mode 100644 src/pages/admin/api.ts diff --git a/package.json b/package.json index e6728c10..6c5f7288 100644 --- a/package.json +++ b/package.json @@ -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" }, diff --git a/src/App.tsx b/src/App.tsx index 031bbdf4..15e1da88 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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 ( - + {!isAdminRoute && } ); diff --git a/src/pages/admin/AdminInvariants.tsx b/src/pages/admin/AdminInvariants.tsx index 8d3551b8..97c60280 100644 --- a/src/pages/admin/AdminInvariants.tsx +++ b/src/pages/admin/AdminInvariants.tsx @@ -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({ queryKey: ["admin/health/invariants"], queryFn: async () => { - const res = await apiRequest("GET", "admin/health/invariants"); - return res.json(); + const data = await fetchAdminJson("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 (
- {invariantsQuery.error instanceof Error - ? invariantsQuery.error.message - : "Failed to load invariant data."} + {getAdminErrorMessage(invariantsQuery.error, "Failed to load invariant data.")}
); } diff --git a/src/pages/admin/AdminLayout.tsx b/src/pages/admin/AdminLayout.tsx index 89aa576b..ca64623f 100644 --- a/src/pages/admin/AdminLayout.tsx +++ b/src/pages/admin/AdminLayout.tsx @@ -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 ( -
- -
-
-
- -
{children}
+
+
+
+
+
+ +
+
+

QuantFortune Admin

+

Protected control plane

+
+
+ +
+ {adminUser.role} +
+

{adminUser.username}

+

{adminUser.id}

+
+ + Back to site + +
+
+ +
+
+ +
{children}
+
-
); } diff --git a/src/pages/admin/AdminOverview.tsx b/src/pages/admin/AdminOverview.tsx index ed53745a..53a46a91 100644 --- a/src/pages/admin/AdminOverview.tsx +++ b/src/pages/admin/AdminOverview.tsx @@ -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 }) =
); +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({ queryKey: ["admin/overview"], queryFn: async () => { - const res = await apiRequest("GET", "admin/overview"); - return res.json(); + const data = await fetchAdminJson("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 ( -
- {overviewQuery.error instanceof Error - ? overviewQuery.error.message - : "Failed to load admin overview."} +
+
+ +
+
+

Admin overview unavailable

+

+ {getAdminErrorMessage(overviewQuery.error, "Failed to load admin overview.")} +

+
+ +
+
); } if (!overviewQuery.data) { - return
No data.
; + return ( +
+ No admin metrics yet. +
+ ); } 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 ( +
+
+ +
+

No admin metrics yet

+

+ The admin API is reachable, but no users, runs, or support activity have been recorded yet. +

+
+
+
+ +
+
+ ); + } + return (
-
+

Overview

System health snapshot

- Admin +
+ + {needsAttention ? "Needs attention" : "Healthy"} + + +
@@ -71,6 +147,9 @@ export default function AdminOverview() { + + +
diff --git a/src/pages/admin/AdminPage.tsx b/src/pages/admin/AdminPage.tsx index 31f6dcfd..bc9f64b8 100644 --- a/src/pages/admin/AdminPage.tsx +++ b/src/pages/admin/AdminPage.tsx @@ -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("loading"); + const [accessError, setAccessError] = useState(null); + const [adminUser, setAdminUser] = useState(null); const [loginForm, setLoginForm] = useState({ email: "", password: "" }); const [loginError, setLoginError] = useState(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("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
Loading admin...
; + return ( +
+ Checking admin access... +
+ ); + } + + if (accessState === "unavailable") { + return ( +
+
+

Admin service

+

Admin service unavailable

+

+ {accessError || "Cannot reach the admin API right now."} +

+
+ + +
+
+
+ ); } if (accessState === "unauthenticated") { return ( -
+

Admin login

@@ -124,10 +145,17 @@ export default function AdminPage() { onChange={(e) => setLoginForm((prev) => ({ ...prev, password: e.target.value }))} />
- {loginError &&

{loginError}

} - + {(loginError || accessError) && ( +

{loginError || accessError}

+ )} +
+ + +
@@ -136,19 +164,30 @@ export default function AdminPage() { if (accessState === "forbidden") { return ( -
-
-

Admin access required

-

- Your account is signed in but does not have admin privileges. +

+
+

Forbidden

+

Admin access required

+

+ Your account is signed in, but it does not have admin privileges.

+
+ + +
); } + if (!adminUser) { + return null; + } + return ( - + diff --git a/src/pages/admin/AdminRunDetail.tsx b/src/pages/admin/AdminRunDetail.tsx index e0f18c00..13fd8e68 100644 --- a/src/pages/admin/AdminRunDetail.tsx +++ b/src/pages/admin/AdminRunDetail.tsx @@ -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({ + const detailQuery = useQuery({ queryKey: ["admin/runs", runId], queryFn: async () => { - const res = await apiRequest("GET", `admin/runs/${runId}`); - return res.json(); + const data = await fetchAdminJson(`admin/runs/${runId}`, { + treat404AsNull: true, + }); + return data; }, }); @@ -19,7 +21,7 @@ export default function AdminRunDetail({ runId }: { runId: string }) { if (detailQuery.isError) { return (
- {detailQuery.error instanceof Error ? detailQuery.error.message : "Failed to load run."} + {getAdminErrorMessage(detailQuery.error, "Failed to load run.")}
); } @@ -91,7 +93,7 @@ export default function AdminRunDetail({ runId }: { runId: string }) {

Ledger Events

- {ledger_events.map((evt, idx) => ( + {ledger_events.map((evt: RunDetailResponse["ledger_events"][number], idx: number) => (
{String(evt.event)}{" "} {String(evt.timestamp ?? "")} @@ -112,7 +114,7 @@ export default function AdminRunDetail({ runId }: { runId: string }) { - {orders.map((order, idx) => ( + {orders.map((order: RunDetailResponse["orders"][number], idx: number) => ( {String(order.id)} {String(order.symbol)} @@ -134,7 +136,7 @@ export default function AdminRunDetail({ runId }: { runId: string }) { - {trades.map((trade, idx) => ( + {trades.map((trade: RunDetailResponse["trades"][number], idx: number) => ( {String(trade.id)} {String(trade.symbol)} diff --git a/src/pages/admin/AdminRuns.tsx b/src/pages/admin/AdminRuns.tsx index 3aeb4d82..c7503ca6 100644 --- a/src/pages/admin/AdminRuns.tsx +++ b/src/pages/admin/AdminRuns.tsx @@ -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({ queryKey: ["admin/runs", page, status, mode, userId], queryFn: async () => { - const res = await apiRequest("GET", `admin/runs?${queryString}`); - return res.json(); + const data = await fetchAdminJson(`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 (
- {runsQuery.error instanceof Error ? runsQuery.error.message : "Failed to load runs."} + {getAdminErrorMessage(runsQuery.error, "Failed to load runs.")}
); } diff --git a/src/pages/admin/AdminSupportTickets.tsx b/src/pages/admin/AdminSupportTickets.tsx index 31b4813a..fe21d934 100644 --- a/src/pages/admin/AdminSupportTickets.tsx +++ b/src/pages/admin/AdminSupportTickets.tsx @@ -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({ + const { data, isLoading, isError, error } = useQuery({ queryKey: ["admin/support-tickets"], - queryFn: getQueryFn({ on401: "throw" }), + queryFn: async () => { + const result = await fetchAdminJson("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 (
- {error instanceof Error ? error.message : "Failed to load support tickets."} + {getAdminErrorMessage(error, "Failed to load support tickets.")}
); } diff --git a/src/pages/admin/AdminUserDetail.tsx b/src/pages/admin/AdminUserDetail.tsx index cfb18827..0ac3e596 100644 --- a/src/pages/admin/AdminUserDetail.tsx +++ b/src/pages/admin/AdminUserDetail.tsx @@ -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({ + const detailQuery = useQuery({ queryKey: ["admin/users", userId], queryFn: async () => { - const res = await apiRequest("GET", `admin/users/${userId}`); - return res.json(); + const data = await fetchAdminJson(`admin/users/${userId}`, { + treat404AsNull: true, + }); + return data; }, }); @@ -19,7 +21,7 @@ export default function AdminUserDetail({ userId }: { userId: string }) { if (detailQuery.isError) { return (
- {detailQuery.error instanceof Error ? detailQuery.error.message : "Failed to load user."} + {getAdminErrorMessage(detailQuery.error, "Failed to load user.")}
); } @@ -63,7 +65,7 @@ export default function AdminUserDetail({ userId }: { userId: string }) {

Recent Runs

- {runs.map((run) => ( + {runs.map((run: UserDetailResponse["runs"][number]) => (
{run.run_id} @@ -78,7 +80,7 @@ export default function AdminUserDetail({ userId }: { userId: string }) {

Recent Events

- {events.map((evt, idx) => ( + {events.map((evt: UserDetailResponse["events"][number], idx: number) => (
{evt.event}{" "} ({evt.source}){" "} diff --git a/src/pages/admin/AdminUsers.tsx b/src/pages/admin/AdminUsers.tsx index f54a7959..bb99c0f9 100644 --- a/src/pages/admin/AdminUsers.tsx +++ b/src/pages/admin/AdminUsers.tsx @@ -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({ queryKey: ["admin/users", page, query], queryFn: async () => { - const res = await apiRequest("GET", `admin/users?${queryString}`); - return res.json(); + const data = await fetchAdminJson(`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( `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( `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 (
- {usersQuery.error instanceof Error ? usersQuery.error.message : "Failed to load users."} + {getAdminErrorMessage(usersQuery.error, "Failed to load users.")}
); } diff --git a/src/pages/admin/api.test.ts b/src/pages/admin/api.test.ts new file mode 100644 index 00000000..e26bb31e --- /dev/null +++ b/src/pages/admin/api.test.ts @@ -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("bad gateway", { + 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.", + ); +}); diff --git a/src/pages/admin/api.ts b/src/pages/admin/api.ts new file mode 100644 index 00000000..baec2143 --- /dev/null +++ b/src/pages/admin/api.ts @@ -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 /]|]|]/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(path: string, options: AdminRequestOptions = {}): Promise { + 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; +} diff --git a/src/pages/admin/components/RoleActions.tsx b/src/pages/admin/components/RoleActions.tsx index 7e083cfa..c8438a18 100644 --- a/src/pages/admin/components/RoleActions.tsx +++ b/src/pages/admin/components/RoleActions.tsx @@ -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); diff --git a/src/pages/admin/types.ts b/src/pages/admin/types.ts index a4a4370c..48ac3d7c 100644 --- a/src/pages/admin/types.ts +++ b/src/pages/admin/types.ts @@ -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; 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[]; +};