Refactor VPN configuration handling to remove device limit management
CI / Backend (build + test) (push) Successful in 1m24s
CI / Frontend (lint + typecheck + build) (push) Successful in 30s

- Updated the VPN configuration commands and handlers to eliminate the device limit parameter, simplifying the configuration process.
- Adjusted related API documentation to reflect the removal of device limit management, clarifying that this setting is now handled directly in the 3x-ui by node administrators.
- Enhanced the overall codebase by removing unnecessary device limit references across various components, ensuring a cleaner and more maintainable code structure.
This commit is contained in:
Leonid Pershin
2026-07-02 23:21:26 +03:00
parent 05d49a8cbd
commit bea2b5fcf7
43 changed files with 933 additions and 157 deletions
+50 -26
View File
@@ -1,7 +1,9 @@
import { useEffect, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { Download } from 'lucide-react'
import { Card, CardHeader, CardTitle } from '@/shared/ui/card'
import { cn } from '@/shared/lib/cn'
import type { OsPlatform } from '@/shared/api/types'
import { listApps } from './api'
@@ -10,38 +12,60 @@ const OS_ORDER: OsPlatform[] = ['IOS', 'Android', 'Windows', 'MacOS', 'Linux']
export function AppsCatalog() {
const { t } = useTranslation()
const { data, isLoading } = useQuery({ queryKey: ['client-apps'], queryFn: listApps })
const [activeOs, setActiveOs] = useState<OsPlatform | null>(null)
const availableOs = data ? OS_ORDER.filter((os) => data[os] && data[os]!.length > 0) : []
useEffect(() => {
if (availableOs.length > 0 && (!activeOs || !availableOs.includes(activeOs))) {
setActiveOs(availableOs[0])
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [data])
if (isLoading) return null
if (!data || Object.keys(data).length === 0) {
if (!data || availableOs.length === 0) {
return <p className="text-sm text-muted-foreground">{t('instructions.noApps')}</p>
}
const apps = activeOs ? data[activeOs] ?? [] : []
return (
<div className="flex flex-col gap-6">
{OS_ORDER.filter((os) => data[os] && data[os]!.length > 0).map((os) => (
<div key={os} className="flex flex-col gap-3">
<h3 className="text-sm font-semibold text-muted-foreground">{t(`instructions.os.${os}`)}</h3>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{data[os]!.map((app) => (
<a key={app.id} href={app.downloadUrl} target="_blank" rel="noreferrer">
<Card className="transition-colors hover:bg-muted">
<CardHeader className="flex-row items-center gap-3 space-y-0">
{app.iconUrl ? (
<img src={app.iconUrl} alt="" className="h-8 w-8 rounded" />
) : (
<Download className="h-6 w-6 text-muted-foreground" />
)}
<div>
<CardTitle className="text-sm">{app.name}</CardTitle>
{app.description && <p className="text-xs text-muted-foreground">{app.description}</p>}
</div>
</CardHeader>
</Card>
</a>
))}
</div>
</div>
))}
<div className="flex flex-col gap-4">
<nav className="flex gap-1 overflow-x-auto border-b border-border">
{availableOs.map((os) => (
<button
key={os}
type="button"
onClick={() => setActiveOs(os)}
className={cn(
'shrink-0 whitespace-nowrap px-3 py-2 text-sm text-muted-foreground hover:text-foreground',
os === activeOs && 'border-b-2 border-primary font-medium text-foreground',
)}
>
{t(`instructions.os.${os}`)}
</button>
))}
</nav>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{apps.map((app) => (
<a key={app.id} href={app.downloadUrl} target="_blank" rel="noreferrer">
<Card className="transition-colors hover:bg-muted">
<CardHeader className="flex-row items-center gap-3 space-y-0">
{app.iconUrl ? (
<img src={app.iconUrl} alt="" className="h-8 w-8 rounded" />
) : (
<Download className="h-6 w-6 text-muted-foreground" />
)}
<div>
<CardTitle className="text-sm">{app.name}</CardTitle>
{app.description && <p className="text-xs text-muted-foreground">{app.description}</p>}
</div>
</CardHeader>
</Card>
</a>
))}
</div>
</div>
)
}
+2 -5
View File
@@ -77,11 +77,8 @@ export function ConfigCard({ config }: { config: VpnConfigDto }) {
</div>
</CardHeader>
<CardContent className="flex flex-col gap-3">
<div className="flex justify-between text-sm text-muted-foreground">
<span>
{formatBytes(config.usedUpBytes)} {formatBytes(config.usedDownBytes)}
</span>
<span>{config.deviceLimit > 0 ? t('configs.deviceLimit', { count: config.deviceLimit }) : t('configs.deviceLimitUnlimited')}</span>
<div className="text-sm text-muted-foreground">
{formatBytes(config.usedUpBytes)} {formatBytes(config.usedDownBytes)}
</div>
<div className="flex flex-wrap gap-2">
<Button size="sm" variant="outline" onClick={() => setDetailsOpen(true)}>
@@ -18,17 +18,15 @@ export function CreateConfigDialog({ inbounds }: { inbounds: AvailableInboundDto
const [open, setOpen] = useState(false)
const [inboundId, setInboundId] = useState('')
const [label, setLabel] = useState('')
const [deviceLimit, setDeviceLimit] = useState('')
const createMutation = useMutation({
mutationFn: () => createConfig(inboundId, label.trim() || undefined, deviceLimit ? Number(deviceLimit) : undefined),
mutationFn: () => createConfig(inboundId, label.trim() || undefined),
onSuccess: async () => {
toast.success(t('configs.created'))
await queryClient.invalidateQueries({ queryKey: ['my-configs'] })
setOpen(false)
setInboundId('')
setLabel('')
setDeviceLimit('')
},
onError: (error) => {
const message =
@@ -75,17 +73,6 @@ export function CreateConfigDialog({ inbounds }: { inbounds: AvailableInboundDto
<Label htmlFor="label">{t('configs.label')}</Label>
<Input id="label" value={label} onChange={(e) => setLabel(e.target.value)} />
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="deviceLimit">{t('configs.deviceLimitLabel')}</Label>
<Input
id="deviceLimit"
type="number"
min={0}
placeholder={t('configs.deviceLimitPlaceholder')}
value={deviceLimit}
onChange={(e) => setDeviceLimit(e.target.value)}
/>
</div>
<Button type="submit" disabled={!inboundId || createMutation.isPending}>
{t('configs.create')}
</Button>
+4 -4
View File
@@ -15,17 +15,17 @@ export function getMyConfigs() {
return apiRequest<GetMyConfigsResult>('/configs')
}
export function createConfig(inboundId: string, label: string | undefined, deviceLimit: number | undefined) {
export function createConfig(inboundId: string, label: string | undefined) {
return apiRequest<VpnConfigDto>('/configs', {
method: 'POST',
body: { inboundId, label: label ?? null, deviceLimit: deviceLimit ?? null },
body: { inboundId, label: label ?? null },
})
}
export function editConfig(id: string, label: string | undefined, deviceLimit: number | undefined) {
export function editConfig(id: string, label: string | undefined) {
return apiRequest<VpnConfigDto>(`/configs/${id}`, {
method: 'PATCH',
body: { label: label ?? null, deviceLimit: deviceLimit ?? null },
body: { label: label ?? null },
})
}
@@ -35,11 +35,11 @@ export function TelegramLinkCard() {
})
useEffect(() => {
if (!meQuery.data?.telegramLinked) return
if (!open || !meQuery.data?.telegramLinked) return
setUser(meQuery.data)
setOpen(false)
toast.success(t('settings.telegramLinked'))
}, [meQuery.data, setUser, t])
}, [open, meQuery.data, setUser, t])
const unlinkMutation = useMutation({
mutationFn: unlinkTelegram,
@@ -91,6 +91,7 @@ export function TelegramLinkCard() {
<a href={deepLink} target="_blank" rel="noreferrer" className="text-sm text-primary hover:underline">
{deepLink}
</a>
<p className="text-center text-sm text-muted-foreground">{t('auth.telegramQrHint')}</p>
</>
) : (
<p className="text-sm text-muted-foreground">{t('auth.telegramBotNotConfigured')}</p>
@@ -72,6 +72,7 @@ export function TelegramLoginButton() {
<a href={deepLink} target="_blank" rel="noreferrer" className="text-sm text-primary hover:underline">
{deepLink}
</a>
<p className="text-center text-sm text-muted-foreground">{t('auth.telegramQrHint')}</p>
</>
)}
{!deepLink && <p className="text-sm text-muted-foreground">{t('auth.telegramBotNotConfigured')}</p>}