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

@@ -97,4 +97,4 @@ Key Principles
- when dividing, make sure variable is not zero - when dividing, make sure variable is not zero
- to test a single ts test you can run : npm run test:single test/plugins/test-name-file.test.tsx - to test a single ts test you can run : npm run test:single test/plugins/test-name-file.test.tsx
- do not implement business logic on the controller, keep the business logic for Service files - do not implement business logic on the controller, keep the business logic for Service files
- When adding new property to and Orleans state, always add the property after the last one and increment the id

View File

@@ -263,5 +263,94 @@ public class AdminController : BaseController
RelatedBacktestsDeleted = backtestsDeleted RelatedBacktestsDeleted = backtestsDeleted
}); });
} }
/// <summary>
/// Retrieves paginated users for admin users.
/// This endpoint returns all users with all their properties.
/// </summary>
/// <param name="page">Page number (defaults to 1)</param>
/// <param name="pageSize">Number of items per page (defaults to 50, max 100)</param>
/// <param name="sortBy">Field to sort by (defaults to "Id")</param>
/// <param name="sortOrder">Sort order - "asc" or "desc" (defaults to "desc")</param>
/// <param name="userNameContains">Filter by user name contains</param>
/// <param name="ownerAddressContains">Filter by owner address contains</param>
/// <param name="agentNameContains">Filter by agent name contains</param>
/// <param name="telegramChannelContains">Filter by telegram channel contains</param>
/// <returns>A paginated list of users.</returns>
[HttpGet]
[Route("Users/Paginated")]
public async Task<ActionResult<PaginatedUsersResponse>> GetUsersPaginated(
[FromQuery] int page = 1,
[FromQuery] int pageSize = 50,
[FromQuery] UserSortableColumn sortBy = UserSortableColumn.Id,
[FromQuery] string sortOrder = "desc",
[FromQuery] string? userNameContains = null,
[FromQuery] string? ownerAddressContains = null,
[FromQuery] string? agentNameContains = null,
[FromQuery] string? telegramChannelContains = null)
{
if (!await IsUserAdmin())
{
_logger.LogWarning("Non-admin user attempted to access admin users endpoint");
return StatusCode(403, new { error = "Only admin users can access this endpoint" });
}
if (page < 1)
{
return BadRequest("Page must be greater than 0");
}
if (pageSize < 1 || pageSize > 100)
{
return BadRequest("Page size must be between 1 and 100");
}
if (sortOrder != "asc" && sortOrder != "desc")
{
return BadRequest("Sort order must be 'asc' or 'desc'");
}
// Build filter
var filter = new UsersFilter
{
UserNameContains = string.IsNullOrWhiteSpace(userNameContains) ? null : userNameContains.Trim(),
OwnerAddressContains = string.IsNullOrWhiteSpace(ownerAddressContains) ? null : ownerAddressContains.Trim(),
AgentNameContains = string.IsNullOrWhiteSpace(agentNameContains) ? null : agentNameContains.Trim(),
TelegramChannelContains = string.IsNullOrWhiteSpace(telegramChannelContains) ? null : telegramChannelContains.Trim()
};
var (users, totalCount) =
await _userService.GetUsersPaginatedAsync(
page,
pageSize,
sortBy,
sortOrder,
filter);
var totalPages = (int)Math.Ceiling(totalCount / (double)pageSize);
var response = new PaginatedUsersResponse
{
Users = users.Select(u => new UserListItemResponse
{
Id = u.Id,
Name = u.Name,
AgentName = u.AgentName,
AvatarUrl = u.AvatarUrl,
TelegramChannel = u.TelegramChannel,
OwnerWalletAddress = u.OwnerWalletAddress,
IsAdmin = u.IsAdmin,
LastConnectionDate = u.LastConnectionDate
}),
TotalCount = totalCount,
CurrentPage = page,
PageSize = pageSize,
TotalPages = totalPages,
HasNextPage = page < totalPages,
HasPreviousPage = page > 1
};
return Ok(response);
}
} }

