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
+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