346 lines
12 KiB
TypeScript
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>
|
|
);
|
|
}
|