View File

@@ -42,6 +42,47 @@ public class PaginatedBundleBacktestRequestsResponse
public bool HasPreviousPage { get; set; } public bool HasPreviousPage { get; set; }
} }
/// <summary>
/// Response model for paginated users
/// </summary>
public class PaginatedUsersResponse
{
/// <summary>
/// The list of users for the current page
/// </summary>
public IEnumerable<UserListItemResponse> Users { get; set; } = new List<UserListItemResponse>();
/// <summary>
/// Total number of users across all pages
/// </summary>
public int TotalCount { get; set; }
/// <summary>
/// Current page number
/// </summary>
public int CurrentPage { get; set; }
/// <summary>
/// Number of items per page
/// </summary>
public int PageSize { get; set; }
/// <summary>
/// Total number of pages
/// </summary>
public int TotalPages { get; set; }
/// <summary>
/// Whether there are more pages available
/// </summary>
public bool HasNextPage { get; set; }
/// <summary>
/// Whether there are previous pages available
/// </summary>
public bool HasPreviousPage { get; set; }
}
/// <summary> /// <summary>
/// Response model for a bundle backtest request list item (summary view) /// Response model for a bundle backtest request list item (summary view)
/// </summary> /// </summary>
@@ -65,4 +106,19 @@ public class BundleBacktestRequestListItemResponse
public int? EstimatedTimeRemainingSeconds { get; set; } public int? EstimatedTimeRemainingSeconds { get; set; }
} }
/// <summary>
/// Response model for a user list item (summary view)
/// </summary>
public class UserListItemResponse
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public string AgentName { get; set; } = string.Empty;
public string AvatarUrl { get; set; } = string.Empty;
public string TelegramChannel { get; set; } = string.Empty;
public string OwnerWalletAddress { get; set; } = string.Empty;
public bool IsAdmin { get; set; }
public DateTimeOffset? LastConnectionDate { get; set; }
}

View File

@@ -1,4 +1,6 @@
using Managing.Domain.Users; using Managing.Application.Abstractions.Shared;
using Managing.Domain.Users;
using static Managing.Common.Enums;
namespace Managing.Application.Abstractions.Repositories; namespace Managing.Application.Abstractions.Repositories;
@@ -8,5 +10,6 @@ public interface IUserRepository
Task<User> GetUserByNameAsync(string name, bool fetchAccounts = false); Task<User> GetUserByNameAsync(string name, bool fetchAccounts = false);
Task<User?> GetUserByIdAsync(int userId); Task<User?> GetUserByIdAsync(int userId);
Task<IEnumerable<User>> GetAllUsersAsync(); Task<IEnumerable<User>> GetAllUsersAsync();
Task<(IEnumerable<User> Users, int TotalCount)> GetUsersPaginatedAsync(int page, int pageSize, UserSortableColumn sortBy, string sortOrder, UsersFilter filter);
Task SaveOrUpdateUserAsync(User user); Task SaveOrUpdateUserAsync(User user);
} }

View File

@@ -1,4 +1,6 @@
using Managing.Domain.Users; using Managing.Application.Abstractions.Shared;
using Managing.Domain.Users;
using static Managing.Common.Enums;
namespace Managing.Application.Abstractions.Services; namespace Managing.Application.Abstractions.Services;
@@ -13,4 +15,5 @@ public interface IUserService
Task<User> GetUserByAgentName(string agentName); Task<User> GetUserByAgentName(string agentName);
Task<User> GetUserByIdAsync(int userId); Task<User> GetUserByIdAsync(int userId);
Task<IEnumerable<User>> GetAllUsersAsync(); Task<IEnumerable<User>> GetAllUsersAsync();
Task<(IEnumerable<User> Users, int TotalCount)> GetUsersPaginatedAsync(int page, int pageSize, UserSortableColumn sortBy, string sortOrder, UsersFilter filter);
} }

