Implement role and user management enhancements
- Added MaxIpLimit to roles, allowing for the configuration of simultaneous IP limits for users. - Updated role creation and update commands to include MaxIpLimit, ensuring proper handling in the application logic. - Enhanced user management by introducing a DELETE endpoint for user accounts, with appropriate checks to prevent self-deletion. - Updated documentation to reflect changes in role and user management, clarifying the new IP limit functionality and user deletion process. - Adjusted related tests to cover new functionality and ensure robust validation of role and user management features.
This commit is contained in:
@@ -29,6 +29,8 @@ AdminSeed__Password=change-me-strong-admin-password
|
|||||||
# ── Роли по умолчанию ─────────────────────────────────────────────────────
|
# ── Роли по умолчанию ─────────────────────────────────────────────────────
|
||||||
# Квота конфигов для системной роли "user" (выдаётся при регистрации).
|
# Квота конфигов для системной роли "user" (выдаётся при регистрации).
|
||||||
Roles__DefaultUserMaxConfigs=3
|
Roles__DefaultUserMaxConfigs=3
|
||||||
|
# Лимит одновременных IP на клиента (limitIp в 3x-ui) для системной роли "user"; -1 = без лимита.
|
||||||
|
Roles__DefaultUserMaxIpLimit=2
|
||||||
|
|
||||||
# ── Rate limiting ────────────────────────────────────────────────────────
|
# ── Rate limiting ────────────────────────────────────────────────────────
|
||||||
# Лимит запросов/мин на auth-эндпоинты (login/register/refresh/telegram/subscription). По умолчанию 20.
|
# Лимит запросов/мин на auth-эндпоинты (login/register/refresh/telegram/subscription). По умолчанию 20.
|
||||||
|
|||||||
@@ -4,151 +4,129 @@
|
|||||||
|
|
||||||
## Что это
|
## Что это
|
||||||
|
|
||||||
**PnvPanel** — self-service портал для VPN-конфигураций. Пользователи сами создают себе конфиги
|
**PnvPanel** — self-service портал для VPN-конфигураций (VLESS/VMess/Trojan/Shadowsocks): пользователи
|
||||||
(VLESS/VMess/Trojan/Shadowsocks), админ управляет серверами и пользователями. Есть **Telegram-бот**
|
сами создают конфиги, админ управляет серверами и пользователями. Есть **Telegram-бот** (ссылка на
|
||||||
(ссылка на сайт, просмотр конфигов, passwordless-вход через привязку Telegram). Бэкенд оркестрирует
|
сайт, просмотр конфигов, passwordless-вход). Бэкенд оркестрирует панели **3x-ui** через
|
||||||
панели **3x-ui** через библиотеку [`ThreeXui.Net`](https://github.com/mrleo1nid/ThreeXui.Net) и
|
[`ThreeXui.Net`](https://github.com/mrleo1nid/ThreeXui.Net) и хранит проекцию домена в PostgreSQL.
|
||||||
хранит свою проекцию домена в PostgreSQL. Живые обновления — по SignalR. Приложение (фронт + бек +
|
Живые обновления — SignalR. Поставка — **единый Docker-образ** (фронт+бек+бот) + PostgreSQL в compose.
|
||||||
бот) поставляется **единым Docker-образом**; PostgreSQL — отдельным контейнером в compose.
|
|
||||||
|
|
||||||
> Бэкенд и фронтенд полностью собраны и покрыты тестами (134 бэкенд-теста), единый Docker-образ и
|
> Собрано и покрыто тестами, единый образ и compose-стек проверены живьём. Осознанно не реализовано:
|
||||||
> docker-compose стек проверены живьём. Осознанно не реализовано: тарифы, лимиты трафика/срока на
|
> тарифы, лимиты трафика/срока на конфиг, полное самообслуживание в боте — см. [tech-stack.md](docs/tech-stack.md).
|
||||||
> конфиг, полное самообслуживание в боте — см. [tech-stack.md](docs/tech-stack.md).
|
|
||||||
|
|
||||||
## Документация (single source of truth)
|
## Документация (single source of truth)
|
||||||
|
|
||||||
Прежде чем менять архитектуру или добавлять фичу — свериться с [`docs/`](docs/README.md):
|
Прежде чем менять архитектуру или добавлять фичу — свериться с [`docs/`](docs/README.md):
|
||||||
|
[Vision](docs/vision.md) · [Architecture](docs/architecture.md) · [Domain Model](docs/domain-model.md) ·
|
||||||
- [Vision](docs/vision.md) · [Architecture](docs/architecture.md) · [Domain Model](docs/domain-model.md)
|
[Tech Stack](docs/tech-stack.md) · [Backend Conventions](docs/backend-conventions.md) ·
|
||||||
- [Tech Stack](docs/tech-stack.md) · [Backend Conventions](docs/backend-conventions.md)
|
[Frontend](docs/frontend.md) · [Telegram Bot](docs/telegram-bot.md) · [API Design](docs/api-design.md)
|
||||||
- [Frontend](docs/frontend.md) · [Telegram Bot](docs/telegram-bot.md) · [API Design](docs/api-design.md)
|
|
||||||
|
|
||||||
**Держи доки в синхроне с кодом.** Меняешь контракт/архитектуру — обнови соответствующий док в том же изменении.
|
**Держи доки в синхроне с кодом.** Меняешь контракт/архитектуру — обнови соответствующий док в том же изменении.
|
||||||
|
|
||||||
## Стек
|
## Стек
|
||||||
|
|
||||||
- **Backend**: C# / .NET 10, ASP.NET Core Web API, Clean Architecture, CQRS (**собственный тонкий
|
- **Backend**: C# / .NET 10, ASP.NET Core Web API, Clean Architecture, CQRS (**собственный тонкий
|
||||||
диспетчер**, без MediatR), EF Core 10 + Npgsql (PostgreSQL), ASP.NET Core Identity + JWT, SignalR,
|
диспетчер**, без MediatR), EF Core 10 + Npgsql, ASP.NET Core Identity + JWT, SignalR, FluentValidation,
|
||||||
FluentValidation, **Serilog** (логирование). Маппинг DTO — вручную (`FromDomain(...)`), Mapster в
|
Serilog. Маппинг DTO вручную (`FromDomain(...)`, без Mapster). OpenAPI — нативный
|
||||||
проект не попал. OpenAPI — нативный `Microsoft.AspNetCore.OpenApi` + Scalar UI, без Swashbuckle.
|
`Microsoft.AspNetCore.OpenApi` + Scalar UI (без Swashbuckle).
|
||||||
- **Frontend**: React 19 + Vite + TypeScript, TanStack Query/Router, shadcn-стиль поверх Radix +
|
- **Frontend**: React 19 + Vite + TS, TanStack Query/Router, shadcn-стиль поверх Radix + Tailwind v4,
|
||||||
Tailwind CSS v4, Zustand (только auth-стор), react-hook-form + zod, @microsoft/signalr. Пакетный
|
Zustand (только auth), react-hook-form + zod, @microsoft/signalr. pnpm, oxlint.
|
||||||
менеджер — pnpm, линтер — oxlint. `recharts`/`@tanstack/react-table` установлены, но не
|
- **Telegram**: Telegram.Bot как `BackgroundService` **в процессе Api** (long polling).
|
||||||
используются (статистика — карточками, таблицы — руками).
|
- **Инфра**: единый Docker-образ (API+бот+статика SPA) + PostgreSQL в docker-compose.
|
||||||
- **Telegram**: Telegram.Bot, бот как `BackgroundService` **в процессе Api** (long polling).
|
|
||||||
- **Инфра**: единый Docker-образ (API + бот + статика SPA) + PostgreSQL в docker-compose.
|
|
||||||
|
|
||||||
## Архитектура — жёсткие правила
|
## Архитектура — жёсткие правила
|
||||||
|
|
||||||
Слои и направление зависимостей: **Api → Infrastructure → Application → Domain** (внутрь).
|
Слои и направление зависимостей: **Api → Infrastructure → Application → Domain** (внутрь).
|
||||||
|
|
||||||
- **Domain** — без внешних зависимостей. Rich model: инварианты в сущностях (приватные сеттеры,
|
- **Domain** — без внешних зависимостей. Rich model: инварианты в сущностях (приватные сеттеры,
|
||||||
фабричные методы, поведенческие методы). Никаких EF/HTTP/Identity здесь.
|
фабричные/поведенческие методы). Никаких EF/HTTP/Identity.
|
||||||
- **Application** — CQRS-хендлеры, DTO, валидаторы, **порты** (интерфейсы). Зависит только от Domain.
|
- **Application** — CQRS-хендлеры, DTO, валидаторы, **порты** (интерфейсы). Зависит только от Domain —
|
||||||
Никаких `Npgsql`/`SignalR`/`ThreeXui.Net` — только их интерфейсы (`IAppDbContext`, `IXuiPanelGateway`,
|
никаких `Npgsql`/`SignalR`/`ThreeXui.Net`, только их интерфейсы (`IAppDbContext`, `IXuiPanelGateway`,
|
||||||
`IRealtimeNotifier`, `ISecretProtector`, `ICurrentUser`, ...).
|
`IRealtimeNotifier`, `ISecretProtector`, `ICurrentUser`, ...).
|
||||||
- **Infrastructure** — реализации портов: EF Core, Identity/JWT, `XuiPanelGateway`, SignalR-пуш,
|
- **Infrastructure** — реализации портов: EF Core, Identity/JWT, `XuiPanelGateway`, SignalR-пуш,
|
||||||
фоновые сервисы, шифрование секретов.
|
фоновые сервисы, шифрование секретов.
|
||||||
- **Api** — Minimal API эндпоинты (по фичам), SignalR-хабы, middleware, DI composition root.
|
- **Api** — Minimal API эндпоинты (по фичам), SignalR-хабы, middleware, DI composition root.
|
||||||
|
|
||||||
Обязательно:
|
Обязательно:
|
||||||
- CQRS: команды меняют состояние и идут в транзакции (UnitOfWorkBehavior); запросы только читают
|
- Команды меняют состояние в транзакции (`UnitOfWorkBehavior`); запросы только читают (`AsNoTracking` +
|
||||||
(`AsNoTracking` + проекция в DTO). Диспетчер — **собственный** (`ISender`/`ICommandHandler`/
|
проекция в DTO). Диспетчер — **собственный** (`ISender`/`ICommandHandler`/`IQueryHandler`, DI-регистрация).
|
||||||
`IQueryHandler`, регистрация хендлеров через DI), без внешних CQRS-библиотек.
|
- Управляемые ошибки — через `Result<T>`, не исключениями (исключения только для исключительного).
|
||||||
- Управляемые ошибки — через `Result<T>`, не исключениями. Исключения — только для исключительного.
|
|
||||||
- Валидация — FluentValidation через `ValidationBehavior`; хендлер не перепроверяет формат ввода.
|
- Валидация — FluentValidation через `ValidationBehavior`; хендлер не перепроверяет формат ввода.
|
||||||
- Всё I/O асинхронно, `CancellationToken` пробрасывается до EF/HTTP. Никаких `.Result`/`.Wait()`.
|
- Всё I/O асинхронно, `CancellationToken` пробрасывается до EF/HTTP. Никаких `.Result`/`.Wait()`.
|
||||||
- Nullable reference types включены; предупреждения анализаторов не игнорировать.
|
- Nullable reference types включены; предупреждения анализаторов не игнорировать.
|
||||||
|
|
||||||
## Интеграция с 3x-ui
|
## Интеграция с 3x-ui
|
||||||
|
|
||||||
- Только через порт `IXuiPanelGateway`. `ThreeXui.Net` регистрируется на один `BaseAddress`, а нод
|
- Только через порт `IXuiPanelGateway`. Один `BaseAddress` в `ThreeXui.Net`, а нод много → гейтвей
|
||||||
много → гейтвей держит **клиента per-node** (кэш по `NodeId`), создавая его из расшифрованных
|
держит **клиента per-node** (кэш по `NodeId`) из расшифрованных `NodeCredentials`. Детали —
|
||||||
`NodeCredentials`. Детали — в [architecture.md](docs/architecture.md#интеграция-с-3x-ui-threexuinet).
|
[architecture.md](docs/architecture.md#интеграция-с-3x-ui-threexuinet).
|
||||||
- Пароли нод **шифруются at-rest** (`ISecretProtector`), расшифровка только внутри Infrastructure,
|
- Пароли нод **шифруются at-rest** (`ISecretProtector`), расшифровка только в Infrastructure, никогда в логах/ответах.
|
||||||
никогда не в логах/ответах API.
|
|
||||||
- Недоступность ноды → `Result.Failure`/`NodeStatus.Offline`, не 500 наружу.
|
- Недоступность ноды → `Result.Failure`/`NodeStatus.Offline`, не 500 наружу.
|
||||||
- Операции с 3x-ui идемпотентны; при частичном сбое (клиент создан в панели, но упала БД) — компенсация.
|
- Операции идемпотентны; при частичном сбое (клиент создан в панели, упала БД) — компенсация.
|
||||||
|
|
||||||
## Роли, активация, сидинг
|
## Домен: роли, активация, конфиги
|
||||||
|
|
||||||
- **Роли динамические**: `AppRole : IdentityRole<Guid>` + поле `MaxConfigs` (квота на число конфигов).
|
Полная модель — [domain-model.md](docs/domain-model.md). Ключевые инварианты:
|
||||||
Квота — **на роли, а не на `Plan`**. **У пользователя ровно одна роль**; квота = `MaxConfigs` его
|
|
||||||
роли (`admin` — без лимита). Системные роли (`admin`/`user`) не удалять/переименовывать.
|
- **Роли динамические** (`AppRole`), квоты на роли (не на `Plan`): `MaxConfigs` (число конфигов),
|
||||||
- **Активация**: новый пользователь `IsActivated = false`, роль `user`. Конфиги может создавать
|
`MaxIpLimit` (лимит одновременных IP клиента в 3x-ui, `limitIp`); -1 = без лимита. У пользователя
|
||||||
только активированный. `ActivationRequest` (с комментарием заявителя) одобряет админ на сайте
|
ровно одна роль; `admin` — без лимитов. Системные роли `admin`/`user` не удалять/переименовывать.
|
||||||
**или** в Telegram — одними и теми же командами (`ApproveActivationCommand`/`RejectActivationCommand`).
|
Понижение роли — грандфазеринг (лишние конфиги не отзываются, новые блокируются до входа в квоту).
|
||||||
- **Инбаунды по ролям**: `Inbound.AllowedRoles` (M:N). При создании конфига доменный инвариант
|
- **`limitIp`** выставляется автоматически по `MaxIpLimit` роли при создании клиента (`Create`/`Rotate`);
|
||||||
проверяет: активирован + под квотой роли + роль входит в `AllowedRoles` инбаунда + нода включена.
|
панель не даёт настраивать его per-конфиг и не трогает уже созданных клиентов при смене роли/квоты.
|
||||||
Проверку квоты делать **в транзакции** (гонки параллельных созданий).
|
- **Активация**: новый пользователь `IsActivated=false`, роль `user`; конфиги создаёт только
|
||||||
- **Понижение роли — грандфазеринг**: смена на меньшую квоту разрешена; лишние конфиги не отзываем,
|
активированный. `ActivationRequest` одобряет админ на сайте или в Telegram — одними командами.
|
||||||
но новые нельзя до входа в квоту.
|
- **Инбаунды по ролям** (`Inbound.AllowedRoles`, M:N): создание конфига проверяет активацию + квоту роли
|
||||||
- **Блокировка** (`AppUser.IsBlocked`): вход запрещён + все конфиги `Disabled` (отключить клиентов
|
(в транзакции — гонки параллельных созданий) + `AllowedRoles` + включённость ноды.
|
||||||
в 3x-ui); разблокировка — обратно. Действие в `AuditLog`.
|
- **Блокировка** (`AppUser.IsBlocked`): вход запрещён + все конфиги `Disabled` в 3x-ui; в `AuditLog`.
|
||||||
- **Аудит**: значимые действия (активация, блок, смена роли, отзыв, ноды/инбаунды) писать в `AuditLog`
|
- **Удаление пользователя** — свой аккаунт (`DELETE /api/auth/me`) или админом
|
||||||
|
(`DELETE /api/admin/users/{id}`, себя удалить нельзя): отзыв всех конфигов в 3x-ui, затем `AppUser`;
|
||||||
|
админский путь дополнительно пишет `AuditLog` (`UserDeleted`) и шлёт Telegram-DM.
|
||||||
|
- **Аудит**: значимые действия (активация/блок/роль/отзыв/ноды/инбаунды/удаление) — `AuditLog`
|
||||||
(append-only, источник Web/Telegram/System).
|
(append-only, источник Web/Telegram/System).
|
||||||
- **Подписка**: агрегированная на юзера (`AppUser.SubscriptionToken`, все активные конфиги) + по конфигу.
|
- **Ротация конфига** (`Rotate()`) — новый UUID/ссылка, квоту не тратит. **Бот read-only** по конфигам.
|
||||||
- **Ротация конфига** (`Rotate()`): новый UUID/ссылка, квоту не тратит. **Бот — read-only** по конфигам.
|
- **Подписка**: агрегированная (`AppUser.SubscriptionToken`) + по конфигу. API без версионирования
|
||||||
- **Конфиг**: пользователь задаёт метку (`Label`), может редактировать. Лимит устройств (`limitIp` в 3x-ui) панелью не управляется — более сложная настройка, задаётся при необходимости администратором ноды напрямую в 3x-ui.
|
(`/api`, без `v1`); подписка отдаёт `Subscription-Userinfo`.
|
||||||
- **Самоудаление аккаунта** (`DELETE /api/auth/me`): отзыв всех конфигов + удаление данных, аудит анонимизируется.
|
- **Вход по `UserName`** (email не используется). Восстановление пароля — через привязанный Telegram
|
||||||
- **API без версионирования** (`/api` без `v1`). Подписка отдаёт `Subscription-Userinfo`.
|
(self-service) либо сбросом админом (`ResetUserPasswordCommand`); без привязки UI настойчиво предлагает привязать.
|
||||||
- **Тема**: светлая/тёмная/системная (Tailwind `dark`, выбор в localStorage).
|
- **Сидинг**: идемпотентный `DbInitializer` создаёт системные роли + админа из env
|
||||||
- **Инструкции + приложения**: отдельная страница инструкций; каталог `ClientApp` (админ CRUD:
|
(`AdminSeed__Username`/`Password`); каталог `ClientApp` — из [`seed/client-apps.json`](seed/client-apps.json).
|
||||||
название/ссылка/ОС/порядок/вкл), пользователю `GET /api/apps` отдаётся сгруппированным по ОС.
|
Источник примера env — [`.env.example`](.env.example), обновляй при новых настройках. Секреты — только через env.
|
||||||
- **Вход — по `UserName`** (email в системе не используется вовсе; SMTP не нужен).
|
`Telegram__AdminTelegramUserIds` — отдельно от сидинга, не пишется в БД, читается напрямую из `TelegramOptions`.
|
||||||
Восстановление пароля: через привязанный Telegram (self-service), без привязки — сброс админом
|
|
||||||
(`ResetUserPasswordCommand`). Пока Telegram не привязан — UI настойчиво предлагает его привязать.
|
|
||||||
- **Сидинг из env**: идемпотентный `DbInitializer` на старте создаёт системные роли и учётку админа
|
|
||||||
(`AdminSeed__Username`/`AdminSeed__Password`) из переменных окружения; каталог приложений `ClientApp`
|
|
||||||
(если пуст) — из [`seed/client-apps.json`](seed/client-apps.json). Единый источник примера env —
|
|
||||||
[`.env.example`](.env.example); при добавлении новой настройки обновляй и его. Секреты (пароль
|
|
||||||
админа, JWT-ключ, `Telegram__BotToken`) — только через env/secret-store.
|
|
||||||
- Telegram id админов — **отдельно от сидинга**, `Telegram__AdminTelegramUserIds` (через запятую),
|
|
||||||
читается `TelegramOptions` напрямую при каждой проверке, не пишется в БД. Именно он авторизует
|
|
||||||
админ-кнопки в боте и адресует уведомления о запросах активации. Seed-админ **не** привязывается к
|
|
||||||
Telegram автоматически — привязка делается вручную в UI, как у любого пользователя.
|
|
||||||
|
|
||||||
## Telegram-бот
|
## Telegram-бот
|
||||||
|
|
||||||
- Бот — **presentation-адаптер**, не бизнес-слой. Хостится в процессе Api (`TelegramBotHostedService`,
|
Детали флоу — [telegram-bot.md](docs/telegram-bot.md).
|
||||||
long polling). Хендлеры апдейтов вызывают **те же** CQRS-команды/запросы через собственный `ISender`
|
|
||||||
(`GetMyConfigsQuery`, `LinkTelegramCommand`, `ApproveTelegramLoginCommand`, ...).
|
- Presentation-адаптер, не бизнес-слой: `TelegramBotHostedService` (long polling) вызывает **те же**
|
||||||
- `Telegram.Bot` не проникает в Application/Domain — только в `Api/Telegram/`.
|
CQRS-команды через `ISender`. `Telegram.Bot` не проникает в Application/Domain — только `Api/Telegram/`.
|
||||||
- Passwordless-вход выпускает **те же** JWT/refresh, что и обычный логин. Требует привязки Telegram —
|
- Passwordless-вход выпускает те же JWT/refresh, что и обычный логин; требует привязки Telegram (с сайта)
|
||||||
либо привязка существующего аккаунта (с сайта), либо регистрация прямо из бота (`RegisterViaTelegramCommand`):
|
либо регистрации прямо из бота (`RegisterViaTelegramCommand` — логин `@username`/id, пароль генерируется
|
||||||
логин — Telegram `@username`, при отсутствии/занятости — Telegram id; пароль генерируется и
|
и приходит в чат один раз). Новый аккаунт — роль `user`, `IsActivated=false`, активация как обычно.
|
||||||
присылается в чат один раз (логин можно сменить позже в Настройках, `ChangeUserNameCommand`).
|
- Токены привязки/входа — короткоживущие одноразовые. `Telegram:BotToken` — секрет, не логировать.
|
||||||
Новый аккаунт получает роль `user` и `IsActivated = false` — активация нужна как обычно.
|
Без токена бот просто не стартует — панель работает и без него.
|
||||||
- Токены привязки/входа: короткоживущие, одноразовые, высокоэнтропийные. `Telegram:BotToken` — секрет,
|
|
||||||
не логировать. Панель должна работать и без бота (если токен не задан — бот просто не стартует).
|
|
||||||
- Детали флоу — [telegram-bot.md](docs/telegram-bot.md).
|
|
||||||
|
|
||||||
## Единый контейнер
|
## Единый контейнер
|
||||||
|
|
||||||
- Один образ приложения: Api раздаёт REST (`/api`), SignalR (`/hubs`), хостит бота **и** статику SPA
|
- Один образ: Api раздаёт REST (`/api`), SignalR (`/hubs`), хостит бота и статику SPA из `wwwroot`
|
||||||
из `wwwroot` (fallback на `index.html`). Фронт и бек — один origin, база API — относительный `/api`.
|
(fallback на `index.html`). Один origin, база API — относительный `/api`.
|
||||||
- Multi-stage Dockerfile: node (сборка фронта) → dotnet sdk (publish + копирование в `wwwroot`) → aspnet runtime.
|
- Multi-stage Dockerfile: node (фронт) → dotnet sdk (publish + копирование в `wwwroot`) → aspnet runtime.
|
||||||
- docker-compose: `app` (единый образ) + `db` (PostgreSQL). В dev — Vite-прокси `/api`,`/hubs` на бэк.
|
- docker-compose: `app` + `db` (PostgreSQL); в dev — Vite-прокси `/api`,`/hubs` на бэк.
|
||||||
- Не вводи отдельный nginx-контейнер для статики без явной просьбы — это ломает требование единого контейнера.
|
- Не вводи отдельный nginx-контейнер для статики без явной просьбы — ломает требование единого контейнера.
|
||||||
- **TLS — внешний** (прокси/шлюз вне compose); `app` отдаёт HTTP + доверяет `X-Forwarded-*` через
|
- **TLS — внешний**; `app` отдаёт HTTP + доверяет `X-Forwarded-*` (`ForwardedHeaders`). Свой nginx/Caddy не добавляй.
|
||||||
`ForwardedHeaders` (иначе Secure-cookie/схема за прокси сломаются). Свой nginx/Caddy не добавляй.
|
- Миграции применяются авто на старте. CI (GitHub Actions) — только build/test, без деплоя.
|
||||||
- **Миграции** применяются авто на старте. **CI** (GitHub Actions) — только build/test, без деплоя.
|
|
||||||
|
|
||||||
## Соглашения по коду
|
## Соглашения по коду
|
||||||
|
|
||||||
Полный список — в [backend-conventions.md](docs/backend-conventions.md). Кратко:
|
Полный список — [backend-conventions.md](docs/backend-conventions.md). Кратко:
|
||||||
|
|
||||||
- Команды `<Verb><Noun>Command`, запросы `<Get/List><Noun>Query`, + `Handler`/`Validator` (валидатор —
|
- `<Verb><Noun>Command`/`<Get|List><Noun>Query` + `Handler`/`Validator` (валидатор — где есть что
|
||||||
не для каждой команды, только где есть что проверить). Application DTO — суффикс `Dto`
|
проверить). DTO — суффикс `Dto`; тела запросов Api — `Body`; тела ответов без Application DTO —
|
||||||
(`FromDomain(...)` конвертирует из сущности); тела запросов Api-слоя — суффикс `Body`; тела ответов,
|
`ResponseDto`. Application — по фичам (feature folders).
|
||||||
которых нет как Application DTO — суффикс `ResponseDto`.
|
- Один публичный тип на файл = имя файла (кроме `Body`/`ResponseDto` в файле эндпоинтов).
|
||||||
- Application организована **по фичам** (feature folders) внутри слоёв.
|
Async-методы — суффикс `Async` + `CancellationToken`.
|
||||||
- Один публичный тип на файл, имя файла = имя типа (кроме вспомогательных `Body`/`ResponseDto`
|
- Секреты не логировать; логи — Serilog. Явного обогащения `UserId`/`NodeId`/`ConfigId`/`CorrelationId`
|
||||||
records — они живут в том же файле, что и класс эндпоинтов). Async-методы — суффикс `Async` + `CancellationToken`.
|
пока нет — не полагайся на него при расследовании.
|
||||||
- Секреты не логировать; логи — Serilog (`UseSerilogRequestLogging` + `Enrich.FromLogContext()`).
|
- Ошибки API — единый `application/problem+json`.
|
||||||
Явного обогащения `UserId`/`NodeId`/`ConfigId`/`CorrelationId` пока нет — не полагайся на него при
|
|
||||||
расследовании, пока не добавлено.
|
|
||||||
- Ошибки API — единый `application/problem+json` (без Swashbuckle — нативный `Microsoft.AspNetCore.OpenApi`).
|
|
||||||
|
|
||||||
## Команды
|
## Команды
|
||||||
|
|
||||||
@@ -177,19 +155,17 @@ docker compose up -d # api + postgres (+ web)
|
|||||||
```
|
```
|
||||||
|
|
||||||
> Окружение: Windows, основная оболочка — **PowerShell**. Для POSIX-скриптов есть Bash-инструмент.
|
> Окружение: Windows, основная оболочка — **PowerShell**. Для POSIX-скриптов есть Bash-инструмент.
|
||||||
> Пути — с учётом Windows.
|
|
||||||
|
|
||||||
## Ключевые решения
|
## Ключевые решения
|
||||||
|
|
||||||
См. [tech-stack.md](docs/tech-stack.md#ключевые-решения-по-домену-и-поведению): CQRS —
|
См. [tech-stack.md](docs/tech-stack.md#ключевые-решения-по-домену-и-поведению): CQRS — собственный
|
||||||
**собственный диспетчер** (не MediatR); **одна роль** на пользователя; секреты нод —
|
диспетчер (не MediatR); одна роль на пользователя; секреты нод — ASP.NET Data Protection; тарифы `Plan`
|
||||||
**ASP.NET Data Protection**; тарифы `Plan` не реализованы (нет лимитов трафика/срока на конфиг);
|
не реализованы; i18n — RU+EN (react-i18next); Telegram — long polling, только привязка (не signup);
|
||||||
i18n — **RU+EN** (react-i18next); Telegram — **long polling**, только **привязка** (не signup);
|
история трафика — простая таблица + TTL; логирование — Serilog.
|
||||||
история трафика — **простая таблица + TTL**; логирование — **Serilog**.
|
|
||||||
|
|
||||||
## Рабочие принципы
|
## Рабочие принципы
|
||||||
|
|
||||||
- Не начинай крупную реализацию без сверки с доками и, при неоднозначности, без вопроса пользователю.
|
- Не начинай крупную реализацию без сверки с доками и, при неоднозначности, без вопроса пользователю.
|
||||||
- Соблюдай границы слоёв — это главный инвариант проекта. Нарушение = ошибка ревью.
|
- Соблюдай границы слоёв — главный инвариант проекта. Нарушение = ошибка ревью.
|
||||||
- Обновляй документацию вместе с кодом. Не коммить и не пуши без явной просьбы.
|
- Обновляй документацию вместе с кодом. Не коммить и не пуши без явной просьбы.
|
||||||
- Отвечай пользователю на русском (язык общения в проекте — русский).
|
- Отвечай пользователю на русском.
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ public static class AdminUserEndpoints
|
|||||||
admin.MapPatch("/users/{id:guid}/block", BlockUser).Produces(StatusCodes.Status204NoContent);
|
admin.MapPatch("/users/{id:guid}/block", BlockUser).Produces(StatusCodes.Status204NoContent);
|
||||||
admin.MapPatch("/users/{id:guid}/unblock", UnblockUser).Produces(StatusCodes.Status204NoContent);
|
admin.MapPatch("/users/{id:guid}/unblock", UnblockUser).Produces(StatusCodes.Status204NoContent);
|
||||||
admin.MapPost("/users/{id:guid}/reset-password", ResetPassword).Produces(StatusCodes.Status204NoContent);
|
admin.MapPost("/users/{id:guid}/reset-password", ResetPassword).Produces(StatusCodes.Status204NoContent);
|
||||||
|
admin.MapDelete("/users/{id:guid}", DeleteUser).Produces(StatusCodes.Status204NoContent);
|
||||||
admin.MapGet("/users/{id:guid}/configs", GetUserConfigs).Produces<IReadOnlyList<VpnConfigDto>>();
|
admin.MapGet("/users/{id:guid}/configs", GetUserConfigs).Produces<IReadOnlyList<VpnConfigDto>>();
|
||||||
admin.MapDelete("/configs/{id:guid}", ForceRevokeConfig).Produces(StatusCodes.Status204NoContent);
|
admin.MapDelete("/configs/{id:guid}", ForceRevokeConfig).Produces(StatusCodes.Status204NoContent);
|
||||||
|
|
||||||
@@ -52,6 +53,12 @@ public static class AdminUserEndpoints
|
|||||||
return result.ToHttpResult();
|
return result.ToHttpResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> DeleteUser(Guid id, ISender sender, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var result = await sender.Send(new DeleteUserCommand(id), cancellationToken);
|
||||||
|
return result.ToHttpResult();
|
||||||
|
}
|
||||||
|
|
||||||
private static async Task<IResult> GetUserConfigs(Guid id, ISender sender, CancellationToken cancellationToken)
|
private static async Task<IResult> GetUserConfigs(Guid id, ISender sender, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var result = await sender.Send(new GetUserConfigsQuery(id), cancellationToken);
|
var result = await sender.Send(new GetUserConfigsQuery(id), cancellationToken);
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ public static class RoleEndpoints
|
|||||||
|
|
||||||
private static async Task<IResult> UpdateRole(Guid id, UpdateRoleBody body, ISender sender, CancellationToken cancellationToken)
|
private static async Task<IResult> UpdateRole(Guid id, UpdateRoleBody body, ISender sender, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var result = await sender.Send(new UpdateRoleCommand(id, body.MaxConfigs), cancellationToken);
|
var result = await sender.Send(new UpdateRoleCommand(id, body.MaxConfigs, body.MaxIpLimit), cancellationToken);
|
||||||
return result.ToHttpResult();
|
return result.ToHttpResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,6 +55,6 @@ public static class RoleEndpoints
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed record UpdateRoleBody(int MaxConfigs);
|
public sealed record UpdateRoleBody(int MaxConfigs, int MaxIpLimit);
|
||||||
|
|
||||||
public sealed record ChangeUserRoleBody(Guid RoleId);
|
public sealed record ChangeUserRoleBody(Guid RoleId);
|
||||||
|
|||||||
@@ -4,4 +4,4 @@ using PnvPanel.Application.Common.Models;
|
|||||||
|
|
||||||
namespace PnvPanel.Application.Admin.Roles;
|
namespace PnvPanel.Application.Admin.Roles;
|
||||||
|
|
||||||
public sealed record CreateRoleCommand(string Name, int MaxConfigs) : ICommand<Result<RoleDto>>;
|
public sealed record CreateRoleCommand(string Name, int MaxConfigs, int MaxIpLimit) : ICommand<Result<RoleDto>>;
|
||||||
|
|||||||
@@ -7,5 +7,5 @@ namespace PnvPanel.Application.Admin.Roles;
|
|||||||
public sealed class CreateRoleCommandHandler(IRoleService roleService) : ICommandHandler<CreateRoleCommand, Result<RoleDto>>
|
public sealed class CreateRoleCommandHandler(IRoleService roleService) : ICommandHandler<CreateRoleCommand, Result<RoleDto>>
|
||||||
{
|
{
|
||||||
public Task<Result<RoleDto>> Handle(CreateRoleCommand command, CancellationToken cancellationToken)
|
public Task<Result<RoleDto>> Handle(CreateRoleCommand command, CancellationToken cancellationToken)
|
||||||
=> roleService.CreateRoleAsync(command.Name, command.MaxConfigs, cancellationToken);
|
=> roleService.CreateRoleAsync(command.Name, command.MaxConfigs, command.MaxIpLimit, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,5 +12,6 @@ public sealed class CreateRoleCommandValidator : AbstractValidator<CreateRoleCom
|
|||||||
.Matches("^[a-zA-Z0-9_-]+$");
|
.Matches("^[a-zA-Z0-9_-]+$");
|
||||||
|
|
||||||
RuleFor(x => x.MaxConfigs).GreaterThanOrEqualTo(-1);
|
RuleFor(x => x.MaxConfigs).GreaterThanOrEqualTo(-1);
|
||||||
|
RuleFor(x => x.MaxIpLimit).GreaterThanOrEqualTo(-1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,4 +4,4 @@ using PnvPanel.Application.Common.Models;
|
|||||||
|
|
||||||
namespace PnvPanel.Application.Admin.Roles;
|
namespace PnvPanel.Application.Admin.Roles;
|
||||||
|
|
||||||
public sealed record UpdateRoleCommand(Guid RoleId, int MaxConfigs) : ICommand<Result<RoleDto>>;
|
public sealed record UpdateRoleCommand(Guid RoleId, int MaxConfigs, int MaxIpLimit) : ICommand<Result<RoleDto>>;
|
||||||
|
|||||||
@@ -7,5 +7,5 @@ namespace PnvPanel.Application.Admin.Roles;
|
|||||||
public sealed class UpdateRoleCommandHandler(IRoleService roleService) : ICommandHandler<UpdateRoleCommand, Result<RoleDto>>
|
public sealed class UpdateRoleCommandHandler(IRoleService roleService) : ICommandHandler<UpdateRoleCommand, Result<RoleDto>>
|
||||||
{
|
{
|
||||||
public Task<Result<RoleDto>> Handle(UpdateRoleCommand command, CancellationToken cancellationToken)
|
public Task<Result<RoleDto>> Handle(UpdateRoleCommand command, CancellationToken cancellationToken)
|
||||||
=> roleService.UpdateRoleAsync(command.RoleId, command.MaxConfigs, cancellationToken);
|
=> roleService.UpdateRoleAsync(command.RoleId, command.MaxConfigs, command.MaxIpLimit, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,5 +7,6 @@ public sealed class UpdateRoleCommandValidator : AbstractValidator<UpdateRoleCom
|
|||||||
public UpdateRoleCommandValidator()
|
public UpdateRoleCommandValidator()
|
||||||
{
|
{
|
||||||
RuleFor(x => x.MaxConfigs).GreaterThanOrEqualTo(-1);
|
RuleFor(x => x.MaxConfigs).GreaterThanOrEqualTo(-1);
|
||||||
|
RuleFor(x => x.MaxIpLimit).GreaterThanOrEqualTo(-1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
using PnvPanel.Application.Common.Messaging;
|
||||||
|
using PnvPanel.Application.Common.Models;
|
||||||
|
|
||||||
|
namespace PnvPanel.Application.Admin.Users;
|
||||||
|
|
||||||
|
public sealed record DeleteUserCommand(Guid UserId) : ICommand<Result>;
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using PnvPanel.Application.Common.Interfaces;
|
||||||
|
using PnvPanel.Application.Common.Messaging;
|
||||||
|
using PnvPanel.Application.Common.Models;
|
||||||
|
using PnvPanel.Domain.Audit;
|
||||||
|
using PnvPanel.Domain.Configs;
|
||||||
|
|
||||||
|
namespace PnvPanel.Application.Admin.Users;
|
||||||
|
|
||||||
|
/// <summary>Удаление пользователя админом: отзывает все его конфиги в 3x-ui, затем удаляет учётку.</summary>
|
||||||
|
public sealed class DeleteUserCommandHandler(
|
||||||
|
IAppDbContext dbContext, IIdentityService identityService, IXuiPanelGateway gateway,
|
||||||
|
ITelegramNotifier telegramNotifier, ICurrentUser currentUser)
|
||||||
|
: ICommandHandler<DeleteUserCommand, Result>
|
||||||
|
{
|
||||||
|
public async Task<Result> Handle(DeleteUserCommand command, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (currentUser.UserId == command.UserId)
|
||||||
|
return Result.Failure(UserErrors.CannotDeleteSelf);
|
||||||
|
|
||||||
|
var configs = await dbContext.VpnConfigs
|
||||||
|
.Where(c => c.UserId == command.UserId && c.Status != ConfigStatus.Revoked)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
foreach (var config in configs)
|
||||||
|
{
|
||||||
|
var inbound = await dbContext.Inbounds.AsNoTracking().FirstOrDefaultAsync(i => i.Id == config.InboundId, cancellationToken);
|
||||||
|
var node = inbound is null
|
||||||
|
? null
|
||||||
|
: await dbContext.Nodes.AsNoTracking().FirstOrDefaultAsync(n => n.Id == inbound.NodeId, cancellationToken);
|
||||||
|
|
||||||
|
if (inbound is not null && node is not null)
|
||||||
|
await gateway.RemoveClientAsync(node, inbound.RemoteInboundId, config.ClientExternalId, config.Protocol, cancellationToken);
|
||||||
|
|
||||||
|
config.Revoke();
|
||||||
|
}
|
||||||
|
|
||||||
|
dbContext.AuditLogs.Add(AuditLog.Create(
|
||||||
|
currentUser.UserId, "UserDeleted", "User", command.UserId.ToString(), metadata: null, AuditSource.Web));
|
||||||
|
|
||||||
|
// Коммитим отзыв конфигов + аудит ДО удаления учётки: UserManager.DeleteAsync ниже удаляет
|
||||||
|
// AppUser отдельным путём (Identity store), после чего NotifyUserAsync уже не найдёт Telegram-привязку.
|
||||||
|
await dbContext.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
await telegramNotifier.NotifyUserAsync(command.UserId, "🗑 Ваш аккаунт удалён администратором.", cancellationToken);
|
||||||
|
|
||||||
|
return await identityService.DeleteUserAsync(command.UserId, cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,4 +5,7 @@ namespace PnvPanel.Application.Admin.Users;
|
|||||||
public static class UserErrors
|
public static class UserErrors
|
||||||
{
|
{
|
||||||
public static readonly Error NotFound = Error.NotFound("Users.NotFound", "Пользователь не найден.");
|
public static readonly Error NotFound = Error.NotFound("Users.NotFound", "Пользователь не найден.");
|
||||||
|
|
||||||
|
public static readonly Error CannotDeleteSelf = Error.Validation(
|
||||||
|
"Users.CannotDeleteSelf", "Нельзя удалить свою учётную запись здесь — используйте удаление аккаунта в Настройках.");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ namespace PnvPanel.Application.Common.Interfaces;
|
|||||||
public sealed record AuthenticatedUser(Guid Id, string UserName, string Role);
|
public sealed record AuthenticatedUser(Guid Id, string UserName, string Role);
|
||||||
|
|
||||||
public sealed record CurrentUserProfile(
|
public sealed record CurrentUserProfile(
|
||||||
Guid Id, string UserName, Guid RoleId, string Role, bool IsActivated, bool IsBlocked, int MaxConfigs, string SubscriptionToken);
|
Guid Id, string UserName, Guid RoleId, string Role, bool IsActivated, bool IsBlocked, int MaxConfigs, int MaxIpLimit,
|
||||||
|
string SubscriptionToken);
|
||||||
|
|
||||||
public sealed record UserSummaryDto(Guid Id, string UserName, string Role, bool IsActivated, bool IsBlocked, DateTimeOffset? ActivatedAt);
|
public sealed record UserSummaryDto(Guid Id, string UserName, string Role, bool IsActivated, bool IsBlocked, DateTimeOffset? ActivatedAt);
|
||||||
|
|
||||||
|
|||||||
@@ -2,13 +2,13 @@ using PnvPanel.Application.Common.Models;
|
|||||||
|
|
||||||
namespace PnvPanel.Application.Common.Interfaces;
|
namespace PnvPanel.Application.Common.Interfaces;
|
||||||
|
|
||||||
public sealed record RoleDto(Guid Id, string Name, int MaxConfigs, bool IsSystem);
|
public sealed record RoleDto(Guid Id, string Name, int MaxConfigs, int MaxIpLimit, bool IsSystem);
|
||||||
|
|
||||||
public interface IRoleService
|
public interface IRoleService
|
||||||
{
|
{
|
||||||
Task<Result<RoleDto>> CreateRoleAsync(string name, int maxConfigs, CancellationToken cancellationToken);
|
Task<Result<RoleDto>> CreateRoleAsync(string name, int maxConfigs, int maxIpLimit, CancellationToken cancellationToken);
|
||||||
|
|
||||||
Task<Result<RoleDto>> UpdateRoleAsync(Guid roleId, int maxConfigs, CancellationToken cancellationToken);
|
Task<Result<RoleDto>> UpdateRoleAsync(Guid roleId, int maxConfigs, int maxIpLimit, CancellationToken cancellationToken);
|
||||||
|
|
||||||
Task<Result> DeleteRoleAsync(Guid roleId, CancellationToken cancellationToken);
|
Task<Result> DeleteRoleAsync(Guid roleId, CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
|||||||
@@ -24,9 +24,13 @@ public interface IXuiPanelGateway
|
|||||||
|
|
||||||
void InvalidateClient(Guid nodeId);
|
void InvalidateClient(Guid nodeId);
|
||||||
|
|
||||||
/// <summary>Возвращает ClientExternalId, присвоенный панелью (UUID для VLESS/VMess, пароль для Trojan/Shadowsocks).</summary>
|
/// <summary>
|
||||||
|
/// Возвращает ClientExternalId, присвоенный панелью (UUID для VLESS/VMess, пароль для Trojan/Shadowsocks).
|
||||||
|
/// <paramref name="limitIp"/> — лимит одновременных IP клиента (квота роли, см. AppRole.MaxIpLimit);
|
||||||
|
/// -1 (RoleQuota.Unlimited) означает без лимита — гейтвей сам переводит его в нативное значение 3x-ui.
|
||||||
|
/// </summary>
|
||||||
Task<Result<string>> AddClientAsync(
|
Task<Result<string>> AddClientAsync(
|
||||||
Node node, string inboundRemoteId, VpnProtocol protocol, string clientEmail, string clientName,
|
Node node, string inboundRemoteId, VpnProtocol protocol, string clientEmail, string clientName, int limitIp,
|
||||||
CancellationToken cancellationToken);
|
CancellationToken cancellationToken);
|
||||||
|
|
||||||
Task<Result> RemoveClientAsync(
|
Task<Result> RemoveClientAsync(
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ public sealed class CreateVpnConfigCommandHandler(
|
|||||||
|
|
||||||
var addResult = await gateway.AddClientAsync(
|
var addResult = await gateway.AddClientAsync(
|
||||||
node, inbound.RemoteInboundId, inbound.Protocol, config.ClientEmail,
|
node, inbound.RemoteInboundId, inbound.Protocol, config.ClientEmail,
|
||||||
config.Label ?? config.ClientEmail, cancellationToken);
|
config.Label ?? config.ClientEmail, profile.MaxIpLimit, cancellationToken);
|
||||||
|
|
||||||
if (!addResult.IsSuccess)
|
if (!addResult.IsSuccess)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ using PnvPanel.Domain.Configs;
|
|||||||
|
|
||||||
namespace PnvPanel.Application.Configs.Rotate;
|
namespace PnvPanel.Application.Configs.Rotate;
|
||||||
|
|
||||||
public sealed class RotateVpnConfigCommandHandler(IAppDbContext dbContext, IXuiPanelGateway gateway, ICurrentUser currentUser)
|
public sealed class RotateVpnConfigCommandHandler(
|
||||||
|
IAppDbContext dbContext, IXuiPanelGateway gateway, IIdentityService identityService, ICurrentUser currentUser)
|
||||||
: ICommandHandler<RotateVpnConfigCommand, Result<VpnConfigDto>>
|
: ICommandHandler<RotateVpnConfigCommand, Result<VpnConfigDto>>
|
||||||
{
|
{
|
||||||
public async Task<Result<VpnConfigDto>> Handle(RotateVpnConfigCommand command, CancellationToken cancellationToken)
|
public async Task<Result<VpnConfigDto>> Handle(RotateVpnConfigCommand command, CancellationToken cancellationToken)
|
||||||
@@ -32,10 +33,14 @@ public sealed class RotateVpnConfigCommandHandler(IAppDbContext dbContext, IXuiP
|
|||||||
if (node is null)
|
if (node is null)
|
||||||
return Result.Failure<VpnConfigDto>(ConfigErrors.NodeDisabled);
|
return Result.Failure<VpnConfigDto>(ConfigErrors.NodeDisabled);
|
||||||
|
|
||||||
|
var profile = await identityService.GetProfileAsync(userId, cancellationToken);
|
||||||
|
if (profile is null)
|
||||||
|
return Result.Failure<VpnConfigDto>(AuthErrors.Unauthorized);
|
||||||
|
|
||||||
var newClientEmail = VpnConfig.GenerateClientEmail(userId);
|
var newClientEmail = VpnConfig.GenerateClientEmail(userId);
|
||||||
var addResult = await gateway.AddClientAsync(
|
var addResult = await gateway.AddClientAsync(
|
||||||
node, inbound.RemoteInboundId, config.Protocol, newClientEmail,
|
node, inbound.RemoteInboundId, config.Protocol, newClientEmail,
|
||||||
config.Label ?? newClientEmail, cancellationToken);
|
config.Label ?? newClientEmail, profile.MaxIpLimit, cancellationToken);
|
||||||
|
|
||||||
if (!addResult.IsSuccess)
|
if (!addResult.IsSuccess)
|
||||||
return Result.Failure<VpnConfigDto>(addResult.Error);
|
return Result.Failure<VpnConfigDto>(addResult.Error);
|
||||||
|
|||||||
@@ -3,12 +3,19 @@ using PnvPanel.Application.Common.Models;
|
|||||||
|
|
||||||
namespace PnvPanel.Infrastructure.Identity;
|
namespace PnvPanel.Infrastructure.Identity;
|
||||||
|
|
||||||
/// <summary>Роль с квотой на число конфигов. У пользователя ровно одна роль.</summary>
|
/// <summary>Роль с квотой на число конфигов и лимитом одновременных IP на клиента. У пользователя ровно одна роль.</summary>
|
||||||
public class AppRole : IdentityRole<Guid>
|
public class AppRole : IdentityRole<Guid>
|
||||||
{
|
{
|
||||||
public const int UnlimitedMaxConfigs = RoleQuota.Unlimited;
|
public const int UnlimitedMaxConfigs = RoleQuota.Unlimited;
|
||||||
|
public const int UnlimitedMaxIpLimit = RoleQuota.Unlimited;
|
||||||
|
|
||||||
public int MaxConfigs { get; set; }
|
public int MaxConfigs { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Лимит одновременных IP на клиента в 3x-ui (`limitIp`); -1 — без лимита. Применяется
|
||||||
|
/// только к новым клиентам, создаваемым в 3x-ui (см. IXuiPanelGateway.AddClientAsync) — при смене
|
||||||
|
/// роли/лимита существующие клиенты в панели не трогаются (как и квота MaxConfigs).</summary>
|
||||||
|
public int MaxIpLimit { get; set; }
|
||||||
|
|
||||||
public bool IsSystem { get; set; }
|
public bool IsSystem { get; set; }
|
||||||
|
|
||||||
public AppRole()
|
public AppRole()
|
||||||
|
|||||||
@@ -22,18 +22,18 @@ public sealed class DbInitializer(
|
|||||||
|
|
||||||
public async Task SeedAsync(CancellationToken cancellationToken = default)
|
public async Task SeedAsync(CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
await EnsureRoleAsync(RoleNames.Admin, AppRole.UnlimitedMaxConfigs, isSystem: true);
|
await EnsureRoleAsync(RoleNames.Admin, AppRole.UnlimitedMaxConfigs, AppRole.UnlimitedMaxIpLimit, isSystem: true);
|
||||||
await EnsureRoleAsync(RoleNames.User, rolesOptions.Value.DefaultUserMaxConfigs, isSystem: true);
|
await EnsureRoleAsync(RoleNames.User, rolesOptions.Value.DefaultUserMaxConfigs, rolesOptions.Value.DefaultUserMaxIpLimit, isSystem: true);
|
||||||
await SeedAdminAsync();
|
await SeedAdminAsync();
|
||||||
await SeedClientAppsAsync(cancellationToken);
|
await SeedClientAppsAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task EnsureRoleAsync(string name, int maxConfigs, bool isSystem)
|
private async Task EnsureRoleAsync(string name, int maxConfigs, int maxIpLimit, bool isSystem)
|
||||||
{
|
{
|
||||||
if (await roleManager.RoleExistsAsync(name))
|
if (await roleManager.RoleExistsAsync(name))
|
||||||
return;
|
return;
|
||||||
|
|
||||||
var role = new AppRole(name) { MaxConfigs = maxConfigs, IsSystem = isSystem };
|
var role = new AppRole(name) { MaxConfigs = maxConfigs, MaxIpLimit = maxIpLimit, IsSystem = isSystem };
|
||||||
var result = await roleManager.CreateAsync(role);
|
var result = await roleManager.CreateAsync(role);
|
||||||
if (!result.Succeeded)
|
if (!result.Succeeded)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -60,7 +60,8 @@ internal sealed class IdentityService(UserManager<AppUser> userManager, SignInMa
|
|||||||
|
|
||||||
var role = await GetPrimaryRoleAsync(user);
|
var role = await GetPrimaryRoleAsync(user);
|
||||||
return new CurrentUserProfile(
|
return new CurrentUserProfile(
|
||||||
user.Id, user.UserName!, role.Id, role.Name!, user.IsActivated, user.IsBlocked, role.MaxConfigs, user.SubscriptionToken);
|
user.Id, user.UserName!, role.Id, role.Name!, user.IsActivated, user.IsBlocked, role.MaxConfigs, role.MaxIpLimit,
|
||||||
|
user.SubscriptionToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<Result> ChangePasswordAsync(Guid userId, string currentPassword, string newPassword, CancellationToken cancellationToken)
|
public async Task<Result> ChangePasswordAsync(Guid userId, string currentPassword, string newPassword, CancellationToken cancellationToken)
|
||||||
|
|||||||
@@ -9,12 +9,12 @@ namespace PnvPanel.Infrastructure.Identity;
|
|||||||
|
|
||||||
internal sealed class RoleService(RoleManager<AppRole> roleManager, UserManager<AppUser> userManager) : IRoleService
|
internal sealed class RoleService(RoleManager<AppRole> roleManager, UserManager<AppUser> userManager) : IRoleService
|
||||||
{
|
{
|
||||||
public async Task<Result<RoleDto>> CreateRoleAsync(string name, int maxConfigs, CancellationToken cancellationToken)
|
public async Task<Result<RoleDto>> CreateRoleAsync(string name, int maxConfigs, int maxIpLimit, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (await roleManager.RoleExistsAsync(name))
|
if (await roleManager.RoleExistsAsync(name))
|
||||||
return Result.Failure<RoleDto>(RoleErrors.DuplicateName);
|
return Result.Failure<RoleDto>(RoleErrors.DuplicateName);
|
||||||
|
|
||||||
var role = new AppRole(name) { MaxConfigs = maxConfigs, IsSystem = false };
|
var role = new AppRole(name) { MaxConfigs = maxConfigs, MaxIpLimit = maxIpLimit, IsSystem = false };
|
||||||
var result = await roleManager.CreateAsync(role);
|
var result = await roleManager.CreateAsync(role);
|
||||||
if (!result.Succeeded)
|
if (!result.Succeeded)
|
||||||
{
|
{
|
||||||
@@ -25,13 +25,14 @@ internal sealed class RoleService(RoleManager<AppRole> roleManager, UserManager<
|
|||||||
return Result.Success(ToDto(role));
|
return Result.Success(ToDto(role));
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<Result<RoleDto>> UpdateRoleAsync(Guid roleId, int maxConfigs, CancellationToken cancellationToken)
|
public async Task<Result<RoleDto>> UpdateRoleAsync(Guid roleId, int maxConfigs, int maxIpLimit, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var role = await roleManager.FindByIdAsync(roleId.ToString());
|
var role = await roleManager.FindByIdAsync(roleId.ToString());
|
||||||
if (role is null)
|
if (role is null)
|
||||||
return Result.Failure<RoleDto>(RoleErrors.NotFound);
|
return Result.Failure<RoleDto>(RoleErrors.NotFound);
|
||||||
|
|
||||||
role.MaxConfigs = maxConfigs;
|
role.MaxConfigs = maxConfigs;
|
||||||
|
role.MaxIpLimit = maxIpLimit;
|
||||||
await roleManager.UpdateAsync(role);
|
await roleManager.UpdateAsync(role);
|
||||||
|
|
||||||
return Result.Success(ToDto(role));
|
return Result.Success(ToDto(role));
|
||||||
@@ -58,7 +59,7 @@ internal sealed class RoleService(RoleManager<AppRole> roleManager, UserManager<
|
|||||||
{
|
{
|
||||||
return await roleManager.Roles
|
return await roleManager.Roles
|
||||||
.OrderBy(r => r.Name)
|
.OrderBy(r => r.Name)
|
||||||
.Select(r => new RoleDto(r.Id, r.Name!, r.MaxConfigs, r.IsSystem))
|
.Select(r => new RoleDto(r.Id, r.Name!, r.MaxConfigs, r.MaxIpLimit, r.IsSystem))
|
||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,5 +81,5 @@ internal sealed class RoleService(RoleManager<AppRole> roleManager, UserManager<
|
|||||||
return Result.Success();
|
return Result.Success();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static RoleDto ToDto(AppRole role) => new(role.Id, role.Name!, role.MaxConfigs, role.IsSystem);
|
private static RoleDto ToDto(AppRole role) => new(role.Id, role.Name!, role.MaxConfigs, role.MaxIpLimit, role.IsSystem);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,4 +5,5 @@ public sealed class RolesOptions
|
|||||||
public const string SectionName = "Roles";
|
public const string SectionName = "Roles";
|
||||||
|
|
||||||
public int DefaultUserMaxConfigs { get; init; } = 3;
|
public int DefaultUserMaxConfigs { get; init; } = 3;
|
||||||
|
public int DefaultUserMaxIpLimit { get; init; } = 2;
|
||||||
}
|
}
|
||||||
|
|||||||
+776
@@ -0,0 +1,776 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||||
|
using PnvPanel.Infrastructure.Persistence;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace PnvPanel.Infrastructure.Persistence.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(AppDbContext))]
|
||||||
|
[Migration("20260713035342_AddRoleMaxIpLimit")]
|
||||||
|
partial class AddRoleMaxIpLimit
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder
|
||||||
|
.HasAnnotation("ProductVersion", "10.0.9")
|
||||||
|
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||||
|
|
||||||
|
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||||
|
|
||||||
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<System.Guid>", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<string>("ClaimType")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("ClaimValue")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<Guid>("RoleId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("RoleId");
|
||||||
|
|
||||||
|
b.ToTable("AspNetRoleClaims", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<string>("ClaimType")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("ClaimValue")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<Guid>("UserId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("UserId");
|
||||||
|
|
||||||
|
b.ToTable("AspNetUserClaims", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("LoginProvider")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("ProviderKey")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("ProviderDisplayName")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<Guid>("UserId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.HasKey("LoginProvider", "ProviderKey");
|
||||||
|
|
||||||
|
b.HasIndex("UserId");
|
||||||
|
|
||||||
|
b.ToTable("AspNetUserLogins", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<System.Guid>", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("UserId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<Guid>("RoleId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.HasKey("UserId", "RoleId");
|
||||||
|
|
||||||
|
b.HasIndex("RoleId");
|
||||||
|
|
||||||
|
b.ToTable("AspNetUserRoles", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<System.Guid>", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("UserId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("LoginProvider")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("Value")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.HasKey("UserId", "LoginProvider", "Name");
|
||||||
|
|
||||||
|
b.ToTable("AspNetUserTokens", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("PnvPanel.Domain.Activation.ActivationRequest", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Comment")
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("character varying(500)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("DecidedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<Guid?>("DecidedBy")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("RejectionReason")
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("character varying(500)");
|
||||||
|
|
||||||
|
b.Property<string>("Status")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(32)
|
||||||
|
.HasColumnType("character varying(32)");
|
||||||
|
|
||||||
|
b.Property<Guid>("UserId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("UserId", "Status");
|
||||||
|
|
||||||
|
b.ToTable("ActivationRequests", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("PnvPanel.Domain.Apps.ClientApp", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Description")
|
||||||
|
.HasMaxLength(300)
|
||||||
|
.HasColumnType("character varying(300)");
|
||||||
|
|
||||||
|
b.Property<string>("DownloadUrl")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("character varying(500)");
|
||||||
|
|
||||||
|
b.Property<string>("IconUrl")
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("character varying(500)");
|
||||||
|
|
||||||
|
b.Property<bool>("IsEnabled")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("character varying(100)");
|
||||||
|
|
||||||
|
b.Property<string>("OperatingSystem")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(32)
|
||||||
|
.HasColumnType("character varying(32)");
|
||||||
|
|
||||||
|
b.Property<int>("SortOrder")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.ToTable("ClientApps", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("PnvPanel.Domain.Audit.AuditLog", b =>
|
||||||
|
{
|
||||||
|
b.Property<long>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||||
|
|
||||||
|
b.Property<string>("Action")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("character varying(100)");
|
||||||
|
|
||||||
|
b.Property<Guid?>("ActorId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("Metadata")
|
||||||
|
.HasColumnType("jsonb");
|
||||||
|
|
||||||
|
b.Property<string>("Source")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(32)
|
||||||
|
.HasColumnType("character varying(32)");
|
||||||
|
|
||||||
|
b.Property<string>("TargetId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("character varying(100)");
|
||||||
|
|
||||||
|
b.Property<string>("TargetType")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("character varying(50)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("CreatedAt");
|
||||||
|
|
||||||
|
b.ToTable("AuditLogs", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("PnvPanel.Domain.Configs.TrafficSample", b =>
|
||||||
|
{
|
||||||
|
b.Property<long>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||||
|
|
||||||
|
b.Property<Guid>("ConfigId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<long>("DownBytes")
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("Timestamp")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<long>("UpBytes")
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("ConfigId", "Timestamp");
|
||||||
|
|
||||||
|
b.ToTable("TrafficSamples", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("PnvPanel.Domain.Configs.VpnConfig", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("ClientEmail")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("character varying(200)");
|
||||||
|
|
||||||
|
b.Property<string>("ClientExternalId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("character varying(200)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("ExpiresAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<Guid>("InboundId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Label")
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("character varying(100)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("LastSyncAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("Protocol")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(32)
|
||||||
|
.HasColumnType("character varying(32)");
|
||||||
|
|
||||||
|
b.Property<string>("Status")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(32)
|
||||||
|
.HasColumnType("character varying(32)");
|
||||||
|
|
||||||
|
b.Property<string>("SubscriptionToken")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("character varying(128)");
|
||||||
|
|
||||||
|
b.Property<long>("UsedDownBytes")
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
b.Property<long>("UsedUpBytes")
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
b.Property<Guid>("UserId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("InboundId");
|
||||||
|
|
||||||
|
b.HasIndex("SubscriptionToken")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.HasIndex("UserId", "Status");
|
||||||
|
|
||||||
|
b.ToTable("VpnConfigs", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("PnvPanel.Domain.Inbounds.Inbound", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.PrimitiveCollection<Guid[]>("AllowedRoleIds")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("uuid[]");
|
||||||
|
|
||||||
|
b.Property<string>("DisplayName")
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("character varying(100)");
|
||||||
|
|
||||||
|
b.Property<bool>("IsPublished")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("LastSyncAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<int?>("MaxClients")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<Guid>("NodeId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<int>("Port")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("Protocol")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(32)
|
||||||
|
.HasColumnType("character varying(32)");
|
||||||
|
|
||||||
|
b.Property<string>("Remark")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("character varying(200)");
|
||||||
|
|
||||||
|
b.Property<string>("RemoteInboundId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("character varying(64)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("NodeId", "RemoteInboundId")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("Inbounds", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("PnvPanel.Domain.News.NewsPost", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Body")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(20000)
|
||||||
|
.HasColumnType("character varying(20000)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("Title")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("character varying(200)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("UpdatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("CreatedAt");
|
||||||
|
|
||||||
|
b.ToTable("NewsPosts", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("PnvPanel.Domain.Nodes.Node", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("BaseAddress")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("character varying(500)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<bool>("IsEnabled")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("LastSyncAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("Location")
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("character varying(100)");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("character varying(100)");
|
||||||
|
|
||||||
|
b.Property<string>("Status")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(32)
|
||||||
|
.HasColumnType("character varying(32)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.ToTable("Nodes", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("PnvPanel.Domain.Telegram.TelegramLinkToken", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("ConsumedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("ExpiresAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("Token")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("character varying(64)");
|
||||||
|
|
||||||
|
b.Property<Guid>("UserId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Token")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("TelegramLinkTokens", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("PnvPanel.Domain.Telegram.TelegramLoginRequest", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Context")
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("character varying(200)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("ExpiresAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("Status")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(32)
|
||||||
|
.HasColumnType("character varying(32)");
|
||||||
|
|
||||||
|
b.Property<Guid?>("UserId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.ToTable("TelegramLoginRequests", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("PnvPanel.Infrastructure.Identity.AppRole", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("ConcurrencyStamp")
|
||||||
|
.IsConcurrencyToken()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<bool>("IsSystem")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<int>("MaxConfigs")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int>("MaxIpLimit")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("character varying(256)");
|
||||||
|
|
||||||
|
b.Property<string>("NormalizedName")
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("character varying(256)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("NormalizedName")
|
||||||
|
.IsUnique()
|
||||||
|
.HasDatabaseName("RoleNameIndex");
|
||||||
|
|
||||||
|
b.ToTable("AspNetRoles", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("PnvPanel.Infrastructure.Identity.AppUser", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<int>("AccessFailedCount")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("ActivatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<Guid?>("ActivatedBy")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("ConcurrencyStamp")
|
||||||
|
.IsConcurrencyToken()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("Email")
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("character varying(256)");
|
||||||
|
|
||||||
|
b.Property<bool>("EmailConfirmed")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<bool>("IsActivated")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<bool>("IsBlocked")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<bool>("LockoutEnabled")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("LockoutEnd")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("NormalizedEmail")
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("character varying(256)");
|
||||||
|
|
||||||
|
b.Property<string>("NormalizedUserName")
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("character varying(256)");
|
||||||
|
|
||||||
|
b.Property<string>("PasswordHash")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("PhoneNumber")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<bool>("PhoneNumberConfirmed")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<string>("SecurityStamp")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("SubscriptionToken")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("TelegramLinkedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<long?>("TelegramUserId")
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
b.Property<string>("TelegramUsername")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<bool>("TwoFactorEnabled")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<string>("UserName")
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("character varying(256)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("NormalizedEmail")
|
||||||
|
.HasDatabaseName("EmailIndex");
|
||||||
|
|
||||||
|
b.HasIndex("NormalizedUserName")
|
||||||
|
.IsUnique()
|
||||||
|
.HasDatabaseName("UserNameIndex");
|
||||||
|
|
||||||
|
b.HasIndex("SubscriptionToken")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.HasIndex("TelegramUserId")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("AspNetUsers", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("PnvPanel.Infrastructure.Identity.RefreshToken", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("ExpiresAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("ReplacedByTokenHash")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("RevokedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("TokenHash")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("character varying(128)");
|
||||||
|
|
||||||
|
b.Property<Guid>("UserId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("TokenHash")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.HasIndex("UserId");
|
||||||
|
|
||||||
|
b.ToTable("RefreshTokens", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<System.Guid>", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("PnvPanel.Infrastructure.Identity.AppRole", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("RoleId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("PnvPanel.Infrastructure.Identity.AppUser", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("UserId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("PnvPanel.Infrastructure.Identity.AppUser", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("UserId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<System.Guid>", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("PnvPanel.Infrastructure.Identity.AppRole", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("RoleId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("PnvPanel.Infrastructure.Identity.AppUser", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("UserId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<System.Guid>", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("PnvPanel.Infrastructure.Identity.AppUser", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("UserId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("PnvPanel.Domain.Nodes.Node", b =>
|
||||||
|
{
|
||||||
|
b.OwnsOne("PnvPanel.Domain.Nodes.NodeCredentials", "Credentials", b1 =>
|
||||||
|
{
|
||||||
|
b1.Property<Guid>("NodeId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b1.Property<string>("ProtectedPassword")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text")
|
||||||
|
.HasColumnName("CredentialsProtectedPassword");
|
||||||
|
|
||||||
|
b1.Property<string>("Username")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("character varying(200)")
|
||||||
|
.HasColumnName("CredentialsUsername");
|
||||||
|
|
||||||
|
b1.HasKey("NodeId");
|
||||||
|
|
||||||
|
b1.ToTable("Nodes");
|
||||||
|
|
||||||
|
b1.WithOwner()
|
||||||
|
.HasForeignKey("NodeId");
|
||||||
|
});
|
||||||
|
|
||||||
|
b.Navigation("Credentials")
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+34
@@ -0,0 +1,34 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace PnvPanel.Infrastructure.Persistence.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddRoleMaxIpLimit : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
// Default -1 (без лимита) — не меняем поведение для уже существующих ролей (включая
|
||||||
|
// кастомные вроде "vip"), которые раньше не были ограничены по IP вовсе. Системную роль
|
||||||
|
// "user" сразу переводим на дефолт из ТЗ (2); её можно поменять в админке в любой момент.
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "MaxIpLimit",
|
||||||
|
table: "AspNetRoles",
|
||||||
|
type: "integer",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: -1);
|
||||||
|
|
||||||
|
migrationBuilder.Sql("UPDATE \"AspNetRoles\" SET \"MaxIpLimit\" = 2 WHERE \"Name\" = 'user';");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "MaxIpLimit",
|
||||||
|
table: "AspNetRoles");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+3
@@ -535,6 +535,9 @@ namespace PnvPanel.Infrastructure.Persistence.Migrations
|
|||||||
b.Property<int>("MaxConfigs")
|
b.Property<int>("MaxConfigs")
|
||||||
.HasColumnType("integer");
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int>("MaxIpLimit")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
b.Property<string>("Name")
|
b.Property<string>("Name")
|
||||||
.HasMaxLength(256)
|
.HasMaxLength(256)
|
||||||
.HasColumnType("character varying(256)");
|
.HasColumnType("character varying(256)");
|
||||||
|
|||||||
@@ -70,15 +70,16 @@ internal sealed class XuiPanelGateway(
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async Task<Result<string>> AddClientAsync(
|
public async Task<Result<string>> AddClientAsync(
|
||||||
Node node, string inboundRemoteId, VpnProtocol protocol, string clientEmail, string clientName,
|
Node node, string inboundRemoteId, VpnProtocol protocol, string clientEmail, string clientName, int limitIp,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var client = GetClient(node);
|
var client = GetClient(node);
|
||||||
// Лимит устройств (limitIp) панелью больше не управляется — задаётся, если нужно,
|
// Наш domain-sentinel RoleQuota.Unlimited (-1) переводим в нативное "без лимита" 3x-ui (0) —
|
||||||
// напрямую в 3x-ui администратором ноды. Новый клиент всегда создаётся без лимита.
|
// ThreeXui.Net.AddClientRequest.LimitIp обязателен (не nullable), 0 в самом 3x-ui означает unlimited.
|
||||||
var request = new AddClientRequest(clientName, clientEmail, ToRemoteProtocol(protocol), 0, null);
|
var request = new AddClientRequest(
|
||||||
|
clientName, clientEmail, ToRemoteProtocol(protocol), limitIp == RoleQuota.Unlimited ? 0 : limitIp, null);
|
||||||
var result = await client.AddClientAsync(inboundRemoteId, request, cancellationToken);
|
var result = await client.AddClientAsync(inboundRemoteId, request, cancellationToken);
|
||||||
return Result.Success(result.ExternalClientId);
|
return Result.Success(result.ExternalClientId);
|
||||||
}
|
}
|
||||||
|
|||||||
+108
@@ -0,0 +1,108 @@
|
|||||||
|
using NSubstitute;
|
||||||
|
using PnvPanel.Application.Admin.Users;
|
||||||
|
using PnvPanel.Application.Common.Interfaces;
|
||||||
|
using PnvPanel.Application.Common.Models;
|
||||||
|
using PnvPanel.Application.Tests.TestSupport;
|
||||||
|
using PnvPanel.Domain.Configs;
|
||||||
|
using PnvPanel.Domain.Inbounds;
|
||||||
|
using PnvPanel.Domain.Nodes;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace PnvPanel.Application.Tests.Admin.Users;
|
||||||
|
|
||||||
|
public class DeleteUserCommandHandlerTests
|
||||||
|
{
|
||||||
|
private readonly IIdentityService _identityService = Substitute.For<IIdentityService>();
|
||||||
|
private readonly IXuiPanelGateway _gateway = Substitute.For<IXuiPanelGateway>();
|
||||||
|
private readonly ITelegramNotifier _telegramNotifier = Substitute.For<ITelegramNotifier>();
|
||||||
|
private readonly ICurrentUser _currentUser = Substitute.For<ICurrentUser>();
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_WhenAdminTargetsSelf_ReturnsCannotDeleteSelfWithoutTouchingConfigs()
|
||||||
|
{
|
||||||
|
using var dbContext = InMemoryDbContextFactory.Create();
|
||||||
|
var adminId = Guid.NewGuid();
|
||||||
|
_currentUser.UserId.Returns(adminId);
|
||||||
|
|
||||||
|
var handler = new DeleteUserCommandHandler(dbContext, _identityService, _gateway, _telegramNotifier, _currentUser);
|
||||||
|
|
||||||
|
var result = await handler.Handle(new DeleteUserCommand(adminId), CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.False(result.IsSuccess);
|
||||||
|
Assert.Equal(UserErrors.CannotDeleteSelf, result.Error);
|
||||||
|
await _identityService.DidNotReceive().DeleteUserAsync(Arg.Any<Guid>(), Arg.Any<CancellationToken>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_Success_RevokesConfigsWritesAuditNotifiesThenDeletesUser()
|
||||||
|
{
|
||||||
|
using var dbContext = InMemoryDbContextFactory.Create();
|
||||||
|
var userId = Guid.NewGuid();
|
||||||
|
var adminId = Guid.NewGuid();
|
||||||
|
|
||||||
|
var node = Node.Register("node-1", new Uri("https://node1.example.com"), new NodeCredentials("admin", "protected"), null);
|
||||||
|
var inbound = Inbound.FromRemote(node.Id, "1", VpnProtocol.Vless, "remark", 443);
|
||||||
|
var config = VpnConfig.Create(userId, inbound.Id, VpnProtocol.Vless, "my-config");
|
||||||
|
config.AssignRemoteClient("external-id");
|
||||||
|
|
||||||
|
dbContext.Nodes.Add(node);
|
||||||
|
dbContext.Inbounds.Add(inbound);
|
||||||
|
dbContext.VpnConfigs.Add(config);
|
||||||
|
await dbContext.SaveChangesAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
_currentUser.UserId.Returns(adminId);
|
||||||
|
_gateway.RemoveClientAsync(Arg.Any<Node>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<VpnProtocol>(), Arg.Any<CancellationToken>())
|
||||||
|
.Returns(Result.Success());
|
||||||
|
_identityService.DeleteUserAsync(userId, Arg.Any<CancellationToken>()).Returns(Result.Success());
|
||||||
|
|
||||||
|
var handler = new DeleteUserCommandHandler(dbContext, _identityService, _gateway, _telegramNotifier, _currentUser);
|
||||||
|
|
||||||
|
var result = await handler.Handle(new DeleteUserCommand(userId), CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
Assert.Equal(ConfigStatus.Revoked, config.Status);
|
||||||
|
|
||||||
|
await _gateway.Received(1).RemoveClientAsync(
|
||||||
|
Arg.Is<Node>(n => n.Id == node.Id), inbound.RemoteInboundId, "external-id", config.Protocol, Arg.Any<CancellationToken>());
|
||||||
|
await _telegramNotifier.Received(1).NotifyUserAsync(userId, Arg.Any<string>(), Arg.Any<CancellationToken>());
|
||||||
|
await _identityService.Received(1).DeleteUserAsync(userId, Arg.Any<CancellationToken>());
|
||||||
|
|
||||||
|
var audit = Assert.Single(dbContext.AuditLogs.Local);
|
||||||
|
Assert.Equal("UserDeleted", audit.Action);
|
||||||
|
Assert.Equal(adminId, audit.ActorId);
|
||||||
|
Assert.Equal(userId.ToString(), audit.TargetId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_NoConfigs_SkipsGatewayButStillDeletesUser()
|
||||||
|
{
|
||||||
|
using var dbContext = InMemoryDbContextFactory.Create();
|
||||||
|
var userId = Guid.NewGuid();
|
||||||
|
_currentUser.UserId.Returns(Guid.NewGuid());
|
||||||
|
_identityService.DeleteUserAsync(userId, Arg.Any<CancellationToken>()).Returns(Result.Success());
|
||||||
|
|
||||||
|
var handler = new DeleteUserCommandHandler(dbContext, _identityService, _gateway, _telegramNotifier, _currentUser);
|
||||||
|
|
||||||
|
var result = await handler.Handle(new DeleteUserCommand(userId), CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
await _gateway.DidNotReceive().RemoveClientAsync(
|
||||||
|
Arg.Any<Node>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<VpnProtocol>(), Arg.Any<CancellationToken>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_WhenIdentityServiceFails_ReturnsFailure()
|
||||||
|
{
|
||||||
|
using var dbContext = InMemoryDbContextFactory.Create();
|
||||||
|
var userId = Guid.NewGuid();
|
||||||
|
_currentUser.UserId.Returns(Guid.NewGuid());
|
||||||
|
_identityService.DeleteUserAsync(userId, Arg.Any<CancellationToken>()).Returns(Result.Failure(UserErrors.NotFound));
|
||||||
|
|
||||||
|
var handler = new DeleteUserCommandHandler(dbContext, _identityService, _gateway, _telegramNotifier, _currentUser);
|
||||||
|
|
||||||
|
var result = await handler.Handle(new DeleteUserCommand(userId), CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.False(result.IsSuccess);
|
||||||
|
Assert.Equal(UserErrors.NotFound, result.Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ using NSubstitute;
|
|||||||
using PnvPanel.Application.Auth;
|
using PnvPanel.Application.Auth;
|
||||||
using PnvPanel.Application.Auth.Me;
|
using PnvPanel.Application.Auth.Me;
|
||||||
using PnvPanel.Application.Common.Interfaces;
|
using PnvPanel.Application.Common.Interfaces;
|
||||||
|
using PnvPanel.Application.Common.Models;
|
||||||
using PnvPanel.Application.Tests.TestSupport;
|
using PnvPanel.Application.Tests.TestSupport;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
@@ -40,7 +41,7 @@ public class GetCurrentUserQueryHandlerTests
|
|||||||
public async Task Handle_AuthenticatedWithProfile_ReturnsCurrentUserDto()
|
public async Task Handle_AuthenticatedWithProfile_ReturnsCurrentUserDto()
|
||||||
{
|
{
|
||||||
var userId = Guid.NewGuid();
|
var userId = Guid.NewGuid();
|
||||||
var profile = new CurrentUserProfile(userId, "alice", Guid.NewGuid(), "user", true, false, 3, "sub-token");
|
var profile = new CurrentUserProfile(userId, "alice", Guid.NewGuid(), "user", true, false, 3, RoleQuota.Unlimited, "sub-token");
|
||||||
_identityService.GetProfileAsync(userId, Arg.Any<CancellationToken>()).Returns(profile);
|
_identityService.GetProfileAsync(userId, Arg.Any<CancellationToken>()).Returns(profile);
|
||||||
_identityService.GetTelegramLinkInfoAsync(userId, Arg.Any<CancellationToken>())
|
_identityService.GetTelegramLinkInfoAsync(userId, Arg.Any<CancellationToken>())
|
||||||
.Returns(new TelegramLinkInfo(true, 42, "alice_tg"));
|
.Returns(new TelegramLinkInfo(true, 42, "alice_tg"));
|
||||||
|
|||||||
@@ -20,7 +20,9 @@ public class LoginCommandHandlerTests
|
|||||||
{
|
{
|
||||||
var userId = Guid.NewGuid();
|
var userId = Guid.NewGuid();
|
||||||
var authUser = new AuthenticatedUser(userId, "alice", "user");
|
var authUser = new AuthenticatedUser(userId, "alice", "user");
|
||||||
var profile = new CurrentUserProfile(userId, "alice", Guid.NewGuid(), "user", IsActivated: true, IsBlocked: false, MaxConfigs: 3, SubscriptionToken: "sub-token");
|
var profile = new CurrentUserProfile(
|
||||||
|
userId, "alice", Guid.NewGuid(), "user", IsActivated: true, IsBlocked: false, MaxConfigs: 3,
|
||||||
|
MaxIpLimit: RoleQuota.Unlimited, SubscriptionToken: "sub-token");
|
||||||
|
|
||||||
_identityService.ValidateCredentialsAsync("alice", "P@ssw0rd", Arg.Any<CancellationToken>())
|
_identityService.ValidateCredentialsAsync("alice", "P@ssw0rd", Arg.Any<CancellationToken>())
|
||||||
.Returns(Result.Success(authUser));
|
.Returns(Result.Success(authUser));
|
||||||
|
|||||||
@@ -19,7 +19,9 @@ public class RefreshCommandHandlerTests
|
|||||||
public async Task Handle_WithValidToken_RotatesAndReturnsNewAuthResult()
|
public async Task Handle_WithValidToken_RotatesAndReturnsNewAuthResult()
|
||||||
{
|
{
|
||||||
var userId = Guid.NewGuid();
|
var userId = Guid.NewGuid();
|
||||||
var profile = new CurrentUserProfile(userId, "alice", Guid.NewGuid(), "user", IsActivated: true, IsBlocked: false, MaxConfigs: 3, SubscriptionToken: "sub-token");
|
var profile = new CurrentUserProfile(
|
||||||
|
userId, "alice", Guid.NewGuid(), "user", IsActivated: true, IsBlocked: false, MaxConfigs: 3,
|
||||||
|
MaxIpLimit: RoleQuota.Unlimited, SubscriptionToken: "sub-token");
|
||||||
var rotated = new RotatedRefreshToken(userId, "new-refresh-token", DateTimeOffset.UtcNow.AddDays(30));
|
var rotated = new RotatedRefreshToken(userId, "new-refresh-token", DateTimeOffset.UtcNow.AddDays(30));
|
||||||
|
|
||||||
_refreshTokenService.RotateAsync("old-token", Arg.Any<CancellationToken>()).Returns(Result.Success(rotated));
|
_refreshTokenService.RotateAsync("old-token", Arg.Any<CancellationToken>()).Returns(Result.Success(rotated));
|
||||||
|
|||||||
+2
-1
@@ -1,6 +1,7 @@
|
|||||||
using NSubstitute;
|
using NSubstitute;
|
||||||
using PnvPanel.Application.Auth;
|
using PnvPanel.Application.Auth;
|
||||||
using PnvPanel.Application.Common.Interfaces;
|
using PnvPanel.Application.Common.Interfaces;
|
||||||
|
using PnvPanel.Application.Common.Models;
|
||||||
using PnvPanel.Application.Configs.GetMyConfigs;
|
using PnvPanel.Application.Configs.GetMyConfigs;
|
||||||
using PnvPanel.Application.Tests.TestSupport;
|
using PnvPanel.Application.Tests.TestSupport;
|
||||||
using PnvPanel.Domain.Configs;
|
using PnvPanel.Domain.Configs;
|
||||||
@@ -32,7 +33,7 @@ public class GetMyConfigsQueryHandlerTests
|
|||||||
dbContext.VpnConfigs.AddRange(activeConfig, revokedConfig, otherUsersConfig);
|
dbContext.VpnConfigs.AddRange(activeConfig, revokedConfig, otherUsersConfig);
|
||||||
await dbContext.SaveChangesAsync(CancellationToken.None);
|
await dbContext.SaveChangesAsync(CancellationToken.None);
|
||||||
|
|
||||||
var profile = new CurrentUserProfile(userId, "alice", Guid.NewGuid(), "user", true, false, 5, "sub-token");
|
var profile = new CurrentUserProfile(userId, "alice", Guid.NewGuid(), "user", true, false, 5, RoleQuota.Unlimited, "sub-token");
|
||||||
_identityService.GetProfileAsync(userId, Arg.Any<CancellationToken>()).Returns(profile);
|
_identityService.GetProfileAsync(userId, Arg.Any<CancellationToken>()).Returns(profile);
|
||||||
|
|
||||||
var handler = new GetMyConfigsQueryHandler(dbContext, _identityService, FakeCurrentUser.Authenticated(userId));
|
var handler = new GetMyConfigsQueryHandler(dbContext, _identityService, FakeCurrentUser.Authenticated(userId));
|
||||||
|
|||||||
+13
-7
@@ -15,12 +15,17 @@ namespace PnvPanel.Application.Tests.Configs.Rotate;
|
|||||||
public class RotateVpnConfigCommandHandlerTests
|
public class RotateVpnConfigCommandHandlerTests
|
||||||
{
|
{
|
||||||
private readonly IXuiPanelGateway _gateway = Substitute.For<IXuiPanelGateway>();
|
private readonly IXuiPanelGateway _gateway = Substitute.For<IXuiPanelGateway>();
|
||||||
|
private readonly IIdentityService _identityService = Substitute.For<IIdentityService>();
|
||||||
|
|
||||||
|
private static CurrentUserProfile MakeProfile(Guid userId) =>
|
||||||
|
new(userId, "alice", Guid.NewGuid(), "user", true, false, 3, RoleQuota.Unlimited, "sub-token");
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Handle_WhenActiveConfigOwnedByUser_RotatesAndAddsNewClient()
|
public async Task Handle_WhenActiveConfigOwnedByUser_RotatesAndAddsNewClient()
|
||||||
{
|
{
|
||||||
using var dbContext = InMemoryDbContextFactory.Create();
|
using var dbContext = InMemoryDbContextFactory.Create();
|
||||||
var userId = Guid.NewGuid();
|
var userId = Guid.NewGuid();
|
||||||
|
_identityService.GetProfileAsync(userId, Arg.Any<CancellationToken>()).Returns(MakeProfile(userId));
|
||||||
|
|
||||||
var node = Node.Register("node-1", new Uri("https://node1.example.com"), new NodeCredentials("admin", "protected"), null);
|
var node = Node.Register("node-1", new Uri("https://node1.example.com"), new NodeCredentials("admin", "protected"), null);
|
||||||
var inbound = Inbound.FromRemote(node.Id, "1", VpnProtocol.Vless, "remark", 443);
|
var inbound = Inbound.FromRemote(node.Id, "1", VpnProtocol.Vless, "remark", 443);
|
||||||
@@ -34,10 +39,10 @@ public class RotateVpnConfigCommandHandlerTests
|
|||||||
|
|
||||||
_gateway.AddClientAsync(
|
_gateway.AddClientAsync(
|
||||||
Arg.Any<Node>(), inbound.RemoteInboundId, config.Protocol, Arg.Any<string>(),
|
Arg.Any<Node>(), inbound.RemoteInboundId, config.Protocol, Arg.Any<string>(),
|
||||||
Arg.Any<string>(), Arg.Any<CancellationToken>())
|
Arg.Any<string>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
|
||||||
.Returns(Result.Success("new-external-id"));
|
.Returns(Result.Success("new-external-id"));
|
||||||
|
|
||||||
var handler = new RotateVpnConfigCommandHandler(dbContext, _gateway, FakeCurrentUser.Authenticated(userId));
|
var handler = new RotateVpnConfigCommandHandler(dbContext, _gateway, _identityService, FakeCurrentUser.Authenticated(userId));
|
||||||
|
|
||||||
var result = await handler.Handle(new RotateVpnConfigCommand(config.Id), CancellationToken.None);
|
var result = await handler.Handle(new RotateVpnConfigCommand(config.Id), CancellationToken.None);
|
||||||
|
|
||||||
@@ -53,7 +58,7 @@ public class RotateVpnConfigCommandHandlerTests
|
|||||||
using var dbContext = InMemoryDbContextFactory.Create();
|
using var dbContext = InMemoryDbContextFactory.Create();
|
||||||
var userId = Guid.NewGuid();
|
var userId = Guid.NewGuid();
|
||||||
|
|
||||||
var handler = new RotateVpnConfigCommandHandler(dbContext, _gateway, FakeCurrentUser.Authenticated(userId));
|
var handler = new RotateVpnConfigCommandHandler(dbContext, _gateway, _identityService, FakeCurrentUser.Authenticated(userId));
|
||||||
|
|
||||||
var result = await handler.Handle(new RotateVpnConfigCommand(Guid.NewGuid()), CancellationToken.None);
|
var result = await handler.Handle(new RotateVpnConfigCommand(Guid.NewGuid()), CancellationToken.None);
|
||||||
|
|
||||||
@@ -75,7 +80,7 @@ public class RotateVpnConfigCommandHandlerTests
|
|||||||
dbContext.VpnConfigs.Add(config);
|
dbContext.VpnConfigs.Add(config);
|
||||||
await dbContext.SaveChangesAsync(CancellationToken.None);
|
await dbContext.SaveChangesAsync(CancellationToken.None);
|
||||||
|
|
||||||
var handler = new RotateVpnConfigCommandHandler(dbContext, _gateway, FakeCurrentUser.Authenticated(otherUserId));
|
var handler = new RotateVpnConfigCommandHandler(dbContext, _gateway, _identityService, FakeCurrentUser.Authenticated(otherUserId));
|
||||||
|
|
||||||
var result = await handler.Handle(new RotateVpnConfigCommand(config.Id), CancellationToken.None);
|
var result = await handler.Handle(new RotateVpnConfigCommand(config.Id), CancellationToken.None);
|
||||||
|
|
||||||
@@ -97,7 +102,7 @@ public class RotateVpnConfigCommandHandlerTests
|
|||||||
dbContext.VpnConfigs.Add(config);
|
dbContext.VpnConfigs.Add(config);
|
||||||
await dbContext.SaveChangesAsync(CancellationToken.None);
|
await dbContext.SaveChangesAsync(CancellationToken.None);
|
||||||
|
|
||||||
var handler = new RotateVpnConfigCommandHandler(dbContext, _gateway, FakeCurrentUser.Authenticated(userId));
|
var handler = new RotateVpnConfigCommandHandler(dbContext, _gateway, _identityService, FakeCurrentUser.Authenticated(userId));
|
||||||
|
|
||||||
var result = await handler.Handle(new RotateVpnConfigCommand(config.Id), CancellationToken.None);
|
var result = await handler.Handle(new RotateVpnConfigCommand(config.Id), CancellationToken.None);
|
||||||
|
|
||||||
@@ -110,6 +115,7 @@ public class RotateVpnConfigCommandHandlerTests
|
|||||||
{
|
{
|
||||||
using var dbContext = InMemoryDbContextFactory.Create();
|
using var dbContext = InMemoryDbContextFactory.Create();
|
||||||
var userId = Guid.NewGuid();
|
var userId = Guid.NewGuid();
|
||||||
|
_identityService.GetProfileAsync(userId, Arg.Any<CancellationToken>()).Returns(MakeProfile(userId));
|
||||||
|
|
||||||
var node = Node.Register("node-1", new Uri("https://node1.example.com"), new NodeCredentials("admin", "protected"), null);
|
var node = Node.Register("node-1", new Uri("https://node1.example.com"), new NodeCredentials("admin", "protected"), null);
|
||||||
var inbound = Inbound.FromRemote(node.Id, "1", VpnProtocol.Vless, "remark", 443);
|
var inbound = Inbound.FromRemote(node.Id, "1", VpnProtocol.Vless, "remark", 443);
|
||||||
@@ -124,10 +130,10 @@ public class RotateVpnConfigCommandHandlerTests
|
|||||||
var gatewayError = Error.Failure("Xui.Unreachable", "Панель недоступна.");
|
var gatewayError = Error.Failure("Xui.Unreachable", "Панель недоступна.");
|
||||||
_gateway.AddClientAsync(
|
_gateway.AddClientAsync(
|
||||||
Arg.Any<Node>(), inbound.RemoteInboundId, config.Protocol, Arg.Any<string>(),
|
Arg.Any<Node>(), inbound.RemoteInboundId, config.Protocol, Arg.Any<string>(),
|
||||||
Arg.Any<string>(), Arg.Any<CancellationToken>())
|
Arg.Any<string>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
|
||||||
.Returns(Result.Failure<string>(gatewayError));
|
.Returns(Result.Failure<string>(gatewayError));
|
||||||
|
|
||||||
var handler = new RotateVpnConfigCommandHandler(dbContext, _gateway, FakeCurrentUser.Authenticated(userId));
|
var handler = new RotateVpnConfigCommandHandler(dbContext, _gateway, _identityService, FakeCurrentUser.Authenticated(userId));
|
||||||
|
|
||||||
var result = await handler.Handle(new RotateVpnConfigCommand(config.Id), CancellationToken.None);
|
var result = await handler.Handle(new RotateVpnConfigCommand(config.Id), CancellationToken.None);
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -1,6 +1,7 @@
|
|||||||
using NSubstitute;
|
using NSubstitute;
|
||||||
using PnvPanel.Application.Auth;
|
using PnvPanel.Application.Auth;
|
||||||
using PnvPanel.Application.Common.Interfaces;
|
using PnvPanel.Application.Common.Interfaces;
|
||||||
|
using PnvPanel.Application.Common.Models;
|
||||||
using PnvPanel.Application.Tests.TestSupport;
|
using PnvPanel.Application.Tests.TestSupport;
|
||||||
using PnvPanel.Domain.Telegram;
|
using PnvPanel.Domain.Telegram;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
@@ -75,7 +76,7 @@ public class GetLoginRequestStatusQueryHandlerTests
|
|||||||
dbContext.TelegramLoginRequests.Add(request);
|
dbContext.TelegramLoginRequests.Add(request);
|
||||||
await dbContext.SaveChangesAsync(CancellationToken.None);
|
await dbContext.SaveChangesAsync(CancellationToken.None);
|
||||||
|
|
||||||
var profile = new CurrentUserProfile(userId, "alice", Guid.NewGuid(), "user", true, false, 3, "sub-token");
|
var profile = new CurrentUserProfile(userId, "alice", Guid.NewGuid(), "user", true, false, 3, RoleQuota.Unlimited, "sub-token");
|
||||||
_identityService.GetProfileAsync(userId, Arg.Any<CancellationToken>()).Returns(profile);
|
_identityService.GetProfileAsync(userId, Arg.Any<CancellationToken>()).Returns(profile);
|
||||||
_jwtTokenService.GenerateAccessToken(Arg.Any<AuthenticatedUser>())
|
_jwtTokenService.GenerateAccessToken(Arg.Any<AuthenticatedUser>())
|
||||||
.Returns(("access-token", DateTimeOffset.UtcNow.AddMinutes(15)));
|
.Returns(("access-token", DateTimeOffset.UtcNow.AddMinutes(15)));
|
||||||
|
|||||||
@@ -40,7 +40,8 @@ public class ConfigQuotaTests(PnvPanelWebApplicationFactory factory)
|
|||||||
var roles = await rolesResponse.ReadAsAsync<List<RoleResponse>>();
|
var roles = await rolesResponse.ReadAsAsync<List<RoleResponse>>();
|
||||||
var userRole = roles!.Single(r => r.Name == "user");
|
var userRole = roles!.Single(r => r.Name == "user");
|
||||||
|
|
||||||
var updateRoleResponse = await adminClient.SendPutJsonAsync($"/api/admin/roles/{userRole.Id}", new { maxConfigs = Quota });
|
var updateRoleResponse = await adminClient.SendPutJsonAsync(
|
||||||
|
$"/api/admin/roles/{userRole.Id}", new { maxConfigs = Quota, maxIpLimit = -1 });
|
||||||
Assert.Equal(HttpStatusCode.OK, updateRoleResponse.StatusCode);
|
Assert.Equal(HttpStatusCode.OK, updateRoleResponse.StatusCode);
|
||||||
|
|
||||||
var registerNodeResponse = await adminClient.PostJsonAsync("/api/admin/nodes", new
|
var registerNodeResponse = await adminClient.PostJsonAsync("/api/admin/nodes", new
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ public sealed class FakeXuiPanelGateway : IXuiPanelGateway
|
|||||||
}
|
}
|
||||||
|
|
||||||
public Task<Result<string>> AddClientAsync(
|
public Task<Result<string>> AddClientAsync(
|
||||||
Node node, string inboundRemoteId, VpnProtocol protocol, string clientEmail, string clientName,
|
Node node, string inboundRemoteId, VpnProtocol protocol, string clientEmail, string clientName, int limitIp,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
=> Task.FromResult(Result.Success(Guid.NewGuid().ToString()));
|
=> Task.FromResult(Result.Success(Guid.NewGuid().ToString()));
|
||||||
|
|
||||||
|
|||||||
+3
-2
@@ -130,8 +130,8 @@ status, createdAt }`. `expiresAt` всегда `null` (лимиты по сро
|
|||||||
| POST | `/api/admin/activation-requests/{id}/approve` | admin | — | `204 No Content` |
|
| POST | `/api/admin/activation-requests/{id}/approve` | admin | — | `204 No Content` |
|
||||||
| POST | `/api/admin/activation-requests/{id}/reject` | admin | `{ reason? }` | `204 No Content` |
|
| POST | `/api/admin/activation-requests/{id}/reject` | admin | `{ reason? }` | `204 No Content` |
|
||||||
| GET | `/api/admin/roles` | admin | — | `RoleDto[]` |
|
| GET | `/api/admin/roles` | admin | — | `RoleDto[]` |
|
||||||
| POST | `/api/admin/roles` | admin | `{ name, maxConfigs }` | `RoleDto` |
|
| POST | `/api/admin/roles` | admin | `{ name, maxConfigs, maxIpLimit }` | `RoleDto` |
|
||||||
| PUT | `/api/admin/roles/{id}` | admin | `{ maxConfigs }` | `RoleDto` |
|
| PUT | `/api/admin/roles/{id}` | admin | `{ maxConfigs, maxIpLimit }` | `RoleDto` |
|
||||||
| DELETE | `/api/admin/roles/{id}` | admin | — | `204 No Content` (системные `admin`/`user` удалить нельзя) |
|
| DELETE | `/api/admin/roles/{id}` | admin | — | `204 No Content` (системные `admin`/`user` удалить нельзя) |
|
||||||
| PATCH | `/api/admin/users/{id}/role` | admin | `{ roleId }` | `204 No Content` |
|
| PATCH | `/api/admin/users/{id}/role` | admin | `{ roleId }` | `204 No Content` |
|
||||||
|
|
||||||
@@ -168,6 +168,7 @@ approve/reject над `ActivationRequest`.
|
|||||||
| PATCH | `/api/admin/users/{id}/block` | admin | — | `204 No Content` |
|
| PATCH | `/api/admin/users/{id}/block` | admin | — | `204 No Content` |
|
||||||
| PATCH | `/api/admin/users/{id}/unblock` | admin | — | `204 No Content` |
|
| PATCH | `/api/admin/users/{id}/unblock` | admin | — | `204 No Content` |
|
||||||
| POST | `/api/admin/users/{id}/reset-password` | admin | `{ newPassword }` | `204 No Content` |
|
| POST | `/api/admin/users/{id}/reset-password` | admin | `{ newPassword }` | `204 No Content` |
|
||||||
|
| DELETE | `/api/admin/users/{id}` | admin | — | `204 No Content` (отзывает все конфиги пользователя в 3x-ui, затем удаляет учётку; себя удалить нельзя) |
|
||||||
| GET | `/api/admin/users/{id}/configs` | admin | — | `VpnConfigDto[]` |
|
| GET | `/api/admin/users/{id}/configs` | admin | — | `VpnConfigDto[]` |
|
||||||
| DELETE | `/api/admin/configs/{id}` | admin | — | `204 No Content` (принудительный отзыв любого конфига) |
|
| DELETE | `/api/admin/configs/{id}` | admin | — | `204 No Content` (принудительный отзыв любого конфига) |
|
||||||
| GET | `/api/admin/stats` | admin | — | `StatsDto` |
|
| GET | `/api/admin/stats` | admin | — | `StatsDto` |
|
||||||
|
|||||||
+13
-6
@@ -100,8 +100,12 @@ NewsPost (лента новостей; публикует
|
|||||||
удаляет старого, генерирует новый `SubscriptionToken`; квоту **не тратит**. Для случая утечки ссылки.
|
удаляет старого, генерирует новый `SubscriptionToken`; квоту **не тратит**. Для случая утечки ссылки.
|
||||||
- `Disable()`/`Enable()` → меняют только статус записи (`Active ↔ Disabled`); отключение/включение
|
- `Disable()`/`Enable()` → меняют только статус записи (`Active ↔ Disabled`); отключение/включение
|
||||||
самого клиента в 3x-ui делает хендлер отдельным вызовом гейтвея (используется при блокировке юзера).
|
самого клиента в 3x-ui делает хендлер отдельным вызовом гейтвея (используется при блокировке юзера).
|
||||||
- `Rename(label)` → юзер меняет метку (синкается в 3x-ui как имя клиента). Лимит устройств/IP (`limitIp`
|
- `Rename(label)` → юзер меняет метку (синкается в 3x-ui как имя клиента).
|
||||||
в 3x-ui) панелью не управляется — более сложная per-node настройка, задаётся напрямую в 3x-ui администратором.
|
- Лимит одновременных IP (`limitIp` в 3x-ui) выставляется при создании клиента (`Create`/`Rotate`) по
|
||||||
|
квоте роли пользователя (`AppRole.MaxIpLimit`; -1 = без лимита) — панель не даёт настраивать его
|
||||||
|
per-конфиг. Как и `MaxConfigs`, лимит применяется только к **новым** клиентам: смена роли/квоты не
|
||||||
|
трогает уже созданных клиентов в 3x-ui (см. `IXuiPanelGateway.UpdateClientAsync`, где `LimitIp`
|
||||||
|
всегда `null` — «не менять»).
|
||||||
- `UpdateTraffic(up, down)` → пишет `TrafficSyncService` при периодической синхронизации, только для отображения.
|
- `UpdateTraffic(up, down)` → пишет `TrafficSyncService` при периодической синхронизации, только для отображения.
|
||||||
- **Создание разрешено только активированному пользователю** (`AppUser.IsActivated == true`).
|
- **Создание разрешено только активированному пользователю** (`AppUser.IsActivated == true`).
|
||||||
- Число активных конфигов пользователя не может превышать **квоту его роли** (`AppRole.MaxConfigs`;
|
- Число активных конфигов пользователя не может превышать **квоту его роли** (`AppRole.MaxConfigs`;
|
||||||
@@ -203,17 +207,19 @@ UI **настойчиво напоминает** привязать его (ед
|
|||||||
|
|
||||||
### AppRole — роль с квотой (Identity, динамическая)
|
### AppRole — роль с квотой (Identity, динамическая)
|
||||||
Расширяет `IdentityRole<Guid>`. Роли **создаёт админ** и назначает пользователям; роль несёт квоту
|
Расширяет `IdentityRole<Guid>`. Роли **создаёт админ** и назначает пользователям; роль несёт квоту
|
||||||
на число конфигов.
|
на число конфигов и лимит одновременных IP на клиента в 3x-ui.
|
||||||
|
|
||||||
| Поле | Тип | Заметки |
|
| Поле | Тип | Заметки |
|
||||||
| ------------ | -------- | --------------------------------------------------------------- |
|
| ------------ | -------- | --------------------------------------------------------------- |
|
||||||
| `Id` | `Guid` | PK |
|
| `Id` | `Guid` | PK |
|
||||||
| `Name` | `string` | Напр. `admin`, `user`, `vip` |
|
| `Name` | `string` | Напр. `admin`, `user`, `vip` |
|
||||||
| `MaxConfigs` | `int` | Квота активных конфигов (для `admin` игнорируется — без лимита) |
|
| `MaxConfigs` | `int` | Квота активных конфигов (-1 = без лимита; для `admin` — без лимита) |
|
||||||
|
| `MaxIpLimit` | `int` | Лимит одновременных IP на клиента (`limitIp` в 3x-ui; -1 = без лимита; для `admin` — без лимита) |
|
||||||
| `IsSystem` | `bool` | Системная (`admin`, `user`) — нельзя удалить/переименовать |
|
| `IsSystem` | `bool` | Системная (`admin`, `user`) — нельзя удалить/переименовать |
|
||||||
|
|
||||||
Сидируются: `admin` (без лимита) и `user` (`MaxConfigs` = `Roles__DefaultUserMaxConfigs`, по умолчанию 3).
|
Сидируются: `admin` (оба лимита без ограничения) и `user` (`MaxConfigs` = `Roles__DefaultUserMaxConfigs`,
|
||||||
**У пользователя ровно одна роль**; его квота = `MaxConfigs` этой роли (`admin` → без лимита).
|
по умолчанию 3; `MaxIpLimit` = `Roles__DefaultUserMaxIpLimit`, по умолчанию 2).
|
||||||
|
**У пользователя ровно одна роль**; его квоты = `MaxConfigs`/`MaxIpLimit` этой роли (`admin` → без лимита).
|
||||||
|
|
||||||
**Понижение роли (грандфазеринг)**: смену роли на роль с меньшей квотой разрешаем даже если текущих
|
**Понижение роли (грандфазеринг)**: смену роли на роль с меньшей квотой разрешаем даже если текущих
|
||||||
конфигов больше новой квоты — существующие конфиги сохраняются, но **создание новых блокируется**,
|
конфигов больше новой квоты — существующие конфиги сохраняются, но **создание новых блокируется**,
|
||||||
@@ -301,6 +307,7 @@ enum OsPlatform { IOS, Android, Windows, MacOS, Linux }
|
|||||||
| `ChangeUserRoleCommandHandler` | `AuditLog` (`UserRoleChanged`) |
|
| `ChangeUserRoleCommandHandler` | `AuditLog` (`UserRoleChanged`) |
|
||||||
| `ForceRevokeConfigCommandHandler` | Отзывает конфиг в 3x-ui; `AuditLog` (`ConfigForceRevoked`); Telegram-DM владельцу |
|
| `ForceRevokeConfigCommandHandler` | Отзывает конфиг в 3x-ui; `AuditLog` (`ConfigForceRevoked`); Telegram-DM владельцу |
|
||||||
| `ResetUserPasswordCommandHandler` | `AuditLog` (`UserPasswordReset`) |
|
| `ResetUserPasswordCommandHandler` | `AuditLog` (`UserPasswordReset`) |
|
||||||
|
| `DeleteUserCommandHandler` | Отзывает все конфиги пользователя в 3x-ui; `AuditLog` (`UserDeleted`); Telegram-DM владельцу; затем удаляет `AppUser`. Админ не может удалить себя |
|
||||||
| `RegisterNodeCommandHandler` / `UpdateNodeCommandHandler` / `DeleteNodeCommandHandler` | `AuditLog` (`NodeRegistered`/`NodeUpdated`/`NodeDeleted`) |
|
| `RegisterNodeCommandHandler` / `UpdateNodeCommandHandler` / `DeleteNodeCommandHandler` | `AuditLog` (`NodeRegistered`/`NodeUpdated`/`NodeDeleted`) |
|
||||||
| `PublishInboundCommandHandler` | `AuditLog` (`InboundPublished`/`InboundUnpublished`) |
|
| `PublishInboundCommandHandler` | `AuditLog` (`InboundPublished`/`InboundUnpublished`) |
|
||||||
| `NodeHealthCheckService` (фон) | Обновляет `NodeStatus`; realtime `nodeStatusChanged` группе `admins` |
|
| `NodeHealthCheckService` (фон) | Обновляет `NodeStatus`; realtime `nodeStatusChanged` группе `admins` |
|
||||||
|
|||||||
+4
-3
@@ -89,9 +89,10 @@ frontend/
|
|||||||
трафик, статус), кнопки на карточке — показать ссылку/QR (запрашивает `GET .../link` по клику,
|
трафик, статус), кнопки на карточке — показать ссылку/QR (запрашивает `GET .../link` по клику,
|
||||||
не сразу при создании), перевыпустить, отозвать; отдельная карточка «Общая подписка». Для
|
не сразу при создании), перевыпустить, отозвать; отдельная карточка «Общая подписка». Для
|
||||||
неактивированного — `ActivationGate` вместо дашборда.
|
неактивированного — `ActivationGate` вместо дашборда.
|
||||||
- **Создание конфига**: диалог — выбор инбаунда (по `displayName`) + метка. Лимит устройств
|
- **Создание конфига**: диалог — выбор инбаунда (по `displayName`) + метка. Лимит одновременных IP
|
||||||
(`limitIp` в 3x-ui) панелью не управляется — задаётся при необходимости напрямую в 3x-ui. После
|
(`limitIp` в 3x-ui) выставляется автоматически по квоте роли пользователя (`AppRole.MaxIpLimit`) —
|
||||||
успеха карточка конфига появляется в списке; ссылку/QR пользователь открывает отдельно.
|
в форме создания не настраивается. После успеха карточка конфига появляется в списке; ссылку/QR
|
||||||
|
пользователь открывает отдельно.
|
||||||
- **Страница инструкций** (`/instructions`): статичные шаги + каталог приложений (`GET /api/apps`),
|
- **Страница инструкций** (`/instructions`): статичные шаги + каталог приложений (`GET /api/apps`),
|
||||||
сгруппированный по ОС и показан вкладками (по одной ОС за раз); клик по приложению открывает
|
сгруппированный по ОС и показан вкладками (по одной ОС за раз); клик по приложению открывает
|
||||||
ссылку на скачивание.
|
ссылку на скачивание.
|
||||||
|
|||||||
+2
-1
@@ -93,8 +93,9 @@
|
|||||||
| Аудит | `AuditLog` (append-only): активация, блокировка, смена роли, отзыв, ноды/инбаунды |
|
| Аудит | `AuditLog` (append-only): активация, блокировка, смена роли, отзыв, ноды/инбаунды |
|
||||||
| Ротация конфига | `Rotate()` — перевыпуск UUID/ссылки, квоту не тратит (на случай утечки) |
|
| Ротация конфига | `Rotate()` — перевыпуск UUID/ссылки, квоту не тратит (на случай утечки) |
|
||||||
| Метка конфига | `Label` — пользователь именует конфиг («Мой телефон») |
|
| Метка конфига | `Label` — пользователь именует конфиг («Мой телефон») |
|
||||||
| Лимит устройств (`limitIp`) | Панелью не управляется — задаётся при необходимости напрямую в 3x-ui администратором ноды |
|
| Лимит устройств (`limitIp`) | Квота роли (`AppRole.MaxIpLimit`; -1 = без лимита), применяется только к новым клиентам в 3x-ui |
|
||||||
| Самоудаление аккаунта | Отзыв всех активных конфигов в 3x-ui + удаление `AppUser` |
|
| Самоудаление аккаунта | Отзыв всех активных конфигов в 3x-ui + удаление `AppUser` |
|
||||||
|
| Удаление пользователя админом | `DELETE /api/admin/users/{id}` — отзыв всех конфигов в 3x-ui + удаление `AppUser`; себя удалить нельзя |
|
||||||
| Версионирование API | Без версий (`/api` без `v1`) |
|
| Версионирование API | Без версий (`/api` без `v1`) |
|
||||||
| Подписка (заголовки) | `Subscription-Userinfo` (used/total/expire) + `profile-update-interval` |
|
| Подписка (заголовки) | `Subscription-Userinfo` (used/total/expire) + `profile-update-interval` |
|
||||||
| Тема сайта | Светлая + тёмная (+ системная); выбор в localStorage |
|
| Тема сайта | Светлая + тёмная (+ системная); выбор в localStorage |
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export function RoleFormDialog({
|
|||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const [name, setName] = useState(role?.name ?? '')
|
const [name, setName] = useState(role?.name ?? '')
|
||||||
const [maxConfigs, setMaxConfigs] = useState(String(role?.maxConfigs ?? 3))
|
const [maxConfigs, setMaxConfigs] = useState(String(role?.maxConfigs ?? 3))
|
||||||
|
const [maxIpLimit, setMaxIpLimit] = useState(String(role?.maxIpLimit ?? 2))
|
||||||
const [internalOpen, setInternalOpen] = useState(false)
|
const [internalOpen, setInternalOpen] = useState(false)
|
||||||
|
|
||||||
const isControlled = open !== undefined
|
const isControlled = open !== undefined
|
||||||
@@ -30,13 +31,17 @@ export function RoleFormDialog({
|
|||||||
const setDialogOpen = isControlled ? onOpenChange! : setInternalOpen
|
const setDialogOpen = isControlled ? onOpenChange! : setInternalOpen
|
||||||
|
|
||||||
const mutation = useMutation({
|
const mutation = useMutation({
|
||||||
mutationFn: () => (role ? updateRole(role.id, Number(maxConfigs)) : createRole(name.trim(), Number(maxConfigs))),
|
mutationFn: () =>
|
||||||
|
role
|
||||||
|
? updateRole(role.id, Number(maxConfigs), Number(maxIpLimit))
|
||||||
|
: createRole(name.trim(), Number(maxConfigs), Number(maxIpLimit)),
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
toast.success(role ? t('admin.roles.updated') : t('admin.roles.created'))
|
toast.success(role ? t('admin.roles.updated') : t('admin.roles.created'))
|
||||||
await queryClient.invalidateQueries({ queryKey: ['admin-roles'] })
|
await queryClient.invalidateQueries({ queryKey: ['admin-roles'] })
|
||||||
setDialogOpen(false)
|
setDialogOpen(false)
|
||||||
setName('')
|
setName('')
|
||||||
setMaxConfigs('3')
|
setMaxConfigs('3')
|
||||||
|
setMaxIpLimit('2')
|
||||||
},
|
},
|
||||||
onError: () => toast.error(t('auth.genericError')),
|
onError: () => toast.error(t('auth.genericError')),
|
||||||
})
|
})
|
||||||
@@ -70,6 +75,11 @@ export function RoleFormDialog({
|
|||||||
<Input id="maxConfigs" type="number" value={maxConfigs} onChange={(e) => setMaxConfigs(e.target.value)} />
|
<Input id="maxConfigs" type="number" value={maxConfigs} onChange={(e) => setMaxConfigs(e.target.value)} />
|
||||||
<p className="text-xs text-muted-foreground">{t('admin.roles.maxConfigsHint')}</p>
|
<p className="text-xs text-muted-foreground">{t('admin.roles.maxConfigsHint')}</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="maxIpLimit">{t('admin.roles.maxIpLimit')}</Label>
|
||||||
|
<Input id="maxIpLimit" type="number" value={maxIpLimit} onChange={(e) => setMaxIpLimit(e.target.value)} />
|
||||||
|
<p className="text-xs text-muted-foreground">{t('admin.roles.maxIpLimitHint')}</p>
|
||||||
|
</div>
|
||||||
<Button type="submit" disabled={mutation.isPending || (!role && !name.trim())}>
|
<Button type="submit" disabled={mutation.isPending || (!role && !name.trim())}>
|
||||||
{role ? t('admin.roles.save') : t('admin.roles.create')}
|
{role ? t('admin.roles.save') : t('admin.roles.create')}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -5,12 +5,12 @@ export function listRoles() {
|
|||||||
return apiRequest<RoleDto[]>('/admin/roles')
|
return apiRequest<RoleDto[]>('/admin/roles')
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createRole(name: string, maxConfigs: number) {
|
export function createRole(name: string, maxConfigs: number, maxIpLimit: number) {
|
||||||
return apiRequest<RoleDto>('/admin/roles', { method: 'POST', body: { name, maxConfigs } })
|
return apiRequest<RoleDto>('/admin/roles', { method: 'POST', body: { name, maxConfigs, maxIpLimit } })
|
||||||
}
|
}
|
||||||
|
|
||||||
export function updateRole(id: string, maxConfigs: number) {
|
export function updateRole(id: string, maxConfigs: number, maxIpLimit: number) {
|
||||||
return apiRequest<RoleDto>(`/admin/roles/${id}`, { method: 'PUT', body: { maxConfigs } })
|
return apiRequest<RoleDto>(`/admin/roles/${id}`, { method: 'PUT', body: { maxConfigs, maxIpLimit } })
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deleteRole(id: string) {
|
export function deleteRole(id: string) {
|
||||||
|
|||||||
@@ -9,10 +9,12 @@ import { Label } from '@/shared/ui/label'
|
|||||||
import { Badge } from '@/shared/ui/badge'
|
import { Badge } from '@/shared/ui/badge'
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
|
||||||
import { listRoles } from '@/features/admin/roles/api'
|
import { listRoles } from '@/features/admin/roles/api'
|
||||||
|
import { useAuthStore } from '@/features/auth/store'
|
||||||
import type { UserSummaryDto } from '@/shared/api/types'
|
import type { UserSummaryDto } from '@/shared/api/types'
|
||||||
import {
|
import {
|
||||||
blockUser,
|
blockUser,
|
||||||
changeUserRole,
|
changeUserRole,
|
||||||
|
deleteUser,
|
||||||
forceRevokeConfig,
|
forceRevokeConfig,
|
||||||
getUserConfigs,
|
getUserConfigs,
|
||||||
resetUserPassword,
|
resetUserPassword,
|
||||||
@@ -23,6 +25,7 @@ export function UserManageDialog({ user, open, onOpenChange }: { user: UserSumma
|
|||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const [newPassword, setNewPassword] = useState('')
|
const [newPassword, setNewPassword] = useState('')
|
||||||
|
const currentUserId = useAuthStore((state) => state.user?.id)
|
||||||
|
|
||||||
const rolesQuery = useQuery({ queryKey: ['admin-roles'], queryFn: listRoles, enabled: open })
|
const rolesQuery = useQuery({ queryKey: ['admin-roles'], queryFn: listRoles, enabled: open })
|
||||||
const configsQuery = useQuery({ queryKey: ['admin-user-configs', user.id], queryFn: () => getUserConfigs(user.id), enabled: open })
|
const configsQuery = useQuery({ queryKey: ['admin-user-configs', user.id], queryFn: () => getUserConfigs(user.id), enabled: open })
|
||||||
@@ -65,6 +68,16 @@ export function UserManageDialog({ user, open, onOpenChange }: { user: UserSumma
|
|||||||
onError: () => toast.error(t('auth.genericError')),
|
onError: () => toast.error(t('auth.genericError')),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: () => deleteUser(user.id),
|
||||||
|
onSuccess: async () => {
|
||||||
|
toast.success(t('admin.users.deleted'))
|
||||||
|
onOpenChange(false)
|
||||||
|
await invalidateUsers()
|
||||||
|
},
|
||||||
|
onError: () => toast.error(t('auth.genericError')),
|
||||||
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent className="max-w-lg">
|
<DialogContent className="max-w-lg">
|
||||||
@@ -147,6 +160,18 @@ export function UserManageDialog({ user, open, onOpenChange }: { user: UserSumma
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{user.id !== currentUserId && (
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
disabled={deleteMutation.isPending}
|
||||||
|
onClick={() => {
|
||||||
|
if (confirm(t('admin.users.confirmDelete'))) deleteMutation.mutate()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('admin.users.delete')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|||||||
@@ -30,3 +30,7 @@ export function forceRevokeConfig(id: string) {
|
|||||||
export function changeUserRole(id: string, roleId: string) {
|
export function changeUserRole(id: string, roleId: string) {
|
||||||
return apiRequest<void>(`/admin/users/${id}/role`, { method: 'PATCH', body: { roleId } })
|
return apiRequest<void>(`/admin/users/${id}/role`, { method: 'PATCH', body: { roleId } })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function deleteUser(id: string) {
|
||||||
|
return apiRequest<void>(`/admin/users/${id}`, { method: 'DELETE' })
|
||||||
|
}
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ function AdminRolesPage() {
|
|||||||
<tr className="border-b border-border text-muted-foreground">
|
<tr className="border-b border-border text-muted-foreground">
|
||||||
<th className="py-2 font-medium">{t('admin.roles.name')}</th>
|
<th className="py-2 font-medium">{t('admin.roles.name')}</th>
|
||||||
<th className="py-2 font-medium">{t('admin.roles.maxConfigs')}</th>
|
<th className="py-2 font-medium">{t('admin.roles.maxConfigs')}</th>
|
||||||
|
<th className="py-2 font-medium">{t('admin.roles.maxIpLimit')}</th>
|
||||||
<th className="py-2" />
|
<th className="py-2" />
|
||||||
<th className="py-2" />
|
<th className="py-2" />
|
||||||
</tr>
|
</tr>
|
||||||
@@ -62,6 +63,7 @@ function AdminRolesPage() {
|
|||||||
{role.name} {role.isSystem && <Badge variant="outline">{t('admin.roles.system')}</Badge>}
|
{role.name} {role.isSystem && <Badge variant="outline">{t('admin.roles.system')}</Badge>}
|
||||||
</td>
|
</td>
|
||||||
<td className="py-2">{role.maxConfigs < 0 ? t('unlimited') : role.maxConfigs}</td>
|
<td className="py-2">{role.maxConfigs < 0 ? t('unlimited') : role.maxConfigs}</td>
|
||||||
|
<td className="py-2">{role.maxIpLimit < 0 ? t('unlimited') : role.maxIpLimit}</td>
|
||||||
<td className="py-2 text-right">
|
<td className="py-2 text-right">
|
||||||
<Button size="sm" variant="outline" onClick={() => setEditing(role)}>
|
<Button size="sm" variant="outline" onClick={() => setEditing(role)}>
|
||||||
{t('admin.roles.edit')}
|
{t('admin.roles.edit')}
|
||||||
|
|||||||
@@ -1854,6 +1854,8 @@ export interface components {
|
|||||||
name: string;
|
name: string;
|
||||||
/** Format: int32 */
|
/** Format: int32 */
|
||||||
maxConfigs: number | string;
|
maxConfigs: number | string;
|
||||||
|
/** Format: int32 */
|
||||||
|
maxIpLimit: number | string;
|
||||||
};
|
};
|
||||||
CurrentUserDto: {
|
CurrentUserDto: {
|
||||||
/** Format: uuid */
|
/** Format: uuid */
|
||||||
@@ -1987,6 +1989,8 @@ export interface components {
|
|||||||
name: string;
|
name: string;
|
||||||
/** Format: int32 */
|
/** Format: int32 */
|
||||||
maxConfigs: number | string;
|
maxConfigs: number | string;
|
||||||
|
/** Format: int32 */
|
||||||
|
maxIpLimit: number | string;
|
||||||
isSystem: boolean;
|
isSystem: boolean;
|
||||||
};
|
};
|
||||||
StatsDto: {
|
StatsDto: {
|
||||||
@@ -2050,6 +2054,8 @@ export interface components {
|
|||||||
UpdateRoleBody: {
|
UpdateRoleBody: {
|
||||||
/** Format: int32 */
|
/** Format: int32 */
|
||||||
maxConfigs: number | string;
|
maxConfigs: number | string;
|
||||||
|
/** Format: int32 */
|
||||||
|
maxIpLimit: number | string;
|
||||||
};
|
};
|
||||||
UserSummaryDto: {
|
UserSummaryDto: {
|
||||||
/** Format: uuid */
|
/** Format: uuid */
|
||||||
|
|||||||
@@ -136,6 +136,7 @@ export type RoleDto = {
|
|||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
maxConfigs: number
|
maxConfigs: number
|
||||||
|
maxIpLimit: number
|
||||||
isSystem: boolean
|
isSystem: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -182,6 +182,9 @@ const resources = {
|
|||||||
reset: 'Сбросить',
|
reset: 'Сбросить',
|
||||||
passwordReset: 'Пароль сброшен.',
|
passwordReset: 'Пароль сброшен.',
|
||||||
configs: 'Конфиги',
|
configs: 'Конфиги',
|
||||||
|
delete: 'Удалить пользователя',
|
||||||
|
confirmDelete: 'Удалить пользователя? Все его конфиги будут отозваны в 3x-ui, действие необратимо.',
|
||||||
|
deleted: 'Пользователь удалён.',
|
||||||
},
|
},
|
||||||
activation: {
|
activation: {
|
||||||
empty: 'Нет ожидающих запросов на активацию.',
|
empty: 'Нет ожидающих запросов на активацию.',
|
||||||
@@ -195,6 +198,8 @@ const resources = {
|
|||||||
name: 'Название',
|
name: 'Название',
|
||||||
maxConfigs: 'Квота конфигов',
|
maxConfigs: 'Квота конфигов',
|
||||||
maxConfigsHint: '−1 = без лимита.',
|
maxConfigsHint: '−1 = без лимита.',
|
||||||
|
maxIpLimit: 'Лимит IP на конфиг',
|
||||||
|
maxIpLimitHint: '−1 = без лимита. Применяется только к новым конфигам.',
|
||||||
system: 'системная',
|
system: 'системная',
|
||||||
edit: 'Изменить',
|
edit: 'Изменить',
|
||||||
save: 'Сохранить',
|
save: 'Сохранить',
|
||||||
@@ -469,6 +474,9 @@ const resources = {
|
|||||||
reset: 'Reset',
|
reset: 'Reset',
|
||||||
passwordReset: 'Password reset.',
|
passwordReset: 'Password reset.',
|
||||||
configs: 'Configs',
|
configs: 'Configs',
|
||||||
|
delete: 'Delete user',
|
||||||
|
confirmDelete: 'Delete this user? All their configs will be revoked in 3x-ui — this cannot be undone.',
|
||||||
|
deleted: 'User deleted.',
|
||||||
},
|
},
|
||||||
activation: {
|
activation: {
|
||||||
empty: 'No pending activation requests.',
|
empty: 'No pending activation requests.',
|
||||||
@@ -482,6 +490,8 @@ const resources = {
|
|||||||
name: 'Name',
|
name: 'Name',
|
||||||
maxConfigs: 'Config quota',
|
maxConfigs: 'Config quota',
|
||||||
maxConfigsHint: '−1 = unlimited.',
|
maxConfigsHint: '−1 = unlimited.',
|
||||||
|
maxIpLimit: 'IP limit per config',
|
||||||
|
maxIpLimitHint: '−1 = unlimited. Applies to new configs only.',
|
||||||
system: 'system',
|
system: 'system',
|
||||||
edit: 'Edit',
|
edit: 'Edit',
|
||||||
save: 'Save',
|
save: 'Save',
|
||||||
|
|||||||
Reference in New Issue
Block a user