Add paginated user retrieval functionality in AdminController and related services. Implemented UsersFilter for filtering user queries and added LastConnectionDate property to User model. Updated database schema and frontend API to support new user management features.

This commit is contained in:
2025-11-17 20:04:17 +07:00
parent 06ef33b7ab
commit 02e46e8d0d
20 changed files with 2559 additions and 6 deletions

View File

@@ -529,6 +529,63 @@ export class AdminClient extends AuthorizedApiBase {
}
return Promise.resolve<FileResponse>(null as any);
}
admin_GetUsersPaginated(page: number | undefined, pageSize: number | undefined, sortBy: UserSortableColumn | undefined, sortOrder: string | null | undefined, userNameContains: string | null | undefined, ownerAddressContains: string | null | undefined, agentNameContains: string | null | undefined, telegramChannelContains: string | null | undefined): Promise<PaginatedUsersResponse> {
let url_ = this.baseUrl + "/Admin/Users/Paginated?";
if (page === null)
throw new Error("The parameter 'page' cannot be null.");
else if (page !== undefined)
url_ += "page=" + encodeURIComponent("" + page) + "&";
if (pageSize === null)
throw new Error("The parameter 'pageSize' cannot be null.");
else if (pageSize !== undefined)
url_ += "pageSize=" + encodeURIComponent("" + pageSize) + "&";
if (sortBy === null)
throw new Error("The parameter 'sortBy' cannot be null.");
else if (sortBy !== undefined)
url_ += "sortBy=" + encodeURIComponent("" + sortBy) + "&";
if (sortOrder !== undefined && sortOrder !== null)
url_ += "sortOrder=" + encodeURIComponent("" + sortOrder) + "&";
if (userNameContains !== undefined && userNameContains !== null)
url_ += "userNameContains=" + encodeURIComponent("" + userNameContains) + "&";
if (ownerAddressContains !== undefined && ownerAddressContains !== null)
url_ += "ownerAddressContains=" + encodeURIComponent("" + ownerAddressContains) + "&";
if (agentNameContains !== undefined && agentNameContains !== null)
url_ += "agentNameContains=" + encodeURIComponent("" + agentNameContains) + "&";
if (telegramChannelContains !== undefined && telegramChannelContains !== null)
url_ += "telegramChannelContains=" + encodeURIComponent("" + telegramChannelContains) + "&";
url_ = url_.replace(/[?&]$/, "");
let options_: RequestInit = {
method: "GET",
headers: {
"Accept": "application/json"
}
};
return this.transformOptions(options_).then(transformedOptions_ => {
return this.http.fetch(url_, transformedOptions_);
}).then((_response: Response) => {
return this.processAdmin_GetUsersPaginated(_response);
});
}
protected processAdmin_GetUsersPaginated(response: Response): Promise<PaginatedUsersResponse> {
const status = response.status;
let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); };
if (status === 200) {
return response.text().then((_responseText) => {
let result200: any = null;
result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as PaginatedUsersResponse;
return result200;
});
} else if (status !== 200 && status !== 204) {
return response.text().then((_responseText) => {
return throwException("An unexpected server error occurred.", status, _responseText, _headers);
});
}
return Promise.resolve<PaginatedUsersResponse>(null as any);
}
}
export class BacktestClient extends AuthorizedApiBase {
@@ -4525,6 +4582,7 @@ export interface User {
telegramChannel?: string | null;
ownerWalletAddress?: string | null;
isAdmin?: boolean;
lastConnectionDate?: Date | null;
}
export interface Balance {
@@ -4767,6 +4825,34 @@ export interface BundleBacktestRequestStatusSummary {
count?: number;
}
export interface PaginatedUsersResponse {
users?: UserListItemResponse[];
totalCount?: number;
currentPage?: number;
pageSize?: number;
totalPages?: number;
hasNextPage?: boolean;
hasPreviousPage?: boolean;
}
export interface UserListItemResponse {
id?: number;
name?: string;
agentName?: string;
avatarUrl?: string;
telegramChannel?: string;
ownerWalletAddress?: string;
isAdmin?: boolean;
lastConnectionDate?: Date | null;
}
export enum UserSortableColumn {
Id = "Id",
Name = "Name",
OwnerWalletAddress = "OwnerWalletAddress",
AgentName = "AgentName",
}
export interface Backtest {
id: string;
finalPnl: number;

View File

@@ -48,6 +48,7 @@ export interface User {
telegramChannel?: string | null;
ownerWalletAddress?: string | null;
isAdmin?: boolean;
lastConnectionDate?: Date | null;
}
export interface Balance {
@@ -290,6 +291,34 @@ export interface BundleBacktestRequestStatusSummary {
count?: number;
}
export interface PaginatedUsersResponse {
users?: UserListItemResponse[];
totalCount?: number;
currentPage?: number;
pageSize?: number;
totalPages?: number;
hasNextPage?: boolean;
hasPreviousPage?: boolean;
}
export interface UserListItemResponse {
id?: number;
name?: string;
agentName?: string;
avatarUrl?: string;
telegramChannel?: string;
ownerWalletAddress?: string;
isAdmin?: boolean;
lastConnectionDate?: Date | null;
}
export enum UserSortableColumn {
Id = "Id",
Name = "Name",
OwnerWalletAddress = "OwnerWalletAddress",
AgentName = "AgentName",
}
export interface Backtest {
id: string;
finalPnl: number;

View File

@@ -0,0 +1,9 @@
import {AdminClient} from '../generated/ManagingApi'
import useApiUrlStore from '../app/store/apiStore'
export const useAdminClient = () => {
const { apiUrl } = useApiUrlStore()
const client = new AdminClient({}, apiUrl)
return { client }
}

View File

@@ -6,6 +6,7 @@ import AccountSettings from './account/accountSettings'
import WhitelistSettings from './whitelist/whitelistSettings'
import JobsSettings from './jobs/jobsSettings'
import BundleBacktestRequestsSettings from './bundleBacktestRequests/bundleBacktestRequestsSettings'
import UsersSettings from './users/usersSettings'
type TabsType = {
label: string
@@ -35,6 +36,11 @@ const tabs: TabsType = [
index: 4,
label: 'Bundle',
},
{
Component: UsersSettings,
index: 5,
label: 'Users',
},
]
const Admin: React.FC = () => {

View File

@@ -0,0 +1,188 @@
import {useState} from 'react'
import {useQuery} from '@tanstack/react-query'
import {useAdminClient} from '../../../hooks/useAdminClient'
import {UserSortableColumn,} from '../../../generated/ManagingApi'
import UsersTable from './usersTable'
const UsersSettings: React.FC = () => {
const { client } = useAdminClient()
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(50)
const [sortBy, setSortBy] = useState<UserSortableColumn>(UserSortableColumn.Id)
const [sortOrder, setSortOrder] = useState<string>('desc')
const [userNameContains, setUserNameContains] = useState<string>('')
const [ownerAddressContains, setOwnerAddressContains] = useState<string>('')
const [agentNameContains, setAgentNameContains] = useState<string>('')
const [telegramChannelContains, setTelegramChannelContains] = useState<string>('')
const [filtersOpen, setFiltersOpen] = useState<boolean>(false)
const {
data: usersData,
isLoading,
error,
} = useQuery({
queryKey: ['users', page, pageSize, sortBy, sortOrder, userNameContains, ownerAddressContains, agentNameContains, telegramChannelContains],
queryFn: async () => {
return await client.admin_GetUsersPaginated(
page,
pageSize,
sortBy,
sortOrder || null,
userNameContains || null,
ownerAddressContains || null,
agentNameContains || null,
telegramChannelContains || null
)
},
staleTime: 10000, // 10 seconds
gcTime: 5 * 60 * 1000,
})
const users = usersData?.users || []
const totalCount = usersData?.totalCount || 0
const totalPages = usersData?.totalPages || 0
const currentPage = usersData?.currentPage || 1
const handlePageChange = (newPage: number) => {
setPage(newPage)
}
const handleSortChange = (newSortBy: UserSortableColumn) => {
if (sortBy === newSortBy) {
setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc')
} else {
setSortBy(newSortBy)
setSortOrder('desc')
}
}
const handleFilterChange = () => {
setPage(1) // Reset to first page when filters change
}
const clearFilters = () => {
setUserNameContains('')
setOwnerAddressContains('')
setAgentNameContains('')
setTelegramChannelContains('')
setPage(1)
}
return (
<div className="container mx-auto p-4 pb-20">
{/* Filters Section */}
<div className="card bg-base-100 shadow-md mb-4">
<div className="card-body py-4">
<div className="flex justify-between items-center mb-4">
<h3 className="card-title text-lg">Filters</h3>
<div className="flex gap-2">
<button
className="btn btn-sm btn-ghost"
onClick={() => setFiltersOpen(!filtersOpen)}
>
{filtersOpen ? 'Hide' : 'Show'} Filters
</button>
<button
className="btn btn-sm btn-outline"
onClick={clearFilters}
>
Clear Filters
</button>
</div>
</div>
{filtersOpen && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div className="form-control">
<label className="label">
<span className="label-text">User Name Contains</span>
</label>
<input
type="text"
placeholder="Search by user name..."
className="input input-bordered input-sm"
value={userNameContains}
onChange={(e) => {
setUserNameContains(e.target.value)
handleFilterChange()
}}
/>
</div>
<div className="form-control">
<label className="label">
<span className="label-text">Owner Address Contains</span>
</label>
<input
type="text"
placeholder="Search by owner address..."
className="input input-bordered input-sm"
value={ownerAddressContains}
onChange={(e) => {
setOwnerAddressContains(e.target.value)
handleFilterChange()
}}
/>
</div>
<div className="form-control">
<label className="label">
<span className="label-text">Agent Name Contains</span>
</label>
<input
type="text"
placeholder="Search by agent name..."
className="input input-bordered input-sm"
value={agentNameContains}
onChange={(e) => {
setAgentNameContains(e.target.value)
handleFilterChange()
}}
/>
</div>
<div className="form-control">
<label className="label">
<span className="label-text">Telegram Channel Contains</span>
</label>
<input
type="text"
placeholder="Search by telegram channel..."
className="input input-bordered input-sm"
value={telegramChannelContains}
onChange={(e) => {
setTelegramChannelContains(e.target.value)
handleFilterChange()
}}
/>
</div>
</div>
)}
</div>
</div>
<UsersTable
users={users}
isLoading={isLoading}
totalCount={totalCount}
currentPage={currentPage}
totalPages={totalPages}
pageSize={pageSize}
sortBy={sortBy}
sortOrder={sortOrder}
onPageChange={handlePageChange}
onSortChange={handleSortChange}
/>
{error && (
<div className="alert alert-error mt-4">
<span>Failed to load users. Please try again.</span>
</div>
)}
</div>
)
}
export default UsersSettings

View File

@@ -0,0 +1,197 @@
import React, {useMemo} from 'react'
import {type UserListItemResponse, UserSortableColumn} from '../../../generated/ManagingApi'
import {Table, Toast} from '../../../components/mollecules'
interface IUsersTable {
users: UserListItemResponse[]
isLoading: boolean
totalCount: number
currentPage: number
totalPages: number
pageSize: number
sortBy: UserSortableColumn
sortOrder: string
onPageChange: (page: number) => void
onSortChange: (sortBy: UserSortableColumn) => void
}
const UsersTable: React.FC<IUsersTable> = ({
users,
isLoading,
totalCount,
currentPage,
totalPages,
pageSize,
sortBy,
sortOrder,
onPageChange,
onSortChange
}) => {
const formatDate = (date: Date | string | null | undefined) => {
if (!date) return '-'
try {
return new Date(date).toLocaleString()
} catch {
return '-'
}
}
const copyToClipboard = async (text: string, label: string) => {
const toast = new Toast(`Copying ${label} to clipboard...`)
try {
await navigator.clipboard.writeText(text)
toast.update('success', `${label} copied to clipboard!`)
} catch (err) {
toast.update('error', 'Failed to copy to clipboard')
}
}
const truncateAddress = (address: string | null | undefined) => {
if (!address) return '-'
if (address.length <= 12) return address
return `${address.substring(0, 6)}...${address.substring(address.length - 4)}`
}
const SortableHeader = ({ column, label }: { column: UserSortableColumn; label: string }) => {
const isActive = sortBy === column
return (
<div
className="flex items-center gap-1 cursor-pointer hover:text-primary text-base-content"
onClick={() => onSortChange(column)}
>
<span className="font-semibold">{label}</span>
{isActive && (
<span className="text-xs">
{sortOrder === 'asc' ? '↑' : '↓'}
</span>
)}
</div>
)
}
const columns = useMemo(() => [
{
id: 'id',
Header: () => <SortableHeader column={UserSortableColumn.Id} label="ID" />,
accessor: (user: UserListItemResponse) => (
<span className="font-mono text-sm">{user.id?.toString() || '-'}</span>
)
},
{
id: 'name',
Header: () => <SortableHeader column={UserSortableColumn.Name} label="Name" />,
accessor: (user: UserListItemResponse) => (
<div className="flex items-center gap-2">
<span className="font-medium">{user.name || '-'}</span>
{user.name && (
<button
className="btn btn-ghost btn-xs"
onClick={() => copyToClipboard(user.name!, 'Name')}
title="Copy name"
>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor" className="w-3 h-3">
<path strokeLinecap="round" strokeLinejoin="round" d="M15.666 3.888A2.25 2.25 0 0013.5 2.25h-3c-1.03 0-1.9.693-2.166 1.638m7.332 0c.055.194.084.4.084.612v0a.75.75 0 01-.75.75H9a.75.75 0 01-.75-.75v0c0-.212.03-.418.084-.612m7.332 0c.646.049 1.288.11 1.927.184 1.1.128 1.907 1.077 1.907 2.185V19.5a2.25 2.25 0 01-2.25 2.25H6.75A2.25 2.25 0 014.5 19.5V6.257c0-1.108.806-2.057 1.907-2.185a48.208 48.208 0 015.927-.184z" />
</svg>
</button>
)}
</div>
)
},
{
id: 'agentName',
Header: () => <SortableHeader column={UserSortableColumn.AgentName} label="Agent Name" />,
accessor: (user: UserListItemResponse) => (
<span className="text-sm">{user.agentName || '-'}</span>
)
},
{
id: 'ownerWalletAddress',
Header: () => <SortableHeader column={UserSortableColumn.OwnerWalletAddress} label="Owner Address" />,
accessor: (user: UserListItemResponse) => (
<div className="flex items-center gap-2">
<span className="font-mono text-xs">{truncateAddress(user.ownerWalletAddress)}</span>
{user.ownerWalletAddress && (
<button
className="btn btn-ghost btn-xs"
onClick={() => copyToClipboard(user.ownerWalletAddress!, 'Address')}
title="Copy full address"
>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor" className="w-3 h-3">
<path strokeLinecap="round" strokeLinejoin="round" d="M15.666 3.888A2.25 2.25 0 0013.5 2.25h-3c-1.03 0-1.9.693-2.166 1.638m7.332 0c.055.194.084.4.084.612v0a.75.75 0 01-.75.75H9a.75.75 0 01-.75-.75v0c0-.212.03-.418.084-.612m7.332 0c.646.049 1.288.11 1.927.184 1.1.128 1.907 1.077 1.907 2.185V19.5a2.25 2.25 0 01-2.25 2.25H6.75A2.25 2.25 0 014.5 19.5V6.257c0-1.108.806-2.057 1.907-2.185a48.208 48.208 0 015.927-.184z" />
</svg>
</button>
)}
</div>
)
},
{
id: 'telegramChannel',
Header: 'Telegram Channel',
accessor: (user: UserListItemResponse) => (
<span className="text-sm">{user.telegramChannel || '-'}</span>
)
},
{
id: 'isAdmin',
Header: 'Admin',
accessor: (user: UserListItemResponse) => (
<span className={`badge badge-sm ${user.isAdmin ? 'badge-success' : 'badge-ghost'}`}>
{user.isAdmin ? 'Admin' : 'User'}
</span>
)
},
{
id: 'avatarUrl',
Header: 'Avatar',
accessor: (user: UserListItemResponse) => (
user.avatarUrl ? (
<div className="avatar">
<div className="w-8 rounded-full">
<img src={user.avatarUrl} alt="Avatar" />
</div>
</div>
) : (
<span className="text-sm text-base-content/50">-</span>
)
)
},
{
id: 'lastConnectionDate',
Header: 'Last Connection',
accessor: (user: UserListItemResponse) => (
<span className="text-xs text-base-content/70">
{formatDate(user.lastConnectionDate)}
</span>
)
}
], [sortBy, sortOrder, onSortChange])
return (
<div className="card bg-base-100 shadow-md">
<div className="card-body">
<div className="flex justify-between items-center mb-4">
<h3 className="card-title text-lg">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor" className="w-6 h-6">
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z" />
</svg>
Users ({totalCount})
</h3>
</div>
<Table
data={users}
columns={columns}
isLoading={isLoading}
totalCount={totalCount}
currentPage={currentPage}
totalPages={totalPages}
pageSize={pageSize}
onPageChange={onPageChange}
emptyMessage="No users found"
/>
</div>
</div>
)
}
export default UsersTable