View File

@@ -68,4 +68,30 @@ public class BundleBacktestRequestsFilter
public DateTime? CreatedAtTo { get; set; } public DateTime? CreatedAtTo { get; set; }
} }
/// <summary>
/// Filter model for users
/// </summary>
public class UsersFilter
{
/// <summary>
/// Filter by user name contains (case-insensitive)
/// </summary>
public string? UserNameContains { get; set; }
/// <summary>
/// Filter by owner address contains (case-insensitive)
/// </summary>
public string? OwnerAddressContains { get; set; }
/// <summary>
/// Filter by agent name contains (case-insensitive)
/// </summary>
public string? AgentNameContains { get; set; }
/// <summary>
/// Filter by telegram channel contains (case-insensitive)
/// </summary>
public string? TelegramChannelContains { get; set; }
}

View File

@@ -2,11 +2,12 @@
using Managing.Application.Abstractions.Grains; using Managing.Application.Abstractions.Grains;
using Managing.Application.Abstractions.Repositories; using Managing.Application.Abstractions.Repositories;
using Managing.Application.Abstractions.Services; using Managing.Application.Abstractions.Services;
using Managing.Common; using Managing.Application.Abstractions.Shared;
using Managing.Domain.Accounts; using Managing.Domain.Accounts;
using Managing.Domain.Users; using Managing.Domain.Users;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using static Managing.Common.Enums;
namespace Managing.Application.Users; namespace Managing.Application.Users;
@@ -113,6 +114,10 @@ public class UserService : IUserService
await _userRepository.SaveOrUpdateUserAsync(user); await _userRepository.SaveOrUpdateUserAsync(user);
} }
// Update last connection date
user.LastConnectionDate = DateTimeOffset.UtcNow;
await _userRepository.SaveOrUpdateUserAsync(user);
return user; return user;
} }
else else
@@ -132,8 +137,8 @@ public class UserService : IUserService
{ {
Name = $"{name}-embedded", Name = $"{name}-embedded",
Key = recoveredAddress, Key = recoveredAddress,
Exchange = Enums.TradingExchanges.Evm, Exchange = TradingExchanges.Evm,
Type = Enums.AccountType.Privy Type = AccountType.Privy
}); });
user.Accounts = new List<Account>() user.Accounts = new List<Account>()
@@ -158,6 +163,10 @@ public class UserService : IUserService
// Don't throw here to avoid breaking the user creation process // Don't throw here to avoid breaking the user creation process
} }
} }
// Update last connection date for new user
user.LastConnectionDate = DateTimeOffset.UtcNow;
await _userRepository.SaveOrUpdateUserAsync(user);
} }
return user; return user;
@@ -313,4 +322,9 @@ public class UserService : IUserService
return user; return user;
} }
public async Task<(IEnumerable<User> Users, int TotalCount)> GetUsersPaginatedAsync(int page, int pageSize, UserSortableColumn sortBy, string sortOrder, UsersFilter filter)
{
return await _userRepository.GetUsersPaginatedAsync(page, pageSize, sortBy, sortOrder, filter);
}
} }

View File

@@ -544,6 +544,17 @@ public static class Enums
UpdatedAt UpdatedAt
} }
/// <summary>
/// Sortable columns for user queries
/// </summary>
public enum UserSortableColumn
{
Id,
Name,
OwnerWalletAddress,
AgentName
}
/// <summary> /// <summary>
/// Event types for agent summary updates /// Event types for agent summary updates
/// </summary> /// </summary>

View File

@@ -21,4 +21,6 @@ public class User
[Id(6)] public string OwnerWalletAddress { get; set; } = string.Empty; [Id(6)] public string OwnerWalletAddress { get; set; } = string.Empty;
[Id(7)] public bool IsAdmin { get; set; } = false; [Id(7)] public bool IsAdmin { get; set; } = false;
[Id(8)] public DateTimeOffset? LastConnectionDate { get; set; }
} }

