Implement rate limiting and enhance authentication flow
- Added rate limiting configuration for authentication endpoints, allowing customizable request limits via environment variables. - Updated authentication flow to utilize HttpRequest for cookie management, ensuring secure handling of refresh tokens. - Introduced a new endpoint to retrieve user subscription details. - Enhanced the handling of Telegram bot token validation to prevent errors with empty tokens. - Updated the application to serialize enums as strings for better documentation and compatibility with TypeScript. - Improved test coverage for new features and adjustments in command handlers.
This commit is contained in:
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"version": "0.0.1",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"name": "frontend",
|
||||||
|
"runtimeExecutable": "pnpm",
|
||||||
|
"runtimeArgs": ["--dir", "frontend", "dev"],
|
||||||
|
"port": 5173
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -33,6 +33,10 @@ AdminSeed__TelegramUserIds=123456789
|
|||||||
# Квота конфигов для системной роли "user" (выдаётся при регистрации).
|
# Квота конфигов для системной роли "user" (выдаётся при регистрации).
|
||||||
Roles__DefaultUserMaxConfigs=3
|
Roles__DefaultUserMaxConfigs=3
|
||||||
|
|
||||||
|
# ── Rate limiting ────────────────────────────────────────────────────────
|
||||||
|
# Лимит запросов/мин на auth-эндпоинты (login/register/refresh/telegram/subscription). По умолчанию 20.
|
||||||
|
# RateLimiting__AuthPermitLimit=20
|
||||||
|
|
||||||
# ── Telegram-бот ──────────────────────────────────────────────────────────
|
# ── Telegram-бот ──────────────────────────────────────────────────────────
|
||||||
# Если BotToken пуст — бот не стартует, панель работает без него.
|
# Если BotToken пуст — бот не стартует, панель работает без него.
|
||||||
Telegram__BotToken=
|
Telegram__BotToken=
|
||||||
|
|||||||
@@ -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();
|
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);
|
var result = await sender.Send(command, cancellationToken);
|
||||||
if (!result.IsSuccess)
|
if (!result.IsSuccess)
|
||||||
return result.ToHttpResult();
|
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));
|
return Results.Ok(ToLoginResponse(result.Value));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,11 +56,11 @@ public static class AuthEndpoints
|
|||||||
var result = await sender.Send(new RefreshCommand(rawToken), cancellationToken);
|
var result = await sender.Send(new RefreshCommand(rawToken), cancellationToken);
|
||||||
if (!result.IsSuccess)
|
if (!result.IsSuccess)
|
||||||
{
|
{
|
||||||
response.Cookies.Delete(RefreshCookieName, BuildCookieOptions());
|
response.Cookies.Delete(RefreshCookieName, BuildCookieOptions(request));
|
||||||
return result.ToHttpResult();
|
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));
|
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))
|
if (request.Cookies.TryGetValue(RefreshCookieName, out var rawToken) && !string.IsNullOrEmpty(rawToken))
|
||||||
await sender.Send(new LogoutCommand(rawToken), cancellationToken);
|
await sender.Send(new LogoutCommand(rawToken), cancellationToken);
|
||||||
|
|
||||||
response.Cookies.Delete(RefreshCookieName, BuildCookieOptions());
|
response.Cookies.Delete(RefreshCookieName, BuildCookieOptions(request));
|
||||||
return Results.NoContent();
|
return Results.NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,10 +85,10 @@ public static class AuthEndpoints
|
|||||||
return result.ToHttpResult();
|
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);
|
var result = await sender.Send(new DeleteMyAccountCommand(), cancellationToken);
|
||||||
response.Cookies.Delete(RefreshCookieName, BuildCookieOptions());
|
response.Cookies.Delete(RefreshCookieName, BuildCookieOptions(request));
|
||||||
return result.ToHttpResult();
|
return result.ToHttpResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,17 +99,19 @@ public static class AuthEndpoints
|
|||||||
user = auth.User,
|
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;
|
options.Expires = expiresAt;
|
||||||
response.Cookies.Append(RefreshCookieName, rawToken, options);
|
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,
|
HttpOnly = true,
|
||||||
Secure = true,
|
Secure = request.IsHttps,
|
||||||
SameSite = SameSiteMode.Strict,
|
SameSite = SameSiteMode.Strict,
|
||||||
Path = "/api/auth",
|
Path = "/api/auth",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ using PnvPanel.Application.Configs.Create;
|
|||||||
using PnvPanel.Application.Configs.Edit;
|
using PnvPanel.Application.Configs.Edit;
|
||||||
using PnvPanel.Application.Configs.GetConfigLink;
|
using PnvPanel.Application.Configs.GetConfigLink;
|
||||||
using PnvPanel.Application.Configs.GetMyConfigs;
|
using PnvPanel.Application.Configs.GetMyConfigs;
|
||||||
|
using PnvPanel.Application.Configs.GetMySubscription;
|
||||||
using PnvPanel.Application.Configs.ListAvailableInbounds;
|
using PnvPanel.Application.Configs.ListAvailableInbounds;
|
||||||
using PnvPanel.Application.Configs.Revoke;
|
using PnvPanel.Application.Configs.Revoke;
|
||||||
using PnvPanel.Application.Configs.Rotate;
|
using PnvPanel.Application.Configs.Rotate;
|
||||||
@@ -23,6 +24,7 @@ public static class ConfigEndpoints
|
|||||||
group.MapPost("/configs/{id:guid}/rotate", RotateConfig);
|
group.MapPost("/configs/{id:guid}/rotate", RotateConfig);
|
||||||
group.MapDelete("/configs/{id:guid}", RevokeConfig);
|
group.MapDelete("/configs/{id:guid}", RevokeConfig);
|
||||||
group.MapGet("/configs/{id:guid}/link", GetConfigLink);
|
group.MapGet("/configs/{id:guid}/link", GetConfigLink);
|
||||||
|
group.MapGet("/subscription", GetMySubscription);
|
||||||
|
|
||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
@@ -74,6 +76,16 @@ public static class ConfigEndpoints
|
|||||||
var subscriptionUrl = $"{request.Scheme}://{request.Host}/sub/{result.Value.SubscriptionToken}";
|
var subscriptionUrl = $"{request.Scheme}://{request.Host}/sub/{result.Value.SubscriptionToken}";
|
||||||
return Results.Ok(new { connectionString = result.Value.ConnectionString, subscriptionUrl });
|
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);
|
public sealed record CreateConfigBody(Guid InboundId, string? Label, int? DeviceLimit);
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using System.Text.Json.Serialization;
|
||||||
using Microsoft.AspNetCore.HttpOverrides;
|
using Microsoft.AspNetCore.HttpOverrides;
|
||||||
using Microsoft.AspNetCore.RateLimiting;
|
using Microsoft.AspNetCore.RateLimiting;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
@@ -40,11 +41,17 @@ builder.Services.AddSignalR();
|
|||||||
builder.Services.AddSingleton<IRealtimeNotifier, SignalRRealtimeNotifier>();
|
builder.Services.AddSingleton<IRealtimeNotifier, SignalRRealtimeNotifier>();
|
||||||
|
|
||||||
// Telegram-бот: presentation-адаптер, хостится в процессе Api (long polling). Клиент регистрируем
|
// Telegram-бот: presentation-адаптер, хостится в процессе Api (long polling). Клиент регистрируем
|
||||||
// всегда (даже с пустым токеном) — TelegramBotHostedService сам решает не стартовать без токена.
|
// всегда (даже с пустым токеном) — TelegramBotHostedService сам решает не стартовать без токена,
|
||||||
|
// а TelegramNotifier — не слать сообщения. TelegramBotClient(...) при этом валидирует формат токена
|
||||||
|
// и падает на пустой строке, поэтому при пустом BotToken подставляем синтаксически валидную заглушку —
|
||||||
|
// реальный HTTP-вызов через неё никогда не происходит (все вызывающие места сами проверяют BotToken).
|
||||||
builder.Services.AddSingleton<ITelegramBotClient>(sp =>
|
builder.Services.AddSingleton<ITelegramBotClient>(sp =>
|
||||||
{
|
{
|
||||||
var options = sp.GetRequiredService<IOptions<TelegramOptions>>();
|
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.
|
// Scoped — зависит от IIdentityService (scoped), не Singleton.
|
||||||
builder.Services.AddScoped<ITelegramNotifier, TelegramNotifier>();
|
builder.Services.AddScoped<ITelegramNotifier, TelegramNotifier>();
|
||||||
@@ -55,13 +62,20 @@ builder.Services.AddRateLimiter(options =>
|
|||||||
{
|
{
|
||||||
options.AddFixedWindowLimiter(RateLimiting.AuthPolicy, limiterOptions =>
|
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.Window = TimeSpan.FromMinutes(1);
|
||||||
limiterOptions.QueueLimit = 0;
|
limiterOptions.QueueLimit = 0;
|
||||||
});
|
});
|
||||||
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
|
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.AddProblemDetails();
|
||||||
builder.Services.AddOpenApi();
|
builder.Services.AddOpenApi();
|
||||||
builder.Services.AddHealthChecks()
|
builder.Services.AddHealthChecks()
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ using PnvPanel.Application.Common.Interfaces;
|
|||||||
using PnvPanel.Application.Common.Messaging;
|
using PnvPanel.Application.Common.Messaging;
|
||||||
using PnvPanel.Application.Common.Models;
|
using PnvPanel.Application.Common.Models;
|
||||||
using PnvPanel.Domain.Activation;
|
using PnvPanel.Domain.Activation;
|
||||||
|
using PnvPanel.Domain.Audit;
|
||||||
|
|
||||||
namespace PnvPanel.Application.Admin.Activation;
|
namespace PnvPanel.Application.Admin.Activation;
|
||||||
|
|
||||||
@@ -33,6 +34,9 @@ public sealed class ApproveActivationCommandHandler(
|
|||||||
if (!activateResult.IsSuccess)
|
if (!activateResult.IsSuccess)
|
||||||
return activateResult;
|
return activateResult;
|
||||||
|
|
||||||
|
dbContext.AuditLogs.Add(AuditLog.Create(
|
||||||
|
adminId, "ActivationApproved", "User", request.UserId.ToString(), metadata: null, AuditSource.Web));
|
||||||
|
|
||||||
await notifier.NotifyUserActivatedAsync(request.UserId, cancellationToken);
|
await notifier.NotifyUserActivatedAsync(request.UserId, cancellationToken);
|
||||||
await telegramNotifier.NotifyUserAsync(request.UserId, "✅ Ваш аккаунт активирован администратором.", cancellationToken);
|
await telegramNotifier.NotifyUserAsync(request.UserId, "✅ Ваш аккаунт активирован администратором.", cancellationToken);
|
||||||
return Result.Success();
|
return Result.Success();
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ using PnvPanel.Application.Common.Interfaces;
|
|||||||
using PnvPanel.Application.Common.Messaging;
|
using PnvPanel.Application.Common.Messaging;
|
||||||
using PnvPanel.Application.Common.Models;
|
using PnvPanel.Application.Common.Models;
|
||||||
using PnvPanel.Domain.Activation;
|
using PnvPanel.Domain.Activation;
|
||||||
|
using PnvPanel.Domain.Audit;
|
||||||
|
|
||||||
namespace PnvPanel.Application.Admin.Activation;
|
namespace PnvPanel.Application.Admin.Activation;
|
||||||
|
|
||||||
@@ -26,6 +27,10 @@ public sealed class RejectActivationCommandHandler(IAppDbContext dbContext, ICur
|
|||||||
return Result.Failure(ActivationErrors.AlreadyDecided);
|
return Result.Failure(ActivationErrors.AlreadyDecided);
|
||||||
|
|
||||||
request.Reject(adminId, command.Reason);
|
request.Reject(adminId, command.Reason);
|
||||||
|
|
||||||
|
dbContext.AuditLogs.Add(AuditLog.Create(
|
||||||
|
adminId, "ActivationRejected", "User", request.UserId.ToString(), metadata: null, AuditSource.Web));
|
||||||
|
|
||||||
return Result.Success();
|
return Result.Success();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,10 +2,11 @@ using Microsoft.EntityFrameworkCore;
|
|||||||
using PnvPanel.Application.Common.Interfaces;
|
using PnvPanel.Application.Common.Interfaces;
|
||||||
using PnvPanel.Application.Common.Messaging;
|
using PnvPanel.Application.Common.Messaging;
|
||||||
using PnvPanel.Application.Common.Models;
|
using PnvPanel.Application.Common.Models;
|
||||||
|
using PnvPanel.Domain.Audit;
|
||||||
|
|
||||||
namespace PnvPanel.Application.Admin.Inbounds;
|
namespace PnvPanel.Application.Admin.Inbounds;
|
||||||
|
|
||||||
public sealed class PublishInboundCommandHandler(IAppDbContext dbContext)
|
public sealed class PublishInboundCommandHandler(IAppDbContext dbContext, ICurrentUser currentUser)
|
||||||
: ICommandHandler<PublishInboundCommand, Result<InboundDto>>
|
: ICommandHandler<PublishInboundCommand, Result<InboundDto>>
|
||||||
{
|
{
|
||||||
public async Task<Result<InboundDto>> Handle(PublishInboundCommand command, CancellationToken cancellationToken)
|
public async Task<Result<InboundDto>> Handle(PublishInboundCommand command, CancellationToken cancellationToken)
|
||||||
@@ -19,6 +20,10 @@ public sealed class PublishInboundCommandHandler(IAppDbContext dbContext)
|
|||||||
else
|
else
|
||||||
inbound.Unpublish();
|
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));
|
return Result.Success(InboundDto.FromDomain(inbound));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,10 +2,11 @@ using Microsoft.EntityFrameworkCore;
|
|||||||
using PnvPanel.Application.Common.Interfaces;
|
using PnvPanel.Application.Common.Interfaces;
|
||||||
using PnvPanel.Application.Common.Messaging;
|
using PnvPanel.Application.Common.Messaging;
|
||||||
using PnvPanel.Application.Common.Models;
|
using PnvPanel.Application.Common.Models;
|
||||||
|
using PnvPanel.Domain.Audit;
|
||||||
|
|
||||||
namespace PnvPanel.Application.Admin.Nodes;
|
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>
|
: ICommandHandler<DeleteNodeCommand, Result>
|
||||||
{
|
{
|
||||||
public async Task<Result> Handle(DeleteNodeCommand command, CancellationToken cancellationToken)
|
public async Task<Result> Handle(DeleteNodeCommand command, CancellationToken cancellationToken)
|
||||||
@@ -19,6 +20,9 @@ public sealed class DeleteNodeCommandHandler(IAppDbContext dbContext, IXuiPanelG
|
|||||||
dbContext.Nodes.Remove(node);
|
dbContext.Nodes.Remove(node);
|
||||||
gateway.InvalidateClient(node.Id);
|
gateway.InvalidateClient(node.Id);
|
||||||
|
|
||||||
|
dbContext.AuditLogs.Add(AuditLog.Create(
|
||||||
|
currentUser.UserId, "NodeDeleted", "Node", node.Id.ToString(), metadata: null, AuditSource.Web));
|
||||||
|
|
||||||
return Result.Success();
|
return Result.Success();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
using PnvPanel.Application.Common.Interfaces;
|
using PnvPanel.Application.Common.Interfaces;
|
||||||
using PnvPanel.Application.Common.Messaging;
|
using PnvPanel.Application.Common.Messaging;
|
||||||
using PnvPanel.Application.Common.Models;
|
using PnvPanel.Application.Common.Models;
|
||||||
|
using PnvPanel.Domain.Audit;
|
||||||
using PnvPanel.Domain.Nodes;
|
using PnvPanel.Domain.Nodes;
|
||||||
|
|
||||||
namespace PnvPanel.Application.Admin.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>>
|
: ICommandHandler<RegisterNodeCommand, Result<NodeDto>>
|
||||||
{
|
{
|
||||||
public Task<Result<NodeDto>> Handle(RegisterNodeCommand command, CancellationToken cancellationToken)
|
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);
|
var node = Node.Register(command.Name, baseAddress, credentials, command.Location);
|
||||||
|
|
||||||
dbContext.Nodes.Add(node);
|
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)));
|
return Task.FromResult(Result.Success(NodeDto.FromDomain(node)));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,11 +2,13 @@ using Microsoft.EntityFrameworkCore;
|
|||||||
using PnvPanel.Application.Common.Interfaces;
|
using PnvPanel.Application.Common.Interfaces;
|
||||||
using PnvPanel.Application.Common.Messaging;
|
using PnvPanel.Application.Common.Messaging;
|
||||||
using PnvPanel.Application.Common.Models;
|
using PnvPanel.Application.Common.Models;
|
||||||
|
using PnvPanel.Domain.Audit;
|
||||||
using PnvPanel.Domain.Nodes;
|
using PnvPanel.Domain.Nodes;
|
||||||
|
|
||||||
namespace PnvPanel.Application.Admin.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>>
|
: ICommandHandler<UpdateNodeCommand, Result<NodeDto>>
|
||||||
{
|
{
|
||||||
public async Task<Result<NodeDto>> Handle(UpdateNodeCommand command, CancellationToken cancellationToken)
|
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);
|
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));
|
return Result.Success(NodeDto.FromDomain(node));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,23 @@
|
|||||||
using PnvPanel.Application.Common.Interfaces;
|
using PnvPanel.Application.Common.Interfaces;
|
||||||
using PnvPanel.Application.Common.Messaging;
|
using PnvPanel.Application.Common.Messaging;
|
||||||
using PnvPanel.Application.Common.Models;
|
using PnvPanel.Application.Common.Models;
|
||||||
|
using PnvPanel.Domain.Audit;
|
||||||
|
|
||||||
namespace PnvPanel.Application.Admin.Users;
|
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)
|
public async Task<Result> Handle(ChangeUserRoleCommand command, CancellationToken cancellationToken)
|
||||||
=> roleService.ChangeUserRoleAsync(command.UserId, command.RoleId, 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 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);
|
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);
|
||||||
+22
@@ -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)
|
public static IServiceCollection AddInfrastructure(this IServiceCollection services, IConfiguration configuration)
|
||||||
{
|
{
|
||||||
var connectionString = configuration["ConnectionStrings:Default"]
|
// Строка подключения читается лениво внутри лямбды (а не в локальную переменную сразу), иначе
|
||||||
?? throw new InvalidOperationException("Строка подключения 'ConnectionStrings:Default' не сконфигурирована.");
|
// в тестах WebApplicationFactory.ConfigureAppConfiguration (Testcontainers-порт) не успевает
|
||||||
|
// примениться до регистрации DbContext — окажется закэширован дефолт из appsettings.json.
|
||||||
services.AddDbContext<AppDbContext>(options => options.UseNpgsql(connectionString));
|
services.AddDbContext<AppDbContext>(options => options.UseNpgsql(
|
||||||
|
configuration["ConnectionStrings:Default"]
|
||||||
|
?? throw new InvalidOperationException("Строка подключения 'ConnectionStrings:Default' не сконфигурирована.")));
|
||||||
services.AddScoped<IAppDbContext>(sp => sp.GetRequiredService<AppDbContext>());
|
services.AddScoped<IAppDbContext>(sp => sp.GetRequiredService<AppDbContext>());
|
||||||
|
|
||||||
services
|
services
|
||||||
|
|||||||
@@ -59,7 +59,8 @@ internal sealed class IdentityService(UserManager<AppUser> userManager, SignInMa
|
|||||||
return null;
|
return null;
|
||||||
|
|
||||||
var role = await GetPrimaryRoleAsync(user);
|
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)
|
public async Task<Result> ChangePasswordAsync(Guid userId, string currentPassword, string newPassword, CancellationToken cancellationToken)
|
||||||
|
|||||||
+7
-3
@@ -1,4 +1,6 @@
|
|||||||
|
using NSubstitute;
|
||||||
using PnvPanel.Application.Admin.Inbounds;
|
using PnvPanel.Application.Admin.Inbounds;
|
||||||
|
using PnvPanel.Application.Common.Interfaces;
|
||||||
using PnvPanel.Application.Tests.TestSupport;
|
using PnvPanel.Application.Tests.TestSupport;
|
||||||
using PnvPanel.Domain.Inbounds;
|
using PnvPanel.Domain.Inbounds;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
@@ -7,6 +9,8 @@ namespace PnvPanel.Application.Tests.Admin.Inbounds;
|
|||||||
|
|
||||||
public class PublishInboundCommandHandlerTests
|
public class PublishInboundCommandHandlerTests
|
||||||
{
|
{
|
||||||
|
private readonly ICurrentUser _currentUser = Substitute.For<ICurrentUser>();
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Handle_WhenPublishingExistingInbound_UpdatesPublishState()
|
public async Task Handle_WhenPublishingExistingInbound_UpdatesPublishState()
|
||||||
{
|
{
|
||||||
@@ -16,7 +20,7 @@ public class PublishInboundCommandHandlerTests
|
|||||||
await dbContext.SaveChangesAsync(CancellationToken.None);
|
await dbContext.SaveChangesAsync(CancellationToken.None);
|
||||||
|
|
||||||
var roleId = Guid.NewGuid();
|
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);
|
var command = new PublishInboundCommand(inbound.Id, true, "EU Fast", [roleId], 100);
|
||||||
|
|
||||||
@@ -39,7 +43,7 @@ public class PublishInboundCommandHandlerTests
|
|||||||
dbContext.Inbounds.Add(inbound);
|
dbContext.Inbounds.Add(inbound);
|
||||||
await dbContext.SaveChangesAsync(CancellationToken.None);
|
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);
|
var command = new PublishInboundCommand(inbound.Id, false, null, [], null);
|
||||||
|
|
||||||
@@ -54,7 +58,7 @@ public class PublishInboundCommandHandlerTests
|
|||||||
{
|
{
|
||||||
using var dbContext = InMemoryDbContextFactory.Create();
|
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);
|
var command = new PublishInboundCommand(Guid.NewGuid(), true, "EU Fast", [], null);
|
||||||
|
|
||||||
|
|||||||
+4
-3
@@ -11,6 +11,7 @@ public class RegisterNodeCommandHandlerTests
|
|||||||
{
|
{
|
||||||
private readonly IXuiPanelGateway _gateway = Substitute.For<IXuiPanelGateway>();
|
private readonly IXuiPanelGateway _gateway = Substitute.For<IXuiPanelGateway>();
|
||||||
private readonly ISecretProtector _secretProtector = Substitute.For<ISecretProtector>();
|
private readonly ISecretProtector _secretProtector = Substitute.For<ISecretProtector>();
|
||||||
|
private readonly ICurrentUser _currentUser = Substitute.For<ICurrentUser>();
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Handle_WithValidAddress_RegistersNodeWithProtectedPassword()
|
public async Task Handle_WithValidAddress_RegistersNodeWithProtectedPassword()
|
||||||
@@ -20,7 +21,7 @@ public class RegisterNodeCommandHandlerTests
|
|||||||
_gateway.ValidateBaseAddress(Arg.Any<Uri>()).Returns(Result.Success());
|
_gateway.ValidateBaseAddress(Arg.Any<Uri>()).Returns(Result.Success());
|
||||||
_secretProtector.Protect("secret-password").Returns("protected-secret-password");
|
_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");
|
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();
|
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);
|
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.");
|
var error = Error.Validation("Nodes.SchemeNotAllowed", "Разрешён только HTTPS.");
|
||||||
_gateway.ValidateBaseAddress(Arg.Any<Uri>()).Returns(Result.Failure(error));
|
_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);
|
var command = new RegisterNodeCommand("node-1", "http://node1.example.com", "admin", "secret-password", null);
|
||||||
|
|
||||||
|
|||||||
+10
-2
@@ -2,6 +2,7 @@ using NSubstitute;
|
|||||||
using PnvPanel.Application.Admin.Users;
|
using PnvPanel.Application.Admin.Users;
|
||||||
using PnvPanel.Application.Common.Interfaces;
|
using PnvPanel.Application.Common.Interfaces;
|
||||||
using PnvPanel.Application.Common.Models;
|
using PnvPanel.Application.Common.Models;
|
||||||
|
using PnvPanel.Application.Tests.TestSupport;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace PnvPanel.Application.Tests.Admin.Users;
|
namespace PnvPanel.Application.Tests.Admin.Users;
|
||||||
@@ -9,35 +10,42 @@ namespace PnvPanel.Application.Tests.Admin.Users;
|
|||||||
public class ChangeUserRoleCommandHandlerTests
|
public class ChangeUserRoleCommandHandlerTests
|
||||||
{
|
{
|
||||||
private readonly IRoleService _roleService = Substitute.For<IRoleService>();
|
private readonly IRoleService _roleService = Substitute.For<IRoleService>();
|
||||||
|
private readonly ICurrentUser _currentUser = Substitute.For<ICurrentUser>();
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Handle_DelegatesToRoleServiceAndReturnsSuccess()
|
public async Task Handle_DelegatesToRoleServiceAndReturnsSuccess()
|
||||||
{
|
{
|
||||||
|
using var dbContext = InMemoryDbContextFactory.Create();
|
||||||
|
|
||||||
var userId = Guid.NewGuid();
|
var userId = Guid.NewGuid();
|
||||||
var roleId = Guid.NewGuid();
|
var roleId = Guid.NewGuid();
|
||||||
_roleService.ChangeUserRoleAsync(userId, roleId, Arg.Any<CancellationToken>()).Returns(Result.Success());
|
_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);
|
var result = await handler.Handle(new ChangeUserRoleCommand(userId, roleId), CancellationToken.None);
|
||||||
|
|
||||||
Assert.True(result.IsSuccess);
|
Assert.True(result.IsSuccess);
|
||||||
await _roleService.Received(1).ChangeUserRoleAsync(userId, roleId, Arg.Any<CancellationToken>());
|
await _roleService.Received(1).ChangeUserRoleAsync(userId, roleId, Arg.Any<CancellationToken>());
|
||||||
|
Assert.Single(dbContext.AuditLogs.Local);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Handle_WhenRoleServiceFails_PropagatesFailure()
|
public async Task Handle_WhenRoleServiceFails_PropagatesFailure()
|
||||||
{
|
{
|
||||||
|
using var dbContext = InMemoryDbContextFactory.Create();
|
||||||
|
|
||||||
var userId = Guid.NewGuid();
|
var userId = Guid.NewGuid();
|
||||||
var roleId = Guid.NewGuid();
|
var roleId = Guid.NewGuid();
|
||||||
var error = UserErrors.NotFound;
|
var error = UserErrors.NotFound;
|
||||||
_roleService.ChangeUserRoleAsync(userId, roleId, Arg.Any<CancellationToken>()).Returns(Result.Failure(error));
|
_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);
|
var result = await handler.Handle(new ChangeUserRoleCommand(userId, roleId), CancellationToken.None);
|
||||||
|
|
||||||
Assert.False(result.IsSuccess);
|
Assert.False(result.IsSuccess);
|
||||||
Assert.Equal(error, result.Error);
|
Assert.Equal(error, result.Error);
|
||||||
|
Assert.Empty(dbContext.AuditLogs.Local);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ public class GetCurrentUserQueryHandlerTests
|
|||||||
public async Task Handle_AuthenticatedWithProfile_ReturnsCurrentUserDto()
|
public async Task Handle_AuthenticatedWithProfile_ReturnsCurrentUserDto()
|
||||||
{
|
{
|
||||||
var userId = Guid.NewGuid();
|
var userId = Guid.NewGuid();
|
||||||
var profile = new CurrentUserProfile(userId, "alice", Guid.NewGuid(), "user", true, false, 3);
|
var profile = new CurrentUserProfile(userId, "alice", Guid.NewGuid(), "user", true, false, 3, "sub-token");
|
||||||
_identityService.GetProfileAsync(userId, Arg.Any<CancellationToken>()).Returns(profile);
|
_identityService.GetProfileAsync(userId, Arg.Any<CancellationToken>()).Returns(profile);
|
||||||
_identityService.GetTelegramLinkInfoAsync(userId, Arg.Any<CancellationToken>())
|
_identityService.GetTelegramLinkInfoAsync(userId, Arg.Any<CancellationToken>())
|
||||||
.Returns(new TelegramLinkInfo(true, 42, "alice_tg"));
|
.Returns(new TelegramLinkInfo(true, 42, "alice_tg"));
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ public class LoginCommandHandlerTests
|
|||||||
{
|
{
|
||||||
var userId = Guid.NewGuid();
|
var userId = Guid.NewGuid();
|
||||||
var authUser = new AuthenticatedUser(userId, "alice", "user");
|
var authUser = new AuthenticatedUser(userId, "alice", "user");
|
||||||
var profile = new CurrentUserProfile(userId, "alice", Guid.NewGuid(), "user", IsActivated: true, IsBlocked: false, MaxConfigs: 3);
|
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>())
|
_identityService.ValidateCredentialsAsync("alice", "P@ssw0rd", Arg.Any<CancellationToken>())
|
||||||
.Returns(Result.Success(authUser));
|
.Returns(Result.Success(authUser));
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ public class RefreshCommandHandlerTests
|
|||||||
public async Task Handle_WithValidToken_RotatesAndReturnsNewAuthResult()
|
public async Task Handle_WithValidToken_RotatesAndReturnsNewAuthResult()
|
||||||
{
|
{
|
||||||
var userId = Guid.NewGuid();
|
var userId = Guid.NewGuid();
|
||||||
var profile = new CurrentUserProfile(userId, "alice", Guid.NewGuid(), "user", IsActivated: true, IsBlocked: false, MaxConfigs: 3);
|
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));
|
var rotated = new RotatedRefreshToken(userId, "new-refresh-token", DateTimeOffset.UtcNow.AddDays(30));
|
||||||
|
|
||||||
_refreshTokenService.RotateAsync("old-token", Arg.Any<CancellationToken>()).Returns(Result.Success(rotated));
|
_refreshTokenService.RotateAsync("old-token", Arg.Any<CancellationToken>()).Returns(Result.Success(rotated));
|
||||||
|
|||||||
+1
-1
@@ -32,7 +32,7 @@ public class GetMyConfigsQueryHandlerTests
|
|||||||
dbContext.VpnConfigs.AddRange(activeConfig, revokedConfig, otherUsersConfig);
|
dbContext.VpnConfigs.AddRange(activeConfig, revokedConfig, otherUsersConfig);
|
||||||
await dbContext.SaveChangesAsync(CancellationToken.None);
|
await dbContext.SaveChangesAsync(CancellationToken.None);
|
||||||
|
|
||||||
var profile = new CurrentUserProfile(userId, "alice", Guid.NewGuid(), "user", true, false, 5);
|
var profile = new CurrentUserProfile(userId, "alice", Guid.NewGuid(), "user", true, false, 5, "sub-token");
|
||||||
_identityService.GetProfileAsync(userId, Arg.Any<CancellationToken>()).Returns(profile);
|
_identityService.GetProfileAsync(userId, Arg.Any<CancellationToken>()).Returns(profile);
|
||||||
|
|
||||||
var handler = new GetMyConfigsQueryHandler(dbContext, _identityService, FakeCurrentUser.Authenticated(userId));
|
var handler = new GetMyConfigsQueryHandler(dbContext, _identityService, FakeCurrentUser.Authenticated(userId));
|
||||||
|
|||||||
+1
-1
@@ -75,7 +75,7 @@ public class GetLoginRequestStatusQueryHandlerTests
|
|||||||
dbContext.TelegramLoginRequests.Add(request);
|
dbContext.TelegramLoginRequests.Add(request);
|
||||||
await dbContext.SaveChangesAsync(CancellationToken.None);
|
await dbContext.SaveChangesAsync(CancellationToken.None);
|
||||||
|
|
||||||
var profile = new CurrentUserProfile(userId, "alice", Guid.NewGuid(), "user", true, false, 3);
|
var profile = new CurrentUserProfile(userId, "alice", Guid.NewGuid(), "user", true, false, 3, "sub-token");
|
||||||
_identityService.GetProfileAsync(userId, Arg.Any<CancellationToken>()).Returns(profile);
|
_identityService.GetProfileAsync(userId, Arg.Any<CancellationToken>()).Returns(profile);
|
||||||
_jwtTokenService.GenerateAccessToken(Arg.Any<AuthenticatedUser>())
|
_jwtTokenService.GenerateAccessToken(Arg.Any<AuthenticatedUser>())
|
||||||
.Returns(("access-token", DateTimeOffset.UtcNow.AddMinutes(15)));
|
.Returns(("access-token", DateTimeOffset.UtcNow.AddMinutes(15)));
|
||||||
|
|||||||
@@ -8,13 +8,13 @@ namespace PnvPanel.IntegrationTests.Admin;
|
|||||||
[Collection(IntegrationTestCollection.Name)]
|
[Collection(IntegrationTestCollection.Name)]
|
||||||
public class NodeInboundCrudTests(PnvPanelWebApplicationFactory factory)
|
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(
|
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);
|
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]
|
[Fact]
|
||||||
public async Task RegisterSyncListPublish_FullNodeInboundLifecycle_Succeeds()
|
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" });
|
var first = await client.PostJsonAsync("/api/auth/register", new { userName, password = "P@ssw0rd123" });
|
||||||
Assert.Equal(HttpStatusCode.OK, first.StatusCode);
|
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);
|
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 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 ActivationRequestResponse(Guid Id, string? Comment, DateTimeOffset CreatedAt);
|
||||||
|
|
||||||
|
private sealed record MyConfigsResponse(List<object> Configs, int MaxConfigs);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Доказывает, что pg_advisory_xact_lock в CreateVpnConfigCommandHandler реально защищает
|
/// Доказывает, что pg_advisory_xact_lock в CreateVpnConfigCommandHandler реально защищает
|
||||||
/// от гонки: при параллельных запросах ровно Quota проходят, остальные — 409 QuotaExceeded.
|
/// от гонки: при параллельных запросах ровно Quota проходят, остальные — 409 QuotaExceeded.
|
||||||
@@ -98,7 +100,7 @@ public class ConfigQuotaTests(PnvPanelWebApplicationFactory factory)
|
|||||||
Assert.Equal(ConcurrentAttempts - Quota, quotaExceeded);
|
Assert.Equal(ConcurrentAttempts - Quota, quotaExceeded);
|
||||||
|
|
||||||
var myConfigsResponse = await userClient.GetAsync("/api/configs");
|
var myConfigsResponse = await userClient.GetAsync("/api/configs");
|
||||||
var myConfigs = await myConfigsResponse.ReadAsAsync<List<object>>();
|
var myConfigs = await myConfigsResponse.ReadAsAsync<MyConfigsResponse>();
|
||||||
Assert.Equal(Quota, myConfigs!.Count);
|
Assert.Equal(Quota, myConfigs!.Configs.Count);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-1
@@ -27,7 +27,10 @@ public sealed class PnvPanelWebApplicationFactory : WebApplicationFactory<Progra
|
|||||||
.WithPassword("pnvpanel")
|
.WithPassword("pnvpanel")
|
||||||
.Build();
|
.Build();
|
||||||
|
|
||||||
public async Task InitializeAsync() => await _postgres.StartAsync();
|
public async Task InitializeAsync()
|
||||||
|
{
|
||||||
|
await _postgres.StartAsync();
|
||||||
|
}
|
||||||
|
|
||||||
async Task IAsyncLifetime.DisposeAsync()
|
async Task IAsyncLifetime.DisposeAsync()
|
||||||
{
|
{
|
||||||
@@ -48,6 +51,9 @@ public sealed class PnvPanelWebApplicationFactory : WebApplicationFactory<Progra
|
|||||||
["AdminSeed:Password"] = AdminPassword,
|
["AdminSeed:Password"] = AdminPassword,
|
||||||
// Пусто — TelegramBotHostedService при пустом токене не стартует (см. Api/Telegram/TelegramBotHostedService.cs).
|
// Пусто — TelegramBotHostedService при пустом токене не стартует (см. Api/Telegram/TelegramBotHostedService.cs).
|
||||||
["Telegram:BotToken"] = "",
|
["Telegram:BotToken"] = "",
|
||||||
|
// Весь collection делит один TestServer/host — все запросы идут от одного "клиента",
|
||||||
|
// дефолтный лимит 20/мин быстро исчерпывается. Поднимаем для тестового окружения.
|
||||||
|
["RateLimiting:AuthPermitLimit"] = "10000",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+27
-2
@@ -8,21 +8,46 @@
|
|||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build",
|
||||||
"lint": "oxlint",
|
"lint": "oxlint",
|
||||||
"typecheck": "tsc -b",
|
"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": {
|
"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-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",
|
"i18next": "^26.3.4",
|
||||||
|
"lucide-react": "^1.22.0",
|
||||||
|
"qrcode.react": "^4.2.0",
|
||||||
"react": "^19.2.7",
|
"react": "^19.2.7",
|
||||||
"react-dom": "^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": {
|
"devDependencies": {
|
||||||
"@tailwindcss/vite": "^4.3.2",
|
"@tailwindcss/vite": "^4.3.2",
|
||||||
|
"@tanstack/react-router-devtools": "^1.167.0",
|
||||||
|
"@tanstack/router-plugin": "^1.168.18",
|
||||||
"@types/node": "^24.13.2",
|
"@types/node": "^24.13.2",
|
||||||
"@types/react": "^19.2.17",
|
"@types/react": "^19.2.17",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitejs/plugin-react": "^6.0.3",
|
"@vitejs/plugin-react": "^6.0.3",
|
||||||
|
"openapi-typescript": "^7.13.0",
|
||||||
"oxlint": "^1.71.0",
|
"oxlint": "^1.71.0",
|
||||||
"tailwindcss": "^4.3.2",
|
"tailwindcss": "^4.3.2",
|
||||||
"typescript": "~6.0.2",
|
"typescript": "~6.0.2",
|
||||||
|
|||||||
Generated
+2260
-2
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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' })
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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' })
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { apiRequest } from '@/shared/api/client'
|
||||||
|
import type { AppsByOs } from '@/shared/api/types'
|
||||||
|
|
||||||
|
export function listApps() {
|
||||||
|
return apiRequest<AppsByOs>('/apps')
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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' }
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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}`)
|
||||||
|
}
|
||||||
@@ -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
@@ -1,10 +1,13 @@
|
|||||||
import { StrictMode } from 'react'
|
import { StrictMode } from 'react'
|
||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from 'react-dom/client'
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
|
import { RouterProvider } from '@tanstack/react-router'
|
||||||
import './index.css'
|
import './index.css'
|
||||||
import './lib/i18n'
|
import './shared/lib/i18n'
|
||||||
import { ThemeProvider } from './lib/theme'
|
import { ThemeProvider } from './theme/ThemeProvider'
|
||||||
import App from './App.tsx'
|
import { ToastProvider } from './shared/ui/toast-store'
|
||||||
|
import { RealtimeProvider } from './shared/realtime/RealtimeProvider'
|
||||||
|
import { router } from './router'
|
||||||
|
|
||||||
const queryClient = new QueryClient()
|
const queryClient = new QueryClient()
|
||||||
|
|
||||||
@@ -12,7 +15,11 @@ createRoot(document.getElementById('root')!).render(
|
|||||||
<StrictMode>
|
<StrictMode>
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
<App />
|
<ToastProvider>
|
||||||
|
<RealtimeProvider>
|
||||||
|
<RouterProvider router={router} />
|
||||||
|
</RealtimeProvider>
|
||||||
|
</ToastProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
|
|||||||
@@ -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>()
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import type { ApiError } from './types'
|
||||||
|
|
||||||
|
let accessToken: string | null = null
|
||||||
|
let refreshInFlight: Promise<boolean> | null = null
|
||||||
|
let onUnauthorized: (() => void) | null = null
|
||||||
|
|
||||||
|
export function setAccessToken(token: string | null) {
|
||||||
|
accessToken = token
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAccessToken() {
|
||||||
|
return accessToken
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Вызывается, когда refresh-токен недействителен — обычно очищает стор авторизации и шлёт на /login. */
|
||||||
|
export function setUnauthorizedHandler(handler: (() => void) | null) {
|
||||||
|
onUnauthorized = handler
|
||||||
|
}
|
||||||
|
|
||||||
|
type RequestOptions = {
|
||||||
|
method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'
|
||||||
|
body?: unknown
|
||||||
|
/** Не пытаться освежить токен на 401 (используется самим refresh-запросом, чтобы не зациклиться). */
|
||||||
|
skipRefresh?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshAccessToken(): Promise<boolean> {
|
||||||
|
if (!refreshInFlight) {
|
||||||
|
refreshInFlight = (async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/auth/refresh', { method: 'POST', credentials: 'include' })
|
||||||
|
if (!response.ok) return false
|
||||||
|
const data = (await response.json()) as { accessToken: string }
|
||||||
|
setAccessToken(data.accessToken)
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
} finally {
|
||||||
|
refreshInFlight = null
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
}
|
||||||
|
return refreshInFlight
|
||||||
|
}
|
||||||
|
|
||||||
|
export class HttpError extends Error implements ApiError {
|
||||||
|
title: string
|
||||||
|
detail: string
|
||||||
|
status: number
|
||||||
|
|
||||||
|
constructor(problem: Partial<ApiError>, status: number) {
|
||||||
|
super(problem.detail ?? problem.title ?? `HTTP ${status}`)
|
||||||
|
this.title = problem.title ?? 'Error'
|
||||||
|
this.detail = problem.detail ?? this.message
|
||||||
|
this.status = status
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function parseError(response: Response): Promise<HttpError> {
|
||||||
|
try {
|
||||||
|
const problem = (await response.json()) as Partial<ApiError>
|
||||||
|
return new HttpError(problem, response.status)
|
||||||
|
} catch {
|
||||||
|
return new HttpError({ title: response.statusText }, response.status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiRequest<T>(path: string, options: RequestOptions = {}): Promise<T> {
|
||||||
|
const headers: Record<string, string> = {}
|
||||||
|
if (accessToken) headers.Authorization = `Bearer ${accessToken}`
|
||||||
|
if (options.body !== undefined) headers['Content-Type'] = 'application/json'
|
||||||
|
|
||||||
|
const response = await fetch(`/api${path}`, {
|
||||||
|
method: options.method ?? 'GET',
|
||||||
|
headers,
|
||||||
|
credentials: 'include',
|
||||||
|
body: options.body !== undefined ? JSON.stringify(options.body) : undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.status === 401 && !options.skipRefresh) {
|
||||||
|
const refreshed = await refreshAccessToken()
|
||||||
|
if (refreshed) return apiRequest<T>(path, { ...options, skipRefresh: true })
|
||||||
|
onUnauthorized?.()
|
||||||
|
throw await parseError(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) throw await parseError(response)
|
||||||
|
|
||||||
|
if (response.status === 204) return undefined as T
|
||||||
|
|
||||||
|
const text = await response.text()
|
||||||
|
return (text ? JSON.parse(text) : undefined) as T
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,212 @@
|
|||||||
|
// Типы вручную синхронизированы с DTO бэкенда (см. backend/src/PnvPanel.Application/**).
|
||||||
|
// TODO: заменить на `pnpm gen:api` (openapi-typescript), когда бэкенд доступен по сети
|
||||||
|
// (сейчас недоступен локально — Postgres/Docker не подняты, схему /openapi/v1.json взять негде).
|
||||||
|
|
||||||
|
export type ApiError = {
|
||||||
|
title: string
|
||||||
|
detail: string
|
||||||
|
status: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type VpnProtocol = 'Vless' | 'Vmess' | 'Trojan' | 'Shadowsocks'
|
||||||
|
export type ConfigStatus = 'Active' | 'Disabled' | 'Expired' | 'LimitReached' | 'Revoked'
|
||||||
|
export type NodeStatus = 'Unknown' | 'Online' | 'Offline'
|
||||||
|
export type ActivationStatus = 'Pending' | 'Approved' | 'Rejected'
|
||||||
|
export type OsPlatform = 'IOS' | 'Android' | 'Windows' | 'MacOS' | 'Linux'
|
||||||
|
export type TelegramLoginStatus = 'Pending' | 'Approved' | 'Rejected' | 'Expired' | 'Consumed'
|
||||||
|
|
||||||
|
export type CurrentUser = {
|
||||||
|
id: string
|
||||||
|
userName: string
|
||||||
|
role: string
|
||||||
|
isActivated: boolean
|
||||||
|
telegramLinked: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AuthResponse = {
|
||||||
|
accessToken: string
|
||||||
|
expiresAt: string
|
||||||
|
user: CurrentUser
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RegisterResponse = {
|
||||||
|
id: string
|
||||||
|
userName: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ActivationRequestDto = {
|
||||||
|
id: string
|
||||||
|
comment: string | null
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ActivationStatusDto = {
|
||||||
|
isActivated: boolean
|
||||||
|
pendingRequest: ActivationRequestDto | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type VpnConfigDto = {
|
||||||
|
id: string
|
||||||
|
label: string | null
|
||||||
|
protocol: VpnProtocol
|
||||||
|
location: string
|
||||||
|
deviceLimit: number
|
||||||
|
usedUpBytes: number
|
||||||
|
usedDownBytes: number
|
||||||
|
expiresAt: string | null
|
||||||
|
status: ConfigStatus
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AvailableInboundDto = {
|
||||||
|
inboundId: string
|
||||||
|
displayName: string
|
||||||
|
protocol: VpnProtocol
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ConfigLinkDto = {
|
||||||
|
connectionString: string
|
||||||
|
subscriptionUrl: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MySubscriptionDto = {
|
||||||
|
subscriptionUrl: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GetMyConfigsResult = {
|
||||||
|
configs: VpnConfigDto[]
|
||||||
|
maxConfigs: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ClientAppDto = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
downloadUrl: string
|
||||||
|
description: string | null
|
||||||
|
iconUrl: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET /api/apps — сгруппировано по ОС на бэкенде; отсутствующая ОС значит "нет приложений". */
|
||||||
|
export type AppsByOs = Partial<Record<OsPlatform, ClientAppDto[]>>
|
||||||
|
|
||||||
|
export type LinkTokenResponse = {
|
||||||
|
deepLink: string | null
|
||||||
|
expiresAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TelegramLoginRequestResponse = {
|
||||||
|
requestId: string
|
||||||
|
deepLink: string | null
|
||||||
|
expiresAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TelegramLoginStatusResponse = {
|
||||||
|
status: TelegramLoginStatus
|
||||||
|
accessToken?: string
|
||||||
|
expiresAt?: string
|
||||||
|
user?: CurrentUser
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PagedList<T> = {
|
||||||
|
items: T[]
|
||||||
|
total: number
|
||||||
|
page: number
|
||||||
|
pageSize: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UserSummaryDto = {
|
||||||
|
id: string
|
||||||
|
userName: string
|
||||||
|
role: string
|
||||||
|
isActivated: boolean
|
||||||
|
isBlocked: boolean
|
||||||
|
activatedAt: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RoleDto = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
maxConfigs: number
|
||||||
|
isSystem: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ActivationRequestAdminDto = {
|
||||||
|
id: string
|
||||||
|
userId: string
|
||||||
|
userName: string
|
||||||
|
comment: string | null
|
||||||
|
status: ActivationStatus
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type NodeDto = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
baseAddress: string
|
||||||
|
username: string
|
||||||
|
location: string | null
|
||||||
|
status: NodeStatus
|
||||||
|
isEnabled: boolean
|
||||||
|
lastSyncAt: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type NodeProbeResultDto = {
|
||||||
|
isReachable: boolean
|
||||||
|
errorMessage: string | null
|
||||||
|
status: NodeStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SyncNodeResultDto = {
|
||||||
|
inboundsSynced: number
|
||||||
|
status: NodeStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
export type InboundDto = {
|
||||||
|
id: string
|
||||||
|
nodeId: string
|
||||||
|
remoteInboundId: string
|
||||||
|
protocol: VpnProtocol
|
||||||
|
remark: string
|
||||||
|
port: number
|
||||||
|
isPublished: boolean
|
||||||
|
displayName: string | null
|
||||||
|
maxClients: number | null
|
||||||
|
allowedRoleIds: string[]
|
||||||
|
lastSyncAt: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AdminAppDto = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
downloadUrl: string
|
||||||
|
operatingSystem: OsPlatform
|
||||||
|
description: string | null
|
||||||
|
iconUrl: string | null
|
||||||
|
sortOrder: number
|
||||||
|
isEnabled: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type StatsDto = {
|
||||||
|
totalUsers: number
|
||||||
|
activatedUsers: number
|
||||||
|
pendingActivationRequests: number
|
||||||
|
totalNodes: number
|
||||||
|
onlineNodes: number
|
||||||
|
totalConfigs: number
|
||||||
|
activeConfigs: number
|
||||||
|
totalUsedUpBytes: number
|
||||||
|
totalUsedDownBytes: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AuditSource = 'Web' | 'Telegram' | 'System'
|
||||||
|
|
||||||
|
export type AuditLogDto = {
|
||||||
|
id: number
|
||||||
|
actorId: string | null
|
||||||
|
action: string
|
||||||
|
targetType: string
|
||||||
|
targetId: string
|
||||||
|
metadata: string | null
|
||||||
|
source: AuditSource
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { clsx, type ClassValue } from 'clsx'
|
||||||
|
import { twMerge } from 'tailwind-merge'
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs))
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
const UNITS = ['Б', 'КБ', 'МБ', 'ГБ', 'ТБ']
|
||||||
|
|
||||||
|
export function formatBytes(bytes: number): string {
|
||||||
|
if (bytes <= 0) return `0 ${UNITS[0]}`
|
||||||
|
const exponent = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), UNITS.length - 1)
|
||||||
|
const value = bytes / 1024 ** exponent
|
||||||
|
return `${value.toFixed(exponent === 0 ? 0 : 1)} ${UNITS[exponent]}`
|
||||||
|
}
|
||||||
@@ -0,0 +1,543 @@
|
|||||||
|
import i18n from 'i18next'
|
||||||
|
import { initReactI18next } from 'react-i18next'
|
||||||
|
|
||||||
|
const resources = {
|
||||||
|
ru: {
|
||||||
|
translation: {
|
||||||
|
appName: 'PnvPanel',
|
||||||
|
tagline: 'Self-service портал для VPN-конфигураций',
|
||||||
|
theme: 'Тема',
|
||||||
|
language: 'Язык',
|
||||||
|
light: 'Светлая',
|
||||||
|
dark: 'Тёмная',
|
||||||
|
system: 'Системная',
|
||||||
|
|
||||||
|
auth: {
|
||||||
|
loginTitle: 'Вход',
|
||||||
|
registerTitle: 'Регистрация',
|
||||||
|
userName: 'Имя пользователя',
|
||||||
|
password: 'Пароль',
|
||||||
|
submitLogin: 'Войти',
|
||||||
|
submitRegister: 'Зарегистрироваться',
|
||||||
|
noAccount: 'Нет аккаунта?',
|
||||||
|
haveAccount: 'Уже есть аккаунт?',
|
||||||
|
goRegister: 'Зарегистрироваться',
|
||||||
|
goLogin: 'Войти',
|
||||||
|
loginViaTelegram: 'Войти через Telegram',
|
||||||
|
invalidCredentials: 'Неверное имя пользователя или пароль.',
|
||||||
|
duplicateUserName: 'Пользователь с таким именем уже существует.',
|
||||||
|
genericError: 'Что-то пошло не так. Попробуйте ещё раз.',
|
||||||
|
userNameHint: 'Латиница, цифры, «_», «.», «-», от 3 до 32 символов.',
|
||||||
|
passwordHint: 'Не менее 8 символов.',
|
||||||
|
or: 'или',
|
||||||
|
telegramBotNotConfigured: 'Telegram-бот не настроен администратором.',
|
||||||
|
waitingForConfirmation: 'Ожидание подтверждения в Telegram…',
|
||||||
|
telegramLoginRejected: 'Вход отклонён в Telegram.',
|
||||||
|
telegramLoginExpired: 'Время ожидания истекло, попробуйте снова.',
|
||||||
|
},
|
||||||
|
|
||||||
|
nav: {
|
||||||
|
dashboard: 'Мои конфиги',
|
||||||
|
instructions: 'Инструкции',
|
||||||
|
settings: 'Настройки',
|
||||||
|
admin: 'Админка',
|
||||||
|
logout: 'Выйти',
|
||||||
|
},
|
||||||
|
|
||||||
|
activation: {
|
||||||
|
title: 'Аккаунт не активирован',
|
||||||
|
description:
|
||||||
|
'Чтобы создавать конфиги, дождитесь активации администратором. Можно оставить комментарий к заявке.',
|
||||||
|
commentLabel: 'Комментарий (необязательно)',
|
||||||
|
submit: 'Запросить активацию',
|
||||||
|
pending: 'Заявка на активацию отправлена, ожидайте решения администратора.',
|
||||||
|
alreadyPending: 'У вас уже есть необработанная заявка на активацию.',
|
||||||
|
retry: 'Повторить',
|
||||||
|
},
|
||||||
|
|
||||||
|
configs: {
|
||||||
|
title: 'Мои конфиги',
|
||||||
|
quota: 'Использовано {{used}} из {{max}}',
|
||||||
|
quotaUnlimited: 'Использовано {{used}}, без лимита',
|
||||||
|
empty: 'У вас пока нет конфигов. Создайте первый.',
|
||||||
|
create: 'Создать конфиг',
|
||||||
|
selectLocation: 'Выберите локацию',
|
||||||
|
location: 'Локация',
|
||||||
|
label: 'Метка (необязательно)',
|
||||||
|
deviceLimitLabel: 'Лимит устройств (необязательно)',
|
||||||
|
deviceLimitPlaceholder: 'Без лимита',
|
||||||
|
noInboundsAvailable: 'Нет доступных локаций для вашей роли.',
|
||||||
|
created: 'Конфиг создан.',
|
||||||
|
quotaExceeded: 'Достигнут лимит конфигов для вашей роли.',
|
||||||
|
showLink: 'Ссылка / QR',
|
||||||
|
rotate: 'Перевыпустить',
|
||||||
|
rotated: 'Конфиг перевыпущен.',
|
||||||
|
revoked: 'Конфиг отозван.',
|
||||||
|
confirmRevoke: 'Отозвать этот конфиг? Действие необратимо.',
|
||||||
|
copied: 'Скопировано.',
|
||||||
|
copyLink: 'Скопировать ссылку',
|
||||||
|
loadingLink: 'Загрузка ссылки…',
|
||||||
|
subscriptionLink: 'Ссылка-подписка (для клиента):',
|
||||||
|
aggregatedSubscription: 'Общая подписка',
|
||||||
|
aggregatedSubscriptionHint: 'Одна ссылка/QR со всеми активными конфигами — удобно добавить один раз в клиент.',
|
||||||
|
deviceLimit: '{{count}} устройство',
|
||||||
|
deviceLimit_few: '{{count}} устройства',
|
||||||
|
deviceLimit_many: '{{count}} устройств',
|
||||||
|
deviceLimitUnlimited: 'Без лимита устройств',
|
||||||
|
status: {
|
||||||
|
Active: 'Активен',
|
||||||
|
Disabled: 'Отключён',
|
||||||
|
Expired: 'Истёк',
|
||||||
|
LimitReached: 'Лимит исчерпан',
|
||||||
|
Revoked: 'Отозван',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
instructions: {
|
||||||
|
title: 'Инструкции по подключению',
|
||||||
|
intro: 'Как подключиться за три шага — на любом устройстве.',
|
||||||
|
step1: 'Установите приложение для вашей ОС из списка ниже.',
|
||||||
|
step2: 'На странице «Мои конфиги» скопируйте ссылку или откройте QR-код нужного конфига.',
|
||||||
|
step3: 'Импортируйте ссылку или отсканируйте QR в приложении — готово.',
|
||||||
|
appsTitle: 'Приложения',
|
||||||
|
noApps: 'Каталог приложений пока пуст.',
|
||||||
|
os: {
|
||||||
|
IOS: 'iOS',
|
||||||
|
Android: 'Android',
|
||||||
|
Windows: 'Windows',
|
||||||
|
MacOS: 'macOS',
|
||||||
|
Linux: 'Linux',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
settings: {
|
||||||
|
changePassword: 'Сменить пароль',
|
||||||
|
currentPassword: 'Текущий пароль',
|
||||||
|
currentPasswordRequired: 'Введите текущий пароль.',
|
||||||
|
currentPasswordInvalid: 'Неверный текущий пароль.',
|
||||||
|
newPassword: 'Новый пароль',
|
||||||
|
passwordChanged: 'Пароль изменён.',
|
||||||
|
telegramHint: 'Привязка Telegram нужна для входа без пароля и восстановления доступа.',
|
||||||
|
telegramLinkedStatus: 'Привязан',
|
||||||
|
link: 'Привязать Telegram',
|
||||||
|
unlink: 'Отвязать',
|
||||||
|
confirmUnlink: 'Отвязать Telegram от аккаунта?',
|
||||||
|
telegramLinked: 'Telegram привязан.',
|
||||||
|
telegramUnlinked: 'Telegram отвязан.',
|
||||||
|
waitingForLink: 'Ожидание подтверждения в Telegram…',
|
||||||
|
deleteAccount: 'Удалить аккаунт',
|
||||||
|
deleteAccountHint: 'Отзовёт все конфиги и безвозвратно удалит аккаунт.',
|
||||||
|
confirmDelete: 'Вы уверены? Это действие необратимо.',
|
||||||
|
confirmDeleteYes: 'Да, удалить',
|
||||||
|
cancel: 'Отмена',
|
||||||
|
},
|
||||||
|
|
||||||
|
admin: {
|
||||||
|
prev: 'Назад',
|
||||||
|
next: 'Вперёд',
|
||||||
|
tabs: {
|
||||||
|
overview: 'Обзор',
|
||||||
|
activation: 'Запросы на активацию',
|
||||||
|
users: 'Пользователи',
|
||||||
|
roles: 'Роли',
|
||||||
|
nodes: 'Ноды',
|
||||||
|
apps: 'Приложения',
|
||||||
|
audit: 'Аудит',
|
||||||
|
},
|
||||||
|
users: {
|
||||||
|
searchPlaceholder: 'Поиск по имени пользователя',
|
||||||
|
userName: 'Имя пользователя',
|
||||||
|
role: 'Роль',
|
||||||
|
statusLabel: 'Статус',
|
||||||
|
manage: 'Управление',
|
||||||
|
empty: 'Пользователи не найдены.',
|
||||||
|
total: 'Всего: {{count}}',
|
||||||
|
status: {
|
||||||
|
blocked: 'Заблокирован',
|
||||||
|
active: 'Активен',
|
||||||
|
pending: 'Не активирован',
|
||||||
|
},
|
||||||
|
block: 'Заблокировать',
|
||||||
|
unblock: 'Разблокировать',
|
||||||
|
confirmBlock: 'Заблокировать пользователя? Все его конфиги будут отключены.',
|
||||||
|
blocked: 'Пользователь заблокирован.',
|
||||||
|
unblocked: 'Пользователь разблокирован.',
|
||||||
|
roleChanged: 'Роль изменена.',
|
||||||
|
resetPassword: 'Сбросить пароль',
|
||||||
|
reset: 'Сбросить',
|
||||||
|
passwordReset: 'Пароль сброшен.',
|
||||||
|
configs: 'Конфиги',
|
||||||
|
},
|
||||||
|
activation: {
|
||||||
|
empty: 'Нет ожидающих запросов на активацию.',
|
||||||
|
approved: 'Пользователь активирован.',
|
||||||
|
rejected: 'Запрос отклонён.',
|
||||||
|
approve: 'Активировать',
|
||||||
|
reject: 'Отклонить',
|
||||||
|
},
|
||||||
|
roles: {
|
||||||
|
create: 'Создать роль',
|
||||||
|
name: 'Название',
|
||||||
|
maxConfigs: 'Квота конфигов',
|
||||||
|
maxConfigsHint: '−1 = без лимита.',
|
||||||
|
system: 'системная',
|
||||||
|
edit: 'Изменить',
|
||||||
|
save: 'Сохранить',
|
||||||
|
delete: 'Удалить',
|
||||||
|
confirmDelete: 'Удалить роль? Это действие необратимо.',
|
||||||
|
created: 'Роль создана.',
|
||||||
|
updated: 'Квота обновлена.',
|
||||||
|
deleted: 'Роль удалена.',
|
||||||
|
},
|
||||||
|
nodes: {
|
||||||
|
create: 'Добавить ноду',
|
||||||
|
name: 'Название',
|
||||||
|
baseAddress: 'Адрес панели',
|
||||||
|
username: 'Логин',
|
||||||
|
password: 'Пароль',
|
||||||
|
location: 'Локация',
|
||||||
|
empty: 'Ноды не добавлены.',
|
||||||
|
created: 'Нода добавлена.',
|
||||||
|
updated: 'Нода обновлена.',
|
||||||
|
deleted: 'Нода удалена.',
|
||||||
|
confirmDelete: 'Удалить ноду? Существующие конфиги на ней перестанут синхронизироваться.',
|
||||||
|
edit: 'Изменить',
|
||||||
|
delete: 'Удалить',
|
||||||
|
probe: 'Проверить',
|
||||||
|
sync: 'Синхронизировать',
|
||||||
|
probeSuccess: 'Нода доступна.',
|
||||||
|
probeFailure: 'Нода недоступна: {{message}}',
|
||||||
|
syncSuccess: 'Синхронизировано инбаундов: {{count}}',
|
||||||
|
status: {
|
||||||
|
Unknown: 'Неизвестно',
|
||||||
|
Online: 'Онлайн',
|
||||||
|
Offline: 'Офлайн',
|
||||||
|
},
|
||||||
|
enabled: 'Включена',
|
||||||
|
disabled: 'Отключена',
|
||||||
|
inbounds: 'Инбаунды',
|
||||||
|
noInbounds: 'Инбаунды не найдены — нажмите «Синхронизировать».',
|
||||||
|
publish: 'Публикация',
|
||||||
|
published: 'Опубликован',
|
||||||
|
unpublished: 'Не опубликован',
|
||||||
|
publishSaved: 'Настройки публикации сохранены.',
|
||||||
|
displayName: 'Отображаемое имя',
|
||||||
|
maxClients: 'Лимит клиентов (необязательно)',
|
||||||
|
allowedRoles: 'Доступно ролям',
|
||||||
|
isPublishedLabel: 'Опубликовать инбаунд',
|
||||||
|
optional: 'необязательно',
|
||||||
|
},
|
||||||
|
apps: {
|
||||||
|
create: 'Добавить приложение',
|
||||||
|
name: 'Название',
|
||||||
|
downloadUrl: 'Ссылка на скачивание',
|
||||||
|
os: 'ОС',
|
||||||
|
description: 'Описание',
|
||||||
|
iconUrl: 'Ссылка на иконку',
|
||||||
|
sortOrder: 'Порядок',
|
||||||
|
enabled: 'Включено',
|
||||||
|
disabled: 'отключено',
|
||||||
|
empty: 'Каталог приложений пуст.',
|
||||||
|
created: 'Приложение добавлено.',
|
||||||
|
updated: 'Приложение обновлено.',
|
||||||
|
deleted: 'Приложение удалено.',
|
||||||
|
confirmDelete: 'Удалить приложение из каталога?',
|
||||||
|
},
|
||||||
|
audit: {
|
||||||
|
time: 'Время',
|
||||||
|
action: 'Действие',
|
||||||
|
target: 'Объект',
|
||||||
|
source: 'Источник',
|
||||||
|
empty: 'Журнал аудита пуст.',
|
||||||
|
},
|
||||||
|
stats: {
|
||||||
|
totalUsers: 'Всего пользователей',
|
||||||
|
activatedUsers: 'Активировано',
|
||||||
|
pendingActivationRequests: 'Ожидают активации',
|
||||||
|
totalNodes: 'Всего нод',
|
||||||
|
onlineNodes: 'Нод онлайн',
|
||||||
|
totalConfigs: 'Всего конфигов',
|
||||||
|
activeConfigs: 'Активных конфигов',
|
||||||
|
totalTraffic: 'Суммарный трафик',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
translation: {
|
||||||
|
appName: 'PnvPanel',
|
||||||
|
tagline: 'Self-service portal for VPN configurations',
|
||||||
|
theme: 'Theme',
|
||||||
|
language: 'Language',
|
||||||
|
light: 'Light',
|
||||||
|
dark: 'Dark',
|
||||||
|
system: 'System',
|
||||||
|
|
||||||
|
auth: {
|
||||||
|
loginTitle: 'Log in',
|
||||||
|
registerTitle: 'Register',
|
||||||
|
userName: 'Username',
|
||||||
|
password: 'Password',
|
||||||
|
submitLogin: 'Log in',
|
||||||
|
submitRegister: 'Register',
|
||||||
|
noAccount: "Don't have an account?",
|
||||||
|
haveAccount: 'Already have an account?',
|
||||||
|
goRegister: 'Register',
|
||||||
|
goLogin: 'Log in',
|
||||||
|
loginViaTelegram: 'Log in via Telegram',
|
||||||
|
invalidCredentials: 'Invalid username or password.',
|
||||||
|
duplicateUserName: 'A user with this name already exists.',
|
||||||
|
genericError: 'Something went wrong. Please try again.',
|
||||||
|
userNameHint: 'Latin letters, digits, "_", ".", "-", 3 to 32 characters.',
|
||||||
|
passwordHint: 'At least 8 characters.',
|
||||||
|
or: 'or',
|
||||||
|
telegramBotNotConfigured: 'The Telegram bot has not been configured by the administrator.',
|
||||||
|
waitingForConfirmation: 'Waiting for confirmation in Telegram…',
|
||||||
|
telegramLoginRejected: 'Login was rejected in Telegram.',
|
||||||
|
telegramLoginExpired: 'The request expired, please try again.',
|
||||||
|
},
|
||||||
|
|
||||||
|
nav: {
|
||||||
|
dashboard: 'My configs',
|
||||||
|
instructions: 'Instructions',
|
||||||
|
settings: 'Settings',
|
||||||
|
admin: 'Admin',
|
||||||
|
logout: 'Log out',
|
||||||
|
},
|
||||||
|
|
||||||
|
activation: {
|
||||||
|
title: 'Account not activated',
|
||||||
|
description:
|
||||||
|
'Wait for an administrator to activate your account before creating configs. You can leave a comment with your request.',
|
||||||
|
commentLabel: 'Comment (optional)',
|
||||||
|
submit: 'Request activation',
|
||||||
|
pending: 'Activation request sent, waiting for administrator review.',
|
||||||
|
alreadyPending: 'You already have a pending activation request.',
|
||||||
|
retry: 'Retry',
|
||||||
|
},
|
||||||
|
|
||||||
|
configs: {
|
||||||
|
title: 'My configs',
|
||||||
|
quota: 'Used {{used}} of {{max}}',
|
||||||
|
quotaUnlimited: 'Used {{used}}, unlimited',
|
||||||
|
empty: "You don't have any configs yet. Create your first one.",
|
||||||
|
create: 'Create config',
|
||||||
|
selectLocation: 'Select location',
|
||||||
|
location: 'Location',
|
||||||
|
label: 'Label (optional)',
|
||||||
|
deviceLimitLabel: 'Device limit (optional)',
|
||||||
|
deviceLimitPlaceholder: 'Unlimited',
|
||||||
|
noInboundsAvailable: 'No locations available for your role.',
|
||||||
|
created: 'Config created.',
|
||||||
|
quotaExceeded: 'Config quota reached for your role.',
|
||||||
|
showLink: 'Link / QR',
|
||||||
|
rotate: 'Rotate',
|
||||||
|
rotated: 'Config rotated.',
|
||||||
|
revoked: 'Config revoked.',
|
||||||
|
confirmRevoke: 'Revoke this config? This cannot be undone.',
|
||||||
|
copied: 'Copied.',
|
||||||
|
copyLink: 'Copy link',
|
||||||
|
loadingLink: 'Loading link…',
|
||||||
|
subscriptionLink: 'Subscription link (for the client app):',
|
||||||
|
aggregatedSubscription: 'Aggregated subscription',
|
||||||
|
aggregatedSubscriptionHint: 'One link/QR with all active configs — add it once to your client.',
|
||||||
|
deviceLimit: '{{count}} device',
|
||||||
|
deviceLimit_other: '{{count}} devices',
|
||||||
|
deviceLimitUnlimited: 'No device limit',
|
||||||
|
status: {
|
||||||
|
Active: 'Active',
|
||||||
|
Disabled: 'Disabled',
|
||||||
|
Expired: 'Expired',
|
||||||
|
LimitReached: 'Limit reached',
|
||||||
|
Revoked: 'Revoked',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
instructions: {
|
||||||
|
title: 'Connection instructions',
|
||||||
|
intro: 'Get connected in three steps, on any device.',
|
||||||
|
step1: 'Install the app for your OS from the list below.',
|
||||||
|
step2: 'On the "My configs" page, copy the link or open the QR code for the config you want.',
|
||||||
|
step3: 'Import the link or scan the QR code in the app — done.',
|
||||||
|
appsTitle: 'Apps',
|
||||||
|
noApps: 'The app catalog is empty right now.',
|
||||||
|
os: {
|
||||||
|
IOS: 'iOS',
|
||||||
|
Android: 'Android',
|
||||||
|
Windows: 'Windows',
|
||||||
|
MacOS: 'macOS',
|
||||||
|
Linux: 'Linux',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
settings: {
|
||||||
|
changePassword: 'Change password',
|
||||||
|
currentPassword: 'Current password',
|
||||||
|
currentPasswordRequired: 'Enter your current password.',
|
||||||
|
currentPasswordInvalid: 'Current password is incorrect.',
|
||||||
|
newPassword: 'New password',
|
||||||
|
passwordChanged: 'Password changed.',
|
||||||
|
telegramHint: 'Linking Telegram enables passwordless login and account recovery.',
|
||||||
|
telegramLinkedStatus: 'Linked',
|
||||||
|
link: 'Link Telegram',
|
||||||
|
unlink: 'Unlink',
|
||||||
|
confirmUnlink: 'Unlink Telegram from your account?',
|
||||||
|
telegramLinked: 'Telegram linked.',
|
||||||
|
telegramUnlinked: 'Telegram unlinked.',
|
||||||
|
waitingForLink: 'Waiting for confirmation in Telegram…',
|
||||||
|
deleteAccount: 'Delete account',
|
||||||
|
deleteAccountHint: 'Revokes all configs and permanently deletes your account.',
|
||||||
|
confirmDelete: 'Are you sure? This cannot be undone.',
|
||||||
|
confirmDeleteYes: 'Yes, delete',
|
||||||
|
cancel: 'Cancel',
|
||||||
|
},
|
||||||
|
|
||||||
|
admin: {
|
||||||
|
prev: 'Previous',
|
||||||
|
next: 'Next',
|
||||||
|
tabs: {
|
||||||
|
overview: 'Overview',
|
||||||
|
activation: 'Activation requests',
|
||||||
|
users: 'Users',
|
||||||
|
roles: 'Roles',
|
||||||
|
nodes: 'Nodes',
|
||||||
|
apps: 'Apps',
|
||||||
|
audit: 'Audit',
|
||||||
|
},
|
||||||
|
users: {
|
||||||
|
searchPlaceholder: 'Search by username',
|
||||||
|
userName: 'Username',
|
||||||
|
role: 'Role',
|
||||||
|
statusLabel: 'Status',
|
||||||
|
manage: 'Manage',
|
||||||
|
empty: 'No users found.',
|
||||||
|
total: 'Total: {{count}}',
|
||||||
|
status: {
|
||||||
|
blocked: 'Blocked',
|
||||||
|
active: 'Active',
|
||||||
|
pending: 'Not activated',
|
||||||
|
},
|
||||||
|
block: 'Block',
|
||||||
|
unblock: 'Unblock',
|
||||||
|
confirmBlock: 'Block this user? All their configs will be disabled.',
|
||||||
|
blocked: 'User blocked.',
|
||||||
|
unblocked: 'User unblocked.',
|
||||||
|
roleChanged: 'Role changed.',
|
||||||
|
resetPassword: 'Reset password',
|
||||||
|
reset: 'Reset',
|
||||||
|
passwordReset: 'Password reset.',
|
||||||
|
configs: 'Configs',
|
||||||
|
},
|
||||||
|
activation: {
|
||||||
|
empty: 'No pending activation requests.',
|
||||||
|
approved: 'User activated.',
|
||||||
|
rejected: 'Request rejected.',
|
||||||
|
approve: 'Approve',
|
||||||
|
reject: 'Reject',
|
||||||
|
},
|
||||||
|
roles: {
|
||||||
|
create: 'Create role',
|
||||||
|
name: 'Name',
|
||||||
|
maxConfigs: 'Config quota',
|
||||||
|
maxConfigsHint: '−1 = unlimited.',
|
||||||
|
system: 'system',
|
||||||
|
edit: 'Edit',
|
||||||
|
save: 'Save',
|
||||||
|
delete: 'Delete',
|
||||||
|
confirmDelete: 'Delete this role? This cannot be undone.',
|
||||||
|
created: 'Role created.',
|
||||||
|
updated: 'Quota updated.',
|
||||||
|
deleted: 'Role deleted.',
|
||||||
|
},
|
||||||
|
nodes: {
|
||||||
|
create: 'Add node',
|
||||||
|
name: 'Name',
|
||||||
|
baseAddress: 'Panel address',
|
||||||
|
username: 'Username',
|
||||||
|
password: 'Password',
|
||||||
|
location: 'Location',
|
||||||
|
empty: 'No nodes added yet.',
|
||||||
|
created: 'Node added.',
|
||||||
|
updated: 'Node updated.',
|
||||||
|
deleted: 'Node deleted.',
|
||||||
|
confirmDelete: 'Delete this node? Existing configs on it will stop syncing.',
|
||||||
|
edit: 'Edit',
|
||||||
|
delete: 'Delete',
|
||||||
|
probe: 'Probe',
|
||||||
|
sync: 'Sync',
|
||||||
|
probeSuccess: 'Node is reachable.',
|
||||||
|
probeFailure: 'Node unreachable: {{message}}',
|
||||||
|
syncSuccess: 'Synced inbounds: {{count}}',
|
||||||
|
status: {
|
||||||
|
Unknown: 'Unknown',
|
||||||
|
Online: 'Online',
|
||||||
|
Offline: 'Offline',
|
||||||
|
},
|
||||||
|
enabled: 'Enabled',
|
||||||
|
disabled: 'Disabled',
|
||||||
|
inbounds: 'Inbounds',
|
||||||
|
noInbounds: 'No inbounds found — click "Sync".',
|
||||||
|
publish: 'Publishing',
|
||||||
|
published: 'Published',
|
||||||
|
unpublished: 'Not published',
|
||||||
|
publishSaved: 'Publishing settings saved.',
|
||||||
|
displayName: 'Display name',
|
||||||
|
maxClients: 'Client limit (optional)',
|
||||||
|
allowedRoles: 'Allowed for roles',
|
||||||
|
isPublishedLabel: 'Publish inbound',
|
||||||
|
optional: 'optional',
|
||||||
|
},
|
||||||
|
apps: {
|
||||||
|
create: 'Add app',
|
||||||
|
name: 'Name',
|
||||||
|
downloadUrl: 'Download link',
|
||||||
|
os: 'OS',
|
||||||
|
description: 'Description',
|
||||||
|
iconUrl: 'Icon URL',
|
||||||
|
sortOrder: 'Sort order',
|
||||||
|
enabled: 'Enabled',
|
||||||
|
disabled: 'disabled',
|
||||||
|
empty: 'The app catalog is empty.',
|
||||||
|
created: 'App added.',
|
||||||
|
updated: 'App updated.',
|
||||||
|
deleted: 'App deleted.',
|
||||||
|
confirmDelete: 'Remove this app from the catalog?',
|
||||||
|
},
|
||||||
|
audit: {
|
||||||
|
time: 'Time',
|
||||||
|
action: 'Action',
|
||||||
|
target: 'Target',
|
||||||
|
source: 'Source',
|
||||||
|
empty: 'The audit log is empty.',
|
||||||
|
},
|
||||||
|
stats: {
|
||||||
|
totalUsers: 'Total users',
|
||||||
|
activatedUsers: 'Activated',
|
||||||
|
pendingActivationRequests: 'Pending activation',
|
||||||
|
totalNodes: 'Total nodes',
|
||||||
|
onlineNodes: 'Nodes online',
|
||||||
|
totalConfigs: 'Total configs',
|
||||||
|
activeConfigs: 'Active configs',
|
||||||
|
totalTraffic: 'Total traffic',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'pnv-lang'
|
||||||
|
const stored = typeof localStorage !== 'undefined' ? localStorage.getItem(STORAGE_KEY) : null
|
||||||
|
|
||||||
|
void i18n.use(initReactI18next).init({
|
||||||
|
resources,
|
||||||
|
lng: stored ?? 'ru',
|
||||||
|
fallbackLng: 'ru',
|
||||||
|
interpolation: { escapeValue: false },
|
||||||
|
})
|
||||||
|
|
||||||
|
export function setLanguage(lng: string) {
|
||||||
|
localStorage.setItem(STORAGE_KEY, lng)
|
||||||
|
void i18n.changeLanguage(lng)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default i18n
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { useEffect } from 'react'
|
||||||
|
import { useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { useAuthStore } from '@/features/auth/store'
|
||||||
|
import type { ConfigStatus, GetMyConfigsResult } from '@/shared/api/types'
|
||||||
|
import { getConnection, startConnection, stopConnection } from './connection'
|
||||||
|
|
||||||
|
type ConfigTrafficUpdated = { configId: string; usedUpBytes: number; usedDownBytes: number }
|
||||||
|
type ConfigStatusChanged = { configId: string; status: ConfigStatus }
|
||||||
|
type UserActivated = { userId: string }
|
||||||
|
|
||||||
|
/** Живые обновления по SignalR: точечно патчит кэш TanStack Query вместо инвалидации всего списка. */
|
||||||
|
export function RealtimeProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const user = useAuthStore((s) => s.user)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!user) {
|
||||||
|
void stopConnection()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const connection = getConnection()
|
||||||
|
|
||||||
|
const onTrafficUpdated = (payload: ConfigTrafficUpdated) => {
|
||||||
|
queryClient.setQueryData<GetMyConfigsResult>(['my-configs'], (prev) =>
|
||||||
|
prev
|
||||||
|
? {
|
||||||
|
...prev,
|
||||||
|
configs: prev.configs.map((c) =>
|
||||||
|
c.id === payload.configId
|
||||||
|
? { ...c, usedUpBytes: payload.usedUpBytes, usedDownBytes: payload.usedDownBytes }
|
||||||
|
: c,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
: prev,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onStatusChanged = (payload: ConfigStatusChanged) => {
|
||||||
|
queryClient.setQueryData<GetMyConfigsResult>(['my-configs'], (prev) =>
|
||||||
|
prev
|
||||||
|
? { ...prev, configs: prev.configs.map((c) => (c.id === payload.configId ? { ...c, status: payload.status } : c)) }
|
||||||
|
: prev,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onUserActivated = (_payload: UserActivated) => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ['activation-status'] })
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ['me-poll'] })
|
||||||
|
}
|
||||||
|
|
||||||
|
connection.on('configTrafficUpdated', onTrafficUpdated)
|
||||||
|
connection.on('configStatusChanged', onStatusChanged)
|
||||||
|
connection.on('userActivated', onUserActivated)
|
||||||
|
|
||||||
|
void startConnection()
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
connection.off('configTrafficUpdated', onTrafficUpdated)
|
||||||
|
connection.off('configStatusChanged', onStatusChanged)
|
||||||
|
connection.off('userActivated', onUserActivated)
|
||||||
|
}
|
||||||
|
}, [user, queryClient])
|
||||||
|
|
||||||
|
return <>{children}</>
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { HubConnectionBuilder, LogLevel, type HubConnection } from '@microsoft/signalr'
|
||||||
|
import { getAccessToken } from '@/shared/api/client'
|
||||||
|
|
||||||
|
let connection: HubConnection | null = null
|
||||||
|
|
||||||
|
function createConnection(): HubConnection {
|
||||||
|
return new HubConnectionBuilder()
|
||||||
|
.withUrl('/hubs/panel', { accessTokenFactory: () => getAccessToken() ?? '' })
|
||||||
|
.withAutomaticReconnect()
|
||||||
|
.configureLogging(LogLevel.Warning)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getConnection(): HubConnection {
|
||||||
|
connection ??= createConnection()
|
||||||
|
return connection
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function startConnection() {
|
||||||
|
const conn = getConnection()
|
||||||
|
if (conn.state !== 'Disconnected') return
|
||||||
|
try {
|
||||||
|
await conn.start()
|
||||||
|
} catch {
|
||||||
|
// Автопереподключение (withAutomaticReconnect) не запускается после неудачного первого
|
||||||
|
// start() — это ожидаемо при недоступном бэкенде, UI продолжает работать через обычный REST.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function stopConnection() {
|
||||||
|
if (!connection) return
|
||||||
|
await connection.stop()
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { cva, type VariantProps } from 'class-variance-authority'
|
||||||
|
import { type HTMLAttributes } from 'react'
|
||||||
|
import { cn } from '@/shared/lib/cn'
|
||||||
|
|
||||||
|
const badgeVariants = cva('inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-medium', {
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: 'border-transparent bg-primary text-primary-foreground',
|
||||||
|
outline: 'border-border text-foreground',
|
||||||
|
success: 'border-transparent bg-emerald-900/50 text-emerald-300',
|
||||||
|
warning: 'border-transparent bg-amber-900/50 text-amber-300',
|
||||||
|
destructive: 'border-transparent bg-red-900/50 text-red-300',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: { variant: 'default' },
|
||||||
|
})
|
||||||
|
|
||||||
|
export type BadgeProps = HTMLAttributes<HTMLSpanElement> & VariantProps<typeof badgeVariants>
|
||||||
|
|
||||||
|
export function Badge({ className, variant, ...props }: BadgeProps) {
|
||||||
|
return <span className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { Slot } from '@radix-ui/react-slot'
|
||||||
|
import { cva, type VariantProps } from 'class-variance-authority'
|
||||||
|
import { type ButtonHTMLAttributes, forwardRef } from 'react'
|
||||||
|
import { cn } from '@/shared/lib/cn'
|
||||||
|
|
||||||
|
export const buttonVariants = cva(
|
||||||
|
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors disabled:pointer-events-none disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50',
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: 'bg-primary text-primary-foreground hover:opacity-90',
|
||||||
|
outline: 'border border-border bg-transparent hover:bg-muted',
|
||||||
|
ghost: 'hover:bg-muted',
|
||||||
|
destructive: 'bg-red-600 text-white hover:bg-red-700',
|
||||||
|
link: 'text-primary underline-offset-4 hover:underline',
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: 'h-10 px-4 py-2',
|
||||||
|
sm: 'h-9 rounded-md px-3',
|
||||||
|
lg: 'h-11 rounded-md px-8',
|
||||||
|
icon: 'h-10 w-10',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: { variant: 'default', size: 'default' },
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
export type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> &
|
||||||
|
VariantProps<typeof buttonVariants> & { asChild?: boolean }
|
||||||
|
|
||||||
|
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
|
({ className, variant, size, asChild, ...props }, ref) => {
|
||||||
|
const Comp = asChild ? Slot : 'button'
|
||||||
|
return <Comp className={cn(buttonVariants({ variant, size }), className)} ref={ref} {...props} />
|
||||||
|
},
|
||||||
|
)
|
||||||
|
Button.displayName = 'Button'
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { type HTMLAttributes, forwardRef } from 'react'
|
||||||
|
import { cn } from '@/shared/lib/cn'
|
||||||
|
|
||||||
|
export const Card = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(({ className, ...props }, ref) => (
|
||||||
|
<div ref={ref} className={cn('rounded-lg border border-border bg-background shadow-sm', className)} {...props} />
|
||||||
|
))
|
||||||
|
Card.displayName = 'Card'
|
||||||
|
|
||||||
|
export const CardHeader = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(({ className, ...props }, ref) => (
|
||||||
|
<div ref={ref} className={cn('flex flex-col gap-1.5 p-6', className)} {...props} />
|
||||||
|
))
|
||||||
|
CardHeader.displayName = 'CardHeader'
|
||||||
|
|
||||||
|
export const CardTitle = forwardRef<HTMLParagraphElement, HTMLAttributes<HTMLHeadingElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<h3 ref={ref} className={cn('text-xl font-semibold tracking-tight', className)} {...props} />
|
||||||
|
),
|
||||||
|
)
|
||||||
|
CardTitle.displayName = 'CardTitle'
|
||||||
|
|
||||||
|
export const CardDescription = forwardRef<HTMLParagraphElement, HTMLAttributes<HTMLParagraphElement>>(
|
||||||
|
({ className, ...props }, ref) => <p ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} />,
|
||||||
|
)
|
||||||
|
CardDescription.displayName = 'CardDescription'
|
||||||
|
|
||||||
|
export const CardContent = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(({ className, ...props }, ref) => (
|
||||||
|
<div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
|
||||||
|
))
|
||||||
|
CardContent.displayName = 'CardContent'
|
||||||
|
|
||||||
|
export const CardFooter = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(({ className, ...props }, ref) => (
|
||||||
|
<div ref={ref} className={cn('flex items-center p-6 pt-0', className)} {...props} />
|
||||||
|
))
|
||||||
|
CardFooter.displayName = 'CardFooter'
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import * as DialogPrimitive from '@radix-ui/react-dialog'
|
||||||
|
import { X } from 'lucide-react'
|
||||||
|
import { cn } from '@/shared/lib/cn'
|
||||||
|
|
||||||
|
export const Dialog = DialogPrimitive.Root
|
||||||
|
export const DialogTrigger = DialogPrimitive.Trigger
|
||||||
|
|
||||||
|
export function DialogContent({ className, children, ...props }: DialogPrimitive.DialogContentProps) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Portal>
|
||||||
|
<DialogPrimitive.Overlay className="fixed inset-0 z-50 bg-black/60" />
|
||||||
|
<DialogPrimitive.Content
|
||||||
|
className={cn(
|
||||||
|
'fixed left-1/2 top-1/2 z-50 w-full max-w-md -translate-x-1/2 -translate-y-1/2 rounded-lg border border-border bg-background p-6 shadow-lg',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<DialogPrimitive.Close className="absolute right-4 top-4 text-muted-foreground hover:text-foreground">
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
</DialogPrimitive.Content>
|
||||||
|
</DialogPrimitive.Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DialogHeader({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||||
|
return <div className={cn('mb-4 flex flex-col gap-1', className)} {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DialogTitle = DialogPrimitive.Title
|
||||||
|
export const DialogDescription = DialogPrimitive.Description
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { type InputHTMLAttributes, forwardRef } from 'react'
|
||||||
|
import { cn } from '@/shared/lib/cn'
|
||||||
|
|
||||||
|
export const Input = forwardRef<HTMLInputElement, InputHTMLAttributes<HTMLInputElement>>(
|
||||||
|
({ className, type, ...props }, ref) => (
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
className={cn(
|
||||||
|
'flex h-10 w-full rounded-md border border-border bg-transparent px-3 py-2 text-sm outline-none transition-colors placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-primary/50 disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
)
|
||||||
|
Input.displayName = 'Input'
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import * as LabelPrimitive from '@radix-ui/react-label'
|
||||||
|
import { forwardRef } from 'react'
|
||||||
|
import { cn } from '@/shared/lib/cn'
|
||||||
|
|
||||||
|
export const Label = forwardRef<
|
||||||
|
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<LabelPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn('text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
Label.displayName = LabelPrimitive.Root.displayName
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { cn } from '@/shared/lib/cn'
|
||||||
|
|
||||||
|
export function Progress({ value, className }: { value: number; className?: string }) {
|
||||||
|
const clamped = Math.min(100, Math.max(0, value))
|
||||||
|
return (
|
||||||
|
<div className={cn('h-2 w-full overflow-hidden rounded-full bg-muted', className)}>
|
||||||
|
<div className="h-full rounded-full bg-primary transition-all" style={{ width: `${clamped}%` }} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user