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 type { ApiError } from './types'
let accessToken: string | null = null
let refreshInFlight: Promise<boolean> | null = null
let onUnauthorized: (() => void) | null = null
export function setAccessToken(token: string | null) {
accessToken = token
}
export function getAccessToken() {
return accessToken
}
/** Вызывается, когда refresh-токен недействителен — обычно очищает стор авторизации и шлёт на /login. */
export function setUnauthorizedHandler(handler: (() => void) | null) {
onUnauthorized = handler
}
type RequestOptions = {
method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'
body?: unknown
/** Не пытаться освежить токен на 401 (используется самим refresh-запросом, чтобы не зациклиться). */
skipRefresh?: boolean
}
async function refreshAccessToken(): Promise<boolean> {
if (!refreshInFlight) {
refreshInFlight = (async () => {
try {
const response = await fetch('/api/auth/refresh', { method: 'POST', credentials: 'include' })
if (!response.ok) return false
const data = (await response.json()) as { accessToken: string }
setAccessToken(data.accessToken)
return true
} catch {
return false
} finally {
refreshInFlight = null
}
})()
}
return refreshInFlight
}
export class HttpError extends Error implements ApiError {
title: string
detail: string
status: number
constructor(problem: Partial<ApiError>, status: number) {
super(problem.detail ?? problem.title ?? `HTTP ${status}`)
this.title = problem.title ?? 'Error'
this.detail = problem.detail ?? this.message
this.status = status
}
}
async function parseError(response: Response): Promise<HttpError> {
try {
const problem = (await response.json()) as Partial<ApiError>
return new HttpError(problem, response.status)
} catch {
return new HttpError({ title: response.statusText }, response.status)
}
}
export async function apiRequest<T>(path: string, options: RequestOptions = {}): Promise<T> {
const headers: Record<string, string> = {}
if (accessToken) headers.Authorization = `Bearer ${accessToken}`
if (options.body !== undefined) headers['Content-Type'] = 'application/json'
const response = await fetch(`/api${path}`, {
method: options.method ?? 'GET',
headers,
credentials: 'include',
body: options.body !== undefined ? JSON.stringify(options.body) : undefined,
})
if (response.status === 401 && !options.skipRefresh) {
const refreshed = await refreshAccessToken()
if (refreshed) return apiRequest<T>(path, { ...options, skipRefresh: true })
onUnauthorized?.()
throw await parseError(response)
}
if (!response.ok) throw await parseError(response)
if (response.status === 204) return undefined as T
const text = await response.text()
return (text ? JSON.parse(text) : undefined) as T
}
File diff suppressed because it is too large Load Diff
+212
View File
@@ -0,0 +1,212 @@
// Типы вручную синхронизированы с DTO бэкенда (см. backend/src/PnvPanel.Application/**).
// TODO: заменить на `pnpm gen:api` (openapi-typescript), когда бэкенд доступен по сети
// (сейчас недоступен локально — Postgres/Docker не подняты, схему /openapi/v1.json взять негде).
export type ApiError = {
title: string
detail: string
status: number
}
export type VpnProtocol = 'Vless' | 'Vmess' | 'Trojan' | 'Shadowsocks'
export type ConfigStatus = 'Active' | 'Disabled' | 'Expired' | 'LimitReached' | 'Revoked'
export type NodeStatus = 'Unknown' | 'Online' | 'Offline'
export type ActivationStatus = 'Pending' | 'Approved' | 'Rejected'
export type OsPlatform = 'IOS' | 'Android' | 'Windows' | 'MacOS' | 'Linux'
export type TelegramLoginStatus = 'Pending' | 'Approved' | 'Rejected' | 'Expired' | 'Consumed'
export type CurrentUser = {
id: string
userName: string
role: string
isActivated: boolean
telegramLinked: boolean
}
export type AuthResponse = {
accessToken: string
expiresAt: string
user: CurrentUser
}
export type RegisterResponse = {
id: string
userName: string
}
export type ActivationRequestDto = {
id: string
comment: string | null
createdAt: string
}
export type ActivationStatusDto = {
isActivated: boolean
pendingRequest: ActivationRequestDto | null
}
export type VpnConfigDto = {
id: string
label: string | null
protocol: VpnProtocol
location: string
deviceLimit: number
usedUpBytes: number
usedDownBytes: number
expiresAt: string | null
status: ConfigStatus
createdAt: string
}
export type AvailableInboundDto = {
inboundId: string
displayName: string
protocol: VpnProtocol
}
export type ConfigLinkDto = {
connectionString: string
subscriptionUrl: string
}
export type MySubscriptionDto = {
subscriptionUrl: string
}
export type GetMyConfigsResult = {
configs: VpnConfigDto[]
maxConfigs: number
}
export type ClientAppDto = {
id: string
name: string
downloadUrl: string
description: string | null
iconUrl: string | null
}
/** GET /api/apps — сгруппировано по ОС на бэкенде; отсутствующая ОС значит "нет приложений". */
export type AppsByOs = Partial<Record<OsPlatform, ClientAppDto[]>>
export type LinkTokenResponse = {
deepLink: string | null
expiresAt: string
}
export type TelegramLoginRequestResponse = {
requestId: string
deepLink: string | null
expiresAt: string
}
export type TelegramLoginStatusResponse = {
status: TelegramLoginStatus
accessToken?: string
expiresAt?: string
user?: CurrentUser
}
export type PagedList<T> = {
items: T[]
total: number
page: number
pageSize: number
}
export type UserSummaryDto = {
id: string
userName: string
role: string
isActivated: boolean
isBlocked: boolean
activatedAt: string | null
}
export type RoleDto = {
id: string
name: string
maxConfigs: number
isSystem: boolean
}
export type ActivationRequestAdminDto = {
id: string
userId: string
userName: string
comment: string | null
status: ActivationStatus
createdAt: string
}
export type NodeDto = {
id: string
name: string
baseAddress: string
username: string
location: string | null
status: NodeStatus
isEnabled: boolean
lastSyncAt: string | null
}
export type NodeProbeResultDto = {
isReachable: boolean
errorMessage: string | null
status: NodeStatus
}
export type SyncNodeResultDto = {
inboundsSynced: number
status: NodeStatus
}
export type InboundDto = {
id: string
nodeId: string
remoteInboundId: string
protocol: VpnProtocol
remark: string
port: number
isPublished: boolean
displayName: string | null
maxClients: number | null
allowedRoleIds: string[]
lastSyncAt: string | null
}
export type AdminAppDto = {
id: string
name: string
downloadUrl: string
operatingSystem: OsPlatform
description: string | null
iconUrl: string | null
sortOrder: number
isEnabled: boolean
}
export type StatsDto = {
totalUsers: number
activatedUsers: number
pendingActivationRequests: number
totalNodes: number
onlineNodes: number
totalConfigs: number
activeConfigs: number
totalUsedUpBytes: number
totalUsedDownBytes: number
}
export type AuditSource = 'Web' | 'Telegram' | 'System'
export type AuditLogDto = {
id: number
actorId: string | null
action: string
targetType: string
targetId: string
metadata: string | null
source: AuditSource
createdAt: string
}
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
+8
View File
@@ -0,0 +1,8 @@
const UNITS = ['Б', 'КБ', 'МБ', 'ГБ', 'ТБ']
export function formatBytes(bytes: number): string {
if (bytes <= 0) return `0 ${UNITS[0]}`
const exponent = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), UNITS.length - 1)
const value = bytes / 1024 ** exponent
return `${value.toFixed(exponent === 0 ? 0 : 1)} ${UNITS[exponent]}`
}
+543
View File
@@ -0,0 +1,543 @@
import i18n from 'i18next'
import { initReactI18next } from 'react-i18next'
const resources = {
ru: {
translation: {
appName: 'PnvPanel',
tagline: 'Self-service портал для VPN-конфигураций',
theme: 'Тема',
language: 'Язык',
light: 'Светлая',
dark: 'Тёмная',
system: 'Системная',
auth: {
loginTitle: 'Вход',
registerTitle: 'Регистрация',
userName: 'Имя пользователя',
password: 'Пароль',
submitLogin: 'Войти',
submitRegister: 'Зарегистрироваться',
noAccount: 'Нет аккаунта?',
haveAccount: 'Уже есть аккаунт?',
goRegister: 'Зарегистрироваться',
goLogin: 'Войти',
loginViaTelegram: 'Войти через Telegram',
invalidCredentials: 'Неверное имя пользователя или пароль.',
duplicateUserName: 'Пользователь с таким именем уже существует.',
genericError: 'Что-то пошло не так. Попробуйте ещё раз.',
userNameHint: 'Латиница, цифры, «_», «.», «-», от 3 до 32 символов.',
passwordHint: 'Не менее 8 символов.',
or: 'или',
telegramBotNotConfigured: 'Telegram-бот не настроен администратором.',
waitingForConfirmation: 'Ожидание подтверждения в Telegram…',
telegramLoginRejected: 'Вход отклонён в Telegram.',
telegramLoginExpired: 'Время ожидания истекло, попробуйте снова.',
},
nav: {
dashboard: 'Мои конфиги',
instructions: 'Инструкции',
settings: 'Настройки',
admin: 'Админка',
logout: 'Выйти',
},
activation: {
title: 'Аккаунт не активирован',
description:
'Чтобы создавать конфиги, дождитесь активации администратором. Можно оставить комментарий к заявке.',
commentLabel: 'Комментарий (необязательно)',
submit: 'Запросить активацию',
pending: 'Заявка на активацию отправлена, ожидайте решения администратора.',
alreadyPending: 'У вас уже есть необработанная заявка на активацию.',
retry: 'Повторить',
},
configs: {
title: 'Мои конфиги',
quota: 'Использовано {{used}} из {{max}}',
quotaUnlimited: 'Использовано {{used}}, без лимита',
empty: 'У вас пока нет конфигов. Создайте первый.',
create: 'Создать конфиг',
selectLocation: 'Выберите локацию',
location: 'Локация',
label: 'Метка (необязательно)',
deviceLimitLabel: 'Лимит устройств (необязательно)',
deviceLimitPlaceholder: 'Без лимита',
noInboundsAvailable: 'Нет доступных локаций для вашей роли.',
created: 'Конфиг создан.',
quotaExceeded: 'Достигнут лимит конфигов для вашей роли.',
showLink: 'Ссылка / QR',
rotate: 'Перевыпустить',
rotated: 'Конфиг перевыпущен.',
revoked: 'Конфиг отозван.',
confirmRevoke: 'Отозвать этот конфиг? Действие необратимо.',
copied: 'Скопировано.',
copyLink: 'Скопировать ссылку',
loadingLink: 'Загрузка ссылки…',
subscriptionLink: 'Ссылка-подписка (для клиента):',
aggregatedSubscription: 'Общая подписка',
aggregatedSubscriptionHint: 'Одна ссылка/QR со всеми активными конфигами — удобно добавить один раз в клиент.',
deviceLimit: '{{count}} устройство',
deviceLimit_few: '{{count}} устройства',
deviceLimit_many: '{{count}} устройств',
deviceLimitUnlimited: 'Без лимита устройств',
status: {
Active: 'Активен',
Disabled: 'Отключён',
Expired: 'Истёк',
LimitReached: 'Лимит исчерпан',
Revoked: 'Отозван',
},
},
instructions: {
title: 'Инструкции по подключению',
intro: 'Как подключиться за три шага — на любом устройстве.',
step1: 'Установите приложение для вашей ОС из списка ниже.',
step2: 'На странице «Мои конфиги» скопируйте ссылку или откройте QR-код нужного конфига.',
step3: 'Импортируйте ссылку или отсканируйте QR в приложении — готово.',
appsTitle: 'Приложения',
noApps: 'Каталог приложений пока пуст.',
os: {
IOS: 'iOS',
Android: 'Android',
Windows: 'Windows',
MacOS: 'macOS',
Linux: 'Linux',
},
},
settings: {
changePassword: 'Сменить пароль',
currentPassword: 'Текущий пароль',
currentPasswordRequired: 'Введите текущий пароль.',
currentPasswordInvalid: 'Неверный текущий пароль.',
newPassword: 'Новый пароль',
passwordChanged: 'Пароль изменён.',
telegramHint: 'Привязка Telegram нужна для входа без пароля и восстановления доступа.',
telegramLinkedStatus: 'Привязан',
link: 'Привязать Telegram',
unlink: 'Отвязать',
confirmUnlink: 'Отвязать Telegram от аккаунта?',
telegramLinked: 'Telegram привязан.',
telegramUnlinked: 'Telegram отвязан.',
waitingForLink: 'Ожидание подтверждения в Telegram…',
deleteAccount: 'Удалить аккаунт',
deleteAccountHint: 'Отзовёт все конфиги и безвозвратно удалит аккаунт.',
confirmDelete: 'Вы уверены? Это действие необратимо.',
confirmDeleteYes: 'Да, удалить',
cancel: 'Отмена',
},
admin: {
prev: 'Назад',
next: 'Вперёд',
tabs: {
overview: 'Обзор',
activation: 'Запросы на активацию',
users: 'Пользователи',
roles: 'Роли',
nodes: 'Ноды',
apps: 'Приложения',
audit: 'Аудит',
},
users: {
searchPlaceholder: 'Поиск по имени пользователя',
userName: 'Имя пользователя',
role: 'Роль',
statusLabel: 'Статус',
manage: 'Управление',
empty: 'Пользователи не найдены.',
total: 'Всего: {{count}}',
status: {
blocked: 'Заблокирован',
active: 'Активен',
pending: 'Не активирован',
},
block: 'Заблокировать',
unblock: 'Разблокировать',
confirmBlock: 'Заблокировать пользователя? Все его конфиги будут отключены.',
blocked: 'Пользователь заблокирован.',
unblocked: 'Пользователь разблокирован.',
roleChanged: 'Роль изменена.',
resetPassword: 'Сбросить пароль',
reset: 'Сбросить',
passwordReset: 'Пароль сброшен.',
configs: 'Конфиги',
},
activation: {
empty: 'Нет ожидающих запросов на активацию.',
approved: 'Пользователь активирован.',
rejected: 'Запрос отклонён.',
approve: 'Активировать',
reject: 'Отклонить',
},
roles: {
create: 'Создать роль',
name: 'Название',
maxConfigs: 'Квота конфигов',
maxConfigsHint: '1 = без лимита.',
system: 'системная',
edit: 'Изменить',
save: 'Сохранить',
delete: 'Удалить',
confirmDelete: 'Удалить роль? Это действие необратимо.',
created: 'Роль создана.',
updated: 'Квота обновлена.',
deleted: 'Роль удалена.',
},
nodes: {
create: 'Добавить ноду',
name: 'Название',
baseAddress: 'Адрес панели',
username: 'Логин',
password: 'Пароль',
location: 'Локация',
empty: 'Ноды не добавлены.',
created: 'Нода добавлена.',
updated: 'Нода обновлена.',
deleted: 'Нода удалена.',
confirmDelete: 'Удалить ноду? Существующие конфиги на ней перестанут синхронизироваться.',
edit: 'Изменить',
delete: 'Удалить',
probe: 'Проверить',
sync: 'Синхронизировать',
probeSuccess: 'Нода доступна.',
probeFailure: 'Нода недоступна: {{message}}',
syncSuccess: 'Синхронизировано инбаундов: {{count}}',
status: {
Unknown: 'Неизвестно',
Online: 'Онлайн',
Offline: 'Офлайн',
},
enabled: 'Включена',
disabled: 'Отключена',
inbounds: 'Инбаунды',
noInbounds: 'Инбаунды не найдены — нажмите «Синхронизировать».',
publish: 'Публикация',
published: 'Опубликован',
unpublished: 'Не опубликован',
publishSaved: 'Настройки публикации сохранены.',
displayName: 'Отображаемое имя',
maxClients: 'Лимит клиентов (необязательно)',
allowedRoles: 'Доступно ролям',
isPublishedLabel: 'Опубликовать инбаунд',
optional: 'необязательно',
},
apps: {
create: 'Добавить приложение',
name: 'Название',
downloadUrl: 'Ссылка на скачивание',
os: 'ОС',
description: 'Описание',
iconUrl: 'Ссылка на иконку',
sortOrder: 'Порядок',
enabled: 'Включено',
disabled: 'отключено',
empty: 'Каталог приложений пуст.',
created: 'Приложение добавлено.',
updated: 'Приложение обновлено.',
deleted: 'Приложение удалено.',
confirmDelete: 'Удалить приложение из каталога?',
},
audit: {
time: 'Время',
action: 'Действие',
target: 'Объект',
source: 'Источник',
empty: 'Журнал аудита пуст.',
},
stats: {
totalUsers: 'Всего пользователей',
activatedUsers: 'Активировано',
pendingActivationRequests: 'Ожидают активации',
totalNodes: 'Всего нод',
onlineNodes: 'Нод онлайн',
totalConfigs: 'Всего конфигов',
activeConfigs: 'Активных конфигов',
totalTraffic: 'Суммарный трафик',
},
},
},
},
en: {
translation: {
appName: 'PnvPanel',
tagline: 'Self-service portal for VPN configurations',
theme: 'Theme',
language: 'Language',
light: 'Light',
dark: 'Dark',
system: 'System',
auth: {
loginTitle: 'Log in',
registerTitle: 'Register',
userName: 'Username',
password: 'Password',
submitLogin: 'Log in',
submitRegister: 'Register',
noAccount: "Don't have an account?",
haveAccount: 'Already have an account?',
goRegister: 'Register',
goLogin: 'Log in',
loginViaTelegram: 'Log in via Telegram',
invalidCredentials: 'Invalid username or password.',
duplicateUserName: 'A user with this name already exists.',
genericError: 'Something went wrong. Please try again.',
userNameHint: 'Latin letters, digits, "_", ".", "-", 3 to 32 characters.',
passwordHint: 'At least 8 characters.',
or: 'or',
telegramBotNotConfigured: 'The Telegram bot has not been configured by the administrator.',
waitingForConfirmation: 'Waiting for confirmation in Telegram…',
telegramLoginRejected: 'Login was rejected in Telegram.',
telegramLoginExpired: 'The request expired, please try again.',
},
nav: {
dashboard: 'My configs',
instructions: 'Instructions',
settings: 'Settings',
admin: 'Admin',
logout: 'Log out',
},
activation: {
title: 'Account not activated',
description:
'Wait for an administrator to activate your account before creating configs. You can leave a comment with your request.',
commentLabel: 'Comment (optional)',
submit: 'Request activation',
pending: 'Activation request sent, waiting for administrator review.',
alreadyPending: 'You already have a pending activation request.',
retry: 'Retry',
},
configs: {
title: 'My configs',
quota: 'Used {{used}} of {{max}}',
quotaUnlimited: 'Used {{used}}, unlimited',
empty: "You don't have any configs yet. Create your first one.",
create: 'Create config',
selectLocation: 'Select location',
location: 'Location',
label: 'Label (optional)',
deviceLimitLabel: 'Device limit (optional)',
deviceLimitPlaceholder: 'Unlimited',
noInboundsAvailable: 'No locations available for your role.',
created: 'Config created.',
quotaExceeded: 'Config quota reached for your role.',
showLink: 'Link / QR',
rotate: 'Rotate',
rotated: 'Config rotated.',
revoked: 'Config revoked.',
confirmRevoke: 'Revoke this config? This cannot be undone.',
copied: 'Copied.',
copyLink: 'Copy link',
loadingLink: 'Loading link…',
subscriptionLink: 'Subscription link (for the client app):',
aggregatedSubscription: 'Aggregated subscription',
aggregatedSubscriptionHint: 'One link/QR with all active configs — add it once to your client.',
deviceLimit: '{{count}} device',
deviceLimit_other: '{{count}} devices',
deviceLimitUnlimited: 'No device limit',
status: {
Active: 'Active',
Disabled: 'Disabled',
Expired: 'Expired',
LimitReached: 'Limit reached',
Revoked: 'Revoked',
},
},
instructions: {
title: 'Connection instructions',
intro: 'Get connected in three steps, on any device.',
step1: 'Install the app for your OS from the list below.',
step2: 'On the "My configs" page, copy the link or open the QR code for the config you want.',
step3: 'Import the link or scan the QR code in the app — done.',
appsTitle: 'Apps',
noApps: 'The app catalog is empty right now.',
os: {
IOS: 'iOS',
Android: 'Android',
Windows: 'Windows',
MacOS: 'macOS',
Linux: 'Linux',
},
},
settings: {
changePassword: 'Change password',
currentPassword: 'Current password',
currentPasswordRequired: 'Enter your current password.',
currentPasswordInvalid: 'Current password is incorrect.',
newPassword: 'New password',
passwordChanged: 'Password changed.',
telegramHint: 'Linking Telegram enables passwordless login and account recovery.',
telegramLinkedStatus: 'Linked',
link: 'Link Telegram',
unlink: 'Unlink',
confirmUnlink: 'Unlink Telegram from your account?',
telegramLinked: 'Telegram linked.',
telegramUnlinked: 'Telegram unlinked.',
waitingForLink: 'Waiting for confirmation in Telegram…',
deleteAccount: 'Delete account',
deleteAccountHint: 'Revokes all configs and permanently deletes your account.',
confirmDelete: 'Are you sure? This cannot be undone.',
confirmDeleteYes: 'Yes, delete',
cancel: 'Cancel',
},
admin: {
prev: 'Previous',
next: 'Next',
tabs: {
overview: 'Overview',
activation: 'Activation requests',
users: 'Users',
roles: 'Roles',
nodes: 'Nodes',
apps: 'Apps',
audit: 'Audit',
},
users: {
searchPlaceholder: 'Search by username',
userName: 'Username',
role: 'Role',
statusLabel: 'Status',
manage: 'Manage',
empty: 'No users found.',
total: 'Total: {{count}}',
status: {
blocked: 'Blocked',
active: 'Active',
pending: 'Not activated',
},
block: 'Block',
unblock: 'Unblock',
confirmBlock: 'Block this user? All their configs will be disabled.',
blocked: 'User blocked.',
unblocked: 'User unblocked.',
roleChanged: 'Role changed.',
resetPassword: 'Reset password',
reset: 'Reset',
passwordReset: 'Password reset.',
configs: 'Configs',
},
activation: {
empty: 'No pending activation requests.',
approved: 'User activated.',
rejected: 'Request rejected.',
approve: 'Approve',
reject: 'Reject',
},
roles: {
create: 'Create role',
name: 'Name',
maxConfigs: 'Config quota',
maxConfigsHint: '1 = unlimited.',
system: 'system',
edit: 'Edit',
save: 'Save',
delete: 'Delete',
confirmDelete: 'Delete this role? This cannot be undone.',
created: 'Role created.',
updated: 'Quota updated.',
deleted: 'Role deleted.',
},
nodes: {
create: 'Add node',
name: 'Name',
baseAddress: 'Panel address',
username: 'Username',
password: 'Password',
location: 'Location',
empty: 'No nodes added yet.',
created: 'Node added.',
updated: 'Node updated.',
deleted: 'Node deleted.',
confirmDelete: 'Delete this node? Existing configs on it will stop syncing.',
edit: 'Edit',
delete: 'Delete',
probe: 'Probe',
sync: 'Sync',
probeSuccess: 'Node is reachable.',
probeFailure: 'Node unreachable: {{message}}',
syncSuccess: 'Synced inbounds: {{count}}',
status: {
Unknown: 'Unknown',
Online: 'Online',
Offline: 'Offline',
},
enabled: 'Enabled',
disabled: 'Disabled',
inbounds: 'Inbounds',
noInbounds: 'No inbounds found — click "Sync".',
publish: 'Publishing',
published: 'Published',
unpublished: 'Not published',
publishSaved: 'Publishing settings saved.',
displayName: 'Display name',
maxClients: 'Client limit (optional)',
allowedRoles: 'Allowed for roles',
isPublishedLabel: 'Publish inbound',
optional: 'optional',
},
apps: {
create: 'Add app',
name: 'Name',
downloadUrl: 'Download link',
os: 'OS',
description: 'Description',
iconUrl: 'Icon URL',
sortOrder: 'Sort order',
enabled: 'Enabled',
disabled: 'disabled',
empty: 'The app catalog is empty.',
created: 'App added.',
updated: 'App updated.',
deleted: 'App deleted.',
confirmDelete: 'Remove this app from the catalog?',
},
audit: {
time: 'Time',
action: 'Action',
target: 'Target',
source: 'Source',
empty: 'The audit log is empty.',
},
stats: {
totalUsers: 'Total users',
activatedUsers: 'Activated',
pendingActivationRequests: 'Pending activation',
totalNodes: 'Total nodes',
onlineNodes: 'Nodes online',
totalConfigs: 'Total configs',
activeConfigs: 'Active configs',
totalTraffic: 'Total traffic',
},
},
},
},
}
const STORAGE_KEY = 'pnv-lang'
const stored = typeof localStorage !== 'undefined' ? localStorage.getItem(STORAGE_KEY) : null
void i18n.use(initReactI18next).init({
resources,
lng: stored ?? 'ru',
fallbackLng: 'ru',
interpolation: { escapeValue: false },
})
export function setLanguage(lng: string) {
localStorage.setItem(STORAGE_KEY, lng)
void i18n.changeLanguage(lng)
}
export default i18n
@@ -0,0 +1,66 @@
import { useEffect } from 'react'
import { useQueryClient } from '@tanstack/react-query'
import { useAuthStore } from '@/features/auth/store'
import type { ConfigStatus, GetMyConfigsResult } from '@/shared/api/types'
import { getConnection, startConnection, stopConnection } from './connection'
type ConfigTrafficUpdated = { configId: string; usedUpBytes: number; usedDownBytes: number }
type ConfigStatusChanged = { configId: string; status: ConfigStatus }
type UserActivated = { userId: string }
/** Живые обновления по SignalR: точечно патчит кэш TanStack Query вместо инвалидации всего списка. */
export function RealtimeProvider({ children }: { children: React.ReactNode }) {
const queryClient = useQueryClient()
const user = useAuthStore((s) => s.user)
useEffect(() => {
if (!user) {
void stopConnection()
return
}
const connection = getConnection()
const onTrafficUpdated = (payload: ConfigTrafficUpdated) => {
queryClient.setQueryData<GetMyConfigsResult>(['my-configs'], (prev) =>
prev
? {
...prev,
configs: prev.configs.map((c) =>
c.id === payload.configId
? { ...c, usedUpBytes: payload.usedUpBytes, usedDownBytes: payload.usedDownBytes }
: c,
),
}
: prev,
)
}
const onStatusChanged = (payload: ConfigStatusChanged) => {
queryClient.setQueryData<GetMyConfigsResult>(['my-configs'], (prev) =>
prev
? { ...prev, configs: prev.configs.map((c) => (c.id === payload.configId ? { ...c, status: payload.status } : c)) }
: prev,
)
}
const onUserActivated = (_payload: UserActivated) => {
void queryClient.invalidateQueries({ queryKey: ['activation-status'] })
void queryClient.invalidateQueries({ queryKey: ['me-poll'] })
}
connection.on('configTrafficUpdated', onTrafficUpdated)
connection.on('configStatusChanged', onStatusChanged)
connection.on('userActivated', onUserActivated)
void startConnection()
return () => {
connection.off('configTrafficUpdated', onTrafficUpdated)
connection.off('configStatusChanged', onStatusChanged)
connection.off('userActivated', onUserActivated)
}
}, [user, queryClient])
return <>{children}</>
}
@@ -0,0 +1,33 @@
import { HubConnectionBuilder, LogLevel, type HubConnection } from '@microsoft/signalr'
import { getAccessToken } from '@/shared/api/client'
let connection: HubConnection | null = null
function createConnection(): HubConnection {
return new HubConnectionBuilder()
.withUrl('/hubs/panel', { accessTokenFactory: () => getAccessToken() ?? '' })
.withAutomaticReconnect()
.configureLogging(LogLevel.Warning)
.build()
}
export function getConnection(): HubConnection {
connection ??= createConnection()
return connection
}
export async function startConnection() {
const conn = getConnection()
if (conn.state !== 'Disconnected') return
try {
await conn.start()
} catch {
// Автопереподключение (withAutomaticReconnect) не запускается после неудачного первого
// start() — это ожидаемо при недоступном бэкенде, UI продолжает работать через обычный REST.
}
}
export async function stopConnection() {
if (!connection) return
await connection.stop()
}
+22
View File
@@ -0,0 +1,22 @@
import { cva, type VariantProps } from 'class-variance-authority'
import { type HTMLAttributes } from 'react'
import { cn } from '@/shared/lib/cn'
const badgeVariants = cva('inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-medium', {
variants: {
variant: {
default: 'border-transparent bg-primary text-primary-foreground',
outline: 'border-border text-foreground',
success: 'border-transparent bg-emerald-900/50 text-emerald-300',
warning: 'border-transparent bg-amber-900/50 text-amber-300',
destructive: 'border-transparent bg-red-900/50 text-red-300',
},
},
defaultVariants: { variant: 'default' },
})
export type BadgeProps = HTMLAttributes<HTMLSpanElement> & VariantProps<typeof badgeVariants>
export function Badge({ className, variant, ...props }: BadgeProps) {
return <span className={cn(badgeVariants({ variant }), className)} {...props} />
}
+37
View File
@@ -0,0 +1,37 @@
import { Slot } from '@radix-ui/react-slot'
import { cva, type VariantProps } from 'class-variance-authority'
import { type ButtonHTMLAttributes, forwardRef } from 'react'
import { cn } from '@/shared/lib/cn'
export const buttonVariants = cva(
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors disabled:pointer-events-none disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50',
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground hover:opacity-90',
outline: 'border border-border bg-transparent hover:bg-muted',
ghost: 'hover:bg-muted',
destructive: 'bg-red-600 text-white hover:bg-red-700',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default: 'h-10 px-4 py-2',
sm: 'h-9 rounded-md px-3',
lg: 'h-11 rounded-md px-8',
icon: 'h-10 w-10',
},
},
defaultVariants: { variant: 'default', size: 'default' },
},
)
export type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> &
VariantProps<typeof buttonVariants> & { asChild?: boolean }
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild, ...props }, ref) => {
const Comp = asChild ? Slot : 'button'
return <Comp className={cn(buttonVariants({ variant, size }), className)} ref={ref} {...props} />
},
)
Button.displayName = 'Button'
+34
View File
@@ -0,0 +1,34 @@
import { type HTMLAttributes, forwardRef } from 'react'
import { cn } from '@/shared/lib/cn'
export const Card = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(({ className, ...props }, ref) => (
<div ref={ref} className={cn('rounded-lg border border-border bg-background shadow-sm', className)} {...props} />
))
Card.displayName = 'Card'
export const CardHeader = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(({ className, ...props }, ref) => (
<div ref={ref} className={cn('flex flex-col gap-1.5 p-6', className)} {...props} />
))
CardHeader.displayName = 'CardHeader'
export const CardTitle = forwardRef<HTMLParagraphElement, HTMLAttributes<HTMLHeadingElement>>(
({ className, ...props }, ref) => (
<h3 ref={ref} className={cn('text-xl font-semibold tracking-tight', className)} {...props} />
),
)
CardTitle.displayName = 'CardTitle'
export const CardDescription = forwardRef<HTMLParagraphElement, HTMLAttributes<HTMLParagraphElement>>(
({ className, ...props }, ref) => <p ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} />,
)
CardDescription.displayName = 'CardDescription'
export const CardContent = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(({ className, ...props }, ref) => (
<div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
))
CardContent.displayName = 'CardContent'
export const CardFooter = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(({ className, ...props }, ref) => (
<div ref={ref} className={cn('flex items-center p-6 pt-0', className)} {...props} />
))
CardFooter.displayName = 'CardFooter'
+33
View File
@@ -0,0 +1,33 @@
import * as DialogPrimitive from '@radix-ui/react-dialog'
import { X } from 'lucide-react'
import { cn } from '@/shared/lib/cn'
export const Dialog = DialogPrimitive.Root
export const DialogTrigger = DialogPrimitive.Trigger
export function DialogContent({ className, children, ...props }: DialogPrimitive.DialogContentProps) {
return (
<DialogPrimitive.Portal>
<DialogPrimitive.Overlay className="fixed inset-0 z-50 bg-black/60" />
<DialogPrimitive.Content
className={cn(
'fixed left-1/2 top-1/2 z-50 w-full max-w-md -translate-x-1/2 -translate-y-1/2 rounded-lg border border-border bg-background p-6 shadow-lg',
className,
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 text-muted-foreground hover:text-foreground">
<X className="h-4 w-4" />
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPrimitive.Portal>
)
}
export function DialogHeader({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return <div className={cn('mb-4 flex flex-col gap-1', className)} {...props} />
}
export const DialogTitle = DialogPrimitive.Title
export const DialogDescription = DialogPrimitive.Description
+17
View File
@@ -0,0 +1,17 @@
import { type InputHTMLAttributes, forwardRef } from 'react'
import { cn } from '@/shared/lib/cn'
export const Input = forwardRef<HTMLInputElement, InputHTMLAttributes<HTMLInputElement>>(
({ className, type, ...props }, ref) => (
<input
type={type}
className={cn(
'flex h-10 w-full rounded-md border border-border bg-transparent px-3 py-2 text-sm outline-none transition-colors placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-primary/50 disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
ref={ref}
{...props}
/>
),
)
Input.displayName = 'Input'
+15
View File
@@ -0,0 +1,15 @@
import * as LabelPrimitive from '@radix-ui/react-label'
import { forwardRef } from 'react'
import { cn } from '@/shared/lib/cn'
export const Label = forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn('text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70', className)}
{...props}
/>
))
Label.displayName = LabelPrimitive.Root.displayName
+10
View File
@@ -0,0 +1,10 @@
import { cn } from '@/shared/lib/cn'
export function Progress({ value, className }: { value: number; className?: string }) {
const clamped = Math.min(100, Math.max(0, value))
return (
<div className={cn('h-2 w-full overflow-hidden rounded-full bg-muted', className)}>
<div className="h-full rounded-full bg-primary transition-all" style={{ width: `${clamped}%` }} />
</div>
)
}
+70
View File
@@ -0,0 +1,70 @@
import * as SelectPrimitive from '@radix-ui/react-select'
import { Check, ChevronDown } from 'lucide-react'
import { forwardRef } from 'react'
import { cn } from '@/shared/lib/cn'
export const Select = SelectPrimitive.Root
export const SelectValue = SelectPrimitive.Value
export const SelectTrigger = forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
'flex h-10 w-full items-center justify-between rounded-md border border-border bg-transparent px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-primary/50 disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
))
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
export const SelectContent = forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
'z-50 max-h-64 min-w-[8rem] overflow-y-auto rounded-md border border-border bg-background shadow-lg',
className,
)}
position="popper"
sideOffset={4}
{...props}
>
<SelectPrimitive.Viewport className="p-1">{children}</SelectPrimitive.Viewport>
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
))
SelectContent.displayName = SelectPrimitive.Content.displayName
export const SelectItem = forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
'relative flex cursor-pointer select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none data-[highlighted]:bg-muted',
className,
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
))
SelectItem.displayName = SelectPrimitive.Item.displayName
+43
View File
@@ -0,0 +1,43 @@
import { createContext, useCallback, useContext, useState, type ReactNode } from 'react'
export type ToastVariant = 'default' | 'success' | 'error'
export type ToastItem = { id: number; message: string; variant: ToastVariant }
let nextId = 1
let pushImpl: ((message: string, variant: ToastVariant) => void) | null = null
type ToastContextValue = {
toasts: ToastItem[]
dismiss: (id: number) => void
}
const ToastContext = createContext<ToastContextValue | null>(null)
export function ToastProvider({ children }: { children: ReactNode }) {
const [toasts, setToasts] = useState<ToastItem[]>([])
const push = useCallback((message: string, variant: ToastVariant) => {
setToasts((prev) => [...prev, { id: nextId++, message, variant }])
}, [])
const dismiss = useCallback((id: number) => {
setToasts((prev) => prev.filter((t) => t.id !== id))
}, [])
pushImpl = push
return <ToastContext value={{ toasts, dismiss }}>{children}</ToastContext>
}
export function useToastContext() {
const ctx = useContext(ToastContext)
if (!ctx) throw new Error('useToastContext must be used within ToastProvider')
return ctx
}
/** Императивный вызов из любого места (не только компонентов) — как sonner/react-hot-toast. */
export const toast = {
success: (message: string) => pushImpl?.(message, 'success'),
error: (message: string) => pushImpl?.(message, 'error'),
message: (message: string) => pushImpl?.(message, 'default'),
}
+46
View File
@@ -0,0 +1,46 @@
import { useEffect } from 'react'
import { cn } from '@/shared/lib/cn'
import { useToastContext, type ToastItem } from './toast-store'
const AUTO_DISMISS_MS = 4000
function ToastCard({ toast, dismiss }: { toast: ToastItem; dismiss: (id: number) => void }) {
useEffect(() => {
const timer = setTimeout(() => dismiss(toast.id), AUTO_DISMISS_MS)
return () => clearTimeout(timer)
}, [toast.id, dismiss])
return (
<div
role="status"
className={cn(
'pointer-events-auto flex items-start gap-2 rounded-md border px-4 py-3 text-sm shadow-lg',
toast.variant === 'error' && 'border-red-900/50 bg-red-950 text-red-200',
toast.variant === 'success' && 'border-emerald-900/50 bg-emerald-950 text-emerald-200',
toast.variant === 'default' && 'border-border bg-muted text-foreground',
)}
>
<span className="flex-1">{toast.message}</span>
<button
type="button"
aria-label="Close"
className="text-current opacity-60 hover:opacity-100"
onClick={() => dismiss(toast.id)}
>
×
</button>
</div>
)
}
export function Toaster() {
const { toasts, dismiss } = useToastContext()
return (
<div className="pointer-events-none fixed right-4 top-4 z-[999999] flex w-full max-w-sm flex-col gap-2">
{toasts.map((t) => (
<ToastCard key={t.id} toast={t} dismiss={dismiss} />
))}
</div>
)
}