View File

@@ -0,0 +1,29 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Managing.Infrastructure.Databases.Migrations
{
/// <inheritdoc />
public partial class AddLastConnectionDateToUsers : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<DateTimeOffset>(
name: "LastConnectionDate",
table: "Users",
type: "timestamp with time zone",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "LastConnectionDate",
table: "Users");
}
}
}

View File

@@ -1413,6 +1413,9 @@ namespace Managing.Infrastructure.Databases.Migrations
b.Property<bool>("IsAdmin") b.Property<bool>("IsAdmin")
.HasColumnType("boolean"); .HasColumnType("boolean");
b.Property<DateTimeOffset?>("LastConnectionDate")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name") b.Property<string>("Name")
.IsRequired() .IsRequired()
.HasMaxLength(255) .HasMaxLength(255)

View File

@@ -14,6 +14,7 @@ public class UserEntity
public string? AvatarUrl { get; set; } public string? AvatarUrl { get; set; }
public string? TelegramChannel { get; set; } public string? TelegramChannel { get; set; }
public string? OwnerWalletAddress { get; set; } public string? OwnerWalletAddress { get; set; }
public DateTimeOffset? LastConnectionDate { get; set; }
public bool IsAdmin { get; set; } public bool IsAdmin { get; set; }
// Navigation properties // Navigation properties

View File

@@ -1,10 +1,12 @@
using Managing.Application.Abstractions.Repositories; using Managing.Application.Abstractions.Repositories;
using Managing.Application.Abstractions.Services; using Managing.Application.Abstractions.Services;
using Managing.Application.Abstractions.Shared;
using Managing.Domain.Accounts; using Managing.Domain.Accounts;
using Managing.Domain.Users; using Managing.Domain.Users;
using Managing.Infrastructure.Databases.PostgreSql.Entities; using Managing.Infrastructure.Databases.PostgreSql.Entities;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using static Managing.Common.Enums;
namespace Managing.Infrastructure.Databases.PostgreSql; namespace Managing.Infrastructure.Databases.PostgreSql;
@@ -318,4 +320,77 @@ public class PostgreSqlUserRepository : BaseRepositoryWithLogging, IUserReposito
} }
}, nameof(SaveOrUpdateUserAsync), ("userName", user.Name), ("userId", user.Id)); }, nameof(SaveOrUpdateUserAsync), ("userName", user.Name), ("userId", user.Id));
} }
public async Task<(IEnumerable<User> Users, int TotalCount)> GetUsersPaginatedAsync(int page, int pageSize, UserSortableColumn sortBy, string sortOrder, UsersFilter filter)
{
return await ExecuteWithLoggingAsync(async () =>
{
try
{
await PostgreSqlConnectionHelper.EnsureConnectionOpenAsync(_context);
var query = _context.Users.AsNoTracking();
// Apply filters
if (!string.IsNullOrWhiteSpace(filter.UserNameContains))
{
query = query.Where(u => EF.Functions.ILike(u.Name, $"%{filter.UserNameContains.Trim()}%"));
}
if (!string.IsNullOrWhiteSpace(filter.OwnerAddressContains))
{
query = query.Where(u => EF.Functions.ILike(u.OwnerWalletAddress, $"%{filter.OwnerAddressContains.Trim()}%"));
}
if (!string.IsNullOrWhiteSpace(filter.AgentNameContains))
{
query = query.Where(u => EF.Functions.ILike(u.AgentName, $"%{filter.AgentNameContains.Trim()}%"));
}
if (!string.IsNullOrWhiteSpace(filter.TelegramChannelContains))
{
query = query.Where(u => EF.Functions.ILike(u.TelegramChannel, $"%{filter.TelegramChannelContains.Trim()}%"));
}
// Get total count for pagination
var totalCount = await query.CountAsync().ConfigureAwait(false);
// Apply sorting
query = sortBy switch
{
UserSortableColumn.Id => sortOrder.ToLower() == "desc" ? query.OrderByDescending(u => u.Id) : query.OrderBy(u => u.Id),
UserSortableColumn.Name => sortOrder.ToLower() == "desc" ? query.OrderByDescending(u => u.Name) : query.OrderBy(u => u.Name),
UserSortableColumn.OwnerWalletAddress => sortOrder.ToLower() == "desc" ? query.OrderByDescending(u => u.OwnerWalletAddress) : query.OrderBy(u => u.OwnerWalletAddress),
UserSortableColumn.AgentName => sortOrder.ToLower() == "desc" ? query.OrderByDescending(u => u.AgentName) : query.OrderBy(u => u.AgentName),
_ => query.OrderBy(u => u.Id) // Default sorting
};
// Apply pagination
var users = await query
.Skip((page - 1) * pageSize)
.Take(pageSize)
.Select(u => new UserEntity
{
Id = u.Id,
Name = u.Name,
AgentName = u.AgentName,
AvatarUrl = u.AvatarUrl,
TelegramChannel = u.TelegramChannel,
OwnerWalletAddress = u.OwnerWalletAddress,
IsAdmin = u.IsAdmin,
LastConnectionDate = u.LastConnectionDate
})
.ToListAsync()
.ConfigureAwait(false);
var domainUsers = users.Select(PostgreSqlMappers.Map).ToList();
return (domainUsers, totalCount);
}
finally
{
await PostgreSqlConnectionHelper.SafeCloseConnectionAsync(_context);
}
}, nameof(GetUsersPaginatedAsync), ("page", page), ("pageSize", pageSize), ("sortBy", sortBy), ("sortOrder", sortOrder));
}
} }

