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,83 @@
|
||||
import { useState, type ReactNode } from 'react'
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from '@/shared/ui/toast-store'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/shared/ui/card'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { Label } from '@/shared/ui/label'
|
||||
import { HttpError } from '@/shared/api/client'
|
||||
import { getActivationStatus, requestActivation } from './api'
|
||||
|
||||
/** Показывает детям только активированным пользователям; иначе — экран запроса активации. */
|
||||
export function ActivationGate({ children }: { children: ReactNode }) {
|
||||
const { t } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
const [comment, setComment] = useState('')
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
const { data, isLoading, isError, refetch } = useQuery({
|
||||
queryKey: ['activation-status'],
|
||||
queryFn: getActivationStatus,
|
||||
})
|
||||
|
||||
if (isLoading) return null
|
||||
if (isError || !data) {
|
||||
return (
|
||||
<div className="flex flex-1 items-center justify-center px-6 py-16">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardContent className="flex flex-col items-center gap-4 pt-6 text-center">
|
||||
<p className="text-sm text-muted-foreground">{t('auth.genericError')}</p>
|
||||
<Button variant="outline" onClick={() => void refetch()}>
|
||||
{t('activation.retry')}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (data.isActivated) return <>{children}</>
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setSubmitting(true)
|
||||
try {
|
||||
await requestActivation(comment.trim() || undefined)
|
||||
await queryClient.invalidateQueries({ queryKey: ['activation-status'] })
|
||||
} catch (error) {
|
||||
const message = error instanceof HttpError && error.status === 409 ? t('activation.alreadyPending') : t('auth.genericError')
|
||||
toast.error(message)
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 items-center justify-center px-6 py-16">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader>
|
||||
<CardTitle>{t('activation.title')}</CardTitle>
|
||||
<CardDescription>{t('activation.description')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
{data.pendingRequest ? (
|
||||
<p className="text-sm text-muted-foreground">{t('activation.pending')}</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="comment">{t('activation.commentLabel')}</Label>
|
||||
<textarea
|
||||
id="comment"
|
||||
className="min-h-24 rounded-md border border-border bg-transparent px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
|
||||
value={comment}
|
||||
onChange={(e) => setComment(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Button onClick={handleSubmit} disabled={submitting}>
|
||||
{t('activation.submit')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { apiRequest } from '@/shared/api/client'
|
||||
import type { ActivationRequestDto, ActivationStatusDto } from '@/shared/api/types'
|
||||
|
||||
export function getActivationStatus() {
|
||||
return apiRequest<ActivationStatusDto>('/activation/status')
|
||||
}
|
||||
|
||||
export function requestActivation(comment: string | undefined) {
|
||||
return apiRequest<ActivationRequestDto>('/activation/request', { method: 'POST', body: { comment: comment ?? null } })
|
||||
}
|
||||
@@ -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 } })
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
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 type { OsPlatform } from '@/shared/api/types'
|
||||
import { listApps } from './api'
|
||||
|
||||
const OS_ORDER: OsPlatform[] = ['IOS', 'Android', 'Windows', 'MacOS', 'Linux']
|
||||
|
||||
export function AppsCatalog() {
|
||||
const { t } = useTranslation()
|
||||
const { data, isLoading } = useQuery({ queryKey: ['client-apps'], queryFn: listApps })
|
||||
|
||||
if (isLoading) return null
|
||||
if (!data || Object.keys(data).length === 0) {
|
||||
return <p className="text-sm text-muted-foreground">{t('instructions.noApps')}</p>
|
||||
}
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { apiRequest } from '@/shared/api/client'
|
||||
import type { AppsByOs } from '@/shared/api/types'
|
||||
|
||||
export function listApps() {
|
||||
return apiRequest<AppsByOs>('/apps')
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { z } from 'zod'
|
||||
import { toast } from '@/shared/ui/toast-store'
|
||||
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 { login, applyAuthResponse } from './api'
|
||||
|
||||
const schema = z.object({
|
||||
userName: z.string().min(1),
|
||||
password: z.string().min(1),
|
||||
})
|
||||
|
||||
type FormValues = z.infer<typeof schema>
|
||||
|
||||
export function LoginForm({ onSuccess }: { onSuccess: () => void }) {
|
||||
const { t } = useTranslation()
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<FormValues>({ resolver: zodResolver(schema) })
|
||||
|
||||
const onSubmit = async (values: FormValues) => {
|
||||
try {
|
||||
const auth = await login(values.userName, values.password)
|
||||
applyAuthResponse(auth)
|
||||
onSuccess()
|
||||
} catch (error) {
|
||||
const message = error instanceof HttpError && error.status === 401 ? t('auth.invalidCredentials') : t('auth.genericError')
|
||||
toast.error(message)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="userName">{t('auth.userName')}</Label>
|
||||
<Input id="userName" autoComplete="username" {...register('userName')} />
|
||||
{errors.userName && <p className="text-sm text-red-500">{errors.userName.message}</p>}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="password">{t('auth.password')}</Label>
|
||||
<Input id="password" type="password" autoComplete="current-password" {...register('password')} />
|
||||
{errors.password && <p className="text-sm text-red-500">{errors.password.message}</p>}
|
||||
</div>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{t('auth.submitLogin')}
|
||||
</Button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { z } from 'zod'
|
||||
import { toast } from '@/shared/ui/toast-store'
|
||||
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 { login, register as registerUser, applyAuthResponse } from './api'
|
||||
|
||||
const schema = z.object({
|
||||
userName: z
|
||||
.string()
|
||||
.min(3)
|
||||
.max(32)
|
||||
.regex(/^[a-zA-Z0-9_.-]+$/),
|
||||
password: z.string().min(8),
|
||||
})
|
||||
|
||||
type FormValues = z.infer<typeof schema>
|
||||
|
||||
export function RegisterForm({ onSuccess }: { onSuccess: () => void }) {
|
||||
const { t } = useTranslation()
|
||||
const {
|
||||
register: registerField,
|
||||
handleSubmit,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<FormValues>({ resolver: zodResolver(schema) })
|
||||
|
||||
const onSubmit = async (values: FormValues) => {
|
||||
try {
|
||||
await registerUser(values.userName, values.password)
|
||||
const auth = await login(values.userName, values.password)
|
||||
applyAuthResponse(auth)
|
||||
onSuccess()
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof HttpError && error.status === 409 ? t('auth.duplicateUserName') : t('auth.genericError')
|
||||
toast.error(message)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="userName">{t('auth.userName')}</Label>
|
||||
<Input id="userName" autoComplete="username" {...registerField('userName')} />
|
||||
{errors.userName ? (
|
||||
<p className="text-sm text-red-500">{t('auth.userNameHint')}</p>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">{t('auth.userNameHint')}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="password">{t('auth.password')}</Label>
|
||||
<Input id="password" type="password" autoComplete="new-password" {...registerField('password')} />
|
||||
{errors.password ? (
|
||||
<p className="text-sm text-red-500">{t('auth.passwordHint')}</p>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">{t('auth.passwordHint')}</p>
|
||||
)}
|
||||
</div>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{t('auth.submitRegister')}
|
||||
</Button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { apiRequest, setAccessToken } from '@/shared/api/client'
|
||||
import type { AuthResponse, CurrentUser, RegisterResponse } from '@/shared/api/types'
|
||||
import { useAuthStore } from './store'
|
||||
|
||||
export function login(userName: string, password: string) {
|
||||
return apiRequest<AuthResponse>('/auth/login', { method: 'POST', body: { userName, password } })
|
||||
}
|
||||
|
||||
export function register(userName: string, password: string) {
|
||||
return apiRequest<RegisterResponse>('/auth/register', { method: 'POST', body: { userName, password } })
|
||||
}
|
||||
|
||||
export function logout() {
|
||||
return apiRequest<void>('/auth/logout', { method: 'POST' })
|
||||
}
|
||||
|
||||
export function fetchCurrentUser() {
|
||||
return apiRequest<CurrentUser>('/auth/me')
|
||||
}
|
||||
|
||||
export function changePassword(currentPassword: string, newPassword: string) {
|
||||
return apiRequest<void>('/auth/change-password', { method: 'POST', body: { currentPassword, newPassword } })
|
||||
}
|
||||
|
||||
export function deleteAccount() {
|
||||
return apiRequest<void>('/auth/me', { method: 'DELETE' })
|
||||
}
|
||||
|
||||
export function applyAuthResponse(auth: AuthResponse) {
|
||||
setAccessToken(auth.accessToken)
|
||||
useAuthStore.getState().setUser(auth.user)
|
||||
}
|
||||
|
||||
/** Тихая попытка восстановить сессию по refresh-cookie при загрузке приложения. */
|
||||
export async function bootstrapSession() {
|
||||
try {
|
||||
const auth = await apiRequest<AuthResponse>('/auth/refresh', { method: 'POST', skipRefresh: true })
|
||||
applyAuthResponse(auth)
|
||||
} catch {
|
||||
setAccessToken(null)
|
||||
useAuthStore.getState().setUser(null)
|
||||
} finally {
|
||||
useAuthStore.getState().finishBootstrap()
|
||||
}
|
||||
}
|
||||
|
||||
export function clearSession() {
|
||||
setAccessToken(null)
|
||||
useAuthStore.getState().setUser(null)
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import { useAuthStore } from './store'
|
||||
|
||||
/** Редиректит на /login, если пользователь не вошёл (после завершения bootstrap-попытки refresh). */
|
||||
export function useRequireAuth() {
|
||||
const { user, isBootstrapping } = useAuthStore()
|
||||
const navigate = useNavigate()
|
||||
|
||||
useEffect(() => {
|
||||
if (!isBootstrapping && !user) void navigate({ to: '/login' })
|
||||
}, [isBootstrapping, user, navigate])
|
||||
|
||||
return { user, isReady: !isBootstrapping && !!user }
|
||||
}
|
||||
|
||||
/** Редиректит уже вошедшего пользователя с login/register на дашборд. */
|
||||
export function useRequireGuest() {
|
||||
const { user, isBootstrapping } = useAuthStore()
|
||||
const navigate = useNavigate()
|
||||
|
||||
useEffect(() => {
|
||||
if (!isBootstrapping && user) void navigate({ to: '/dashboard' })
|
||||
}, [isBootstrapping, user, navigate])
|
||||
}
|
||||
|
||||
/** Как useRequireAuth, но дополнительно требует роль admin — иначе редирект на дашборд. */
|
||||
export function useRequireAdmin() {
|
||||
const { user, isBootstrapping } = useAuthStore()
|
||||
const navigate = useNavigate()
|
||||
|
||||
useEffect(() => {
|
||||
if (isBootstrapping) return
|
||||
if (!user) void navigate({ to: '/login' })
|
||||
else if (user.role !== 'admin') void navigate({ to: '/dashboard' })
|
||||
}, [isBootstrapping, user, navigate])
|
||||
|
||||
return { user, isReady: !isBootstrapping && !!user && user.role === 'admin' }
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { create } from 'zustand'
|
||||
import type { CurrentUser } from '@/shared/api/types'
|
||||
|
||||
type AuthState = {
|
||||
user: CurrentUser | null
|
||||
/** Пока не завершилась попытка тихого восстановления сессии при старте приложения. */
|
||||
isBootstrapping: boolean
|
||||
setUser: (user: CurrentUser | null) => void
|
||||
finishBootstrap: () => void
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthState>((set) => ({
|
||||
user: null,
|
||||
isBootstrapping: true,
|
||||
setUser: (user) => set({ user }),
|
||||
finishBootstrap: () => set({ isBootstrapping: false }),
|
||||
}))
|
||||
@@ -0,0 +1,146 @@
|
||||
import { useState } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { QRCodeSVG } from 'qrcode.react'
|
||||
import { Copy, QrCode, RefreshCw, Trash2 } from 'lucide-react'
|
||||
import { toast } from '@/shared/ui/toast-store'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/shared/ui/card'
|
||||
import { Badge } from '@/shared/ui/badge'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/shared/ui/dialog'
|
||||
import { HttpError } from '@/shared/api/client'
|
||||
import { formatBytes } from '@/shared/lib/format'
|
||||
import type { ConfigStatus, VpnConfigDto } from '@/shared/api/types'
|
||||
import { getConfigLink, revokeConfig, rotateConfig } from './api'
|
||||
|
||||
const STATUS_VARIANT: Record<ConfigStatus, 'success' | 'warning' | 'destructive'> = {
|
||||
Active: 'success',
|
||||
Disabled: 'warning',
|
||||
Expired: 'destructive',
|
||||
LimitReached: 'destructive',
|
||||
Revoked: 'destructive',
|
||||
}
|
||||
|
||||
export function ConfigCard({ config }: { config: VpnConfigDto }) {
|
||||
const { t } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
const [detailsOpen, setDetailsOpen] = useState(false)
|
||||
|
||||
const linkQuery = useQuery({
|
||||
queryKey: ['config-link', config.id],
|
||||
queryFn: () => getConfigLink(config.id),
|
||||
enabled: detailsOpen,
|
||||
})
|
||||
|
||||
const rotateMutation = useMutation({
|
||||
mutationFn: () => rotateConfig(config.id),
|
||||
onSuccess: async () => {
|
||||
toast.success(t('configs.rotated'))
|
||||
await queryClient.invalidateQueries({ queryKey: ['my-configs'] })
|
||||
await queryClient.invalidateQueries({ queryKey: ['config-link', config.id] })
|
||||
},
|
||||
onError: () => toast.error(t('auth.genericError')),
|
||||
})
|
||||
|
||||
const revokeMutation = useMutation({
|
||||
mutationFn: () => revokeConfig(config.id),
|
||||
onSuccess: async () => {
|
||||
toast.success(t('configs.revoked'))
|
||||
setDetailsOpen(false)
|
||||
await queryClient.invalidateQueries({ queryKey: ['my-configs'] })
|
||||
},
|
||||
onError: () => toast.error(t('auth.genericError')),
|
||||
})
|
||||
|
||||
const copy = async (value: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(value)
|
||||
toast.success(t('configs.copied'))
|
||||
} catch {
|
||||
toast.error(t('auth.genericError'))
|
||||
}
|
||||
}
|
||||
|
||||
const isActive = config.status === 'Active'
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card>
|
||||
<CardHeader className="flex-row items-start justify-between gap-2 space-y-0">
|
||||
<div>
|
||||
<CardTitle className="text-base">{config.label ?? config.location}</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">{config.location}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline">{config.protocol}</Badge>
|
||||
<Badge variant={STATUS_VARIANT[config.status]}>{t(`configs.status.${config.status}`)}</Badge>
|
||||
</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>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" variant="outline" onClick={() => setDetailsOpen(true)}>
|
||||
<QrCode className="h-4 w-4" />
|
||||
{t('configs.showLink')}
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" disabled={!isActive || rotateMutation.isPending} onClick={() => rotateMutation.mutate()}>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
{t('configs.rotate')}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={config.status === 'Revoked' || revokeMutation.isPending}
|
||||
onClick={() => {
|
||||
if (confirm(t('configs.confirmRevoke'))) revokeMutation.mutate()
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Dialog open={detailsOpen} onOpenChange={setDetailsOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{config.label ?? config.location}</DialogTitle>
|
||||
</DialogHeader>
|
||||
{linkQuery.isLoading && <p className="text-sm text-muted-foreground">{t('configs.loadingLink')}</p>}
|
||||
{linkQuery.isError && (
|
||||
<p className="text-sm text-red-500">
|
||||
{linkQuery.error instanceof HttpError ? linkQuery.error.detail : t('auth.genericError')}
|
||||
</p>
|
||||
)}
|
||||
{linkQuery.data && (
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<QRCodeSVG value={linkQuery.data.connectionString} size={200} />
|
||||
<div className="flex w-full items-center gap-2">
|
||||
<code className="flex-1 truncate rounded-md bg-muted px-2 py-1.5 text-xs">
|
||||
{linkQuery.data.connectionString}
|
||||
</code>
|
||||
<Button size="icon" variant="outline" onClick={() => void copy(linkQuery.data!.connectionString)}>
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{t('configs.subscriptionLink')}</p>
|
||||
<div className="flex w-full items-center gap-2">
|
||||
<code className="flex-1 truncate rounded-md bg-muted px-2 py-1.5 text-xs">
|
||||
{linkQuery.data.subscriptionUrl}
|
||||
</code>
|
||||
<Button size="icon" variant="outline" onClick={() => void copy(linkQuery.data!.subscriptionUrl)}>
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { useState } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Plus } from 'lucide-react'
|
||||
import { toast } from '@/shared/ui/toast-store'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { Input } from '@/shared/ui/input'
|
||||
import { Label } from '@/shared/ui/label'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/shared/ui/dialog'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
|
||||
import { HttpError } from '@/shared/api/client'
|
||||
import { createConfig, listAvailableInbounds } from './api'
|
||||
|
||||
export function CreateConfigDialog() {
|
||||
const { t } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [inboundId, setInboundId] = useState('')
|
||||
const [label, setLabel] = useState('')
|
||||
const [deviceLimit, setDeviceLimit] = useState('')
|
||||
|
||||
const inboundsQuery = useQuery({ queryKey: ['available-inbounds'], queryFn: listAvailableInbounds, enabled: open })
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: () => createConfig(inboundId, label.trim() || undefined, deviceLimit ? Number(deviceLimit) : undefined),
|
||||
onSuccess: async () => {
|
||||
toast.success(t('configs.created'))
|
||||
await queryClient.invalidateQueries({ queryKey: ['my-configs'] })
|
||||
setOpen(false)
|
||||
setInboundId('')
|
||||
setLabel('')
|
||||
setDeviceLimit('')
|
||||
},
|
||||
onError: (error) => {
|
||||
const message =
|
||||
error instanceof HttpError && error.status === 409 ? t('configs.quotaExceeded') : t('auth.genericError')
|
||||
toast.error(message)
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button>
|
||||
<Plus className="h-4 w-4" />
|
||||
{t('configs.create')}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('configs.create')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form
|
||||
className="flex flex-col gap-4"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
if (inboundId) createMutation.mutate()
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('configs.location')}</Label>
|
||||
<Select value={inboundId} onValueChange={setInboundId}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t('configs.selectLocation')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{inboundsQuery.data?.map((inbound) => (
|
||||
<SelectItem key={inbound.inboundId} value={inbound.inboundId}>
|
||||
{inbound.displayName} ({inbound.protocol})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{inboundsQuery.data?.length === 0 && (
|
||||
<p className="text-xs text-muted-foreground">{t('configs.noInboundsAvailable')}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<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>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { QRCodeSVG } from 'qrcode.react'
|
||||
import { Copy } from 'lucide-react'
|
||||
import { toast } from '@/shared/ui/toast-store'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/shared/ui/card'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { getMySubscription } from './api'
|
||||
|
||||
export function SubscriptionCard() {
|
||||
const { t } = useTranslation()
|
||||
const { data, isLoading } = useQuery({ queryKey: ['my-subscription'], queryFn: getMySubscription })
|
||||
|
||||
const copy = async () => {
|
||||
if (!data) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(data.subscriptionUrl)
|
||||
toast.success(t('configs.copied'))
|
||||
} catch {
|
||||
toast.error(t('auth.genericError'))
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading || !data) return null
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{t('configs.aggregatedSubscription')}</CardTitle>
|
||||
<CardDescription>{t('configs.aggregatedSubscriptionHint')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex items-center gap-4">
|
||||
<QRCodeSVG value={data.subscriptionUrl} size={96} />
|
||||
<div className="flex flex-1 flex-col gap-2">
|
||||
<code className="truncate rounded-md bg-muted px-2 py-1.5 text-xs">{data.subscriptionUrl}</code>
|
||||
<Button size="sm" variant="outline" className="self-start" onClick={() => void copy()}>
|
||||
<Copy className="h-4 w-4" />
|
||||
{t('configs.copyLink')}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { apiRequest } from '@/shared/api/client'
|
||||
import type {
|
||||
AvailableInboundDto,
|
||||
ConfigLinkDto,
|
||||
GetMyConfigsResult,
|
||||
MySubscriptionDto,
|
||||
VpnConfigDto,
|
||||
} from '@/shared/api/types'
|
||||
|
||||
export function listAvailableInbounds() {
|
||||
return apiRequest<AvailableInboundDto[]>('/inbounds/available')
|
||||
}
|
||||
|
||||
export function getMyConfigs() {
|
||||
return apiRequest<GetMyConfigsResult>('/configs')
|
||||
}
|
||||
|
||||
export function createConfig(inboundId: string, label: string | undefined, deviceLimit: number | undefined) {
|
||||
return apiRequest<VpnConfigDto>('/configs', {
|
||||
method: 'POST',
|
||||
body: { inboundId, label: label ?? null, deviceLimit: deviceLimit ?? null },
|
||||
})
|
||||
}
|
||||
|
||||
export function editConfig(id: string, label: string | undefined, deviceLimit: number | undefined) {
|
||||
return apiRequest<VpnConfigDto>(`/configs/${id}`, {
|
||||
method: 'PATCH',
|
||||
body: { label: label ?? null, deviceLimit: deviceLimit ?? null },
|
||||
})
|
||||
}
|
||||
|
||||
export function rotateConfig(id: string) {
|
||||
return apiRequest<VpnConfigDto>(`/configs/${id}/rotate`, { method: 'POST' })
|
||||
}
|
||||
|
||||
export function revokeConfig(id: string) {
|
||||
return apiRequest<void>(`/configs/${id}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
export function getConfigLink(id: string) {
|
||||
return apiRequest<ConfigLinkDto>(`/configs/${id}/link`)
|
||||
}
|
||||
|
||||
export function getMySubscription() {
|
||||
return apiRequest<MySubscriptionDto>('/subscription')
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { z } from 'zod'
|
||||
import { toast } from '@/shared/ui/toast-store'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { Input } from '@/shared/ui/input'
|
||||
import { Label } from '@/shared/ui/label'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/shared/ui/card'
|
||||
import { HttpError } from '@/shared/api/client'
|
||||
import { changePassword } from '@/features/auth/api'
|
||||
|
||||
const schema = z.object({
|
||||
currentPassword: z.string().min(1),
|
||||
newPassword: z.string().min(8),
|
||||
})
|
||||
|
||||
type FormValues = z.infer<typeof schema>
|
||||
|
||||
export function ChangePasswordForm() {
|
||||
const { t } = useTranslation()
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<FormValues>({ resolver: zodResolver(schema) })
|
||||
|
||||
const onSubmit = async (values: FormValues) => {
|
||||
try {
|
||||
await changePassword(values.currentPassword, values.newPassword)
|
||||
toast.success(t('settings.passwordChanged'))
|
||||
reset()
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof HttpError && error.status === 400 ? t('settings.currentPasswordInvalid') : t('auth.genericError')
|
||||
toast.error(message)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{t('settings.changePassword')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="currentPassword">{t('settings.currentPassword')}</Label>
|
||||
<Input id="currentPassword" type="password" autoComplete="current-password" {...register('currentPassword')} />
|
||||
{errors.currentPassword && <p className="text-sm text-red-500">{t('settings.currentPasswordRequired')}</p>}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="newPassword">{t('settings.newPassword')}</Label>
|
||||
<Input id="newPassword" type="password" autoComplete="new-password" {...register('newPassword')} />
|
||||
{errors.newPassword && <p className="text-sm text-red-500">{t('auth.passwordHint')}</p>}
|
||||
</div>
|
||||
<Button type="submit" disabled={isSubmitting} className="self-start">
|
||||
{t('settings.changePassword')}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { useState } from 'react'
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from '@/shared/ui/toast-store'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/shared/ui/card'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { deleteAccount, clearSession } from '@/features/auth/api'
|
||||
|
||||
export function DeleteAccountSection() {
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
const [confirming, setConfirming] = useState(false)
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: deleteAccount,
|
||||
onSuccess: () => {
|
||||
clearSession()
|
||||
void navigate({ to: '/login' })
|
||||
},
|
||||
onError: () => toast.error(t('auth.genericError')),
|
||||
})
|
||||
|
||||
return (
|
||||
<Card className="border-red-900/50">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base text-red-400">{t('settings.deleteAccount')}</CardTitle>
|
||||
<CardDescription>{t('settings.deleteAccountHint')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{!confirming ? (
|
||||
<Button variant="destructive" size="sm" onClick={() => setConfirming(true)}>
|
||||
{t('settings.deleteAccount')}
|
||||
</Button>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-sm">{t('settings.confirmDelete')}</p>
|
||||
<Button variant="destructive" size="sm" disabled={deleteMutation.isPending} onClick={() => deleteMutation.mutate()}>
|
||||
{t('settings.confirmDeleteYes')}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => setConfirming(false)}>
|
||||
{t('settings.cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { QRCodeSVG } from 'qrcode.react'
|
||||
import { toast } from '@/shared/ui/toast-store'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/shared/ui/card'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/shared/ui/dialog'
|
||||
import { useAuthStore } from '@/features/auth/store'
|
||||
import { fetchCurrentUser } from '@/features/auth/api'
|
||||
import { createLinkToken, unlinkTelegram } from '@/features/telegram/api'
|
||||
|
||||
export function TelegramLinkCard() {
|
||||
const { t } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
const user = useAuthStore((s) => s.user)
|
||||
const setUser = useAuthStore((s) => s.setUser)
|
||||
const [open, setOpen] = useState(false)
|
||||
const [deepLink, setDeepLink] = useState<string | null>(null)
|
||||
|
||||
const linkMutation = useMutation({
|
||||
mutationFn: createLinkToken,
|
||||
onSuccess: (data) => {
|
||||
setDeepLink(data.deepLink)
|
||||
setOpen(true)
|
||||
},
|
||||
onError: () => toast.error(t('auth.genericError')),
|
||||
})
|
||||
|
||||
const meQuery = useQuery({
|
||||
queryKey: ['me-poll'],
|
||||
queryFn: fetchCurrentUser,
|
||||
enabled: open,
|
||||
refetchInterval: 2500,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!meQuery.data?.telegramLinked) return
|
||||
setUser(meQuery.data)
|
||||
setOpen(false)
|
||||
toast.success(t('settings.telegramLinked'))
|
||||
}, [meQuery.data, setUser, t])
|
||||
|
||||
const unlinkMutation = useMutation({
|
||||
mutationFn: unlinkTelegram,
|
||||
onSuccess: async () => {
|
||||
if (user) setUser({ ...user, telegramLinked: false })
|
||||
toast.success(t('settings.telegramUnlinked'))
|
||||
await queryClient.invalidateQueries({ queryKey: ['me-poll'] })
|
||||
},
|
||||
onError: () => toast.error(t('auth.genericError')),
|
||||
})
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Telegram</CardTitle>
|
||||
<CardDescription>{t('settings.telegramHint')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{user?.telegramLinked ? (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-emerald-500">{t('settings.telegramLinkedStatus')}</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={unlinkMutation.isPending}
|
||||
onClick={() => {
|
||||
if (confirm(t('settings.confirmUnlink'))) unlinkMutation.mutate()
|
||||
}}
|
||||
>
|
||||
{t('settings.unlink')}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button variant="outline" size="sm" disabled={linkMutation.isPending} onClick={() => linkMutation.mutate()}>
|
||||
{t('settings.link')}
|
||||
</Button>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('settings.link')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
{deepLink ? (
|
||||
<>
|
||||
<QRCodeSVG value={deepLink} size={200} />
|
||||
<a href={deepLink} target="_blank" rel="noreferrer" className="text-sm text-primary hover:underline">
|
||||
{deepLink}
|
||||
</a>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">{t('auth.telegramBotNotConfigured')}</p>
|
||||
)}
|
||||
<p className="text-sm text-muted-foreground">{t('settings.waitingForLink')}</p>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useMutation, useQuery } from '@tanstack/react-query'
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { QRCodeSVG } from 'qrcode.react'
|
||||
import { Send } from 'lucide-react'
|
||||
import { toast } from '@/shared/ui/toast-store'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/shared/ui/dialog'
|
||||
import { applyAuthResponse } from '@/features/auth/api'
|
||||
import { createLoginRequest, getLoginRequestStatus } from './api'
|
||||
import type { TelegramLoginStatus } from '@/shared/api/types'
|
||||
|
||||
const TERMINAL: TelegramLoginStatus[] = ['Rejected', 'Expired', 'Consumed']
|
||||
|
||||
export function TelegramLoginButton() {
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [requestId, setRequestId] = useState<string | null>(null)
|
||||
const [deepLink, setDeepLink] = useState<string | null>(null)
|
||||
|
||||
const startMutation = useMutation({
|
||||
mutationFn: createLoginRequest,
|
||||
onSuccess: (data) => {
|
||||
setRequestId(data.requestId)
|
||||
setDeepLink(data.deepLink)
|
||||
setOpen(true)
|
||||
},
|
||||
onError: () => toast.error(t('auth.genericError')),
|
||||
})
|
||||
|
||||
const statusQuery = useQuery({
|
||||
queryKey: ['telegram-login-status', requestId],
|
||||
queryFn: () => getLoginRequestStatus(requestId!),
|
||||
enabled: open && !!requestId,
|
||||
refetchInterval: (query) => {
|
||||
const status = query.state.data?.status
|
||||
return status && (status === 'Approved' || TERMINAL.includes(status)) ? false : 2000
|
||||
},
|
||||
})
|
||||
|
||||
const status = statusQuery.data?.status
|
||||
|
||||
useEffect(() => {
|
||||
if (status !== 'Approved' || !statusQuery.data?.accessToken || !statusQuery.data.user) return
|
||||
applyAuthResponse({
|
||||
accessToken: statusQuery.data.accessToken,
|
||||
expiresAt: statusQuery.data.expiresAt!,
|
||||
user: statusQuery.data.user,
|
||||
})
|
||||
setOpen(false)
|
||||
void navigate({ to: '/dashboard' })
|
||||
}, [status, statusQuery.data, navigate])
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button type="button" variant="outline" className="w-full" onClick={() => startMutation.mutate()} disabled={startMutation.isPending}>
|
||||
<Send className="h-4 w-4" />
|
||||
{t('auth.loginViaTelegram')}
|
||||
</Button>
|
||||
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('auth.loginViaTelegram')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
{deepLink && (
|
||||
<>
|
||||
<QRCodeSVG value={deepLink} size={200} />
|
||||
<a href={deepLink} target="_blank" rel="noreferrer" className="text-sm text-primary hover:underline">
|
||||
{deepLink}
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
{!deepLink && <p className="text-sm text-muted-foreground">{t('auth.telegramBotNotConfigured')}</p>}
|
||||
|
||||
{status === 'Pending' && <p className="text-sm text-muted-foreground">{t('auth.waitingForConfirmation')}</p>}
|
||||
{status === 'Rejected' && <p className="text-sm text-red-500">{t('auth.telegramLoginRejected')}</p>}
|
||||
{status === 'Expired' && <p className="text-sm text-red-500">{t('auth.telegramLoginExpired')}</p>}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { apiRequest } from '@/shared/api/client'
|
||||
import type { LinkTokenResponse, TelegramLoginRequestResponse, TelegramLoginStatusResponse } from '@/shared/api/types'
|
||||
|
||||
export function createLinkToken() {
|
||||
return apiRequest<LinkTokenResponse>('/auth/telegram/link-token', { method: 'POST' })
|
||||
}
|
||||
|
||||
export function unlinkTelegram() {
|
||||
return apiRequest<void>('/auth/telegram/unlink', { method: 'POST' })
|
||||
}
|
||||
|
||||
export function createLoginRequest() {
|
||||
return apiRequest<TelegramLoginRequestResponse>('/auth/telegram/login-request', { method: 'POST' })
|
||||
}
|
||||
|
||||
export function getLoginRequestStatus(id: string) {
|
||||
return apiRequest<TelegramLoginStatusResponse>(`/auth/telegram/login-request/${id}`)
|
||||
}
|
||||
Reference in New Issue
Block a user