From 8b92204733693d0bd7c7e2da1a5a8579198a2089 Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Thu, 2 Jul 2026 12:56:03 +0300 Subject: [PATCH] Enhance API endpoints with response type annotations - Updated various API endpoints to include response type annotations using .Produces() for better documentation and type safety. - Enhanced activation, admin, user, config, and other endpoints to specify response types, improving clarity for frontend integration. - Added new DTOs for structured responses in authentication and Telegram-related endpoints. - Improved overall API schema generation to reflect these changes, ensuring consistency between backend and frontend types. --- .../Endpoints/ActivationEndpoints.cs | 11 +- .../Endpoints/AdminAppEndpoints.cs | 8 +- .../Endpoints/AdminStatsEndpoints.cs | 5 +- .../Endpoints/AdminUserEndpoints.cs | 15 +- .../PnvPanel.Api/Endpoints/AppEndpoints.cs | 6 +- .../PnvPanel.Api/Endpoints/AuthEndpoints.cs | 26 +- .../PnvPanel.Api/Endpoints/ConfigEndpoints.cs | 25 +- .../Endpoints/InboundEndpoints.cs | 4 +- .../PnvPanel.Api/Endpoints/NodeEndpoints.cs | 12 +- .../PnvPanel.Api/Endpoints/RoleEndpoints.cs | 11 +- .../Endpoints/SubscriptionEndpoints.cs | 4 +- .../Endpoints/TelegramEndpoints.cs | 39 +- docs/roadmap.md | 8 +- frontend/src/shared/api/schema.gen.ts | 457 +++++++++++++++--- frontend/src/shared/api/types.ts | 5 +- 15 files changed, 493 insertions(+), 143 deletions(-) diff --git a/backend/src/PnvPanel.Api/Endpoints/ActivationEndpoints.cs b/backend/src/PnvPanel.Api/Endpoints/ActivationEndpoints.cs index 9dd5cba..d2f8469 100644 --- a/backend/src/PnvPanel.Api/Endpoints/ActivationEndpoints.cs +++ b/backend/src/PnvPanel.Api/Endpoints/ActivationEndpoints.cs @@ -2,6 +2,7 @@ using PnvPanel.Api.Common; using PnvPanel.Application.Activation; using PnvPanel.Application.Admin.Activation; using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; using PnvPanel.Domain.Activation; using PnvPanel.Infrastructure.Identity; @@ -12,16 +13,16 @@ public static class ActivationEndpoints public static IEndpointRouteBuilder MapActivationEndpoints(this IEndpointRouteBuilder app) { var user = app.MapGroup("/api/activation").WithTags("Activation").RequireAuthorization(); - user.MapGet("/status", GetStatus); - user.MapPost("/request", RequestActivation); + user.MapGet("/status", GetStatus).Produces(); + user.MapPost("/request", RequestActivation).Produces(); var admin = app.MapGroup("/api/admin/activation-requests") .WithTags("Admin.Activation") .RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin)); - admin.MapGet("", ListRequests); - admin.MapPost("/{id:guid}/approve", Approve); - admin.MapPost("/{id:guid}/reject", Reject); + admin.MapGet("", ListRequests).Produces>(); + admin.MapPost("/{id:guid}/approve", Approve).Produces(StatusCodes.Status204NoContent); + admin.MapPost("/{id:guid}/reject", Reject).Produces(StatusCodes.Status204NoContent); return app; } diff --git a/backend/src/PnvPanel.Api/Endpoints/AdminAppEndpoints.cs b/backend/src/PnvPanel.Api/Endpoints/AdminAppEndpoints.cs index c7c9855..738803a 100644 --- a/backend/src/PnvPanel.Api/Endpoints/AdminAppEndpoints.cs +++ b/backend/src/PnvPanel.Api/Endpoints/AdminAppEndpoints.cs @@ -14,10 +14,10 @@ public static class AdminAppEndpoints .WithTags("Admin.Apps") .RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin)); - admin.MapGet("", ListApps); - admin.MapPost("", CreateApp); - admin.MapPut("/{id:guid}", UpdateApp); - admin.MapDelete("/{id:guid}", DeleteApp); + admin.MapGet("", ListApps).Produces>(); + admin.MapPost("", CreateApp).Produces(); + admin.MapPut("/{id:guid}", UpdateApp).Produces(); + admin.MapDelete("/{id:guid}", DeleteApp).Produces(StatusCodes.Status204NoContent); return app; } diff --git a/backend/src/PnvPanel.Api/Endpoints/AdminStatsEndpoints.cs b/backend/src/PnvPanel.Api/Endpoints/AdminStatsEndpoints.cs index 5b7fd67..d02dc5f 100644 --- a/backend/src/PnvPanel.Api/Endpoints/AdminStatsEndpoints.cs +++ b/backend/src/PnvPanel.Api/Endpoints/AdminStatsEndpoints.cs @@ -2,6 +2,7 @@ using PnvPanel.Api.Common; using PnvPanel.Application.Admin.Audit; using PnvPanel.Application.Admin.Stats; using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; using PnvPanel.Infrastructure.Identity; namespace PnvPanel.Api.Endpoints; @@ -14,8 +15,8 @@ public static class AdminStatsEndpoints .WithTags("Admin.Stats") .RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin)); - admin.MapGet("/stats", GetStats); - admin.MapGet("/audit", GetAudit); + admin.MapGet("/stats", GetStats).Produces(); + admin.MapGet("/audit", GetAudit).Produces>(); return app; } diff --git a/backend/src/PnvPanel.Api/Endpoints/AdminUserEndpoints.cs b/backend/src/PnvPanel.Api/Endpoints/AdminUserEndpoints.cs index 1555c2f..36ac1da 100644 --- a/backend/src/PnvPanel.Api/Endpoints/AdminUserEndpoints.cs +++ b/backend/src/PnvPanel.Api/Endpoints/AdminUserEndpoints.cs @@ -1,6 +1,9 @@ using PnvPanel.Api.Common; using PnvPanel.Application.Admin.Users; +using PnvPanel.Application.Common.Interfaces; using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; +using PnvPanel.Application.Configs; using PnvPanel.Infrastructure.Identity; namespace PnvPanel.Api.Endpoints; @@ -13,12 +16,12 @@ public static class AdminUserEndpoints .WithTags("Admin.Users") .RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin)); - admin.MapGet("/users", ListUsers); - admin.MapPatch("/users/{id:guid}/block", BlockUser); - admin.MapPatch("/users/{id:guid}/unblock", UnblockUser); - admin.MapPost("/users/{id:guid}/reset-password", ResetPassword); - admin.MapGet("/users/{id:guid}/configs", GetUserConfigs); - admin.MapDelete("/configs/{id:guid}", ForceRevokeConfig); + admin.MapGet("/users", ListUsers).Produces>(); + 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.MapGet("/users/{id:guid}/configs", GetUserConfigs).Produces>(); + admin.MapDelete("/configs/{id:guid}", ForceRevokeConfig).Produces(StatusCodes.Status204NoContent); return app; } diff --git a/backend/src/PnvPanel.Api/Endpoints/AppEndpoints.cs b/backend/src/PnvPanel.Api/Endpoints/AppEndpoints.cs index 668676a..8ffcaeb 100644 --- a/backend/src/PnvPanel.Api/Endpoints/AppEndpoints.cs +++ b/backend/src/PnvPanel.Api/Endpoints/AppEndpoints.cs @@ -1,6 +1,7 @@ using PnvPanel.Api.Common; using PnvPanel.Application.Apps; using PnvPanel.Application.Common.Messaging; +using PnvPanel.Domain.Apps; namespace PnvPanel.Api.Endpoints; @@ -8,7 +9,10 @@ public static class AppEndpoints { public static IEndpointRouteBuilder MapAppEndpoints(this IEndpointRouteBuilder app) { - app.MapGet("/api/apps", ListApps).WithTags("Apps").RequireAuthorization(); + app.MapGet("/api/apps", ListApps) + .WithTags("Apps") + .RequireAuthorization() + .Produces>>(); return app; } diff --git a/backend/src/PnvPanel.Api/Endpoints/AuthEndpoints.cs b/backend/src/PnvPanel.Api/Endpoints/AuthEndpoints.cs index 4127db6..e72a26d 100644 --- a/backend/src/PnvPanel.Api/Endpoints/AuthEndpoints.cs +++ b/backend/src/PnvPanel.Api/Endpoints/AuthEndpoints.cs @@ -21,13 +21,13 @@ public static class AuthEndpoints .WithTags("Auth") .RequireRateLimiting(RateLimiting.AuthPolicy); - group.MapPost("/register", Register); - group.MapPost("/login", Login); - group.MapPost("/refresh", Refresh); - group.MapPost("/logout", Logout).RequireAuthorization(); - group.MapPost("/change-password", ChangePassword).RequireAuthorization(); - group.MapGet("/me", Me).RequireAuthorization(); - group.MapDelete("/me", DeleteMe).RequireAuthorization(); + group.MapPost("/register", Register).Produces(); + group.MapPost("/login", Login).Produces(); + group.MapPost("/refresh", Refresh).Produces(); + group.MapPost("/logout", Logout).RequireAuthorization().Produces(StatusCodes.Status204NoContent); + group.MapPost("/change-password", ChangePassword).RequireAuthorization().Produces(StatusCodes.Status204NoContent); + group.MapGet("/me", Me).RequireAuthorization().Produces(); + group.MapDelete("/me", DeleteMe).RequireAuthorization().Produces(StatusCodes.Status204NoContent); return app; } @@ -64,6 +64,9 @@ public static class AuthEndpoints return Results.Ok(ToLoginResponse(result.Value)); } + internal static AuthResponseDto ToLoginResponse(AuthResult auth) => + new(auth.AccessToken, auth.AccessTokenExpiresAt, auth.User); + private static async Task Logout(HttpRequest request, HttpResponse response, ISender sender, CancellationToken cancellationToken) { if (request.Cookies.TryGetValue(RefreshCookieName, out var rawToken) && !string.IsNullOrEmpty(rawToken)) @@ -92,13 +95,6 @@ public static class AuthEndpoints return result.ToHttpResult(); } - private static object ToLoginResponse(AuthResult auth) => new - { - accessToken = auth.AccessToken, - expiresAt = auth.AccessTokenExpiresAt, - user = auth.User, - }; - private static void SetRefreshCookie(HttpRequest request, HttpResponse response, string rawToken, DateTimeOffset expiresAt) { var options = BuildCookieOptions(request); @@ -116,3 +112,5 @@ public static class AuthEndpoints Path = "/api/auth", }; } + +public sealed record AuthResponseDto(string AccessToken, DateTimeOffset ExpiresAt, CurrentUserDto User); diff --git a/backend/src/PnvPanel.Api/Endpoints/ConfigEndpoints.cs b/backend/src/PnvPanel.Api/Endpoints/ConfigEndpoints.cs index b259d3e..9da2d1e 100644 --- a/backend/src/PnvPanel.Api/Endpoints/ConfigEndpoints.cs +++ b/backend/src/PnvPanel.Api/Endpoints/ConfigEndpoints.cs @@ -1,5 +1,6 @@ using PnvPanel.Api.Common; using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Configs; using PnvPanel.Application.Configs.Create; using PnvPanel.Application.Configs.Edit; using PnvPanel.Application.Configs.GetConfigLink; @@ -17,14 +18,14 @@ public static class ConfigEndpoints { var group = app.MapGroup("/api").WithTags("Configs").RequireAuthorization(); - group.MapGet("/inbounds/available", ListAvailableInbounds); - group.MapGet("/configs", GetMyConfigs); - group.MapPost("/configs", CreateConfig); - group.MapPatch("/configs/{id:guid}", EditConfig); - group.MapPost("/configs/{id:guid}/rotate", RotateConfig); - group.MapDelete("/configs/{id:guid}", RevokeConfig); - group.MapGet("/configs/{id:guid}/link", GetConfigLink); - group.MapGet("/subscription", GetMySubscription); + group.MapGet("/inbounds/available", ListAvailableInbounds).Produces>(); + group.MapGet("/configs", GetMyConfigs).Produces(); + group.MapPost("/configs", CreateConfig).Produces(); + group.MapPatch("/configs/{id:guid}", EditConfig).Produces(); + group.MapPost("/configs/{id:guid}/rotate", RotateConfig).Produces(); + group.MapDelete("/configs/{id:guid}", RevokeConfig).Produces(StatusCodes.Status204NoContent); + group.MapGet("/configs/{id:guid}/link", GetConfigLink).Produces(); + group.MapGet("/subscription", GetMySubscription).Produces(); return app; } @@ -74,7 +75,7 @@ public static class ConfigEndpoints return result.ToHttpResult(); var subscriptionUrl = $"{request.Scheme}://{request.Host}/sub/{result.Value.SubscriptionToken}"; - return Results.Ok(new { connectionString = result.Value.ConnectionString, subscriptionUrl }); + return Results.Ok(new ConfigLinkResponseDto(result.Value.ConnectionString, subscriptionUrl)); } private static async Task GetMySubscription(HttpRequest request, ISender sender, CancellationToken cancellationToken) @@ -84,10 +85,14 @@ public static class ConfigEndpoints return result.ToHttpResult(); var subscriptionUrl = $"{request.Scheme}://{request.Host}/sub/{result.Value.SubscriptionToken}"; - return Results.Ok(new { subscriptionUrl }); + return Results.Ok(new MySubscriptionResponseDto(subscriptionUrl)); } } public sealed record CreateConfigBody(Guid InboundId, string? Label, int? DeviceLimit); public sealed record EditConfigBody(string? Label, int? DeviceLimit); + +public sealed record ConfigLinkResponseDto(string ConnectionString, string SubscriptionUrl); + +public sealed record MySubscriptionResponseDto(string SubscriptionUrl); diff --git a/backend/src/PnvPanel.Api/Endpoints/InboundEndpoints.cs b/backend/src/PnvPanel.Api/Endpoints/InboundEndpoints.cs index 4b31810..67114b7 100644 --- a/backend/src/PnvPanel.Api/Endpoints/InboundEndpoints.cs +++ b/backend/src/PnvPanel.Api/Endpoints/InboundEndpoints.cs @@ -13,8 +13,8 @@ public static class InboundEndpoints .WithTags("Admin.Inbounds") .RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin)); - admin.MapGet("", ListInbounds); - admin.MapPut("/{id:guid}/publish", PublishInbound); + admin.MapGet("", ListInbounds).Produces>(); + admin.MapPut("/{id:guid}/publish", PublishInbound).Produces(); return app; } diff --git a/backend/src/PnvPanel.Api/Endpoints/NodeEndpoints.cs b/backend/src/PnvPanel.Api/Endpoints/NodeEndpoints.cs index 714d752..f4c8158 100644 --- a/backend/src/PnvPanel.Api/Endpoints/NodeEndpoints.cs +++ b/backend/src/PnvPanel.Api/Endpoints/NodeEndpoints.cs @@ -13,12 +13,12 @@ public static class NodeEndpoints .WithTags("Admin.Nodes") .RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin)); - admin.MapGet("", ListNodes); - admin.MapPost("", RegisterNode); - admin.MapPut("/{id:guid}", UpdateNode); - admin.MapDelete("/{id:guid}", DeleteNode); - admin.MapPost("/{id:guid}/sync", SyncNode); - admin.MapPost("/{id:guid}/probe", ProbeNode); + admin.MapGet("", ListNodes).Produces>(); + admin.MapPost("", RegisterNode).Produces(); + admin.MapPut("/{id:guid}", UpdateNode).Produces(); + admin.MapDelete("/{id:guid}", DeleteNode).Produces(StatusCodes.Status204NoContent); + admin.MapPost("/{id:guid}/sync", SyncNode).Produces(); + admin.MapPost("/{id:guid}/probe", ProbeNode).Produces(); return app; } diff --git a/backend/src/PnvPanel.Api/Endpoints/RoleEndpoints.cs b/backend/src/PnvPanel.Api/Endpoints/RoleEndpoints.cs index 6778d8b..a7050cc 100644 --- a/backend/src/PnvPanel.Api/Endpoints/RoleEndpoints.cs +++ b/backend/src/PnvPanel.Api/Endpoints/RoleEndpoints.cs @@ -1,6 +1,7 @@ using PnvPanel.Api.Common; using PnvPanel.Application.Admin.Roles; using PnvPanel.Application.Admin.Users; +using PnvPanel.Application.Common.Interfaces; using PnvPanel.Application.Common.Messaging; using PnvPanel.Infrastructure.Identity; @@ -14,11 +15,11 @@ public static class RoleEndpoints .WithTags("Admin.Roles") .RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin)); - admin.MapGet("/roles", ListRoles); - admin.MapPost("/roles", CreateRole); - admin.MapPut("/roles/{id:guid}", UpdateRole); - admin.MapDelete("/roles/{id:guid}", DeleteRole); - admin.MapPatch("/users/{id:guid}/role", ChangeUserRole); + admin.MapGet("/roles", ListRoles).Produces>(); + admin.MapPost("/roles", CreateRole).Produces(); + admin.MapPut("/roles/{id:guid}", UpdateRole).Produces(); + admin.MapDelete("/roles/{id:guid}", DeleteRole).Produces(StatusCodes.Status204NoContent); + admin.MapPatch("/users/{id:guid}/role", ChangeUserRole).Produces(StatusCodes.Status204NoContent); return app; } diff --git a/backend/src/PnvPanel.Api/Endpoints/SubscriptionEndpoints.cs b/backend/src/PnvPanel.Api/Endpoints/SubscriptionEndpoints.cs index cab366f..f22b5f6 100644 --- a/backend/src/PnvPanel.Api/Endpoints/SubscriptionEndpoints.cs +++ b/backend/src/PnvPanel.Api/Endpoints/SubscriptionEndpoints.cs @@ -12,7 +12,9 @@ public static class SubscriptionEndpoints // Вне /api по дизайну (api-design.md) — публичный эндпоинт для VPN-клиентов. app.MapGet("/sub/{token}", GetSubscription) .WithTags("Subscription") - .RequireRateLimiting(RateLimiting.AuthPolicy); + .RequireRateLimiting(RateLimiting.AuthPolicy) + .Produces(StatusCodes.Status200OK, "text/plain") + .Produces(StatusCodes.Status404NotFound); return app; } diff --git a/backend/src/PnvPanel.Api/Endpoints/TelegramEndpoints.cs b/backend/src/PnvPanel.Api/Endpoints/TelegramEndpoints.cs index af629c1..8bcf274 100644 --- a/backend/src/PnvPanel.Api/Endpoints/TelegramEndpoints.cs +++ b/backend/src/PnvPanel.Api/Endpoints/TelegramEndpoints.cs @@ -1,7 +1,9 @@ using Microsoft.Extensions.Options; using PnvPanel.Api.Common; +using PnvPanel.Application.Auth; using PnvPanel.Application.Common.Messaging; using PnvPanel.Application.Telegram; +using PnvPanel.Domain.Telegram; using PnvPanel.Infrastructure.Telegram; namespace PnvPanel.Api.Endpoints; @@ -14,10 +16,10 @@ public static class TelegramEndpoints .WithTags("Auth.Telegram") .RequireRateLimiting(RateLimiting.AuthPolicy); - group.MapPost("/link-token", CreateLinkToken).RequireAuthorization(); - group.MapPost("/unlink", Unlink).RequireAuthorization(); - group.MapPost("/login-request", CreateLoginRequest); - group.MapGet("/login-request/{id:guid}", GetLoginRequestStatus); + group.MapPost("/link-token", CreateLinkToken).RequireAuthorization().Produces(); + group.MapPost("/unlink", Unlink).RequireAuthorization().Produces(StatusCodes.Status204NoContent); + group.MapPost("/login-request", CreateLoginRequest).Produces(); + group.MapGet("/login-request/{id:guid}", GetLoginRequestStatus).Produces(); return app; } @@ -34,7 +36,7 @@ public static class TelegramEndpoints ? null : $"https://t.me/{botUsername}?start=link_{result.Value.Token}"; - return Results.Ok(new { deepLink, expiresAt = result.Value.ExpiresAt }); + return Results.Ok(new LinkTokenResponseDto(deepLink, result.Value.ExpiresAt)); } private static async Task Unlink(ISender sender, CancellationToken cancellationToken) @@ -56,10 +58,11 @@ public static class TelegramEndpoints ? null : $"https://t.me/{botUsername}?start=login_{result.Value.RequestId}"; - return Results.Ok(new { requestId = result.Value.RequestId, deepLink, expiresAt = result.Value.ExpiresAt }); + return Results.Ok(new TelegramLoginRequestResponseDto(result.Value.RequestId, deepLink, result.Value.ExpiresAt)); } - private static async Task GetLoginRequestStatus(Guid id, HttpResponse response, ISender sender, CancellationToken cancellationToken) + private static async Task GetLoginRequestStatus( + Guid id, HttpRequest request, HttpResponse response, ISender sender, CancellationToken cancellationToken) { var result = await sender.Send(new GetLoginRequestStatusQuery(id), cancellationToken); if (!result.IsSuccess) @@ -67,25 +70,29 @@ public static class TelegramEndpoints if (result.Value.Auth is { } auth) { + // Secure = IsHttps запроса (учитывает ForwardedHeaders за внешним TLS-прокси) — + // иначе браузер/HttpClient не пришлёт cookie обратно на plain-http (см. AuthEndpoints). var cookieOptions = new CookieOptions { HttpOnly = true, - Secure = true, + Secure = request.IsHttps, SameSite = SameSiteMode.Strict, Path = "/api/auth", Expires = auth.RefreshTokenExpiresAt, }; response.Cookies.Append("pnv_refresh_token", auth.RefreshToken, cookieOptions); - return Results.Ok(new - { - status = result.Value.Status.ToString(), - accessToken = auth.AccessToken, - expiresAt = auth.AccessTokenExpiresAt, - user = auth.User, - }); + return Results.Ok(new TelegramLoginStatusResponseDto( + result.Value.Status, auth.AccessToken, auth.AccessTokenExpiresAt, auth.User)); } - return Results.Ok(new { status = result.Value.Status.ToString() }); + return Results.Ok(new TelegramLoginStatusResponseDto(result.Value.Status, null, null, null)); } } + +public sealed record LinkTokenResponseDto(string? DeepLink, DateTimeOffset ExpiresAt); + +public sealed record TelegramLoginRequestResponseDto(Guid RequestId, string? DeepLink, DateTimeOffset ExpiresAt); + +public sealed record TelegramLoginStatusResponseDto( + TelegramLoginStatus Status, string? AccessToken, DateTimeOffset? ExpiresAt, CurrentUserDto? User); diff --git a/docs/roadmap.md b/docs/roadmap.md index 8637a82..cc59b6c 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -73,7 +73,8 @@ - **Админ в боте**: уведомления о запросах активации + inline «Активировать/Отклонить», `/requests` (по Telegram id из env). - **DM-уведомления юзеру**: активация (`ApproveActivationCommandHandler`), блокировка (`BlockUserCommandHandler`), принудительный отзыв конфига админом (`ForceRevokeConfigCommandHandler`) — если Telegram привязан. Бот — read-only по конфигам. - **Готово, когда**: юзер привязывает Telegram, входит без пароля, видит конфиги; админ активирует запросы прямо в боте. ✅ Достигнуто. -- **Перенесено в backlog** (не реализовано в MVP): восстановление пароля через бота (`/resetpassword` с одноразовой ссылкой) — сейчас сброс пароля только через админа (`ResetUserPasswordCommand`); QR прямо в сообщениях бота; фронтовые кнопки «Войти через Telegram»/«Привязать Telegram» (бэкенд-контракт готов, фронт не реализовывался в эту итерацию). +- **Перенесено в backlog** (не реализовано в MVP): восстановление пароля через бота (`/resetpassword` с одноразовой ссылкой) — сейчас сброс пароля только через админа (`ResetUserPasswordCommand`); QR прямо в сообщениях бота. + Фронтовые кнопки «Войти через Telegram»/«Привязать Telegram» реализованы в отдельной итерации (см. M0 фронт). ## M8 — Закалка (hardening) ✅ - Тесты: `PnvPanel.Domain.Tests` (54, чистые unit-тесты инвариантов сущностей), `PnvPanel.Application.Tests` @@ -87,8 +88,9 @@ `dp_keys` для key-ring Data Protection (переживает пересоздание контейнера), healthcheck `app` через `GET /health` (curl добавлен в runtime-образ). TLS — внешним прокси (без изменений). - **Готово, когда**: зелёный CI, покрытие ключевых сценариев, готовность к деплою. ✅ Достигнуто - (интеграционные тесты не запускались локально — Docker Desktop недоступен на машине разработки; - зависят от Docker в CI для первого реального прогона). + (интеграционные тесты прогнаны локально через Testcontainers после появления Docker на машине + разработки — 134/134 зелёных; там же впервые собран и проверен единый Docker-образ и + docker-compose стек end-to-end). ## Backlog (после MVP) - Полное самообслуживание в боте (создание/ротация/отзыв конфигов) — в MVP бот read-only. diff --git a/frontend/src/shared/api/schema.gen.ts b/frontend/src/shared/api/schema.gen.ts index 942d1a8..87a3efb 100644 --- a/frontend/src/shared/api/schema.gen.ts +++ b/frontend/src/shared/api/schema.gen.ts @@ -24,6 +24,15 @@ export interface paths { responses: { /** @description OK */ 200: { + headers: { + [name: string]: unknown; + }; + content: { + "text/plain": string; + }; + }; + /** @description Not Found */ + 404: { headers: { [name: string]: unknown; }; @@ -60,7 +69,11 @@ export interface paths { headers: { [name: string]: unknown; }; - content?: never; + content: { + "application/json": { + [key: string]: components["schemas"]["ClientAppDto"][]; + }; + }; }; }; }; @@ -99,7 +112,9 @@ export interface paths { headers: { [name: string]: unknown; }; - content?: never; + content: { + "application/json": components["schemas"]["RegisterResult"]; + }; }; }; }; @@ -136,7 +151,9 @@ export interface paths { headers: { [name: string]: unknown; }; - content?: never; + content: { + "application/json": components["schemas"]["AuthResponseDto"]; + }; }; }; }; @@ -169,7 +186,9 @@ export interface paths { headers: { [name: string]: unknown; }; - content?: never; + content: { + "application/json": components["schemas"]["AuthResponseDto"]; + }; }; }; }; @@ -197,8 +216,8 @@ export interface paths { }; requestBody?: never; responses: { - /** @description OK */ - 200: { + /** @description No Content */ + 204: { headers: { [name: string]: unknown; }; @@ -234,8 +253,8 @@ export interface paths { }; }; responses: { - /** @description OK */ - 200: { + /** @description No Content */ + 204: { headers: { [name: string]: unknown; }; @@ -270,7 +289,9 @@ export interface paths { headers: { [name: string]: unknown; }; - content?: never; + content: { + "application/json": components["schemas"]["CurrentUserDto"]; + }; }; }; }; @@ -285,8 +306,8 @@ export interface paths { }; requestBody?: never; responses: { - /** @description OK */ - 200: { + /** @description No Content */ + 204: { headers: { [name: string]: unknown; }; @@ -320,7 +341,9 @@ export interface paths { headers: { [name: string]: unknown; }; - content?: never; + content: { + "application/json": components["schemas"]["ActivationStatusDto"]; + }; }; }; }; @@ -359,7 +382,9 @@ export interface paths { headers: { [name: string]: unknown; }; - content?: never; + content: { + "application/json": components["schemas"]["ActivationRequestDto"]; + }; }; }; }; @@ -394,7 +419,9 @@ export interface paths { headers: { [name: string]: unknown; }; - content?: never; + content: { + "application/json": components["schemas"]["PagedListOfActivationRequestAdminDto"]; + }; }; }; }; @@ -426,8 +453,8 @@ export interface paths { }; requestBody?: never; responses: { - /** @description OK */ - 200: { + /** @description No Content */ + 204: { headers: { [name: string]: unknown; }; @@ -465,8 +492,8 @@ export interface paths { }; }; responses: { - /** @description OK */ - 200: { + /** @description No Content */ + 204: { headers: { [name: string]: unknown; }; @@ -501,7 +528,9 @@ export interface paths { headers: { [name: string]: unknown; }; - content?: never; + content: { + "application/json": components["schemas"]["RoleDto"][]; + }; }; }; }; @@ -524,7 +553,9 @@ export interface paths { headers: { [name: string]: unknown; }; - content?: never; + content: { + "application/json": components["schemas"]["RoleDto"]; + }; }; }; }; @@ -562,7 +593,9 @@ export interface paths { headers: { [name: string]: unknown; }; - content?: never; + content: { + "application/json": components["schemas"]["RoleDto"]; + }; }; }; }; @@ -578,8 +611,8 @@ export interface paths { }; requestBody?: never; responses: { - /** @description OK */ - 200: { + /** @description No Content */ + 204: { headers: { [name: string]: unknown; }; @@ -620,8 +653,8 @@ export interface paths { }; }; responses: { - /** @description OK */ - 200: { + /** @description No Content */ + 204: { headers: { [name: string]: unknown; }; @@ -652,7 +685,9 @@ export interface paths { headers: { [name: string]: unknown; }; - content?: never; + content: { + "application/json": components["schemas"]["NodeDto"][]; + }; }; }; }; @@ -675,7 +710,9 @@ export interface paths { headers: { [name: string]: unknown; }; - content?: never; + content: { + "application/json": components["schemas"]["NodeDto"]; + }; }; }; }; @@ -713,7 +750,9 @@ export interface paths { headers: { [name: string]: unknown; }; - content?: never; + content: { + "application/json": components["schemas"]["NodeDto"]; + }; }; }; }; @@ -729,8 +768,8 @@ export interface paths { }; requestBody?: never; responses: { - /** @description OK */ - 200: { + /** @description No Content */ + 204: { headers: { [name: string]: unknown; }; @@ -768,7 +807,9 @@ export interface paths { headers: { [name: string]: unknown; }; - content?: never; + content: { + "application/json": components["schemas"]["SyncNodeResultDto"]; + }; }; }; }; @@ -803,7 +844,9 @@ export interface paths { headers: { [name: string]: unknown; }; - content?: never; + content: { + "application/json": components["schemas"]["NodeProbeResultDto"]; + }; }; }; }; @@ -836,7 +879,9 @@ export interface paths { headers: { [name: string]: unknown; }; - content?: never; + content: { + "application/json": components["schemas"]["InboundDto"][]; + }; }; }; }; @@ -876,7 +921,9 @@ export interface paths { headers: { [name: string]: unknown; }; - content?: never; + content: { + "application/json": components["schemas"]["InboundDto"]; + }; }; }; }; @@ -908,7 +955,9 @@ export interface paths { headers: { [name: string]: unknown; }; - content?: never; + content: { + "application/json": components["schemas"]["AvailableInboundDto"][]; + }; }; }; }; @@ -941,7 +990,9 @@ export interface paths { headers: { [name: string]: unknown; }; - content?: never; + content: { + "application/json": components["schemas"]["GetMyConfigsResult"]; + }; }; }; }; @@ -964,7 +1015,9 @@ export interface paths { headers: { [name: string]: unknown; }; - content?: never; + content: { + "application/json": components["schemas"]["VpnConfigDto"]; + }; }; }; }; @@ -995,8 +1048,8 @@ export interface paths { }; requestBody?: never; responses: { - /** @description OK */ - 200: { + /** @description No Content */ + 204: { headers: { [name: string]: unknown; }; @@ -1026,7 +1079,9 @@ export interface paths { headers: { [name: string]: unknown; }; - content?: never; + content: { + "application/json": components["schemas"]["VpnConfigDto"]; + }; }; }; }; @@ -1057,7 +1112,9 @@ export interface paths { headers: { [name: string]: unknown; }; - content?: never; + content: { + "application/json": components["schemas"]["VpnConfigDto"]; + }; }; }; }; @@ -1090,7 +1147,9 @@ export interface paths { headers: { [name: string]: unknown; }; - content?: never; + content: { + "application/json": components["schemas"]["ConfigLinkResponseDto"]; + }; }; }; }; @@ -1123,7 +1182,9 @@ export interface paths { headers: { [name: string]: unknown; }; - content?: never; + content: { + "application/json": components["schemas"]["MySubscriptionResponseDto"]; + }; }; }; }; @@ -1160,7 +1221,9 @@ export interface paths { headers: { [name: string]: unknown; }; - content?: never; + content: { + "application/json": components["schemas"]["PagedListOfUserSummaryDto"]; + }; }; }; }; @@ -1196,8 +1259,8 @@ export interface paths { }; requestBody?: never; responses: { - /** @description OK */ - 200: { + /** @description No Content */ + 204: { headers: { [name: string]: unknown; }; @@ -1231,8 +1294,8 @@ export interface paths { }; requestBody?: never; responses: { - /** @description OK */ - 200: { + /** @description No Content */ + 204: { headers: { [name: string]: unknown; }; @@ -1266,8 +1329,8 @@ export interface paths { }; }; responses: { - /** @description OK */ - 200: { + /** @description No Content */ + 204: { headers: { [name: string]: unknown; }; @@ -1304,7 +1367,9 @@ export interface paths { headers: { [name: string]: unknown; }; - content?: never; + content: { + "application/json": components["schemas"]["VpnConfigDto"][]; + }; }; }; }; @@ -1337,8 +1402,8 @@ export interface paths { }; requestBody?: never; responses: { - /** @description OK */ - 200: { + /** @description No Content */ + 204: { headers: { [name: string]: unknown; }; @@ -1372,7 +1437,9 @@ export interface paths { headers: { [name: string]: unknown; }; - content?: never; + content: { + "application/json": components["schemas"]["StatsDto"]; + }; }; }; }; @@ -1408,7 +1475,9 @@ export interface paths { headers: { [name: string]: unknown; }; - content?: never; + content: { + "application/json": components["schemas"]["PagedListOfAuditLogDto"]; + }; }; }; }; @@ -1441,7 +1510,9 @@ export interface paths { headers: { [name: string]: unknown; }; - content?: never; + content: { + "application/json": components["schemas"]["AdminAppDto"][]; + }; }; }; }; @@ -1464,7 +1535,9 @@ export interface paths { headers: { [name: string]: unknown; }; - content?: never; + content: { + "application/json": components["schemas"]["AdminAppDto"]; + }; }; }; }; @@ -1502,7 +1575,9 @@ export interface paths { headers: { [name: string]: unknown; }; - content?: never; + content: { + "application/json": components["schemas"]["AdminAppDto"]; + }; }; }; }; @@ -1518,8 +1593,8 @@ export interface paths { }; requestBody?: never; responses: { - /** @description OK */ - 200: { + /** @description No Content */ + 204: { headers: { [name: string]: unknown; }; @@ -1555,7 +1630,9 @@ export interface paths { headers: { [name: string]: unknown; }; - content?: never; + content: { + "application/json": components["schemas"]["LinkTokenResponseDto"]; + }; }; }; }; @@ -1583,8 +1660,8 @@ export interface paths { }; requestBody?: never; responses: { - /** @description OK */ - 200: { + /** @description No Content */ + 204: { headers: { [name: string]: unknown; }; @@ -1621,7 +1698,9 @@ export interface paths { headers: { [name: string]: unknown; }; - content?: never; + content: { + "application/json": components["schemas"]["TelegramLoginRequestResponseDto"]; + }; }; }; }; @@ -1654,7 +1733,9 @@ export interface paths { headers: { [name: string]: unknown; }; - content?: never; + content: { + "application/json": components["schemas"]["TelegramLoginStatusResponseDto"]; + }; }; }; }; @@ -1670,8 +1751,69 @@ export interface paths { export type webhooks = Record; export interface components { schemas: { + ActivationRequestAdminDto: { + /** Format: uuid */ + id: string; + /** Format: uuid */ + userId: string; + userName: string; + comment: null | string; + status: components["schemas"]["ActivationStatus"]; + /** Format: date-time */ + createdAt: string; + }; + ActivationRequestDto: { + /** Format: uuid */ + id: string; + comment: null | string; + /** Format: date-time */ + createdAt: string; + }; /** @enum {unknown} */ - ActivationStatus: "Pending" | "Approved" | "Rejected" | null; + ActivationStatus: "Pending" | "Approved" | "Rejected"; + ActivationStatusDto: { + isActivated: boolean; + pendingRequest: null | components["schemas"]["ActivationRequestDto"]; + }; + AdminAppDto: { + /** Format: uuid */ + id: string; + name: string; + downloadUrl: string; + operatingSystem: components["schemas"]["OsPlatform"]; + description: null | string; + iconUrl: null | string; + /** Format: int32 */ + sortOrder: number | string; + isEnabled: boolean; + }; + AuditLogDto: { + /** Format: int64 */ + id: number | string; + /** Format: uuid */ + actorId: null | string; + action: string; + targetType: string; + targetId: string; + metadata: null | string; + source: components["schemas"]["AuditSource"]; + /** Format: date-time */ + createdAt: string; + }; + /** @enum {unknown} */ + AuditSource: "Web" | "Telegram" | "System"; + AuthResponseDto: { + accessToken: string; + /** Format: date-time */ + expiresAt: string; + user: components["schemas"]["CurrentUserDto"]; + }; + AvailableInboundDto: { + /** Format: uuid */ + inboundId: string; + displayName: string; + protocol: components["schemas"]["VpnProtocol"]; + }; ChangePasswordCommand: { currentPassword: string; newPassword: string; @@ -1680,6 +1822,20 @@ export interface components { /** Format: uuid */ roleId: string; }; + ClientAppDto: { + /** Format: uuid */ + id: string; + name: string; + downloadUrl: string; + description: null | string; + iconUrl: null | string; + }; + ConfigLinkResponseDto: { + connectionString: string; + subscriptionUrl: string; + }; + /** @enum {unknown} */ + ConfigStatus: "Active" | "Disabled" | "Expired" | "LimitReached" | "Revoked"; CreateAppCommand: { name: string; downloadUrl: string; @@ -1701,17 +1857,102 @@ export interface components { /** Format: int32 */ maxConfigs: number | string; }; + CurrentUserDto: { + /** Format: uuid */ + id: string; + userName: string; + role: string; + isActivated: boolean; + telegramLinked: boolean; + }; EditConfigBody: { label: null | string; /** Format: int32 */ deviceLimit: null | number | string; }; + GetMyConfigsResult: { + configs: components["schemas"]["VpnConfigDto"][]; + /** Format: int32 */ + maxConfigs: number | string; + }; + InboundDto: { + /** Format: uuid */ + id: string; + /** Format: uuid */ + nodeId: string; + remoteInboundId: string; + protocol: components["schemas"]["VpnProtocol"]; + remark: string; + /** Format: int32 */ + port: number | string; + isPublished: boolean; + displayName: null | string; + /** Format: int32 */ + maxClients: null | number | string; + allowedRoleIds: string[]; + /** Format: date-time */ + lastSyncAt: null | string; + }; + LinkTokenResponseDto: { + deepLink: null | string; + /** Format: date-time */ + expiresAt: string; + }; LoginCommand: { userName: string; password: string; }; + MySubscriptionResponseDto: { + subscriptionUrl: string; + }; + NodeDto: { + /** Format: uuid */ + id: string; + name: string; + baseAddress: string; + username: string; + location: null | string; + status: components["schemas"]["NodeStatus"]; + isEnabled: boolean; + /** Format: date-time */ + lastSyncAt: null | string; + }; + NodeProbeResultDto: { + isReachable: boolean; + errorMessage: null | string; + status: components["schemas"]["NodeStatus"]; + }; + /** @enum {unknown} */ + NodeStatus: "Unknown" | "Online" | "Offline"; /** @enum {unknown} */ OsPlatform: "IOS" | "Android" | "Windows" | "MacOS" | "Linux"; + PagedListOfActivationRequestAdminDto: { + items: components["schemas"]["ActivationRequestAdminDto"][]; + /** Format: int32 */ + total: number | string; + /** Format: int32 */ + page: number | string; + /** Format: int32 */ + pageSize: number | string; + }; + PagedListOfAuditLogDto: { + items: components["schemas"]["AuditLogDto"][]; + /** Format: int32 */ + total: number | string; + /** Format: int32 */ + page: number | string; + /** Format: int32 */ + pageSize: number | string; + }; + PagedListOfUserSummaryDto: { + items: components["schemas"]["UserSummaryDto"][]; + /** Format: int32 */ + total: number | string; + /** Format: int32 */ + page: number | string; + /** Format: int32 */ + pageSize: number | string; + }; PublishInboundBody: { isPublished: boolean; displayName: null | string; @@ -1730,6 +1971,11 @@ export interface components { password: string; location: null | string; }; + RegisterResult: { + /** Format: uuid */ + id: string; + userName: string; + }; RejectActivationBody: { reason: null | string; }; @@ -1739,6 +1985,55 @@ export interface components { ResetPasswordBody: { newPassword: string; }; + RoleDto: { + /** Format: uuid */ + id: string; + name: string; + /** Format: int32 */ + maxConfigs: number | string; + isSystem: boolean; + }; + StatsDto: { + /** Format: int32 */ + totalUsers: number | string; + /** Format: int32 */ + activatedUsers: number | string; + /** Format: int32 */ + pendingActivationRequests: number | string; + /** Format: int32 */ + totalNodes: number | string; + /** Format: int32 */ + onlineNodes: number | string; + /** Format: int32 */ + totalConfigs: number | string; + /** Format: int32 */ + activeConfigs: number | string; + /** Format: int64 */ + totalUsedUpBytes: number | string; + /** Format: int64 */ + totalUsedDownBytes: number | string; + }; + SyncNodeResultDto: { + /** Format: int32 */ + inboundsSynced: number | string; + status: components["schemas"]["NodeStatus"]; + }; + TelegramLoginRequestResponseDto: { + /** Format: uuid */ + requestId: string; + deepLink: null | string; + /** Format: date-time */ + expiresAt: string; + }; + /** @enum {unknown} */ + TelegramLoginStatus: "Pending" | "Approved" | "Rejected" | "Expired" | "Consumed"; + TelegramLoginStatusResponseDto: { + status: components["schemas"]["TelegramLoginStatus"]; + accessToken: null | string; + /** Format: date-time */ + expiresAt: null | string; + user: null | components["schemas"]["CurrentUserDto"]; + }; UpdateAppBody: { name: string; downloadUrl: string; @@ -1760,6 +2055,36 @@ export interface components { /** Format: int32 */ maxConfigs: number | string; }; + UserSummaryDto: { + /** Format: uuid */ + id: string; + userName: string; + role: string; + isActivated: boolean; + isBlocked: boolean; + /** Format: date-time */ + activatedAt: null | string; + }; + VpnConfigDto: { + /** Format: uuid */ + id: string; + label: null | string; + protocol: components["schemas"]["VpnProtocol"]; + location: string; + /** Format: int32 */ + deviceLimit: number | string; + /** Format: int64 */ + usedUpBytes: number | string; + /** Format: int64 */ + usedDownBytes: number | string; + /** Format: date-time */ + expiresAt: null | string; + status: components["schemas"]["ConfigStatus"]; + /** Format: date-time */ + createdAt: string; + }; + /** @enum {unknown} */ + VpnProtocol: "Vless" | "Vmess" | "Trojan" | "Shadowsocks"; }; responses: never; parameters: never; diff --git a/frontend/src/shared/api/types.ts b/frontend/src/shared/api/types.ts index bf91bb2..c949b8b 100644 --- a/frontend/src/shared/api/types.ts +++ b/frontend/src/shared/api/types.ts @@ -1,6 +1,7 @@ // Типы вручную синхронизированы с DTO бэкенда (см. backend/src/PnvPanel.Application/**). -// TODO: заменить на `pnpm gen:api` (openapi-typescript), когда бэкенд доступен по сети -// (сейчас недоступен локально — Postgres/Docker не подняты, схему /openapi/v1.json взять негде). +// Держи в синхроне с `pnpm gen:api` (openapi-typescript, -> schema.gen.ts): все эндпоинты аннотированы +// `.Produces()`, схема полностью описывает тела ответов — сверяй при расхождении вручную (сгенерированный +// файл не используется напрямую фичами — предпочтены осмысленные имена и generic PagedList, которых нет в JSON Schema). export type ApiError = { title: string