View File

@@ -529,6 +529,63 @@ export class AdminClient extends AuthorizedApiBase {
} }
return Promise.resolve<FileResponse>(null as any); 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 { export class BacktestClient extends AuthorizedApiBase {
@@ -4525,6 +4582,7 @@ export interface User {
telegramChannel?: string | null; telegramChannel?: string | null;
ownerWalletAddress?: string | null; ownerWalletAddress?: string | null;
isAdmin?: boolean; isAdmin?: boolean;
lastConnectionDate?: Date | null;
} }
export interface Balance { export interface Balance {
@@ -4767,6 +4825,34 @@ export interface BundleBacktestRequestStatusSummary {
count?: number; 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 { export interface Backtest {
id: string; id: string;
finalPnl: number; finalPnl: number;

View File

@@ -48,6 +48,7 @@ export interface User {
telegramChannel?: string | null; telegramChannel?: string | null;
ownerWalletAddress?: string | null; ownerWalletAddress?: string | null;
isAdmin?: boolean; isAdmin?: boolean;
lastConnectionDate?: Date | null;
} }
export interface Balance { export interface Balance {
@@ -290,6 +291,34 @@ export interface BundleBacktestRequestStatusSummary {
count?: number; 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 { export interface Backtest {
id: string; id: string;
finalPnl: number; 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 WhitelistSettings from './whitelist/whitelistSettings'
import JobsSettings from './jobs/jobsSettings' import JobsSettings from './jobs/jobsSettings'
import BundleBacktestRequestsSettings from './bundleBacktestRequests/bundleBacktestRequestsSettings' import BundleBacktestRequestsSettings from './bundleBacktestRequests/bundleBacktestRequestsSettings'
import UsersSettings from './users/usersSettings'
type TabsType = { type TabsType = {
label: string label: string
@@ -35,6 +36,11 @@ const tabs: TabsType = [
index: 4, index: 4,
label: 'Bundle', label: 'Bundle',
}, },
{
Component: UsersSettings,
index: 5,
label: 'Users',
},
] ]
const Admin: React.FC = () => { 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