Implement rate limiting and enhance authentication flow
- Added rate limiting configuration for authentication endpoints, allowing customizable request limits via environment variables. - Updated authentication flow to utilize HttpRequest for cookie management, ensuring secure handling of refresh tokens. - Introduced a new endpoint to retrieve user subscription details. - Enhanced the handling of Telegram bot token validation to prevent errors with empty tokens. - Updated the application to serialize enums as strings for better documentation and compatibility with TypeScript. - Improved test coverage for new features and adjustments in command handlers.
This commit is contained in:
@@ -0,0 +1,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
|
||||
Reference in New Issue
Block a user