Refactor VPN configuration handling to remove device limit management
- 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:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>}
|
||||
|
||||
@@ -32,6 +32,14 @@
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--font-sans: system-ui, 'Segoe UI', Roboto, sans-serif;
|
||||
|
||||
/* +12.5% к дефолтной Tailwind-шкале (line-height — заданные Tailwind соотношения, масштабируются вместе с размером). */
|
||||
--text-xs: 0.844rem;
|
||||
--text-sm: 0.984rem;
|
||||
--text-base: 1.125rem;
|
||||
--text-lg: 1.266rem;
|
||||
--text-xl: 1.406rem;
|
||||
--text-2xl: 1.688rem;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
|
||||
@@ -61,7 +61,7 @@ function AdminRolesPage() {
|
||||
<td className="py-2">
|
||||
{role.name} {role.isSystem && <Badge variant="outline">{t('admin.roles.system')}</Badge>}
|
||||
</td>
|
||||
<td className="py-2">{role.maxConfigs < 0 ? t('configs.deviceLimitUnlimited') : role.maxConfigs}</td>
|
||||
<td className="py-2">{role.maxConfigs < 0 ? t('unlimited') : role.maxConfigs}</td>
|
||||
<td className="py-2 text-right">
|
||||
<Button size="sm" variant="outline" onClick={() => setEditing(role)}>
|
||||
{t('admin.roles.edit')}
|
||||
|
||||
@@ -1849,8 +1849,6 @@ export interface components {
|
||||
/** Format: uuid */
|
||||
inboundId: string;
|
||||
label: null | string;
|
||||
/** Format: int32 */
|
||||
deviceLimit: null | number | string;
|
||||
};
|
||||
CreateRoleCommand: {
|
||||
name: string;
|
||||
@@ -1867,8 +1865,6 @@ export interface components {
|
||||
};
|
||||
EditConfigBody: {
|
||||
label: null | string;
|
||||
/** Format: int32 */
|
||||
deviceLimit: null | number | string;
|
||||
};
|
||||
GetMyConfigsResult: {
|
||||
configs: components["schemas"]["VpnConfigDto"][];
|
||||
@@ -2071,8 +2067,6 @@ export interface components {
|
||||
label: null | string;
|
||||
protocol: components["schemas"]["VpnProtocol"];
|
||||
location: string;
|
||||
/** Format: int32 */
|
||||
deviceLimit: number | string;
|
||||
/** Format: int64 */
|
||||
usedUpBytes: number | string;
|
||||
/** Format: int64 */
|
||||
|
||||
@@ -51,7 +51,6 @@ export type VpnConfigDto = {
|
||||
label: string | null
|
||||
protocol: VpnProtocol
|
||||
location: string
|
||||
deviceLimit: number
|
||||
usedUpBytes: number
|
||||
usedDownBytes: number
|
||||
expiresAt: string | null
|
||||
|
||||
@@ -11,6 +11,7 @@ const resources = {
|
||||
light: 'Светлая',
|
||||
dark: 'Тёмная',
|
||||
system: 'Системная',
|
||||
unlimited: 'Без лимита',
|
||||
|
||||
auth: {
|
||||
loginTitle: 'Вход',
|
||||
@@ -31,6 +32,8 @@ const resources = {
|
||||
passwordHint: 'Не менее 8 символов, минимум одна заглавная буква и одна цифра.',
|
||||
or: 'или',
|
||||
telegramBotNotConfigured: 'Telegram-бот не настроен администратором.',
|
||||
telegramQrHint:
|
||||
'Перейдите по ссылке или отсканируйте QR-код — откроется бот в Telegram. Если открываете его впервые, нажмите «Старт».',
|
||||
waitingForConfirmation: 'Ожидание подтверждения в Telegram…',
|
||||
telegramLoginRejected: 'Вход отклонён в Telegram.',
|
||||
telegramLoginExpired: 'Время ожидания истекло, попробуйте снова.',
|
||||
@@ -71,8 +74,6 @@ const resources = {
|
||||
selectLocation: 'Выберите локацию',
|
||||
location: 'Локация',
|
||||
label: 'Метка (необязательно)',
|
||||
deviceLimitLabel: 'Лимит устройств (необязательно)',
|
||||
deviceLimitPlaceholder: 'Без лимита',
|
||||
noInboundsNotice: 'Пока нет доступных локаций для создания конфига. Обратитесь к администратору — необходимо, чтобы он добавил сервер.',
|
||||
created: 'Конфиг создан.',
|
||||
quotaExceeded: 'Достигнут лимит конфигов для вашей роли.',
|
||||
@@ -87,10 +88,6 @@ const resources = {
|
||||
subscriptionLink: 'Ссылка-подписка (для клиента):',
|
||||
aggregatedSubscription: 'Общая подписка',
|
||||
aggregatedSubscriptionHint: 'Одна ссылка/QR со всеми активными конфигами — удобно добавить один раз в клиент.',
|
||||
deviceLimit: '{{count}} устройство',
|
||||
deviceLimit_few: '{{count}} устройства',
|
||||
deviceLimit_many: '{{count}} устройств',
|
||||
deviceLimitUnlimited: 'Без лимита устройств',
|
||||
status: {
|
||||
Active: 'Активен',
|
||||
Disabled: 'Отключён',
|
||||
@@ -283,6 +280,7 @@ const resources = {
|
||||
light: 'Light',
|
||||
dark: 'Dark',
|
||||
system: 'System',
|
||||
unlimited: 'Unlimited',
|
||||
|
||||
auth: {
|
||||
loginTitle: 'Log in',
|
||||
@@ -303,6 +301,8 @@ const resources = {
|
||||
passwordHint: 'At least 8 characters, with one uppercase letter and one digit.',
|
||||
or: 'or',
|
||||
telegramBotNotConfigured: 'The Telegram bot has not been configured by the administrator.',
|
||||
telegramQrHint:
|
||||
'Follow the link or scan the QR code — it opens the bot in Telegram. If this is your first time, tap "Start".',
|
||||
waitingForConfirmation: 'Waiting for confirmation in Telegram…',
|
||||
telegramLoginRejected: 'Login was rejected in Telegram.',
|
||||
telegramLoginExpired: 'The request expired, please try again.',
|
||||
@@ -343,8 +343,6 @@ const resources = {
|
||||
selectLocation: 'Select location',
|
||||
location: 'Location',
|
||||
label: 'Label (optional)',
|
||||
deviceLimitLabel: 'Device limit (optional)',
|
||||
deviceLimitPlaceholder: 'Unlimited',
|
||||
noInboundsNotice: 'No locations are available for creating a config yet. Please contact the administrator — a server needs to be added.',
|
||||
created: 'Config created.',
|
||||
quotaExceeded: 'Config quota reached for your role.',
|
||||
@@ -359,9 +357,6 @@ const resources = {
|
||||
subscriptionLink: 'Subscription link (for the client app):',
|
||||
aggregatedSubscription: 'Aggregated subscription',
|
||||
aggregatedSubscriptionHint: 'One link/QR with all active configs — add it once to your client.',
|
||||
deviceLimit: '{{count}} device',
|
||||
deviceLimit_other: '{{count}} devices',
|
||||
deviceLimitUnlimited: 'No device limit',
|
||||
status: {
|
||||
Active: 'Active',
|
||||
Disabled: 'Disabled',
|
||||
|
||||
@@ -5,7 +5,7 @@ import { cn } from '@/shared/lib/cn'
|
||||
export const Dialog = DialogPrimitive.Root
|
||||
export const DialogTrigger = DialogPrimitive.Trigger
|
||||
|
||||
export function DialogContent({ className, children, ...props }: DialogPrimitive.DialogContentProps) {
|
||||
export function DialogContent({ className, children, style, ...props }: DialogPrimitive.DialogContentProps) {
|
||||
return (
|
||||
<DialogPrimitive.Portal>
|
||||
<DialogPrimitive.Overlay className="fixed inset-0 z-50 bg-black/60" />
|
||||
@@ -14,6 +14,12 @@ export function DialogContent({ className, children, ...props }: DialogPrimitive
|
||||
'fixed left-1/2 top-1/2 z-50 w-[calc(100vw-2rem)] max-w-md -translate-x-1/2 -translate-y-1/2 rounded-lg border border-border bg-background p-6 shadow-lg',
|
||||
className,
|
||||
)}
|
||||
// Пока открыт вложенный Select/Popover, Radix ставит body { pointer-events: none },
|
||||
// из-за чего клик мимо попапа, но всё ещё в области диалога, проваливается сквозь
|
||||
// DialogContent на DialogOverlay (у него свой pointer-events: auto) — а Overlay
|
||||
// всегда закрывает диалог. Возвращаем контенту явный auto, чтобы клики по его
|
||||
// области не проваливались; попап при этом закрывается своей собственной логикой.
|
||||
style={{ pointerEvents: 'auto', ...style }}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
|
||||
Reference in New Issue
Block a user