Implement rate limiting and enhance authentication flow
CI / Backend (build + test) (push) Successful in 1m17s
CI / Frontend (lint + typecheck + build) (push) Successful in 35s

- 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:
Leonid Pershin
2026-07-02 12:40:23 +03:00
parent ed07221ca5
commit 8067be3c35
106 changed files with 8823 additions and 172 deletions
@@ -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>
)
}
+40
View File
@@ -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' })
}
+6
View File
@@ -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>
)
}
+36
View File
@@ -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>
)
}
+18
View File
@@ -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' })
}
+6
View File
@@ -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>
)
}
+32
View File
@@ -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 } })
}