Add whitelisting and admin
This commit is contained in:
@@ -34,6 +34,7 @@
|
||||
"RequestsChannelId": 1018589494968078356,
|
||||
"ButtonExpirationMinutes": 2
|
||||
},
|
||||
"EnableSwagger": true,
|
||||
"RunOrleansGrains": true,
|
||||
"AllowedHosts": "*",
|
||||
"KAIGEN_SECRET_KEY": "KaigenXCowchain",
|
||||
|
||||
@@ -13,5 +13,6 @@ public interface IWhitelistService
|
||||
Task<int> SetIsWhitelistedAsync(IEnumerable<int> accountIds, bool isWhitelisted);
|
||||
Task<WhitelistAccount?> GetByIdAsync(int id);
|
||||
Task<WhitelistAccount> ProcessPrivyWebhookAsync(string privyUserId, long privyCreatedAt, string walletAddress, string? externalEthereumAccount, string? twitterAccount);
|
||||
Task<bool> IsEmbeddedWalletWhitelistedAsync(string embeddedWallet);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
using Managing.Application.Abstractions.Repositories;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Managing.Application.Shared;
|
||||
@@ -13,11 +15,16 @@ public class AdminConfigurationService : IAdminConfigurationService
|
||||
{
|
||||
private readonly IConfiguration _configuration;
|
||||
private readonly ILogger<AdminConfigurationService> _logger;
|
||||
private readonly IServiceScopeFactory _serviceScopeFactory;
|
||||
|
||||
public AdminConfigurationService(IConfiguration configuration, ILogger<AdminConfigurationService> logger)
|
||||
public AdminConfigurationService(
|
||||
IConfiguration configuration,
|
||||
ILogger<AdminConfigurationService> logger,
|
||||
IServiceScopeFactory serviceScopeFactory)
|
||||
{
|
||||
_configuration = configuration;
|
||||
_logger = logger;
|
||||
_serviceScopeFactory = serviceScopeFactory;
|
||||
}
|
||||
|
||||
public bool IsUserAdmin(string userName)
|
||||
@@ -27,15 +34,37 @@ public class AdminConfigurationService : IAdminConfigurationService
|
||||
return false;
|
||||
}
|
||||
|
||||
// First check configuration (for backward compatibility)
|
||||
var adminUserNames = GetAdminUserNames();
|
||||
var isAdmin = adminUserNames.Contains(userName, StringComparer.OrdinalIgnoreCase);
|
||||
var isAdminFromConfig = adminUserNames.Contains(userName, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
if (isAdmin)
|
||||
if (isAdminFromConfig)
|
||||
{
|
||||
_logger.LogInformation("User {UserName} has admin privileges", userName);
|
||||
_logger.LogInformation("User {UserName} has admin privileges from configuration", userName);
|
||||
return true;
|
||||
}
|
||||
|
||||
// If not in config, check database User.IsAdmin flag
|
||||
try
|
||||
{
|
||||
using var scope = _serviceScopeFactory.CreateScope();
|
||||
var userRepository = scope.ServiceProvider.GetRequiredService<IUserRepository>();
|
||||
|
||||
var user = userRepository.GetUserByNameAsync(userName).GetAwaiter().GetResult();
|
||||
|
||||
if (user != null && user.IsAdmin)
|
||||
{
|
||||
_logger.LogInformation("User {UserName} has admin privileges from database", userName);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Error checking admin status for user {UserName} from database", userName);
|
||||
// If database check fails, fall back to config-only result
|
||||
}
|
||||
|
||||
return isAdmin;
|
||||
return false;
|
||||
}
|
||||
|
||||
public List<string> GetAdminUserNames()
|
||||
|
||||
@@ -18,6 +18,7 @@ public class UserService : IUserService
|
||||
private readonly ILogger<UserService> _logger;
|
||||
private readonly ICacheService _cacheService;
|
||||
private readonly IGrainFactory _grainFactory;
|
||||
private readonly IWhitelistService _whitelistService;
|
||||
private readonly string[] _authorizedAddresses;
|
||||
|
||||
public UserService(
|
||||
@@ -27,6 +28,7 @@ public class UserService : IUserService
|
||||
ILogger<UserService> logger,
|
||||
ICacheService cacheService,
|
||||
IGrainFactory grainFactory,
|
||||
IWhitelistService whitelistService,
|
||||
IConfiguration configuration)
|
||||
{
|
||||
_evmManager = evmManager;
|
||||
@@ -35,6 +37,7 @@ public class UserService : IUserService
|
||||
_logger = logger;
|
||||
_cacheService = cacheService;
|
||||
_grainFactory = grainFactory;
|
||||
_whitelistService = whitelistService;
|
||||
|
||||
var authorizedAddressesString = configuration["AUTHORIZED_ADDRESSES"] ?? string.Empty;
|
||||
_authorizedAddresses = string.IsNullOrEmpty(authorizedAddressesString)
|
||||
@@ -54,9 +57,19 @@ public class UserService : IUserService
|
||||
$"Message not good : {message} - Address : {address} - User : {name} - Signature : {signature}");
|
||||
}
|
||||
|
||||
if (!_authorizedAddresses.Any(a => string.Equals(a, recoveredAddress, StringComparison.OrdinalIgnoreCase)))
|
||||
// Check if address is whitelisted first (skip authorized addresses check if whitelisted)
|
||||
var isWhitelisted = await _whitelistService.IsEmbeddedWalletWhitelistedAsync(recoveredAddress);
|
||||
|
||||
if (!isWhitelisted)
|
||||
{
|
||||
throw new Exception($"Address {recoveredAddress} not authorized. Please wait for team approval.");
|
||||
// Only check authorized addresses if not whitelisted
|
||||
var isInAuthorizedAddresses = _authorizedAddresses.Any(a => string.Equals(a, recoveredAddress, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (!isInAuthorizedAddresses)
|
||||
{
|
||||
_logger.LogWarning("Address {Address} is not authorized and not whitelisted", recoveredAddress);
|
||||
throw new Exception($"Address {recoveredAddress} not authorized. Please wait for team approval.");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -66,6 +66,18 @@ public class WhitelistService : IWhitelistService
|
||||
return await _whitelistRepository.GetByIdAsync(id);
|
||||
}
|
||||
|
||||
public async Task<bool> IsEmbeddedWalletWhitelistedAsync(string embeddedWallet)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(embeddedWallet))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var account = await _whitelistRepository.GetByEmbeddedWalletAsync(embeddedWallet);
|
||||
|
||||
return account?.IsWhitelisted ?? false;
|
||||
}
|
||||
|
||||
public async Task<WhitelistAccount> ProcessPrivyWebhookAsync(
|
||||
string privyUserId,
|
||||
long privyCreatedAt,
|
||||
@@ -76,8 +88,8 @@ public class WhitelistService : IWhitelistService
|
||||
_logger.LogInformation("Processing Privy webhook - PrivyId: {PrivyId}, Wallet: {Wallet}, ExternalEthereum: {ExternalEthereum}, Twitter: {Twitter}",
|
||||
privyUserId, walletAddress, externalEthereumAccount ?? "null", twitterAccount ?? "null");
|
||||
|
||||
// Convert Unix timestamp to DateTime
|
||||
var privyCreationDate = DateTimeOffset.FromUnixTimeSeconds(privyCreatedAt).DateTime;
|
||||
// Convert Unix timestamp to UTC DateTime (PostgreSQL requires UTC)
|
||||
var privyCreationDate = DateTimeOffset.FromUnixTimeSeconds(privyCreatedAt).UtcDateTime;
|
||||
|
||||
// Check if account already exists
|
||||
var existing = await _whitelistRepository.GetByPrivyIdAsync(privyUserId) ??
|
||||
|
||||
@@ -17,4 +17,6 @@ public class User
|
||||
[Id(4)] public string AvatarUrl { get; set; } = string.Empty;
|
||||
|
||||
[Id(5)] public string TelegramChannel { get; set; } = string.Empty;
|
||||
|
||||
[Id(6)] public bool IsAdmin { get; set; } = false;
|
||||
}
|
||||
1605
src/Managing.Infrastructure.Database/Migrations/20251107163433_AddIsAdminToUsers.Designer.cs
generated
Normal file
1605
src/Managing.Infrastructure.Database/Migrations/20251107163433_AddIsAdminToUsers.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,29 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Managing.Infrastructure.Databases.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddIsAdminToUsers : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "IsAdmin",
|
||||
table: "Users",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "IsAdmin",
|
||||
table: "Users");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1306,6 +1306,9 @@ namespace Managing.Infrastructure.Databases.Migrations
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)");
|
||||
|
||||
b.Property<bool>("IsAdmin")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(255)
|
||||
|
||||
@@ -13,6 +13,7 @@ public class UserEntity
|
||||
[MaxLength(255)] public string? AgentName { get; set; }
|
||||
public string? AvatarUrl { get; set; }
|
||||
public string? TelegramChannel { get; set; }
|
||||
public bool IsAdmin { get; set; } = false;
|
||||
|
||||
// Navigation properties
|
||||
public virtual ICollection<AccountEntity> Accounts { get; set; } = new List<AccountEntity>();
|
||||
|
||||
@@ -131,6 +131,7 @@ public static class PostgreSqlMappers
|
||||
AvatarUrl = entity.AvatarUrl,
|
||||
TelegramChannel = entity.TelegramChannel,
|
||||
Id = entity.Id, // Assuming Id is the primary key for UserEntity
|
||||
IsAdmin = entity.IsAdmin,
|
||||
Accounts = entity.Accounts?.Select(MapAccountWithoutUser).ToList() ?? new List<Account>()
|
||||
};
|
||||
}
|
||||
@@ -163,7 +164,8 @@ public static class PostgreSqlMappers
|
||||
Name = user.Name,
|
||||
AgentName = user.AgentName,
|
||||
AvatarUrl = user.AvatarUrl,
|
||||
TelegramChannel = user.TelegramChannel
|
||||
TelegramChannel = user.TelegramChannel,
|
||||
IsAdmin = user.IsAdmin
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -201,6 +201,7 @@ public class PostgreSqlUserRepository : BaseRepositoryWithLogging, IUserReposito
|
||||
existingUser.AgentName = user.AgentName;
|
||||
existingUser.AvatarUrl = user.AvatarUrl;
|
||||
existingUser.TelegramChannel = user.TelegramChannel;
|
||||
existingUser.IsAdmin = user.IsAdmin;
|
||||
|
||||
_context.Users.Update(existingUser);
|
||||
|
||||
|
||||
@@ -3879,6 +3879,142 @@ export class UserClient extends AuthorizedApiBase {
|
||||
}
|
||||
}
|
||||
|
||||
export class WhitelistClient extends AuthorizedApiBase {
|
||||
private http: { fetch(url: RequestInfo, init?: RequestInit): Promise<Response> };
|
||||
private baseUrl: string;
|
||||
protected jsonParseReviver: ((key: string, value: any) => any) | undefined = undefined;
|
||||
|
||||
constructor(configuration: IConfig, baseUrl?: string, http?: { fetch(url: RequestInfo, init?: RequestInit): Promise<Response> }) {
|
||||
super(configuration);
|
||||
this.http = http ? http : window as any;
|
||||
this.baseUrl = baseUrl ?? "http://localhost:5000";
|
||||
}
|
||||
|
||||
whitelist_GetWhitelistAccounts(pageNumber: number | undefined, pageSize: number | undefined, searchExternalEthereumAccount: string | null | undefined, searchTwitterAccount: string | null | undefined): Promise<PaginatedWhitelistAccountsResponse> {
|
||||
let url_ = this.baseUrl + "/api/Whitelist?";
|
||||
if (pageNumber === null)
|
||||
throw new Error("The parameter 'pageNumber' cannot be null.");
|
||||
else if (pageNumber !== undefined)
|
||||
url_ += "pageNumber=" + encodeURIComponent("" + pageNumber) + "&";
|
||||
if (pageSize === null)
|
||||
throw new Error("The parameter 'pageSize' cannot be null.");
|
||||
else if (pageSize !== undefined)
|
||||
url_ += "pageSize=" + encodeURIComponent("" + pageSize) + "&";
|
||||
if (searchExternalEthereumAccount !== undefined && searchExternalEthereumAccount !== null)
|
||||
url_ += "searchExternalEthereumAccount=" + encodeURIComponent("" + searchExternalEthereumAccount) + "&";
|
||||
if (searchTwitterAccount !== undefined && searchTwitterAccount !== null)
|
||||
url_ += "searchTwitterAccount=" + encodeURIComponent("" + searchTwitterAccount) + "&";
|
||||
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.processWhitelist_GetWhitelistAccounts(_response);
|
||||
});
|
||||
}
|
||||
|
||||
protected processWhitelist_GetWhitelistAccounts(response: Response): Promise<PaginatedWhitelistAccountsResponse> {
|
||||
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 PaginatedWhitelistAccountsResponse;
|
||||
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<PaginatedWhitelistAccountsResponse>(null as any);
|
||||
}
|
||||
|
||||
whitelist_SetWhitelisted(id: number): Promise<WhitelistAccount> {
|
||||
let url_ = this.baseUrl + "/api/Whitelist/{id}/whitelist";
|
||||
if (id === undefined || id === null)
|
||||
throw new Error("The parameter 'id' must be defined.");
|
||||
url_ = url_.replace("{id}", encodeURIComponent("" + id));
|
||||
url_ = url_.replace(/[?&]$/, "");
|
||||
|
||||
let options_: RequestInit = {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Accept": "application/json"
|
||||
}
|
||||
};
|
||||
|
||||
return this.transformOptions(options_).then(transformedOptions_ => {
|
||||
return this.http.fetch(url_, transformedOptions_);
|
||||
}).then((_response: Response) => {
|
||||
return this.processWhitelist_SetWhitelisted(_response);
|
||||
});
|
||||
}
|
||||
|
||||
protected processWhitelist_SetWhitelisted(response: Response): Promise<WhitelistAccount> {
|
||||
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 WhitelistAccount;
|
||||
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<WhitelistAccount>(null as any);
|
||||
}
|
||||
|
||||
whitelist_ProcessPrivyWebhook(webhook: PrivyWebhookDto): Promise<WhitelistAccount> {
|
||||
let url_ = this.baseUrl + "/api/Whitelist/webhook";
|
||||
url_ = url_.replace(/[?&]$/, "");
|
||||
|
||||
const content_ = JSON.stringify(webhook);
|
||||
|
||||
let options_: RequestInit = {
|
||||
body: content_,
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json"
|
||||
}
|
||||
};
|
||||
|
||||
return this.transformOptions(options_).then(transformedOptions_ => {
|
||||
return this.http.fetch(url_, transformedOptions_);
|
||||
}).then((_response: Response) => {
|
||||
return this.processWhitelist_ProcessPrivyWebhook(_response);
|
||||
});
|
||||
}
|
||||
|
||||
protected processWhitelist_ProcessPrivyWebhook(response: Response): Promise<WhitelistAccount> {
|
||||
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 WhitelistAccount;
|
||||
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<WhitelistAccount>(null as any);
|
||||
}
|
||||
}
|
||||
|
||||
export interface Account {
|
||||
id?: number;
|
||||
name: string;
|
||||
@@ -5117,6 +5253,55 @@ export interface LoginRequest {
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface PaginatedWhitelistAccountsResponse {
|
||||
accounts?: WhitelistAccount[] | null;
|
||||
totalCount?: number;
|
||||
pageNumber?: number;
|
||||
pageSize?: number;
|
||||
totalPages?: number;
|
||||
}
|
||||
|
||||
export interface WhitelistAccount {
|
||||
id?: number;
|
||||
privyId?: string | null;
|
||||
privyCreationDate?: Date;
|
||||
embeddedWallet?: string | null;
|
||||
externalEthereumAccount?: string | null;
|
||||
twitterAccount?: string | null;
|
||||
isWhitelisted?: boolean;
|
||||
createdAt?: Date;
|
||||
updatedAt?: Date | null;
|
||||
}
|
||||
|
||||
export interface PrivyWebhookDto {
|
||||
type?: string | null;
|
||||
user?: PrivyUserDto | null;
|
||||
wallet?: PrivyWalletDto | null;
|
||||
}
|
||||
|
||||
export interface PrivyUserDto {
|
||||
created_at?: number;
|
||||
has_accepted_terms?: boolean;
|
||||
id?: string | null;
|
||||
is_guest?: boolean;
|
||||
linked_accounts?: PrivyLinkedAccountDto[] | null;
|
||||
mfa_methods?: any[] | null;
|
||||
}
|
||||
|
||||
export interface PrivyLinkedAccountDto {
|
||||
address?: string | null;
|
||||
first_verified_at?: number | null;
|
||||
latest_verified_at?: number | null;
|
||||
type?: string | null;
|
||||
verified_at?: number | null;
|
||||
}
|
||||
|
||||
export interface PrivyWalletDto {
|
||||
type?: string | null;
|
||||
address?: string | null;
|
||||
chain_type?: string | null;
|
||||
}
|
||||
|
||||
export interface FileResponse {
|
||||
data: Blob;
|
||||
status: number;
|
||||
|
||||
@@ -1248,6 +1248,55 @@ export interface LoginRequest {
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface PaginatedWhitelistAccountsResponse {
|
||||
accounts?: WhitelistAccount[] | null;
|
||||
totalCount?: number;
|
||||
pageNumber?: number;
|
||||
pageSize?: number;
|
||||
totalPages?: number;
|
||||
}
|
||||
|
||||
export interface WhitelistAccount {
|
||||
id?: number;
|
||||
privyId?: string | null;
|
||||
privyCreationDate?: Date;
|
||||
embeddedWallet?: string | null;
|
||||
externalEthereumAccount?: string | null;
|
||||
twitterAccount?: string | null;
|
||||
isWhitelisted?: boolean;
|
||||
createdAt?: Date;
|
||||
updatedAt?: Date | null;
|
||||
}
|
||||
|
||||
export interface PrivyWebhookDto {
|
||||
type?: string | null;
|
||||
user?: PrivyUserDto | null;
|
||||
wallet?: PrivyWalletDto | null;
|
||||
}
|
||||
|
||||
export interface PrivyUserDto {
|
||||
created_at?: number;
|
||||
has_accepted_terms?: boolean;
|
||||
id?: string | null;
|
||||
is_guest?: boolean;
|
||||
linked_accounts?: PrivyLinkedAccountDto[] | null;
|
||||
mfa_methods?: any[] | null;
|
||||
}
|
||||
|
||||
export interface PrivyLinkedAccountDto {
|
||||
address?: string | null;
|
||||
first_verified_at?: number | null;
|
||||
latest_verified_at?: number | null;
|
||||
type?: string | null;
|
||||
verified_at?: number | null;
|
||||
}
|
||||
|
||||
export interface PrivyWalletDto {
|
||||
type?: string | null;
|
||||
address?: string | null;
|
||||
chain_type?: string | null;
|
||||
}
|
||||
|
||||
export interface FileResponse {
|
||||
data: Blob;
|
||||
status: number;
|
||||
|
||||
@@ -10,6 +10,7 @@ import DefaultConfig from './defaultConfig/defaultConfig'
|
||||
import UserInfoSettings from './UserInfoSettings'
|
||||
import AccountFee from './accountFee/accountFee'
|
||||
import SqlMonitoring from './sqlmonitoring/sqlMonitoring'
|
||||
import WhitelistSettings from './whitelist/whitelistSettings'
|
||||
|
||||
type TabsType = {
|
||||
label: string
|
||||
@@ -59,6 +60,11 @@ const tabs: TabsType = [
|
||||
index: 8,
|
||||
label: 'SQL Monitoring',
|
||||
},
|
||||
{
|
||||
Component: WhitelistSettings,
|
||||
index: 9,
|
||||
label: 'Whitelist',
|
||||
},
|
||||
]
|
||||
|
||||
const Settings: React.FC = () => {
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import {useState} from 'react'
|
||||
import {useQuery} from '@tanstack/react-query'
|
||||
|
||||
import useApiUrlStore from '../../../app/store/apiStore'
|
||||
import {WhitelistClient} from '../../../generated/ManagingApi'
|
||||
|
||||
import WhitelistTable from './whitelistTable'
|
||||
|
||||
const WhitelistSettings: React.FC = () => {
|
||||
const { apiUrl } = useApiUrlStore()
|
||||
const [pageNumber, setPageNumber] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(20)
|
||||
const [searchExternalEthereumAccount, setSearchExternalEthereumAccount] = useState<string>('')
|
||||
const [searchTwitterAccount, setSearchTwitterAccount] = useState<string>('')
|
||||
|
||||
const whitelistClient = new WhitelistClient({}, apiUrl)
|
||||
|
||||
const {
|
||||
data: whitelistData,
|
||||
isLoading,
|
||||
error,
|
||||
refetch
|
||||
} = useQuery({
|
||||
queryKey: ['whitelistAccounts', pageNumber, pageSize, searchExternalEthereumAccount, searchTwitterAccount],
|
||||
queryFn: async () => {
|
||||
return await whitelistClient.whitelist_GetWhitelistAccounts(
|
||||
pageNumber,
|
||||
pageSize,
|
||||
searchExternalEthereumAccount || null,
|
||||
searchTwitterAccount || null
|
||||
)
|
||||
},
|
||||
staleTime: 30000,
|
||||
gcTime: 5 * 60 * 1000,
|
||||
})
|
||||
|
||||
const accounts = whitelistData?.accounts || []
|
||||
const totalCount = whitelistData?.totalCount || 0
|
||||
const totalPages = whitelistData?.totalPages || 0
|
||||
|
||||
const handlePageChange = (newPage: number) => {
|
||||
setPageNumber(newPage)
|
||||
}
|
||||
|
||||
const handleSearchExternalEthereum = (value: string) => {
|
||||
setSearchExternalEthereumAccount(value)
|
||||
setPageNumber(1) // Reset to first page when searching
|
||||
}
|
||||
|
||||
const handleSearchTwitter = (value: string) => {
|
||||
setSearchTwitterAccount(value)
|
||||
setPageNumber(1) // Reset to first page when searching
|
||||
}
|
||||
|
||||
const handleWhitelistSuccess = () => {
|
||||
refetch()
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="container mx-auto">
|
||||
<div className="mb-4">
|
||||
<h2 className="text-2xl font-bold mb-4">Whitelist Accounts</h2>
|
||||
|
||||
{/* Search Filters */}
|
||||
<div className="flex gap-4 mb-4">
|
||||
<div className="form-control w-full max-w-xs">
|
||||
<label className="label">
|
||||
<span className="label-text">Search by Ethereum Account</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Enter Ethereum address..."
|
||||
className="input input-bordered w-full"
|
||||
value={searchExternalEthereumAccount}
|
||||
onChange={(e) => handleSearchExternalEthereum(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-control w-full max-w-xs">
|
||||
<label className="label">
|
||||
<span className="label-text">Search by Twitter Account</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Enter Twitter handle..."
|
||||
className="input input-bordered w-full"
|
||||
value={searchTwitterAccount}
|
||||
onChange={(e) => handleSearchTwitter(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<WhitelistTable
|
||||
list={accounts}
|
||||
isFetching={isLoading}
|
||||
totalCount={totalCount}
|
||||
currentPage={pageNumber}
|
||||
totalPages={totalPages}
|
||||
onPageChange={handlePageChange}
|
||||
onWhitelistSuccess={handleWhitelistSuccess}
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<div className="alert alert-error mt-4">
|
||||
<span>Failed to load whitelist accounts. Please try again.</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default WhitelistSettings
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
import React, {useMemo} from 'react'
|
||||
import {type WhitelistAccount, WhitelistClient} from '../../../generated/ManagingApi'
|
||||
import useApiUrlStore from '../../../app/store/apiStore'
|
||||
import {Table, Toast} from '../../../components/mollecules'
|
||||
|
||||
interface IWhitelistList {
|
||||
list: WhitelistAccount[]
|
||||
isFetching: boolean
|
||||
totalCount: number
|
||||
currentPage: number
|
||||
totalPages: number
|
||||
onPageChange: (page: number) => void
|
||||
onWhitelistSuccess: () => void
|
||||
}
|
||||
|
||||
const WhitelistTable: React.FC<IWhitelistList> = ({
|
||||
list,
|
||||
isFetching,
|
||||
totalCount,
|
||||
currentPage,
|
||||
totalPages,
|
||||
onPageChange,
|
||||
onWhitelistSuccess
|
||||
}) => {
|
||||
const { apiUrl } = useApiUrlStore()
|
||||
const whitelistClient = new WhitelistClient({}, apiUrl)
|
||||
|
||||
async function handleSetWhitelisted(id: number) {
|
||||
const t = new Toast('Updating whitelist status...')
|
||||
|
||||
try {
|
||||
await whitelistClient.whitelist_SetWhitelisted(id)
|
||||
t.update('success', 'Account whitelisted successfully')
|
||||
onWhitelistSuccess()
|
||||
} catch (err: any) {
|
||||
const errorMessage = err?.response || err?.message || 'Unknown error'
|
||||
t.update('error', `Error: ${errorMessage}`)
|
||||
}
|
||||
}
|
||||
|
||||
const columns = useMemo(() => [
|
||||
{
|
||||
Header: 'Is Whitelisted',
|
||||
accessor: 'isWhitelisted',
|
||||
width: 120,
|
||||
Cell: ({ value }: any) => (
|
||||
<div className={`badge badge-lg ${value ? 'badge-success' : 'badge-warning'}`}>
|
||||
{value ? 'Yes' : 'No'}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
Header: 'Actions',
|
||||
accessor: 'actions',
|
||||
width: 150,
|
||||
Cell: ({ row }: any) => {
|
||||
const account = row.original as WhitelistAccount
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
{!account.isWhitelisted && (
|
||||
<button
|
||||
className="btn btn-sm btn-primary"
|
||||
onClick={() => handleSetWhitelisted(account.id!)}
|
||||
disabled={isFetching}
|
||||
>
|
||||
Whitelist
|
||||
</button>
|
||||
)}
|
||||
{account.isWhitelisted && (
|
||||
<span className="badge badge-success badge-lg">Whitelisted</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
Header: 'Privy ID',
|
||||
accessor: 'privyId',
|
||||
width: 200,
|
||||
},
|
||||
{
|
||||
Header: 'Embedded Wallet',
|
||||
accessor: 'embeddedWallet',
|
||||
width: 200,
|
||||
Cell: ({ value }: any) => (
|
||||
<span className="font-mono text-xs">{value || '-'}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
Header: 'External Ethereum Account',
|
||||
accessor: 'externalEthereumAccount',
|
||||
width: 200,
|
||||
Cell: ({ value }: any) => (
|
||||
<span className="font-mono text-xs">{value || '-'}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
Header: 'Twitter Account',
|
||||
accessor: 'twitterAccount',
|
||||
width: 150,
|
||||
Cell: ({ value }: any) => (
|
||||
<span>{value || '-'}</span>
|
||||
),
|
||||
},
|
||||
], [isFetching])
|
||||
|
||||
const tableData = useMemo(() => {
|
||||
return list.map((account) => ({
|
||||
id: account.id,
|
||||
privyId: account.privyId || '-',
|
||||
embeddedWallet: account.embeddedWallet || '-',
|
||||
externalEthereumAccount: account.externalEthereumAccount || '-',
|
||||
twitterAccount: account.twitterAccount || '-',
|
||||
isWhitelisted: account.isWhitelisted || false,
|
||||
createdAt: account.createdAt,
|
||||
actions: account,
|
||||
}))
|
||||
}, [list])
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-4">
|
||||
<p className="text-sm text-gray-500">
|
||||
Total accounts: {totalCount} | Page {currentPage} of {totalPages}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isFetching && (
|
||||
<div className="flex justify-center my-4">
|
||||
<span className="loading loading-spinner loading-lg"></span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isFetching && list.length === 0 && (
|
||||
<div className="alert alert-info">
|
||||
<span>No whitelist accounts found.</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isFetching && list.length > 0 && (
|
||||
<>
|
||||
<Table
|
||||
columns={columns}
|
||||
data={tableData}
|
||||
showPagination={false}
|
||||
hiddenColumns={[]}
|
||||
/>
|
||||
|
||||
{/* Manual Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex justify-center items-center gap-2 mt-4">
|
||||
<button
|
||||
className="btn btn-sm"
|
||||
onClick={() => onPageChange(1)}
|
||||
disabled={currentPage === 1}
|
||||
>
|
||||
{'<<'}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-sm"
|
||||
onClick={() => onPageChange(currentPage - 1)}
|
||||
disabled={currentPage === 1}
|
||||
>
|
||||
{'<'}
|
||||
</button>
|
||||
|
||||
{/* Page numbers */}
|
||||
<div className="flex gap-1">
|
||||
{Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
|
||||
let pageNum
|
||||
if (totalPages <= 5) {
|
||||
pageNum = i + 1
|
||||
} else if (currentPage <= 3) {
|
||||
pageNum = i + 1
|
||||
} else if (currentPage >= totalPages - 2) {
|
||||
pageNum = totalPages - 4 + i
|
||||
} else {
|
||||
pageNum = currentPage - 2 + i
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
key={pageNum}
|
||||
className={`btn btn-sm ${currentPage === pageNum ? 'btn-primary' : ''}`}
|
||||
onClick={() => onPageChange(pageNum)}
|
||||
>
|
||||
{pageNum}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="btn btn-sm"
|
||||
onClick={() => onPageChange(currentPage + 1)}
|
||||
disabled={currentPage >= totalPages}
|
||||
>
|
||||
{'>'}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-sm"
|
||||
onClick={() => onPageChange(totalPages)}
|
||||
disabled={currentPage >= totalPages}
|
||||
>
|
||||
{'>>'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default WhitelistTable
|
||||
|
||||
Reference in New Issue
Block a user