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
+93
View File
@@ -0,0 +1,93 @@
import { useState } from 'react'
import { createFileRoute } from '@tanstack/react-router'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { toast } from '@/shared/ui/toast-store'
import { Card, CardContent, CardHeader, CardTitle } from '@/shared/ui/card'
import { Button } from '@/shared/ui/button'
import { approveActivationRequest, listActivationRequests, rejectActivationRequest } from '@/features/admin/activation/api'
export const Route = createFileRoute('/admin/activation')({ component: AdminActivationPage })
function AdminActivationPage() {
const { t } = useTranslation()
const queryClient = useQueryClient()
const [page, setPage] = useState(1)
const { data, isLoading, isError, refetch } = useQuery({
queryKey: ['admin-activation-requests', page],
queryFn: () => listActivationRequests('Pending', page, 20),
})
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['admin-activation-requests'] })
const approveMutation = useMutation({
mutationFn: approveActivationRequest,
onSuccess: async () => {
toast.success(t('admin.activation.approved'))
await invalidate()
},
onError: () => toast.error(t('auth.genericError')),
})
const rejectMutation = useMutation({
mutationFn: (id: string) => rejectActivationRequest(id, undefined),
onSuccess: async () => {
toast.success(t('admin.activation.rejected'))
await invalidate()
},
onError: () => toast.error(t('auth.genericError')),
})
if (isLoading) return <p className="text-sm text-muted-foreground"></p>
if (isError || !data) {
return (
<div className="flex items-center gap-2">
<p className="text-sm text-muted-foreground">{t('auth.genericError')}</p>
<Button variant="outline" size="sm" onClick={() => void refetch()}>
{t('activation.retry')}
</Button>
</div>
)
}
if (data.items.length === 0) {
return <p className="text-sm text-muted-foreground">{t('admin.activation.empty')}</p>
}
return (
<div className="flex flex-col gap-4">
{data.items.map((request) => (
<Card key={request.id}>
<CardHeader>
<CardTitle className="text-base">{request.userName}</CardTitle>
{request.comment && <p className="text-sm text-muted-foreground">{request.comment}</p>}
</CardHeader>
<CardContent className="flex gap-2">
<Button size="sm" disabled={approveMutation.isPending} onClick={() => approveMutation.mutate(request.id)}>
{t('admin.activation.approve')}
</Button>
<Button
size="sm"
variant="outline"
disabled={rejectMutation.isPending}
onClick={() => rejectMutation.mutate(request.id)}
>
{t('admin.activation.reject')}
</Button>
</CardContent>
</Card>
))}
<div className="flex justify-end gap-2 text-sm">
<Button variant="outline" size="sm" disabled={page <= 1} onClick={() => setPage((p) => p - 1)}>
{t('admin.prev')}
</Button>
<Button variant="outline" size="sm" disabled={page * 20 >= data.total} onClick={() => setPage((p) => p + 1)}>
{t('admin.next')}
</Button>
</div>
</div>
)
}
+89
View File
@@ -0,0 +1,89 @@
import { useState } from 'react'
import { createFileRoute } from '@tanstack/react-router'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { toast } from '@/shared/ui/toast-store'
import { Button } from '@/shared/ui/button'
import { Badge } from '@/shared/ui/badge'
import { listAdminApps, deleteApp } from '@/features/admin/apps/api'
import { AppFormDialog } from '@/features/admin/apps/AppFormDialog'
import type { AdminAppDto, OsPlatform } from '@/shared/api/types'
export const Route = createFileRoute('/admin/apps')({ component: AdminAppsPage })
const OS_ORDER: OsPlatform[] = ['IOS', 'Android', 'Windows', 'MacOS', 'Linux']
function AdminAppsPage() {
const { t } = useTranslation()
const queryClient = useQueryClient()
const [editing, setEditing] = useState<AdminAppDto | null>(null)
const { data, isLoading, isError, refetch } = useQuery({ queryKey: ['admin-apps'], queryFn: listAdminApps })
const deleteMutation = useMutation({
mutationFn: deleteApp,
onSuccess: async () => {
toast.success(t('admin.apps.deleted'))
await queryClient.invalidateQueries({ queryKey: ['admin-apps'] })
},
onError: () => toast.error(t('auth.genericError')),
})
return (
<div className="flex flex-col gap-4">
<div className="flex justify-end">
<AppFormDialog />
</div>
{isLoading && <p className="text-sm text-muted-foreground"></p>}
{isError && (
<div className="flex items-center gap-2">
<p className="text-sm text-muted-foreground">{t('auth.genericError')}</p>
<Button variant="outline" size="sm" onClick={() => void refetch()}>
{t('activation.retry')}
</Button>
</div>
)}
{data?.length === 0 && <p className="text-sm text-muted-foreground">{t('admin.apps.empty')}</p>}
{data && (
<div className="flex flex-col gap-6">
{OS_ORDER.filter((os) => data.some((a) => a.operatingSystem === os)).map((os) => (
<div key={os} className="flex flex-col gap-2">
<h3 className="text-sm font-semibold text-muted-foreground">{t(`instructions.os.${os}`)}</h3>
{data
.filter((a) => a.operatingSystem === os)
.map((app) => (
<div key={app.id} className="flex items-center justify-between rounded-md border border-border px-3 py-2 text-sm">
<div className="flex items-center gap-2">
<span>{app.name}</span>
{!app.isEnabled && <Badge variant="outline">{t('admin.apps.disabled')}</Badge>}
</div>
<div className="flex gap-2">
<Button size="sm" variant="outline" onClick={() => setEditing(app)}>
{t('admin.roles.edit')}
</Button>
<Button
size="sm"
variant="ghost"
disabled={deleteMutation.isPending}
onClick={() => {
if (confirm(t('admin.apps.confirmDelete'))) deleteMutation.mutate(app.id)
}}
>
{t('admin.roles.delete')}
</Button>
</div>
</div>
))}
</div>
))}
</div>
)}
{editing && <AppFormDialog app={editing} open={!!editing} onOpenChange={(open) => !open && setEditing(null)} />}
</div>
)
}
+79
View File
@@ -0,0 +1,79 @@
import { useState } from 'react'
import { createFileRoute } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { Button } from '@/shared/ui/button'
import { Badge } from '@/shared/ui/badge'
import { listAuditLogs } from '@/features/admin/audit/api'
export const Route = createFileRoute('/admin/audit')({ component: AdminAuditPage })
const PAGE_SIZE = 50
function AdminAuditPage() {
const { t } = useTranslation()
const [page, setPage] = useState(1)
const { data, isLoading, isError, refetch } = useQuery({
queryKey: ['admin-audit', page],
queryFn: () => listAuditLogs(page, PAGE_SIZE),
})
return (
<div className="flex flex-col gap-4">
{isLoading && <p className="text-sm text-muted-foreground"></p>}
{isError && (
<div className="flex items-center gap-2">
<p className="text-sm text-muted-foreground">{t('auth.genericError')}</p>
<Button variant="outline" size="sm" onClick={() => void refetch()}>
{t('activation.retry')}
</Button>
</div>
)}
{data?.items.length === 0 && <p className="text-sm text-muted-foreground">{t('admin.audit.empty')}</p>}
{data && data.items.length > 0 && (
<>
<table className="w-full text-left text-sm">
<thead>
<tr className="border-b border-border text-muted-foreground">
<th className="py-2 font-medium">{t('admin.audit.time')}</th>
<th className="py-2 font-medium">{t('admin.audit.action')}</th>
<th className="py-2 font-medium">{t('admin.audit.target')}</th>
<th className="py-2 font-medium">{t('admin.audit.source')}</th>
</tr>
</thead>
<tbody>
{data.items.map((entry) => (
<tr key={entry.id} className="border-b border-border align-top">
<td className="whitespace-nowrap py-2 text-muted-foreground">{new Date(entry.createdAt).toLocaleString()}</td>
<td className="py-2">{entry.action}</td>
<td className="py-2 text-muted-foreground">
{entry.targetType} · {entry.targetId.slice(0, 8)}
</td>
<td className="py-2">
<Badge variant="outline">{entry.source}</Badge>
</td>
</tr>
))}
</tbody>
</table>
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">{t('admin.users.total', { count: data.total })}</span>
<div className="flex gap-2">
<Button variant="outline" size="sm" disabled={page <= 1} onClick={() => setPage((p) => p - 1)}>
{t('admin.prev')}
</Button>
<Button variant="outline" size="sm" disabled={page * PAGE_SIZE >= data.total} onClick={() => setPage((p) => p + 1)}>
{t('admin.next')}
</Button>
</div>
</div>
</>
)}
</div>
)
}
+51
View File
@@ -0,0 +1,51 @@
import { createFileRoute } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { Card, CardContent, CardHeader, CardTitle } from '@/shared/ui/card'
import { Button } from '@/shared/ui/button'
import { formatBytes } from '@/shared/lib/format'
import { getStats } from '@/features/admin/stats/api'
export const Route = createFileRoute('/admin/')({ component: AdminIndex })
function AdminIndex() {
const { t } = useTranslation()
const { data, isLoading, isError, refetch } = useQuery({ queryKey: ['admin-stats'], queryFn: getStats })
if (isLoading) return <p className="text-sm text-muted-foreground"></p>
if (isError || !data) {
return (
<div className="flex items-center gap-2">
<p className="text-sm text-muted-foreground">{t('auth.genericError')}</p>
<Button variant="outline" size="sm" onClick={() => void refetch()}>
{t('activation.retry')}
</Button>
</div>
)
}
const cards = [
{ label: t('admin.stats.totalUsers'), value: data.totalUsers },
{ label: t('admin.stats.activatedUsers'), value: data.activatedUsers },
{ label: t('admin.stats.pendingActivationRequests'), value: data.pendingActivationRequests },
{ label: t('admin.stats.totalNodes'), value: data.totalNodes },
{ label: t('admin.stats.onlineNodes'), value: data.onlineNodes },
{ label: t('admin.stats.totalConfigs'), value: data.totalConfigs },
{ label: t('admin.stats.activeConfigs'), value: data.activeConfigs },
{ label: t('admin.stats.totalTraffic'), value: formatBytes(data.totalUsedUpBytes + data.totalUsedDownBytes) },
]
return (
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
{cards.map((card) => (
<Card key={card.label}>
<CardHeader className="pb-2">
<CardTitle className="text-2xl">{card.value}</CardTitle>
</CardHeader>
<CardContent className="pt-0 text-sm text-muted-foreground">{card.label}</CardContent>
</Card>
))}
</div>
)
}
+37
View File
@@ -0,0 +1,37 @@
import { createFileRoute } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { Button } from '@/shared/ui/button'
import { listNodes } from '@/features/admin/nodes/api'
import { NodeCard } from '@/features/admin/nodes/NodeCard'
import { RegisterNodeDialog } from '@/features/admin/nodes/RegisterNodeDialog'
export const Route = createFileRoute('/admin/nodes')({ component: AdminNodesPage })
function AdminNodesPage() {
const { t } = useTranslation()
const { data, isLoading, isError, refetch } = useQuery({ queryKey: ['admin-nodes'], queryFn: listNodes })
return (
<div className="flex flex-col gap-4">
<div className="flex justify-end">
<RegisterNodeDialog />
</div>
{isLoading && <p className="text-sm text-muted-foreground"></p>}
{isError && (
<div className="flex items-center gap-2">
<p className="text-sm text-muted-foreground">{t('auth.genericError')}</p>
<Button variant="outline" size="sm" onClick={() => void refetch()}>
{t('activation.retry')}
</Button>
</div>
)}
{data?.length === 0 && <p className="text-sm text-muted-foreground">{t('admin.nodes.empty')}</p>}
<div className="flex flex-col gap-3">{data?.map((node) => <NodeCard key={node.id} node={node} />)}</div>
</div>
)
}
+92
View File
@@ -0,0 +1,92 @@
import { useState } from 'react'
import { createFileRoute } from '@tanstack/react-router'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { toast } from '@/shared/ui/toast-store'
import { Button } from '@/shared/ui/button'
import { Badge } from '@/shared/ui/badge'
import { listRoles, deleteRole } from '@/features/admin/roles/api'
import { RoleFormDialog } from '@/features/admin/roles/RoleFormDialog'
import type { RoleDto } from '@/shared/api/types'
export const Route = createFileRoute('/admin/roles')({ component: AdminRolesPage })
function AdminRolesPage() {
const { t } = useTranslation()
const queryClient = useQueryClient()
const [editing, setEditing] = useState<RoleDto | null>(null)
const { data, isLoading, isError, refetch } = useQuery({ queryKey: ['admin-roles-page'], queryFn: listRoles })
const deleteMutation = useMutation({
mutationFn: deleteRole,
onSuccess: async () => {
toast.success(t('admin.roles.deleted'))
await queryClient.invalidateQueries({ queryKey: ['admin-roles-page'] })
},
onError: () => toast.error(t('auth.genericError')),
})
return (
<div className="flex flex-col gap-4">
<div className="flex justify-end">
<RoleFormDialog />
</div>
{isLoading && <p className="text-sm text-muted-foreground"></p>}
{isError && (
<div className="flex items-center gap-2">
<p className="text-sm text-muted-foreground">{t('auth.genericError')}</p>
<Button variant="outline" size="sm" onClick={() => void refetch()}>
{t('activation.retry')}
</Button>
</div>
)}
{data && (
<table className="w-full text-left text-sm">
<thead>
<tr className="border-b border-border text-muted-foreground">
<th className="py-2 font-medium">{t('admin.roles.name')}</th>
<th className="py-2 font-medium">{t('admin.roles.maxConfigs')}</th>
<th className="py-2" />
<th className="py-2" />
</tr>
</thead>
<tbody>
{data.map((role) => (
<tr key={role.id} className="border-b border-border">
<td className="py-2">
{role.name} {role.isSystem && <Badge variant="outline">{t('admin.roles.system')}</Badge>}
</td>
<td className="py-2">{role.maxConfigs < 0 ? t('configs.deviceLimitUnlimited') : role.maxConfigs}</td>
<td className="py-2 text-right">
<Button size="sm" variant="outline" onClick={() => setEditing(role)}>
{t('admin.roles.edit')}
</Button>
</td>
<td className="py-2 text-right">
{!role.isSystem && (
<Button
size="sm"
variant="ghost"
disabled={deleteMutation.isPending}
onClick={() => {
if (confirm(t('admin.roles.confirmDelete'))) deleteMutation.mutate(role.id)
}}
>
{t('admin.roles.delete')}
</Button>
)}
</td>
</tr>
))}
</tbody>
</table>
)}
{editing && <RoleFormDialog role={editing} open={!!editing} onOpenChange={(open) => !open && setEditing(null)} />}
</div>
)
}
+104
View File
@@ -0,0 +1,104 @@
import { useState } from 'react'
import { createFileRoute } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { Input } from '@/shared/ui/input'
import { Button } from '@/shared/ui/button'
import { Badge } from '@/shared/ui/badge'
import { listUsers } from '@/features/admin/users/api'
import { UserManageDialog } from '@/features/admin/users/UserManageDialog'
import type { UserSummaryDto } from '@/shared/api/types'
export const Route = createFileRoute('/admin/users')({ component: AdminUsersPage })
const PAGE_SIZE = 20
function AdminUsersPage() {
const { t } = useTranslation()
const [search, setSearch] = useState('')
const [page, setPage] = useState(1)
const [managing, setManaging] = useState<UserSummaryDto | null>(null)
const { data, isLoading, isError, refetch } = useQuery({
queryKey: ['admin-users', page, search],
queryFn: () => listUsers(page, PAGE_SIZE, search || undefined),
})
return (
<div className="flex flex-col gap-4">
<Input
placeholder={t('admin.users.searchPlaceholder')}
value={search}
onChange={(e) => {
setSearch(e.target.value)
setPage(1)
}}
className="max-w-sm"
/>
{isLoading && <p className="text-sm text-muted-foreground"></p>}
{isError && (
<div className="flex items-center gap-2">
<p className="text-sm text-muted-foreground">{t('auth.genericError')}</p>
<Button variant="outline" size="sm" onClick={() => void refetch()}>
{t('activation.retry')}
</Button>
</div>
)}
{data && (
<>
<table className="w-full text-left text-sm">
<thead>
<tr className="border-b border-border text-muted-foreground">
<th className="py-2 font-medium">{t('admin.users.userName')}</th>
<th className="py-2 font-medium">{t('admin.users.role')}</th>
<th className="py-2 font-medium">{t('admin.users.statusLabel')}</th>
<th className="py-2" />
</tr>
</thead>
<tbody>
{data.items.map((user) => (
<tr key={user.id} className="border-b border-border">
<td className="py-2">{user.userName}</td>
<td className="py-2">{user.role}</td>
<td className="py-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>
</td>
<td className="py-2 text-right">
<Button size="sm" variant="outline" onClick={() => setManaging(user)}>
{t('admin.users.manage')}
</Button>
</td>
</tr>
))}
</tbody>
</table>
{data.items.length === 0 && <p className="text-sm text-muted-foreground">{t('admin.users.empty')}</p>}
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">{t('admin.users.total', { count: data.total })}</span>
<div className="flex gap-2">
<Button variant="outline" size="sm" disabled={page <= 1} onClick={() => setPage((p) => p - 1)}>
{t('admin.prev')}
</Button>
<Button variant="outline" size="sm" disabled={page * PAGE_SIZE >= data.total} onClick={() => setPage((p) => p + 1)}>
{t('admin.next')}
</Button>
</div>
</div>
</>
)}
{managing && <UserManageDialog user={managing} open={!!managing} onOpenChange={(open) => !open && setManaging(null)} />}
</div>
)
}