Enhance admin endpoints and queries for improved filtering and management
CI / Backend (build + test) (push) Successful in 1m22s
CI / Frontend (lint + typecheck + build) (push) Successful in 34s

- Updated `ListPaymentRequestsQuery` to include `Kind` and `Search` parameters for better filtering of payment requests.
- Enhanced `ListAuditLogsQuery` to support additional filters: `Source`, `TargetType`, and `Action`, improving audit log retrieval.
- Modified `ListUsersQuery` to accept new filters: `RoleId`, `IsActivated`, `IsBlocked`, and `BillingExpired`, allowing for more granular user management.
- Introduced `DeleteInbound` endpoint to allow deletion of inbounds that are not currently available, enhancing inbound management capabilities.
- Updated frontend API calls to reflect new query parameters and support for additional filtering options in the admin interface.
- Revised API documentation to include new parameters and endpoint functionalities for better clarity and usage guidance.
This commit is contained in:
Leonid Pershin
2026-07-20 10:35:48 +03:00
parent e19860ba46
commit 33ad98cf62
37 changed files with 1115 additions and 124 deletions
+86 -48
View File
@@ -5,18 +5,31 @@ import { useTranslation } from 'react-i18next'
import { toast } from '@/shared/ui/toast-store'
import { Card, CardContent, CardHeader, CardTitle } from '@/shared/ui/card'
import { Button } from '@/shared/ui/button'
import { Badge } from '@/shared/ui/badge'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
import { approveActivationRequest, listActivationRequests, rejectActivationRequest } from '@/features/admin/activation/api'
import type { ActivationStatus } from '@/shared/api/types'
export const Route = createFileRoute('/admin/activation')({ component: AdminActivationPage })
const STATUSES: ActivationStatus[] = ['Pending', 'Approved', 'Rejected']
const STATUS_VARIANT: Record<ActivationStatus, 'success' | 'warning' | 'destructive'> = {
Pending: 'warning',
Approved: 'success',
Rejected: 'destructive',
}
function AdminActivationPage() {
const { t } = useTranslation()
const queryClient = useQueryClient()
const [page, setPage] = useState(1)
const [status, setStatus] = useState<ActivationStatus | 'all'>('Pending')
const statusFilter = status === 'all' ? undefined : status
const { data, isLoading, isError, refetch } = useQuery({
queryKey: ['admin-activation-requests', page],
queryFn: () => listActivationRequests('Pending', page, 20),
queryKey: ['admin-activation-requests', page, statusFilter],
queryFn: () => listActivationRequests(statusFilter, page, 20),
})
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['admin-activation-requests'] })
@@ -39,55 +52,80 @@ function AdminActivationPage() {
onError: () => toast.error(t('auth.genericError')),
})
if (isLoading) return <p className="text-sm text-muted-foreground"></p>
if (isError || !data) {
return (
<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>
)
}
if (data.items.length === 0) {
return <p className="text-sm text-muted-foreground">{t('admin.activation.empty')}</p>
}
return (
<div className="flex flex-col gap-4">
{data.items.map((request) => (
<Card key={request.id}>
<CardHeader>
<CardTitle className="text-base">{request.userName}</CardTitle>
{request.comment && <p className="text-sm text-muted-foreground">{request.comment}</p>}
</CardHeader>
<CardContent className="flex gap-2">
<Button size="sm" disabled={approveMutation.isPending} onClick={() => approveMutation.mutate(request.id)}>
{t('admin.activation.approve')}
</Button>
<Button
size="sm"
variant="outline"
disabled={rejectMutation.isPending}
onClick={() => rejectMutation.mutate(request.id)}
>
{t('admin.activation.reject')}
</Button>
</CardContent>
</Card>
))}
<Select
value={status}
onValueChange={(v) => {
setStatus(v as ActivationStatus | 'all')
setPage(1)
}}
>
<SelectTrigger className="w-48">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t('admin.activation.allStatuses')}</SelectItem>
{STATUSES.map((s) => (
<SelectItem key={s} value={s}>
{t(`admin.activation.status.${s}`)}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="flex justify-end gap-2 text-sm">
<Button variant="outline" size="sm" disabled={page <= 1} onClick={() => setPage((p) => p - 1)}>
{t('admin.prev')}
</Button>
<Button variant="outline" size="sm" disabled={page * 20 >= data.total} onClick={() => setPage((p) => p + 1)}>
{t('admin.next')}
</Button>
</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?.items.length === 0 && <p className="text-sm text-muted-foreground">{t('admin.activation.empty')}</p>}
{data && data.items.length > 0 && (
<>
{data.items.map((request) => (
<Card key={request.id}>
<CardHeader className="flex-row items-center justify-between gap-2 space-y-0">
<CardTitle className="text-base">{request.userName}</CardTitle>
<Badge variant={STATUS_VARIANT[request.status]}>{t(`admin.activation.status.${request.status}`)}</Badge>
</CardHeader>
<CardContent className="flex flex-col gap-2">
{request.comment && <p className="text-sm text-muted-foreground">{request.comment}</p>}
{request.status === 'Pending' && (
<div className="flex gap-2">
<Button size="sm" disabled={approveMutation.isPending} onClick={() => approveMutation.mutate(request.id)}>
{t('admin.activation.approve')}
</Button>
<Button
size="sm"
variant="outline"
disabled={rejectMutation.isPending}
onClick={() => rejectMutation.mutate(request.id)}
>
{t('admin.activation.reject')}
</Button>
</div>
)}
</CardContent>
</Card>
))}
<div className="flex justify-end gap-2 text-sm">
<Button variant="outline" size="sm" disabled={page <= 1} onClick={() => setPage((p) => p - 1)}>
{t('admin.prev')}
</Button>
<Button variant="outline" size="sm" disabled={page * 20 >= data.total} onClick={() => setPage((p) => p + 1)}>
{t('admin.next')}
</Button>
</div>
</>
)}
</div>
)
}
+64 -2
View File
@@ -4,23 +4,85 @@ import { useQuery } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { Button } from '@/shared/ui/button'
import { Badge } from '@/shared/ui/badge'
import { Input } from '@/shared/ui/input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
import { listAuditLogs } from '@/features/admin/audit/api'
import type { AuditSource } from '@/shared/api/types'
export const Route = createFileRoute('/admin/audit')({ component: AdminAuditPage })
const PAGE_SIZE = 50
const SOURCES: AuditSource[] = ['Web', 'Telegram', 'System']
const TARGET_TYPES = ['User', 'VpnConfig', 'Inbound', 'Node', 'SupportTicket', 'PaymentRequest']
function AdminAuditPage() {
const { t } = useTranslation()
const [page, setPage] = useState(1)
const [source, setSource] = useState<AuditSource | 'all'>('all')
const [targetType, setTargetType] = useState<string>('all')
const [action, setAction] = useState('')
const sourceFilter = source === 'all' ? undefined : source
const targetTypeFilter = targetType === 'all' ? undefined : targetType
const { data, isLoading, isError, refetch } = useQuery({
queryKey: ['admin-audit', page],
queryFn: () => listAuditLogs(page, PAGE_SIZE),
queryKey: ['admin-audit', page, sourceFilter, targetTypeFilter, action],
queryFn: () => listAuditLogs(page, PAGE_SIZE, sourceFilter, targetTypeFilter, action || undefined),
})
return (
<div className="flex flex-col gap-4">
<div className="flex flex-wrap gap-2">
<Input
placeholder={t('admin.audit.actionPlaceholder')}
value={action}
onChange={(e) => {
setAction(e.target.value)
setPage(1)
}}
className="max-w-xs"
/>
<Select
value={source}
onValueChange={(v) => {
setSource(v as AuditSource | 'all')
setPage(1)
}}
>
<SelectTrigger className="w-40">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t('admin.audit.allSources')}</SelectItem>
{SOURCES.map((s) => (
<SelectItem key={s} value={s}>
{s}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={targetType}
onValueChange={(v) => {
setTargetType(v)
setPage(1)
}}
>
<SelectTrigger className="w-48">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t('admin.audit.allTargetTypes')}</SelectItem>
{TARGET_TYPES.map((tt) => (
<SelectItem key={tt} value={tt}>
{tt}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{isLoading && <p className="text-sm text-muted-foreground"></p>}
{isError && (
+58 -21
View File
@@ -8,7 +8,8 @@ import { Badge } from '@/shared/ui/badge'
import { Button } from '@/shared/ui/button'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
import { HttpError } from '@/shared/api/client'
import type { PaymentRequestStatus } from '@/shared/api/types'
import { Input } from '@/shared/ui/input'
import type { PaymentRequestKind, PaymentRequestStatus } from '@/shared/api/types'
import { BillingSettingsEditor } from '@/features/admin/billing/BillingSettingsEditor'
import {
confirmPaymentRequest,
@@ -19,6 +20,8 @@ import {
export const Route = createFileRoute('/admin/billing')({ component: AdminBillingPage })
const KIND_FILTERS: (PaymentRequestKind | 'All')[] = ['Subscription', 'RoleChangeTopUp', 'All']
const STATUS_FILTERS: (PaymentRequestStatus | 'All')[] = [
'AwaitingConfirmation',
'AwaitingPayment',
@@ -81,11 +84,16 @@ function RequestsSection() {
const { t } = useTranslation()
const queryClient = useQueryClient()
const [status, setStatus] = useState<PaymentRequestStatus | 'All'>('AwaitingConfirmation')
const [kind, setKind] = useState<PaymentRequestKind | 'All'>('All')
const [search, setSearch] = useState('')
const [page, setPage] = useState(1)
const kindFilter = kind === 'All' ? undefined : kind
const { data, isLoading, isError, refetch } = useQuery({
queryKey: ['admin-payment-requests', status, page],
queryFn: () => listPaymentRequests(status === 'All' ? undefined : status, page, 20),
queryKey: ['admin-payment-requests', status, kindFilter, search, page],
queryFn: () =>
listPaymentRequests(status === 'All' ? undefined : status, kindFilter, search || undefined, page, 20),
})
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['admin-payment-requests'] })
@@ -113,24 +121,53 @@ function RequestsSection() {
return (
<div className="flex flex-col gap-4">
<Select
value={status}
onValueChange={(v) => {
setStatus(v as PaymentRequestStatus | 'All')
setPage(1)
}}
>
<SelectTrigger className="w-56">
<SelectValue />
</SelectTrigger>
<SelectContent>
{STATUS_FILTERS.map((s) => (
<SelectItem key={s} value={s}>
{s === 'All' ? t('admin.billing.allStatuses') : t(`admin.billing.status.${s}`)}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="flex flex-wrap gap-2">
<Input
placeholder={t('admin.billing.searchPlaceholder')}
value={search}
onChange={(e) => {
setSearch(e.target.value)
setPage(1)
}}
className="max-w-xs"
/>
<Select
value={status}
onValueChange={(v) => {
setStatus(v as PaymentRequestStatus | 'All')
setPage(1)
}}
>
<SelectTrigger className="w-56">
<SelectValue />
</SelectTrigger>
<SelectContent>
{STATUS_FILTERS.map((s) => (
<SelectItem key={s} value={s}>
{s === 'All' ? t('admin.billing.allStatuses') : t(`admin.billing.status.${s}`)}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={kind}
onValueChange={(v) => {
setKind(v as PaymentRequestKind | 'All')
setPage(1)
}}
>
<SelectTrigger className="w-56">
<SelectValue />
</SelectTrigger>
<SelectContent>
{KIND_FILTERS.map((k) => (
<SelectItem key={k} value={k}>
{k === 'All' ? t('admin.billing.allKinds') : k === 'RoleChangeTopUp' ? t('billing.roleChangeTopUp') : t('billing.subscription')}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{isLoading && <p className="text-sm text-muted-foreground"></p>}
+50 -3
View File
@@ -11,7 +11,10 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
import { formatBytes } from '@/shared/lib/format'
import { listAllConfigs } from '@/features/admin/configs/api'
import { forceRevokeConfig } from '@/features/admin/users/api'
import type { ConfigStatus } from '@/shared/api/types'
import { listNodes } from '@/features/admin/nodes/api'
import type { ConfigStatus, VpnProtocol } from '@/shared/api/types'
const PROTOCOLS: VpnProtocol[] = ['Vless', 'Vmess', 'Trojan', 'Shadowsocks']
export const Route = createFileRoute('/admin/configs')({ component: AdminConfigsPage })
@@ -30,13 +33,19 @@ function AdminConfigsPage() {
const queryClient = useQueryClient()
const [search, setSearch] = useState('')
const [status, setStatus] = useState<ConfigStatus | 'all'>('all')
const [protocol, setProtocol] = useState<VpnProtocol | 'all'>('all')
const [nodeId, setNodeId] = useState<string>('all')
const [page, setPage] = useState(1)
const statusFilter = status === 'all' ? undefined : status
const protocolFilter = protocol === 'all' ? undefined : protocol
const nodeFilter = nodeId === 'all' ? undefined : nodeId
const nodesQuery = useQuery({ queryKey: ['admin-nodes-lite'], queryFn: listNodes })
const { data, isLoading, isError, refetch } = useQuery({
queryKey: ['admin-configs', page, search, statusFilter],
queryFn: () => listAllConfigs(page, PAGE_SIZE, search || undefined, statusFilter),
queryKey: ['admin-configs', page, search, statusFilter, protocolFilter, nodeFilter],
queryFn: () => listAllConfigs(page, PAGE_SIZE, search || undefined, statusFilter, protocolFilter, nodeFilter),
})
const revokeMutation = useMutation({
@@ -79,6 +88,44 @@ function AdminConfigsPage() {
))}
</SelectContent>
</Select>
<Select
value={protocol}
onValueChange={(value) => {
setProtocol(value as VpnProtocol | 'all')
setPage(1)
}}
>
<SelectTrigger className="w-40">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t('admin.configs.allProtocols')}</SelectItem>
{PROTOCOLS.map((p) => (
<SelectItem key={p} value={p}>
{p}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={nodeId}
onValueChange={(value) => {
setNodeId(value)
setPage(1)
}}
>
<SelectTrigger className="w-48">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t('admin.configs.allNodes')}</SelectItem>
{nodesQuery.data?.map((n) => (
<SelectItem key={n.id} value={n.id}>
{n.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{isLoading && <p className="text-sm text-muted-foreground"></p>}
+53 -2
View File
@@ -4,9 +4,14 @@ import { useQuery } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { Button } from '@/shared/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/shared/ui/card'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
import { TicketStatusBadge } from '@/features/support/TicketStatusBadge'
import { AdminTicketDetailDialog } from '@/features/admin/support/AdminTicketDetailDialog'
import { listAllTickets } from '@/features/admin/support/api'
import type { TicketStatus, TicketType } from '@/shared/api/types'
const TYPES: TicketType[] = ['BugReport', 'RoleRequest', 'ExtensionRequest']
const STATUSES: TicketStatus[] = ['Open', 'Resolved', 'Closed']
export const Route = createFileRoute('/admin/support')({
component: AdminSupportPage,
@@ -24,14 +29,60 @@ function AdminSupportPage() {
const navigate = useNavigate({ from: Route.fullPath })
const { ticket: selectedTicketId } = Route.useSearch()
const [page, setPage] = useState(1)
const [type, setType] = useState<TicketType | 'all'>('all')
const [status, setStatus] = useState<TicketStatus | 'all'>('all')
const typeFilter = type === 'all' ? undefined : type
const statusFilter = status === 'all' ? undefined : status
const { data, isLoading, isError, refetch } = useQuery({
queryKey: ['admin-tickets', page],
queryFn: () => listAllTickets(undefined, undefined, page, PAGE_SIZE),
queryKey: ['admin-tickets', page, typeFilter, statusFilter],
queryFn: () => listAllTickets(typeFilter, statusFilter, page, PAGE_SIZE),
})
return (
<div className="flex flex-col gap-4">
<div className="flex flex-wrap gap-2">
<Select
value={type}
onValueChange={(v) => {
setType(v as TicketType | 'all')
setPage(1)
}}
>
<SelectTrigger className="w-56">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t('admin.support.allTypes')}</SelectItem>
{TYPES.map((ty) => (
<SelectItem key={ty} value={ty}>
{t(`support.type.${ty}`)}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={status}
onValueChange={(v) => {
setStatus(v as TicketStatus | 'all')
setPage(1)
}}
>
<SelectTrigger className="w-48">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t('admin.support.allStatuses')}</SelectItem>
{STATUSES.map((st) => (
<SelectItem key={st} value={st}>
{t(`support.status.${st}`)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{isLoading && <p className="text-sm text-muted-foreground"></p>}
{isError && (
+80 -11
View File
@@ -5,17 +5,25 @@ 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, не сам объект — иначе диалог держит "замороженный" снимок пользователя и не видит
// изменения, сделанные им же самим (гифт/блок/смена роли инвалидируют этот запрос, но проп
@@ -23,23 +31,84 @@ function AdminUsersPage() {
// запроса на каждый рендер.
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],
queryFn: () => listUsers(page, PAGE_SIZE, search || undefined),
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">
<Input
placeholder={t('admin.users.searchPlaceholder')}
value={search}
onChange={(e) => {
setSearch(e.target.value)
setPage(1)
}}
className="max-w-sm"
/>
<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>}