Add whitelisting and admin

This commit is contained in:
2025-11-07 23:46:48 +07:00
parent 21110cd771
commit e0795677e4
17 changed files with 2280 additions and 10 deletions

View File

@@ -34,6 +34,7 @@
"RequestsChannelId": 1018589494968078356,
"ButtonExpirationMinutes": 2
},
"EnableSwagger": true,
"RunOrleansGrains": true,
"AllowedHosts": "*",
"KAIGEN_SECRET_KEY": "KaigenXCowchain",

View File

@@ -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);
}

View File

@@ -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()

View File

@@ -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.");
}
}

View File

@@ -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) ??

View File

@@ -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;
}

File diff suppressed because it is too large Load Diff

View File

@@ -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");
}
}
}

View File

@@ -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)

View File

@@ -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>();

View File

@@ -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
};
}

View File

@@ -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);

View File

@@ -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;

View File

@@ -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;

View File

@@ -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 = () => {

View File

@@ -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

View File

@@ -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