From 24d9ea10990697cba13964307b81cb4186bd6533 Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Mon, 13 Jul 2026 07:18:13 +0300 Subject: [PATCH] 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. --- .env.example | 2 + CLAUDE.md | 196 ++--- .../Endpoints/AdminUserEndpoints.cs | 7 + .../PnvPanel.Api/Endpoints/RoleEndpoints.cs | 4 +- .../Admin/Roles/CreateRoleCommand.cs | 2 +- .../Admin/Roles/CreateRoleCommandHandler.cs | 2 +- .../Admin/Roles/CreateRoleCommandValidator.cs | 1 + .../Admin/Roles/UpdateRoleCommand.cs | 2 +- .../Admin/Roles/UpdateRoleCommandHandler.cs | 2 +- .../Admin/Roles/UpdateRoleCommandValidator.cs | 1 + .../Admin/Users/DeleteUserCommand.cs | 6 + .../Admin/Users/DeleteUserCommandHandler.cs | 49 ++ .../Admin/Users/UserErrors.cs | 3 + .../Common/Interfaces/IIdentityService.cs | 3 +- .../Common/Interfaces/IRoleService.cs | 6 +- .../Common/Interfaces/IXuiPanelGateway.cs | 8 +- .../Create/CreateVpnConfigCommandHandler.cs | 2 +- .../Rotate/RotateVpnConfigCommandHandler.cs | 9 +- .../Identity/AppRole.cs | 9 +- .../Identity/DbInitializer.cs | 8 +- .../Identity/IdentityService.cs | 3 +- .../Identity/RoleService.cs | 11 +- .../Identity/RolesOptions.cs | 1 + ...260713035342_AddRoleMaxIpLimit.Designer.cs | 776 ++++++++++++++++++ .../20260713035342_AddRoleMaxIpLimit.cs | 34 + .../Migrations/AppDbContextModelSnapshot.cs | 3 + .../Xui/XuiPanelGateway.cs | 9 +- .../Users/DeleteUserCommandHandlerTests.cs | 108 +++ .../Auth/GetCurrentUserQueryHandlerTests.cs | 3 +- .../Auth/LoginCommandHandlerTests.cs | 4 +- .../Auth/RefreshCommandHandlerTests.cs | 4 +- .../GetMyConfigsQueryHandlerTests.cs | 3 +- .../RotateVpnConfigCommandHandlerTests.cs | 20 +- .../GetLoginRequestStatusQueryHandlerTests.cs | 3 +- .../Configs/ConfigQuotaTests.cs | 3 +- .../TestSupport/FakeXuiPanelGateway.cs | 2 +- docs/api-design.md | 5 +- docs/domain-model.md | 19 +- docs/frontend.md | 7 +- docs/tech-stack.md | 3 +- .../features/admin/roles/RoleFormDialog.tsx | 12 +- frontend/src/features/admin/roles/api.ts | 8 +- .../features/admin/users/UserManageDialog.tsx | 25 + frontend/src/features/admin/users/api.ts | 4 + frontend/src/routes/admin/roles.tsx | 2 + frontend/src/shared/api/schema.gen.ts | 6 + frontend/src/shared/api/types.ts | 1 + frontend/src/shared/lib/i18n.ts | 10 + 48 files changed, 1240 insertions(+), 171 deletions(-) create mode 100644 backend/src/PnvPanel.Application/Admin/Users/DeleteUserCommand.cs create mode 100644 backend/src/PnvPanel.Application/Admin/Users/DeleteUserCommandHandler.cs create mode 100644 backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260713035342_AddRoleMaxIpLimit.Designer.cs create mode 100644 backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260713035342_AddRoleMaxIpLimit.cs create mode 100644 backend/tests/PnvPanel.Application.Tests/Admin/Users/DeleteUserCommandHandlerTests.cs diff --git a/.env.example b/.env.example index 7d042d7..0a5dfd8 100644 --- a/.env.example +++ b/.env.example @@ -29,6 +29,8 @@ AdminSeed__Password=change-me-strong-admin-password # ── Роли по умолчанию ───────────────────────────────────────────────────── # Квота конфигов для системной роли "user" (выдаётся при регистрации). Roles__DefaultUserMaxConfigs=3 +# Лимит одновременных IP на клиента (limitIp в 3x-ui) для системной роли "user"; -1 = без лимита. +Roles__DefaultUserMaxIpLimit=2 # ── Rate limiting ──────────────────────────────────────────────────────── # Лимит запросов/мин на auth-эндпоинты (login/register/refresh/telegram/subscription). По умолчанию 20. diff --git a/CLAUDE.md b/CLAUDE.md index f422b73..8f95b3f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,151 +4,129 @@ ## Что это -**PnvPanel** — self-service портал для VPN-конфигураций. Пользователи сами создают себе конфиги -(VLESS/VMess/Trojan/Shadowsocks), админ управляет серверами и пользователями. Есть **Telegram-бот** -(ссылка на сайт, просмотр конфигов, passwordless-вход через привязку Telegram). Бэкенд оркестрирует -панели **3x-ui** через библиотеку [`ThreeXui.Net`](https://github.com/mrleo1nid/ThreeXui.Net) и -хранит свою проекцию домена в PostgreSQL. Живые обновления — по SignalR. Приложение (фронт + бек + -бот) поставляется **единым Docker-образом**; PostgreSQL — отдельным контейнером в compose. +**PnvPanel** — self-service портал для VPN-конфигураций (VLESS/VMess/Trojan/Shadowsocks): пользователи +сами создают конфиги, админ управляет серверами и пользователями. Есть **Telegram-бот** (ссылка на +сайт, просмотр конфигов, passwordless-вход). Бэкенд оркестрирует панели **3x-ui** через +[`ThreeXui.Net`](https://github.com/mrleo1nid/ThreeXui.Net) и хранит проекцию домена в PostgreSQL. +Живые обновления — SignalR. Поставка — **единый Docker-образ** (фронт+бек+бот) + PostgreSQL в compose. -> Бэкенд и фронтенд полностью собраны и покрыты тестами (134 бэкенд-теста), единый Docker-образ и -> docker-compose стек проверены живьём. Осознанно не реализовано: тарифы, лимиты трафика/срока на -> конфиг, полное самообслуживание в боте — см. [tech-stack.md](docs/tech-stack.md). +> Собрано и покрыто тестами, единый образ и compose-стек проверены живьём. Осознанно не реализовано: +> тарифы, лимиты трафика/срока на конфиг, полное самообслуживание в боте — см. [tech-stack.md](docs/tech-stack.md). ## Документация (single source of truth) Прежде чем менять архитектуру или добавлять фичу — свериться с [`docs/`](docs/README.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) -- [Frontend](docs/frontend.md) · [Telegram Bot](docs/telegram-bot.md) · [API Design](docs/api-design.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) · +[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 (**собственный тонкий - диспетчер**, без MediatR), EF Core 10 + Npgsql (PostgreSQL), ASP.NET Core Identity + JWT, SignalR, - FluentValidation, **Serilog** (логирование). Маппинг DTO — вручную (`FromDomain(...)`), Mapster в - проект не попал. OpenAPI — нативный `Microsoft.AspNetCore.OpenApi` + Scalar UI, без Swashbuckle. -- **Frontend**: React 19 + Vite + TypeScript, TanStack Query/Router, shadcn-стиль поверх Radix + - Tailwind CSS v4, Zustand (только auth-стор), react-hook-form + zod, @microsoft/signalr. Пакетный - менеджер — pnpm, линтер — oxlint. `recharts`/`@tanstack/react-table` установлены, но не - используются (статистика — карточками, таблицы — руками). -- **Telegram**: Telegram.Bot, бот как `BackgroundService` **в процессе Api** (long polling). -- **Инфра**: единый Docker-образ (API + бот + статика SPA) + PostgreSQL в docker-compose. + диспетчер**, без MediatR), EF Core 10 + Npgsql, ASP.NET Core Identity + JWT, SignalR, FluentValidation, + Serilog. Маппинг DTO вручную (`FromDomain(...)`, без Mapster). OpenAPI — нативный + `Microsoft.AspNetCore.OpenApi` + Scalar UI (без Swashbuckle). +- **Frontend**: React 19 + Vite + TS, TanStack Query/Router, shadcn-стиль поверх Radix + Tailwind v4, + Zustand (только auth), react-hook-form + zod, @microsoft/signalr. pnpm, oxlint. +- **Telegram**: Telegram.Bot как `BackgroundService` **в процессе Api** (long polling). +- **Инфра**: единый Docker-образ (API+бот+статика SPA) + PostgreSQL в docker-compose. ## Архитектура — жёсткие правила Слои и направление зависимостей: **Api → Infrastructure → Application → Domain** (внутрь). - **Domain** — без внешних зависимостей. Rich model: инварианты в сущностях (приватные сеттеры, - фабричные методы, поведенческие методы). Никаких EF/HTTP/Identity здесь. -- **Application** — CQRS-хендлеры, DTO, валидаторы, **порты** (интерфейсы). Зависит только от Domain. - Никаких `Npgsql`/`SignalR`/`ThreeXui.Net` — только их интерфейсы (`IAppDbContext`, `IXuiPanelGateway`, + фабричные/поведенческие методы). Никаких EF/HTTP/Identity. +- **Application** — CQRS-хендлеры, DTO, валидаторы, **порты** (интерфейсы). Зависит только от Domain — + никаких `Npgsql`/`SignalR`/`ThreeXui.Net`, только их интерфейсы (`IAppDbContext`, `IXuiPanelGateway`, `IRealtimeNotifier`, `ISecretProtector`, `ICurrentUser`, ...). - **Infrastructure** — реализации портов: EF Core, Identity/JWT, `XuiPanelGateway`, SignalR-пуш, фоновые сервисы, шифрование секретов. - **Api** — Minimal API эндпоинты (по фичам), SignalR-хабы, middleware, DI composition root. Обязательно: -- CQRS: команды меняют состояние и идут в транзакции (UnitOfWorkBehavior); запросы только читают - (`AsNoTracking` + проекция в DTO). Диспетчер — **собственный** (`ISender`/`ICommandHandler`/ - `IQueryHandler`, регистрация хендлеров через DI), без внешних CQRS-библиотек. -- Управляемые ошибки — через `Result`, не исключениями. Исключения — только для исключительного. +- Команды меняют состояние в транзакции (`UnitOfWorkBehavior`); запросы только читают (`AsNoTracking` + + проекция в DTO). Диспетчер — **собственный** (`ISender`/`ICommandHandler`/`IQueryHandler`, DI-регистрация). +- Управляемые ошибки — через `Result`, не исключениями (исключения только для исключительного). - Валидация — FluentValidation через `ValidationBehavior`; хендлер не перепроверяет формат ввода. - Всё I/O асинхронно, `CancellationToken` пробрасывается до EF/HTTP. Никаких `.Result`/`.Wait()`. - Nullable reference types включены; предупреждения анализаторов не игнорировать. ## Интеграция с 3x-ui -- Только через порт `IXuiPanelGateway`. `ThreeXui.Net` регистрируется на один `BaseAddress`, а нод - много → гейтвей держит **клиента per-node** (кэш по `NodeId`), создавая его из расшифрованных - `NodeCredentials`. Детали — в [architecture.md](docs/architecture.md#интеграция-с-3x-ui-threexuinet). -- Пароли нод **шифруются at-rest** (`ISecretProtector`), расшифровка только внутри Infrastructure, - никогда не в логах/ответах API. +- Только через порт `IXuiPanelGateway`. Один `BaseAddress` в `ThreeXui.Net`, а нод много → гейтвей + держит **клиента per-node** (кэш по `NodeId`) из расшифрованных `NodeCredentials`. Детали — + [architecture.md](docs/architecture.md#интеграция-с-3x-ui-threexuinet). +- Пароли нод **шифруются at-rest** (`ISecretProtector`), расшифровка только в Infrastructure, никогда в логах/ответах. - Недоступность ноды → `Result.Failure`/`NodeStatus.Offline`, не 500 наружу. -- Операции с 3x-ui идемпотентны; при частичном сбое (клиент создан в панели, но упала БД) — компенсация. +- Операции идемпотентны; при частичном сбое (клиент создан в панели, упала БД) — компенсация. -## Роли, активация, сидинг +## Домен: роли, активация, конфиги -- **Роли динамические**: `AppRole : IdentityRole` + поле `MaxConfigs` (квота на число конфигов). - Квота — **на роли, а не на `Plan`**. **У пользователя ровно одна роль**; квота = `MaxConfigs` его - роли (`admin` — без лимита). Системные роли (`admin`/`user`) не удалять/переименовывать. -- **Активация**: новый пользователь `IsActivated = false`, роль `user`. Конфиги может создавать - только активированный. `ActivationRequest` (с комментарием заявителя) одобряет админ на сайте - **или** в Telegram — одними и теми же командами (`ApproveActivationCommand`/`RejectActivationCommand`). -- **Инбаунды по ролям**: `Inbound.AllowedRoles` (M:N). При создании конфига доменный инвариант - проверяет: активирован + под квотой роли + роль входит в `AllowedRoles` инбаунда + нода включена. - Проверку квоты делать **в транзакции** (гонки параллельных созданий). -- **Понижение роли — грандфазеринг**: смена на меньшую квоту разрешена; лишние конфиги не отзываем, - но новые нельзя до входа в квоту. -- **Блокировка** (`AppUser.IsBlocked`): вход запрещён + все конфиги `Disabled` (отключить клиентов - в 3x-ui); разблокировка — обратно. Действие в `AuditLog`. -- **Аудит**: значимые действия (активация, блок, смена роли, отзыв, ноды/инбаунды) писать в `AuditLog` +Полная модель — [domain-model.md](docs/domain-model.md). Ключевые инварианты: + +- **Роли динамические** (`AppRole`), квоты на роли (не на `Plan`): `MaxConfigs` (число конфигов), + `MaxIpLimit` (лимит одновременных IP клиента в 3x-ui, `limitIp`); -1 = без лимита. У пользователя + ровно одна роль; `admin` — без лимитов. Системные роли `admin`/`user` не удалять/переименовывать. + Понижение роли — грандфазеринг (лишние конфиги не отзываются, новые блокируются до входа в квоту). +- **`limitIp`** выставляется автоматически по `MaxIpLimit` роли при создании клиента (`Create`/`Rotate`); + панель не даёт настраивать его per-конфиг и не трогает уже созданных клиентов при смене роли/квоты. +- **Активация**: новый пользователь `IsActivated=false`, роль `user`; конфиги создаёт только + активированный. `ActivationRequest` одобряет админ на сайте или в Telegram — одними командами. +- **Инбаунды по ролям** (`Inbound.AllowedRoles`, M:N): создание конфига проверяет активацию + квоту роли + (в транзакции — гонки параллельных созданий) + `AllowedRoles` + включённость ноды. +- **Блокировка** (`AppUser.IsBlocked`): вход запрещён + все конфиги `Disabled` в 3x-ui; в `AuditLog`. +- **Удаление пользователя** — свой аккаунт (`DELETE /api/auth/me`) или админом + (`DELETE /api/admin/users/{id}`, себя удалить нельзя): отзыв всех конфигов в 3x-ui, затем `AppUser`; + админский путь дополнительно пишет `AuditLog` (`UserDeleted`) и шлёт Telegram-DM. +- **Аудит**: значимые действия (активация/блок/роль/отзыв/ноды/инбаунды/удаление) — `AuditLog` (append-only, источник Web/Telegram/System). -- **Подписка**: агрегированная на юзера (`AppUser.SubscriptionToken`, все активные конфиги) + по конфигу. -- **Ротация конфига** (`Rotate()`): новый UUID/ссылка, квоту не тратит. **Бот — read-only** по конфигам. -- **Конфиг**: пользователь задаёт метку (`Label`), может редактировать. Лимит устройств (`limitIp` в 3x-ui) панелью не управляется — более сложная настройка, задаётся при необходимости администратором ноды напрямую в 3x-ui. -- **Самоудаление аккаунта** (`DELETE /api/auth/me`): отзыв всех конфигов + удаление данных, аудит анонимизируется. -- **API без версионирования** (`/api` без `v1`). Подписка отдаёт `Subscription-Userinfo`. -- **Тема**: светлая/тёмная/системная (Tailwind `dark`, выбор в localStorage). -- **Инструкции + приложения**: отдельная страница инструкций; каталог `ClientApp` (админ CRUD: - название/ссылка/ОС/порядок/вкл), пользователю `GET /api/apps` отдаётся сгруппированным по ОС. -- **Вход — по `UserName`** (email в системе не используется вовсе; SMTP не нужен). - Восстановление пароля: через привязанный 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, как у любого пользователя. +- **Ротация конфига** (`Rotate()`) — новый UUID/ссылка, квоту не тратит. **Бот read-only** по конфигам. +- **Подписка**: агрегированная (`AppUser.SubscriptionToken`) + по конфигу. API без версионирования + (`/api`, без `v1`); подписка отдаёт `Subscription-Userinfo`. +- **Вход по `UserName`** (email не используется). Восстановление пароля — через привязанный Telegram + (self-service) либо сбросом админом (`ResetUserPasswordCommand`); без привязки UI настойчиво предлагает привязать. +- **Сидинг**: идемпотентный `DbInitializer` создаёт системные роли + админа из env + (`AdminSeed__Username`/`Password`); каталог `ClientApp` — из [`seed/client-apps.json`](seed/client-apps.json). + Источник примера env — [`.env.example`](.env.example), обновляй при новых настройках. Секреты — только через env. + `Telegram__AdminTelegramUserIds` — отдельно от сидинга, не пишется в БД, читается напрямую из `TelegramOptions`. ## Telegram-бот -- Бот — **presentation-адаптер**, не бизнес-слой. Хостится в процессе Api (`TelegramBotHostedService`, - long polling). Хендлеры апдейтов вызывают **те же** CQRS-команды/запросы через собственный `ISender` - (`GetMyConfigsQuery`, `LinkTelegramCommand`, `ApproveTelegramLoginCommand`, ...). -- `Telegram.Bot` не проникает в Application/Domain — только в `Api/Telegram/`. -- Passwordless-вход выпускает **те же** JWT/refresh, что и обычный логин. Требует привязки Telegram — - либо привязка существующего аккаунта (с сайта), либо регистрация прямо из бота (`RegisterViaTelegramCommand`): - логин — Telegram `@username`, при отсутствии/занятости — Telegram id; пароль генерируется и - присылается в чат один раз (логин можно сменить позже в Настройках, `ChangeUserNameCommand`). - Новый аккаунт получает роль `user` и `IsActivated = false` — активация нужна как обычно. -- Токены привязки/входа: короткоживущие, одноразовые, высокоэнтропийные. `Telegram:BotToken` — секрет, - не логировать. Панель должна работать и без бота (если токен не задан — бот просто не стартует). -- Детали флоу — [telegram-bot.md](docs/telegram-bot.md). +Детали флоу — [telegram-bot.md](docs/telegram-bot.md). + +- Presentation-адаптер, не бизнес-слой: `TelegramBotHostedService` (long polling) вызывает **те же** + CQRS-команды через `ISender`. `Telegram.Bot` не проникает в Application/Domain — только `Api/Telegram/`. +- Passwordless-вход выпускает те же JWT/refresh, что и обычный логин; требует привязки Telegram (с сайта) + либо регистрации прямо из бота (`RegisterViaTelegramCommand` — логин `@username`/id, пароль генерируется + и приходит в чат один раз). Новый аккаунт — роль `user`, `IsActivated=false`, активация как обычно. +- Токены привязки/входа — короткоживущие одноразовые. `Telegram:BotToken` — секрет, не логировать. + Без токена бот просто не стартует — панель работает и без него. ## Единый контейнер -- Один образ приложения: Api раздаёт REST (`/api`), SignalR (`/hubs`), хостит бота **и** статику SPA - из `wwwroot` (fallback на `index.html`). Фронт и бек — один origin, база API — относительный `/api`. -- Multi-stage Dockerfile: node (сборка фронта) → dotnet sdk (publish + копирование в `wwwroot`) → aspnet runtime. -- docker-compose: `app` (единый образ) + `db` (PostgreSQL). В dev — Vite-прокси `/api`,`/hubs` на бэк. -- Не вводи отдельный nginx-контейнер для статики без явной просьбы — это ломает требование единого контейнера. -- **TLS — внешний** (прокси/шлюз вне compose); `app` отдаёт HTTP + доверяет `X-Forwarded-*` через - `ForwardedHeaders` (иначе Secure-cookie/схема за прокси сломаются). Свой nginx/Caddy не добавляй. -- **Миграции** применяются авто на старте. **CI** (GitHub Actions) — только build/test, без деплоя. +- Один образ: Api раздаёт REST (`/api`), SignalR (`/hubs`), хостит бота и статику SPA из `wwwroot` + (fallback на `index.html`). Один origin, база API — относительный `/api`. +- Multi-stage Dockerfile: node (фронт) → dotnet sdk (publish + копирование в `wwwroot`) → aspnet runtime. +- docker-compose: `app` + `db` (PostgreSQL); в dev — Vite-прокси `/api`,`/hubs` на бэк. +- Не вводи отдельный nginx-контейнер для статики без явной просьбы — ломает требование единого контейнера. +- **TLS — внешний**; `app` отдаёт HTTP + доверяет `X-Forwarded-*` (`ForwardedHeaders`). Свой nginx/Caddy не добавляй. +- Миграции применяются авто на старте. CI (GitHub Actions) — только build/test, без деплоя. ## Соглашения по коду -Полный список — в [backend-conventions.md](docs/backend-conventions.md). Кратко: +Полный список — [backend-conventions.md](docs/backend-conventions.md). Кратко: -- Команды `Command`, запросы `Query`, + `Handler`/`Validator` (валидатор — - не для каждой команды, только где есть что проверить). Application DTO — суффикс `Dto` - (`FromDomain(...)` конвертирует из сущности); тела запросов Api-слоя — суффикс `Body`; тела ответов, - которых нет как Application DTO — суффикс `ResponseDto`. -- Application организована **по фичам** (feature folders) внутри слоёв. -- Один публичный тип на файл, имя файла = имя типа (кроме вспомогательных `Body`/`ResponseDto` - records — они живут в том же файле, что и класс эндпоинтов). Async-методы — суффикс `Async` + `CancellationToken`. -- Секреты не логировать; логи — Serilog (`UseSerilogRequestLogging` + `Enrich.FromLogContext()`). - Явного обогащения `UserId`/`NodeId`/`ConfigId`/`CorrelationId` пока нет — не полагайся на него при - расследовании, пока не добавлено. -- Ошибки API — единый `application/problem+json` (без Swashbuckle — нативный `Microsoft.AspNetCore.OpenApi`). +- `Command`/`Query` + `Handler`/`Validator` (валидатор — где есть что + проверить). DTO — суффикс `Dto`; тела запросов Api — `Body`; тела ответов без Application DTO — + `ResponseDto`. Application — по фичам (feature folders). +- Один публичный тип на файл = имя файла (кроме `Body`/`ResponseDto` в файле эндпоинтов). + Async-методы — суффикс `Async` + `CancellationToken`. +- Секреты не логировать; логи — Serilog. Явного обогащения `UserId`/`NodeId`/`ConfigId`/`CorrelationId` + пока нет — не полагайся на него при расследовании. +- Ошибки API — единый `application/problem+json`. ## Команды @@ -177,19 +155,17 @@ docker compose up -d # api + postgres (+ web) ``` > Окружение: Windows, основная оболочка — **PowerShell**. Для POSIX-скриптов есть Bash-инструмент. -> Пути — с учётом Windows. ## Ключевые решения -См. [tech-stack.md](docs/tech-stack.md#ключевые-решения-по-домену-и-поведению): CQRS — -**собственный диспетчер** (не MediatR); **одна роль** на пользователя; секреты нод — -**ASP.NET Data Protection**; тарифы `Plan` не реализованы (нет лимитов трафика/срока на конфиг); -i18n — **RU+EN** (react-i18next); Telegram — **long polling**, только **привязка** (не signup); -история трафика — **простая таблица + TTL**; логирование — **Serilog**. +См. [tech-stack.md](docs/tech-stack.md#ключевые-решения-по-домену-и-поведению): CQRS — собственный +диспетчер (не MediatR); одна роль на пользователя; секреты нод — ASP.NET Data Protection; тарифы `Plan` +не реализованы; i18n — RU+EN (react-i18next); Telegram — long polling, только привязка (не signup); +история трафика — простая таблица + TTL; логирование — Serilog. ## Рабочие принципы - Не начинай крупную реализацию без сверки с доками и, при неоднозначности, без вопроса пользователю. -- Соблюдай границы слоёв — это главный инвариант проекта. Нарушение = ошибка ревью. +- Соблюдай границы слоёв — главный инвариант проекта. Нарушение = ошибка ревью. - Обновляй документацию вместе с кодом. Не коммить и не пуши без явной просьбы. -- Отвечай пользователю на русском (язык общения в проекте — русский). +- Отвечай пользователю на русском. diff --git a/backend/src/PnvPanel.Api/Endpoints/AdminUserEndpoints.cs b/backend/src/PnvPanel.Api/Endpoints/AdminUserEndpoints.cs index 36ac1da..3a3ef45 100644 --- a/backend/src/PnvPanel.Api/Endpoints/AdminUserEndpoints.cs +++ b/backend/src/PnvPanel.Api/Endpoints/AdminUserEndpoints.cs @@ -20,6 +20,7 @@ public static class AdminUserEndpoints admin.MapPatch("/users/{id:guid}/block", BlockUser).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.MapDelete("/users/{id:guid}", DeleteUser).Produces(StatusCodes.Status204NoContent); admin.MapGet("/users/{id:guid}/configs", GetUserConfigs).Produces>(); admin.MapDelete("/configs/{id:guid}", ForceRevokeConfig).Produces(StatusCodes.Status204NoContent); @@ -52,6 +53,12 @@ public static class AdminUserEndpoints return result.ToHttpResult(); } + private static async Task DeleteUser(Guid id, ISender sender, CancellationToken cancellationToken) + { + var result = await sender.Send(new DeleteUserCommand(id), cancellationToken); + return result.ToHttpResult(); + } + private static async Task GetUserConfigs(Guid id, ISender sender, CancellationToken cancellationToken) { var result = await sender.Send(new GetUserConfigsQuery(id), cancellationToken); diff --git a/backend/src/PnvPanel.Api/Endpoints/RoleEndpoints.cs b/backend/src/PnvPanel.Api/Endpoints/RoleEndpoints.cs index a7050cc..6c17643 100644 --- a/backend/src/PnvPanel.Api/Endpoints/RoleEndpoints.cs +++ b/backend/src/PnvPanel.Api/Endpoints/RoleEndpoints.cs @@ -38,7 +38,7 @@ public static class RoleEndpoints private static async Task 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(); } @@ -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); diff --git a/backend/src/PnvPanel.Application/Admin/Roles/CreateRoleCommand.cs b/backend/src/PnvPanel.Application/Admin/Roles/CreateRoleCommand.cs index 134d630..4e2030f 100644 --- a/backend/src/PnvPanel.Application/Admin/Roles/CreateRoleCommand.cs +++ b/backend/src/PnvPanel.Application/Admin/Roles/CreateRoleCommand.cs @@ -4,4 +4,4 @@ using PnvPanel.Application.Common.Models; namespace PnvPanel.Application.Admin.Roles; -public sealed record CreateRoleCommand(string Name, int MaxConfigs) : ICommand>; +public sealed record CreateRoleCommand(string Name, int MaxConfigs, int MaxIpLimit) : ICommand>; diff --git a/backend/src/PnvPanel.Application/Admin/Roles/CreateRoleCommandHandler.cs b/backend/src/PnvPanel.Application/Admin/Roles/CreateRoleCommandHandler.cs index 600cccf..2972f00 100644 --- a/backend/src/PnvPanel.Application/Admin/Roles/CreateRoleCommandHandler.cs +++ b/backend/src/PnvPanel.Application/Admin/Roles/CreateRoleCommandHandler.cs @@ -7,5 +7,5 @@ namespace PnvPanel.Application.Admin.Roles; public sealed class CreateRoleCommandHandler(IRoleService roleService) : ICommandHandler> { public Task> Handle(CreateRoleCommand command, CancellationToken cancellationToken) - => roleService.CreateRoleAsync(command.Name, command.MaxConfigs, cancellationToken); + => roleService.CreateRoleAsync(command.Name, command.MaxConfigs, command.MaxIpLimit, cancellationToken); } diff --git a/backend/src/PnvPanel.Application/Admin/Roles/CreateRoleCommandValidator.cs b/backend/src/PnvPanel.Application/Admin/Roles/CreateRoleCommandValidator.cs index 5ddfb40..4e3e4cf 100644 --- a/backend/src/PnvPanel.Application/Admin/Roles/CreateRoleCommandValidator.cs +++ b/backend/src/PnvPanel.Application/Admin/Roles/CreateRoleCommandValidator.cs @@ -12,5 +12,6 @@ public sealed class CreateRoleCommandValidator : AbstractValidator x.MaxConfigs).GreaterThanOrEqualTo(-1); + RuleFor(x => x.MaxIpLimit).GreaterThanOrEqualTo(-1); } } diff --git a/backend/src/PnvPanel.Application/Admin/Roles/UpdateRoleCommand.cs b/backend/src/PnvPanel.Application/Admin/Roles/UpdateRoleCommand.cs index 79775e0..ffdfe80 100644 --- a/backend/src/PnvPanel.Application/Admin/Roles/UpdateRoleCommand.cs +++ b/backend/src/PnvPanel.Application/Admin/Roles/UpdateRoleCommand.cs @@ -4,4 +4,4 @@ using PnvPanel.Application.Common.Models; namespace PnvPanel.Application.Admin.Roles; -public sealed record UpdateRoleCommand(Guid RoleId, int MaxConfigs) : ICommand>; +public sealed record UpdateRoleCommand(Guid RoleId, int MaxConfigs, int MaxIpLimit) : ICommand>; diff --git a/backend/src/PnvPanel.Application/Admin/Roles/UpdateRoleCommandHandler.cs b/backend/src/PnvPanel.Application/Admin/Roles/UpdateRoleCommandHandler.cs index 38e71f6..7c0ae82 100644 --- a/backend/src/PnvPanel.Application/Admin/Roles/UpdateRoleCommandHandler.cs +++ b/backend/src/PnvPanel.Application/Admin/Roles/UpdateRoleCommandHandler.cs @@ -7,5 +7,5 @@ namespace PnvPanel.Application.Admin.Roles; public sealed class UpdateRoleCommandHandler(IRoleService roleService) : ICommandHandler> { public Task> Handle(UpdateRoleCommand command, CancellationToken cancellationToken) - => roleService.UpdateRoleAsync(command.RoleId, command.MaxConfigs, cancellationToken); + => roleService.UpdateRoleAsync(command.RoleId, command.MaxConfigs, command.MaxIpLimit, cancellationToken); } diff --git a/backend/src/PnvPanel.Application/Admin/Roles/UpdateRoleCommandValidator.cs b/backend/src/PnvPanel.Application/Admin/Roles/UpdateRoleCommandValidator.cs index ff5f251..f436e57 100644 --- a/backend/src/PnvPanel.Application/Admin/Roles/UpdateRoleCommandValidator.cs +++ b/backend/src/PnvPanel.Application/Admin/Roles/UpdateRoleCommandValidator.cs @@ -7,5 +7,6 @@ public sealed class UpdateRoleCommandValidator : AbstractValidator x.MaxConfigs).GreaterThanOrEqualTo(-1); + RuleFor(x => x.MaxIpLimit).GreaterThanOrEqualTo(-1); } } diff --git a/backend/src/PnvPanel.Application/Admin/Users/DeleteUserCommand.cs b/backend/src/PnvPanel.Application/Admin/Users/DeleteUserCommand.cs new file mode 100644 index 0000000..38195ac --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Users/DeleteUserCommand.cs @@ -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; diff --git a/backend/src/PnvPanel.Application/Admin/Users/DeleteUserCommandHandler.cs b/backend/src/PnvPanel.Application/Admin/Users/DeleteUserCommandHandler.cs new file mode 100644 index 0000000..36d0243 --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Users/DeleteUserCommandHandler.cs @@ -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; + +/// Удаление пользователя админом: отзывает все его конфиги в 3x-ui, затем удаляет учётку. +public sealed class DeleteUserCommandHandler( + IAppDbContext dbContext, IIdentityService identityService, IXuiPanelGateway gateway, + ITelegramNotifier telegramNotifier, ICurrentUser currentUser) + : ICommandHandler +{ + public async Task 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); + } +} diff --git a/backend/src/PnvPanel.Application/Admin/Users/UserErrors.cs b/backend/src/PnvPanel.Application/Admin/Users/UserErrors.cs index 6cb9654..8baf48d 100644 --- a/backend/src/PnvPanel.Application/Admin/Users/UserErrors.cs +++ b/backend/src/PnvPanel.Application/Admin/Users/UserErrors.cs @@ -5,4 +5,7 @@ namespace PnvPanel.Application.Admin.Users; public static class UserErrors { public static readonly Error NotFound = Error.NotFound("Users.NotFound", "Пользователь не найден."); + + public static readonly Error CannotDeleteSelf = Error.Validation( + "Users.CannotDeleteSelf", "Нельзя удалить свою учётную запись здесь — используйте удаление аккаунта в Настройках."); } diff --git a/backend/src/PnvPanel.Application/Common/Interfaces/IIdentityService.cs b/backend/src/PnvPanel.Application/Common/Interfaces/IIdentityService.cs index 831b48c..6bd0c63 100644 --- a/backend/src/PnvPanel.Application/Common/Interfaces/IIdentityService.cs +++ b/backend/src/PnvPanel.Application/Common/Interfaces/IIdentityService.cs @@ -5,7 +5,8 @@ namespace PnvPanel.Application.Common.Interfaces; public sealed record AuthenticatedUser(Guid Id, string UserName, string Role); 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); diff --git a/backend/src/PnvPanel.Application/Common/Interfaces/IRoleService.cs b/backend/src/PnvPanel.Application/Common/Interfaces/IRoleService.cs index 2f9ce64..f191d22 100644 --- a/backend/src/PnvPanel.Application/Common/Interfaces/IRoleService.cs +++ b/backend/src/PnvPanel.Application/Common/Interfaces/IRoleService.cs @@ -2,13 +2,13 @@ using PnvPanel.Application.Common.Models; 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 { - Task> CreateRoleAsync(string name, int maxConfigs, CancellationToken cancellationToken); + Task> CreateRoleAsync(string name, int maxConfigs, int maxIpLimit, CancellationToken cancellationToken); - Task> UpdateRoleAsync(Guid roleId, int maxConfigs, CancellationToken cancellationToken); + Task> UpdateRoleAsync(Guid roleId, int maxConfigs, int maxIpLimit, CancellationToken cancellationToken); Task DeleteRoleAsync(Guid roleId, CancellationToken cancellationToken); diff --git a/backend/src/PnvPanel.Application/Common/Interfaces/IXuiPanelGateway.cs b/backend/src/PnvPanel.Application/Common/Interfaces/IXuiPanelGateway.cs index f769eb4..aea9de9 100644 --- a/backend/src/PnvPanel.Application/Common/Interfaces/IXuiPanelGateway.cs +++ b/backend/src/PnvPanel.Application/Common/Interfaces/IXuiPanelGateway.cs @@ -24,9 +24,13 @@ public interface IXuiPanelGateway void InvalidateClient(Guid nodeId); - /// Возвращает ClientExternalId, присвоенный панелью (UUID для VLESS/VMess, пароль для Trojan/Shadowsocks). + /// + /// Возвращает ClientExternalId, присвоенный панелью (UUID для VLESS/VMess, пароль для Trojan/Shadowsocks). + /// — лимит одновременных IP клиента (квота роли, см. AppRole.MaxIpLimit); + /// -1 (RoleQuota.Unlimited) означает без лимита — гейтвей сам переводит его в нативное значение 3x-ui. + /// Task> 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); Task RemoveClientAsync( diff --git a/backend/src/PnvPanel.Application/Configs/Create/CreateVpnConfigCommandHandler.cs b/backend/src/PnvPanel.Application/Configs/Create/CreateVpnConfigCommandHandler.cs index 156582b..d8759a9 100644 --- a/backend/src/PnvPanel.Application/Configs/Create/CreateVpnConfigCommandHandler.cs +++ b/backend/src/PnvPanel.Application/Configs/Create/CreateVpnConfigCommandHandler.cs @@ -44,7 +44,7 @@ public sealed class CreateVpnConfigCommandHandler( var addResult = await gateway.AddClientAsync( node, inbound.RemoteInboundId, inbound.Protocol, config.ClientEmail, - config.Label ?? config.ClientEmail, cancellationToken); + config.Label ?? config.ClientEmail, profile.MaxIpLimit, cancellationToken); if (!addResult.IsSuccess) { diff --git a/backend/src/PnvPanel.Application/Configs/Rotate/RotateVpnConfigCommandHandler.cs b/backend/src/PnvPanel.Application/Configs/Rotate/RotateVpnConfigCommandHandler.cs index 1a6fc1e..380e85a 100644 --- a/backend/src/PnvPanel.Application/Configs/Rotate/RotateVpnConfigCommandHandler.cs +++ b/backend/src/PnvPanel.Application/Configs/Rotate/RotateVpnConfigCommandHandler.cs @@ -7,7 +7,8 @@ using PnvPanel.Domain.Configs; 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> { public async Task> Handle(RotateVpnConfigCommand command, CancellationToken cancellationToken) @@ -32,10 +33,14 @@ public sealed class RotateVpnConfigCommandHandler(IAppDbContext dbContext, IXuiP if (node is null) return Result.Failure(ConfigErrors.NodeDisabled); + var profile = await identityService.GetProfileAsync(userId, cancellationToken); + if (profile is null) + return Result.Failure(AuthErrors.Unauthorized); + var newClientEmail = VpnConfig.GenerateClientEmail(userId); var addResult = await gateway.AddClientAsync( node, inbound.RemoteInboundId, config.Protocol, newClientEmail, - config.Label ?? newClientEmail, cancellationToken); + config.Label ?? newClientEmail, profile.MaxIpLimit, cancellationToken); if (!addResult.IsSuccess) return Result.Failure(addResult.Error); diff --git a/backend/src/PnvPanel.Infrastructure/Identity/AppRole.cs b/backend/src/PnvPanel.Infrastructure/Identity/AppRole.cs index c083e7d..b9dc2d3 100644 --- a/backend/src/PnvPanel.Infrastructure/Identity/AppRole.cs +++ b/backend/src/PnvPanel.Infrastructure/Identity/AppRole.cs @@ -3,12 +3,19 @@ using PnvPanel.Application.Common.Models; namespace PnvPanel.Infrastructure.Identity; -/// Роль с квотой на число конфигов. У пользователя ровно одна роль. +/// Роль с квотой на число конфигов и лимитом одновременных IP на клиента. У пользователя ровно одна роль. public class AppRole : IdentityRole { public const int UnlimitedMaxConfigs = RoleQuota.Unlimited; + public const int UnlimitedMaxIpLimit = RoleQuota.Unlimited; public int MaxConfigs { get; set; } + + /// Лимит одновременных IP на клиента в 3x-ui (`limitIp`); -1 — без лимита. Применяется + /// только к новым клиентам, создаваемым в 3x-ui (см. IXuiPanelGateway.AddClientAsync) — при смене + /// роли/лимита существующие клиенты в панели не трогаются (как и квота MaxConfigs). + public int MaxIpLimit { get; set; } + public bool IsSystem { get; set; } public AppRole() diff --git a/backend/src/PnvPanel.Infrastructure/Identity/DbInitializer.cs b/backend/src/PnvPanel.Infrastructure/Identity/DbInitializer.cs index 970158d..917c6cd 100644 --- a/backend/src/PnvPanel.Infrastructure/Identity/DbInitializer.cs +++ b/backend/src/PnvPanel.Infrastructure/Identity/DbInitializer.cs @@ -22,18 +22,18 @@ public sealed class DbInitializer( public async Task SeedAsync(CancellationToken cancellationToken = default) { - await EnsureRoleAsync(RoleNames.Admin, AppRole.UnlimitedMaxConfigs, isSystem: true); - await EnsureRoleAsync(RoleNames.User, rolesOptions.Value.DefaultUserMaxConfigs, isSystem: true); + await EnsureRoleAsync(RoleNames.Admin, AppRole.UnlimitedMaxConfigs, AppRole.UnlimitedMaxIpLimit, isSystem: true); + await EnsureRoleAsync(RoleNames.User, rolesOptions.Value.DefaultUserMaxConfigs, rolesOptions.Value.DefaultUserMaxIpLimit, isSystem: true); await SeedAdminAsync(); 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)) 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); if (!result.Succeeded) { diff --git a/backend/src/PnvPanel.Infrastructure/Identity/IdentityService.cs b/backend/src/PnvPanel.Infrastructure/Identity/IdentityService.cs index 3866edc..df29b4e 100644 --- a/backend/src/PnvPanel.Infrastructure/Identity/IdentityService.cs +++ b/backend/src/PnvPanel.Infrastructure/Identity/IdentityService.cs @@ -60,7 +60,8 @@ internal sealed class IdentityService(UserManager userManager, SignInMa var role = await GetPrimaryRoleAsync(user); 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 ChangePasswordAsync(Guid userId, string currentPassword, string newPassword, CancellationToken cancellationToken) diff --git a/backend/src/PnvPanel.Infrastructure/Identity/RoleService.cs b/backend/src/PnvPanel.Infrastructure/Identity/RoleService.cs index 5eef3af..23cdf8f 100644 --- a/backend/src/PnvPanel.Infrastructure/Identity/RoleService.cs +++ b/backend/src/PnvPanel.Infrastructure/Identity/RoleService.cs @@ -9,12 +9,12 @@ namespace PnvPanel.Infrastructure.Identity; internal sealed class RoleService(RoleManager roleManager, UserManager userManager) : IRoleService { - public async Task> CreateRoleAsync(string name, int maxConfigs, CancellationToken cancellationToken) + public async Task> CreateRoleAsync(string name, int maxConfigs, int maxIpLimit, CancellationToken cancellationToken) { if (await roleManager.RoleExistsAsync(name)) return Result.Failure(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); if (!result.Succeeded) { @@ -25,13 +25,14 @@ internal sealed class RoleService(RoleManager roleManager, UserManager< return Result.Success(ToDto(role)); } - public async Task> UpdateRoleAsync(Guid roleId, int maxConfigs, CancellationToken cancellationToken) + public async Task> UpdateRoleAsync(Guid roleId, int maxConfigs, int maxIpLimit, CancellationToken cancellationToken) { var role = await roleManager.FindByIdAsync(roleId.ToString()); if (role is null) return Result.Failure(RoleErrors.NotFound); role.MaxConfigs = maxConfigs; + role.MaxIpLimit = maxIpLimit; await roleManager.UpdateAsync(role); return Result.Success(ToDto(role)); @@ -58,7 +59,7 @@ internal sealed class RoleService(RoleManager roleManager, UserManager< { return await roleManager.Roles .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); } @@ -80,5 +81,5 @@ internal sealed class RoleService(RoleManager roleManager, UserManager< 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); } diff --git a/backend/src/PnvPanel.Infrastructure/Identity/RolesOptions.cs b/backend/src/PnvPanel.Infrastructure/Identity/RolesOptions.cs index f44c205..1697952 100644 --- a/backend/src/PnvPanel.Infrastructure/Identity/RolesOptions.cs +++ b/backend/src/PnvPanel.Infrastructure/Identity/RolesOptions.cs @@ -5,4 +5,5 @@ public sealed class RolesOptions public const string SectionName = "Roles"; public int DefaultUserMaxConfigs { get; init; } = 3; + public int DefaultUserMaxIpLimit { get; init; } = 2; } diff --git a/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260713035342_AddRoleMaxIpLimit.Designer.cs b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260713035342_AddRoleMaxIpLimit.Designer.cs new file mode 100644 index 0000000..cfdcc18 --- /dev/null +++ b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260713035342_AddRoleMaxIpLimit.Designer.cs @@ -0,0 +1,776 @@ +// +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 + { + /// + 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", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Activation.ActivationRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Comment") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DecidedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DecidedBy") + .HasColumnType("uuid"); + + b.Property("RejectionReason") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Status"); + + b.ToTable("ActivationRequests", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Apps.ClientApp", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Description") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("DownloadUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("IconUrl") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("OperatingSystem") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("ClientApps", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Audit.AuditLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Action") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ActorId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Metadata") + .HasColumnType("jsonb"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("TargetId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ConfigId") + .HasColumnType("uuid"); + + b.Property("DownBytes") + .HasColumnType("bigint"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("UpBytes") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("ConfigId", "Timestamp"); + + b.ToTable("TrafficSamples", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Configs.VpnConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ClientEmail") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ClientExternalId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("InboundId") + .HasColumnType("uuid"); + + b.Property("Label") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("LastSyncAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Protocol") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("SubscriptionToken") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UsedDownBytes") + .HasColumnType("bigint"); + + b.Property("UsedUpBytes") + .HasColumnType("bigint"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.PrimitiveCollection("AllowedRoleIds") + .IsRequired() + .HasColumnType("uuid[]"); + + b.Property("DisplayName") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsPublished") + .HasColumnType("boolean"); + + b.Property("LastSyncAt") + .HasColumnType("timestamp with time zone"); + + b.Property("MaxClients") + .HasColumnType("integer"); + + b.Property("NodeId") + .HasColumnType("uuid"); + + b.Property("Port") + .HasColumnType("integer"); + + b.Property("Protocol") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Remark") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(20000) + .HasColumnType("character varying(20000)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BaseAddress") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("LastSyncAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Location") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConsumedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Token") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("Token") + .IsUnique(); + + b.ToTable("TelegramLinkTokens", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Telegram.TelegramLoginRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Context") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.ToTable("TelegramLoginRequests", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Infrastructure.Identity.AppRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("IsSystem") + .HasColumnType("boolean"); + + b.Property("MaxConfigs") + .HasColumnType("integer"); + + b.Property("MaxIpLimit") + .HasColumnType("integer"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ActivatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ActivatedBy") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("IsActivated") + .HasColumnType("boolean"); + + b.Property("IsBlocked") + .HasColumnType("boolean"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("SubscriptionToken") + .IsRequired() + .HasColumnType("text"); + + b.Property("TelegramLinkedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TelegramUserId") + .HasColumnType("bigint"); + + b.Property("TelegramUsername") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReplacedByTokenHash") + .HasColumnType("text"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("RefreshTokens", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("PnvPanel.Infrastructure.Identity.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("PnvPanel.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("PnvPanel.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", 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", 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("NodeId") + .HasColumnType("uuid"); + + b1.Property("ProtectedPassword") + .IsRequired() + .HasColumnType("text") + .HasColumnName("CredentialsProtectedPassword"); + + b1.Property("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 + } + } +} diff --git a/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260713035342_AddRoleMaxIpLimit.cs b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260713035342_AddRoleMaxIpLimit.cs new file mode 100644 index 0000000..1138bd3 --- /dev/null +++ b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260713035342_AddRoleMaxIpLimit.cs @@ -0,0 +1,34 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace PnvPanel.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddRoleMaxIpLimit : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + // Default -1 (без лимита) — не меняем поведение для уже существующих ролей (включая + // кастомные вроде "vip"), которые раньше не были ограничены по IP вовсе. Системную роль + // "user" сразу переводим на дефолт из ТЗ (2); её можно поменять в админке в любой момент. + migrationBuilder.AddColumn( + name: "MaxIpLimit", + table: "AspNetRoles", + type: "integer", + nullable: false, + defaultValue: -1); + + migrationBuilder.Sql("UPDATE \"AspNetRoles\" SET \"MaxIpLimit\" = 2 WHERE \"Name\" = 'user';"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "MaxIpLimit", + table: "AspNetRoles"); + } + } +} diff --git a/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs index 3512ec1..564364c 100644 --- a/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs +++ b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs @@ -535,6 +535,9 @@ namespace PnvPanel.Infrastructure.Persistence.Migrations b.Property("MaxConfigs") .HasColumnType("integer"); + b.Property("MaxIpLimit") + .HasColumnType("integer"); + b.Property("Name") .HasMaxLength(256) .HasColumnType("character varying(256)"); diff --git a/backend/src/PnvPanel.Infrastructure/Xui/XuiPanelGateway.cs b/backend/src/PnvPanel.Infrastructure/Xui/XuiPanelGateway.cs index a7a81b2..cba834d 100644 --- a/backend/src/PnvPanel.Infrastructure/Xui/XuiPanelGateway.cs +++ b/backend/src/PnvPanel.Infrastructure/Xui/XuiPanelGateway.cs @@ -70,15 +70,16 @@ internal sealed class XuiPanelGateway( } public async Task> 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) { try { var client = GetClient(node); - // Лимит устройств (limitIp) панелью больше не управляется — задаётся, если нужно, - // напрямую в 3x-ui администратором ноды. Новый клиент всегда создаётся без лимита. - var request = new AddClientRequest(clientName, clientEmail, ToRemoteProtocol(protocol), 0, null); + // Наш domain-sentinel RoleQuota.Unlimited (-1) переводим в нативное "без лимита" 3x-ui (0) — + // ThreeXui.Net.AddClientRequest.LimitIp обязателен (не nullable), 0 в самом 3x-ui означает unlimited. + var request = new AddClientRequest( + clientName, clientEmail, ToRemoteProtocol(protocol), limitIp == RoleQuota.Unlimited ? 0 : limitIp, null); var result = await client.AddClientAsync(inboundRemoteId, request, cancellationToken); return Result.Success(result.ExternalClientId); } diff --git a/backend/tests/PnvPanel.Application.Tests/Admin/Users/DeleteUserCommandHandlerTests.cs b/backend/tests/PnvPanel.Application.Tests/Admin/Users/DeleteUserCommandHandlerTests.cs new file mode 100644 index 0000000..d50b8c7 --- /dev/null +++ b/backend/tests/PnvPanel.Application.Tests/Admin/Users/DeleteUserCommandHandlerTests.cs @@ -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(); + private readonly IXuiPanelGateway _gateway = Substitute.For(); + private readonly ITelegramNotifier _telegramNotifier = Substitute.For(); + private readonly ICurrentUser _currentUser = Substitute.For(); + + [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(), Arg.Any()); + } + + [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(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Result.Success()); + _identityService.DeleteUserAsync(userId, Arg.Any()).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(n => n.Id == node.Id), inbound.RemoteInboundId, "external-id", config.Protocol, Arg.Any()); + await _telegramNotifier.Received(1).NotifyUserAsync(userId, Arg.Any(), Arg.Any()); + await _identityService.Received(1).DeleteUserAsync(userId, Arg.Any()); + + 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()).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(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); + } + + [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()).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); + } +} diff --git a/backend/tests/PnvPanel.Application.Tests/Auth/GetCurrentUserQueryHandlerTests.cs b/backend/tests/PnvPanel.Application.Tests/Auth/GetCurrentUserQueryHandlerTests.cs index 8db7b2c..84bd19f 100644 --- a/backend/tests/PnvPanel.Application.Tests/Auth/GetCurrentUserQueryHandlerTests.cs +++ b/backend/tests/PnvPanel.Application.Tests/Auth/GetCurrentUserQueryHandlerTests.cs @@ -2,6 +2,7 @@ using NSubstitute; using PnvPanel.Application.Auth; using PnvPanel.Application.Auth.Me; using PnvPanel.Application.Common.Interfaces; +using PnvPanel.Application.Common.Models; using PnvPanel.Application.Tests.TestSupport; using Xunit; @@ -40,7 +41,7 @@ public class GetCurrentUserQueryHandlerTests public async Task Handle_AuthenticatedWithProfile_ReturnsCurrentUserDto() { 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()).Returns(profile); _identityService.GetTelegramLinkInfoAsync(userId, Arg.Any()) .Returns(new TelegramLinkInfo(true, 42, "alice_tg")); diff --git a/backend/tests/PnvPanel.Application.Tests/Auth/LoginCommandHandlerTests.cs b/backend/tests/PnvPanel.Application.Tests/Auth/LoginCommandHandlerTests.cs index 0f7494a..712574e 100644 --- a/backend/tests/PnvPanel.Application.Tests/Auth/LoginCommandHandlerTests.cs +++ b/backend/tests/PnvPanel.Application.Tests/Auth/LoginCommandHandlerTests.cs @@ -20,7 +20,9 @@ public class LoginCommandHandlerTests { var userId = Guid.NewGuid(); 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()) .Returns(Result.Success(authUser)); diff --git a/backend/tests/PnvPanel.Application.Tests/Auth/RefreshCommandHandlerTests.cs b/backend/tests/PnvPanel.Application.Tests/Auth/RefreshCommandHandlerTests.cs index 0c72292..93441cf 100644 --- a/backend/tests/PnvPanel.Application.Tests/Auth/RefreshCommandHandlerTests.cs +++ b/backend/tests/PnvPanel.Application.Tests/Auth/RefreshCommandHandlerTests.cs @@ -19,7 +19,9 @@ public class RefreshCommandHandlerTests public async Task Handle_WithValidToken_RotatesAndReturnsNewAuthResult() { 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)); _refreshTokenService.RotateAsync("old-token", Arg.Any()).Returns(Result.Success(rotated)); diff --git a/backend/tests/PnvPanel.Application.Tests/Configs/GetMyConfigs/GetMyConfigsQueryHandlerTests.cs b/backend/tests/PnvPanel.Application.Tests/Configs/GetMyConfigs/GetMyConfigsQueryHandlerTests.cs index a4d74cf..cb80e53 100644 --- a/backend/tests/PnvPanel.Application.Tests/Configs/GetMyConfigs/GetMyConfigsQueryHandlerTests.cs +++ b/backend/tests/PnvPanel.Application.Tests/Configs/GetMyConfigs/GetMyConfigsQueryHandlerTests.cs @@ -1,6 +1,7 @@ using NSubstitute; using PnvPanel.Application.Auth; using PnvPanel.Application.Common.Interfaces; +using PnvPanel.Application.Common.Models; using PnvPanel.Application.Configs.GetMyConfigs; using PnvPanel.Application.Tests.TestSupport; using PnvPanel.Domain.Configs; @@ -32,7 +33,7 @@ public class GetMyConfigsQueryHandlerTests dbContext.VpnConfigs.AddRange(activeConfig, revokedConfig, otherUsersConfig); 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()).Returns(profile); var handler = new GetMyConfigsQueryHandler(dbContext, _identityService, FakeCurrentUser.Authenticated(userId)); diff --git a/backend/tests/PnvPanel.Application.Tests/Configs/Rotate/RotateVpnConfigCommandHandlerTests.cs b/backend/tests/PnvPanel.Application.Tests/Configs/Rotate/RotateVpnConfigCommandHandlerTests.cs index dcf401d..6bb36bb 100644 --- a/backend/tests/PnvPanel.Application.Tests/Configs/Rotate/RotateVpnConfigCommandHandlerTests.cs +++ b/backend/tests/PnvPanel.Application.Tests/Configs/Rotate/RotateVpnConfigCommandHandlerTests.cs @@ -15,12 +15,17 @@ namespace PnvPanel.Application.Tests.Configs.Rotate; public class RotateVpnConfigCommandHandlerTests { private readonly IXuiPanelGateway _gateway = Substitute.For(); + private readonly IIdentityService _identityService = Substitute.For(); + + private static CurrentUserProfile MakeProfile(Guid userId) => + new(userId, "alice", Guid.NewGuid(), "user", true, false, 3, RoleQuota.Unlimited, "sub-token"); [Fact] public async Task Handle_WhenActiveConfigOwnedByUser_RotatesAndAddsNewClient() { using var dbContext = InMemoryDbContextFactory.Create(); var userId = Guid.NewGuid(); + _identityService.GetProfileAsync(userId, Arg.Any()).Returns(MakeProfile(userId)); 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); @@ -34,10 +39,10 @@ public class RotateVpnConfigCommandHandlerTests _gateway.AddClientAsync( Arg.Any(), inbound.RemoteInboundId, config.Protocol, Arg.Any(), - Arg.Any(), Arg.Any()) + Arg.Any(), Arg.Any(), Arg.Any()) .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); @@ -53,7 +58,7 @@ public class RotateVpnConfigCommandHandlerTests using var dbContext = InMemoryDbContextFactory.Create(); 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); @@ -75,7 +80,7 @@ public class RotateVpnConfigCommandHandlerTests dbContext.VpnConfigs.Add(config); 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); @@ -97,7 +102,7 @@ public class RotateVpnConfigCommandHandlerTests dbContext.VpnConfigs.Add(config); 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); @@ -110,6 +115,7 @@ public class RotateVpnConfigCommandHandlerTests { using var dbContext = InMemoryDbContextFactory.Create(); var userId = Guid.NewGuid(); + _identityService.GetProfileAsync(userId, Arg.Any()).Returns(MakeProfile(userId)); 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); @@ -124,10 +130,10 @@ public class RotateVpnConfigCommandHandlerTests var gatewayError = Error.Failure("Xui.Unreachable", "Панель недоступна."); _gateway.AddClientAsync( Arg.Any(), inbound.RemoteInboundId, config.Protocol, Arg.Any(), - Arg.Any(), Arg.Any()) + Arg.Any(), Arg.Any(), Arg.Any()) .Returns(Result.Failure(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); diff --git a/backend/tests/PnvPanel.Application.Tests/Telegram/GetLoginRequestStatusQueryHandlerTests.cs b/backend/tests/PnvPanel.Application.Tests/Telegram/GetLoginRequestStatusQueryHandlerTests.cs index 8280823..e1b6f81 100644 --- a/backend/tests/PnvPanel.Application.Tests/Telegram/GetLoginRequestStatusQueryHandlerTests.cs +++ b/backend/tests/PnvPanel.Application.Tests/Telegram/GetLoginRequestStatusQueryHandlerTests.cs @@ -1,6 +1,7 @@ using NSubstitute; using PnvPanel.Application.Auth; using PnvPanel.Application.Common.Interfaces; +using PnvPanel.Application.Common.Models; using PnvPanel.Application.Tests.TestSupport; using PnvPanel.Domain.Telegram; using Xunit; @@ -75,7 +76,7 @@ public class GetLoginRequestStatusQueryHandlerTests dbContext.TelegramLoginRequests.Add(request); 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()).Returns(profile); _jwtTokenService.GenerateAccessToken(Arg.Any()) .Returns(("access-token", DateTimeOffset.UtcNow.AddMinutes(15))); diff --git a/backend/tests/PnvPanel.IntegrationTests/Configs/ConfigQuotaTests.cs b/backend/tests/PnvPanel.IntegrationTests/Configs/ConfigQuotaTests.cs index a4c6071..ccfbe85 100644 --- a/backend/tests/PnvPanel.IntegrationTests/Configs/ConfigQuotaTests.cs +++ b/backend/tests/PnvPanel.IntegrationTests/Configs/ConfigQuotaTests.cs @@ -40,7 +40,8 @@ public class ConfigQuotaTests(PnvPanelWebApplicationFactory factory) var roles = await rolesResponse.ReadAsAsync>(); 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); var registerNodeResponse = await adminClient.PostJsonAsync("/api/admin/nodes", new diff --git a/backend/tests/PnvPanel.IntegrationTests/TestSupport/FakeXuiPanelGateway.cs b/backend/tests/PnvPanel.IntegrationTests/TestSupport/FakeXuiPanelGateway.cs index 3dd0fdb..b9ac544 100644 --- a/backend/tests/PnvPanel.IntegrationTests/TestSupport/FakeXuiPanelGateway.cs +++ b/backend/tests/PnvPanel.IntegrationTests/TestSupport/FakeXuiPanelGateway.cs @@ -31,7 +31,7 @@ public sealed class FakeXuiPanelGateway : IXuiPanelGateway } public Task> 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) => Task.FromResult(Result.Success(Guid.NewGuid().ToString())); diff --git a/docs/api-design.md b/docs/api-design.md index 1dcc965..c509efe 100644 --- a/docs/api-design.md +++ b/docs/api-design.md @@ -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}/reject` | admin | `{ reason? }` | `204 No Content` | | GET | `/api/admin/roles` | admin | — | `RoleDto[]` | -| POST | `/api/admin/roles` | admin | `{ name, maxConfigs }` | `RoleDto` | -| PUT | `/api/admin/roles/{id}` | admin | `{ maxConfigs }` | `RoleDto` | +| POST | `/api/admin/roles` | admin | `{ name, maxConfigs, maxIpLimit }` | `RoleDto` | +| PUT | `/api/admin/roles/{id}` | admin | `{ maxConfigs, maxIpLimit }` | `RoleDto` | | DELETE | `/api/admin/roles/{id}` | admin | — | `204 No Content` (системные `admin`/`user` удалить нельзя) | | 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}/unblock` | admin | — | `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[]` | | DELETE | `/api/admin/configs/{id}` | admin | — | `204 No Content` (принудительный отзыв любого конфига) | | GET | `/api/admin/stats` | admin | — | `StatsDto` | diff --git a/docs/domain-model.md b/docs/domain-model.md index 77bbd9e..30bd462 100644 --- a/docs/domain-model.md +++ b/docs/domain-model.md @@ -100,8 +100,12 @@ NewsPost (лента новостей; публикует удаляет старого, генерирует новый `SubscriptionToken`; квоту **не тратит**. Для случая утечки ссылки. - `Disable()`/`Enable()` → меняют только статус записи (`Active ↔ Disabled`); отключение/включение самого клиента в 3x-ui делает хендлер отдельным вызовом гейтвея (используется при блокировке юзера). -- `Rename(label)` → юзер меняет метку (синкается в 3x-ui как имя клиента). Лимит устройств/IP (`limitIp` - в 3x-ui) панелью не управляется — более сложная per-node настройка, задаётся напрямую в 3x-ui администратором. +- `Rename(label)` → юзер меняет метку (синкается в 3x-ui как имя клиента). +- Лимит одновременных IP (`limitIp` в 3x-ui) выставляется при создании клиента (`Create`/`Rotate`) по + квоте роли пользователя (`AppRole.MaxIpLimit`; -1 = без лимита) — панель не даёт настраивать его + per-конфиг. Как и `MaxConfigs`, лимит применяется только к **новым** клиентам: смена роли/квоты не + трогает уже созданных клиентов в 3x-ui (см. `IXuiPanelGateway.UpdateClientAsync`, где `LimitIp` + всегда `null` — «не менять»). - `UpdateTraffic(up, down)` → пишет `TrafficSyncService` при периодической синхронизации, только для отображения. - **Создание разрешено только активированному пользователю** (`AppUser.IsActivated == true`). - Число активных конфигов пользователя не может превышать **квоту его роли** (`AppRole.MaxConfigs`; @@ -203,17 +207,19 @@ UI **настойчиво напоминает** привязать его (ед ### AppRole — роль с квотой (Identity, динамическая) Расширяет `IdentityRole`. Роли **создаёт админ** и назначает пользователям; роль несёт квоту -на число конфигов. +на число конфигов и лимит одновременных IP на клиента в 3x-ui. | Поле | Тип | Заметки | | ------------ | -------- | --------------------------------------------------------------- | | `Id` | `Guid` | PK | | `Name` | `string` | Напр. `admin`, `user`, `vip` | -| `MaxConfigs` | `int` | Квота активных конфигов (для `admin` игнорируется — без лимита) | +| `MaxConfigs` | `int` | Квота активных конфигов (-1 = без лимита; для `admin` — без лимита) | +| `MaxIpLimit` | `int` | Лимит одновременных IP на клиента (`limitIp` в 3x-ui; -1 = без лимита; для `admin` — без лимита) | | `IsSystem` | `bool` | Системная (`admin`, `user`) — нельзя удалить/переименовать | -Сидируются: `admin` (без лимита) и `user` (`MaxConfigs` = `Roles__DefaultUserMaxConfigs`, по умолчанию 3). -**У пользователя ровно одна роль**; его квота = `MaxConfigs` этой роли (`admin` → без лимита). +Сидируются: `admin` (оба лимита без ограничения) и `user` (`MaxConfigs` = `Roles__DefaultUserMaxConfigs`, +по умолчанию 3; `MaxIpLimit` = `Roles__DefaultUserMaxIpLimit`, по умолчанию 2). +**У пользователя ровно одна роль**; его квоты = `MaxConfigs`/`MaxIpLimit` этой роли (`admin` → без лимита). **Понижение роли (грандфазеринг)**: смену роли на роль с меньшей квотой разрешаем даже если текущих конфигов больше новой квоты — существующие конфиги сохраняются, но **создание новых блокируется**, @@ -301,6 +307,7 @@ enum OsPlatform { IOS, Android, Windows, MacOS, Linux } | `ChangeUserRoleCommandHandler` | `AuditLog` (`UserRoleChanged`) | | `ForceRevokeConfigCommandHandler` | Отзывает конфиг в 3x-ui; `AuditLog` (`ConfigForceRevoked`); Telegram-DM владельцу | | `ResetUserPasswordCommandHandler` | `AuditLog` (`UserPasswordReset`) | +| `DeleteUserCommandHandler` | Отзывает все конфиги пользователя в 3x-ui; `AuditLog` (`UserDeleted`); Telegram-DM владельцу; затем удаляет `AppUser`. Админ не может удалить себя | | `RegisterNodeCommandHandler` / `UpdateNodeCommandHandler` / `DeleteNodeCommandHandler` | `AuditLog` (`NodeRegistered`/`NodeUpdated`/`NodeDeleted`) | | `PublishInboundCommandHandler` | `AuditLog` (`InboundPublished`/`InboundUnpublished`) | | `NodeHealthCheckService` (фон) | Обновляет `NodeStatus`; realtime `nodeStatusChanged` группе `admins` | diff --git a/docs/frontend.md b/docs/frontend.md index 56ffa94..26d475c 100644 --- a/docs/frontend.md +++ b/docs/frontend.md @@ -89,9 +89,10 @@ frontend/ трафик, статус), кнопки на карточке — показать ссылку/QR (запрашивает `GET .../link` по клику, не сразу при создании), перевыпустить, отозвать; отдельная карточка «Общая подписка». Для неактивированного — `ActivationGate` вместо дашборда. -- **Создание конфига**: диалог — выбор инбаунда (по `displayName`) + метка. Лимит устройств - (`limitIp` в 3x-ui) панелью не управляется — задаётся при необходимости напрямую в 3x-ui. После - успеха карточка конфига появляется в списке; ссылку/QR пользователь открывает отдельно. +- **Создание конфига**: диалог — выбор инбаунда (по `displayName`) + метка. Лимит одновременных IP + (`limitIp` в 3x-ui) выставляется автоматически по квоте роли пользователя (`AppRole.MaxIpLimit`) — + в форме создания не настраивается. После успеха карточка конфига появляется в списке; ссылку/QR + пользователь открывает отдельно. - **Страница инструкций** (`/instructions`): статичные шаги + каталог приложений (`GET /api/apps`), сгруппированный по ОС и показан вкладками (по одной ОС за раз); клик по приложению открывает ссылку на скачивание. diff --git a/docs/tech-stack.md b/docs/tech-stack.md index 136556e..a4611a1 100644 --- a/docs/tech-stack.md +++ b/docs/tech-stack.md @@ -93,8 +93,9 @@ | Аудит | `AuditLog` (append-only): активация, блокировка, смена роли, отзыв, ноды/инбаунды | | Ротация конфига | `Rotate()` — перевыпуск UUID/ссылки, квоту не тратит (на случай утечки) | | Метка конфига | `Label` — пользователь именует конфиг («Мой телефон») | -| Лимит устройств (`limitIp`) | Панелью не управляется — задаётся при необходимости напрямую в 3x-ui администратором ноды | +| Лимит устройств (`limitIp`) | Квота роли (`AppRole.MaxIpLimit`; -1 = без лимита), применяется только к новым клиентам в 3x-ui | | Самоудаление аккаунта | Отзыв всех активных конфигов в 3x-ui + удаление `AppUser` | +| Удаление пользователя админом | `DELETE /api/admin/users/{id}` — отзыв всех конфигов в 3x-ui + удаление `AppUser`; себя удалить нельзя | | Версионирование API | Без версий (`/api` без `v1`) | | Подписка (заголовки) | `Subscription-Userinfo` (used/total/expire) + `profile-update-interval` | | Тема сайта | Светлая + тёмная (+ системная); выбор в localStorage | diff --git a/frontend/src/features/admin/roles/RoleFormDialog.tsx b/frontend/src/features/admin/roles/RoleFormDialog.tsx index bf6d4f3..1dc838a 100644 --- a/frontend/src/features/admin/roles/RoleFormDialog.tsx +++ b/frontend/src/features/admin/roles/RoleFormDialog.tsx @@ -23,6 +23,7 @@ export function RoleFormDialog({ const queryClient = useQueryClient() const [name, setName] = useState(role?.name ?? '') const [maxConfigs, setMaxConfigs] = useState(String(role?.maxConfigs ?? 3)) + const [maxIpLimit, setMaxIpLimit] = useState(String(role?.maxIpLimit ?? 2)) const [internalOpen, setInternalOpen] = useState(false) const isControlled = open !== undefined @@ -30,13 +31,17 @@ export function RoleFormDialog({ const setDialogOpen = isControlled ? onOpenChange! : setInternalOpen 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 () => { toast.success(role ? t('admin.roles.updated') : t('admin.roles.created')) await queryClient.invalidateQueries({ queryKey: ['admin-roles'] }) setDialogOpen(false) setName('') setMaxConfigs('3') + setMaxIpLimit('2') }, onError: () => toast.error(t('auth.genericError')), }) @@ -70,6 +75,11 @@ export function RoleFormDialog({ setMaxConfigs(e.target.value)} />

