- Updated `ListUsersQueryHandler` to include plan names and config quotas in `UserSummaryDto`, enriching user data retrieval. - Implemented `WithPlanNamesAsync` method to fetch plan names based on user plan IDs, improving user experience in the admin interface. - Enhanced `Node` class with a `ConsecutiveProbeFailures` property for better status management during health checks. - Modified `NodeHealthCheckService` to utilize the new `RecordProbe` method, implementing a hysteresis mechanism for node status changes. - Updated frontend components to display user config quotas and plan names, improving clarity in user management. - Enhanced tests for user listing and node status handling to ensure robust functionality and coverage. - Updated documentation to reflect changes in user and node management features.
204 lines
8.5 KiB
TypeScript
204 lines
8.5 KiB
TypeScript
import { useState } from 'react'
|
|
import { createFileRoute } from '@tanstack/react-router'
|
|
import { useQuery } from '@tanstack/react-query'
|
|
import { useTranslation } from 'react-i18next'
|
|
import { Input } from '@/shared/ui/input'
|
|
import { Button } from '@/shared/ui/button'
|
|
import { Badge } from '@/shared/ui/badge'
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
|
|
import { PaidUntilBadge } from '@/features/billing/PaidUntilBadge'
|
|
import { listUsers } from '@/features/admin/users/api'
|
|
import { listRoles } from '@/features/admin/roles/api'
|
|
import { UserManageDialog } from '@/features/admin/users/UserManageDialog'
|
|
|
|
export const Route = createFileRoute('/admin/users')({ component: AdminUsersPage })
|
|
|
|
const PAGE_SIZE = 20
|
|
|
|
type StatusFilter = 'all' | 'active' | 'pending' | 'blocked'
|
|
type BillingFilter = 'all' | 'expired' | 'paid'
|
|
|
|
function AdminUsersPage() {
|
|
const { t } = useTranslation()
|
|
const [search, setSearch] = useState('')
|
|
const [roleId, setRoleId] = useState<string>('all')
|
|
const [status, setStatus] = useState<StatusFilter>('all')
|
|
const [billing, setBilling] = useState<BillingFilter>('all')
|
|
const [page, setPage] = useState(1)
|
|
// Id, не сам объект — иначе диалог держит "замороженный" снимок пользователя и не видит
|
|
// изменения, сделанные им же самим (гифт/блок/смена роли инвалидируют этот запрос, но проп
|
|
// диалога от этого не обновится, если хранить готовый объект). Ищем свежую версию в live-данных
|
|
// запроса на каждый рендер.
|
|
const [managingId, setManagingId] = useState<string | null>(null)
|
|
|
|
const rolesQuery = useQuery({ queryKey: ['admin-roles'], queryFn: listRoles })
|
|
|
|
const roleFilter = roleId === 'all' ? undefined : roleId
|
|
const isActivated = status === 'active' ? true : status === 'pending' ? false : undefined
|
|
const isBlocked = status === 'blocked' ? true : status === 'all' ? undefined : false
|
|
const billingExpired = billing === 'all' ? undefined : billing === 'expired'
|
|
|
|
const { data, isLoading, isError, refetch } = useQuery({
|
|
queryKey: ['admin-users', page, search, roleFilter, isActivated, isBlocked, billingExpired],
|
|
queryFn: () => listUsers(page, PAGE_SIZE, search || undefined, roleFilter, isActivated, isBlocked, billingExpired),
|
|
})
|
|
const managingUser = managingId ? (data?.items.find((u) => u.id === managingId) ?? null) : null
|
|
|
|
return (
|
|
<div className="flex flex-col gap-4">
|
|
<div className="flex flex-wrap gap-2">
|
|
<Input
|
|
placeholder={t('admin.users.searchPlaceholder')}
|
|
value={search}
|
|
onChange={(e) => {
|
|
setSearch(e.target.value)
|
|
setPage(1)
|
|
}}
|
|
className="max-w-sm"
|
|
/>
|
|
<Select
|
|
value={roleId}
|
|
onValueChange={(v) => {
|
|
setRoleId(v)
|
|
setPage(1)
|
|
}}
|
|
>
|
|
<SelectTrigger className="w-40">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="all">{t('admin.users.allRoles')}</SelectItem>
|
|
{rolesQuery.data?.map((role) => (
|
|
<SelectItem key={role.id} value={role.id}>
|
|
{role.name}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
<Select
|
|
value={status}
|
|
onValueChange={(v) => {
|
|
setStatus(v as StatusFilter)
|
|
setPage(1)
|
|
}}
|
|
>
|
|
<SelectTrigger className="w-40">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="all">{t('admin.users.allStatuses')}</SelectItem>
|
|
<SelectItem value="active">{t('admin.users.status.active')}</SelectItem>
|
|
<SelectItem value="pending">{t('admin.users.status.pending')}</SelectItem>
|
|
<SelectItem value="blocked">{t('admin.users.status.blocked')}</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
<Select
|
|
value={billing}
|
|
onValueChange={(v) => {
|
|
setBilling(v as BillingFilter)
|
|
setPage(1)
|
|
}}
|
|
>
|
|
<SelectTrigger className="w-40">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="all">{t('admin.users.allBilling')}</SelectItem>
|
|
<SelectItem value="expired">{t('admin.users.billingExpired')}</SelectItem>
|
|
<SelectItem value="paid">{t('admin.users.billingPaid')}</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
{isLoading && <p className="text-sm text-muted-foreground">…</p>}
|
|
|
|
{isError && (
|
|
<div className="flex items-center gap-2">
|
|
<p className="text-sm text-muted-foreground">{t('auth.genericError')}</p>
|
|
<Button variant="outline" size="sm" onClick={() => void refetch()}>
|
|
{t('activation.retry')}
|
|
</Button>
|
|
</div>
|
|
)}
|
|
|
|
{data && (
|
|
<>
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-left text-sm">
|
|
<thead>
|
|
<tr className="border-b border-border text-muted-foreground">
|
|
<th className="py-2 font-medium">{t('admin.users.userName')}</th>
|
|
<th className="py-2 font-medium">{t('admin.users.role')}</th>
|
|
<th className="py-2 font-medium">{t('admin.users.statusLabel')}</th>
|
|
<th className="py-2 font-medium">{t('admin.users.configQuota')}</th>
|
|
<th className="py-2 font-medium">{t('admin.users.billingLabel')}</th>
|
|
<th className="py-2" />
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{data.items.map((user) => (
|
|
<tr key={user.id} className="border-b border-border">
|
|
<td className="py-2">{user.userName}</td>
|
|
<td className="py-2">{user.role}</td>
|
|
<td className="py-2">
|
|
<Badge variant={user.isBlocked ? 'destructive' : user.isActivated ? 'success' : 'warning'}>
|
|
{user.isBlocked
|
|
? t('admin.users.status.blocked')
|
|
: user.isActivated
|
|
? t('admin.users.status.active')
|
|
: t('admin.users.status.pending')}
|
|
</Badge>
|
|
</td>
|
|
<td className="py-2">
|
|
<span>{user.configQuota === -1 ? '∞' : user.configQuota}</span>
|
|
<span className="ml-1 text-xs text-muted-foreground">
|
|
{user.configQuota === -1
|
|
? t('admin.users.unlimitedQuota')
|
|
: (user.planName ?? t('admin.users.customQuota'))}
|
|
</span>
|
|
</td>
|
|
<td className="py-2">
|
|
{user.billingEnabled ? (
|
|
<PaidUntilBadge
|
|
paidUntil={user.billingPaidUntil}
|
|
pendingReview={user.billingPendingReview}
|
|
showDate
|
|
/>
|
|
) : (
|
|
<span className="text-muted-foreground">—</span>
|
|
)}
|
|
</td>
|
|
<td className="py-2 text-right">
|
|
<Button size="sm" variant="outline" onClick={() => setManagingId(user.id)}>
|
|
{t('admin.users.manage')}
|
|
</Button>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
{data.items.length === 0 && <p className="text-sm text-muted-foreground">{t('admin.users.empty')}</p>}
|
|
|
|
<div className="flex items-center justify-between text-sm">
|
|
<span className="text-muted-foreground">{t('admin.users.total', { count: data.total })}</span>
|
|
<div className="flex gap-2">
|
|
<Button variant="outline" size="sm" disabled={page <= 1} onClick={() => setPage((p) => p - 1)}>
|
|
{t('admin.prev')}
|
|
</Button>
|
|
<Button variant="outline" size="sm" disabled={page * PAGE_SIZE >= data.total} onClick={() => setPage((p) => p + 1)}>
|
|
{t('admin.next')}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{managingUser && (
|
|
<UserManageDialog user={managingUser} open={!!managingUser} onOpenChange={(open) => !open && setManagingId(null)} />
|
|
)}
|
|
</div>
|
|
)
|
|
}
|