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

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