2026-04-10 00:36:46 +05:30

346 lines
12 KiB
TypeScript

import { useMemo, useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Link } from "wouter";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Checkbox } from "@/components/ui/checkbox";
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);
const [query, setQuery] = useState("");
const [deleteTarget, setDeleteTarget] = useState<UserSummary | null>(null);
const [confirmChecked, setConfirmChecked] = useState(false);
const [deleteError, setDeleteError] = useState<string | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
const [resetTarget, setResetTarget] = useState<UserSummary | null>(null);
const [resetChecked, setResetChecked] = useState(false);
const [resetError, setResetError] = useState<string | null>(null);
const [isResetting, setIsResetting] = useState(false);
const queryClient = useQueryClient();
const pageSize = 20;
const queryString = useMemo(() => {
const params = new URLSearchParams();
params.set("page", String(page));
params.set("page_size", String(pageSize));
if (query.trim()) {
params.set("query", query.trim());
}
return params.toString();
}, [page, pageSize, query]);
const usersQuery = useQuery<UsersResponse>({
queryKey: ["admin/users", page, query],
queryFn: async () => {
const data = await fetchAdminJson<UsersResponse>(`admin/users?${queryString}`);
if (!data) {
throw new Error("No admin users response returned.");
}
return data;
},
});
const meQuery = useQuery<{ id: string; username: string; role?: string } | null>({
queryKey: ["me"],
queryFn: getQueryFn({ on401: "returnNull" }),
});
const canDelete = !!deleteTarget && confirmChecked && !isDeleting;
const canReset = !!resetTarget && resetChecked && !isResetting;
const handleDelete = async () => {
if (!deleteTarget) {
return;
}
setIsDeleting(true);
setDeleteError(null);
try {
const result = await fetchAdminJson<DeleteUserResponse>(
`admin/users/${deleteTarget.user_id}?hard=true`,
{ method: "DELETE" },
);
void result;
setDeleteTarget(null);
setConfirmChecked(false);
await queryClient.invalidateQueries({ queryKey: ["admin/users"] });
await queryClient.invalidateQueries({ queryKey: ["admin/overview"] });
} catch (err) {
const message = getAdminErrorMessage(err, "Delete failed.");
setDeleteError(message);
} finally {
setIsDeleting(false);
}
};
const handleReset = async () => {
if (!resetTarget) {
return;
}
setIsResetting(true);
setResetError(null);
try {
const result = await fetchAdminJson<HardResetResponse>(
`admin/users/${resetTarget.user_id}/hard-reset`,
{ method: "POST" },
);
void result;
setResetTarget(null);
setResetChecked(false);
await queryClient.invalidateQueries({ queryKey: ["admin/users"] });
await queryClient.invalidateQueries({ queryKey: ["admin/overview"] });
} catch (err) {
const message = getAdminErrorMessage(err, "Reset failed.");
setResetError(message);
} finally {
setIsResetting(false);
}
};
if (usersQuery.isLoading) {
return <div className="text-sm text-muted-foreground">Loading users...</div>;
}
if (usersQuery.isError) {
return (
<div className="rounded-2xl border border-destructive/30 bg-destructive/5 p-4 text-sm text-destructive">
{getAdminErrorMessage(usersQuery.error, "Failed to load users.")}
</div>
);
}
const data = usersQuery.data;
if (!data) {
return <div className="text-sm text-muted-foreground">No users.</div>;
}
const totalPages = Math.max(1, Math.ceil(data.total / data.page_size));
const currentUserId = meQuery.data?.id;
const currentUserRole = meQuery.data?.role ?? "USER";
const isSuperAdmin = currentUserRole === "SUPER_ADMIN";
return (
<div className="space-y-6">
<div className="rounded-2xl border border-border/60 bg-card/70 p-6 shadow-sm">
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div>
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground">Users</p>
<h2 className="text-2xl font-semibold">User directory</h2>
</div>
<Input
className="md:w-72"
placeholder="Search by email or ID"
value={query}
onChange={(e) => {
setQuery(e.target.value);
setPage(1);
}}
/>
</div>
</div>
<div className="rounded-2xl border border-border/60 bg-card/70 p-6 shadow-sm overflow-x-auto">
<table className="min-w-full text-sm">
<thead className="text-muted-foreground">
<tr>
<th className="px-4 py-2 text-left">User</th>
<th className="px-4 py-2 text-left">Role</th>
<th className="px-4 py-2 text-left">Last login</th>
<th className="px-4 py-2 text-left">Runs</th>
<th className="px-4 py-2 text-left">Active run</th>
<th className="px-4 py-2 text-left">Broker</th>
{isSuperAdmin && <th className="px-4 py-2 text-left">Actions</th>}
</tr>
</thead>
<tbody className="divide-y divide-border/60">
{data.users.map((user) => (
<tr key={user.user_id}>
<td className="px-4 py-2">
<Link href={`/admin/users/${user.user_id}`}>
<a className="text-primary hover:underline">{user.username}</a>
</Link>
</td>
<td className="px-4 py-2">
<RoleBadge role={user.role} isSelf={user.user_id === currentUserId} />
</td>
<td className="px-4 py-2 text-xs text-muted-foreground">
{user.last_login_at ?? "-"}
</td>
<td className="px-4 py-2">{user.runs_count}</td>
<td className="px-4 py-2 text-xs">
{user.active_run_status ? `${user.active_run_status}` : "-"}
</td>
<td className="px-4 py-2 text-xs">
{user.broker_connected ? "Connected" : "Not connected"}
</td>
{isSuperAdmin && (
<td className="px-4 py-2 space-y-2">
<RoleActions
target={user}
isSelf={user.user_id === currentUserId}
isSuperAdmin={isSuperAdmin}
onUpdated={() => {
queryClient.invalidateQueries({ queryKey: ["admin/users"] });
queryClient.invalidateQueries({ queryKey: ["admin/overview"] });
}}
/>
<Button
variant="outline"
size="sm"
onClick={() => {
setResetTarget(user);
setResetChecked(false);
setResetError(null);
}}
>
Hard reset data
</Button>
<Button
variant="destructive"
size="sm"
disabled={user.user_id === currentUserId && user.role === "SUPER_ADMIN"}
onClick={() => {
setDeleteTarget(user);
setConfirmChecked(false);
setDeleteError(null);
}}
>
Delete user
</Button>
</td>
)}
</tr>
))}
</tbody>
</table>
</div>
<div className="flex items-center justify-between">
<Button
variant="outline"
disabled={page <= 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
Prev
</Button>
<span className="text-xs text-muted-foreground">
Page {page} of {totalPages}
</span>
<Button
variant="outline"
disabled={page >= totalPages}
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
>
Next
</Button>
</div>
<Dialog
open={!!deleteTarget}
onOpenChange={(open) => {
if (!open) {
setDeleteTarget(null);
}
}}
>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Delete user</DialogTitle>
<DialogDescription>
This will permanently delete all trading history, orders, logs, sessions, and broker
links for this user. This action cannot be undone.
</DialogDescription>
</DialogHeader>
{deleteTarget && (
<div className="space-y-4">
<label className="flex items-start gap-3 text-sm">
<Checkbox
checked={confirmChecked}
onCheckedChange={(checked) => setConfirmChecked(Boolean(checked))}
/>
<span className="text-muted-foreground">
I understand this action is permanent and cannot be undone.
</span>
</label>
{deleteError && <p className="text-xs text-destructive">{deleteError}</p>}
</div>
)}
<DialogFooter className="flex flex-col gap-2 sm:flex-row sm:justify-end">
<Button
variant="outline"
onClick={() => setDeleteTarget(null)}
disabled={isDeleting}
>
Cancel
</Button>
<Button variant="destructive" onClick={handleDelete} disabled={!canDelete}>
{isDeleting ? "Deleting..." : "Delete permanently"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog
open={!!resetTarget}
onOpenChange={(open) => {
if (!open) {
setResetTarget(null);
}
}}
>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Hard reset user data</DialogTitle>
<DialogDescription>
This clears all runs, orders, trades, positions, MTM, logs, and events for this user.
The account stays, but trading history is wiped. This cannot be undone.
</DialogDescription>
</DialogHeader>
{resetTarget && (
<div className="space-y-4">
<label className="flex items-start gap-3 text-sm">
<Checkbox
checked={resetChecked}
onCheckedChange={(checked) => setResetChecked(Boolean(checked))}
/>
<span className="text-muted-foreground">
I understand this will permanently wipe all trading history for this user.
</span>
</label>
{resetError && <p className="text-xs text-destructive">{resetError}</p>}
</div>
)}
<DialogFooter className="flex flex-col gap-2 sm:flex-row sm:justify-end">
<Button
variant="outline"
onClick={() => setResetTarget(null)}
disabled={isResetting}
>
Cancel
</Button>
<Button variant="destructive" onClick={handleReset} disabled={!canReset}>
{isResetting ? "Resetting..." : "Reset data"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}