Initial commit: base slice (auth, roles, users, admin) scaffold

Backend: .NET 10 Clean Architecture + LiteCqrs.Net + EF Core/PostgreSQL +
Identity/JWT. Frontend: React 19 + Vite + TanStack Query/Router + Tailwind v4
with a retro CRT theme. Docker/compose deployment mirroring PnvPanel's
conventions, scoped down to the current base feature set.
This commit is contained in:
Leonid Pershin
2026-07-24 05:40:34 +03:00
commit 8a3eebc48f
156 changed files with 9335 additions and 0 deletions
@@ -0,0 +1,6 @@
namespace TeleWave.Api.Common;
public static class RateLimiting
{
public const string AuthPolicy = "auth";
}
@@ -0,0 +1,27 @@
using TeleWave.Application.Common.Models;
namespace TeleWave.Api.Common;
public static class ResultExtensions
{
public static IResult ToHttpResult(this Result result) =>
result.IsSuccess ? Results.NoContent() : ToProblem(result.Error);
public static IResult ToHttpResult<T>(this Result<T> result) =>
result.IsSuccess ? Results.Ok(result.Value) : ToProblem(result.Error);
private static IResult ToProblem(Error error)
{
var statusCode = error.Type switch
{
ErrorType.Validation => StatusCodes.Status400BadRequest,
ErrorType.Unauthorized => StatusCodes.Status401Unauthorized,
ErrorType.Forbidden => StatusCodes.Status403Forbidden,
ErrorType.NotFound => StatusCodes.Status404NotFound,
ErrorType.Conflict => StatusCodes.Status409Conflict,
_ => StatusCodes.Status422UnprocessableEntity,
};
return Results.Problem(title: error.Code, detail: error.Message, statusCode: statusCode);
}
}
@@ -0,0 +1,91 @@
using LiteCqrs;
using TeleWave.Api.Common;
using TeleWave.Application.Admin.Users.BlockUser;
using TeleWave.Application.Admin.Users.DeleteUser;
using TeleWave.Application.Admin.Users.GetUser;
using TeleWave.Application.Admin.Users.ListUsers;
using TeleWave.Application.Admin.Users.UnblockUser;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
using TeleWave.Infrastructure.Identity;
namespace TeleWave.Api.Endpoints;
public static class AdminUserEndpoints
{
public static IEndpointRouteBuilder MapAdminUserEndpoints(this IEndpointRouteBuilder app)
{
var admin = app.MapGroup("/api/admin/users")
.WithTags("Admin.Users")
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
admin.MapGet("", ListUsers).Produces<PagedList<UserSummaryDto>>();
admin.MapGet("/{id:guid}", GetUser).Produces<UserSummaryDto>();
admin
.MapPost("/{id:guid}/block", BlockUser)
.Produces(StatusCodes.Status204NoContent);
admin
.MapPost("/{id:guid}/unblock", UnblockUser)
.Produces(StatusCodes.Status204NoContent);
admin.MapDelete("/{id:guid}", DeleteUser).Produces(StatusCodes.Status204NoContent);
return app;
}
private static async Task<IResult> ListUsers(
int page,
int pageSize,
string? search,
Guid? roleId,
bool? isBlocked,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new ListUsersQuery(page <= 0 ? 1 : page, pageSize <= 0 ? 20 : pageSize, search, roleId, isBlocked),
cancellationToken
);
return Results.Ok(result);
}
private static async Task<IResult> GetUser(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new GetUserQuery(id), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> BlockUser(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new BlockUserCommand(id), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> UnblockUser(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new UnblockUserCommand(id), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> DeleteUser(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new DeleteUserCommand(id), cancellationToken);
return result.ToHttpResult();
}
}
@@ -0,0 +1,204 @@
using LiteCqrs;
using TeleWave.Api.Common;
using TeleWave.Application.Auth;
using TeleWave.Application.Auth.ChangePassword;
using TeleWave.Application.Auth.ChangeUserName;
using TeleWave.Application.Auth.DeleteMyAccount;
using TeleWave.Application.Auth.Login;
using TeleWave.Application.Auth.Logout;
using TeleWave.Application.Auth.Me;
using TeleWave.Application.Auth.Refresh;
using TeleWave.Application.Auth.Register;
namespace TeleWave.Api.Endpoints;
public static class AuthEndpoints
{
private const string RefreshCookieName = "tw_refresh_token";
public static IEndpointRouteBuilder MapAuthEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/api/auth")
.WithTags("Auth")
.RequireRateLimiting(RateLimiting.AuthPolicy);
group.MapPost("/register", Register).Produces<AuthResponseDto>();
group.MapPost("/login", Login).Produces<AuthResponseDto>();
group.MapPost("/refresh", Refresh).Produces<AuthResponseDto>();
group
.MapPost("/logout", Logout)
.RequireAuthorization()
.Produces(StatusCodes.Status204NoContent);
group
.MapPost("/change-password", ChangePassword)
.RequireAuthorization()
.Produces(StatusCodes.Status204NoContent);
group
.MapPost("/change-username", ChangeUserName)
.RequireAuthorization()
.Produces(StatusCodes.Status204NoContent);
group.MapGet("/me", Me).RequireAuthorization().Produces<CurrentUserDto>();
group
.MapDelete("/me", DeleteMe)
.RequireAuthorization()
.Produces(StatusCodes.Status204NoContent);
return app;
}
private static async Task<IResult> Register(
RegisterCommand command,
ISender sender,
HttpRequest request,
HttpResponse response,
CancellationToken cancellationToken
)
{
var result = await sender.Send(command, cancellationToken);
if (!result.IsSuccess)
return result.ToHttpResult();
SetRefreshCookie(
request,
response,
result.Value.RefreshToken,
result.Value.RefreshTokenExpiresAt
);
return Results.Ok(ToLoginResponse(result.Value));
}
private static async Task<IResult> Login(
LoginCommand command,
ISender sender,
HttpRequest request,
HttpResponse response,
CancellationToken cancellationToken
)
{
var result = await sender.Send(command, cancellationToken);
if (!result.IsSuccess)
return result.ToHttpResult();
SetRefreshCookie(
request,
response,
result.Value.RefreshToken,
result.Value.RefreshTokenExpiresAt
);
return Results.Ok(ToLoginResponse(result.Value));
}
private static async Task<IResult> Refresh(
HttpRequest request,
HttpResponse response,
ISender sender,
CancellationToken cancellationToken
)
{
if (
!request.Cookies.TryGetValue(RefreshCookieName, out var rawToken)
|| string.IsNullOrEmpty(rawToken)
)
return Results.Unauthorized();
var result = await sender.Send(new RefreshCommand(rawToken), cancellationToken);
if (!result.IsSuccess)
{
response.Cookies.Delete(RefreshCookieName, BuildCookieOptions(request));
return result.ToHttpResult();
}
SetRefreshCookie(
request,
response,
result.Value.RefreshToken,
result.Value.RefreshTokenExpiresAt
);
return Results.Ok(ToLoginResponse(result.Value));
}
internal static AuthResponseDto ToLoginResponse(AuthResult auth) =>
new(auth.AccessToken, auth.AccessTokenExpiresAt, auth.User);
private static async Task<IResult> Logout(
HttpRequest request,
HttpResponse response,
ISender sender,
CancellationToken cancellationToken
)
{
if (
request.Cookies.TryGetValue(RefreshCookieName, out var rawToken)
&& !string.IsNullOrEmpty(rawToken)
)
await sender.Send(new LogoutCommand(rawToken), cancellationToken);
response.Cookies.Delete(RefreshCookieName, BuildCookieOptions(request));
return Results.NoContent();
}
private static async Task<IResult> ChangePassword(
ChangePasswordCommand command,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(command, cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> ChangeUserName(
ChangeUserNameCommand command,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(command, cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> Me(ISender sender, CancellationToken cancellationToken)
{
var result = await sender.Send(new GetCurrentUserQuery(), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> DeleteMe(
HttpRequest request,
HttpResponse response,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new DeleteMyAccountCommand(), cancellationToken);
response.Cookies.Delete(RefreshCookieName, BuildCookieOptions(request));
return result.ToHttpResult();
}
private static void SetRefreshCookie(
HttpRequest request,
HttpResponse response,
string rawToken,
DateTimeOffset expiresAt
)
{
var options = BuildCookieOptions(request);
options.Expires = expiresAt;
response.Cookies.Append(RefreshCookieName, rawToken, options);
}
private static CookieOptions BuildCookieOptions(HttpRequest request) =>
new()
{
HttpOnly = true,
Secure = request.IsHttps,
SameSite = SameSiteMode.Strict,
Path = "/api/auth",
};
}
public sealed record AuthResponseDto(
string AccessToken,
DateTimeOffset ExpiresAt,
CurrentUserDto User
);
@@ -0,0 +1,86 @@
using LiteCqrs;
using TeleWave.Api.Common;
using TeleWave.Application.Admin.Roles.ChangeUserRole;
using TeleWave.Application.Admin.Roles.CreateRole;
using TeleWave.Application.Admin.Roles.DeleteRole;
using TeleWave.Application.Admin.Roles.ListRoles;
using TeleWave.Application.Admin.Roles.UpdateRole;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Infrastructure.Identity;
namespace TeleWave.Api.Endpoints;
public static class RoleEndpoints
{
public static IEndpointRouteBuilder MapRoleEndpoints(this IEndpointRouteBuilder app)
{
var admin = app.MapGroup("/api/admin")
.WithTags("Admin.Roles")
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
admin.MapGet("/roles", ListRoles).Produces<IReadOnlyList<RoleDto>>();
admin.MapPost("/roles", CreateRole).Produces<RoleDto>();
admin.MapPut("/roles/{id:guid}", UpdateRole).Produces<RoleDto>();
admin.MapDelete("/roles/{id:guid}", DeleteRole).Produces(StatusCodes.Status204NoContent);
admin
.MapPatch("/users/{id:guid}/role", ChangeUserRole)
.Produces(StatusCodes.Status204NoContent);
return app;
}
private static async Task<IResult> ListRoles(ISender sender, CancellationToken cancellationToken)
{
var roles = await sender.Send(new ListRolesQuery(), cancellationToken);
return Results.Ok(roles);
}
private static async Task<IResult> CreateRole(
CreateRoleCommand command,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(command, cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> UpdateRole(
Guid id,
UpdateRoleBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new UpdateRoleCommand(id, body.Name), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> DeleteRole(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new DeleteRoleCommand(id), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> ChangeUserRole(
Guid id,
ChangeUserRoleBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new ChangeUserRoleCommand(id, body.RoleId),
cancellationToken
);
return result.ToHttpResult();
}
}
public sealed record UpdateRoleBody(string Name);
public sealed record ChangeUserRoleBody(Guid RoleId);
+117
View File
@@ -0,0 +1,117 @@
using System.Net;
using System.Text.Json.Serialization;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.AspNetCore.RateLimiting;
using TeleWave.Api.Common;
using TeleWave.Api.Endpoints;
using TeleWave.Application;
using TeleWave.Infrastructure;
using TeleWave.Infrastructure.Identity;
using TeleWave.Infrastructure.Persistence;
using Scalar.AspNetCore;
using Serilog;
var builder = WebApplication.CreateBuilder(args);
// Структурное логирование (Serilog), конфигурация из appsettings/env.
builder.Services.AddSerilog(
(services, configuration) =>
configuration
.ReadFrom.Configuration(builder.Configuration)
.ReadFrom.Services(services)
.Enrich.FromLogContext()
);
// За внешним прокси доверяем X-Forwarded-* (TLS терминируется вне контейнера), но ТОЛЬКО от явно
// перечисленных адресов/сетей прокси — иначе клиент может подделать свой IP/схему напрямую, минуя
// прокси. По умолчанию (без конфигурации) остаётся дефолт ASP.NET Core — доверие только loopback;
// для прод-топологии прокси задаётся через ForwardedHeaders__KnownProxies/KnownNetworks (см. .env.example).
builder.Services.Configure<ForwardedHeadersOptions>(options =>
{
options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
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.AddApplication();
builder.Services.AddInfrastructure(builder.Configuration);
builder.Services.AddRateLimiter(options =>
{
options.AddFixedWindowLimiter(
RateLimiting.AuthPolicy,
limiterOptions =>
{
limiterOptions.PermitLimit = builder.Configuration.GetValue(
"RateLimiting:AuthPermitLimit",
20
);
limiterOptions.Window = TimeSpan.FromMinutes(1);
limiterOptions.QueueLimit = 0;
}
);
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
});
// Энумы сериализуются строками, не числами — самодокументируемый JSON.
builder.Services.ConfigureHttpJsonOptions(options =>
options.SerializerOptions.Converters.Add(new JsonStringEnumConverter())
);
builder.Services.AddProblemDetails();
builder.Services.AddOpenApi();
builder.Services.AddHealthChecks().AddDbContextCheck<AppDbContext>();
var app = builder.Build();
// Авто-применение миграций и идемпотентный сидинг (роли + админ из env) на старте.
await app.Services.ApplyMigrationsAsync();
await app.Services.SeedDataAsync();
app.UseForwardedHeaders();
app.UseSerilogRequestLogging();
app.UseExceptionHandler();
app.UseRateLimiter();
app.UseAuthentication();
app.UseAuthorization();
app.MapOpenApi();
app.MapScalarApiReference();
app.MapHealthChecks("/health");
app.MapAuthEndpoints();
app.MapRoleEndpoints();
app.MapAdminUserEndpoints();
// Раздача статики SPA из wwwroot + fallback на index.html для клиентских маршрутов.
app.UseDefaultFiles();
app.UseStaticFiles();
app.MapFallbackToFile("index.html");
app.Run();
/// <summary>Делает неявный класс Program доступным для WebApplicationFactory&lt;Program&gt; в интеграционных тестах.</summary>
public partial class Program;
@@ -0,0 +1,14 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "http://localhost:8080",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}
@@ -0,0 +1,24 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<ItemGroup>
<ProjectReference Include="..\TeleWave.Infrastructure\TeleWave.Infrastructure.csproj" />
<ProjectReference Include="..\TeleWave.Application\TeleWave.Application.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" />
<PackageReference Include="Microsoft.OpenApi" />
<PackageReference Include="Scalar.AspNetCore" />
<PackageReference Include="Serilog.AspNetCore" />
</ItemGroup>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
</Project>
@@ -0,0 +1,12 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AdminSeed": {
"Username": "admin",
"Password": "Passw0rd!Dev"
}
}
+29
View File
@@ -0,0 +1,29 @@
{
"ConnectionStrings": {
"Default": "Host=localhost;Port=5432;Database=telewave;Username=telewave;Password=telewave"
},
"Jwt": {
"Issuer": "TeleWave",
"Audience": "TeleWave",
"SigningKey": "change-me-min-32-chars-random-secret",
"AccessTokenMinutes": 15,
"RefreshTokenDays": 30
},
"AdminSeed": {
"Username": "",
"Password": ""
},
"Serilog": {
"Using": [ "Serilog.Sinks.Console" ],
"MinimumLevel": {
"Default": "Information",
"Override": {
"Microsoft.AspNetCore": "Warning",
"Microsoft.EntityFrameworkCore": "Warning"
}
},
"WriteTo": [ { "Name": "Console" } ],
"Enrich": [ "FromLogContext" ]
},
"AllowedHosts": "*"
}
@@ -0,0 +1,7 @@
<!doctype html>
<html>
<head><meta charset="utf-8" /><title>TeleWave</title></head>
<body>
<p>Frontend build not present yet — run the Vite build to populate wwwroot.</p>
</body>
</html>