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,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
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from 'clsx'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
@@ -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]}`
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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} />
|
||||
}
|
||||
@@ -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'
|
||||
@@ -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'
|
||||
@@ -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
|
||||
@@ -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'
|
||||
@@ -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
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
@@ -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'),
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user