- {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[];
+};