{t('admin.roles.maxConfigsHint')}

+
+ + setMaxIpLimit(e.target.value)} /> +

{t('admin.roles.maxIpLimitHint')}

+
diff --git a/frontend/src/features/admin/roles/api.ts b/frontend/src/features/admin/roles/api.ts index 34d5ed2..a965f0a 100644 --- a/frontend/src/features/admin/roles/api.ts +++ b/frontend/src/features/admin/roles/api.ts @@ -5,12 +5,12 @@ export function listRoles() { return apiRequest('/admin/roles') } -export function createRole(name: string, maxConfigs: number) { - return apiRequest('/admin/roles', { method: 'POST', body: { name, maxConfigs } }) +export function createRole(name: string, maxConfigs: number, maxIpLimit: number) { + return apiRequest('/admin/roles', { method: 'POST', body: { name, maxConfigs, maxIpLimit } }) } -export function updateRole(id: string, maxConfigs: number) { - return apiRequest(`/admin/roles/${id}`, { method: 'PUT', body: { maxConfigs } }) +export function updateRole(id: string, maxConfigs: number, maxIpLimit: number) { + return apiRequest(`/admin/roles/${id}`, { method: 'PUT', body: { maxConfigs, maxIpLimit } }) } export function deleteRole(id: string) { diff --git a/frontend/src/features/admin/users/UserManageDialog.tsx b/frontend/src/features/admin/users/UserManageDialog.tsx index b0c50fd..48c8abb 100644 --- a/frontend/src/features/admin/users/UserManageDialog.tsx +++ b/frontend/src/features/admin/users/UserManageDialog.tsx @@ -9,10 +9,12 @@ import { Label } from '@/shared/ui/label' import { Badge } from '@/shared/ui/badge' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select' import { listRoles } from '@/features/admin/roles/api' +import { useAuthStore } from '@/features/auth/store' import type { UserSummaryDto } from '@/shared/api/types' import { blockUser, changeUserRole, + deleteUser, forceRevokeConfig, getUserConfigs, resetUserPassword, @@ -23,6 +25,7 @@ export function UserManageDialog({ user, open, onOpenChange }: { user: UserSumma const { t } = useTranslation() const queryClient = useQueryClient() const [newPassword, setNewPassword] = useState('') + const currentUserId = useAuthStore((state) => state.user?.id) 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 }) @@ -65,6 +68,16 @@ export function UserManageDialog({ user, open, onOpenChange }: { user: UserSumma 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 ( @@ -147,6 +160,18 @@ export function UserManageDialog({ user, open, onOpenChange }: { user: UserSumma ))} + + {user.id !== currentUserId && ( + + )} diff --git a/frontend/src/features/admin/users/api.ts b/frontend/src/features/admin/users/api.ts index 53814b9..88dc418 100644 --- a/frontend/src/features/admin/users/api.ts +++ b/frontend/src/features/admin/users/api.ts @@ -30,3 +30,7 @@ export function forceRevokeConfig(id: string) { export function changeUserRole(id: string, roleId: string) { return apiRequest(`/admin/users/${id}/role`, { method: 'PATCH', body: { roleId } }) } + +export function deleteUser(id: string) { + return apiRequest(`/admin/users/${id}`, { method: 'DELETE' }) +} diff --git a/frontend/src/routes/admin/roles.tsx b/frontend/src/routes/admin/roles.tsx index 942057f..dd76af4 100644 --- a/frontend/src/routes/admin/roles.tsx +++ b/frontend/src/routes/admin/roles.tsx @@ -51,6 +51,7 @@ function AdminRolesPage() { {t('admin.roles.name')} {t('admin.roles.maxConfigs')} + {t('admin.roles.maxIpLimit')} @@ -62,6 +63,7 @@ function AdminRolesPage() { {role.name} {role.isSystem && {t('admin.roles.system')}} {role.maxConfigs < 0 ? t('unlimited') : role.maxConfigs} + {role.maxIpLimit < 0 ? t('unlimited') : role.maxIpLimit}