Implement rate limiting and enhance authentication flow
- Added rate limiting configuration for authentication endpoints, allowing customizable request limits via environment variables. - Updated authentication flow to utilize HttpRequest for cookie management, ensuring secure handling of refresh tokens. - Introduced a new endpoint to retrieve user subscription details. - Enhanced the handling of Telegram bot token validation to prevent errors with empty tokens. - Updated the application to serialize enums as strings for better documentation and compatibility with TypeScript. - Improved test coverage for new features and adjustments in command handlers.
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
import { apiRequest } from '@/shared/api/client'
|
||||
import type { ActivationRequestAdminDto, ActivationStatus, PagedList } from '@/shared/api/types'
|
||||
|
||||
export function listActivationRequests(statusFilter: ActivationStatus | undefined, page: number, pageSize: number) {
|
||||
const params = new URLSearchParams({ page: String(page), pageSize: String(pageSize) })
|
||||
if (statusFilter) params.set('statusFilter', statusFilter)
|
||||
return apiRequest<PagedList<ActivationRequestAdminDto>>(`/admin/activation-requests?${params.toString()}`)
|
||||
}
|
||||
|
||||
export function approveActivationRequest(id: string) {
|
||||
return apiRequest<void>(`/admin/activation-requests/${id}/approve`, { method: 'POST' })
|
||||
}
|
||||
|
||||
export function rejectActivationRequest(id: string, reason: string | undefined) {
|
||||
return apiRequest<void>(`/admin/activation-requests/${id}/reject`, { method: 'POST', body: { reason: reason ?? null } })
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { useState } from 'react'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from '@/shared/ui/toast-store'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/shared/ui/dialog'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { Input } from '@/shared/ui/input'
|
||||
import { Label } from '@/shared/ui/label'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
|
||||
import { HttpError } from '@/shared/api/client'
|
||||
import type { AdminAppDto, OsPlatform } from '@/shared/api/types'
|
||||
import { createApp, updateApp } from './api'
|
||||
|
||||
const OS_OPTIONS: OsPlatform[] = ['IOS', 'Android', 'Windows', 'MacOS', 'Linux']
|
||||
|
||||
export function AppFormDialog({ app, open, onOpenChange }: { app?: AdminAppDto; open?: boolean; onOpenChange?: (open: boolean) => void }) {
|
||||
const { t } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
const [internalOpen, setInternalOpen] = useState(false)
|
||||
const [name, setName] = useState(app?.name ?? '')
|
||||
const [downloadUrl, setDownloadUrl] = useState(app?.downloadUrl ?? '')
|
||||
const [operatingSystem, setOperatingSystem] = useState<OsPlatform>(app?.operatingSystem ?? 'IOS')
|
||||
const [description, setDescription] = useState(app?.description ?? '')
|
||||
const [iconUrl, setIconUrl] = useState(app?.iconUrl ?? '')
|
||||
const [sortOrder, setSortOrder] = useState(String(app?.sortOrder ?? 0))
|
||||
const [isEnabled, setIsEnabled] = useState(app?.isEnabled ?? true)
|
||||
|
||||
const isControlled = open !== undefined
|
||||
const dialogOpen = isControlled ? open : internalOpen
|
||||
const setDialogOpen = isControlled ? onOpenChange! : setInternalOpen
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: () =>
|
||||
app
|
||||
? updateApp(app.id, name.trim(), downloadUrl.trim(), operatingSystem, description.trim() || undefined, iconUrl.trim() || undefined, Number(sortOrder), isEnabled)
|
||||
: createApp(name.trim(), downloadUrl.trim(), operatingSystem, description.trim() || undefined, iconUrl.trim() || undefined, Number(sortOrder)),
|
||||
onSuccess: async () => {
|
||||
toast.success(app ? t('admin.apps.updated') : t('admin.apps.created'))
|
||||
await queryClient.invalidateQueries({ queryKey: ['admin-apps'] })
|
||||
setDialogOpen(false)
|
||||
},
|
||||
onError: (error) => toast.error(error instanceof HttpError ? error.detail : t('auth.genericError')),
|
||||
})
|
||||
|
||||
const canSubmit = name.trim() && downloadUrl.trim()
|
||||
|
||||
return (
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
{!isControlled && (
|
||||
<DialogTrigger asChild>
|
||||
<Button size="sm">{t('admin.apps.create')}</Button>
|
||||
</DialogTrigger>
|
||||
)}
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{app ? app.name : t('admin.apps.create')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form
|
||||
className="flex flex-col gap-4"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
if (canSubmit) mutation.mutate()
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="appName">{t('admin.apps.name')}</Label>
|
||||
<Input id="appName" value={name} onChange={(e) => setName(e.target.value)} required />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="downloadUrl">{t('admin.apps.downloadUrl')}</Label>
|
||||
<Input id="downloadUrl" value={downloadUrl} onChange={(e) => setDownloadUrl(e.target.value)} required />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.apps.os')}</Label>
|
||||
<Select value={operatingSystem} onValueChange={(v) => setOperatingSystem(v as OsPlatform)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{OS_OPTIONS.map((os) => (
|
||||
<SelectItem key={os} value={os}>
|
||||
{t(`instructions.os.${os}`)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="appDescription">{t('admin.apps.description')}</Label>
|
||||
<Input id="appDescription" value={description} onChange={(e) => setDescription(e.target.value)} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="iconUrl">{t('admin.apps.iconUrl')}</Label>
|
||||
<Input id="iconUrl" value={iconUrl} onChange={(e) => setIconUrl(e.target.value)} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="sortOrder">{t('admin.apps.sortOrder')}</Label>
|
||||
<Input id="sortOrder" type="number" min={0} value={sortOrder} onChange={(e) => setSortOrder(e.target.value)} />
|
||||
</div>
|
||||
{app && (
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" checked={isEnabled} onChange={(e) => setIsEnabled(e.target.checked)} />
|
||||
{t('admin.apps.enabled')}
|
||||
</label>
|
||||
)}
|
||||
<Button type="submit" disabled={!canSubmit || mutation.isPending}>
|
||||
{app ? t('admin.roles.save') : t('admin.apps.create')}
|
||||
</Button>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { apiRequest } from '@/shared/api/client'
|
||||
import type { AdminAppDto, OsPlatform } from '@/shared/api/types'
|
||||
|
||||
export function listAdminApps() {
|
||||
return apiRequest<AdminAppDto[]>('/admin/apps')
|
||||
}
|
||||
|
||||
export function createApp(
|
||||
name: string,
|
||||
downloadUrl: string,
|
||||
operatingSystem: OsPlatform,
|
||||
description: string | undefined,
|
||||
iconUrl: string | undefined,
|
||||
sortOrder: number,
|
||||
) {
|
||||
return apiRequest<AdminAppDto>('/admin/apps', {
|
||||
method: 'POST',
|
||||
body: { name, downloadUrl, operatingSystem, description: description ?? null, iconUrl: iconUrl ?? null, sortOrder },
|
||||
})
|
||||
}
|
||||
|
||||
export function updateApp(
|
||||
id: string,
|
||||
name: string,
|
||||
downloadUrl: string,
|
||||
operatingSystem: OsPlatform,
|
||||
description: string | undefined,
|
||||
iconUrl: string | undefined,
|
||||
sortOrder: number,
|
||||
isEnabled: boolean,
|
||||
) {
|
||||
return apiRequest<AdminAppDto>(`/admin/apps/${id}`, {
|
||||
method: 'PUT',
|
||||
body: { name, downloadUrl, operatingSystem, description: description ?? null, iconUrl: iconUrl ?? null, sortOrder, isEnabled },
|
||||
})
|
||||
}
|
||||
|
||||
export function deleteApp(id: string) {
|
||||
return apiRequest<void>(`/admin/apps/${id}`, { method: 'DELETE' })
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { apiRequest } from '@/shared/api/client'
|
||||
import type { AuditLogDto, PagedList } from '@/shared/api/types'
|
||||
|
||||
export function listAuditLogs(page: number, pageSize: number) {
|
||||
return apiRequest<PagedList<AuditLogDto>>(`/admin/audit?page=${page}&pageSize=${pageSize}`)
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { useState } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from '@/shared/ui/toast-store'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/shared/ui/dialog'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { Input } from '@/shared/ui/input'
|
||||
import { Label } from '@/shared/ui/label'
|
||||
import { HttpError } from '@/shared/api/client'
|
||||
import { listRoles } from '@/features/admin/roles/api'
|
||||
import type { InboundDto } from '@/shared/api/types'
|
||||
import { publishInbound } from './api'
|
||||
|
||||
export function PublishInboundDialog({
|
||||
inbound,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
inbound: InboundDto
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
const [isPublished, setIsPublished] = useState(inbound.isPublished)
|
||||
const [displayName, setDisplayName] = useState(inbound.displayName ?? inbound.remark)
|
||||
const [maxClients, setMaxClients] = useState(inbound.maxClients?.toString() ?? '')
|
||||
const [selectedRoles, setSelectedRoles] = useState<Set<string>>(new Set(inbound.allowedRoleIds))
|
||||
|
||||
const rolesQuery = useQuery({ queryKey: ['admin-roles-page'], queryFn: listRoles, enabled: open })
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: () =>
|
||||
publishInbound(inbound.id, isPublished, displayName.trim() || undefined, Array.from(selectedRoles), maxClients ? Number(maxClients) : undefined),
|
||||
onSuccess: async () => {
|
||||
toast.success(t('admin.nodes.publishSaved'))
|
||||
await queryClient.invalidateQueries({ queryKey: ['admin-inbounds', inbound.nodeId] })
|
||||
onOpenChange(false)
|
||||
},
|
||||
onError: (error) => toast.error(error instanceof HttpError ? error.detail : t('auth.genericError')),
|
||||
})
|
||||
|
||||
const toggleRole = (roleId: string) => {
|
||||
setSelectedRoles((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(roleId)) next.delete(roleId)
|
||||
else next.add(roleId)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{inbound.remark} · {inbound.protocol}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form
|
||||
className="flex flex-col gap-4"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
mutation.mutate()
|
||||
}}
|
||||
>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" checked={isPublished} onChange={(e) => setIsPublished(e.target.checked)} />
|
||||
{t('admin.nodes.isPublishedLabel')}
|
||||
</label>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="displayName">{t('admin.nodes.displayName')}</Label>
|
||||
<Input id="displayName" value={displayName} onChange={(e) => setDisplayName(e.target.value)} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="maxClients">{t('admin.nodes.maxClients')}</Label>
|
||||
<Input id="maxClients" type="number" min={0} value={maxClients} onChange={(e) => setMaxClients(e.target.value)} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.nodes.allowedRoles')}</Label>
|
||||
<div className="flex flex-col gap-1">
|
||||
{rolesQuery.data?.map((role) => (
|
||||
<label key={role.id} className="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" checked={selectedRoles.has(role.id)} onChange={() => toggleRole(role.id)} />
|
||||
{role.name}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<Button type="submit" disabled={mutation.isPending}>
|
||||
{t('admin.roles.save')}
|
||||
</Button>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { apiRequest } from '@/shared/api/client'
|
||||
import type { InboundDto } from '@/shared/api/types'
|
||||
|
||||
export function listInbounds(nodeId: string) {
|
||||
return apiRequest<InboundDto[]>(`/admin/inbounds?nodeId=${nodeId}`)
|
||||
}
|
||||
|
||||
export function publishInbound(
|
||||
id: string,
|
||||
isPublished: boolean,
|
||||
displayName: string | undefined,
|
||||
allowedRoleIds: string[],
|
||||
maxClients: number | undefined,
|
||||
) {
|
||||
return apiRequest<InboundDto>(`/admin/inbounds/${id}/publish`, {
|
||||
method: 'PUT',
|
||||
body: { isPublished, displayName: displayName ?? null, allowedRoleIds, maxClients: maxClients ?? null },
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { useState } from 'react'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from '@/shared/ui/toast-store'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/shared/ui/dialog'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { Input } from '@/shared/ui/input'
|
||||
import { Label } from '@/shared/ui/label'
|
||||
import { HttpError } from '@/shared/api/client'
|
||||
import type { NodeDto } from '@/shared/api/types'
|
||||
import { updateNode } from './api'
|
||||
|
||||
export function EditNodeDialog({ node, open, onOpenChange }: { node: NodeDto; open: boolean; onOpenChange: (open: boolean) => void }) {
|
||||
const { t } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
const [name, setName] = useState(node.name)
|
||||
const [location, setLocation] = useState(node.location ?? '')
|
||||
const [isEnabled, setIsEnabled] = useState(node.isEnabled)
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: () => updateNode(node.id, name.trim(), location.trim() || undefined, isEnabled, username.trim() || undefined, password || undefined),
|
||||
onSuccess: async () => {
|
||||
toast.success(t('admin.nodes.updated'))
|
||||
await queryClient.invalidateQueries({ queryKey: ['admin-nodes'] })
|
||||
onOpenChange(false)
|
||||
},
|
||||
onError: (error) => toast.error(error instanceof HttpError ? error.detail : t('auth.genericError')),
|
||||
})
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{node.name}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form
|
||||
className="flex flex-col gap-4"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
mutation.mutate()
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="editName">{t('admin.nodes.name')}</Label>
|
||||
<Input id="editName" value={name} onChange={(e) => setName(e.target.value)} required />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="editLocation">{t('admin.nodes.location')}</Label>
|
||||
<Input id="editLocation" value={location} onChange={(e) => setLocation(e.target.value)} />
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" checked={isEnabled} onChange={(e) => setIsEnabled(e.target.checked)} />
|
||||
{t('admin.nodes.enabled')}
|
||||
</label>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="editUsername">
|
||||
{t('admin.nodes.username')} ({t('admin.nodes.optional')})
|
||||
</Label>
|
||||
<Input id="editUsername" value={username} onChange={(e) => setUsername(e.target.value)} placeholder={t('admin.nodes.username')} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="editPassword">
|
||||
{t('admin.nodes.password')} ({t('admin.nodes.optional')})
|
||||
</Label>
|
||||
<Input id="editPassword" type="password" value={password} onChange={(e) => setPassword(e.target.value)} />
|
||||
</div>
|
||||
<Button type="submit" disabled={!name.trim() || mutation.isPending}>
|
||||
{t('admin.roles.save')}
|
||||
</Button>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import { useState } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ChevronDown, ChevronUp } from 'lucide-react'
|
||||
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 { HttpError } from '@/shared/api/client'
|
||||
import { 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'
|
||||
import { EditNodeDialog } from './EditNodeDialog'
|
||||
|
||||
const STATUS_VARIANT: Record<NodeStatus, 'success' | 'warning' | 'destructive'> = {
|
||||
Online: 'success',
|
||||
Unknown: 'warning',
|
||||
Offline: 'destructive',
|
||||
}
|
||||
|
||||
export function NodeCard({ node }: { node: NodeDto }) {
|
||||
const { t } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [publishing, setPublishing] = useState<InboundDto | null>(null)
|
||||
|
||||
const inboundsQuery = useQuery({
|
||||
queryKey: ['admin-inbounds', node.id],
|
||||
queryFn: () => listInbounds(node.id),
|
||||
enabled: expanded,
|
||||
})
|
||||
|
||||
const invalidateNodes = () => queryClient.invalidateQueries({ queryKey: ['admin-nodes'] })
|
||||
|
||||
const probeMutation = useMutation({
|
||||
mutationFn: () => probeNode(node.id),
|
||||
onSuccess: async (result) => {
|
||||
toast[result.isReachable ? 'success' : 'error'](
|
||||
result.isReachable ? t('admin.nodes.probeSuccess') : t('admin.nodes.probeFailure', { message: result.errorMessage ?? '' }),
|
||||
)
|
||||
await invalidateNodes()
|
||||
},
|
||||
onError: (error) => toast.error(error instanceof HttpError ? error.detail : t('auth.genericError')),
|
||||
})
|
||||
|
||||
const syncMutation = useMutation({
|
||||
mutationFn: () => syncNode(node.id),
|
||||
onSuccess: async (result) => {
|
||||
toast.success(t('admin.nodes.syncSuccess', { count: result.inboundsSynced }))
|
||||
await invalidateNodes()
|
||||
await queryClient.invalidateQueries({ queryKey: ['admin-inbounds', node.id] })
|
||||
},
|
||||
onError: (error) => toast.error(error instanceof HttpError ? error.detail : t('auth.genericError')),
|
||||
})
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: () => deleteNode(node.id),
|
||||
onSuccess: async () => {
|
||||
toast.success(t('admin.nodes.deleted'))
|
||||
await invalidateNodes()
|
||||
},
|
||||
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">
|
||||
<div>
|
||||
<CardTitle className="text-base">{node.name}</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{node.baseAddress} {node.location && `· ${node.location}`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant={STATUS_VARIANT[node.status]}>{t(`admin.nodes.status.${node.status}`)}</Badge>
|
||||
<Badge variant="outline">{node.isEnabled ? t('admin.nodes.enabled') : t('admin.nodes.disabled')}</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-3">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button size="sm" variant="outline" disabled={probeMutation.isPending} onClick={() => probeMutation.mutate()}>
|
||||
{t('admin.nodes.probe')}
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" disabled={syncMutation.isPending} onClick={() => syncMutation.mutate()}>
|
||||
{t('admin.nodes.sync')}
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => setEditing(true)}>
|
||||
{t('admin.nodes.edit')}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={deleteMutation.isPending}
|
||||
onClick={() => {
|
||||
if (confirm(t('admin.nodes.confirmDelete'))) deleteMutation.mutate()
|
||||
}}
|
||||
>
|
||||
{t('admin.nodes.delete')}
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" className="ml-auto" onClick={() => setExpanded((v) => !v)}>
|
||||
{t('admin.nodes.inbounds')}
|
||||
{expanded ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{expanded && (
|
||||
<div className="flex flex-col gap-2 border-t border-border pt-3">
|
||||
{inboundsQuery.data?.length === 0 && <p className="text-sm text-muted-foreground">{t('admin.nodes.noInbounds')}</p>}
|
||||
{inboundsQuery.data?.map((inbound) => (
|
||||
<div key={inbound.id} className="flex items-center justify-between rounded-md border border-border px-3 py-2 text-sm">
|
||||
<span>
|
||||
{inbound.remark} · {inbound.protocol} · :{inbound.port}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant={inbound.isPublished ? 'success' : 'outline'}>
|
||||
{inbound.isPublished ? t('admin.nodes.published') : t('admin.nodes.unpublished')}
|
||||
</Badge>
|
||||
<Button size="sm" variant="outline" onClick={() => setPublishing(inbound)}>
|
||||
{t('admin.nodes.publish')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
{editing && <EditNodeDialog node={node} open={editing} onOpenChange={setEditing} />}
|
||||
{publishing && <PublishInboundDialog inbound={publishing} open={!!publishing} onOpenChange={(open) => !open && setPublishing(null)} />}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { useState } from 'react'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from '@/shared/ui/toast-store'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/shared/ui/dialog'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { Input } from '@/shared/ui/input'
|
||||
import { Label } from '@/shared/ui/label'
|
||||
import { HttpError } from '@/shared/api/client'
|
||||
import { registerNode } from './api'
|
||||
|
||||
export function RegisterNodeDialog() {
|
||||
const { t } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [name, setName] = useState('')
|
||||
const [baseAddress, setBaseAddress] = useState('')
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [location, setLocation] = useState('')
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: () => registerNode(name.trim(), baseAddress.trim(), username.trim(), password, location.trim() || undefined),
|
||||
onSuccess: async () => {
|
||||
toast.success(t('admin.nodes.created'))
|
||||
await queryClient.invalidateQueries({ queryKey: ['admin-nodes'] })
|
||||
setOpen(false)
|
||||
setName('')
|
||||
setBaseAddress('')
|
||||
setUsername('')
|
||||
setPassword('')
|
||||
setLocation('')
|
||||
},
|
||||
onError: (error) => toast.error(error instanceof HttpError ? error.detail : t('auth.genericError')),
|
||||
})
|
||||
|
||||
const canSubmit = name.trim() && baseAddress.trim() && username.trim() && password
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button size="sm">{t('admin.nodes.create')}</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('admin.nodes.create')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form
|
||||
className="flex flex-col gap-4"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
if (canSubmit) mutation.mutate()
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="nodeName">{t('admin.nodes.name')}</Label>
|
||||
<Input id="nodeName" value={name} onChange={(e) => setName(e.target.value)} required />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="baseAddress">{t('admin.nodes.baseAddress')}</Label>
|
||||
<Input
|
||||
id="baseAddress"
|
||||
placeholder="https://panel.example.com:2053"
|
||||
value={baseAddress}
|
||||
onChange={(e) => setBaseAddress(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="nodeUsername">{t('admin.nodes.username')}</Label>
|
||||
<Input id="nodeUsername" value={username} onChange={(e) => setUsername(e.target.value)} required />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="nodePassword">{t('admin.nodes.password')}</Label>
|
||||
<Input id="nodePassword" type="password" value={password} onChange={(e) => setPassword(e.target.value)} required />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="nodeLocation">{t('admin.nodes.location')}</Label>
|
||||
<Input id="nodeLocation" value={location} onChange={(e) => setLocation(e.target.value)} />
|
||||
</div>
|
||||
<Button type="submit" disabled={!canSubmit || mutation.isPending}>
|
||||
{t('admin.nodes.create')}
|
||||
</Button>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { apiRequest } from '@/shared/api/client'
|
||||
import type { NodeDto, NodeProbeResultDto, SyncNodeResultDto } from '@/shared/api/types'
|
||||
|
||||
export function listNodes() {
|
||||
return apiRequest<NodeDto[]>('/admin/nodes')
|
||||
}
|
||||
|
||||
export function registerNode(name: string, baseAddress: string, username: string, password: string, location: string | undefined) {
|
||||
return apiRequest<NodeDto>('/admin/nodes', { method: 'POST', body: { name, baseAddress, username, password, location: location ?? null } })
|
||||
}
|
||||
|
||||
export function updateNode(
|
||||
id: string,
|
||||
name: string,
|
||||
location: string | undefined,
|
||||
isEnabled: boolean,
|
||||
username: string | undefined,
|
||||
password: string | undefined,
|
||||
) {
|
||||
return apiRequest<NodeDto>(`/admin/nodes/${id}`, {
|
||||
method: 'PUT',
|
||||
body: { name, location: location ?? null, isEnabled, username: username ?? null, password: password ?? null },
|
||||
})
|
||||
}
|
||||
|
||||
export function deleteNode(id: string) {
|
||||
return apiRequest<void>(`/admin/nodes/${id}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
export function syncNode(id: string) {
|
||||
return apiRequest<SyncNodeResultDto>(`/admin/nodes/${id}/sync`, { method: 'POST' })
|
||||
}
|
||||
|
||||
export function probeNode(id: string) {
|
||||
return apiRequest<NodeProbeResultDto>(`/admin/nodes/${id}/probe`, { method: 'POST' })
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { useState } from 'react'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from '@/shared/ui/toast-store'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/shared/ui/dialog'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { Input } from '@/shared/ui/input'
|
||||
import { Label } from '@/shared/ui/label'
|
||||
import type { RoleDto } from '@/shared/api/types'
|
||||
import { createRole, updateRole } from './api'
|
||||
|
||||
/** Без role — диалог создания (кнопка-триггер); с role — диалог редактирования квоты (управляется извне). */
|
||||
export function RoleFormDialog({
|
||||
role,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
role?: RoleDto
|
||||
open?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
const [name, setName] = useState(role?.name ?? '')
|
||||
const [maxConfigs, setMaxConfigs] = useState(String(role?.maxConfigs ?? 3))
|
||||
const [internalOpen, setInternalOpen] = useState(false)
|
||||
|
||||
const isControlled = open !== undefined
|
||||
const dialogOpen = isControlled ? open : internalOpen
|
||||
const setDialogOpen = isControlled ? onOpenChange! : setInternalOpen
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: () => (role ? updateRole(role.id, Number(maxConfigs)) : createRole(name.trim(), Number(maxConfigs))),
|
||||
onSuccess: async () => {
|
||||
toast.success(role ? t('admin.roles.updated') : t('admin.roles.created'))
|
||||
await queryClient.invalidateQueries({ queryKey: ['admin-roles-page'] })
|
||||
setDialogOpen(false)
|
||||
setName('')
|
||||
setMaxConfigs('3')
|
||||
},
|
||||
onError: () => toast.error(t('auth.genericError')),
|
||||
})
|
||||
|
||||
return (
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
{!isControlled && (
|
||||
<DialogTrigger asChild>
|
||||
<Button size="sm">{t('admin.roles.create')}</Button>
|
||||
</DialogTrigger>
|
||||
)}
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{role ? role.name : t('admin.roles.create')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form
|
||||
className="flex flex-col gap-4"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
mutation.mutate()
|
||||
}}
|
||||
>
|
||||
{!role && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="roleName">{t('admin.roles.name')}</Label>
|
||||
<Input id="roleName" value={name} onChange={(e) => setName(e.target.value)} required />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="maxConfigs">{t('admin.roles.maxConfigs')}</Label>
|
||||
<Input id="maxConfigs" type="number" value={maxConfigs} onChange={(e) => setMaxConfigs(e.target.value)} />
|
||||
<p className="text-xs text-muted-foreground">{t('admin.roles.maxConfigsHint')}</p>
|
||||
</div>
|
||||
<Button type="submit" disabled={mutation.isPending || (!role && !name.trim())}>
|
||||
{role ? t('admin.roles.save') : t('admin.roles.create')}
|
||||
</Button>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { apiRequest } from '@/shared/api/client'
|
||||
import type { RoleDto } from '@/shared/api/types'
|
||||
|
||||
export function listRoles() {
|
||||
return apiRequest<RoleDto[]>('/admin/roles')
|
||||
}
|
||||
|
||||
export function createRole(name: string, maxConfigs: number) {
|
||||
return apiRequest<RoleDto>('/admin/roles', { method: 'POST', body: { name, maxConfigs } })
|
||||
}
|
||||
|
||||
export function updateRole(id: string, maxConfigs: number) {
|
||||
return apiRequest<RoleDto>(`/admin/roles/${id}`, { method: 'PUT', body: { maxConfigs } })
|
||||
}
|
||||
|
||||
export function deleteRole(id: string) {
|
||||
return apiRequest<void>(`/admin/roles/${id}`, { method: 'DELETE' })
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { apiRequest } from '@/shared/api/client'
|
||||
import type { StatsDto } from '@/shared/api/types'
|
||||
|
||||
export function getStats() {
|
||||
return apiRequest<StatsDto>('/admin/stats')
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import { useState } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from '@/shared/ui/toast-store'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/shared/ui/dialog'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { Input } from '@/shared/ui/input'
|
||||
import { Label } from '@/shared/ui/label'
|
||||
import { Badge } from '@/shared/ui/badge'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
|
||||
import { listRoles } from '@/features/admin/roles/api'
|
||||
import type { UserSummaryDto } from '@/shared/api/types'
|
||||
import {
|
||||
blockUser,
|
||||
changeUserRole,
|
||||
forceRevokeConfig,
|
||||
getUserConfigs,
|
||||
resetUserPassword,
|
||||
unblockUser,
|
||||
} from './api'
|
||||
|
||||
export function UserManageDialog({ user, open, onOpenChange }: { user: UserSummaryDto; open: boolean; onOpenChange: (open: boolean) => void }) {
|
||||
const { t } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
const [newPassword, setNewPassword] = useState('')
|
||||
|
||||
const rolesQuery = useQuery({ queryKey: ['admin-roles'], queryFn: listRoles, enabled: open })
|
||||
const configsQuery = useQuery({ queryKey: ['admin-user-configs', user.id], queryFn: () => getUserConfigs(user.id), enabled: open })
|
||||
|
||||
const invalidateUsers = () => queryClient.invalidateQueries({ queryKey: ['admin-users'] })
|
||||
|
||||
const blockMutation = useMutation({
|
||||
mutationFn: () => (user.isBlocked ? unblockUser(user.id) : blockUser(user.id)),
|
||||
onSuccess: async () => {
|
||||
toast.success(user.isBlocked ? t('admin.users.unblocked') : t('admin.users.blocked'))
|
||||
await invalidateUsers()
|
||||
},
|
||||
onError: () => toast.error(t('auth.genericError')),
|
||||
})
|
||||
|
||||
const roleMutation = useMutation({
|
||||
mutationFn: (roleId: string) => changeUserRole(user.id, roleId),
|
||||
onSuccess: async () => {
|
||||
toast.success(t('admin.users.roleChanged'))
|
||||
await invalidateUsers()
|
||||
},
|
||||
onError: () => toast.error(t('auth.genericError')),
|
||||
})
|
||||
|
||||
const resetPasswordMutation = useMutation({
|
||||
mutationFn: () => resetUserPassword(user.id, newPassword),
|
||||
onSuccess: () => {
|
||||
toast.success(t('admin.users.passwordReset'))
|
||||
setNewPassword('')
|
||||
},
|
||||
onError: () => toast.error(t('auth.genericError')),
|
||||
})
|
||||
|
||||
const revokeMutation = useMutation({
|
||||
mutationFn: (configId: string) => forceRevokeConfig(configId),
|
||||
onSuccess: async () => {
|
||||
toast.success(t('configs.revoked'))
|
||||
await queryClient.invalidateQueries({ queryKey: ['admin-user-configs', user.id] })
|
||||
},
|
||||
onError: () => toast.error(t('auth.genericError')),
|
||||
})
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{user.userName}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center gap-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>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={blockMutation.isPending}
|
||||
onClick={() => {
|
||||
if (!user.isBlocked && !confirm(t('admin.users.confirmBlock'))) return
|
||||
blockMutation.mutate()
|
||||
}}
|
||||
>
|
||||
{user.isBlocked ? t('admin.users.unblock') : t('admin.users.block')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.users.role')}</Label>
|
||||
<Select defaultValue="" onValueChange={(roleId) => roleMutation.mutate(roleId)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={user.role} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{rolesQuery.data?.map((role) => (
|
||||
<SelectItem key={role.id} value={role.id}>
|
||||
{role.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="newPassword">{t('admin.users.resetPassword')}</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="newPassword"
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
placeholder={t('auth.passwordHint')}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={newPassword.length < 8 || resetPasswordMutation.isPending}
|
||||
onClick={() => resetPasswordMutation.mutate()}
|
||||
>
|
||||
{t('admin.users.reset')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>{t('admin.users.configs')}</Label>
|
||||
{configsQuery.data?.length === 0 && <p className="text-sm text-muted-foreground">{t('configs.empty')}</p>}
|
||||
{configsQuery.data?.map((config) => (
|
||||
<div key={config.id} className="flex items-center justify-between rounded-md border border-border px-3 py-2 text-sm">
|
||||
<span>
|
||||
{config.label ?? config.location} · {config.protocol} · {t(`configs.status.${config.status}`)}
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={config.status === 'Revoked' || revokeMutation.isPending}
|
||||
onClick={() => {
|
||||
if (confirm(t('configs.confirmRevoke'))) revokeMutation.mutate(config.id)
|
||||
}}
|
||||
>
|
||||
{t('configs.revoke')}
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
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) {
|
||||
const params = new URLSearchParams({ page: String(page), pageSize: String(pageSize) })
|
||||
if (search) params.set('search', search)
|
||||
return apiRequest<PagedList<UserSummaryDto>>(`/admin/users?${params.toString()}`)
|
||||
}
|
||||
|
||||
export function blockUser(id: string) {
|
||||
return apiRequest<void>(`/admin/users/${id}/block`, { method: 'PATCH' })
|
||||
}
|
||||
|
||||
export function unblockUser(id: string) {
|
||||
return apiRequest<void>(`/admin/users/${id}/unblock`, { method: 'PATCH' })
|
||||
}
|
||||
|
||||
export function resetUserPassword(id: string, newPassword: string) {
|
||||
return apiRequest<void>(`/admin/users/${id}/reset-password`, { method: 'POST', body: { newPassword } })
|
||||
}
|
||||
|
||||
export function getUserConfigs(id: string) {
|
||||
return apiRequest<VpnConfigDto[]>(`/admin/users/${id}/configs`)
|
||||
}
|
||||
|
||||
export function forceRevokeConfig(id: string) {
|
||||
return apiRequest<void>(`/admin/configs/${id}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
export function changeUserRole(id: string, roleId: string) {
|
||||
return apiRequest<void>(`/admin/users/${id}/role`, { method: 'PATCH', body: { roleId } })
|
||||
}
|
||||
Reference in New Issue
Block a user