Enhance documentation with new features: added dark/light/system theme support, instructions page, and application catalog. Updated API and domain model for app management and automatic migrations on startup. Improved frontend structure with new routes and features for user instructions and app management.

This commit is contained in:
Leonid Pershin
2026-07-01 22:38:01 +03:00
parent d8930409fe
commit 1a8d33efa3
229 changed files with 9226 additions and 20 deletions
@@ -0,0 +1,21 @@
using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Auth;
public static class AuthErrors
{
public static readonly Error DuplicateUserName =
Error.Conflict("Auth.DuplicateUserName", "Пользователь с таким именем уже существует.");
public static readonly Error InvalidCredentials =
Error.Unauthorized("Auth.InvalidCredentials", "Неверное имя пользователя или пароль.");
public static readonly Error LockedOut =
Error.Unauthorized("Auth.LockedOut", "Слишком много неудачных попыток входа. Попробуйте позже.");
public static readonly Error InvalidRefreshToken =
Error.Unauthorized("Auth.InvalidRefreshToken", "Недействительный refresh-токен.");
public static readonly Error Unauthorized =
Error.Unauthorized("Auth.Unauthorized", "Требуется аутентификация.");
}
@@ -0,0 +1,8 @@
namespace PnvPanel.Application.Auth;
public sealed record AuthResult(
string AccessToken,
DateTimeOffset AccessTokenExpiresAt,
string RefreshToken,
DateTimeOffset RefreshTokenExpiresAt,
CurrentUserDto User);
@@ -0,0 +1,6 @@
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Auth.ChangePassword;
public sealed record ChangePasswordCommand(string CurrentPassword, string NewPassword) : ICommand<Result>;
@@ -0,0 +1,17 @@
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Auth.ChangePassword;
public sealed class ChangePasswordCommandHandler(IIdentityService identityService, ICurrentUser currentUser)
: ICommandHandler<ChangePasswordCommand, Result>
{
public Task<Result> Handle(ChangePasswordCommand command, CancellationToken cancellationToken)
{
if (currentUser.UserId is not { } userId)
return Task.FromResult(Result.Failure(AuthErrors.Unauthorized));
return identityService.ChangePasswordAsync(userId, command.CurrentPassword, command.NewPassword, cancellationToken);
}
}
@@ -0,0 +1,12 @@
using FluentValidation;
namespace PnvPanel.Application.Auth.ChangePassword;
public sealed class ChangePasswordCommandValidator : AbstractValidator<ChangePasswordCommand>
{
public ChangePasswordCommandValidator()
{
RuleFor(x => x.CurrentPassword).NotEmpty();
RuleFor(x => x.NewPassword).NotEmpty().MinimumLength(8);
}
}
@@ -0,0 +1,3 @@
namespace PnvPanel.Application.Auth;
public sealed record CurrentUserDto(Guid Id, string UserName, string Role, bool IsActivated);
@@ -0,0 +1,6 @@
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Auth.DeleteMyAccount;
public sealed record DeleteMyAccountCommand : ICommand<Result>;
@@ -0,0 +1,38 @@
using Microsoft.EntityFrameworkCore;
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
using PnvPanel.Domain.Configs;
namespace PnvPanel.Application.Auth.DeleteMyAccount;
public sealed class DeleteMyAccountCommandHandler(IAppDbContext dbContext, IIdentityService identityService, IXuiPanelGateway gateway, ICurrentUser currentUser)
: ICommandHandler<DeleteMyAccountCommand, Result>
{
public async Task<Result> Handle(DeleteMyAccountCommand command, CancellationToken cancellationToken)
{
if (currentUser.UserId is not { } userId)
return Result.Failure(AuthErrors.Unauthorized);
var configs = await dbContext.VpnConfigs
.Where(c => c.UserId == userId && c.Status != ConfigStatus.Revoked)
.ToListAsync(cancellationToken);
foreach (var config in configs)
{
var inbound = await dbContext.Inbounds.AsNoTracking().FirstOrDefaultAsync(i => i.Id == config.InboundId, cancellationToken);
var node = inbound is null
? null
: await dbContext.Nodes.AsNoTracking().FirstOrDefaultAsync(n => n.Id == inbound.NodeId, cancellationToken);
if (inbound is not null && node is not null)
await gateway.RemoveClientAsync(node, inbound.RemoteInboundId, config.ClientExternalId, config.Protocol, cancellationToken);
config.Revoke();
}
await dbContext.SaveChangesAsync(cancellationToken);
return await identityService.DeleteUserAsync(userId, cancellationToken);
}
}
@@ -0,0 +1,6 @@
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Auth.Login;
public sealed record LoginCommand(string UserName, string Password) : ICommand<Result<AuthResult>>;
@@ -0,0 +1,35 @@
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Auth.Login;
public sealed class LoginCommandHandler(
IIdentityService identityService,
IJwtTokenService jwtTokenService,
IRefreshTokenService refreshTokenService) : ICommandHandler<LoginCommand, Result<AuthResult>>
{
public async Task<Result<AuthResult>> Handle(LoginCommand command, CancellationToken cancellationToken)
{
var credentialsResult = await identityService.ValidateCredentialsAsync(command.UserName, command.Password, cancellationToken);
if (!credentialsResult.IsSuccess)
return Result.Failure<AuthResult>(credentialsResult.Error);
var user = credentialsResult.Value;
var profile = await identityService.GetProfileAsync(user.Id, cancellationToken);
if (profile is null)
return Result.Failure<AuthResult>(AuthErrors.InvalidCredentials);
var (accessToken, accessExpiresAt) = jwtTokenService.GenerateAccessToken(user);
var refreshToken = await refreshTokenService.IssueAsync(user.Id, cancellationToken);
var dto = new CurrentUserDto(profile.Id, profile.UserName, profile.Role, profile.IsActivated);
return Result.Success(new AuthResult(
accessToken,
accessExpiresAt,
refreshToken.RawToken,
refreshToken.ExpiresAt,
dto));
}
}
@@ -0,0 +1,12 @@
using FluentValidation;
namespace PnvPanel.Application.Auth.Login;
public sealed class LoginCommandValidator : AbstractValidator<LoginCommand>
{
public LoginCommandValidator()
{
RuleFor(x => x.UserName).NotEmpty();
RuleFor(x => x.Password).NotEmpty();
}
}
@@ -0,0 +1,6 @@
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Auth.Logout;
public sealed record LogoutCommand(string RawRefreshToken) : ICommand<Result>;
@@ -0,0 +1,15 @@
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Auth.Logout;
public sealed class LogoutCommandHandler(IRefreshTokenService refreshTokenService)
: ICommandHandler<LogoutCommand, Result>
{
public async Task<Result> Handle(LogoutCommand command, CancellationToken cancellationToken)
{
await refreshTokenService.RevokeAsync(command.RawRefreshToken, cancellationToken);
return Result.Success();
}
}
@@ -0,0 +1,6 @@
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Auth.Me;
public sealed record GetCurrentUserQuery : IQuery<Result<CurrentUserDto>>;
@@ -0,0 +1,21 @@
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Auth.Me;
public sealed class GetCurrentUserQueryHandler(IIdentityService identityService, ICurrentUser currentUser)
: IQueryHandler<GetCurrentUserQuery, Result<CurrentUserDto>>
{
public async Task<Result<CurrentUserDto>> Handle(GetCurrentUserQuery query, CancellationToken cancellationToken)
{
if (currentUser.UserId is not { } userId)
return Result.Failure<CurrentUserDto>(AuthErrors.Unauthorized);
var profile = await identityService.GetProfileAsync(userId, cancellationToken);
if (profile is null)
return Result.Failure<CurrentUserDto>(AuthErrors.Unauthorized);
return Result.Success(new CurrentUserDto(profile.Id, profile.UserName, profile.Role, profile.IsActivated));
}
}
@@ -0,0 +1,6 @@
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Auth.Refresh;
public sealed record RefreshCommand(string RawRefreshToken) : ICommand<Result<AuthResult>>;
@@ -0,0 +1,34 @@
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Auth.Refresh;
public sealed class RefreshCommandHandler(
IIdentityService identityService,
IJwtTokenService jwtTokenService,
IRefreshTokenService refreshTokenService) : ICommandHandler<RefreshCommand, Result<AuthResult>>
{
public async Task<Result<AuthResult>> Handle(RefreshCommand command, CancellationToken cancellationToken)
{
var rotated = await refreshTokenService.RotateAsync(command.RawRefreshToken, cancellationToken);
if (!rotated.IsSuccess)
return Result.Failure<AuthResult>(rotated.Error);
var profile = await identityService.GetProfileAsync(rotated.Value.UserId, cancellationToken);
if (profile is null)
return Result.Failure<AuthResult>(AuthErrors.InvalidRefreshToken);
var authUser = new AuthenticatedUser(profile.Id, profile.UserName, profile.Role);
var (accessToken, accessExpiresAt) = jwtTokenService.GenerateAccessToken(authUser);
var dto = new CurrentUserDto(profile.Id, profile.UserName, profile.Role, profile.IsActivated);
return Result.Success(new AuthResult(
accessToken,
accessExpiresAt,
rotated.Value.RawToken,
rotated.Value.ExpiresAt,
dto));
}
}
@@ -0,0 +1,11 @@
using FluentValidation;
namespace PnvPanel.Application.Auth.Refresh;
public sealed class RefreshCommandValidator : AbstractValidator<RefreshCommand>
{
public RefreshCommandValidator()
{
RuleFor(x => x.RawRefreshToken).NotEmpty();
}
}
@@ -0,0 +1,8 @@
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Auth.Register;
public sealed record RegisterCommand(string UserName, string Password) : ICommand<Result<RegisterResult>>;
public sealed record RegisterResult(Guid Id, string UserName);
@@ -0,0 +1,18 @@
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Auth.Register;
public sealed class RegisterCommandHandler(IIdentityService identityService)
: ICommandHandler<RegisterCommand, Result<RegisterResult>>
{
public async Task<Result<RegisterResult>> Handle(RegisterCommand command, CancellationToken cancellationToken)
{
var result = await identityService.CreateUserAsync(command.UserName, command.Password, cancellationToken);
return result.IsSuccess
? Result.Success(new RegisterResult(result.Value, command.UserName))
: Result.Failure<RegisterResult>(result.Error);
}
}
@@ -0,0 +1,19 @@
using FluentValidation;
namespace PnvPanel.Application.Auth.Register;
public sealed class RegisterCommandValidator : AbstractValidator<RegisterCommand>
{
public RegisterCommandValidator()
{
RuleFor(x => x.UserName)
.NotEmpty()
.Length(3, 32)
.Matches("^[a-zA-Z0-9_.-]+$")
.WithMessage("Имя пользователя может содержать только латиницу, цифры, '_', '.', '-'.");
RuleFor(x => x.Password)
.NotEmpty()
.MinimumLength(8);
}
}