Enhance admin endpoints and queries for improved filtering and management
- 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:
@@ -1,6 +1,16 @@
|
||||
import { apiRequest } from '@/shared/api/client'
|
||||
import type { AuditLogDto, PagedList } from '@/shared/api/types'
|
||||
import type { AuditLogDto, AuditSource, PagedList } from '@/shared/api/types'
|
||||
|
||||
export function listAuditLogs(page: number, pageSize: number) {
|
||||
return apiRequest<PagedList<AuditLogDto>>(`/admin/audit?page=${page}&pageSize=${pageSize}`)
|
||||
export function listAuditLogs(
|
||||
page: number,
|
||||
pageSize: number,
|
||||
source: AuditSource | undefined,
|
||||
targetType: string | undefined,
|
||||
action: string | undefined,
|
||||
) {
|
||||
const params = new URLSearchParams({ page: String(page), pageSize: String(pageSize) })
|
||||
if (source) params.set('source', source)
|
||||
if (targetType) params.set('targetType', targetType)
|
||||
if (action) params.set('action', action)
|
||||
return apiRequest<PagedList<AuditLogDto>>(`/admin/audit?${params.toString()}`)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import type {
|
||||
AdminPaymentRequestDto,
|
||||
BillingSettingsDto,
|
||||
PagedList,
|
||||
PaymentRequestKind,
|
||||
PaymentRequestStatus,
|
||||
} from '@/shared/api/types'
|
||||
|
||||
@@ -21,9 +22,17 @@ export function updateBillingSettings(
|
||||
})
|
||||
}
|
||||
|
||||
export function listPaymentRequests(status?: PaymentRequestStatus, page = 1, pageSize = 20) {
|
||||
export function listPaymentRequests(
|
||||
status?: PaymentRequestStatus,
|
||||
kind?: PaymentRequestKind,
|
||||
search?: string,
|
||||
page = 1,
|
||||
pageSize = 20,
|
||||
) {
|
||||
const params = new URLSearchParams({ page: String(page), pageSize: String(pageSize) })
|
||||
if (status) params.set('status', status)
|
||||
if (kind) params.set('kind', kind)
|
||||
if (search) params.set('search', search)
|
||||
return apiRequest<PagedList<AdminPaymentRequestDto>>(`/admin/billing/requests?${params.toString()}`)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
import { apiRequest } from '@/shared/api/client'
|
||||
import type { AdminVpnConfigDto, ConfigStatus, PagedList } from '@/shared/api/types'
|
||||
import type { AdminVpnConfigDto, ConfigStatus, PagedList, VpnProtocol } from '@/shared/api/types'
|
||||
|
||||
export function listAllConfigs(page: number, pageSize: number, search: string | undefined, status: ConfigStatus | undefined) {
|
||||
export function listAllConfigs(
|
||||
page: number,
|
||||
pageSize: number,
|
||||
search: string | undefined,
|
||||
status: ConfigStatus | undefined,
|
||||
protocol: VpnProtocol | undefined,
|
||||
nodeId: string | undefined,
|
||||
) {
|
||||
const params = new URLSearchParams({ page: String(page), pageSize: String(pageSize) })
|
||||
if (search) params.set('search', search)
|
||||
if (status) params.set('status', status)
|
||||
if (protocol) params.set('protocol', protocol)
|
||||
if (nodeId) params.set('nodeId', nodeId)
|
||||
return apiRequest<PagedList<AdminVpnConfigDto>>(`/admin/configs?${params.toString()}`)
|
||||
}
|
||||
|
||||
@@ -16,3 +16,7 @@ export function publishInbound(
|
||||
body: { isPublished, displayName: displayName ?? null, allowedRoleIds },
|
||||
})
|
||||
}
|
||||
|
||||
export function deleteInbound(id: string) {
|
||||
return apiRequest<void>(`/admin/inbounds/${id}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/shared/ui/card'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { Badge } from '@/shared/ui/badge'
|
||||
import { HttpError } from '@/shared/api/client'
|
||||
import { listInbounds } from '@/features/admin/inbounds/api'
|
||||
import { deleteInbound, listInbounds } from '@/features/admin/inbounds/api'
|
||||
import { PublishInboundDialog } from '@/features/admin/inbounds/PublishInboundDialog'
|
||||
import type { InboundDto, NodeDto, NodeStatus } from '@/shared/api/types'
|
||||
import { deleteNode, probeNode, syncNode } from './api'
|
||||
@@ -66,6 +66,15 @@ export function NodeCard({ node }: { node: NodeDto }) {
|
||||
onError: (error) => toast.error(error instanceof HttpError ? error.detail : t('auth.genericError')),
|
||||
})
|
||||
|
||||
const deleteInboundMutation = useMutation({
|
||||
mutationFn: (inboundId: string) => deleteInbound(inboundId),
|
||||
onSuccess: async () => {
|
||||
toast.success(t('admin.nodes.inboundDeleted'))
|
||||
await queryClient.invalidateQueries({ queryKey: ['admin-inbounds', node.id] })
|
||||
},
|
||||
onError: (error) => toast.error(error instanceof HttpError ? error.detail : t('auth.genericError')),
|
||||
})
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex-row items-start justify-between gap-2 space-y-0">
|
||||
@@ -123,10 +132,21 @@ export function NodeCard({ node }: { node: NodeDto }) {
|
||||
: t('admin.nodes.unpublished')
|
||||
: t('admin.nodes.unavailable')}
|
||||
</Badge>
|
||||
{inbound.isAvailable && (
|
||||
{inbound.isAvailable ? (
|
||||
<Button size="sm" variant="outline" onClick={() => setPublishing(inbound)}>
|
||||
{t('admin.nodes.publish')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={deleteInboundMutation.isPending}
|
||||
onClick={() => {
|
||||
if (confirm(t('admin.nodes.confirmDeleteInbound'))) deleteInboundMutation.mutate(inbound.id)
|
||||
}}
|
||||
>
|
||||
{t('admin.nodes.delete')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,21 @@
|
||||
import { apiRequest } from '@/shared/api/client'
|
||||
import type { PagedList, UserSummaryDto, VpnConfigDto } from '@/shared/api/types'
|
||||
|
||||
export function listUsers(page: number, pageSize: number, search: string | undefined) {
|
||||
export function listUsers(
|
||||
page: number,
|
||||
pageSize: number,
|
||||
search: string | undefined,
|
||||
roleId: string | undefined,
|
||||
isActivated: boolean | undefined,
|
||||
isBlocked: boolean | undefined,
|
||||
billingExpired: boolean | undefined,
|
||||
) {
|
||||
const params = new URLSearchParams({ page: String(page), pageSize: String(pageSize) })
|
||||
if (search) params.set('search', search)
|
||||
if (roleId) params.set('roleId', roleId)
|
||||
if (isActivated !== undefined) params.set('isActivated', String(isActivated))
|
||||
if (isBlocked !== undefined) params.set('isBlocked', String(isBlocked))
|
||||
if (billingExpired !== undefined) params.set('billingExpired', String(billingExpired))
|
||||
return apiRequest<PagedList<UserSummaryDto>>(`/admin/users?${params.toString()}`)
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>}
|
||||
|
||||
|
||||
@@ -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>}
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>}
|
||||
|
||||
|
||||
@@ -149,6 +149,7 @@ const resources = {
|
||||
awaitingAdminHint: 'Администратор уведомлён и проверит оплату. Конфиги не отключатся, пока заявка не решена.',
|
||||
roleChangeTopUp: 'Доплата за смену роли',
|
||||
roleChangeTopUpHint: 'Новая роль дороже прежней — эта сумма покрывает разницу в цене за оставшуюся часть уже оплаченного периода, срок подписки при этом не меняется.',
|
||||
subscription: 'Подписка',
|
||||
},
|
||||
|
||||
instructions: {
|
||||
@@ -272,6 +273,11 @@ const resources = {
|
||||
manage: 'Управление',
|
||||
empty: 'Пользователи не найдены.',
|
||||
total: 'Всего: {{count}}',
|
||||
allRoles: 'Все роли',
|
||||
allStatuses: 'Все статусы',
|
||||
allBilling: 'Любая оплата',
|
||||
billingExpired: 'Просрочена',
|
||||
billingPaid: 'Оплачена',
|
||||
status: {
|
||||
blocked: 'Заблокирован',
|
||||
active: 'Активен',
|
||||
@@ -303,6 +309,8 @@ const resources = {
|
||||
traffic: 'Трафик',
|
||||
statusLabel: 'Статус',
|
||||
allStatuses: 'Все статусы',
|
||||
allProtocols: 'Все протоколы',
|
||||
allNodes: 'Все ноды',
|
||||
created: 'Создан',
|
||||
empty: 'Конфиги не найдены.',
|
||||
total: 'Всего: {{count}}',
|
||||
@@ -313,6 +321,12 @@ const resources = {
|
||||
rejected: 'Запрос отклонён.',
|
||||
approve: 'Активировать',
|
||||
reject: 'Отклонить',
|
||||
allStatuses: 'Все статусы',
|
||||
status: {
|
||||
Pending: 'Ожидает',
|
||||
Approved: 'Одобрен',
|
||||
Rejected: 'Отклонён',
|
||||
},
|
||||
},
|
||||
roles: {
|
||||
create: 'Создать роль',
|
||||
@@ -368,7 +382,9 @@ const resources = {
|
||||
defaultBillingEnabledForNewRolesLabel: 'Новые роли по умолчанию с включённым биллингом',
|
||||
settingsUpdated: 'Настройки биллинга обновлены.',
|
||||
requestsTitle: 'Заявки на оплату',
|
||||
searchPlaceholder: 'Поиск по имени пользователя',
|
||||
allStatuses: 'Все статусы',
|
||||
allKinds: 'Все виды',
|
||||
user: 'Пользователь',
|
||||
period: 'Период',
|
||||
amount: 'Сумма',
|
||||
@@ -420,6 +436,8 @@ const resources = {
|
||||
published: 'Опубликован',
|
||||
unpublished: 'Не опубликован',
|
||||
unavailable: 'Недоступен на панели',
|
||||
inboundDeleted: 'Инбаунд удалён.',
|
||||
confirmDeleteInbound: 'Удалить инбаунд? Все ещё активные конфиги на нём будут отозваны.',
|
||||
publishSaved: 'Настройки публикации сохранены.',
|
||||
displayName: 'Отображаемое имя',
|
||||
allowedRoles: 'Доступно ролям',
|
||||
@@ -478,6 +496,8 @@ const resources = {
|
||||
approved: 'Заявка одобрена, роль выдана.',
|
||||
reject: 'Отклонить',
|
||||
rejected: 'Заявка отклонена.',
|
||||
allTypes: 'Все типы',
|
||||
allStatuses: 'Все статусы',
|
||||
},
|
||||
audit: {
|
||||
time: 'Время',
|
||||
@@ -485,6 +505,9 @@ const resources = {
|
||||
target: 'Объект',
|
||||
source: 'Источник',
|
||||
empty: 'Журнал аудита пуст.',
|
||||
actionPlaceholder: 'Поиск по действию',
|
||||
allSources: 'Все источники',
|
||||
allTargetTypes: 'Все типы объектов',
|
||||
},
|
||||
maintenance: {
|
||||
closedTickets: {
|
||||
@@ -694,6 +717,7 @@ const resources = {
|
||||
awaitingAdminHint: 'The administrator has been notified and will verify the payment. Configs stay active until the request is decided.',
|
||||
roleChangeTopUp: 'Role change top-up',
|
||||
roleChangeTopUpHint: "Your new role costs more than the old one — this amount covers the price difference for the remaining part of your already-paid period; your subscription end date doesn't change.",
|
||||
subscription: 'Subscription',
|
||||
},
|
||||
|
||||
instructions: {
|
||||
@@ -817,6 +841,11 @@ const resources = {
|
||||
manage: 'Manage',
|
||||
empty: 'No users found.',
|
||||
total: 'Total: {{count}}',
|
||||
allRoles: 'All roles',
|
||||
allStatuses: 'All statuses',
|
||||
allBilling: 'Any billing',
|
||||
billingExpired: 'Expired',
|
||||
billingPaid: 'Paid',
|
||||
status: {
|
||||
blocked: 'Blocked',
|
||||
active: 'Active',
|
||||
@@ -848,6 +877,8 @@ const resources = {
|
||||
traffic: 'Traffic',
|
||||
statusLabel: 'Status',
|
||||
allStatuses: 'All statuses',
|
||||
allProtocols: 'All protocols',
|
||||
allNodes: 'All nodes',
|
||||
created: 'Created',
|
||||
empty: 'No configs found.',
|
||||
total: 'Total: {{count}}',
|
||||
@@ -858,6 +889,12 @@ const resources = {
|
||||
rejected: 'Request rejected.',
|
||||
approve: 'Approve',
|
||||
reject: 'Reject',
|
||||
allStatuses: 'All statuses',
|
||||
status: {
|
||||
Pending: 'Pending',
|
||||
Approved: 'Approved',
|
||||
Rejected: 'Rejected',
|
||||
},
|
||||
},
|
||||
roles: {
|
||||
create: 'Create role',
|
||||
@@ -913,7 +950,9 @@ const resources = {
|
||||
defaultBillingEnabledForNewRolesLabel: 'New roles default to billing enabled',
|
||||
settingsUpdated: 'Billing settings updated.',
|
||||
requestsTitle: 'Payment requests',
|
||||
searchPlaceholder: 'Search by username',
|
||||
allStatuses: 'All statuses',
|
||||
allKinds: 'All kinds',
|
||||
user: 'User',
|
||||
period: 'Period',
|
||||
amount: 'Amount',
|
||||
@@ -965,6 +1004,8 @@ const resources = {
|
||||
published: 'Published',
|
||||
unpublished: 'Not published',
|
||||
unavailable: 'Unavailable on panel',
|
||||
inboundDeleted: 'Inbound deleted.',
|
||||
confirmDeleteInbound: 'Delete this inbound? Any still-active configs on it will be revoked.',
|
||||
publishSaved: 'Publishing settings saved.',
|
||||
displayName: 'Display name',
|
||||
allowedRoles: 'Allowed for roles',
|
||||
@@ -1023,6 +1064,8 @@ const resources = {
|
||||
approved: 'Request approved, role granted.',
|
||||
reject: 'Reject',
|
||||
rejected: 'Request rejected.',
|
||||
allTypes: 'All types',
|
||||
allStatuses: 'All statuses',
|
||||
},
|
||||
audit: {
|
||||
time: 'Time',
|
||||
@@ -1030,6 +1073,9 @@ const resources = {
|
||||
target: 'Target',
|
||||
source: 'Source',
|
||||
empty: 'The audit log is empty.',
|
||||
actionPlaceholder: 'Search by action',
|
||||
allSources: 'All sources',
|
||||
allTargetTypes: 'All target types',
|
||||
},
|
||||
maintenance: {
|
||||
closedTickets: {
|
||||
|
||||
Reference in New Issue
Block a user