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:
@@ -97,4 +97,4 @@ Key Principles
|
||||
- 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
|
||||
- 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
|
||||
|
||||
@@ -263,5 +263,94 @@ public class AdminController : BaseController
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -42,6 +42,47 @@ public class PaginatedBundleBacktestRequestsResponse
|
||||
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>
|
||||
/// Response model for a bundle backtest request list item (summary view)
|
||||
/// </summary>
|
||||
@@ -65,4 +106,19 @@ public class BundleBacktestRequestListItemResponse
|
||||
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; }
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -8,5 +10,6 @@ public interface IUserRepository
|
||||
Task<User> GetUserByNameAsync(string name, bool fetchAccounts = false);
|
||||
Task<User?> GetUserByIdAsync(int userId);
|
||||
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);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -13,4 +15,5 @@ public interface IUserService
|
||||
Task<User> GetUserByAgentName(string agentName);
|
||||
Task<User> GetUserByIdAsync(int userId);
|
||||
Task<IEnumerable<User>> GetAllUsersAsync();
|
||||
Task<(IEnumerable<User> Users, int TotalCount)> GetUsersPaginatedAsync(int page, int pageSize, UserSortableColumn sortBy, string sortOrder, UsersFilter filter);
|
||||
}
|
||||
@@ -68,4 +68,30 @@ public class BundleBacktestRequestsFilter
|
||||
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; }
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -2,11 +2,12 @@
|
||||
using Managing.Application.Abstractions.Grains;
|
||||
using Managing.Application.Abstractions.Repositories;
|
||||
using Managing.Application.Abstractions.Services;
|
||||
using Managing.Common;
|
||||
using Managing.Application.Abstractions.Shared;
|
||||
using Managing.Domain.Accounts;
|
||||
using Managing.Domain.Users;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using static Managing.Common.Enums;
|
||||
|
||||
namespace Managing.Application.Users;
|
||||
|
||||
@@ -113,6 +114,10 @@ public class UserService : IUserService
|
||||
await _userRepository.SaveOrUpdateUserAsync(user);
|
||||
}
|
||||
|
||||
// Update last connection date
|
||||
user.LastConnectionDate = DateTimeOffset.UtcNow;
|
||||
await _userRepository.SaveOrUpdateUserAsync(user);
|
||||
|
||||
return user;
|
||||
}
|
||||
else
|
||||
@@ -132,8 +137,8 @@ public class UserService : IUserService
|
||||
{
|
||||
Name = $"{name}-embedded",
|
||||
Key = recoveredAddress,
|
||||
Exchange = Enums.TradingExchanges.Evm,
|
||||
Type = Enums.AccountType.Privy
|
||||
Exchange = TradingExchanges.Evm,
|
||||
Type = AccountType.Privy
|
||||
});
|
||||
|
||||
user.Accounts = new List<Account>()
|
||||
@@ -158,6 +163,10 @@ public class UserService : IUserService
|
||||
// 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;
|
||||
@@ -313,4 +322,9 @@ public class UserService : IUserService
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -544,6 +544,17 @@ public static class Enums
|
||||
UpdatedAt
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sortable columns for user queries
|
||||
/// </summary>
|
||||
public enum UserSortableColumn
|
||||
{
|
||||
Id,
|
||||
Name,
|
||||
OwnerWalletAddress,
|
||||
AgentName
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Event types for agent summary updates
|
||||
/// </summary>
|
||||
|
||||
@@ -21,4 +21,6 @@ public class User
|
||||
[Id(6)] public string OwnerWalletAddress { get; set; } = string.Empty;
|
||||
|
||||
[Id(7)] public bool IsAdmin { get; set; } = false;
|
||||
|
||||
[Id(8)] public DateTimeOffset? LastConnectionDate { get; set; }
|
||||
}
|
||||
1726
src/Managing.Infrastructure.Database/Migrations/20251117115213_AddLastConnectionDateToUsers.Designer.cs
generated
Normal file
1726
src/Managing.Infrastructure.Database/Migrations/20251117115213_AddLastConnectionDateToUsers.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1413,6 +1413,9 @@ namespace Managing.Infrastructure.Databases.Migrations
|
||||
b.Property<bool>("IsAdmin")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<DateTimeOffset?>("LastConnectionDate")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(255)
|
||||
|
||||
@@ -14,6 +14,7 @@ public class UserEntity
|
||||
public string? AvatarUrl { get; set; }
|
||||
public string? TelegramChannel { get; set; }
|
||||
public string? OwnerWalletAddress { get; set; }
|
||||
public DateTimeOffset? LastConnectionDate { get; set; }
|
||||
public bool IsAdmin { get; set; }
|
||||
|
||||
// Navigation properties
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
using Managing.Application.Abstractions.Repositories;
|
||||
using Managing.Application.Abstractions.Services;
|
||||
using Managing.Application.Abstractions.Shared;
|
||||
using Managing.Domain.Accounts;
|
||||
using Managing.Domain.Users;
|
||||
using Managing.Infrastructure.Databases.PostgreSql.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using static Managing.Common.Enums;
|
||||
|
||||
namespace Managing.Infrastructure.Databases.PostgreSql;
|
||||
|
||||
@@ -318,4 +320,77 @@ public class PostgreSqlUserRepository : BaseRepositoryWithLogging, IUserReposito
|
||||
}
|
||||
}, 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));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
9
src/Managing.WebApp/src/hooks/useAdminClient.tsx
Normal file
9
src/Managing.WebApp/src/hooks/useAdminClient.tsx
Normal 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 }
|
||||
}
|
||||
@@ -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 = () => {
|
||||
|
||||
188
src/Managing.WebApp/src/pages/adminPage/users/usersSettings.tsx
Normal file
188
src/Managing.WebApp/src/pages/adminPage/users/usersSettings.tsx
Normal 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
|
||||
197
src/Managing.WebApp/src/pages/adminPage/users/usersTable.tsx
Normal file
197
src/Managing.WebApp/src/pages/adminPage/users/usersTable.tsx
Normal 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
|
||||
Reference in New Issue
Block a user