Enhance configuration and logging for user management commands
CI / Backend (build + test) (push) Successful in 1m18s
CI / Frontend (lint + typecheck + build) (push) Successful in 31s

- Updated `.env.example` to include new settings for trusted proxies and networks for better security with X-Forwarded headers.
- Modified `BlockUserCommandHandler` and `UnblockUserCommandHandler` to include logging for gateway update failures, ensuring better traceability of issues during user blocking/unblocking.
- Adjusted tests for command handlers to incorporate logging functionality, improving test coverage and reliability.
- Updated frontend configuration to dynamically set the server port based on environment variables.
This commit is contained in:
Leonid Pershin
2026-07-02 14:33:32 +03:00
parent cdd67f8e2b
commit c04e0d7261
13 changed files with 162 additions and 17 deletions
+2 -1
View File
@@ -5,7 +5,8 @@
"name": "frontend", "name": "frontend",
"runtimeExecutable": "pnpm", "runtimeExecutable": "pnpm",
"runtimeArgs": ["--dir", "frontend", "dev"], "runtimeArgs": ["--dir", "frontend", "dev"],
"port": 5173 "port": 5173,
"autoPort": true
} }
] ]
} }
+8
View File
@@ -47,3 +47,11 @@ Telegram__AdminTelegramUserIds=123456789
# ── ASP.NET Core ────────────────────────────────────────────────────────── # ── ASP.NET Core ──────────────────────────────────────────────────────────
ASPNETCORE_ENVIRONMENT=Production ASPNETCORE_ENVIRONMENT=Production
ASPNETCORE_HTTP_PORTS=8080 ASPNETCORE_HTTP_PORTS=8080
# ── Доверенные прокси (X-Forwarded-For/Proto) ──────────────────────────────
# TLS терминируется вне compose внешним прокси/шлюзом. Чтобы клиент не мог подделать свой IP/схему
# напрямую (в обход прокси), по умолчанию доверяется только loopback (дефолт ASP.NET Core). Если
# прокси стоит не на loopback (отдельный контейнер/хост), перечисли его через запятую — конкретные
# IP через KnownProxies и/или сети в формате CIDR через KnownNetworks.
# ForwardedHeaders__KnownProxies=203.0.113.10
# ForwardedHeaders__KnownNetworks=172.18.0.0/16
+24 -3
View File
@@ -1,3 +1,4 @@
using System.Net;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using Microsoft.AspNetCore.HttpOverrides; using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.AspNetCore.RateLimiting; using Microsoft.AspNetCore.RateLimiting;
@@ -24,12 +25,23 @@ builder.Services.AddSerilog((services, configuration) => configuration
.ReadFrom.Services(services) .ReadFrom.Services(services)
.Enrich.FromLogContext()); .Enrich.FromLogContext());
// За внешним прокси доверяем X-Forwarded-* (TLS терминируется вне контейнера). // За внешним прокси доверяем X-Forwarded-* (TLS терминируется вне контейнера), но ТОЛЬКО от явно
// перечисленных адресов/сетей прокси — иначе клиент может подделать свой IP/схему напрямую, минуя
// прокси (спуфинг rate-limiting по IP, аудит-лога, Secure-cookie). По умолчанию (без конфигурации)
// остаётся дефолт ASP.NET Core — доверие только loopback; для прод-топологии прокси задаётся через
// ForwardedHeaders__KnownProxies / ForwardedHeaders__KnownNetworks (см. .env.example).
builder.Services.Configure<ForwardedHeadersOptions>(options => builder.Services.Configure<ForwardedHeadersOptions>(options =>
{ {
options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto; options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
options.KnownIPNetworks.Clear();
options.KnownProxies.Clear(); foreach (var proxy in builder.Configuration.GetSection("ForwardedHeaders:KnownProxies").Get<string[]>() ?? [])
options.KnownProxies.Add(IPAddress.Parse(proxy));
foreach (var network in builder.Configuration.GetSection("ForwardedHeaders:KnownNetworks").Get<string[]>() ?? [])
{
var parts = network.Split('/');
options.KnownIPNetworks.Add(new System.Net.IPNetwork(IPAddress.Parse(parts[0]), int.Parse(parts[1])));
}
}); });
builder.Services.AddHttpContextAccessor(); builder.Services.AddHttpContextAccessor();
@@ -83,6 +95,15 @@ builder.Services.AddHealthChecks()
var app = builder.Build(); var app = builder.Build();
// Без персистентного пути key-ring живёт только в памяти контейнера — после пересоздания
// расшифровать уже сохранённые пароли нод будет невозможно. Предупреждаем громко, не молчим.
if (string.IsNullOrWhiteSpace(builder.Configuration["DataProtection:KeyRingPath"]))
{
app.Logger.LogWarning(
"DataProtection:KeyRingPath не задан — ключи шифрования секретов нод не персистентны " +
"и будут потеряны при пересоздании контейнера. В проде обязательно смонтируй том и укажи путь.");
}
// Авто-применение миграций и идемпотентный сидинг (роли + админ из env) на старте. // Авто-применение миграций и идемпотентный сидинг (роли + админ из env) на старте.
await app.Services.ApplyMigrationsAsync(); await app.Services.ApplyMigrationsAsync();
await app.Services.SeedDataAsync(); await app.Services.SeedDataAsync();
@@ -1,4 +1,5 @@
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
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;
@@ -10,7 +11,8 @@ namespace PnvPanel.Application.Admin.Users;
/// <summary>Блокировка гасит все активные конфиги в 3x-ui (см. architecture.md).</summary> /// <summary>Блокировка гасит все активные конфиги в 3x-ui (см. architecture.md).</summary>
public sealed class BlockUserCommandHandler( public sealed class BlockUserCommandHandler(
IAppDbContext dbContext, IIdentityService identityService, IXuiPanelGateway gateway, IAppDbContext dbContext, IIdentityService identityService, IXuiPanelGateway gateway,
IRealtimeNotifier notifier, ITelegramNotifier telegramNotifier, ICurrentUser currentUser) IRealtimeNotifier notifier, ITelegramNotifier telegramNotifier, ICurrentUser currentUser,
ILogger<BlockUserCommandHandler> logger)
: ICommandHandler<BlockUserCommand, Result> : ICommandHandler<BlockUserCommand, Result>
{ {
public async Task<Result> Handle(BlockUserCommand command, CancellationToken cancellationToken) public async Task<Result> Handle(BlockUserCommand command, CancellationToken cancellationToken)
@@ -32,9 +34,20 @@ public sealed class BlockUserCommandHandler(
if (inbound is not null && node is not null) if (inbound is not null && node is not null)
{ {
await gateway.UpdateClientAsync( var updateResult = await gateway.UpdateClientAsync(
node, inbound.RemoteInboundId, config.ClientExternalId, config.Protocol, node, inbound.RemoteInboundId, config.ClientExternalId, config.Protocol,
config.Label ?? config.ClientEmail, config.DeviceLimit, enable: false, cancellationToken); config.Label ?? config.ClientEmail, config.DeviceLimit, enable: false, cancellationToken);
if (!updateResult.IsSuccess)
{
// Нода недоступна/сбой панели — не помечаем Disabled локально, иначе БД разойдётся
// с реальным состоянием клиента в 3x-ui (пользователь решит, что VPN погашен, а он жив).
// Конфиг останется Active и будет подхвачен повторным BlockUserCommand (идемпотентен).
logger.LogWarning(
"Не удалось отключить клиента конфига {ConfigId} на ноде {NodeId} при блокировке пользователя {UserId}: {Error}",
config.Id, node.Id, command.UserId, updateResult.Error);
continue;
}
} }
config.Disable(); config.Disable();
@@ -1,4 +1,5 @@
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
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;
@@ -10,7 +11,7 @@ namespace PnvPanel.Application.Admin.Users;
/// <summary>Разблокировка возвращает в 3x-ui только конфиги, погашенные блокировкой (Disabled).</summary> /// <summary>Разблокировка возвращает в 3x-ui только конфиги, погашенные блокировкой (Disabled).</summary>
public sealed class UnblockUserCommandHandler( public sealed class UnblockUserCommandHandler(
IAppDbContext dbContext, IIdentityService identityService, IXuiPanelGateway gateway, IAppDbContext dbContext, IIdentityService identityService, IXuiPanelGateway gateway,
IRealtimeNotifier notifier, ICurrentUser currentUser) IRealtimeNotifier notifier, ICurrentUser currentUser, ILogger<UnblockUserCommandHandler> logger)
: ICommandHandler<UnblockUserCommand, Result> : ICommandHandler<UnblockUserCommand, Result>
{ {
public async Task<Result> Handle(UnblockUserCommand command, CancellationToken cancellationToken) public async Task<Result> Handle(UnblockUserCommand command, CancellationToken cancellationToken)
@@ -32,9 +33,20 @@ public sealed class UnblockUserCommandHandler(
if (inbound is not null && node is not null) if (inbound is not null && node is not null)
{ {
await gateway.UpdateClientAsync( var updateResult = await gateway.UpdateClientAsync(
node, inbound.RemoteInboundId, config.ClientExternalId, config.Protocol, node, inbound.RemoteInboundId, config.ClientExternalId, config.Protocol,
config.Label ?? config.ClientEmail, config.DeviceLimit, enable: true, cancellationToken); config.Label ?? config.ClientEmail, config.DeviceLimit, enable: true, cancellationToken);
if (!updateResult.IsSuccess)
{
// Нода недоступна/сбой панели — не помечаем Enabled локально, иначе БД разойдётся
// с реальным состоянием клиента в 3x-ui. Конфиг останется Disabled и будет подхвачен
// повторным UnblockUserCommand (идемпотентен).
logger.LogWarning(
"Не удалось включить клиента конфига {ConfigId} на ноде {NodeId} при разблокировке пользователя {UserId}: {Error}",
config.Id, node.Id, command.UserId, updateResult.Error);
continue;
}
} }
config.Enable(); config.Enable();
@@ -1,3 +1,4 @@
using Microsoft.Extensions.Logging;
using NSubstitute; using NSubstitute;
using PnvPanel.Application.Admin.Users; using PnvPanel.Application.Admin.Users;
using PnvPanel.Application.Common.Interfaces; using PnvPanel.Application.Common.Interfaces;
@@ -17,6 +18,7 @@ public class BlockUserCommandHandlerTests
private readonly IRealtimeNotifier _notifier = Substitute.For<IRealtimeNotifier>(); private readonly IRealtimeNotifier _notifier = Substitute.For<IRealtimeNotifier>();
private readonly ITelegramNotifier _telegramNotifier = Substitute.For<ITelegramNotifier>(); private readonly ITelegramNotifier _telegramNotifier = Substitute.For<ITelegramNotifier>();
private readonly ICurrentUser _currentUser = Substitute.For<ICurrentUser>(); private readonly ICurrentUser _currentUser = Substitute.For<ICurrentUser>();
private readonly ILogger<BlockUserCommandHandler> _logger = Substitute.For<ILogger<BlockUserCommandHandler>>();
[Fact] [Fact]
public async Task Handle_IdentityServiceFails_ReturnsFailureWithoutTouchingConfigs() public async Task Handle_IdentityServiceFails_ReturnsFailureWithoutTouchingConfigs()
@@ -26,7 +28,7 @@ public class BlockUserCommandHandlerTests
var failure = UserErrors.NotFound; var failure = UserErrors.NotFound;
_identityService.BlockUserAsync(userId, Arg.Any<CancellationToken>()).Returns(Result.Failure(failure)); _identityService.BlockUserAsync(userId, Arg.Any<CancellationToken>()).Returns(Result.Failure(failure));
var handler = new BlockUserCommandHandler(dbContext, _identityService, _gateway, _notifier, _telegramNotifier, _currentUser); var handler = new BlockUserCommandHandler(dbContext, _identityService, _gateway, _notifier, _telegramNotifier, _currentUser, _logger);
var result = await handler.Handle(new BlockUserCommand(userId), CancellationToken.None); var result = await handler.Handle(new BlockUserCommand(userId), CancellationToken.None);
@@ -55,8 +57,12 @@ public class BlockUserCommandHandlerTests
_identityService.BlockUserAsync(userId, Arg.Any<CancellationToken>()).Returns(Result.Success()); _identityService.BlockUserAsync(userId, Arg.Any<CancellationToken>()).Returns(Result.Success());
_currentUser.UserId.Returns(adminId); _currentUser.UserId.Returns(adminId);
_gateway.UpdateClientAsync(
Arg.Any<Node>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<VpnProtocol>(),
Arg.Any<string>(), Arg.Any<int>(), Arg.Any<bool>(), Arg.Any<CancellationToken>())
.Returns(Result.Success());
var handler = new BlockUserCommandHandler(dbContext, _identityService, _gateway, _notifier, _telegramNotifier, _currentUser); var handler = new BlockUserCommandHandler(dbContext, _identityService, _gateway, _notifier, _telegramNotifier, _currentUser, _logger);
var result = await handler.Handle(new BlockUserCommand(userId), CancellationToken.None); var result = await handler.Handle(new BlockUserCommand(userId), CancellationToken.None);
@@ -82,7 +88,7 @@ public class BlockUserCommandHandlerTests
_identityService.BlockUserAsync(userId, Arg.Any<CancellationToken>()).Returns(Result.Success()); _identityService.BlockUserAsync(userId, Arg.Any<CancellationToken>()).Returns(Result.Success());
_currentUser.UserId.Returns(Guid.NewGuid()); _currentUser.UserId.Returns(Guid.NewGuid());
var handler = new BlockUserCommandHandler(dbContext, _identityService, _gateway, _notifier, _telegramNotifier, _currentUser); var handler = new BlockUserCommandHandler(dbContext, _identityService, _gateway, _notifier, _telegramNotifier, _currentUser, _logger);
var result = await handler.Handle(new BlockUserCommand(userId), CancellationToken.None); var result = await handler.Handle(new BlockUserCommand(userId), CancellationToken.None);
@@ -91,4 +97,37 @@ public class BlockUserCommandHandlerTests
Arg.Any<Node>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<VpnProtocol>(), Arg.Any<Node>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<VpnProtocol>(),
Arg.Any<string>(), Arg.Any<int>(), Arg.Any<bool>(), Arg.Any<CancellationToken>()); Arg.Any<string>(), Arg.Any<int>(), Arg.Any<bool>(), Arg.Any<CancellationToken>());
} }
[Fact]
public async Task Handle_GatewayFails_KeepsConfigActiveForRetryAndDoesNotNotify()
{
using var dbContext = InMemoryDbContextFactory.Create();
var userId = Guid.NewGuid();
var adminId = Guid.NewGuid();
var node = Node.Register("node-1", new Uri("https://node1.example.com"), new NodeCredentials("admin", "protected"), null);
var inbound = Inbound.FromRemote(node.Id, "1", VpnProtocol.Vless, "remark", 443);
var config = VpnConfig.Create(userId, inbound.Id, VpnProtocol.Vless, "my-config", 0);
dbContext.Nodes.Add(node);
dbContext.Inbounds.Add(inbound);
dbContext.VpnConfigs.Add(config);
await dbContext.SaveChangesAsync(CancellationToken.None);
_identityService.BlockUserAsync(userId, Arg.Any<CancellationToken>()).Returns(Result.Success());
_currentUser.UserId.Returns(adminId);
_gateway.UpdateClientAsync(
Arg.Any<Node>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<VpnProtocol>(),
Arg.Any<string>(), Arg.Any<int>(), Arg.Any<bool>(), Arg.Any<CancellationToken>())
.Returns(Result.Failure(Error.Failure("Xui.UpdateClientFailed", "Нода недоступна.")));
var handler = new BlockUserCommandHandler(dbContext, _identityService, _gateway, _notifier, _telegramNotifier, _currentUser, _logger);
var result = await handler.Handle(new BlockUserCommand(userId), CancellationToken.None);
Assert.True(result.IsSuccess);
Assert.Equal(ConfigStatus.Active, config.Status);
await _notifier.DidNotReceive().NotifyConfigStatusChangedAsync(
Arg.Any<Guid>(), Arg.Any<Guid>(), Arg.Any<ConfigStatus>(), Arg.Any<CancellationToken>());
}
} }
@@ -1,3 +1,4 @@
using Microsoft.Extensions.Logging;
using NSubstitute; using NSubstitute;
using PnvPanel.Application.Admin.Users; using PnvPanel.Application.Admin.Users;
using PnvPanel.Application.Common.Interfaces; using PnvPanel.Application.Common.Interfaces;
@@ -16,6 +17,7 @@ public class UnblockUserCommandHandlerTests
private readonly IXuiPanelGateway _gateway = Substitute.For<IXuiPanelGateway>(); private readonly IXuiPanelGateway _gateway = Substitute.For<IXuiPanelGateway>();
private readonly IRealtimeNotifier _notifier = Substitute.For<IRealtimeNotifier>(); private readonly IRealtimeNotifier _notifier = Substitute.For<IRealtimeNotifier>();
private readonly ICurrentUser _currentUser = Substitute.For<ICurrentUser>(); private readonly ICurrentUser _currentUser = Substitute.For<ICurrentUser>();
private readonly ILogger<UnblockUserCommandHandler> _logger = Substitute.For<ILogger<UnblockUserCommandHandler>>();
[Fact] [Fact]
public async Task Handle_WhenUnblockSucceeds_ReEnablesDisabledConfigsAndWritesAudit() public async Task Handle_WhenUnblockSucceeds_ReEnablesDisabledConfigsAndWritesAudit()
@@ -36,8 +38,12 @@ public class UnblockUserCommandHandlerTests
_currentUser.UserId.Returns(adminId); _currentUser.UserId.Returns(adminId);
_identityService.UnblockUserAsync(userId, Arg.Any<CancellationToken>()).Returns(Result.Success()); _identityService.UnblockUserAsync(userId, Arg.Any<CancellationToken>()).Returns(Result.Success());
_gateway.UpdateClientAsync(
Arg.Any<Node>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<VpnProtocol>(),
Arg.Any<string>(), Arg.Any<int>(), Arg.Any<bool>(), Arg.Any<CancellationToken>())
.Returns(Result.Success());
var handler = new UnblockUserCommandHandler(dbContext, _identityService, _gateway, _notifier, _currentUser); var handler = new UnblockUserCommandHandler(dbContext, _identityService, _gateway, _notifier, _currentUser, _logger);
var result = await handler.Handle(new UnblockUserCommand(userId), CancellationToken.None); var result = await handler.Handle(new UnblockUserCommand(userId), CancellationToken.None);
@@ -60,7 +66,7 @@ public class UnblockUserCommandHandlerTests
_identityService.UnblockUserAsync(userId, Arg.Any<CancellationToken>()).Returns(Result.Failure(error)); _identityService.UnblockUserAsync(userId, Arg.Any<CancellationToken>()).Returns(Result.Failure(error));
var handler = new UnblockUserCommandHandler(dbContext, _identityService, _gateway, _notifier, _currentUser); var handler = new UnblockUserCommandHandler(dbContext, _identityService, _gateway, _notifier, _currentUser, _logger);
var result = await handler.Handle(new UnblockUserCommand(userId), CancellationToken.None); var result = await handler.Handle(new UnblockUserCommand(userId), CancellationToken.None);
@@ -68,4 +74,38 @@ public class UnblockUserCommandHandlerTests
Assert.Equal(error, result.Error); Assert.Equal(error, result.Error);
Assert.Empty(dbContext.AuditLogs); Assert.Empty(dbContext.AuditLogs);
} }
[Fact]
public async Task Handle_GatewayFails_KeepsConfigDisabledForRetryAndDoesNotNotify()
{
using var dbContext = InMemoryDbContextFactory.Create();
var userId = Guid.NewGuid();
var adminId = Guid.NewGuid();
var node = Node.Register("node-1", new Uri("https://node1.example.com"), new NodeCredentials("admin", "protected"), null);
var inbound = Inbound.FromRemote(node.Id, "1", VpnProtocol.Vless, "remark", 443);
var config = VpnConfig.Create(userId, inbound.Id, VpnProtocol.Vless, "my-config", 0);
config.Disable();
dbContext.Nodes.Add(node);
dbContext.Inbounds.Add(inbound);
dbContext.VpnConfigs.Add(config);
await dbContext.SaveChangesAsync(CancellationToken.None);
_currentUser.UserId.Returns(adminId);
_identityService.UnblockUserAsync(userId, Arg.Any<CancellationToken>()).Returns(Result.Success());
_gateway.UpdateClientAsync(
Arg.Any<Node>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<VpnProtocol>(),
Arg.Any<string>(), Arg.Any<int>(), Arg.Any<bool>(), Arg.Any<CancellationToken>())
.Returns(Result.Failure(Error.Failure("Xui.UpdateClientFailed", "Нода недоступна.")));
var handler = new UnblockUserCommandHandler(dbContext, _identityService, _gateway, _notifier, _currentUser, _logger);
var result = await handler.Handle(new UnblockUserCommand(userId), CancellationToken.None);
Assert.True(result.IsSuccess);
Assert.Equal(ConfigStatus.Disabled, config.Status);
await _notifier.DidNotReceive().NotifyConfigStatusChangedAsync(
Arg.Any<Guid>(), Arg.Any<Guid>(), Arg.Any<ConfigStatus>(), Arg.Any<CancellationToken>());
}
} }
@@ -27,7 +27,7 @@ export function PublishInboundDialog({
const [maxClients, setMaxClients] = useState(inbound.maxClients?.toString() ?? '') const [maxClients, setMaxClients] = useState(inbound.maxClients?.toString() ?? '')
const [selectedRoles, setSelectedRoles] = useState<Set<string>>(new Set(inbound.allowedRoleIds)) const [selectedRoles, setSelectedRoles] = useState<Set<string>>(new Set(inbound.allowedRoleIds))
const rolesQuery = useQuery({ queryKey: ['admin-roles-page'], queryFn: listRoles, enabled: open }) const rolesQuery = useQuery({ queryKey: ['admin-roles'], queryFn: listRoles, enabled: open })
const mutation = useMutation({ const mutation = useMutation({
mutationFn: () => mutationFn: () =>
@@ -35,6 +35,8 @@ export function PublishInboundDialog({
onSuccess: async () => { onSuccess: async () => {
toast.success(t('admin.nodes.publishSaved')) toast.success(t('admin.nodes.publishSaved'))
await queryClient.invalidateQueries({ queryKey: ['admin-inbounds', inbound.nodeId] }) await queryClient.invalidateQueries({ queryKey: ['admin-inbounds', inbound.nodeId] })
// Публикация/список ролей инбаунда влияет на то, что видит пользователь при создании конфига.
await queryClient.invalidateQueries({ queryKey: ['available-inbounds'] })
onOpenChange(false) onOpenChange(false)
}, },
onError: (error) => toast.error(error instanceof HttpError ? error.detail : t('auth.genericError')), onError: (error) => toast.error(error instanceof HttpError ? error.detail : t('auth.genericError')),
@@ -51,6 +51,8 @@ export function NodeCard({ node }: { node: NodeDto }) {
toast.success(t('admin.nodes.syncSuccess', { count: result.inboundsSynced })) toast.success(t('admin.nodes.syncSuccess', { count: result.inboundsSynced }))
await invalidateNodes() await invalidateNodes()
await queryClient.invalidateQueries({ queryKey: ['admin-inbounds', node.id] }) await queryClient.invalidateQueries({ queryKey: ['admin-inbounds', node.id] })
// Синхронизация могла добавить/убрать инбаунды, доступные пользователю при создании конфига.
await queryClient.invalidateQueries({ queryKey: ['available-inbounds'] })
}, },
onError: (error) => toast.error(error instanceof HttpError ? error.detail : t('auth.genericError')), onError: (error) => toast.error(error instanceof HttpError ? error.detail : t('auth.genericError')),
}) })
@@ -33,7 +33,7 @@ export function RoleFormDialog({
mutationFn: () => (role ? updateRole(role.id, Number(maxConfigs)) : createRole(name.trim(), Number(maxConfigs))), mutationFn: () => (role ? updateRole(role.id, Number(maxConfigs)) : createRole(name.trim(), Number(maxConfigs))),
onSuccess: async () => { onSuccess: async () => {
toast.success(role ? t('admin.roles.updated') : t('admin.roles.created')) toast.success(role ? t('admin.roles.updated') : t('admin.roles.created'))
await queryClient.invalidateQueries({ queryKey: ['admin-roles-page'] }) await queryClient.invalidateQueries({ queryKey: ['admin-roles'] })
setDialogOpen(false) setDialogOpen(false)
setName('') setName('')
setMaxConfigs('3') setMaxConfigs('3')
+6
View File
@@ -8,6 +8,12 @@ import { ThemeProvider } from './theme/ThemeProvider'
import { ToastProvider } from './shared/ui/toast-store' import { ToastProvider } from './shared/ui/toast-store'
import { RealtimeProvider } from './shared/realtime/RealtimeProvider' import { RealtimeProvider } from './shared/realtime/RealtimeProvider'
import { router } from './router' import { router } from './router'
import { setUnauthorizedHandler } from './shared/api/client'
import { clearSession } from './features/auth/api'
// Если refresh-токен недействителен (истёк/отозван) — очищаем стор авторизации, чтобы
// useRequireAuth/useRequireAdmin увидели user === null и сами увели на /login.
setUnauthorizedHandler(clearSession)
const queryClient = new QueryClient() const queryClient = new QueryClient()
+2 -2
View File
@@ -16,13 +16,13 @@ function AdminRolesPage() {
const queryClient = useQueryClient() const queryClient = useQueryClient()
const [editing, setEditing] = useState<RoleDto | null>(null) const [editing, setEditing] = useState<RoleDto | null>(null)
const { data, isLoading, isError, refetch } = useQuery({ queryKey: ['admin-roles-page'], queryFn: listRoles }) const { data, isLoading, isError, refetch } = useQuery({ queryKey: ['admin-roles'], queryFn: listRoles })
const deleteMutation = useMutation({ const deleteMutation = useMutation({
mutationFn: deleteRole, mutationFn: deleteRole,
onSuccess: async () => { onSuccess: async () => {
toast.success(t('admin.roles.deleted')) toast.success(t('admin.roles.deleted'))
await queryClient.invalidateQueries({ queryKey: ['admin-roles-page'] }) await queryClient.invalidateQueries({ queryKey: ['admin-roles'] })
}, },
onError: () => toast.error(t('auth.genericError')), onError: () => toast.error(t('auth.genericError')),
}) })
+1
View File
@@ -18,6 +18,7 @@ export default defineConfig({
alias: { '@': path.resolve(__dirname, './src') }, alias: { '@': path.resolve(__dirname, './src') },
}, },
server: { server: {
port: process.env.PORT ? Number(process.env.PORT) : 5173,
proxy: { proxy: {
'/api': { target: apiTarget, changeOrigin: true }, '/api': { target: apiTarget, changeOrigin: true },
'/hubs': { target: apiTarget, changeOrigin: true, ws: true }, '/hubs': { target: apiTarget, changeOrigin: true, ws: true },