Implement rate limiting and enhance authentication flow
CI / Backend (build + test) (push) Successful in 1m17s
CI / Frontend (lint + typecheck + build) (push) Successful in 35s

- Added rate limiting configuration for authentication endpoints, allowing customizable request limits via environment variables.
- Updated authentication flow to utilize HttpRequest for cookie management, ensuring secure handling of refresh tokens.
- Introduced a new endpoint to retrieve user subscription details.
- Enhanced the handling of Telegram bot token validation to prevent errors with empty tokens.
- Updated the application to serialize enums as strings for better documentation and compatibility with TypeScript.
- Improved test coverage for new features and adjustments in command handlers.
This commit is contained in:
Leonid Pershin
2026-07-02 12:40:23 +03:00
parent ed07221ca5
commit 8067be3c35
106 changed files with 8823 additions and 172 deletions
+11
View File
@@ -0,0 +1,11 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "frontend",
"runtimeExecutable": "pnpm",
"runtimeArgs": ["--dir", "frontend", "dev"],
"port": 5173
}
]
}
+4
View File
@@ -33,6 +33,10 @@ AdminSeed__TelegramUserIds=123456789
# Квота конфигов для системной роли "user" (выдаётся при регистрации).
Roles__DefaultUserMaxConfigs=3
# ── Rate limiting ────────────────────────────────────────────────────────
# Лимит запросов/мин на auth-эндпоинты (login/register/refresh/telegram/subscription). По умолчанию 20.
# RateLimiting__AuthPermitLimit=20
# ── Telegram-бот ──────────────────────────────────────────────────────────
# Если BotToken пуст — бот не стартует, панель работает без него.
Telegram__BotToken=
+77
View File
@@ -0,0 +1,77 @@
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.5.2.0
MinimumVisualStudioVersion = 10.0.40219.1
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "backend", "backend", "{1AE8ACA6-933B-BF2A-3671-3E2EAC007D16}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{0F9113EE-888A-26D2-68B0-4A7D0A2A8745}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PnvPanel.Api", "backend\src\PnvPanel.Api\PnvPanel.Api.csproj", "{3B6A930E-4799-6F42-1E94-163F6773FBBC}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PnvPanel.Application", "backend\src\PnvPanel.Application\PnvPanel.Application.csproj", "{25F9AF36-7508-0DC8-2469-D065D078DBF3}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PnvPanel.Domain", "backend\src\PnvPanel.Domain\PnvPanel.Domain.csproj", "{7511C433-2BCA-BF01-BEF4-DBC6A5DB8CF3}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PnvPanel.Infrastructure", "backend\src\PnvPanel.Infrastructure\PnvPanel.Infrastructure.csproj", "{470155DC-9172-CAAF-7AA4-D642ECCFD2D2}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{F57642F3-C37C-D174-720E-6A6AAD5BEE22}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PnvPanel.Application.Tests", "backend\tests\PnvPanel.Application.Tests\PnvPanel.Application.Tests.csproj", "{4A683703-6702-96CD-5AB6-56199C1C1C7E}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PnvPanel.Domain.Tests", "backend\tests\PnvPanel.Domain.Tests\PnvPanel.Domain.Tests.csproj", "{F3793E3C-AC9E-D8DA-704A-E67DC4FA790F}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PnvPanel.IntegrationTests", "backend\tests\PnvPanel.IntegrationTests\PnvPanel.IntegrationTests.csproj", "{8A44D601-6F27-4D86-63F7-25C42FF67414}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{3B6A930E-4799-6F42-1E94-163F6773FBBC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{3B6A930E-4799-6F42-1E94-163F6773FBBC}.Debug|Any CPU.Build.0 = Debug|Any CPU
{3B6A930E-4799-6F42-1E94-163F6773FBBC}.Release|Any CPU.ActiveCfg = Release|Any CPU
{3B6A930E-4799-6F42-1E94-163F6773FBBC}.Release|Any CPU.Build.0 = Release|Any CPU
{25F9AF36-7508-0DC8-2469-D065D078DBF3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{25F9AF36-7508-0DC8-2469-D065D078DBF3}.Debug|Any CPU.Build.0 = Debug|Any CPU
{25F9AF36-7508-0DC8-2469-D065D078DBF3}.Release|Any CPU.ActiveCfg = Release|Any CPU
{25F9AF36-7508-0DC8-2469-D065D078DBF3}.Release|Any CPU.Build.0 = Release|Any CPU
{7511C433-2BCA-BF01-BEF4-DBC6A5DB8CF3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{7511C433-2BCA-BF01-BEF4-DBC6A5DB8CF3}.Debug|Any CPU.Build.0 = Debug|Any CPU
{7511C433-2BCA-BF01-BEF4-DBC6A5DB8CF3}.Release|Any CPU.ActiveCfg = Release|Any CPU
{7511C433-2BCA-BF01-BEF4-DBC6A5DB8CF3}.Release|Any CPU.Build.0 = Release|Any CPU
{470155DC-9172-CAAF-7AA4-D642ECCFD2D2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{470155DC-9172-CAAF-7AA4-D642ECCFD2D2}.Debug|Any CPU.Build.0 = Debug|Any CPU
{470155DC-9172-CAAF-7AA4-D642ECCFD2D2}.Release|Any CPU.ActiveCfg = Release|Any CPU
{470155DC-9172-CAAF-7AA4-D642ECCFD2D2}.Release|Any CPU.Build.0 = Release|Any CPU
{4A683703-6702-96CD-5AB6-56199C1C1C7E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{4A683703-6702-96CD-5AB6-56199C1C1C7E}.Debug|Any CPU.Build.0 = Debug|Any CPU
{4A683703-6702-96CD-5AB6-56199C1C1C7E}.Release|Any CPU.ActiveCfg = Release|Any CPU
{4A683703-6702-96CD-5AB6-56199C1C1C7E}.Release|Any CPU.Build.0 = Release|Any CPU
{F3793E3C-AC9E-D8DA-704A-E67DC4FA790F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{F3793E3C-AC9E-D8DA-704A-E67DC4FA790F}.Debug|Any CPU.Build.0 = Debug|Any CPU
{F3793E3C-AC9E-D8DA-704A-E67DC4FA790F}.Release|Any CPU.ActiveCfg = Release|Any CPU
{F3793E3C-AC9E-D8DA-704A-E67DC4FA790F}.Release|Any CPU.Build.0 = Release|Any CPU
{8A44D601-6F27-4D86-63F7-25C42FF67414}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{8A44D601-6F27-4D86-63F7-25C42FF67414}.Debug|Any CPU.Build.0 = Debug|Any CPU
{8A44D601-6F27-4D86-63F7-25C42FF67414}.Release|Any CPU.ActiveCfg = Release|Any CPU
{8A44D601-6F27-4D86-63F7-25C42FF67414}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{0F9113EE-888A-26D2-68B0-4A7D0A2A8745} = {1AE8ACA6-933B-BF2A-3671-3E2EAC007D16}
{3B6A930E-4799-6F42-1E94-163F6773FBBC} = {0F9113EE-888A-26D2-68B0-4A7D0A2A8745}
{25F9AF36-7508-0DC8-2469-D065D078DBF3} = {0F9113EE-888A-26D2-68B0-4A7D0A2A8745}
{7511C433-2BCA-BF01-BEF4-DBC6A5DB8CF3} = {0F9113EE-888A-26D2-68B0-4A7D0A2A8745}
{470155DC-9172-CAAF-7AA4-D642ECCFD2D2} = {0F9113EE-888A-26D2-68B0-4A7D0A2A8745}
{F57642F3-C37C-D174-720E-6A6AAD5BEE22} = {1AE8ACA6-933B-BF2A-3671-3E2EAC007D16}
{4A683703-6702-96CD-5AB6-56199C1C1C7E} = {F57642F3-C37C-D174-720E-6A6AAD5BEE22}
{F3793E3C-AC9E-D8DA-704A-E67DC4FA790F} = {F57642F3-C37C-D174-720E-6A6AAD5BEE22}
{8A44D601-6F27-4D86-63F7-25C42FF67414} = {F57642F3-C37C-D174-720E-6A6AAD5BEE22}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {B0DAEC4D-A6CA-40D5-94C1-F0ED1F9EBB44}
EndGlobalSection
EndGlobal
@@ -38,13 +38,13 @@ public static class AuthEndpoints
return result.ToHttpResult();
}
private static async Task<IResult> Login(LoginCommand command, ISender sender, HttpResponse response, CancellationToken cancellationToken)
private static async Task<IResult> Login(LoginCommand command, ISender sender, HttpRequest request, HttpResponse response, CancellationToken cancellationToken)
{
var result = await sender.Send(command, cancellationToken);
if (!result.IsSuccess)
return result.ToHttpResult();
SetRefreshCookie(response, result.Value.RefreshToken, result.Value.RefreshTokenExpiresAt);
SetRefreshCookie(request, response, result.Value.RefreshToken, result.Value.RefreshTokenExpiresAt);
return Results.Ok(ToLoginResponse(result.Value));
}
@@ -56,11 +56,11 @@ public static class AuthEndpoints
var result = await sender.Send(new RefreshCommand(rawToken), cancellationToken);
if (!result.IsSuccess)
{
response.Cookies.Delete(RefreshCookieName, BuildCookieOptions());
response.Cookies.Delete(RefreshCookieName, BuildCookieOptions(request));
return result.ToHttpResult();
}
SetRefreshCookie(response, result.Value.RefreshToken, result.Value.RefreshTokenExpiresAt);
SetRefreshCookie(request, response, result.Value.RefreshToken, result.Value.RefreshTokenExpiresAt);
return Results.Ok(ToLoginResponse(result.Value));
}
@@ -69,7 +69,7 @@ public static class AuthEndpoints
if (request.Cookies.TryGetValue(RefreshCookieName, out var rawToken) && !string.IsNullOrEmpty(rawToken))
await sender.Send(new LogoutCommand(rawToken), cancellationToken);
response.Cookies.Delete(RefreshCookieName, BuildCookieOptions());
response.Cookies.Delete(RefreshCookieName, BuildCookieOptions(request));
return Results.NoContent();
}
@@ -85,10 +85,10 @@ public static class AuthEndpoints
return result.ToHttpResult();
}
private static async Task<IResult> DeleteMe(HttpResponse response, ISender sender, CancellationToken cancellationToken)
private static async Task<IResult> DeleteMe(HttpRequest request, HttpResponse response, ISender sender, CancellationToken cancellationToken)
{
var result = await sender.Send(new DeleteMyAccountCommand(), cancellationToken);
response.Cookies.Delete(RefreshCookieName, BuildCookieOptions());
response.Cookies.Delete(RefreshCookieName, BuildCookieOptions(request));
return result.ToHttpResult();
}
@@ -99,17 +99,19 @@ public static class AuthEndpoints
user = auth.User,
};
private static void SetRefreshCookie(HttpResponse response, string rawToken, DateTimeOffset expiresAt)
private static void SetRefreshCookie(HttpRequest request, HttpResponse response, string rawToken, DateTimeOffset expiresAt)
{
var options = BuildCookieOptions();
var options = BuildCookieOptions(request);
options.Expires = expiresAt;
response.Cookies.Append(RefreshCookieName, rawToken, options);
}
private static CookieOptions BuildCookieOptions() => new()
// Secure = IsHttps запроса (учитывает ForwardedHeaders за внешним TLS-прокси, см. CLAUDE.md) —
// иначе браузер/HttpClient не пришлёт cookie обратно на plain-http (локальный dev, TestServer).
private static CookieOptions BuildCookieOptions(HttpRequest request) => new()
{
HttpOnly = true,
Secure = true,
Secure = request.IsHttps,
SameSite = SameSiteMode.Strict,
Path = "/api/auth",
};
@@ -4,6 +4,7 @@ using PnvPanel.Application.Configs.Create;
using PnvPanel.Application.Configs.Edit;
using PnvPanel.Application.Configs.GetConfigLink;
using PnvPanel.Application.Configs.GetMyConfigs;
using PnvPanel.Application.Configs.GetMySubscription;
using PnvPanel.Application.Configs.ListAvailableInbounds;
using PnvPanel.Application.Configs.Revoke;
using PnvPanel.Application.Configs.Rotate;
@@ -23,6 +24,7 @@ public static class ConfigEndpoints
group.MapPost("/configs/{id:guid}/rotate", RotateConfig);
group.MapDelete("/configs/{id:guid}", RevokeConfig);
group.MapGet("/configs/{id:guid}/link", GetConfigLink);
group.MapGet("/subscription", GetMySubscription);
return app;
}
@@ -74,6 +76,16 @@ public static class ConfigEndpoints
var subscriptionUrl = $"{request.Scheme}://{request.Host}/sub/{result.Value.SubscriptionToken}";
return Results.Ok(new { connectionString = result.Value.ConnectionString, subscriptionUrl });
}
private static async Task<IResult> GetMySubscription(HttpRequest request, ISender sender, CancellationToken cancellationToken)
{
var result = await sender.Send(new GetMySubscriptionQuery(), cancellationToken);
if (!result.IsSuccess)
return result.ToHttpResult();
var subscriptionUrl = $"{request.Scheme}://{request.Host}/sub/{result.Value.SubscriptionToken}";
return Results.Ok(new { subscriptionUrl });
}
}
public sealed record CreateConfigBody(Guid InboundId, string? Label, int? DeviceLimit);
+17 -3
View File
@@ -1,3 +1,4 @@
using System.Text.Json.Serialization;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.Extensions.Options;
@@ -40,11 +41,17 @@ builder.Services.AddSignalR();
builder.Services.AddSingleton<IRealtimeNotifier, SignalRRealtimeNotifier>();
// Telegram-бот: presentation-адаптер, хостится в процессе Api (long polling). Клиент регистрируем
// всегда (даже с пустым токеном) — TelegramBotHostedService сам решает не стартовать без токена.
// всегда (даже с пустым токеном) — TelegramBotHostedService сам решает не стартовать без токена,
// а TelegramNotifier — не слать сообщения. TelegramBotClient(...) при этом валидирует формат токена
// и падает на пустой строке, поэтому при пустом BotToken подставляем синтаксически валидную заглушку —
// реальный HTTP-вызов через неё никогда не происходит (все вызывающие места сами проверяют BotToken).
builder.Services.AddSingleton<ITelegramBotClient>(sp =>
{
var options = sp.GetRequiredService<IOptions<TelegramOptions>>();
return new TelegramBotClient(options.Value.BotToken ?? string.Empty);
var token = options.Value.BotToken;
return new TelegramBotClient(string.IsNullOrWhiteSpace(token)
? "0:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
: token);
});
// Scoped — зависит от IIdentityService (scoped), не Singleton.
builder.Services.AddScoped<ITelegramNotifier, TelegramNotifier>();
@@ -55,13 +62,20 @@ builder.Services.AddRateLimiter(options =>
{
options.AddFixedWindowLimiter(RateLimiting.AuthPolicy, limiterOptions =>
{
limiterOptions.PermitLimit = 20;
// Настраиваемо через конфиг, чтобы интеграционные тесты (общий TestServer/host на весь
// collection, все запросы — от одного "клиента") могли поднять лимит и не ловить 429.
limiterOptions.PermitLimit = builder.Configuration.GetValue("RateLimiting:AuthPermitLimit", 20);
limiterOptions.Window = TimeSpan.FromMinutes(1);
limiterOptions.QueueLimit = 0;
});
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
});
// Энумы сериализуются строками ("Vless", "Active", ...), не числами — самодокументируемый JSON,
// корректные строковые литералы при генерации TS-типов из OpenAPI-схемы (см. docs/frontend.md).
builder.Services.ConfigureHttpJsonOptions(options =>
options.SerializerOptions.Converters.Add(new JsonStringEnumConverter()));
builder.Services.AddProblemDetails();
builder.Services.AddOpenApi();
builder.Services.AddHealthChecks()
@@ -5,6 +5,7 @@ using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
using PnvPanel.Domain.Activation;
using PnvPanel.Domain.Audit;
namespace PnvPanel.Application.Admin.Activation;
@@ -33,6 +34,9 @@ public sealed class ApproveActivationCommandHandler(
if (!activateResult.IsSuccess)
return activateResult;
dbContext.AuditLogs.Add(AuditLog.Create(
adminId, "ActivationApproved", "User", request.UserId.ToString(), metadata: null, AuditSource.Web));
await notifier.NotifyUserActivatedAsync(request.UserId, cancellationToken);
await telegramNotifier.NotifyUserAsync(request.UserId, "✅ Ваш аккаунт активирован администратором.", cancellationToken);
return Result.Success();
@@ -5,6 +5,7 @@ using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
using PnvPanel.Domain.Activation;
using PnvPanel.Domain.Audit;
namespace PnvPanel.Application.Admin.Activation;
@@ -26,6 +27,10 @@ public sealed class RejectActivationCommandHandler(IAppDbContext dbContext, ICur
return Result.Failure(ActivationErrors.AlreadyDecided);
request.Reject(adminId, command.Reason);
dbContext.AuditLogs.Add(AuditLog.Create(
adminId, "ActivationRejected", "User", request.UserId.ToString(), metadata: null, AuditSource.Web));
return Result.Success();
}
}
@@ -2,10 +2,11 @@ using Microsoft.EntityFrameworkCore;
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
using PnvPanel.Domain.Audit;
namespace PnvPanel.Application.Admin.Inbounds;
public sealed class PublishInboundCommandHandler(IAppDbContext dbContext)
public sealed class PublishInboundCommandHandler(IAppDbContext dbContext, ICurrentUser currentUser)
: ICommandHandler<PublishInboundCommand, Result<InboundDto>>
{
public async Task<Result<InboundDto>> Handle(PublishInboundCommand command, CancellationToken cancellationToken)
@@ -19,6 +20,10 @@ public sealed class PublishInboundCommandHandler(IAppDbContext dbContext)
else
inbound.Unpublish();
dbContext.AuditLogs.Add(AuditLog.Create(
currentUser.UserId, command.IsPublished ? "InboundPublished" : "InboundUnpublished",
"Inbound", inbound.Id.ToString(), metadata: null, AuditSource.Web));
return Result.Success(InboundDto.FromDomain(inbound));
}
}
@@ -2,10 +2,11 @@ using Microsoft.EntityFrameworkCore;
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
using PnvPanel.Domain.Audit;
namespace PnvPanel.Application.Admin.Nodes;
public sealed class DeleteNodeCommandHandler(IAppDbContext dbContext, IXuiPanelGateway gateway)
public sealed class DeleteNodeCommandHandler(IAppDbContext dbContext, IXuiPanelGateway gateway, ICurrentUser currentUser)
: ICommandHandler<DeleteNodeCommand, Result>
{
public async Task<Result> Handle(DeleteNodeCommand command, CancellationToken cancellationToken)
@@ -19,6 +20,9 @@ public sealed class DeleteNodeCommandHandler(IAppDbContext dbContext, IXuiPanelG
dbContext.Nodes.Remove(node);
gateway.InvalidateClient(node.Id);
dbContext.AuditLogs.Add(AuditLog.Create(
currentUser.UserId, "NodeDeleted", "Node", node.Id.ToString(), metadata: null, AuditSource.Web));
return Result.Success();
}
}
@@ -1,11 +1,13 @@
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
using PnvPanel.Domain.Audit;
using PnvPanel.Domain.Nodes;
namespace PnvPanel.Application.Admin.Nodes;
public sealed class RegisterNodeCommandHandler(IAppDbContext dbContext, IXuiPanelGateway gateway, ISecretProtector secretProtector)
public sealed class RegisterNodeCommandHandler(
IAppDbContext dbContext, IXuiPanelGateway gateway, ISecretProtector secretProtector, ICurrentUser currentUser)
: ICommandHandler<RegisterNodeCommand, Result<NodeDto>>
{
public Task<Result<NodeDto>> Handle(RegisterNodeCommand command, CancellationToken cancellationToken)
@@ -21,6 +23,8 @@ public sealed class RegisterNodeCommandHandler(IAppDbContext dbContext, IXuiPane
var node = Node.Register(command.Name, baseAddress, credentials, command.Location);
dbContext.Nodes.Add(node);
dbContext.AuditLogs.Add(AuditLog.Create(
currentUser.UserId, "NodeRegistered", "Node", node.Id.ToString(), metadata: null, AuditSource.Web));
return Task.FromResult(Result.Success(NodeDto.FromDomain(node)));
}
@@ -2,11 +2,13 @@ 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.Nodes;
namespace PnvPanel.Application.Admin.Nodes;
public sealed class UpdateNodeCommandHandler(IAppDbContext dbContext, IXuiPanelGateway gateway, ISecretProtector secretProtector)
public sealed class UpdateNodeCommandHandler(
IAppDbContext dbContext, IXuiPanelGateway gateway, ISecretProtector secretProtector, ICurrentUser currentUser)
: ICommandHandler<UpdateNodeCommand, Result<NodeDto>>
{
public async Task<Result<NodeDto>> Handle(UpdateNodeCommand command, CancellationToken cancellationToken)
@@ -28,6 +30,9 @@ public sealed class UpdateNodeCommandHandler(IAppDbContext dbContext, IXuiPanelG
gateway.InvalidateClient(node.Id);
}
dbContext.AuditLogs.Add(AuditLog.Create(
currentUser.UserId, "NodeUpdated", "Node", node.Id.ToString(), metadata: null, AuditSource.Web));
return Result.Success(NodeDto.FromDomain(node));
}
}
@@ -1,11 +1,23 @@
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
using PnvPanel.Domain.Audit;
namespace PnvPanel.Application.Admin.Users;
public sealed class ChangeUserRoleCommandHandler(IRoleService roleService) : ICommandHandler<ChangeUserRoleCommand, Result>
public sealed class ChangeUserRoleCommandHandler(IRoleService roleService, IAppDbContext dbContext, ICurrentUser currentUser)
: ICommandHandler<ChangeUserRoleCommand, Result>
{
public Task<Result> Handle(ChangeUserRoleCommand command, CancellationToken cancellationToken)
=> roleService.ChangeUserRoleAsync(command.UserId, command.RoleId, cancellationToken);
public async Task<Result> Handle(ChangeUserRoleCommand command, CancellationToken cancellationToken)
{
var result = await roleService.ChangeUserRoleAsync(command.UserId, command.RoleId, cancellationToken);
if (!result.IsSuccess)
return result;
dbContext.AuditLogs.Add(AuditLog.Create(
currentUser.UserId, "UserRoleChanged", "User", command.UserId.ToString(),
metadata: $"{{\"roleId\":\"{command.RoleId}\"}}", AuditSource.Web));
return result;
}
}
@@ -4,7 +4,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);
public sealed record CurrentUserProfile(
Guid Id, string UserName, Guid RoleId, string Role, bool IsActivated, bool IsBlocked, int MaxConfigs, string SubscriptionToken);
public sealed record UserSummaryDto(Guid Id, string UserName, string Role, bool IsActivated, bool IsBlocked, DateTimeOffset? ActivatedAt);
@@ -0,0 +1,9 @@
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Configs.GetMySubscription;
public sealed record GetMySubscriptionQuery : IQuery<Result<MySubscriptionDto>>;
/// <summary>SubscriptionToken — Api-слой строит из него абсолютный URL (знает scheme/host запроса), см. GetConfigLinkQuery.</summary>
public sealed record MySubscriptionDto(string SubscriptionToken);
@@ -0,0 +1,22 @@
using PnvPanel.Application.Auth;
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Configs.GetMySubscription;
public sealed class GetMySubscriptionQueryHandler(IIdentityService identityService, ICurrentUser currentUser)
: IQueryHandler<GetMySubscriptionQuery, Result<MySubscriptionDto>>
{
public async Task<Result<MySubscriptionDto>> Handle(GetMySubscriptionQuery query, CancellationToken cancellationToken)
{
if (currentUser.UserId is not { } userId)
return Result.Failure<MySubscriptionDto>(AuthErrors.Unauthorized);
var profile = await identityService.GetProfileAsync(userId, cancellationToken);
if (profile is null)
return Result.Failure<MySubscriptionDto>(AuthErrors.Unauthorized);
return Result.Success(new MySubscriptionDto(profile.SubscriptionToken));
}
}
@@ -26,10 +26,12 @@ public static class DependencyInjection
{
public static IServiceCollection AddInfrastructure(this IServiceCollection services, IConfiguration configuration)
{
var connectionString = configuration["ConnectionStrings:Default"]
?? throw new InvalidOperationException("Строка подключения 'ConnectionStrings:Default' не сконфигурирована.");
services.AddDbContext<AppDbContext>(options => options.UseNpgsql(connectionString));
// Строка подключения читается лениво внутри лямбды (а не в локальную переменную сразу), иначе
// в тестах WebApplicationFactory.ConfigureAppConfiguration (Testcontainers-порт) не успевает
// примениться до регистрации DbContext — окажется закэширован дефолт из appsettings.json.
services.AddDbContext<AppDbContext>(options => options.UseNpgsql(
configuration["ConnectionStrings:Default"]
?? throw new InvalidOperationException("Строка подключения 'ConnectionStrings:Default' не сконфигурирована.")));
services.AddScoped<IAppDbContext>(sp => sp.GetRequiredService<AppDbContext>());
services
@@ -59,7 +59,8 @@ internal sealed class IdentityService(UserManager<AppUser> userManager, SignInMa
return null;
var role = await GetPrimaryRoleAsync(user);
return new CurrentUserProfile(user.Id, user.UserName!, role.Id, role.Name!, user.IsActivated, user.IsBlocked, role.MaxConfigs);
return new CurrentUserProfile(
user.Id, user.UserName!, role.Id, role.Name!, user.IsActivated, user.IsBlocked, role.MaxConfigs, user.SubscriptionToken);
}
public async Task<Result> ChangePasswordAsync(Guid userId, string currentPassword, string newPassword, CancellationToken cancellationToken)
@@ -1,4 +1,6 @@
using NSubstitute;
using PnvPanel.Application.Admin.Inbounds;
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Tests.TestSupport;
using PnvPanel.Domain.Inbounds;
using Xunit;
@@ -7,6 +9,8 @@ namespace PnvPanel.Application.Tests.Admin.Inbounds;
public class PublishInboundCommandHandlerTests
{
private readonly ICurrentUser _currentUser = Substitute.For<ICurrentUser>();
[Fact]
public async Task Handle_WhenPublishingExistingInbound_UpdatesPublishState()
{
@@ -16,7 +20,7 @@ public class PublishInboundCommandHandlerTests
await dbContext.SaveChangesAsync(CancellationToken.None);
var roleId = Guid.NewGuid();
var handler = new PublishInboundCommandHandler(dbContext);
var handler = new PublishInboundCommandHandler(dbContext, _currentUser);
var command = new PublishInboundCommand(inbound.Id, true, "EU Fast", [roleId], 100);
@@ -39,7 +43,7 @@ public class PublishInboundCommandHandlerTests
dbContext.Inbounds.Add(inbound);
await dbContext.SaveChangesAsync(CancellationToken.None);
var handler = new PublishInboundCommandHandler(dbContext);
var handler = new PublishInboundCommandHandler(dbContext, _currentUser);
var command = new PublishInboundCommand(inbound.Id, false, null, [], null);
@@ -54,7 +58,7 @@ public class PublishInboundCommandHandlerTests
{
using var dbContext = InMemoryDbContextFactory.Create();
var handler = new PublishInboundCommandHandler(dbContext);
var handler = new PublishInboundCommandHandler(dbContext, _currentUser);
var command = new PublishInboundCommand(Guid.NewGuid(), true, "EU Fast", [], null);
@@ -11,6 +11,7 @@ public class RegisterNodeCommandHandlerTests
{
private readonly IXuiPanelGateway _gateway = Substitute.For<IXuiPanelGateway>();
private readonly ISecretProtector _secretProtector = Substitute.For<ISecretProtector>();
private readonly ICurrentUser _currentUser = Substitute.For<ICurrentUser>();
[Fact]
public async Task Handle_WithValidAddress_RegistersNodeWithProtectedPassword()
@@ -20,7 +21,7 @@ public class RegisterNodeCommandHandlerTests
_gateway.ValidateBaseAddress(Arg.Any<Uri>()).Returns(Result.Success());
_secretProtector.Protect("secret-password").Returns("protected-secret-password");
var handler = new RegisterNodeCommandHandler(dbContext, _gateway, _secretProtector);
var handler = new RegisterNodeCommandHandler(dbContext, _gateway, _secretProtector, _currentUser);
var command = new RegisterNodeCommand("node-1", "https://node1.example.com", "admin", "secret-password", "eu-west");
@@ -38,7 +39,7 @@ public class RegisterNodeCommandHandlerTests
{
using var dbContext = InMemoryDbContextFactory.Create();
var handler = new RegisterNodeCommandHandler(dbContext, _gateway, _secretProtector);
var handler = new RegisterNodeCommandHandler(dbContext, _gateway, _secretProtector, _currentUser);
var command = new RegisterNodeCommand("node-1", "not-a-uri", "admin", "secret-password", null);
@@ -58,7 +59,7 @@ public class RegisterNodeCommandHandlerTests
var error = Error.Validation("Nodes.SchemeNotAllowed", "Разрешён только HTTPS.");
_gateway.ValidateBaseAddress(Arg.Any<Uri>()).Returns(Result.Failure(error));
var handler = new RegisterNodeCommandHandler(dbContext, _gateway, _secretProtector);
var handler = new RegisterNodeCommandHandler(dbContext, _gateway, _secretProtector, _currentUser);
var command = new RegisterNodeCommand("node-1", "http://node1.example.com", "admin", "secret-password", null);
@@ -2,6 +2,7 @@ using NSubstitute;
using PnvPanel.Application.Admin.Users;
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Models;
using PnvPanel.Application.Tests.TestSupport;
using Xunit;
namespace PnvPanel.Application.Tests.Admin.Users;
@@ -9,35 +10,42 @@ namespace PnvPanel.Application.Tests.Admin.Users;
public class ChangeUserRoleCommandHandlerTests
{
private readonly IRoleService _roleService = Substitute.For<IRoleService>();
private readonly ICurrentUser _currentUser = Substitute.For<ICurrentUser>();
[Fact]
public async Task Handle_DelegatesToRoleServiceAndReturnsSuccess()
{
using var dbContext = InMemoryDbContextFactory.Create();
var userId = Guid.NewGuid();
var roleId = Guid.NewGuid();
_roleService.ChangeUserRoleAsync(userId, roleId, Arg.Any<CancellationToken>()).Returns(Result.Success());
var handler = new ChangeUserRoleCommandHandler(_roleService);
var handler = new ChangeUserRoleCommandHandler(_roleService, dbContext, _currentUser);
var result = await handler.Handle(new ChangeUserRoleCommand(userId, roleId), CancellationToken.None);
Assert.True(result.IsSuccess);
await _roleService.Received(1).ChangeUserRoleAsync(userId, roleId, Arg.Any<CancellationToken>());
Assert.Single(dbContext.AuditLogs.Local);
}
[Fact]
public async Task Handle_WhenRoleServiceFails_PropagatesFailure()
{
using var dbContext = InMemoryDbContextFactory.Create();
var userId = Guid.NewGuid();
var roleId = Guid.NewGuid();
var error = UserErrors.NotFound;
_roleService.ChangeUserRoleAsync(userId, roleId, Arg.Any<CancellationToken>()).Returns(Result.Failure(error));
var handler = new ChangeUserRoleCommandHandler(_roleService);
var handler = new ChangeUserRoleCommandHandler(_roleService, dbContext, _currentUser);
var result = await handler.Handle(new ChangeUserRoleCommand(userId, roleId), CancellationToken.None);
Assert.False(result.IsSuccess);
Assert.Equal(error, result.Error);
Assert.Empty(dbContext.AuditLogs.Local);
}
}
@@ -40,7 +40,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);
var profile = new CurrentUserProfile(userId, "alice", Guid.NewGuid(), "user", true, false, 3, "sub-token");
_identityService.GetProfileAsync(userId, Arg.Any<CancellationToken>()).Returns(profile);
_identityService.GetTelegramLinkInfoAsync(userId, Arg.Any<CancellationToken>())
.Returns(new TelegramLinkInfo(true, 42, "alice_tg"));
@@ -20,7 +20,7 @@ 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);
var profile = new CurrentUserProfile(userId, "alice", Guid.NewGuid(), "user", IsActivated: true, IsBlocked: false, MaxConfigs: 3, SubscriptionToken: "sub-token");
_identityService.ValidateCredentialsAsync("alice", "P@ssw0rd", Arg.Any<CancellationToken>())
.Returns(Result.Success(authUser));
@@ -19,7 +19,7 @@ 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);
var profile = new CurrentUserProfile(userId, "alice", Guid.NewGuid(), "user", IsActivated: true, IsBlocked: false, MaxConfigs: 3, SubscriptionToken: "sub-token");
var rotated = new RotatedRefreshToken(userId, "new-refresh-token", DateTimeOffset.UtcNow.AddDays(30));
_refreshTokenService.RotateAsync("old-token", Arg.Any<CancellationToken>()).Returns(Result.Success(rotated));
@@ -32,7 +32,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);
var profile = new CurrentUserProfile(userId, "alice", Guid.NewGuid(), "user", true, false, 5, "sub-token");
_identityService.GetProfileAsync(userId, Arg.Any<CancellationToken>()).Returns(profile);
var handler = new GetMyConfigsQueryHandler(dbContext, _identityService, FakeCurrentUser.Authenticated(userId));
@@ -75,7 +75,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);
var profile = new CurrentUserProfile(userId, "alice", Guid.NewGuid(), "user", true, false, 3, "sub-token");
_identityService.GetProfileAsync(userId, Arg.Any<CancellationToken>()).Returns(profile);
_jwtTokenService.GenerateAccessToken(Arg.Any<AuthenticatedUser>())
.Returns(("access-token", DateTimeOffset.UtcNow.AddMinutes(15)));
@@ -8,13 +8,13 @@ namespace PnvPanel.IntegrationTests.Admin;
[Collection(IntegrationTestCollection.Name)]
public class NodeInboundCrudTests(PnvPanelWebApplicationFactory factory)
{
private sealed record NodeResponse(Guid Id, string Name, string BaseAddress, string Username, string? Location, int Status, bool IsEnabled);
private sealed record NodeResponse(Guid Id, string Name, string BaseAddress, string Username, string? Location, string Status, bool IsEnabled);
private sealed record InboundResponse(
Guid Id, Guid NodeId, string RemoteInboundId, int Protocol, string Remark, int Port,
Guid Id, Guid NodeId, string RemoteInboundId, string Protocol, string Remark, int Port,
bool IsPublished, string? DisplayName, int? MaxClients, IReadOnlyList<Guid> AllowedRoleIds);
private sealed record SyncNodeResponse(int InboundsSynced, int Status);
private sealed record SyncNodeResponse(int InboundsSynced, string Status);
[Fact]
public async Task RegisterSyncListPublish_FullNodeInboundLifecycle_Succeeds()
@@ -77,7 +77,7 @@ public class AuthFlowTests(PnvPanelWebApplicationFactory factory)
var first = await client.PostJsonAsync("/api/auth/register", new { userName, password = "P@ssw0rd123" });
Assert.Equal(HttpStatusCode.OK, first.StatusCode);
var second = await client.PostJsonAsync("/api/auth/register", new { userName, password = "AnotherPass123" });
var second = await client.PostJsonAsync("/api/auth/register", new { userName, password = "AnotherPass123!" });
Assert.Equal(HttpStatusCode.Conflict, second.StatusCode);
}
@@ -15,12 +15,14 @@ public class ConfigQuotaTests(PnvPanelWebApplicationFactory factory)
private sealed record NodeResponse(Guid Id, string Name);
private sealed record SyncNodeResponse(int InboundsSynced, int Status);
private sealed record SyncNodeResponse(int InboundsSynced, string Status);
private sealed record InboundResponse(Guid Id, Guid NodeId, string RemoteInboundId, int Protocol, string Remark, int Port, bool IsPublished);
private sealed record InboundResponse(Guid Id, Guid NodeId, string RemoteInboundId, string Protocol, string Remark, int Port, bool IsPublished);
private sealed record ActivationRequestResponse(Guid Id, string? Comment, DateTimeOffset CreatedAt);
private sealed record MyConfigsResponse(List<object> Configs, int MaxConfigs);
/// <summary>
/// Доказывает, что pg_advisory_xact_lock в CreateVpnConfigCommandHandler реально защищает
/// от гонки: при параллельных запросах ровно Quota проходят, остальные — 409 QuotaExceeded.
@@ -98,7 +100,7 @@ public class ConfigQuotaTests(PnvPanelWebApplicationFactory factory)
Assert.Equal(ConcurrentAttempts - Quota, quotaExceeded);
var myConfigsResponse = await userClient.GetAsync("/api/configs");
var myConfigs = await myConfigsResponse.ReadAsAsync<List<object>>();
Assert.Equal(Quota, myConfigs!.Count);
var myConfigs = await myConfigsResponse.ReadAsAsync<MyConfigsResponse>();
Assert.Equal(Quota, myConfigs!.Configs.Count);
}
}
@@ -27,7 +27,10 @@ public sealed class PnvPanelWebApplicationFactory : WebApplicationFactory<Progra
.WithPassword("pnvpanel")
.Build();
public async Task InitializeAsync() => await _postgres.StartAsync();
public async Task InitializeAsync()
{
await _postgres.StartAsync();
}
async Task IAsyncLifetime.DisposeAsync()
{
@@ -48,6 +51,9 @@ public sealed class PnvPanelWebApplicationFactory : WebApplicationFactory<Progra
["AdminSeed:Password"] = AdminPassword,
// Пусто — TelegramBotHostedService при пустом токене не стартует (см. Api/Telegram/TelegramBotHostedService.cs).
["Telegram:BotToken"] = "",
// Весь collection делит один TestServer/host — все запросы идут от одного "клиента",
// дефолтный лимит 20/мин быстро исчерпывается. Поднимаем для тестового окружения.
["RateLimiting:AuthPermitLimit"] = "10000",
});
});
+27 -2
View File
@@ -8,21 +8,46 @@
"build": "tsc -b && vite build",
"lint": "oxlint",
"typecheck": "tsc -b",
"preview": "vite preview"
"preview": "vite preview",
"gen:api": "openapi-typescript http://localhost:8080/openapi/v1.json -o src/shared/api/schema.gen.ts"
},
"dependencies": {
"@hookform/resolvers": "^5.4.0",
"@microsoft/signalr": "^10.0.0",
"@radix-ui/react-checkbox": "^1.3.6",
"@radix-ui/react-dialog": "^1.1.18",
"@radix-ui/react-dropdown-menu": "^2.1.19",
"@radix-ui/react-label": "^2.1.11",
"@radix-ui/react-select": "^2.3.2",
"@radix-ui/react-slot": "^1.3.0",
"@radix-ui/react-switch": "^1.3.2",
"@radix-ui/react-tabs": "^1.1.16",
"@tanstack/react-query": "^5.101.2",
"@tanstack/react-router": "^1.170.16",
"@tanstack/react-table": "^8.21.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"i18next": "^26.3.4",
"lucide-react": "^1.22.0",
"qrcode.react": "^4.2.0",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react-i18next": "^17.0.8"
"react-hook-form": "^7.80.0",
"react-i18next": "^17.0.8",
"recharts": "^3.9.1",
"tailwind-merge": "^3.6.0",
"zod": "^4.4.3",
"zustand": "^5.0.14"
},
"devDependencies": {
"@tailwindcss/vite": "^4.3.2",
"@tanstack/react-router-devtools": "^1.167.0",
"@tanstack/router-plugin": "^1.168.18",
"@types/node": "^24.13.2",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.3",
"openapi-typescript": "^7.13.0",
"oxlint": "^1.71.0",
"tailwindcss": "^4.3.2",
"typescript": "~6.0.2",
+2260 -2
View File
File diff suppressed because it is too large Load Diff
-68
View File
@@ -1,68 +0,0 @@
import { useTranslation } from 'react-i18next'
import { useTheme, type Theme } from './lib/theme'
import { setLanguage } from './lib/i18n'
function App() {
const { t, i18n } = useTranslation()
const { theme, setTheme } = useTheme()
const themes: Theme[] = ['light', 'dark', 'system']
const langs = ['ru', 'en']
return (
<div className="mx-auto flex min-h-svh max-w-2xl flex-col justify-center gap-8 px-6 py-16">
<header className="flex items-center justify-between">
<span className="text-lg font-semibold text-primary">{t('appName')}</span>
<div className="flex items-center gap-4 text-sm">
<label className="flex items-center gap-2">
<span className="text-muted-foreground">{t('language')}</span>
<select
className="rounded-md border border-border bg-muted px-2 py-1"
value={i18n.language}
onChange={(e) => setLanguage(e.target.value)}
>
{langs.map((l) => (
<option key={l} value={l}>
{l.toUpperCase()}
</option>
))}
</select>
</label>
<label className="flex items-center gap-2">
<span className="text-muted-foreground">{t('theme')}</span>
<select
className="rounded-md border border-border bg-muted px-2 py-1"
value={theme}
onChange={(e) => setTheme(e.target.value as Theme)}
>
{themes.map((th) => (
<option key={th} value={th}>
{t(th)}
</option>
))}
</select>
</label>
</div>
</header>
<main className="flex flex-col gap-4">
<h1 className="text-4xl font-semibold tracking-tight">{t('appName')}</h1>
<p className="text-lg text-muted-foreground">{t('tagline')}</p>
<p className="text-sm text-muted-foreground">{t('scaffoldNote')}</p>
<div className="flex gap-3 text-sm">
<a
className="rounded-md bg-primary px-4 py-2 font-medium text-primary-foreground"
href="/scalar"
>
API (Scalar)
</a>
<a className="rounded-md border border-border px-4 py-2 font-medium" href="/health">
Health
</a>
</div>
</main>
</div>
)
}
export default App
@@ -0,0 +1,83 @@
import { useState, type ReactNode } from 'react'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { toast } from '@/shared/ui/toast-store'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/shared/ui/card'
import { Button } from '@/shared/ui/button'
import { Label } from '@/shared/ui/label'
import { HttpError } from '@/shared/api/client'
import { getActivationStatus, requestActivation } from './api'
/** Показывает детям только активированным пользователям; иначе — экран запроса активации. */
export function ActivationGate({ children }: { children: ReactNode }) {
const { t } = useTranslation()
const queryClient = useQueryClient()
const [comment, setComment] = useState('')
const [submitting, setSubmitting] = useState(false)
const { data, isLoading, isError, refetch } = useQuery({
queryKey: ['activation-status'],
queryFn: getActivationStatus,
})
if (isLoading) return null
if (isError || !data) {
return (
<div className="flex flex-1 items-center justify-center px-6 py-16">
<Card className="w-full max-w-md">
<CardContent className="flex flex-col items-center gap-4 pt-6 text-center">
<p className="text-sm text-muted-foreground">{t('auth.genericError')}</p>
<Button variant="outline" onClick={() => void refetch()}>
{t('activation.retry')}
</Button>
</CardContent>
</Card>
</div>
)
}
if (data.isActivated) return <>{children}</>
const handleSubmit = async () => {
setSubmitting(true)
try {
await requestActivation(comment.trim() || undefined)
await queryClient.invalidateQueries({ queryKey: ['activation-status'] })
} catch (error) {
const message = error instanceof HttpError && error.status === 409 ? t('activation.alreadyPending') : t('auth.genericError')
toast.error(message)
} finally {
setSubmitting(false)
}
}
return (
<div className="flex flex-1 items-center justify-center px-6 py-16">
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle>{t('activation.title')}</CardTitle>
<CardDescription>{t('activation.description')}</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
{data.pendingRequest ? (
<p className="text-sm text-muted-foreground">{t('activation.pending')}</p>
) : (
<>
<div className="flex flex-col gap-1.5">
<Label htmlFor="comment">{t('activation.commentLabel')}</Label>
<textarea
id="comment"
className="min-h-24 rounded-md border border-border bg-transparent px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
value={comment}
onChange={(e) => setComment(e.target.value)}
/>
</div>
<Button onClick={handleSubmit} disabled={submitting}>
{t('activation.submit')}
</Button>
</>
)}
</CardContent>
</Card>
</div>
)
}
+10
View File
@@ -0,0 +1,10 @@
import { apiRequest } from '@/shared/api/client'
import type { ActivationRequestDto, ActivationStatusDto } from '@/shared/api/types'
export function getActivationStatus() {
return apiRequest<ActivationStatusDto>('/activation/status')
}
export function requestActivation(comment: string | undefined) {
return apiRequest<ActivationRequestDto>('/activation/request', { method: 'POST', body: { comment: comment ?? null } })
}
@@ -0,0 +1,16 @@
import { apiRequest } from '@/shared/api/client'
import type { ActivationRequestAdminDto, ActivationStatus, PagedList } from '@/shared/api/types'
export function listActivationRequests(statusFilter: ActivationStatus | undefined, page: number, pageSize: number) {
const params = new URLSearchParams({ page: String(page), pageSize: String(pageSize) })
if (statusFilter) params.set('statusFilter', statusFilter)
return apiRequest<PagedList<ActivationRequestAdminDto>>(`/admin/activation-requests?${params.toString()}`)
}
export function approveActivationRequest(id: string) {
return apiRequest<void>(`/admin/activation-requests/${id}/approve`, { method: 'POST' })
}
export function rejectActivationRequest(id: string, reason: string | undefined) {
return apiRequest<void>(`/admin/activation-requests/${id}/reject`, { method: 'POST', body: { reason: reason ?? null } })
}
@@ -0,0 +1,113 @@
import { useState } from 'react'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { toast } from '@/shared/ui/toast-store'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/shared/ui/dialog'
import { Button } from '@/shared/ui/button'
import { Input } from '@/shared/ui/input'
import { Label } from '@/shared/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
import { HttpError } from '@/shared/api/client'
import type { AdminAppDto, OsPlatform } from '@/shared/api/types'
import { createApp, updateApp } from './api'
const OS_OPTIONS: OsPlatform[] = ['IOS', 'Android', 'Windows', 'MacOS', 'Linux']
export function AppFormDialog({ app, open, onOpenChange }: { app?: AdminAppDto; open?: boolean; onOpenChange?: (open: boolean) => void }) {
const { t } = useTranslation()
const queryClient = useQueryClient()
const [internalOpen, setInternalOpen] = useState(false)
const [name, setName] = useState(app?.name ?? '')
const [downloadUrl, setDownloadUrl] = useState(app?.downloadUrl ?? '')
const [operatingSystem, setOperatingSystem] = useState<OsPlatform>(app?.operatingSystem ?? 'IOS')
const [description, setDescription] = useState(app?.description ?? '')
const [iconUrl, setIconUrl] = useState(app?.iconUrl ?? '')
const [sortOrder, setSortOrder] = useState(String(app?.sortOrder ?? 0))
const [isEnabled, setIsEnabled] = useState(app?.isEnabled ?? true)
const isControlled = open !== undefined
const dialogOpen = isControlled ? open : internalOpen
const setDialogOpen = isControlled ? onOpenChange! : setInternalOpen
const mutation = useMutation({
mutationFn: () =>
app
? updateApp(app.id, name.trim(), downloadUrl.trim(), operatingSystem, description.trim() || undefined, iconUrl.trim() || undefined, Number(sortOrder), isEnabled)
: createApp(name.trim(), downloadUrl.trim(), operatingSystem, description.trim() || undefined, iconUrl.trim() || undefined, Number(sortOrder)),
onSuccess: async () => {
toast.success(app ? t('admin.apps.updated') : t('admin.apps.created'))
await queryClient.invalidateQueries({ queryKey: ['admin-apps'] })
setDialogOpen(false)
},
onError: (error) => toast.error(error instanceof HttpError ? error.detail : t('auth.genericError')),
})
const canSubmit = name.trim() && downloadUrl.trim()
return (
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
{!isControlled && (
<DialogTrigger asChild>
<Button size="sm">{t('admin.apps.create')}</Button>
</DialogTrigger>
)}
<DialogContent>
<DialogHeader>
<DialogTitle>{app ? app.name : t('admin.apps.create')}</DialogTitle>
</DialogHeader>
<form
className="flex flex-col gap-4"
onSubmit={(e) => {
e.preventDefault()
if (canSubmit) mutation.mutate()
}}
>
<div className="flex flex-col gap-1.5">
<Label htmlFor="appName">{t('admin.apps.name')}</Label>
<Input id="appName" value={name} onChange={(e) => setName(e.target.value)} required />
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="downloadUrl">{t('admin.apps.downloadUrl')}</Label>
<Input id="downloadUrl" value={downloadUrl} onChange={(e) => setDownloadUrl(e.target.value)} required />
</div>
<div className="flex flex-col gap-1.5">
<Label>{t('admin.apps.os')}</Label>
<Select value={operatingSystem} onValueChange={(v) => setOperatingSystem(v as OsPlatform)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{OS_OPTIONS.map((os) => (
<SelectItem key={os} value={os}>
{t(`instructions.os.${os}`)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="appDescription">{t('admin.apps.description')}</Label>
<Input id="appDescription" value={description} onChange={(e) => setDescription(e.target.value)} />
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="iconUrl">{t('admin.apps.iconUrl')}</Label>
<Input id="iconUrl" value={iconUrl} onChange={(e) => setIconUrl(e.target.value)} />
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="sortOrder">{t('admin.apps.sortOrder')}</Label>
<Input id="sortOrder" type="number" min={0} value={sortOrder} onChange={(e) => setSortOrder(e.target.value)} />
</div>
{app && (
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" checked={isEnabled} onChange={(e) => setIsEnabled(e.target.checked)} />
{t('admin.apps.enabled')}
</label>
)}
<Button type="submit" disabled={!canSubmit || mutation.isPending}>
{app ? t('admin.roles.save') : t('admin.apps.create')}
</Button>
</form>
</DialogContent>
</Dialog>
)
}
+40
View File
@@ -0,0 +1,40 @@
import { apiRequest } from '@/shared/api/client'
import type { AdminAppDto, OsPlatform } from '@/shared/api/types'
export function listAdminApps() {
return apiRequest<AdminAppDto[]>('/admin/apps')
}
export function createApp(
name: string,
downloadUrl: string,
operatingSystem: OsPlatform,
description: string | undefined,
iconUrl: string | undefined,
sortOrder: number,
) {
return apiRequest<AdminAppDto>('/admin/apps', {
method: 'POST',
body: { name, downloadUrl, operatingSystem, description: description ?? null, iconUrl: iconUrl ?? null, sortOrder },
})
}
export function updateApp(
id: string,
name: string,
downloadUrl: string,
operatingSystem: OsPlatform,
description: string | undefined,
iconUrl: string | undefined,
sortOrder: number,
isEnabled: boolean,
) {
return apiRequest<AdminAppDto>(`/admin/apps/${id}`, {
method: 'PUT',
body: { name, downloadUrl, operatingSystem, description: description ?? null, iconUrl: iconUrl ?? null, sortOrder, isEnabled },
})
}
export function deleteApp(id: string) {
return apiRequest<void>(`/admin/apps/${id}`, { method: 'DELETE' })
}
+6
View File
@@ -0,0 +1,6 @@
import { apiRequest } from '@/shared/api/client'
import type { AuditLogDto, PagedList } from '@/shared/api/types'
export function listAuditLogs(page: number, pageSize: number) {
return apiRequest<PagedList<AuditLogDto>>(`/admin/audit?page=${page}&pageSize=${pageSize}`)
}
@@ -0,0 +1,97 @@
import { useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { toast } from '@/shared/ui/toast-store'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/shared/ui/dialog'
import { Button } from '@/shared/ui/button'
import { Input } from '@/shared/ui/input'
import { Label } from '@/shared/ui/label'
import { HttpError } from '@/shared/api/client'
import { listRoles } from '@/features/admin/roles/api'
import type { InboundDto } from '@/shared/api/types'
import { publishInbound } from './api'
export function PublishInboundDialog({
inbound,
open,
onOpenChange,
}: {
inbound: InboundDto
open: boolean
onOpenChange: (open: boolean) => void
}) {
const { t } = useTranslation()
const queryClient = useQueryClient()
const [isPublished, setIsPublished] = useState(inbound.isPublished)
const [displayName, setDisplayName] = useState(inbound.displayName ?? inbound.remark)
const [maxClients, setMaxClients] = useState(inbound.maxClients?.toString() ?? '')
const [selectedRoles, setSelectedRoles] = useState<Set<string>>(new Set(inbound.allowedRoleIds))
const rolesQuery = useQuery({ queryKey: ['admin-roles-page'], queryFn: listRoles, enabled: open })
const mutation = useMutation({
mutationFn: () =>
publishInbound(inbound.id, isPublished, displayName.trim() || undefined, Array.from(selectedRoles), maxClients ? Number(maxClients) : undefined),
onSuccess: async () => {
toast.success(t('admin.nodes.publishSaved'))
await queryClient.invalidateQueries({ queryKey: ['admin-inbounds', inbound.nodeId] })
onOpenChange(false)
},
onError: (error) => toast.error(error instanceof HttpError ? error.detail : t('auth.genericError')),
})
const toggleRole = (roleId: string) => {
setSelectedRoles((prev) => {
const next = new Set(prev)
if (next.has(roleId)) next.delete(roleId)
else next.add(roleId)
return next
})
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{inbound.remark} · {inbound.protocol}
</DialogTitle>
</DialogHeader>
<form
className="flex flex-col gap-4"
onSubmit={(e) => {
e.preventDefault()
mutation.mutate()
}}
>
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" checked={isPublished} onChange={(e) => setIsPublished(e.target.checked)} />
{t('admin.nodes.isPublishedLabel')}
</label>
<div className="flex flex-col gap-1.5">
<Label htmlFor="displayName">{t('admin.nodes.displayName')}</Label>
<Input id="displayName" value={displayName} onChange={(e) => setDisplayName(e.target.value)} />
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="maxClients">{t('admin.nodes.maxClients')}</Label>
<Input id="maxClients" type="number" min={0} value={maxClients} onChange={(e) => setMaxClients(e.target.value)} />
</div>
<div className="flex flex-col gap-1.5">
<Label>{t('admin.nodes.allowedRoles')}</Label>
<div className="flex flex-col gap-1">
{rolesQuery.data?.map((role) => (
<label key={role.id} className="flex items-center gap-2 text-sm">
<input type="checkbox" checked={selectedRoles.has(role.id)} onChange={() => toggleRole(role.id)} />
{role.name}
</label>
))}
</div>
</div>
<Button type="submit" disabled={mutation.isPending}>
{t('admin.roles.save')}
</Button>
</form>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,19 @@
import { apiRequest } from '@/shared/api/client'
import type { InboundDto } from '@/shared/api/types'
export function listInbounds(nodeId: string) {
return apiRequest<InboundDto[]>(`/admin/inbounds?nodeId=${nodeId}`)
}
export function publishInbound(
id: string,
isPublished: boolean,
displayName: string | undefined,
allowedRoleIds: string[],
maxClients: number | undefined,
) {
return apiRequest<InboundDto>(`/admin/inbounds/${id}/publish`, {
method: 'PUT',
body: { isPublished, displayName: displayName ?? null, allowedRoleIds, maxClients: maxClients ?? null },
})
}
@@ -0,0 +1,76 @@
import { useState } from 'react'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { toast } from '@/shared/ui/toast-store'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/shared/ui/dialog'
import { Button } from '@/shared/ui/button'
import { Input } from '@/shared/ui/input'
import { Label } from '@/shared/ui/label'
import { HttpError } from '@/shared/api/client'
import type { NodeDto } from '@/shared/api/types'
import { updateNode } from './api'
export function EditNodeDialog({ node, open, onOpenChange }: { node: NodeDto; open: boolean; onOpenChange: (open: boolean) => void }) {
const { t } = useTranslation()
const queryClient = useQueryClient()
const [name, setName] = useState(node.name)
const [location, setLocation] = useState(node.location ?? '')
const [isEnabled, setIsEnabled] = useState(node.isEnabled)
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const mutation = useMutation({
mutationFn: () => updateNode(node.id, name.trim(), location.trim() || undefined, isEnabled, username.trim() || undefined, password || undefined),
onSuccess: async () => {
toast.success(t('admin.nodes.updated'))
await queryClient.invalidateQueries({ queryKey: ['admin-nodes'] })
onOpenChange(false)
},
onError: (error) => toast.error(error instanceof HttpError ? error.detail : t('auth.genericError')),
})
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{node.name}</DialogTitle>
</DialogHeader>
<form
className="flex flex-col gap-4"
onSubmit={(e) => {
e.preventDefault()
mutation.mutate()
}}
>
<div className="flex flex-col gap-1.5">
<Label htmlFor="editName">{t('admin.nodes.name')}</Label>
<Input id="editName" value={name} onChange={(e) => setName(e.target.value)} required />
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="editLocation">{t('admin.nodes.location')}</Label>
<Input id="editLocation" value={location} onChange={(e) => setLocation(e.target.value)} />
</div>
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" checked={isEnabled} onChange={(e) => setIsEnabled(e.target.checked)} />
{t('admin.nodes.enabled')}
</label>
<div className="flex flex-col gap-1.5">
<Label htmlFor="editUsername">
{t('admin.nodes.username')} ({t('admin.nodes.optional')})
</Label>
<Input id="editUsername" value={username} onChange={(e) => setUsername(e.target.value)} placeholder={t('admin.nodes.username')} />
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="editPassword">
{t('admin.nodes.password')} ({t('admin.nodes.optional')})
</Label>
<Input id="editPassword" type="password" value={password} onChange={(e) => setPassword(e.target.value)} />
</div>
<Button type="submit" disabled={!name.trim() || mutation.isPending}>
{t('admin.roles.save')}
</Button>
</form>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,134 @@
import { useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { ChevronDown, ChevronUp } from 'lucide-react'
import { toast } from '@/shared/ui/toast-store'
import { Card, CardContent, CardHeader, CardTitle } from '@/shared/ui/card'
import { Button } from '@/shared/ui/button'
import { Badge } from '@/shared/ui/badge'
import { HttpError } from '@/shared/api/client'
import { listInbounds } from '@/features/admin/inbounds/api'
import { PublishInboundDialog } from '@/features/admin/inbounds/PublishInboundDialog'
import type { InboundDto, NodeDto, NodeStatus } from '@/shared/api/types'
import { deleteNode, probeNode, syncNode } from './api'
import { EditNodeDialog } from './EditNodeDialog'
const STATUS_VARIANT: Record<NodeStatus, 'success' | 'warning' | 'destructive'> = {
Online: 'success',
Unknown: 'warning',
Offline: 'destructive',
}
export function NodeCard({ node }: { node: NodeDto }) {
const { t } = useTranslation()
const queryClient = useQueryClient()
const [expanded, setExpanded] = useState(false)
const [editing, setEditing] = useState(false)
const [publishing, setPublishing] = useState<InboundDto | null>(null)
const inboundsQuery = useQuery({
queryKey: ['admin-inbounds', node.id],
queryFn: () => listInbounds(node.id),
enabled: expanded,
})
const invalidateNodes = () => queryClient.invalidateQueries({ queryKey: ['admin-nodes'] })
const probeMutation = useMutation({
mutationFn: () => probeNode(node.id),
onSuccess: async (result) => {
toast[result.isReachable ? 'success' : 'error'](
result.isReachable ? t('admin.nodes.probeSuccess') : t('admin.nodes.probeFailure', { message: result.errorMessage ?? '' }),
)
await invalidateNodes()
},
onError: (error) => toast.error(error instanceof HttpError ? error.detail : t('auth.genericError')),
})
const syncMutation = useMutation({
mutationFn: () => syncNode(node.id),
onSuccess: async (result) => {
toast.success(t('admin.nodes.syncSuccess', { count: result.inboundsSynced }))
await invalidateNodes()
await queryClient.invalidateQueries({ queryKey: ['admin-inbounds', node.id] })
},
onError: (error) => toast.error(error instanceof HttpError ? error.detail : t('auth.genericError')),
})
const deleteMutation = useMutation({
mutationFn: () => deleteNode(node.id),
onSuccess: async () => {
toast.success(t('admin.nodes.deleted'))
await invalidateNodes()
},
onError: (error) => toast.error(error instanceof HttpError ? error.detail : t('auth.genericError')),
})
return (
<Card>
<CardHeader className="flex-row items-start justify-between gap-2 space-y-0">
<div>
<CardTitle className="text-base">{node.name}</CardTitle>
<p className="text-sm text-muted-foreground">
{node.baseAddress} {node.location && `· ${node.location}`}
</p>
</div>
<div className="flex items-center gap-2">
<Badge variant={STATUS_VARIANT[node.status]}>{t(`admin.nodes.status.${node.status}`)}</Badge>
<Badge variant="outline">{node.isEnabled ? t('admin.nodes.enabled') : t('admin.nodes.disabled')}</Badge>
</div>
</CardHeader>
<CardContent className="flex flex-col gap-3">
<div className="flex flex-wrap gap-2">
<Button size="sm" variant="outline" disabled={probeMutation.isPending} onClick={() => probeMutation.mutate()}>
{t('admin.nodes.probe')}
</Button>
<Button size="sm" variant="outline" disabled={syncMutation.isPending} onClick={() => syncMutation.mutate()}>
{t('admin.nodes.sync')}
</Button>
<Button size="sm" variant="outline" onClick={() => setEditing(true)}>
{t('admin.nodes.edit')}
</Button>
<Button
size="sm"
variant="ghost"
disabled={deleteMutation.isPending}
onClick={() => {
if (confirm(t('admin.nodes.confirmDelete'))) deleteMutation.mutate()
}}
>
{t('admin.nodes.delete')}
</Button>
<Button size="sm" variant="ghost" className="ml-auto" onClick={() => setExpanded((v) => !v)}>
{t('admin.nodes.inbounds')}
{expanded ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
</Button>
</div>
{expanded && (
<div className="flex flex-col gap-2 border-t border-border pt-3">
{inboundsQuery.data?.length === 0 && <p className="text-sm text-muted-foreground">{t('admin.nodes.noInbounds')}</p>}
{inboundsQuery.data?.map((inbound) => (
<div key={inbound.id} className="flex items-center justify-between rounded-md border border-border px-3 py-2 text-sm">
<span>
{inbound.remark} · {inbound.protocol} · :{inbound.port}
</span>
<div className="flex items-center gap-2">
<Badge variant={inbound.isPublished ? 'success' : 'outline'}>
{inbound.isPublished ? t('admin.nodes.published') : t('admin.nodes.unpublished')}
</Badge>
<Button size="sm" variant="outline" onClick={() => setPublishing(inbound)}>
{t('admin.nodes.publish')}
</Button>
</div>
</div>
))}
</div>
)}
</CardContent>
{editing && <EditNodeDialog node={node} open={editing} onOpenChange={setEditing} />}
{publishing && <PublishInboundDialog inbound={publishing} open={!!publishing} onOpenChange={(open) => !open && setPublishing(null)} />}
</Card>
)
}
@@ -0,0 +1,88 @@
import { useState } from 'react'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { toast } from '@/shared/ui/toast-store'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/shared/ui/dialog'
import { Button } from '@/shared/ui/button'
import { Input } from '@/shared/ui/input'
import { Label } from '@/shared/ui/label'
import { HttpError } from '@/shared/api/client'
import { registerNode } from './api'
export function RegisterNodeDialog() {
const { t } = useTranslation()
const queryClient = useQueryClient()
const [open, setOpen] = useState(false)
const [name, setName] = useState('')
const [baseAddress, setBaseAddress] = useState('')
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [location, setLocation] = useState('')
const mutation = useMutation({
mutationFn: () => registerNode(name.trim(), baseAddress.trim(), username.trim(), password, location.trim() || undefined),
onSuccess: async () => {
toast.success(t('admin.nodes.created'))
await queryClient.invalidateQueries({ queryKey: ['admin-nodes'] })
setOpen(false)
setName('')
setBaseAddress('')
setUsername('')
setPassword('')
setLocation('')
},
onError: (error) => toast.error(error instanceof HttpError ? error.detail : t('auth.genericError')),
})
const canSubmit = name.trim() && baseAddress.trim() && username.trim() && password
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button size="sm">{t('admin.nodes.create')}</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('admin.nodes.create')}</DialogTitle>
</DialogHeader>
<form
className="flex flex-col gap-4"
onSubmit={(e) => {
e.preventDefault()
if (canSubmit) mutation.mutate()
}}
>
<div className="flex flex-col gap-1.5">
<Label htmlFor="nodeName">{t('admin.nodes.name')}</Label>
<Input id="nodeName" value={name} onChange={(e) => setName(e.target.value)} required />
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="baseAddress">{t('admin.nodes.baseAddress')}</Label>
<Input
id="baseAddress"
placeholder="https://panel.example.com:2053"
value={baseAddress}
onChange={(e) => setBaseAddress(e.target.value)}
required
/>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="nodeUsername">{t('admin.nodes.username')}</Label>
<Input id="nodeUsername" value={username} onChange={(e) => setUsername(e.target.value)} required />
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="nodePassword">{t('admin.nodes.password')}</Label>
<Input id="nodePassword" type="password" value={password} onChange={(e) => setPassword(e.target.value)} required />
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="nodeLocation">{t('admin.nodes.location')}</Label>
<Input id="nodeLocation" value={location} onChange={(e) => setLocation(e.target.value)} />
</div>
<Button type="submit" disabled={!canSubmit || mutation.isPending}>
{t('admin.nodes.create')}
</Button>
</form>
</DialogContent>
</Dialog>
)
}
+36
View File
@@ -0,0 +1,36 @@
import { apiRequest } from '@/shared/api/client'
import type { NodeDto, NodeProbeResultDto, SyncNodeResultDto } from '@/shared/api/types'
export function listNodes() {
return apiRequest<NodeDto[]>('/admin/nodes')
}
export function registerNode(name: string, baseAddress: string, username: string, password: string, location: string | undefined) {
return apiRequest<NodeDto>('/admin/nodes', { method: 'POST', body: { name, baseAddress, username, password, location: location ?? null } })
}
export function updateNode(
id: string,
name: string,
location: string | undefined,
isEnabled: boolean,
username: string | undefined,
password: string | undefined,
) {
return apiRequest<NodeDto>(`/admin/nodes/${id}`, {
method: 'PUT',
body: { name, location: location ?? null, isEnabled, username: username ?? null, password: password ?? null },
})
}
export function deleteNode(id: string) {
return apiRequest<void>(`/admin/nodes/${id}`, { method: 'DELETE' })
}
export function syncNode(id: string) {
return apiRequest<SyncNodeResultDto>(`/admin/nodes/${id}/sync`, { method: 'POST' })
}
export function probeNode(id: string) {
return apiRequest<NodeProbeResultDto>(`/admin/nodes/${id}/probe`, { method: 'POST' })
}
@@ -0,0 +1,80 @@
import { useState } from 'react'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { toast } from '@/shared/ui/toast-store'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/shared/ui/dialog'
import { Button } from '@/shared/ui/button'
import { Input } from '@/shared/ui/input'
import { Label } from '@/shared/ui/label'
import type { RoleDto } from '@/shared/api/types'
import { createRole, updateRole } from './api'
/** Без role — диалог создания (кнопка-триггер); с role — диалог редактирования квоты (управляется извне). */
export function RoleFormDialog({
role,
open,
onOpenChange,
}: {
role?: RoleDto
open?: boolean
onOpenChange?: (open: boolean) => void
}) {
const { t } = useTranslation()
const queryClient = useQueryClient()
const [name, setName] = useState(role?.name ?? '')
const [maxConfigs, setMaxConfigs] = useState(String(role?.maxConfigs ?? 3))
const [internalOpen, setInternalOpen] = useState(false)
const isControlled = open !== undefined
const dialogOpen = isControlled ? open : internalOpen
const setDialogOpen = isControlled ? onOpenChange! : setInternalOpen
const mutation = useMutation({
mutationFn: () => (role ? updateRole(role.id, Number(maxConfigs)) : createRole(name.trim(), Number(maxConfigs))),
onSuccess: async () => {
toast.success(role ? t('admin.roles.updated') : t('admin.roles.created'))
await queryClient.invalidateQueries({ queryKey: ['admin-roles-page'] })
setDialogOpen(false)
setName('')
setMaxConfigs('3')
},
onError: () => toast.error(t('auth.genericError')),
})
return (
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
{!isControlled && (
<DialogTrigger asChild>
<Button size="sm">{t('admin.roles.create')}</Button>
</DialogTrigger>
)}
<DialogContent>
<DialogHeader>
<DialogTitle>{role ? role.name : t('admin.roles.create')}</DialogTitle>
</DialogHeader>
<form
className="flex flex-col gap-4"
onSubmit={(e) => {
e.preventDefault()
mutation.mutate()
}}
>
{!role && (
<div className="flex flex-col gap-1.5">
<Label htmlFor="roleName">{t('admin.roles.name')}</Label>
<Input id="roleName" value={name} onChange={(e) => setName(e.target.value)} required />
</div>
)}
<div className="flex flex-col gap-1.5">
<Label htmlFor="maxConfigs">{t('admin.roles.maxConfigs')}</Label>
<Input id="maxConfigs" type="number" value={maxConfigs} onChange={(e) => setMaxConfigs(e.target.value)} />
<p className="text-xs text-muted-foreground">{t('admin.roles.maxConfigsHint')}</p>
</div>
<Button type="submit" disabled={mutation.isPending || (!role && !name.trim())}>
{role ? t('admin.roles.save') : t('admin.roles.create')}
</Button>
</form>
</DialogContent>
</Dialog>
)
}
+18
View File
@@ -0,0 +1,18 @@
import { apiRequest } from '@/shared/api/client'
import type { RoleDto } from '@/shared/api/types'
export function listRoles() {
return apiRequest<RoleDto[]>('/admin/roles')
}
export function createRole(name: string, maxConfigs: number) {
return apiRequest<RoleDto>('/admin/roles', { method: 'POST', body: { name, maxConfigs } })
}
export function updateRole(id: string, maxConfigs: number) {
return apiRequest<RoleDto>(`/admin/roles/${id}`, { method: 'PUT', body: { maxConfigs } })
}
export function deleteRole(id: string) {
return apiRequest<void>(`/admin/roles/${id}`, { method: 'DELETE' })
}
+6
View File
@@ -0,0 +1,6 @@
import { apiRequest } from '@/shared/api/client'
import type { StatsDto } from '@/shared/api/types'
export function getStats() {
return apiRequest<StatsDto>('/admin/stats')
}
@@ -0,0 +1,154 @@
import { useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { toast } from '@/shared/ui/toast-store'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/shared/ui/dialog'
import { Button } from '@/shared/ui/button'
import { Input } from '@/shared/ui/input'
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 type { UserSummaryDto } from '@/shared/api/types'
import {
blockUser,
changeUserRole,
forceRevokeConfig,
getUserConfigs,
resetUserPassword,
unblockUser,
} from './api'
export function UserManageDialog({ user, open, onOpenChange }: { user: UserSummaryDto; open: boolean; onOpenChange: (open: boolean) => void }) {
const { t } = useTranslation()
const queryClient = useQueryClient()
const [newPassword, setNewPassword] = useState('')
const rolesQuery = useQuery({ queryKey: ['admin-roles'], queryFn: listRoles, enabled: open })
const configsQuery = useQuery({ queryKey: ['admin-user-configs', user.id], queryFn: () => getUserConfigs(user.id), enabled: open })
const invalidateUsers = () => queryClient.invalidateQueries({ queryKey: ['admin-users'] })
const blockMutation = useMutation({
mutationFn: () => (user.isBlocked ? unblockUser(user.id) : blockUser(user.id)),
onSuccess: async () => {
toast.success(user.isBlocked ? t('admin.users.unblocked') : t('admin.users.blocked'))
await invalidateUsers()
},
onError: () => toast.error(t('auth.genericError')),
})
const roleMutation = useMutation({
mutationFn: (roleId: string) => changeUserRole(user.id, roleId),
onSuccess: async () => {
toast.success(t('admin.users.roleChanged'))
await invalidateUsers()
},
onError: () => toast.error(t('auth.genericError')),
})
const resetPasswordMutation = useMutation({
mutationFn: () => resetUserPassword(user.id, newPassword),
onSuccess: () => {
toast.success(t('admin.users.passwordReset'))
setNewPassword('')
},
onError: () => toast.error(t('auth.genericError')),
})
const revokeMutation = useMutation({
mutationFn: (configId: string) => forceRevokeConfig(configId),
onSuccess: async () => {
toast.success(t('configs.revoked'))
await queryClient.invalidateQueries({ queryKey: ['admin-user-configs', user.id] })
},
onError: () => toast.error(t('auth.genericError')),
})
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>{user.userName}</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-6">
<div className="flex items-center gap-2">
<Badge variant={user.isBlocked ? 'destructive' : user.isActivated ? 'success' : 'warning'}>
{user.isBlocked ? t('admin.users.status.blocked') : user.isActivated ? t('admin.users.status.active') : t('admin.users.status.pending')}
</Badge>
<Button
size="sm"
variant="outline"
disabled={blockMutation.isPending}
onClick={() => {
if (!user.isBlocked && !confirm(t('admin.users.confirmBlock'))) return
blockMutation.mutate()
}}
>
{user.isBlocked ? t('admin.users.unblock') : t('admin.users.block')}
</Button>
</div>
<div className="flex flex-col gap-1.5">
<Label>{t('admin.users.role')}</Label>
<Select defaultValue="" onValueChange={(roleId) => roleMutation.mutate(roleId)}>
<SelectTrigger>
<SelectValue placeholder={user.role} />
</SelectTrigger>
<SelectContent>
{rolesQuery.data?.map((role) => (
<SelectItem key={role.id} value={role.id}>
{role.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="newPassword">{t('admin.users.resetPassword')}</Label>
<div className="flex gap-2">
<Input
id="newPassword"
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
placeholder={t('auth.passwordHint')}
/>
<Button
variant="outline"
disabled={newPassword.length < 8 || resetPasswordMutation.isPending}
onClick={() => resetPasswordMutation.mutate()}
>
{t('admin.users.reset')}
</Button>
</div>
</div>
<div className="flex flex-col gap-2">
<Label>{t('admin.users.configs')}</Label>
{configsQuery.data?.length === 0 && <p className="text-sm text-muted-foreground">{t('configs.empty')}</p>}
{configsQuery.data?.map((config) => (
<div key={config.id} className="flex items-center justify-between rounded-md border border-border px-3 py-2 text-sm">
<span>
{config.label ?? config.location} · {config.protocol} · {t(`configs.status.${config.status}`)}
</span>
<Button
size="sm"
variant="ghost"
disabled={config.status === 'Revoked' || revokeMutation.isPending}
onClick={() => {
if (confirm(t('configs.confirmRevoke'))) revokeMutation.mutate(config.id)
}}
>
{t('configs.revoke')}
</Button>
</div>
))}
</div>
</div>
</DialogContent>
</Dialog>
)
}
+32
View File
@@ -0,0 +1,32 @@
import { apiRequest } from '@/shared/api/client'
import type { PagedList, UserSummaryDto, VpnConfigDto } from '@/shared/api/types'
export function listUsers(page: number, pageSize: number, search: string | undefined) {
const params = new URLSearchParams({ page: String(page), pageSize: String(pageSize) })
if (search) params.set('search', search)
return apiRequest<PagedList<UserSummaryDto>>(`/admin/users?${params.toString()}`)
}
export function blockUser(id: string) {
return apiRequest<void>(`/admin/users/${id}/block`, { method: 'PATCH' })
}
export function unblockUser(id: string) {
return apiRequest<void>(`/admin/users/${id}/unblock`, { method: 'PATCH' })
}
export function resetUserPassword(id: string, newPassword: string) {
return apiRequest<void>(`/admin/users/${id}/reset-password`, { method: 'POST', body: { newPassword } })
}
export function getUserConfigs(id: string) {
return apiRequest<VpnConfigDto[]>(`/admin/users/${id}/configs`)
}
export function forceRevokeConfig(id: string) {
return apiRequest<void>(`/admin/configs/${id}`, { method: 'DELETE' })
}
export function changeUserRole(id: string, roleId: string) {
return apiRequest<void>(`/admin/users/${id}/role`, { method: 'PATCH', body: { roleId } })
}
@@ -0,0 +1,47 @@
import { useQuery } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { Download } from 'lucide-react'
import { Card, CardHeader, CardTitle } from '@/shared/ui/card'
import type { OsPlatform } from '@/shared/api/types'
import { listApps } from './api'
const OS_ORDER: OsPlatform[] = ['IOS', 'Android', 'Windows', 'MacOS', 'Linux']
export function AppsCatalog() {
const { t } = useTranslation()
const { data, isLoading } = useQuery({ queryKey: ['client-apps'], queryFn: listApps })
if (isLoading) return null
if (!data || Object.keys(data).length === 0) {
return <p className="text-sm text-muted-foreground">{t('instructions.noApps')}</p>
}
return (
<div className="flex flex-col gap-6">
{OS_ORDER.filter((os) => data[os] && data[os]!.length > 0).map((os) => (
<div key={os} className="flex flex-col gap-3">
<h3 className="text-sm font-semibold text-muted-foreground">{t(`instructions.os.${os}`)}</h3>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{data[os]!.map((app) => (
<a key={app.id} href={app.downloadUrl} target="_blank" rel="noreferrer">
<Card className="transition-colors hover:bg-muted">
<CardHeader className="flex-row items-center gap-3 space-y-0">
{app.iconUrl ? (
<img src={app.iconUrl} alt="" className="h-8 w-8 rounded" />
) : (
<Download className="h-6 w-6 text-muted-foreground" />
)}
<div>
<CardTitle className="text-sm">{app.name}</CardTitle>
{app.description && <p className="text-xs text-muted-foreground">{app.description}</p>}
</div>
</CardHeader>
</Card>
</a>
))}
</div>
</div>
))}
</div>
)
}
+6
View File
@@ -0,0 +1,6 @@
import { apiRequest } from '@/shared/api/client'
import type { AppsByOs } from '@/shared/api/types'
export function listApps() {
return apiRequest<AppsByOs>('/apps')
}
+55
View File
@@ -0,0 +1,55 @@
import { zodResolver } from '@hookform/resolvers/zod'
import { useForm } from 'react-hook-form'
import { useTranslation } from 'react-i18next'
import { z } from 'zod'
import { toast } from '@/shared/ui/toast-store'
import { Button } from '@/shared/ui/button'
import { Input } from '@/shared/ui/input'
import { Label } from '@/shared/ui/label'
import { HttpError } from '@/shared/api/client'
import { login, applyAuthResponse } from './api'
const schema = z.object({
userName: z.string().min(1),
password: z.string().min(1),
})
type FormValues = z.infer<typeof schema>
export function LoginForm({ onSuccess }: { onSuccess: () => void }) {
const { t } = useTranslation()
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<FormValues>({ resolver: zodResolver(schema) })
const onSubmit = async (values: FormValues) => {
try {
const auth = await login(values.userName, values.password)
applyAuthResponse(auth)
onSuccess()
} catch (error) {
const message = error instanceof HttpError && error.status === 401 ? t('auth.invalidCredentials') : t('auth.genericError')
toast.error(message)
}
}
return (
<form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-4">
<div className="flex flex-col gap-1.5">
<Label htmlFor="userName">{t('auth.userName')}</Label>
<Input id="userName" autoComplete="username" {...register('userName')} />
{errors.userName && <p className="text-sm text-red-500">{errors.userName.message}</p>}
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="password">{t('auth.password')}</Label>
<Input id="password" type="password" autoComplete="current-password" {...register('password')} />
{errors.password && <p className="text-sm text-red-500">{errors.password.message}</p>}
</div>
<Button type="submit" disabled={isSubmitting}>
{t('auth.submitLogin')}
</Button>
</form>
)
}
@@ -0,0 +1,69 @@
import { zodResolver } from '@hookform/resolvers/zod'
import { useForm } from 'react-hook-form'
import { useTranslation } from 'react-i18next'
import { z } from 'zod'
import { toast } from '@/shared/ui/toast-store'
import { Button } from '@/shared/ui/button'
import { Input } from '@/shared/ui/input'
import { Label } from '@/shared/ui/label'
import { HttpError } from '@/shared/api/client'
import { login, register as registerUser, applyAuthResponse } from './api'
const schema = z.object({
userName: z
.string()
.min(3)
.max(32)
.regex(/^[a-zA-Z0-9_.-]+$/),
password: z.string().min(8),
})
type FormValues = z.infer<typeof schema>
export function RegisterForm({ onSuccess }: { onSuccess: () => void }) {
const { t } = useTranslation()
const {
register: registerField,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<FormValues>({ resolver: zodResolver(schema) })
const onSubmit = async (values: FormValues) => {
try {
await registerUser(values.userName, values.password)
const auth = await login(values.userName, values.password)
applyAuthResponse(auth)
onSuccess()
} catch (error) {
const message =
error instanceof HttpError && error.status === 409 ? t('auth.duplicateUserName') : t('auth.genericError')
toast.error(message)
}
}
return (
<form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-4">
<div className="flex flex-col gap-1.5">
<Label htmlFor="userName">{t('auth.userName')}</Label>
<Input id="userName" autoComplete="username" {...registerField('userName')} />
{errors.userName ? (
<p className="text-sm text-red-500">{t('auth.userNameHint')}</p>
) : (
<p className="text-xs text-muted-foreground">{t('auth.userNameHint')}</p>
)}
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="password">{t('auth.password')}</Label>
<Input id="password" type="password" autoComplete="new-password" {...registerField('password')} />
{errors.password ? (
<p className="text-sm text-red-500">{t('auth.passwordHint')}</p>
) : (
<p className="text-xs text-muted-foreground">{t('auth.passwordHint')}</p>
)}
</div>
<Button type="submit" disabled={isSubmitting}>
{t('auth.submitRegister')}
</Button>
</form>
)
}
+50
View File
@@ -0,0 +1,50 @@
import { apiRequest, setAccessToken } from '@/shared/api/client'
import type { AuthResponse, CurrentUser, RegisterResponse } from '@/shared/api/types'
import { useAuthStore } from './store'
export function login(userName: string, password: string) {
return apiRequest<AuthResponse>('/auth/login', { method: 'POST', body: { userName, password } })
}
export function register(userName: string, password: string) {
return apiRequest<RegisterResponse>('/auth/register', { method: 'POST', body: { userName, password } })
}
export function logout() {
return apiRequest<void>('/auth/logout', { method: 'POST' })
}
export function fetchCurrentUser() {
return apiRequest<CurrentUser>('/auth/me')
}
export function changePassword(currentPassword: string, newPassword: string) {
return apiRequest<void>('/auth/change-password', { method: 'POST', body: { currentPassword, newPassword } })
}
export function deleteAccount() {
return apiRequest<void>('/auth/me', { method: 'DELETE' })
}
export function applyAuthResponse(auth: AuthResponse) {
setAccessToken(auth.accessToken)
useAuthStore.getState().setUser(auth.user)
}
/** Тихая попытка восстановить сессию по refresh-cookie при загрузке приложения. */
export async function bootstrapSession() {
try {
const auth = await apiRequest<AuthResponse>('/auth/refresh', { method: 'POST', skipRefresh: true })
applyAuthResponse(auth)
} catch {
setAccessToken(null)
useAuthStore.getState().setUser(null)
} finally {
useAuthStore.getState().finishBootstrap()
}
}
export function clearSession() {
setAccessToken(null)
useAuthStore.getState().setUser(null)
}
+39
View File
@@ -0,0 +1,39 @@
import { useEffect } from 'react'
import { useNavigate } from '@tanstack/react-router'
import { useAuthStore } from './store'
/** Редиректит на /login, если пользователь не вошёл (после завершения bootstrap-попытки refresh). */
export function useRequireAuth() {
const { user, isBootstrapping } = useAuthStore()
const navigate = useNavigate()
useEffect(() => {
if (!isBootstrapping && !user) void navigate({ to: '/login' })
}, [isBootstrapping, user, navigate])
return { user, isReady: !isBootstrapping && !!user }
}
/** Редиректит уже вошедшего пользователя с login/register на дашборд. */
export function useRequireGuest() {
const { user, isBootstrapping } = useAuthStore()
const navigate = useNavigate()
useEffect(() => {
if (!isBootstrapping && user) void navigate({ to: '/dashboard' })
}, [isBootstrapping, user, navigate])
}
/** Как useRequireAuth, но дополнительно требует роль admin — иначе редирект на дашборд. */
export function useRequireAdmin() {
const { user, isBootstrapping } = useAuthStore()
const navigate = useNavigate()
useEffect(() => {
if (isBootstrapping) return
if (!user) void navigate({ to: '/login' })
else if (user.role !== 'admin') void navigate({ to: '/dashboard' })
}, [isBootstrapping, user, navigate])
return { user, isReady: !isBootstrapping && !!user && user.role === 'admin' }
}
+17
View File
@@ -0,0 +1,17 @@
import { create } from 'zustand'
import type { CurrentUser } from '@/shared/api/types'
type AuthState = {
user: CurrentUser | null
/** Пока не завершилась попытка тихого восстановления сессии при старте приложения. */
isBootstrapping: boolean
setUser: (user: CurrentUser | null) => void
finishBootstrap: () => void
}
export const useAuthStore = create<AuthState>((set) => ({
user: null,
isBootstrapping: true,
setUser: (user) => set({ user }),
finishBootstrap: () => set({ isBootstrapping: false }),
}))
@@ -0,0 +1,146 @@
import { useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { QRCodeSVG } from 'qrcode.react'
import { Copy, QrCode, RefreshCw, Trash2 } from 'lucide-react'
import { toast } from '@/shared/ui/toast-store'
import { Card, CardContent, CardHeader, CardTitle } from '@/shared/ui/card'
import { Badge } from '@/shared/ui/badge'
import { Button } from '@/shared/ui/button'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/shared/ui/dialog'
import { HttpError } from '@/shared/api/client'
import { formatBytes } from '@/shared/lib/format'
import type { ConfigStatus, VpnConfigDto } from '@/shared/api/types'
import { getConfigLink, revokeConfig, rotateConfig } from './api'
const STATUS_VARIANT: Record<ConfigStatus, 'success' | 'warning' | 'destructive'> = {
Active: 'success',
Disabled: 'warning',
Expired: 'destructive',
LimitReached: 'destructive',
Revoked: 'destructive',
}
export function ConfigCard({ config }: { config: VpnConfigDto }) {
const { t } = useTranslation()
const queryClient = useQueryClient()
const [detailsOpen, setDetailsOpen] = useState(false)
const linkQuery = useQuery({
queryKey: ['config-link', config.id],
queryFn: () => getConfigLink(config.id),
enabled: detailsOpen,
})
const rotateMutation = useMutation({
mutationFn: () => rotateConfig(config.id),
onSuccess: async () => {
toast.success(t('configs.rotated'))
await queryClient.invalidateQueries({ queryKey: ['my-configs'] })
await queryClient.invalidateQueries({ queryKey: ['config-link', config.id] })
},
onError: () => toast.error(t('auth.genericError')),
})
const revokeMutation = useMutation({
mutationFn: () => revokeConfig(config.id),
onSuccess: async () => {
toast.success(t('configs.revoked'))
setDetailsOpen(false)
await queryClient.invalidateQueries({ queryKey: ['my-configs'] })
},
onError: () => toast.error(t('auth.genericError')),
})
const copy = async (value: string) => {
try {
await navigator.clipboard.writeText(value)
toast.success(t('configs.copied'))
} catch {
toast.error(t('auth.genericError'))
}
}
const isActive = config.status === 'Active'
return (
<>
<Card>
<CardHeader className="flex-row items-start justify-between gap-2 space-y-0">
<div>
<CardTitle className="text-base">{config.label ?? config.location}</CardTitle>
<p className="text-sm text-muted-foreground">{config.location}</p>
</div>
<div className="flex items-center gap-2">
<Badge variant="outline">{config.protocol}</Badge>
<Badge variant={STATUS_VARIANT[config.status]}>{t(`configs.status.${config.status}`)}</Badge>
</div>
</CardHeader>
<CardContent className="flex flex-col gap-3">
<div className="flex justify-between text-sm text-muted-foreground">
<span>
{formatBytes(config.usedUpBytes)} {formatBytes(config.usedDownBytes)}
</span>
<span>{config.deviceLimit > 0 ? t('configs.deviceLimit', { count: config.deviceLimit }) : t('configs.deviceLimitUnlimited')}</span>
</div>
<div className="flex gap-2">
<Button size="sm" variant="outline" onClick={() => setDetailsOpen(true)}>
<QrCode className="h-4 w-4" />
{t('configs.showLink')}
</Button>
<Button size="sm" variant="outline" disabled={!isActive || rotateMutation.isPending} onClick={() => rotateMutation.mutate()}>
<RefreshCw className="h-4 w-4" />
{t('configs.rotate')}
</Button>
<Button
size="sm"
variant="ghost"
disabled={config.status === 'Revoked' || revokeMutation.isPending}
onClick={() => {
if (confirm(t('configs.confirmRevoke'))) revokeMutation.mutate()
}}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</CardContent>
</Card>
<Dialog open={detailsOpen} onOpenChange={setDetailsOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{config.label ?? config.location}</DialogTitle>
</DialogHeader>
{linkQuery.isLoading && <p className="text-sm text-muted-foreground">{t('configs.loadingLink')}</p>}
{linkQuery.isError && (
<p className="text-sm text-red-500">
{linkQuery.error instanceof HttpError ? linkQuery.error.detail : t('auth.genericError')}
</p>
)}
{linkQuery.data && (
<div className="flex flex-col items-center gap-4">
<QRCodeSVG value={linkQuery.data.connectionString} size={200} />
<div className="flex w-full items-center gap-2">
<code className="flex-1 truncate rounded-md bg-muted px-2 py-1.5 text-xs">
{linkQuery.data.connectionString}
</code>
<Button size="icon" variant="outline" onClick={() => void copy(linkQuery.data!.connectionString)}>
<Copy className="h-4 w-4" />
</Button>
</div>
<p className="text-xs text-muted-foreground">{t('configs.subscriptionLink')}</p>
<div className="flex w-full items-center gap-2">
<code className="flex-1 truncate rounded-md bg-muted px-2 py-1.5 text-xs">
{linkQuery.data.subscriptionUrl}
</code>
<Button size="icon" variant="outline" onClick={() => void copy(linkQuery.data!.subscriptionUrl)}>
<Copy className="h-4 w-4" />
</Button>
</div>
</div>
)}
</DialogContent>
</Dialog>
</>
)
}
@@ -0,0 +1,100 @@
import { useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { Plus } from 'lucide-react'
import { toast } from '@/shared/ui/toast-store'
import { Button } from '@/shared/ui/button'
import { Input } from '@/shared/ui/input'
import { Label } from '@/shared/ui/label'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/shared/ui/dialog'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
import { HttpError } from '@/shared/api/client'
import { createConfig, listAvailableInbounds } from './api'
export function CreateConfigDialog() {
const { t } = useTranslation()
const queryClient = useQueryClient()
const [open, setOpen] = useState(false)
const [inboundId, setInboundId] = useState('')
const [label, setLabel] = useState('')
const [deviceLimit, setDeviceLimit] = useState('')
const inboundsQuery = useQuery({ queryKey: ['available-inbounds'], queryFn: listAvailableInbounds, enabled: open })
const createMutation = useMutation({
mutationFn: () => createConfig(inboundId, label.trim() || undefined, deviceLimit ? Number(deviceLimit) : undefined),
onSuccess: async () => {
toast.success(t('configs.created'))
await queryClient.invalidateQueries({ queryKey: ['my-configs'] })
setOpen(false)
setInboundId('')
setLabel('')
setDeviceLimit('')
},
onError: (error) => {
const message =
error instanceof HttpError && error.status === 409 ? t('configs.quotaExceeded') : t('auth.genericError')
toast.error(message)
},
})
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button>
<Plus className="h-4 w-4" />
{t('configs.create')}
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('configs.create')}</DialogTitle>
</DialogHeader>
<form
className="flex flex-col gap-4"
onSubmit={(e) => {
e.preventDefault()
if (inboundId) createMutation.mutate()
}}
>
<div className="flex flex-col gap-1.5">
<Label>{t('configs.location')}</Label>
<Select value={inboundId} onValueChange={setInboundId}>
<SelectTrigger>
<SelectValue placeholder={t('configs.selectLocation')} />
</SelectTrigger>
<SelectContent>
{inboundsQuery.data?.map((inbound) => (
<SelectItem key={inbound.inboundId} value={inbound.inboundId}>
{inbound.displayName} ({inbound.protocol})
</SelectItem>
))}
</SelectContent>
</Select>
{inboundsQuery.data?.length === 0 && (
<p className="text-xs text-muted-foreground">{t('configs.noInboundsAvailable')}</p>
)}
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="label">{t('configs.label')}</Label>
<Input id="label" value={label} onChange={(e) => setLabel(e.target.value)} />
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="deviceLimit">{t('configs.deviceLimitLabel')}</Label>
<Input
id="deviceLimit"
type="number"
min={0}
placeholder={t('configs.deviceLimitPlaceholder')}
value={deviceLimit}
onChange={(e) => setDeviceLimit(e.target.value)}
/>
</div>
<Button type="submit" disabled={!inboundId || createMutation.isPending}>
{t('configs.create')}
</Button>
</form>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,44 @@
import { useQuery } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { QRCodeSVG } from 'qrcode.react'
import { Copy } from 'lucide-react'
import { toast } from '@/shared/ui/toast-store'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/shared/ui/card'
import { Button } from '@/shared/ui/button'
import { getMySubscription } from './api'
export function SubscriptionCard() {
const { t } = useTranslation()
const { data, isLoading } = useQuery({ queryKey: ['my-subscription'], queryFn: getMySubscription })
const copy = async () => {
if (!data) return
try {
await navigator.clipboard.writeText(data.subscriptionUrl)
toast.success(t('configs.copied'))
} catch {
toast.error(t('auth.genericError'))
}
}
if (isLoading || !data) return null
return (
<Card>
<CardHeader>
<CardTitle className="text-base">{t('configs.aggregatedSubscription')}</CardTitle>
<CardDescription>{t('configs.aggregatedSubscriptionHint')}</CardDescription>
</CardHeader>
<CardContent className="flex items-center gap-4">
<QRCodeSVG value={data.subscriptionUrl} size={96} />
<div className="flex flex-1 flex-col gap-2">
<code className="truncate rounded-md bg-muted px-2 py-1.5 text-xs">{data.subscriptionUrl}</code>
<Button size="sm" variant="outline" className="self-start" onClick={() => void copy()}>
<Copy className="h-4 w-4" />
{t('configs.copyLink')}
</Button>
</div>
</CardContent>
</Card>
)
}
+46
View File
@@ -0,0 +1,46 @@
import { apiRequest } from '@/shared/api/client'
import type {
AvailableInboundDto,
ConfigLinkDto,
GetMyConfigsResult,
MySubscriptionDto,
VpnConfigDto,
} from '@/shared/api/types'
export function listAvailableInbounds() {
return apiRequest<AvailableInboundDto[]>('/inbounds/available')
}
export function getMyConfigs() {
return apiRequest<GetMyConfigsResult>('/configs')
}
export function createConfig(inboundId: string, label: string | undefined, deviceLimit: number | undefined) {
return apiRequest<VpnConfigDto>('/configs', {
method: 'POST',
body: { inboundId, label: label ?? null, deviceLimit: deviceLimit ?? null },
})
}
export function editConfig(id: string, label: string | undefined, deviceLimit: number | undefined) {
return apiRequest<VpnConfigDto>(`/configs/${id}`, {
method: 'PATCH',
body: { label: label ?? null, deviceLimit: deviceLimit ?? null },
})
}
export function rotateConfig(id: string) {
return apiRequest<VpnConfigDto>(`/configs/${id}/rotate`, { method: 'POST' })
}
export function revokeConfig(id: string) {
return apiRequest<void>(`/configs/${id}`, { method: 'DELETE' })
}
export function getConfigLink(id: string) {
return apiRequest<ConfigLinkDto>(`/configs/${id}/link`)
}
export function getMySubscription() {
return apiRequest<MySubscriptionDto>('/subscription')
}
@@ -0,0 +1,65 @@
import { zodResolver } from '@hookform/resolvers/zod'
import { useForm } from 'react-hook-form'
import { useTranslation } from 'react-i18next'
import { z } from 'zod'
import { toast } from '@/shared/ui/toast-store'
import { Button } from '@/shared/ui/button'
import { Input } from '@/shared/ui/input'
import { Label } from '@/shared/ui/label'
import { Card, CardContent, CardHeader, CardTitle } from '@/shared/ui/card'
import { HttpError } from '@/shared/api/client'
import { changePassword } from '@/features/auth/api'
const schema = z.object({
currentPassword: z.string().min(1),
newPassword: z.string().min(8),
})
type FormValues = z.infer<typeof schema>
export function ChangePasswordForm() {
const { t } = useTranslation()
const {
register,
handleSubmit,
reset,
formState: { errors, isSubmitting },
} = useForm<FormValues>({ resolver: zodResolver(schema) })
const onSubmit = async (values: FormValues) => {
try {
await changePassword(values.currentPassword, values.newPassword)
toast.success(t('settings.passwordChanged'))
reset()
} catch (error) {
const message =
error instanceof HttpError && error.status === 400 ? t('settings.currentPasswordInvalid') : t('auth.genericError')
toast.error(message)
}
}
return (
<Card>
<CardHeader>
<CardTitle className="text-base">{t('settings.changePassword')}</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-4">
<div className="flex flex-col gap-1.5">
<Label htmlFor="currentPassword">{t('settings.currentPassword')}</Label>
<Input id="currentPassword" type="password" autoComplete="current-password" {...register('currentPassword')} />
{errors.currentPassword && <p className="text-sm text-red-500">{t('settings.currentPasswordRequired')}</p>}
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="newPassword">{t('settings.newPassword')}</Label>
<Input id="newPassword" type="password" autoComplete="new-password" {...register('newPassword')} />
{errors.newPassword && <p className="text-sm text-red-500">{t('auth.passwordHint')}</p>}
</div>
<Button type="submit" disabled={isSubmitting} className="self-start">
{t('settings.changePassword')}
</Button>
</form>
</CardContent>
</Card>
)
}
@@ -0,0 +1,49 @@
import { useState } from 'react'
import { useMutation } from '@tanstack/react-query'
import { useNavigate } from '@tanstack/react-router'
import { useTranslation } from 'react-i18next'
import { toast } from '@/shared/ui/toast-store'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/shared/ui/card'
import { Button } from '@/shared/ui/button'
import { deleteAccount, clearSession } from '@/features/auth/api'
export function DeleteAccountSection() {
const { t } = useTranslation()
const navigate = useNavigate()
const [confirming, setConfirming] = useState(false)
const deleteMutation = useMutation({
mutationFn: deleteAccount,
onSuccess: () => {
clearSession()
void navigate({ to: '/login' })
},
onError: () => toast.error(t('auth.genericError')),
})
return (
<Card className="border-red-900/50">
<CardHeader>
<CardTitle className="text-base text-red-400">{t('settings.deleteAccount')}</CardTitle>
<CardDescription>{t('settings.deleteAccountHint')}</CardDescription>
</CardHeader>
<CardContent>
{!confirming ? (
<Button variant="destructive" size="sm" onClick={() => setConfirming(true)}>
{t('settings.deleteAccount')}
</Button>
) : (
<div className="flex items-center gap-2">
<p className="text-sm">{t('settings.confirmDelete')}</p>
<Button variant="destructive" size="sm" disabled={deleteMutation.isPending} onClick={() => deleteMutation.mutate()}>
{t('settings.confirmDeleteYes')}
</Button>
<Button variant="ghost" size="sm" onClick={() => setConfirming(false)}>
{t('settings.cancel')}
</Button>
</div>
)}
</CardContent>
</Card>
)
}
@@ -0,0 +1,104 @@
import { useEffect, useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { QRCodeSVG } from 'qrcode.react'
import { toast } from '@/shared/ui/toast-store'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/shared/ui/card'
import { Button } from '@/shared/ui/button'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/shared/ui/dialog'
import { useAuthStore } from '@/features/auth/store'
import { fetchCurrentUser } from '@/features/auth/api'
import { createLinkToken, unlinkTelegram } from '@/features/telegram/api'
export function TelegramLinkCard() {
const { t } = useTranslation()
const queryClient = useQueryClient()
const user = useAuthStore((s) => s.user)
const setUser = useAuthStore((s) => s.setUser)
const [open, setOpen] = useState(false)
const [deepLink, setDeepLink] = useState<string | null>(null)
const linkMutation = useMutation({
mutationFn: createLinkToken,
onSuccess: (data) => {
setDeepLink(data.deepLink)
setOpen(true)
},
onError: () => toast.error(t('auth.genericError')),
})
const meQuery = useQuery({
queryKey: ['me-poll'],
queryFn: fetchCurrentUser,
enabled: open,
refetchInterval: 2500,
})
useEffect(() => {
if (!meQuery.data?.telegramLinked) return
setUser(meQuery.data)
setOpen(false)
toast.success(t('settings.telegramLinked'))
}, [meQuery.data, setUser, t])
const unlinkMutation = useMutation({
mutationFn: unlinkTelegram,
onSuccess: async () => {
if (user) setUser({ ...user, telegramLinked: false })
toast.success(t('settings.telegramUnlinked'))
await queryClient.invalidateQueries({ queryKey: ['me-poll'] })
},
onError: () => toast.error(t('auth.genericError')),
})
return (
<Card>
<CardHeader>
<CardTitle className="text-base">Telegram</CardTitle>
<CardDescription>{t('settings.telegramHint')}</CardDescription>
</CardHeader>
<CardContent>
{user?.telegramLinked ? (
<div className="flex items-center justify-between">
<span className="text-sm text-emerald-500">{t('settings.telegramLinkedStatus')}</span>
<Button
variant="outline"
size="sm"
disabled={unlinkMutation.isPending}
onClick={() => {
if (confirm(t('settings.confirmUnlink'))) unlinkMutation.mutate()
}}
>
{t('settings.unlink')}
</Button>
</div>
) : (
<Button variant="outline" size="sm" disabled={linkMutation.isPending} onClick={() => linkMutation.mutate()}>
{t('settings.link')}
</Button>
)}
</CardContent>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('settings.link')}</DialogTitle>
</DialogHeader>
<div className="flex flex-col items-center gap-4">
{deepLink ? (
<>
<QRCodeSVG value={deepLink} size={200} />
<a href={deepLink} target="_blank" rel="noreferrer" className="text-sm text-primary hover:underline">
{deepLink}
</a>
</>
) : (
<p className="text-sm text-muted-foreground">{t('auth.telegramBotNotConfigured')}</p>
)}
<p className="text-sm text-muted-foreground">{t('settings.waitingForLink')}</p>
</div>
</DialogContent>
</Dialog>
</Card>
)
}
@@ -0,0 +1,87 @@
import { useEffect, useState } from 'react'
import { useMutation, useQuery } from '@tanstack/react-query'
import { useNavigate } from '@tanstack/react-router'
import { useTranslation } from 'react-i18next'
import { QRCodeSVG } from 'qrcode.react'
import { Send } from 'lucide-react'
import { toast } from '@/shared/ui/toast-store'
import { Button } from '@/shared/ui/button'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/shared/ui/dialog'
import { applyAuthResponse } from '@/features/auth/api'
import { createLoginRequest, getLoginRequestStatus } from './api'
import type { TelegramLoginStatus } from '@/shared/api/types'
const TERMINAL: TelegramLoginStatus[] = ['Rejected', 'Expired', 'Consumed']
export function TelegramLoginButton() {
const { t } = useTranslation()
const navigate = useNavigate()
const [open, setOpen] = useState(false)
const [requestId, setRequestId] = useState<string | null>(null)
const [deepLink, setDeepLink] = useState<string | null>(null)
const startMutation = useMutation({
mutationFn: createLoginRequest,
onSuccess: (data) => {
setRequestId(data.requestId)
setDeepLink(data.deepLink)
setOpen(true)
},
onError: () => toast.error(t('auth.genericError')),
})
const statusQuery = useQuery({
queryKey: ['telegram-login-status', requestId],
queryFn: () => getLoginRequestStatus(requestId!),
enabled: open && !!requestId,
refetchInterval: (query) => {
const status = query.state.data?.status
return status && (status === 'Approved' || TERMINAL.includes(status)) ? false : 2000
},
})
const status = statusQuery.data?.status
useEffect(() => {
if (status !== 'Approved' || !statusQuery.data?.accessToken || !statusQuery.data.user) return
applyAuthResponse({
accessToken: statusQuery.data.accessToken,
expiresAt: statusQuery.data.expiresAt!,
user: statusQuery.data.user,
})
setOpen(false)
void navigate({ to: '/dashboard' })
}, [status, statusQuery.data, navigate])
return (
<>
<Button type="button" variant="outline" className="w-full" onClick={() => startMutation.mutate()} disabled={startMutation.isPending}>
<Send className="h-4 w-4" />
{t('auth.loginViaTelegram')}
</Button>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('auth.loginViaTelegram')}</DialogTitle>
</DialogHeader>
<div className="flex flex-col items-center gap-4">
{deepLink && (
<>
<QRCodeSVG value={deepLink} size={200} />
<a href={deepLink} target="_blank" rel="noreferrer" className="text-sm text-primary hover:underline">
{deepLink}
</a>
</>
)}
{!deepLink && <p className="text-sm text-muted-foreground">{t('auth.telegramBotNotConfigured')}</p>}
{status === 'Pending' && <p className="text-sm text-muted-foreground">{t('auth.waitingForConfirmation')}</p>}
{status === 'Rejected' && <p className="text-sm text-red-500">{t('auth.telegramLoginRejected')}</p>}
{status === 'Expired' && <p className="text-sm text-red-500">{t('auth.telegramLoginExpired')}</p>}
</div>
</DialogContent>
</Dialog>
</>
)
}
+18
View File
@@ -0,0 +1,18 @@
import { apiRequest } from '@/shared/api/client'
import type { LinkTokenResponse, TelegramLoginRequestResponse, TelegramLoginStatusResponse } from '@/shared/api/types'
export function createLinkToken() {
return apiRequest<LinkTokenResponse>('/auth/telegram/link-token', { method: 'POST' })
}
export function unlinkTelegram() {
return apiRequest<void>('/auth/telegram/unlink', { method: 'POST' })
}
export function createLoginRequest() {
return apiRequest<TelegramLoginRequestResponse>('/auth/telegram/login-request', { method: 'POST' })
}
export function getLoginRequestStatus(id: string) {
return apiRequest<TelegramLoginStatusResponse>(`/auth/telegram/login-request/${id}`)
}
-46
View File
@@ -1,46 +0,0 @@
import i18n from 'i18next'
import { initReactI18next } from 'react-i18next'
const resources = {
ru: {
translation: {
appName: 'PnvPanel',
tagline: 'Self-service портал для VPN-конфигураций',
scaffoldNote: 'Каркас приложения (M0). Далее — аутентификация, ноды, конфиги (см. roadmap).',
theme: 'Тема',
language: 'Язык',
light: 'Светлая',
dark: 'Тёмная',
system: 'Системная',
},
},
en: {
translation: {
appName: 'PnvPanel',
tagline: 'Self-service portal for VPN configurations',
scaffoldNote: 'Application scaffold (M0). Next: authentication, nodes, configs (see roadmap).',
theme: 'Theme',
language: 'Language',
light: 'Light',
dark: 'Dark',
system: 'System',
},
},
}
const STORAGE_KEY = 'pnv-lang'
const stored = typeof localStorage !== 'undefined' ? localStorage.getItem(STORAGE_KEY) : null
void i18n.use(initReactI18next).init({
resources,
lng: stored ?? 'ru',
fallbackLng: 'ru',
interpolation: { escapeValue: false },
})
export function setLanguage(lng: string) {
localStorage.setItem(STORAGE_KEY, lng)
void i18n.changeLanguage(lng)
}
export default i18n
+11 -4
View File
@@ -1,10 +1,13 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { RouterProvider } from '@tanstack/react-router'
import './index.css'
import './lib/i18n'
import { ThemeProvider } from './lib/theme'
import App from './App.tsx'
import './shared/lib/i18n'
import { ThemeProvider } from './theme/ThemeProvider'
import { ToastProvider } from './shared/ui/toast-store'
import { RealtimeProvider } from './shared/realtime/RealtimeProvider'
import { router } from './router'
const queryClient = new QueryClient()
@@ -12,7 +15,11 @@ createRoot(document.getElementById('root')!).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<ThemeProvider>
<App />
<ToastProvider>
<RealtimeProvider>
<RouterProvider router={router} />
</RealtimeProvider>
</ToastProvider>
</ThemeProvider>
</QueryClientProvider>
</StrictMode>,
+342
View File
@@ -0,0 +1,342 @@
/* eslint-disable */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// This file was automatically generated by TanStack Router.
// You should NOT make any changes in this file as it will be overwritten.
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
import { Route as rootRouteImport } from './routes/__root'
import { Route as SettingsRouteImport } from './routes/settings'
import { Route as RegisterRouteImport } from './routes/register'
import { Route as LoginRouteImport } from './routes/login'
import { Route as InstructionsRouteImport } from './routes/instructions'
import { Route as DashboardRouteImport } from './routes/dashboard'
import { Route as AdminRouteImport } from './routes/admin'
import { Route as IndexRouteImport } from './routes/index'
import { Route as AdminIndexRouteImport } from './routes/admin/index'
import { Route as AdminUsersRouteImport } from './routes/admin/users'
import { Route as AdminRolesRouteImport } from './routes/admin/roles'
import { Route as AdminNodesRouteImport } from './routes/admin/nodes'
import { Route as AdminAuditRouteImport } from './routes/admin/audit'
import { Route as AdminAppsRouteImport } from './routes/admin/apps'
import { Route as AdminActivationRouteImport } from './routes/admin/activation'
const SettingsRoute = SettingsRouteImport.update({
id: '/settings',
path: '/settings',
getParentRoute: () => rootRouteImport,
} as any)
const RegisterRoute = RegisterRouteImport.update({
id: '/register',
path: '/register',
getParentRoute: () => rootRouteImport,
} as any)
const LoginRoute = LoginRouteImport.update({
id: '/login',
path: '/login',
getParentRoute: () => rootRouteImport,
} as any)
const InstructionsRoute = InstructionsRouteImport.update({
id: '/instructions',
path: '/instructions',
getParentRoute: () => rootRouteImport,
} as any)
const DashboardRoute = DashboardRouteImport.update({
id: '/dashboard',
path: '/dashboard',
getParentRoute: () => rootRouteImport,
} as any)
const AdminRoute = AdminRouteImport.update({
id: '/admin',
path: '/admin',
getParentRoute: () => rootRouteImport,
} as any)
const IndexRoute = IndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => rootRouteImport,
} as any)
const AdminIndexRoute = AdminIndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => AdminRoute,
} as any)
const AdminUsersRoute = AdminUsersRouteImport.update({
id: '/users',
path: '/users',
getParentRoute: () => AdminRoute,
} as any)
const AdminRolesRoute = AdminRolesRouteImport.update({
id: '/roles',
path: '/roles',
getParentRoute: () => AdminRoute,
} as any)
const AdminNodesRoute = AdminNodesRouteImport.update({
id: '/nodes',
path: '/nodes',
getParentRoute: () => AdminRoute,
} as any)
const AdminAuditRoute = AdminAuditRouteImport.update({
id: '/audit',
path: '/audit',
getParentRoute: () => AdminRoute,
} as any)
const AdminAppsRoute = AdminAppsRouteImport.update({
id: '/apps',
path: '/apps',
getParentRoute: () => AdminRoute,
} as any)
const AdminActivationRoute = AdminActivationRouteImport.update({
id: '/activation',
path: '/activation',
getParentRoute: () => AdminRoute,
} as any)
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/admin': typeof AdminRouteWithChildren
'/dashboard': typeof DashboardRoute
'/instructions': typeof InstructionsRoute
'/login': typeof LoginRoute
'/register': typeof RegisterRoute
'/settings': typeof SettingsRoute
'/admin/activation': typeof AdminActivationRoute
'/admin/apps': typeof AdminAppsRoute
'/admin/audit': typeof AdminAuditRoute
'/admin/nodes': typeof AdminNodesRoute
'/admin/roles': typeof AdminRolesRoute
'/admin/users': typeof AdminUsersRoute
'/admin/': typeof AdminIndexRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/dashboard': typeof DashboardRoute
'/instructions': typeof InstructionsRoute
'/login': typeof LoginRoute
'/register': typeof RegisterRoute
'/settings': typeof SettingsRoute
'/admin/activation': typeof AdminActivationRoute
'/admin/apps': typeof AdminAppsRoute
'/admin/audit': typeof AdminAuditRoute
'/admin/nodes': typeof AdminNodesRoute
'/admin/roles': typeof AdminRolesRoute
'/admin/users': typeof AdminUsersRoute
'/admin': typeof AdminIndexRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
'/admin': typeof AdminRouteWithChildren
'/dashboard': typeof DashboardRoute
'/instructions': typeof InstructionsRoute
'/login': typeof LoginRoute
'/register': typeof RegisterRoute
'/settings': typeof SettingsRoute
'/admin/activation': typeof AdminActivationRoute
'/admin/apps': typeof AdminAppsRoute
'/admin/audit': typeof AdminAuditRoute
'/admin/nodes': typeof AdminNodesRoute
'/admin/roles': typeof AdminRolesRoute
'/admin/users': typeof AdminUsersRoute
'/admin/': typeof AdminIndexRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths:
| '/'
| '/admin'
| '/dashboard'
| '/instructions'
| '/login'
| '/register'
| '/settings'
| '/admin/activation'
| '/admin/apps'
| '/admin/audit'
| '/admin/nodes'
| '/admin/roles'
| '/admin/users'
| '/admin/'
fileRoutesByTo: FileRoutesByTo
to:
| '/'
| '/dashboard'
| '/instructions'
| '/login'
| '/register'
| '/settings'
| '/admin/activation'
| '/admin/apps'
| '/admin/audit'
| '/admin/nodes'
| '/admin/roles'
| '/admin/users'
| '/admin'
id:
| '__root__'
| '/'
| '/admin'
| '/dashboard'
| '/instructions'
| '/login'
| '/register'
| '/settings'
| '/admin/activation'
| '/admin/apps'
| '/admin/audit'
| '/admin/nodes'
| '/admin/roles'
| '/admin/users'
| '/admin/'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
AdminRoute: typeof AdminRouteWithChildren
DashboardRoute: typeof DashboardRoute
InstructionsRoute: typeof InstructionsRoute
LoginRoute: typeof LoginRoute
RegisterRoute: typeof RegisterRoute
SettingsRoute: typeof SettingsRoute
}
declare module '@tanstack/react-router' {
interface FileRoutesByPath {
'/settings': {
id: '/settings'
path: '/settings'
fullPath: '/settings'
preLoaderRoute: typeof SettingsRouteImport
parentRoute: typeof rootRouteImport
}
'/register': {
id: '/register'
path: '/register'
fullPath: '/register'
preLoaderRoute: typeof RegisterRouteImport
parentRoute: typeof rootRouteImport
}
'/login': {
id: '/login'
path: '/login'
fullPath: '/login'
preLoaderRoute: typeof LoginRouteImport
parentRoute: typeof rootRouteImport
}
'/instructions': {
id: '/instructions'
path: '/instructions'
fullPath: '/instructions'
preLoaderRoute: typeof InstructionsRouteImport
parentRoute: typeof rootRouteImport
}
'/dashboard': {
id: '/dashboard'
path: '/dashboard'
fullPath: '/dashboard'
preLoaderRoute: typeof DashboardRouteImport
parentRoute: typeof rootRouteImport
}
'/admin': {
id: '/admin'
path: '/admin'
fullPath: '/admin'
preLoaderRoute: typeof AdminRouteImport
parentRoute: typeof rootRouteImport
}
'/': {
id: '/'
path: '/'
fullPath: '/'
preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport
}
'/admin/': {
id: '/admin/'
path: '/'
fullPath: '/admin/'
preLoaderRoute: typeof AdminIndexRouteImport
parentRoute: typeof AdminRoute
}
'/admin/users': {
id: '/admin/users'
path: '/users'
fullPath: '/admin/users'
preLoaderRoute: typeof AdminUsersRouteImport
parentRoute: typeof AdminRoute
}
'/admin/roles': {
id: '/admin/roles'
path: '/roles'
fullPath: '/admin/roles'
preLoaderRoute: typeof AdminRolesRouteImport
parentRoute: typeof AdminRoute
}
'/admin/nodes': {
id: '/admin/nodes'
path: '/nodes'
fullPath: '/admin/nodes'
preLoaderRoute: typeof AdminNodesRouteImport
parentRoute: typeof AdminRoute
}
'/admin/audit': {
id: '/admin/audit'
path: '/audit'
fullPath: '/admin/audit'
preLoaderRoute: typeof AdminAuditRouteImport
parentRoute: typeof AdminRoute
}
'/admin/apps': {
id: '/admin/apps'
path: '/apps'
fullPath: '/admin/apps'
preLoaderRoute: typeof AdminAppsRouteImport
parentRoute: typeof AdminRoute
}
'/admin/activation': {
id: '/admin/activation'
path: '/activation'
fullPath: '/admin/activation'
preLoaderRoute: typeof AdminActivationRouteImport
parentRoute: typeof AdminRoute
}
}
}
interface AdminRouteChildren {
AdminActivationRoute: typeof AdminActivationRoute
AdminAppsRoute: typeof AdminAppsRoute
AdminAuditRoute: typeof AdminAuditRoute
AdminNodesRoute: typeof AdminNodesRoute
AdminRolesRoute: typeof AdminRolesRoute
AdminUsersRoute: typeof AdminUsersRoute
AdminIndexRoute: typeof AdminIndexRoute
}
const AdminRouteChildren: AdminRouteChildren = {
AdminActivationRoute: AdminActivationRoute,
AdminAppsRoute: AdminAppsRoute,
AdminAuditRoute: AdminAuditRoute,
AdminNodesRoute: AdminNodesRoute,
AdminRolesRoute: AdminRolesRoute,
AdminUsersRoute: AdminUsersRoute,
AdminIndexRoute: AdminIndexRoute,
}
const AdminRouteWithChildren = AdminRoute._addFileChildren(AdminRouteChildren)
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
AdminRoute: AdminRouteWithChildren,
DashboardRoute: DashboardRoute,
InstructionsRoute: InstructionsRoute,
LoginRoute: LoginRoute,
RegisterRoute: RegisterRoute,
SettingsRoute: SettingsRoute,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)
._addFileTypes<FileRouteTypes>()
+10
View File
@@ -0,0 +1,10 @@
import { createRouter } from '@tanstack/react-router'
import { routeTree } from './routeTree.gen'
export const router = createRouter({ routeTree, defaultPreload: 'intent' })
declare module '@tanstack/react-router' {
interface Register {
router: typeof router
}
}
+97
View File
@@ -0,0 +1,97 @@
import { useEffect } from 'react'
import { Link, Outlet, createRootRoute } from '@tanstack/react-router'
import { useTranslation } from 'react-i18next'
import { useTheme, type Theme } from '@/theme/ThemeProvider'
import { setLanguage } from '@/shared/lib/i18n'
import { Toaster } from '@/shared/ui/toaster'
import { Button } from '@/shared/ui/button'
import { useAuthStore } from '@/features/auth/store'
import { bootstrapSession, clearSession, logout } from '@/features/auth/api'
export const Route = createRootRoute({ component: RootLayout })
function RootLayout() {
const { t, i18n } = useTranslation()
const { theme, setTheme } = useTheme()
const { user, isBootstrapping } = useAuthStore()
useEffect(() => {
void bootstrapSession()
}, [])
const themes: Theme[] = ['light', 'dark', 'system']
const langs = ['ru', 'en']
const handleLogout = async () => {
try {
await logout()
} finally {
clearSession()
}
}
return (
<div className="flex min-h-svh flex-col">
<header className="flex items-center justify-between border-b border-border px-6 py-4">
<Link to="/" className="text-lg font-semibold text-primary">
{t('appName')}
</Link>
<nav className="flex items-center gap-4 text-sm">
{!isBootstrapping && user && (
<>
<Link to="/dashboard" className="text-muted-foreground hover:text-foreground">
{t('nav.dashboard')}
</Link>
<Link to="/instructions" className="text-muted-foreground hover:text-foreground">
{t('nav.instructions')}
</Link>
<Link to="/settings" className="text-muted-foreground hover:text-foreground">
{t('nav.settings')}
</Link>
{user.role === 'admin' && (
<Link to="/admin" className="text-muted-foreground hover:text-foreground">
{t('nav.admin')}
</Link>
)}
<Button variant="ghost" size="sm" onClick={handleLogout}>
{t('nav.logout')}
</Button>
</>
)}
<label className="flex items-center gap-2">
<select
className="rounded-md border border-border bg-muted px-2 py-1"
value={i18n.language}
onChange={(e) => setLanguage(e.target.value)}
>
{langs.map((l) => (
<option key={l} value={l}>
{l.toUpperCase()}
</option>
))}
</select>
</label>
<label className="flex items-center gap-2">
<select
className="rounded-md border border-border bg-muted px-2 py-1"
value={theme}
onChange={(e) => setTheme(e.target.value as Theme)}
>
{themes.map((th) => (
<option key={th} value={th}>
{t(th)}
</option>
))}
</select>
</label>
</nav>
</header>
<main className="flex flex-1 flex-col">
<Outlet />
</main>
<Toaster />
</div>
)
}
+43
View File
@@ -0,0 +1,43 @@
import { createFileRoute, Link, Outlet } from '@tanstack/react-router'
import { useTranslation } from 'react-i18next'
import { useRequireAdmin } from '@/features/auth/guards'
import { cn } from '@/shared/lib/cn'
export const Route = createFileRoute('/admin')({ component: AdminLayout })
const TABS = [
{ to: '/admin', key: 'overview' },
{ to: '/admin/activation', key: 'activation' },
{ to: '/admin/users', key: 'users' },
{ to: '/admin/roles', key: 'roles' },
{ to: '/admin/nodes', key: 'nodes' },
{ to: '/admin/apps', key: 'apps' },
{ to: '/admin/audit', key: 'audit' },
] as const
function AdminLayout() {
const { t } = useTranslation()
const { isReady } = useRequireAdmin()
if (!isReady) return null
return (
<div className="mx-auto flex w-full max-w-5xl flex-col gap-6 px-6 py-10">
<h1 className="text-2xl font-semibold tracking-tight">{t('nav.admin')}</h1>
<nav className="flex gap-1 border-b border-border">
{TABS.map((tab) => (
<Link
key={tab.to}
to={tab.to}
activeOptions={{ exact: tab.to === '/admin' }}
className={cn('px-3 py-2 text-sm text-muted-foreground hover:text-foreground')}
activeProps={{ className: 'border-b-2 border-primary text-foreground font-medium' }}
>
{t(`admin.tabs.${tab.key}`)}
</Link>
))}
</nav>
<Outlet />
</div>
)
}
+93
View File
@@ -0,0 +1,93 @@
import { useState } from 'react'
import { createFileRoute } from '@tanstack/react-router'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { toast } from '@/shared/ui/toast-store'
import { Card, CardContent, CardHeader, CardTitle } from '@/shared/ui/card'
import { Button } from '@/shared/ui/button'
import { approveActivationRequest, listActivationRequests, rejectActivationRequest } from '@/features/admin/activation/api'
export const Route = createFileRoute('/admin/activation')({ component: AdminActivationPage })
function AdminActivationPage() {
const { t } = useTranslation()
const queryClient = useQueryClient()
const [page, setPage] = useState(1)
const { data, isLoading, isError, refetch } = useQuery({
queryKey: ['admin-activation-requests', page],
queryFn: () => listActivationRequests('Pending', page, 20),
})
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['admin-activation-requests'] })
const approveMutation = useMutation({
mutationFn: approveActivationRequest,
onSuccess: async () => {
toast.success(t('admin.activation.approved'))
await invalidate()
},
onError: () => toast.error(t('auth.genericError')),
})
const rejectMutation = useMutation({
mutationFn: (id: string) => rejectActivationRequest(id, undefined),
onSuccess: async () => {
toast.success(t('admin.activation.rejected'))
await invalidate()
},
onError: () => toast.error(t('auth.genericError')),
})
if (isLoading) return <p className="text-sm text-muted-foreground"></p>
if (isError || !data) {
return (
<div className="flex items-center gap-2">
<p className="text-sm text-muted-foreground">{t('auth.genericError')}</p>
<Button variant="outline" size="sm" onClick={() => void refetch()}>
{t('activation.retry')}
</Button>
</div>
)
}
if (data.items.length === 0) {
return <p className="text-sm text-muted-foreground">{t('admin.activation.empty')}</p>
}
return (
<div className="flex flex-col gap-4">
{data.items.map((request) => (
<Card key={request.id}>
<CardHeader>
<CardTitle className="text-base">{request.userName}</CardTitle>
{request.comment && <p className="text-sm text-muted-foreground">{request.comment}</p>}
</CardHeader>
<CardContent className="flex gap-2">
<Button size="sm" disabled={approveMutation.isPending} onClick={() => approveMutation.mutate(request.id)}>
{t('admin.activation.approve')}
</Button>
<Button
size="sm"
variant="outline"
disabled={rejectMutation.isPending}
onClick={() => rejectMutation.mutate(request.id)}
>
{t('admin.activation.reject')}
</Button>
</CardContent>
</Card>
))}
<div className="flex justify-end gap-2 text-sm">
<Button variant="outline" size="sm" disabled={page <= 1} onClick={() => setPage((p) => p - 1)}>
{t('admin.prev')}
</Button>
<Button variant="outline" size="sm" disabled={page * 20 >= data.total} onClick={() => setPage((p) => p + 1)}>
{t('admin.next')}
</Button>
</div>
</div>
)
}
+89
View File
@@ -0,0 +1,89 @@
import { useState } from 'react'
import { createFileRoute } from '@tanstack/react-router'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { toast } from '@/shared/ui/toast-store'
import { Button } from '@/shared/ui/button'
import { Badge } from '@/shared/ui/badge'
import { listAdminApps, deleteApp } from '@/features/admin/apps/api'
import { AppFormDialog } from '@/features/admin/apps/AppFormDialog'
import type { AdminAppDto, OsPlatform } from '@/shared/api/types'
export const Route = createFileRoute('/admin/apps')({ component: AdminAppsPage })
const OS_ORDER: OsPlatform[] = ['IOS', 'Android', 'Windows', 'MacOS', 'Linux']
function AdminAppsPage() {
const { t } = useTranslation()
const queryClient = useQueryClient()
const [editing, setEditing] = useState<AdminAppDto | null>(null)
const { data, isLoading, isError, refetch } = useQuery({ queryKey: ['admin-apps'], queryFn: listAdminApps })
const deleteMutation = useMutation({
mutationFn: deleteApp,
onSuccess: async () => {
toast.success(t('admin.apps.deleted'))
await queryClient.invalidateQueries({ queryKey: ['admin-apps'] })
},
onError: () => toast.error(t('auth.genericError')),
})
return (
<div className="flex flex-col gap-4">
<div className="flex justify-end">
<AppFormDialog />
</div>
{isLoading && <p className="text-sm text-muted-foreground"></p>}
{isError && (
<div className="flex items-center gap-2">
<p className="text-sm text-muted-foreground">{t('auth.genericError')}</p>
<Button variant="outline" size="sm" onClick={() => void refetch()}>
{t('activation.retry')}
</Button>
</div>
)}
{data?.length === 0 && <p className="text-sm text-muted-foreground">{t('admin.apps.empty')}</p>}
{data && (
<div className="flex flex-col gap-6">
{OS_ORDER.filter((os) => data.some((a) => a.operatingSystem === os)).map((os) => (
<div key={os} className="flex flex-col gap-2">
<h3 className="text-sm font-semibold text-muted-foreground">{t(`instructions.os.${os}`)}</h3>
{data
.filter((a) => a.operatingSystem === os)
.map((app) => (
<div key={app.id} className="flex items-center justify-between rounded-md border border-border px-3 py-2 text-sm">
<div className="flex items-center gap-2">
<span>{app.name}</span>
{!app.isEnabled && <Badge variant="outline">{t('admin.apps.disabled')}</Badge>}
</div>
<div className="flex gap-2">
<Button size="sm" variant="outline" onClick={() => setEditing(app)}>
{t('admin.roles.edit')}
</Button>
<Button
size="sm"
variant="ghost"
disabled={deleteMutation.isPending}
onClick={() => {
if (confirm(t('admin.apps.confirmDelete'))) deleteMutation.mutate(app.id)
}}
>
{t('admin.roles.delete')}
</Button>
</div>
</div>
))}
</div>
))}
</div>
)}
{editing && <AppFormDialog app={editing} open={!!editing} onOpenChange={(open) => !open && setEditing(null)} />}
</div>
)
}
+79
View File
@@ -0,0 +1,79 @@
import { useState } from 'react'
import { createFileRoute } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { Button } from '@/shared/ui/button'
import { Badge } from '@/shared/ui/badge'
import { listAuditLogs } from '@/features/admin/audit/api'
export const Route = createFileRoute('/admin/audit')({ component: AdminAuditPage })
const PAGE_SIZE = 50
function AdminAuditPage() {
const { t } = useTranslation()
const [page, setPage] = useState(1)
const { data, isLoading, isError, refetch } = useQuery({
queryKey: ['admin-audit', page],
queryFn: () => listAuditLogs(page, PAGE_SIZE),
})
return (
<div className="flex flex-col gap-4">
{isLoading && <p className="text-sm text-muted-foreground"></p>}
{isError && (
<div className="flex items-center gap-2">
<p className="text-sm text-muted-foreground">{t('auth.genericError')}</p>
<Button variant="outline" size="sm" onClick={() => void refetch()}>
{t('activation.retry')}
</Button>
</div>
)}
{data?.items.length === 0 && <p className="text-sm text-muted-foreground">{t('admin.audit.empty')}</p>}
{data && data.items.length > 0 && (
<>
<table className="w-full text-left text-sm">
<thead>
<tr className="border-b border-border text-muted-foreground">
<th className="py-2 font-medium">{t('admin.audit.time')}</th>
<th className="py-2 font-medium">{t('admin.audit.action')}</th>
<th className="py-2 font-medium">{t('admin.audit.target')}</th>
<th className="py-2 font-medium">{t('admin.audit.source')}</th>
</tr>
</thead>
<tbody>
{data.items.map((entry) => (
<tr key={entry.id} className="border-b border-border align-top">
<td className="whitespace-nowrap py-2 text-muted-foreground">{new Date(entry.createdAt).toLocaleString()}</td>
<td className="py-2">{entry.action}</td>
<td className="py-2 text-muted-foreground">
{entry.targetType} · {entry.targetId.slice(0, 8)}
</td>
<td className="py-2">
<Badge variant="outline">{entry.source}</Badge>
</td>
</tr>
))}
</tbody>
</table>
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">{t('admin.users.total', { count: data.total })}</span>
<div className="flex gap-2">
<Button variant="outline" size="sm" disabled={page <= 1} onClick={() => setPage((p) => p - 1)}>
{t('admin.prev')}
</Button>
<Button variant="outline" size="sm" disabled={page * PAGE_SIZE >= data.total} onClick={() => setPage((p) => p + 1)}>
{t('admin.next')}
</Button>
</div>
</div>
</>
)}
</div>
)
}
+51
View File
@@ -0,0 +1,51 @@
import { createFileRoute } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { Card, CardContent, CardHeader, CardTitle } from '@/shared/ui/card'
import { Button } from '@/shared/ui/button'
import { formatBytes } from '@/shared/lib/format'
import { getStats } from '@/features/admin/stats/api'
export const Route = createFileRoute('/admin/')({ component: AdminIndex })
function AdminIndex() {
const { t } = useTranslation()
const { data, isLoading, isError, refetch } = useQuery({ queryKey: ['admin-stats'], queryFn: getStats })
if (isLoading) return <p className="text-sm text-muted-foreground"></p>
if (isError || !data) {
return (
<div className="flex items-center gap-2">
<p className="text-sm text-muted-foreground">{t('auth.genericError')}</p>
<Button variant="outline" size="sm" onClick={() => void refetch()}>
{t('activation.retry')}
</Button>
</div>
)
}
const cards = [
{ label: t('admin.stats.totalUsers'), value: data.totalUsers },
{ label: t('admin.stats.activatedUsers'), value: data.activatedUsers },
{ label: t('admin.stats.pendingActivationRequests'), value: data.pendingActivationRequests },
{ label: t('admin.stats.totalNodes'), value: data.totalNodes },
{ label: t('admin.stats.onlineNodes'), value: data.onlineNodes },
{ label: t('admin.stats.totalConfigs'), value: data.totalConfigs },
{ label: t('admin.stats.activeConfigs'), value: data.activeConfigs },
{ label: t('admin.stats.totalTraffic'), value: formatBytes(data.totalUsedUpBytes + data.totalUsedDownBytes) },
]
return (
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
{cards.map((card) => (
<Card key={card.label}>
<CardHeader className="pb-2">
<CardTitle className="text-2xl">{card.value}</CardTitle>
</CardHeader>
<CardContent className="pt-0 text-sm text-muted-foreground">{card.label}</CardContent>
</Card>
))}
</div>
)
}
+37
View File
@@ -0,0 +1,37 @@
import { createFileRoute } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { Button } from '@/shared/ui/button'
import { listNodes } from '@/features/admin/nodes/api'
import { NodeCard } from '@/features/admin/nodes/NodeCard'
import { RegisterNodeDialog } from '@/features/admin/nodes/RegisterNodeDialog'
export const Route = createFileRoute('/admin/nodes')({ component: AdminNodesPage })
function AdminNodesPage() {
const { t } = useTranslation()
const { data, isLoading, isError, refetch } = useQuery({ queryKey: ['admin-nodes'], queryFn: listNodes })
return (
<div className="flex flex-col gap-4">
<div className="flex justify-end">
<RegisterNodeDialog />
</div>
{isLoading && <p className="text-sm text-muted-foreground"></p>}
{isError && (
<div className="flex items-center gap-2">
<p className="text-sm text-muted-foreground">{t('auth.genericError')}</p>
<Button variant="outline" size="sm" onClick={() => void refetch()}>
{t('activation.retry')}
</Button>
</div>
)}
{data?.length === 0 && <p className="text-sm text-muted-foreground">{t('admin.nodes.empty')}</p>}
<div className="flex flex-col gap-3">{data?.map((node) => <NodeCard key={node.id} node={node} />)}</div>
</div>
)
}
+92
View File
@@ -0,0 +1,92 @@
import { useState } from 'react'
import { createFileRoute } from '@tanstack/react-router'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { toast } from '@/shared/ui/toast-store'
import { Button } from '@/shared/ui/button'
import { Badge } from '@/shared/ui/badge'
import { listRoles, deleteRole } from '@/features/admin/roles/api'
import { RoleFormDialog } from '@/features/admin/roles/RoleFormDialog'
import type { RoleDto } from '@/shared/api/types'
export const Route = createFileRoute('/admin/roles')({ component: AdminRolesPage })
function AdminRolesPage() {
const { t } = useTranslation()
const queryClient = useQueryClient()
const [editing, setEditing] = useState<RoleDto | null>(null)
const { data, isLoading, isError, refetch } = useQuery({ queryKey: ['admin-roles-page'], queryFn: listRoles })
const deleteMutation = useMutation({
mutationFn: deleteRole,
onSuccess: async () => {
toast.success(t('admin.roles.deleted'))
await queryClient.invalidateQueries({ queryKey: ['admin-roles-page'] })
},
onError: () => toast.error(t('auth.genericError')),
})
return (
<div className="flex flex-col gap-4">
<div className="flex justify-end">
<RoleFormDialog />
</div>
{isLoading && <p className="text-sm text-muted-foreground"></p>}
{isError && (
<div className="flex items-center gap-2">
<p className="text-sm text-muted-foreground">{t('auth.genericError')}</p>
<Button variant="outline" size="sm" onClick={() => void refetch()}>
{t('activation.retry')}
</Button>
</div>
)}
{data && (
<table className="w-full text-left text-sm">
<thead>
<tr className="border-b border-border text-muted-foreground">
<th className="py-2 font-medium">{t('admin.roles.name')}</th>
<th className="py-2 font-medium">{t('admin.roles.maxConfigs')}</th>
<th className="py-2" />
<th className="py-2" />
</tr>
</thead>
<tbody>
{data.map((role) => (
<tr key={role.id} className="border-b border-border">
<td className="py-2">
{role.name} {role.isSystem && <Badge variant="outline">{t('admin.roles.system')}</Badge>}
</td>
<td className="py-2">{role.maxConfigs < 0 ? t('configs.deviceLimitUnlimited') : role.maxConfigs}</td>
<td className="py-2 text-right">
<Button size="sm" variant="outline" onClick={() => setEditing(role)}>
{t('admin.roles.edit')}
</Button>
</td>
<td className="py-2 text-right">
{!role.isSystem && (
<Button
size="sm"
variant="ghost"
disabled={deleteMutation.isPending}
onClick={() => {
if (confirm(t('admin.roles.confirmDelete'))) deleteMutation.mutate(role.id)
}}
>
{t('admin.roles.delete')}
</Button>
)}
</td>
</tr>
))}
</tbody>
</table>
)}
{editing && <RoleFormDialog role={editing} open={!!editing} onOpenChange={(open) => !open && setEditing(null)} />}
</div>
)
}
+104
View File
@@ -0,0 +1,104 @@
import { useState } from 'react'
import { createFileRoute } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { Input } from '@/shared/ui/input'
import { Button } from '@/shared/ui/button'
import { Badge } from '@/shared/ui/badge'
import { listUsers } from '@/features/admin/users/api'
import { UserManageDialog } from '@/features/admin/users/UserManageDialog'
import type { UserSummaryDto } from '@/shared/api/types'
export const Route = createFileRoute('/admin/users')({ component: AdminUsersPage })
const PAGE_SIZE = 20
function AdminUsersPage() {
const { t } = useTranslation()
const [search, setSearch] = useState('')
const [page, setPage] = useState(1)
const [managing, setManaging] = useState<UserSummaryDto | null>(null)
const { data, isLoading, isError, refetch } = useQuery({
queryKey: ['admin-users', page, search],
queryFn: () => listUsers(page, PAGE_SIZE, search || undefined),
})
return (
<div className="flex flex-col gap-4">
<Input
placeholder={t('admin.users.searchPlaceholder')}
value={search}
onChange={(e) => {
setSearch(e.target.value)
setPage(1)
}}
className="max-w-sm"
/>
{isLoading && <p className="text-sm text-muted-foreground"></p>}
{isError && (
<div className="flex items-center gap-2">
<p className="text-sm text-muted-foreground">{t('auth.genericError')}</p>
<Button variant="outline" size="sm" onClick={() => void refetch()}>
{t('activation.retry')}
</Button>
</div>
)}
{data && (
<>
<table className="w-full text-left text-sm">
<thead>
<tr className="border-b border-border text-muted-foreground">
<th className="py-2 font-medium">{t('admin.users.userName')}</th>
<th className="py-2 font-medium">{t('admin.users.role')}</th>
<th className="py-2 font-medium">{t('admin.users.statusLabel')}</th>
<th className="py-2" />
</tr>
</thead>
<tbody>
{data.items.map((user) => (
<tr key={user.id} className="border-b border-border">
<td className="py-2">{user.userName}</td>
<td className="py-2">{user.role}</td>
<td className="py-2">
<Badge variant={user.isBlocked ? 'destructive' : user.isActivated ? 'success' : 'warning'}>
{user.isBlocked
? t('admin.users.status.blocked')
: user.isActivated
? t('admin.users.status.active')
: t('admin.users.status.pending')}
</Badge>
</td>
<td className="py-2 text-right">
<Button size="sm" variant="outline" onClick={() => setManaging(user)}>
{t('admin.users.manage')}
</Button>
</td>
</tr>
))}
</tbody>
</table>
{data.items.length === 0 && <p className="text-sm text-muted-foreground">{t('admin.users.empty')}</p>}
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">{t('admin.users.total', { count: data.total })}</span>
<div className="flex gap-2">
<Button variant="outline" size="sm" disabled={page <= 1} onClick={() => setPage((p) => p - 1)}>
{t('admin.prev')}
</Button>
<Button variant="outline" size="sm" disabled={page * PAGE_SIZE >= data.total} onClick={() => setPage((p) => p + 1)}>
{t('admin.next')}
</Button>
</div>
</div>
</>
)}
{managing && <UserManageDialog user={managing} open={!!managing} onOpenChange={(open) => !open && setManaging(null)} />}
</div>
)
}
+58
View File
@@ -0,0 +1,58 @@
import { createFileRoute } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { useRequireAuth } from '@/features/auth/guards'
import { ActivationGate } from '@/features/activation/ActivationGate'
import { ConfigCard } from '@/features/configs/ConfigCard'
import { CreateConfigDialog } from '@/features/configs/CreateConfigDialog'
import { SubscriptionCard } from '@/features/configs/SubscriptionCard'
import { getMyConfigs } from '@/features/configs/api'
export const Route = createFileRoute('/dashboard')({ component: DashboardPage })
function DashboardPage() {
const { isReady } = useRequireAuth()
if (!isReady) return null
return (
<ActivationGate>
<ConfigsList />
</ActivationGate>
)
}
function ConfigsList() {
const { t } = useTranslation()
const { data, isLoading } = useQuery({ queryKey: ['my-configs'], queryFn: getMyConfigs })
return (
<div className="mx-auto flex w-full max-w-4xl flex-col gap-6 px-6 py-10">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold tracking-tight">{t('configs.title')}</h1>
{data && (
<p className="text-sm text-muted-foreground">
{data.maxConfigs < 0
? t('configs.quotaUnlimited', { used: data.configs.length })
: t('configs.quota', { used: data.configs.length, max: data.maxConfigs })}
</p>
)}
</div>
<CreateConfigDialog />
</div>
{!isLoading && data && data.configs.length > 0 && <SubscriptionCard />}
{isLoading && <p className="text-sm text-muted-foreground"></p>}
{!isLoading && data && data.configs.length === 0 && (
<p className="text-sm text-muted-foreground">{t('configs.empty')}</p>
)}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
{data?.configs.map((config) => <ConfigCard key={config.id} config={config} />)}
</div>
</div>
)
}
+18
View File
@@ -0,0 +1,18 @@
import { createFileRoute } from '@tanstack/react-router'
import { useEffect } from 'react'
import { useNavigate } from '@tanstack/react-router'
import { useAuthStore } from '@/features/auth/store'
export const Route = createFileRoute('/')({ component: IndexRedirect })
function IndexRedirect() {
const { user, isBootstrapping } = useAuthStore()
const navigate = useNavigate()
useEffect(() => {
if (isBootstrapping) return
void navigate({ to: user ? '/dashboard' : '/login', replace: true })
}, [isBootstrapping, user, navigate])
return null
}
+33
View File
@@ -0,0 +1,33 @@
import { createFileRoute } from '@tanstack/react-router'
import { useTranslation } from 'react-i18next'
import { useRequireAuth } from '@/features/auth/guards'
import { AppsCatalog } from '@/features/apps/AppsCatalog'
export const Route = createFileRoute('/instructions')({ component: InstructionsPage })
function InstructionsPage() {
const { t } = useTranslation()
const { isReady } = useRequireAuth()
if (!isReady) return null
return (
<div className="mx-auto flex w-full max-w-4xl flex-col gap-8 px-6 py-10">
<div>
<h1 className="text-2xl font-semibold tracking-tight">{t('instructions.title')}</h1>
<p className="mt-1 text-sm text-muted-foreground">{t('instructions.intro')}</p>
</div>
<ol className="flex flex-col gap-2 text-sm">
<li>1. {t('instructions.step1')}</li>
<li>2. {t('instructions.step2')}</li>
<li>3. {t('instructions.step3')}</li>
</ol>
<div>
<h2 className="mb-3 text-lg font-semibold tracking-tight">{t('instructions.appsTitle')}</h2>
<AppsCatalog />
</div>
</div>
)
}
+39
View File
@@ -0,0 +1,39 @@
import { createFileRoute, Link, useNavigate } from '@tanstack/react-router'
import { useTranslation } from 'react-i18next'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/shared/ui/card'
import { LoginForm } from '@/features/auth/LoginForm'
import { useRequireGuest } from '@/features/auth/guards'
import { TelegramLoginButton } from '@/features/telegram/TelegramLoginButton'
export const Route = createFileRoute('/login')({ component: LoginPage })
function LoginPage() {
const { t } = useTranslation()
const navigate = useNavigate()
useRequireGuest()
return (
<div className="flex flex-1 items-center justify-center px-6 py-16">
<Card className="w-full max-w-sm">
<CardHeader>
<CardTitle>{t('auth.loginTitle')}</CardTitle>
<CardDescription>
{t('auth.noAccount')}{' '}
<Link to="/register" className="text-primary hover:underline">
{t('auth.goRegister')}
</Link>
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<LoginForm onSuccess={() => void navigate({ to: '/dashboard' })} />
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<div className="h-px flex-1 bg-border" />
{t('auth.or')}
<div className="h-px flex-1 bg-border" />
</div>
<TelegramLoginButton />
</CardContent>
</Card>
</div>
)
}
+32
View File
@@ -0,0 +1,32 @@
import { createFileRoute, Link, useNavigate } from '@tanstack/react-router'
import { useTranslation } from 'react-i18next'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/shared/ui/card'
import { RegisterForm } from '@/features/auth/RegisterForm'
import { useRequireGuest } from '@/features/auth/guards'
export const Route = createFileRoute('/register')({ component: RegisterPage })
function RegisterPage() {
const { t } = useTranslation()
const navigate = useNavigate()
useRequireGuest()
return (
<div className="flex flex-1 items-center justify-center px-6 py-16">
<Card className="w-full max-w-sm">
<CardHeader>
<CardTitle>{t('auth.registerTitle')}</CardTitle>
<CardDescription>
{t('auth.haveAccount')}{' '}
<Link to="/login" className="text-primary hover:underline">
{t('auth.goLogin')}
</Link>
</CardDescription>
</CardHeader>
<CardContent>
<RegisterForm onSuccess={() => void navigate({ to: '/dashboard' })} />
</CardContent>
</Card>
</div>
)
}
+24
View File
@@ -0,0 +1,24 @@
import { createFileRoute } from '@tanstack/react-router'
import { useTranslation } from 'react-i18next'
import { useRequireAuth } from '@/features/auth/guards'
import { ChangePasswordForm } from '@/features/settings/ChangePasswordForm'
import { TelegramLinkCard } from '@/features/settings/TelegramLinkCard'
import { DeleteAccountSection } from '@/features/settings/DeleteAccountSection'
export const Route = createFileRoute('/settings')({ component: SettingsPage })
function SettingsPage() {
const { t } = useTranslation()
const { isReady } = useRequireAuth()
if (!isReady) return null
return (
<div className="mx-auto flex w-full max-w-2xl flex-col gap-6 px-6 py-10">
<h1 className="text-2xl font-semibold tracking-tight">{t('nav.settings')}</h1>
<ChangePasswordForm />
<TelegramLinkCard />
<DeleteAccountSection />
</div>
)
}
+93
View File
@@ -0,0 +1,93 @@
import type { ApiError } from './types'
let accessToken: string | null = null
let refreshInFlight: Promise<boolean> | null = null
let onUnauthorized: (() => void) | null = null
export function setAccessToken(token: string | null) {
accessToken = token
}
export function getAccessToken() {
return accessToken
}
/** Вызывается, когда refresh-токен недействителен — обычно очищает стор авторизации и шлёт на /login. */
export function setUnauthorizedHandler(handler: (() => void) | null) {
onUnauthorized = handler
}
type RequestOptions = {
method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'
body?: unknown
/** Не пытаться освежить токен на 401 (используется самим refresh-запросом, чтобы не зациклиться). */
skipRefresh?: boolean
}
async function refreshAccessToken(): Promise<boolean> {
if (!refreshInFlight) {
refreshInFlight = (async () => {
try {
const response = await fetch('/api/auth/refresh', { method: 'POST', credentials: 'include' })
if (!response.ok) return false
const data = (await response.json()) as { accessToken: string }
setAccessToken(data.accessToken)
return true
} catch {
return false
} finally {
refreshInFlight = null
}
})()
}
return refreshInFlight
}
export class HttpError extends Error implements ApiError {
title: string
detail: string
status: number
constructor(problem: Partial<ApiError>, status: number) {
super(problem.detail ?? problem.title ?? `HTTP ${status}`)
this.title = problem.title ?? 'Error'
this.detail = problem.detail ?? this.message
this.status = status
}
}
async function parseError(response: Response): Promise<HttpError> {
try {
const problem = (await response.json()) as Partial<ApiError>
return new HttpError(problem, response.status)
} catch {
return new HttpError({ title: response.statusText }, response.status)
}
}
export async function apiRequest<T>(path: string, options: RequestOptions = {}): Promise<T> {
const headers: Record<string, string> = {}
if (accessToken) headers.Authorization = `Bearer ${accessToken}`
if (options.body !== undefined) headers['Content-Type'] = 'application/json'
const response = await fetch(`/api${path}`, {
method: options.method ?? 'GET',
headers,
credentials: 'include',
body: options.body !== undefined ? JSON.stringify(options.body) : undefined,
})
if (response.status === 401 && !options.skipRefresh) {
const refreshed = await refreshAccessToken()
if (refreshed) return apiRequest<T>(path, { ...options, skipRefresh: true })
onUnauthorized?.()
throw await parseError(response)
}
if (!response.ok) throw await parseError(response)
if (response.status === 204) return undefined as T
const text = await response.text()
return (text ? JSON.parse(text) : undefined) as T
}
File diff suppressed because it is too large Load Diff
+212
View File
@@ -0,0 +1,212 @@
// Типы вручную синхронизированы с DTO бэкенда (см. backend/src/PnvPanel.Application/**).
// TODO: заменить на `pnpm gen:api` (openapi-typescript), когда бэкенд доступен по сети
// (сейчас недоступен локально — Postgres/Docker не подняты, схему /openapi/v1.json взять негде).
export type ApiError = {
title: string
detail: string
status: number
}
export type VpnProtocol = 'Vless' | 'Vmess' | 'Trojan' | 'Shadowsocks'
export type ConfigStatus = 'Active' | 'Disabled' | 'Expired' | 'LimitReached' | 'Revoked'
export type NodeStatus = 'Unknown' | 'Online' | 'Offline'
export type ActivationStatus = 'Pending' | 'Approved' | 'Rejected'
export type OsPlatform = 'IOS' | 'Android' | 'Windows' | 'MacOS' | 'Linux'
export type TelegramLoginStatus = 'Pending' | 'Approved' | 'Rejected' | 'Expired' | 'Consumed'
export type CurrentUser = {
id: string
userName: string
role: string
isActivated: boolean
telegramLinked: boolean
}
export type AuthResponse = {
accessToken: string
expiresAt: string
user: CurrentUser
}
export type RegisterResponse = {
id: string
userName: string
}
export type ActivationRequestDto = {
id: string
comment: string | null
createdAt: string
}
export type ActivationStatusDto = {
isActivated: boolean
pendingRequest: ActivationRequestDto | null
}
export type VpnConfigDto = {
id: string
label: string | null
protocol: VpnProtocol
location: string
deviceLimit: number
usedUpBytes: number
usedDownBytes: number
expiresAt: string | null
status: ConfigStatus
createdAt: string
}
export type AvailableInboundDto = {
inboundId: string
displayName: string
protocol: VpnProtocol
}
export type ConfigLinkDto = {
connectionString: string
subscriptionUrl: string
}
export type MySubscriptionDto = {
subscriptionUrl: string
}
export type GetMyConfigsResult = {
configs: VpnConfigDto[]
maxConfigs: number
}
export type ClientAppDto = {
id: string
name: string
downloadUrl: string
description: string | null
iconUrl: string | null
}
/** GET /api/apps — сгруппировано по ОС на бэкенде; отсутствующая ОС значит "нет приложений". */
export type AppsByOs = Partial<Record<OsPlatform, ClientAppDto[]>>
export type LinkTokenResponse = {
deepLink: string | null
expiresAt: string
}
export type TelegramLoginRequestResponse = {
requestId: string
deepLink: string | null
expiresAt: string
}
export type TelegramLoginStatusResponse = {
status: TelegramLoginStatus
accessToken?: string
expiresAt?: string
user?: CurrentUser
}
export type PagedList<T> = {
items: T[]
total: number
page: number
pageSize: number
}
export type UserSummaryDto = {
id: string
userName: string
role: string
isActivated: boolean
isBlocked: boolean
activatedAt: string | null
}
export type RoleDto = {
id: string
name: string
maxConfigs: number
isSystem: boolean
}
export type ActivationRequestAdminDto = {
id: string
userId: string
userName: string
comment: string | null
status: ActivationStatus
createdAt: string
}
export type NodeDto = {
id: string
name: string
baseAddress: string
username: string
location: string | null
status: NodeStatus
isEnabled: boolean
lastSyncAt: string | null
}
export type NodeProbeResultDto = {
isReachable: boolean
errorMessage: string | null
status: NodeStatus
}
export type SyncNodeResultDto = {
inboundsSynced: number
status: NodeStatus
}
export type InboundDto = {
id: string
nodeId: string
remoteInboundId: string
protocol: VpnProtocol
remark: string
port: number
isPublished: boolean
displayName: string | null
maxClients: number | null
allowedRoleIds: string[]
lastSyncAt: string | null
}
export type AdminAppDto = {
id: string
name: string
downloadUrl: string
operatingSystem: OsPlatform
description: string | null
iconUrl: string | null
sortOrder: number
isEnabled: boolean
}
export type StatsDto = {
totalUsers: number
activatedUsers: number
pendingActivationRequests: number
totalNodes: number
onlineNodes: number
totalConfigs: number
activeConfigs: number
totalUsedUpBytes: number
totalUsedDownBytes: number
}
export type AuditSource = 'Web' | 'Telegram' | 'System'
export type AuditLogDto = {
id: number
actorId: string | null
action: string
targetType: string
targetId: string
metadata: string | null
source: AuditSource
createdAt: string
}
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
+8
View File
@@ -0,0 +1,8 @@
const UNITS = ['Б', 'КБ', 'МБ', 'ГБ', 'ТБ']
export function formatBytes(bytes: number): string {
if (bytes <= 0) return `0 ${UNITS[0]}`
const exponent = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), UNITS.length - 1)
const value = bytes / 1024 ** exponent
return `${value.toFixed(exponent === 0 ? 0 : 1)} ${UNITS[exponent]}`
}
+543
View File
@@ -0,0 +1,543 @@
import i18n from 'i18next'
import { initReactI18next } from 'react-i18next'
const resources = {
ru: {
translation: {
appName: 'PnvPanel',
tagline: 'Self-service портал для VPN-конфигураций',
theme: 'Тема',
language: 'Язык',
light: 'Светлая',
dark: 'Тёмная',
system: 'Системная',
auth: {
loginTitle: 'Вход',
registerTitle: 'Регистрация',
userName: 'Имя пользователя',
password: 'Пароль',
submitLogin: 'Войти',
submitRegister: 'Зарегистрироваться',
noAccount: 'Нет аккаунта?',
haveAccount: 'Уже есть аккаунт?',
goRegister: 'Зарегистрироваться',
goLogin: 'Войти',
loginViaTelegram: 'Войти через Telegram',
invalidCredentials: 'Неверное имя пользователя или пароль.',
duplicateUserName: 'Пользователь с таким именем уже существует.',
genericError: 'Что-то пошло не так. Попробуйте ещё раз.',
userNameHint: 'Латиница, цифры, «_», «.», «-», от 3 до 32 символов.',
passwordHint: 'Не менее 8 символов.',
or: 'или',
telegramBotNotConfigured: 'Telegram-бот не настроен администратором.',
waitingForConfirmation: 'Ожидание подтверждения в Telegram…',
telegramLoginRejected: 'Вход отклонён в Telegram.',
telegramLoginExpired: 'Время ожидания истекло, попробуйте снова.',
},
nav: {
dashboard: 'Мои конфиги',
instructions: 'Инструкции',
settings: 'Настройки',
admin: 'Админка',
logout: 'Выйти',
},
activation: {
title: 'Аккаунт не активирован',
description:
'Чтобы создавать конфиги, дождитесь активации администратором. Можно оставить комментарий к заявке.',
commentLabel: 'Комментарий (необязательно)',
submit: 'Запросить активацию',
pending: 'Заявка на активацию отправлена, ожидайте решения администратора.',
alreadyPending: 'У вас уже есть необработанная заявка на активацию.',
retry: 'Повторить',
},
configs: {
title: 'Мои конфиги',
quota: 'Использовано {{used}} из {{max}}',
quotaUnlimited: 'Использовано {{used}}, без лимита',
empty: 'У вас пока нет конфигов. Создайте первый.',
create: 'Создать конфиг',
selectLocation: 'Выберите локацию',
location: 'Локация',
label: 'Метка (необязательно)',
deviceLimitLabel: 'Лимит устройств (необязательно)',
deviceLimitPlaceholder: 'Без лимита',
noInboundsAvailable: 'Нет доступных локаций для вашей роли.',
created: 'Конфиг создан.',
quotaExceeded: 'Достигнут лимит конфигов для вашей роли.',
showLink: 'Ссылка / QR',
rotate: 'Перевыпустить',
rotated: 'Конфиг перевыпущен.',
revoked: 'Конфиг отозван.',
confirmRevoke: 'Отозвать этот конфиг? Действие необратимо.',
copied: 'Скопировано.',
copyLink: 'Скопировать ссылку',
loadingLink: 'Загрузка ссылки…',
subscriptionLink: 'Ссылка-подписка (для клиента):',
aggregatedSubscription: 'Общая подписка',
aggregatedSubscriptionHint: 'Одна ссылка/QR со всеми активными конфигами — удобно добавить один раз в клиент.',
deviceLimit: '{{count}} устройство',
deviceLimit_few: '{{count}} устройства',
deviceLimit_many: '{{count}} устройств',
deviceLimitUnlimited: 'Без лимита устройств',
status: {
Active: 'Активен',
Disabled: 'Отключён',
Expired: 'Истёк',
LimitReached: 'Лимит исчерпан',
Revoked: 'Отозван',
},
},
instructions: {
title: 'Инструкции по подключению',
intro: 'Как подключиться за три шага — на любом устройстве.',
step1: 'Установите приложение для вашей ОС из списка ниже.',
step2: 'На странице «Мои конфиги» скопируйте ссылку или откройте QR-код нужного конфига.',
step3: 'Импортируйте ссылку или отсканируйте QR в приложении — готово.',
appsTitle: 'Приложения',
noApps: 'Каталог приложений пока пуст.',
os: {
IOS: 'iOS',
Android: 'Android',
Windows: 'Windows',
MacOS: 'macOS',
Linux: 'Linux',
},
},
settings: {
changePassword: 'Сменить пароль',
currentPassword: 'Текущий пароль',
currentPasswordRequired: 'Введите текущий пароль.',
currentPasswordInvalid: 'Неверный текущий пароль.',
newPassword: 'Новый пароль',
passwordChanged: 'Пароль изменён.',
telegramHint: 'Привязка Telegram нужна для входа без пароля и восстановления доступа.',
telegramLinkedStatus: 'Привязан',
link: 'Привязать Telegram',
unlink: 'Отвязать',
confirmUnlink: 'Отвязать Telegram от аккаунта?',
telegramLinked: 'Telegram привязан.',
telegramUnlinked: 'Telegram отвязан.',
waitingForLink: 'Ожидание подтверждения в Telegram…',
deleteAccount: 'Удалить аккаунт',
deleteAccountHint: 'Отзовёт все конфиги и безвозвратно удалит аккаунт.',
confirmDelete: 'Вы уверены? Это действие необратимо.',
confirmDeleteYes: 'Да, удалить',
cancel: 'Отмена',
},
admin: {
prev: 'Назад',
next: 'Вперёд',
tabs: {
overview: 'Обзор',
activation: 'Запросы на активацию',
users: 'Пользователи',
roles: 'Роли',
nodes: 'Ноды',
apps: 'Приложения',
audit: 'Аудит',
},
users: {
searchPlaceholder: 'Поиск по имени пользователя',
userName: 'Имя пользователя',
role: 'Роль',
statusLabel: 'Статус',
manage: 'Управление',
empty: 'Пользователи не найдены.',
total: 'Всего: {{count}}',
status: {
blocked: 'Заблокирован',
active: 'Активен',
pending: 'Не активирован',
},
block: 'Заблокировать',
unblock: 'Разблокировать',
confirmBlock: 'Заблокировать пользователя? Все его конфиги будут отключены.',
blocked: 'Пользователь заблокирован.',
unblocked: 'Пользователь разблокирован.',
roleChanged: 'Роль изменена.',
resetPassword: 'Сбросить пароль',
reset: 'Сбросить',
passwordReset: 'Пароль сброшен.',
configs: 'Конфиги',
},
activation: {
empty: 'Нет ожидающих запросов на активацию.',
approved: 'Пользователь активирован.',
rejected: 'Запрос отклонён.',
approve: 'Активировать',
reject: 'Отклонить',
},
roles: {
create: 'Создать роль',
name: 'Название',
maxConfigs: 'Квота конфигов',
maxConfigsHint: '1 = без лимита.',
system: 'системная',
edit: 'Изменить',
save: 'Сохранить',
delete: 'Удалить',
confirmDelete: 'Удалить роль? Это действие необратимо.',
created: 'Роль создана.',
updated: 'Квота обновлена.',
deleted: 'Роль удалена.',
},
nodes: {
create: 'Добавить ноду',
name: 'Название',
baseAddress: 'Адрес панели',
username: 'Логин',
password: 'Пароль',
location: 'Локация',
empty: 'Ноды не добавлены.',
created: 'Нода добавлена.',
updated: 'Нода обновлена.',
deleted: 'Нода удалена.',
confirmDelete: 'Удалить ноду? Существующие конфиги на ней перестанут синхронизироваться.',
edit: 'Изменить',
delete: 'Удалить',
probe: 'Проверить',
sync: 'Синхронизировать',
probeSuccess: 'Нода доступна.',
probeFailure: 'Нода недоступна: {{message}}',
syncSuccess: 'Синхронизировано инбаундов: {{count}}',
status: {
Unknown: 'Неизвестно',
Online: 'Онлайн',
Offline: 'Офлайн',
},
enabled: 'Включена',
disabled: 'Отключена',
inbounds: 'Инбаунды',
noInbounds: 'Инбаунды не найдены — нажмите «Синхронизировать».',
publish: 'Публикация',
published: 'Опубликован',
unpublished: 'Не опубликован',
publishSaved: 'Настройки публикации сохранены.',
displayName: 'Отображаемое имя',
maxClients: 'Лимит клиентов (необязательно)',
allowedRoles: 'Доступно ролям',
isPublishedLabel: 'Опубликовать инбаунд',
optional: 'необязательно',
},
apps: {
create: 'Добавить приложение',
name: 'Название',
downloadUrl: 'Ссылка на скачивание',
os: 'ОС',
description: 'Описание',
iconUrl: 'Ссылка на иконку',
sortOrder: 'Порядок',
enabled: 'Включено',
disabled: 'отключено',
empty: 'Каталог приложений пуст.',
created: 'Приложение добавлено.',
updated: 'Приложение обновлено.',
deleted: 'Приложение удалено.',
confirmDelete: 'Удалить приложение из каталога?',
},
audit: {
time: 'Время',
action: 'Действие',
target: 'Объект',
source: 'Источник',
empty: 'Журнал аудита пуст.',
},
stats: {
totalUsers: 'Всего пользователей',
activatedUsers: 'Активировано',
pendingActivationRequests: 'Ожидают активации',
totalNodes: 'Всего нод',
onlineNodes: 'Нод онлайн',
totalConfigs: 'Всего конфигов',
activeConfigs: 'Активных конфигов',
totalTraffic: 'Суммарный трафик',
},
},
},
},
en: {
translation: {
appName: 'PnvPanel',
tagline: 'Self-service portal for VPN configurations',
theme: 'Theme',
language: 'Language',
light: 'Light',
dark: 'Dark',
system: 'System',
auth: {
loginTitle: 'Log in',
registerTitle: 'Register',
userName: 'Username',
password: 'Password',
submitLogin: 'Log in',
submitRegister: 'Register',
noAccount: "Don't have an account?",
haveAccount: 'Already have an account?',
goRegister: 'Register',
goLogin: 'Log in',
loginViaTelegram: 'Log in via Telegram',
invalidCredentials: 'Invalid username or password.',
duplicateUserName: 'A user with this name already exists.',
genericError: 'Something went wrong. Please try again.',
userNameHint: 'Latin letters, digits, "_", ".", "-", 3 to 32 characters.',
passwordHint: 'At least 8 characters.',
or: 'or',
telegramBotNotConfigured: 'The Telegram bot has not been configured by the administrator.',
waitingForConfirmation: 'Waiting for confirmation in Telegram…',
telegramLoginRejected: 'Login was rejected in Telegram.',
telegramLoginExpired: 'The request expired, please try again.',
},
nav: {
dashboard: 'My configs',
instructions: 'Instructions',
settings: 'Settings',
admin: 'Admin',
logout: 'Log out',
},
activation: {
title: 'Account not activated',
description:
'Wait for an administrator to activate your account before creating configs. You can leave a comment with your request.',
commentLabel: 'Comment (optional)',
submit: 'Request activation',
pending: 'Activation request sent, waiting for administrator review.',
alreadyPending: 'You already have a pending activation request.',
retry: 'Retry',
},
configs: {
title: 'My configs',
quota: 'Used {{used}} of {{max}}',
quotaUnlimited: 'Used {{used}}, unlimited',
empty: "You don't have any configs yet. Create your first one.",
create: 'Create config',
selectLocation: 'Select location',
location: 'Location',
label: 'Label (optional)',
deviceLimitLabel: 'Device limit (optional)',
deviceLimitPlaceholder: 'Unlimited',
noInboundsAvailable: 'No locations available for your role.',
created: 'Config created.',
quotaExceeded: 'Config quota reached for your role.',
showLink: 'Link / QR',
rotate: 'Rotate',
rotated: 'Config rotated.',
revoked: 'Config revoked.',
confirmRevoke: 'Revoke this config? This cannot be undone.',
copied: 'Copied.',
copyLink: 'Copy link',
loadingLink: 'Loading link…',
subscriptionLink: 'Subscription link (for the client app):',
aggregatedSubscription: 'Aggregated subscription',
aggregatedSubscriptionHint: 'One link/QR with all active configs — add it once to your client.',
deviceLimit: '{{count}} device',
deviceLimit_other: '{{count}} devices',
deviceLimitUnlimited: 'No device limit',
status: {
Active: 'Active',
Disabled: 'Disabled',
Expired: 'Expired',
LimitReached: 'Limit reached',
Revoked: 'Revoked',
},
},
instructions: {
title: 'Connection instructions',
intro: 'Get connected in three steps, on any device.',
step1: 'Install the app for your OS from the list below.',
step2: 'On the "My configs" page, copy the link or open the QR code for the config you want.',
step3: 'Import the link or scan the QR code in the app — done.',
appsTitle: 'Apps',
noApps: 'The app catalog is empty right now.',
os: {
IOS: 'iOS',
Android: 'Android',
Windows: 'Windows',
MacOS: 'macOS',
Linux: 'Linux',
},
},
settings: {
changePassword: 'Change password',
currentPassword: 'Current password',
currentPasswordRequired: 'Enter your current password.',
currentPasswordInvalid: 'Current password is incorrect.',
newPassword: 'New password',
passwordChanged: 'Password changed.',
telegramHint: 'Linking Telegram enables passwordless login and account recovery.',
telegramLinkedStatus: 'Linked',
link: 'Link Telegram',
unlink: 'Unlink',
confirmUnlink: 'Unlink Telegram from your account?',
telegramLinked: 'Telegram linked.',
telegramUnlinked: 'Telegram unlinked.',
waitingForLink: 'Waiting for confirmation in Telegram…',
deleteAccount: 'Delete account',
deleteAccountHint: 'Revokes all configs and permanently deletes your account.',
confirmDelete: 'Are you sure? This cannot be undone.',
confirmDeleteYes: 'Yes, delete',
cancel: 'Cancel',
},
admin: {
prev: 'Previous',
next: 'Next',
tabs: {
overview: 'Overview',
activation: 'Activation requests',
users: 'Users',
roles: 'Roles',
nodes: 'Nodes',
apps: 'Apps',
audit: 'Audit',
},
users: {
searchPlaceholder: 'Search by username',
userName: 'Username',
role: 'Role',
statusLabel: 'Status',
manage: 'Manage',
empty: 'No users found.',
total: 'Total: {{count}}',
status: {
blocked: 'Blocked',
active: 'Active',
pending: 'Not activated',
},
block: 'Block',
unblock: 'Unblock',
confirmBlock: 'Block this user? All their configs will be disabled.',
blocked: 'User blocked.',
unblocked: 'User unblocked.',
roleChanged: 'Role changed.',
resetPassword: 'Reset password',
reset: 'Reset',
passwordReset: 'Password reset.',
configs: 'Configs',
},
activation: {
empty: 'No pending activation requests.',
approved: 'User activated.',
rejected: 'Request rejected.',
approve: 'Approve',
reject: 'Reject',
},
roles: {
create: 'Create role',
name: 'Name',
maxConfigs: 'Config quota',
maxConfigsHint: '1 = unlimited.',
system: 'system',
edit: 'Edit',
save: 'Save',
delete: 'Delete',
confirmDelete: 'Delete this role? This cannot be undone.',
created: 'Role created.',
updated: 'Quota updated.',
deleted: 'Role deleted.',
},
nodes: {
create: 'Add node',
name: 'Name',
baseAddress: 'Panel address',
username: 'Username',
password: 'Password',
location: 'Location',
empty: 'No nodes added yet.',
created: 'Node added.',
updated: 'Node updated.',
deleted: 'Node deleted.',
confirmDelete: 'Delete this node? Existing configs on it will stop syncing.',
edit: 'Edit',
delete: 'Delete',
probe: 'Probe',
sync: 'Sync',
probeSuccess: 'Node is reachable.',
probeFailure: 'Node unreachable: {{message}}',
syncSuccess: 'Synced inbounds: {{count}}',
status: {
Unknown: 'Unknown',
Online: 'Online',
Offline: 'Offline',
},
enabled: 'Enabled',
disabled: 'Disabled',
inbounds: 'Inbounds',
noInbounds: 'No inbounds found — click "Sync".',
publish: 'Publishing',
published: 'Published',
unpublished: 'Not published',
publishSaved: 'Publishing settings saved.',
displayName: 'Display name',
maxClients: 'Client limit (optional)',
allowedRoles: 'Allowed for roles',
isPublishedLabel: 'Publish inbound',
optional: 'optional',
},
apps: {
create: 'Add app',
name: 'Name',
downloadUrl: 'Download link',
os: 'OS',
description: 'Description',
iconUrl: 'Icon URL',
sortOrder: 'Sort order',
enabled: 'Enabled',
disabled: 'disabled',
empty: 'The app catalog is empty.',
created: 'App added.',
updated: 'App updated.',
deleted: 'App deleted.',
confirmDelete: 'Remove this app from the catalog?',
},
audit: {
time: 'Time',
action: 'Action',
target: 'Target',
source: 'Source',
empty: 'The audit log is empty.',
},
stats: {
totalUsers: 'Total users',
activatedUsers: 'Activated',
pendingActivationRequests: 'Pending activation',
totalNodes: 'Total nodes',
onlineNodes: 'Nodes online',
totalConfigs: 'Total configs',
activeConfigs: 'Active configs',
totalTraffic: 'Total traffic',
},
},
},
},
}
const STORAGE_KEY = 'pnv-lang'
const stored = typeof localStorage !== 'undefined' ? localStorage.getItem(STORAGE_KEY) : null
void i18n.use(initReactI18next).init({
resources,
lng: stored ?? 'ru',
fallbackLng: 'ru',
interpolation: { escapeValue: false },
})
export function setLanguage(lng: string) {
localStorage.setItem(STORAGE_KEY, lng)
void i18n.changeLanguage(lng)
}
export default i18n
@@ -0,0 +1,66 @@
import { useEffect } from 'react'
import { useQueryClient } from '@tanstack/react-query'
import { useAuthStore } from '@/features/auth/store'
import type { ConfigStatus, GetMyConfigsResult } from '@/shared/api/types'
import { getConnection, startConnection, stopConnection } from './connection'
type ConfigTrafficUpdated = { configId: string; usedUpBytes: number; usedDownBytes: number }
type ConfigStatusChanged = { configId: string; status: ConfigStatus }
type UserActivated = { userId: string }
/** Живые обновления по SignalR: точечно патчит кэш TanStack Query вместо инвалидации всего списка. */
export function RealtimeProvider({ children }: { children: React.ReactNode }) {
const queryClient = useQueryClient()
const user = useAuthStore((s) => s.user)
useEffect(() => {
if (!user) {
void stopConnection()
return
}
const connection = getConnection()
const onTrafficUpdated = (payload: ConfigTrafficUpdated) => {
queryClient.setQueryData<GetMyConfigsResult>(['my-configs'], (prev) =>
prev
? {
...prev,
configs: prev.configs.map((c) =>
c.id === payload.configId
? { ...c, usedUpBytes: payload.usedUpBytes, usedDownBytes: payload.usedDownBytes }
: c,
),
}
: prev,
)
}
const onStatusChanged = (payload: ConfigStatusChanged) => {
queryClient.setQueryData<GetMyConfigsResult>(['my-configs'], (prev) =>
prev
? { ...prev, configs: prev.configs.map((c) => (c.id === payload.configId ? { ...c, status: payload.status } : c)) }
: prev,
)
}
const onUserActivated = (_payload: UserActivated) => {
void queryClient.invalidateQueries({ queryKey: ['activation-status'] })
void queryClient.invalidateQueries({ queryKey: ['me-poll'] })
}
connection.on('configTrafficUpdated', onTrafficUpdated)
connection.on('configStatusChanged', onStatusChanged)
connection.on('userActivated', onUserActivated)
void startConnection()
return () => {
connection.off('configTrafficUpdated', onTrafficUpdated)
connection.off('configStatusChanged', onStatusChanged)
connection.off('userActivated', onUserActivated)
}
}, [user, queryClient])
return <>{children}</>
}
@@ -0,0 +1,33 @@
import { HubConnectionBuilder, LogLevel, type HubConnection } from '@microsoft/signalr'
import { getAccessToken } from '@/shared/api/client'
let connection: HubConnection | null = null
function createConnection(): HubConnection {
return new HubConnectionBuilder()
.withUrl('/hubs/panel', { accessTokenFactory: () => getAccessToken() ?? '' })
.withAutomaticReconnect()
.configureLogging(LogLevel.Warning)
.build()
}
export function getConnection(): HubConnection {
connection ??= createConnection()
return connection
}
export async function startConnection() {
const conn = getConnection()
if (conn.state !== 'Disconnected') return
try {
await conn.start()
} catch {
// Автопереподключение (withAutomaticReconnect) не запускается после неудачного первого
// start() — это ожидаемо при недоступном бэкенде, UI продолжает работать через обычный REST.
}
}
export async function stopConnection() {
if (!connection) return
await connection.stop()
}
+22
View File
@@ -0,0 +1,22 @@
import { cva, type VariantProps } from 'class-variance-authority'
import { type HTMLAttributes } from 'react'
import { cn } from '@/shared/lib/cn'
const badgeVariants = cva('inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-medium', {
variants: {
variant: {
default: 'border-transparent bg-primary text-primary-foreground',
outline: 'border-border text-foreground',
success: 'border-transparent bg-emerald-900/50 text-emerald-300',
warning: 'border-transparent bg-amber-900/50 text-amber-300',
destructive: 'border-transparent bg-red-900/50 text-red-300',
},
},
defaultVariants: { variant: 'default' },
})
export type BadgeProps = HTMLAttributes<HTMLSpanElement> & VariantProps<typeof badgeVariants>
export function Badge({ className, variant, ...props }: BadgeProps) {
return <span className={cn(badgeVariants({ variant }), className)} {...props} />
}
+37
View File
@@ -0,0 +1,37 @@
import { Slot } from '@radix-ui/react-slot'
import { cva, type VariantProps } from 'class-variance-authority'
import { type ButtonHTMLAttributes, forwardRef } from 'react'
import { cn } from '@/shared/lib/cn'
export const buttonVariants = cva(
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors disabled:pointer-events-none disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50',
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground hover:opacity-90',
outline: 'border border-border bg-transparent hover:bg-muted',
ghost: 'hover:bg-muted',
destructive: 'bg-red-600 text-white hover:bg-red-700',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default: 'h-10 px-4 py-2',
sm: 'h-9 rounded-md px-3',
lg: 'h-11 rounded-md px-8',
icon: 'h-10 w-10',
},
},
defaultVariants: { variant: 'default', size: 'default' },
},
)
export type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> &
VariantProps<typeof buttonVariants> & { asChild?: boolean }
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild, ...props }, ref) => {
const Comp = asChild ? Slot : 'button'
return <Comp className={cn(buttonVariants({ variant, size }), className)} ref={ref} {...props} />
},
)
Button.displayName = 'Button'
+34
View File
@@ -0,0 +1,34 @@
import { type HTMLAttributes, forwardRef } from 'react'
import { cn } from '@/shared/lib/cn'
export const Card = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(({ className, ...props }, ref) => (
<div ref={ref} className={cn('rounded-lg border border-border bg-background shadow-sm', className)} {...props} />
))
Card.displayName = 'Card'
export const CardHeader = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(({ className, ...props }, ref) => (
<div ref={ref} className={cn('flex flex-col gap-1.5 p-6', className)} {...props} />
))
CardHeader.displayName = 'CardHeader'
export const CardTitle = forwardRef<HTMLParagraphElement, HTMLAttributes<HTMLHeadingElement>>(
({ className, ...props }, ref) => (
<h3 ref={ref} className={cn('text-xl font-semibold tracking-tight', className)} {...props} />
),
)
CardTitle.displayName = 'CardTitle'
export const CardDescription = forwardRef<HTMLParagraphElement, HTMLAttributes<HTMLParagraphElement>>(
({ className, ...props }, ref) => <p ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} />,
)
CardDescription.displayName = 'CardDescription'
export const CardContent = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(({ className, ...props }, ref) => (
<div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
))
CardContent.displayName = 'CardContent'
export const CardFooter = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(({ className, ...props }, ref) => (
<div ref={ref} className={cn('flex items-center p-6 pt-0', className)} {...props} />
))
CardFooter.displayName = 'CardFooter'
+33
View File
@@ -0,0 +1,33 @@
import * as DialogPrimitive from '@radix-ui/react-dialog'
import { X } from 'lucide-react'
import { cn } from '@/shared/lib/cn'
export const Dialog = DialogPrimitive.Root
export const DialogTrigger = DialogPrimitive.Trigger
export function DialogContent({ className, children, ...props }: DialogPrimitive.DialogContentProps) {
return (
<DialogPrimitive.Portal>
<DialogPrimitive.Overlay className="fixed inset-0 z-50 bg-black/60" />
<DialogPrimitive.Content
className={cn(
'fixed left-1/2 top-1/2 z-50 w-full max-w-md -translate-x-1/2 -translate-y-1/2 rounded-lg border border-border bg-background p-6 shadow-lg',
className,
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 text-muted-foreground hover:text-foreground">
<X className="h-4 w-4" />
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPrimitive.Portal>
)
}
export function DialogHeader({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return <div className={cn('mb-4 flex flex-col gap-1', className)} {...props} />
}
export const DialogTitle = DialogPrimitive.Title
export const DialogDescription = DialogPrimitive.Description
+17
View File
@@ -0,0 +1,17 @@
import { type InputHTMLAttributes, forwardRef } from 'react'
import { cn } from '@/shared/lib/cn'
export const Input = forwardRef<HTMLInputElement, InputHTMLAttributes<HTMLInputElement>>(
({ className, type, ...props }, ref) => (
<input
type={type}
className={cn(
'flex h-10 w-full rounded-md border border-border bg-transparent px-3 py-2 text-sm outline-none transition-colors placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-primary/50 disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
ref={ref}
{...props}
/>
),
)
Input.displayName = 'Input'
+15
View File
@@ -0,0 +1,15 @@
import * as LabelPrimitive from '@radix-ui/react-label'
import { forwardRef } from 'react'
import { cn } from '@/shared/lib/cn'
export const Label = forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn('text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70', className)}
{...props}
/>
))
Label.displayName = LabelPrimitive.Root.displayName
+10
View File
@@ -0,0 +1,10 @@
import { cn } from '@/shared/lib/cn'
export function Progress({ value, className }: { value: number; className?: string }) {
const clamped = Math.min(100, Math.max(0, value))
return (
<div className={cn('h-2 w-full overflow-hidden rounded-full bg-muted', className)}>
<div className="h-full rounded-full bg-primary transition-all" style={{ width: `${clamped}%` }} />
</div>
)
}

Some files were not shown because too many files have changed in this diff Show More