mirror of
https://github.com/aaronpo97/the-biergarten-app.git
synced 2026-02-16 02:39:03 +00:00
Update exception handling (#146)
This commit is contained in:
@@ -18,7 +18,8 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\Domain\Domain.csproj" />
|
||||
<ProjectReference Include="..\..\Domain.Entities\Domain.Entities.csproj" />
|
||||
<ProjectReference Include="..\..\Domain.Exceptions\Domain.Exceptions.csproj" />
|
||||
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Repository\Infrastructure.Repository.csproj" />
|
||||
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Jwt\Infrastructure.Jwt.csproj" />
|
||||
<ProjectReference Include="..\..\Service\Service.Auth\Service.Auth.csproj" />
|
||||
|
||||
@@ -44,13 +44,6 @@ namespace API.Core.Controllers
|
||||
public async Task<ActionResult> Login([FromBody] LoginRequest req)
|
||||
{
|
||||
var userAccount = await login.LoginAsync(req.Username, req.Password);
|
||||
if (userAccount is null)
|
||||
{
|
||||
return Unauthorized(new ResponseBody
|
||||
{
|
||||
Message = "Invalid username or password."
|
||||
});
|
||||
}
|
||||
|
||||
UserDTO dto = new(userAccount.UserAccountId, userAccount.Username);
|
||||
|
||||
|
||||
@@ -19,7 +19,6 @@ namespace API.Core.Controllers
|
||||
public async Task<ActionResult<UserAccount>> GetById(Guid id)
|
||||
{
|
||||
var user = await userService.GetByIdAsync(id);
|
||||
if (user is null) return NotFound();
|
||||
return Ok(user);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,8 @@ FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
||||
ARG BUILD_CONFIGURATION=Release
|
||||
WORKDIR /src
|
||||
COPY ["API/API.Core/API.Core.csproj", "API/API.Core/"]
|
||||
COPY ["Domain/Domain.csproj", "Domain/"]
|
||||
COPY ["Domain.Entities/Domain.Entities.csproj", "Domain.Entities/"]
|
||||
COPY ["Domain.Exceptions/Domain.Exceptions.csproj", "Domain.Exceptions/"]
|
||||
COPY ["Infrastructure/Infrastructure.Repository/Infrastructure.Repository.csproj", "Infrastructure/Infrastructure.Repository/"]
|
||||
COPY ["Infrastructure/Infrastructure.Jwt/Infrastructure.Jwt.csproj", "Infrastructure/Infrastructure.Jwt/"]
|
||||
COPY ["Infrastructure/Infrastructure.PasswordHashing/Infrastructure.PasswordHashing.csproj", "Infrastructure/Infrastructure.PasswordHashing/"]
|
||||
|
||||
84
src/Core/API/API.Core/GlobalException.cs
Normal file
84
src/Core/API/API.Core/GlobalException.cs
Normal file
@@ -0,0 +1,84 @@
|
||||
// API.Core/Filters/GlobalExceptionFilter.cs
|
||||
|
||||
using API.Core.Contracts.Common;
|
||||
using Domain.Exceptions;
|
||||
using FluentValidation;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Filters;
|
||||
|
||||
namespace API.Core;
|
||||
|
||||
public class GlobalExceptionFilter(ILogger<GlobalExceptionFilter> logger) : IExceptionFilter
|
||||
{
|
||||
public void OnException(ExceptionContext context)
|
||||
{
|
||||
logger.LogError(context.Exception, "Unhandled exception occurred");
|
||||
|
||||
switch (context.Exception)
|
||||
{
|
||||
case FluentValidation.ValidationException fluentValidationException:
|
||||
var errors = fluentValidationException.Errors
|
||||
.GroupBy(e => e.PropertyName)
|
||||
.ToDictionary(
|
||||
g => g.Key,
|
||||
g => g.Select(e => e.ErrorMessage).ToArray()
|
||||
);
|
||||
|
||||
context.Result = new BadRequestObjectResult(new
|
||||
{
|
||||
message = "Validation failed",
|
||||
errors
|
||||
});
|
||||
context.ExceptionHandled = true;
|
||||
break;
|
||||
|
||||
case ConflictException ex:
|
||||
context.Result = new ObjectResult(new ResponseBody { Message = ex.Message })
|
||||
{
|
||||
StatusCode = 409
|
||||
};
|
||||
context.ExceptionHandled = true;
|
||||
break;
|
||||
|
||||
case NotFoundException ex:
|
||||
context.Result = new ObjectResult(new ResponseBody { Message = ex.Message })
|
||||
{
|
||||
StatusCode = 404
|
||||
};
|
||||
context.ExceptionHandled = true;
|
||||
break;
|
||||
|
||||
case UnauthorizedException ex:
|
||||
context.Result = new ObjectResult(new ResponseBody { Message = ex.Message })
|
||||
{
|
||||
StatusCode = 401
|
||||
};
|
||||
context.ExceptionHandled = true;
|
||||
break;
|
||||
|
||||
case ForbiddenException ex:
|
||||
context.Result = new ObjectResult(new ResponseBody { Message = ex.Message })
|
||||
{
|
||||
StatusCode = 403
|
||||
};
|
||||
context.ExceptionHandled = true;
|
||||
break;
|
||||
|
||||
case Domain.Exceptions.ValidationException ex:
|
||||
context.Result = new ObjectResult(new ResponseBody { Message = ex.Message })
|
||||
{
|
||||
StatusCode = 400
|
||||
};
|
||||
context.ExceptionHandled = true;
|
||||
break;
|
||||
|
||||
default:
|
||||
context.Result = new ObjectResult(new ResponseBody { Message = "An unexpected error occurred" })
|
||||
{
|
||||
StatusCode = 500
|
||||
};
|
||||
context.ExceptionHandled = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
using API.Core.Contracts.Common;
|
||||
using FluentValidation;
|
||||
|
||||
namespace API.Core.Middleware;
|
||||
|
||||
public class ValidationExceptionHandlingMiddleware(RequestDelegate next)
|
||||
{
|
||||
public async Task InvokeAsync(HttpContext context)
|
||||
{
|
||||
try
|
||||
{
|
||||
await next(context);
|
||||
}
|
||||
catch (ValidationException ex)
|
||||
{
|
||||
await HandleValidationExceptionAsync(context, ex);
|
||||
}
|
||||
}
|
||||
|
||||
private static Task HandleValidationExceptionAsync(HttpContext context, ValidationException exception)
|
||||
{
|
||||
context.Response.ContentType = "application/json";
|
||||
context.Response.StatusCode = (int)HttpStatusCode.BadRequest;
|
||||
|
||||
var errors = exception.Errors
|
||||
.Select(e => e.ErrorMessage)
|
||||
.ToList();
|
||||
|
||||
var message = errors.Count == 1
|
||||
? errors[0]
|
||||
: "Validation failed. " + string.Join(" ", errors);
|
||||
|
||||
var response = new ResponseBody
|
||||
{
|
||||
Message = message
|
||||
};
|
||||
|
||||
var jsonOptions = new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
};
|
||||
|
||||
return context.Response.WriteAsync(JsonSerializer.Serialize(response, jsonOptions));
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
using API.Core;
|
||||
using Domain.Exceptions;
|
||||
using FluentValidation;
|
||||
using FluentValidation.AspNetCore;
|
||||
using Infrastructure.Jwt;
|
||||
@@ -6,34 +8,19 @@ using Infrastructure.Repository.Auth;
|
||||
using Infrastructure.Repository.Sql;
|
||||
using Infrastructure.Repository.UserAccount;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Filters;
|
||||
using Service.Auth.Auth;
|
||||
using Service.UserManagement.User;
|
||||
|
||||
using API.Core.Contracts.Common;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
builder.Services.AddControllers()
|
||||
.ConfigureApiBehaviorOptions(options =>
|
||||
{
|
||||
options.InvalidModelStateResponseFactory = context =>
|
||||
{
|
||||
var errors = context.ModelState.Values
|
||||
.SelectMany(v => v.Errors)
|
||||
.Select(e => e.ErrorMessage)
|
||||
.ToList();
|
||||
// Global Exception Filter
|
||||
builder.Services.AddControllers(options =>
|
||||
{
|
||||
options.Filters.Add<GlobalExceptionFilter>();
|
||||
});
|
||||
|
||||
var message = errors.Count == 1
|
||||
? errors[0]
|
||||
: string.Join(" ", errors);
|
||||
|
||||
var response = new
|
||||
{
|
||||
message
|
||||
};
|
||||
|
||||
return new BadRequestObjectResult(response);
|
||||
};
|
||||
});
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
builder.Services.AddSwaggerGen();
|
||||
builder.Services.AddOpenApi();
|
||||
@@ -67,6 +54,8 @@ builder.Services.AddScoped<IRegisterService, RegisterService>();
|
||||
builder.Services.AddScoped<ITokenInfrastructure, JwtInfrastructure>();
|
||||
builder.Services.AddScoped<IPasswordInfrastructure, Argon2Infrastructure>();
|
||||
|
||||
// Register the exception filter
|
||||
builder.Services.AddScoped<GlobalExceptionFilter>();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
@@ -90,6 +79,3 @@ lifetime.ApplicationStopping.Register(() =>
|
||||
});
|
||||
|
||||
app.Run();
|
||||
|
||||
// Make Program class accessible to test projects
|
||||
public partial class Program { }
|
||||
|
||||
@@ -3,7 +3,8 @@ ARG BUILD_CONFIGURATION=Release
|
||||
WORKDIR /src
|
||||
COPY ["API/API.Core/API.Core.csproj", "API/API.Core/"]
|
||||
COPY ["API/API.Specs/API.Specs.csproj", "API/API.Specs/"]
|
||||
COPY ["Domain/Domain.csproj", "Domain/"]
|
||||
COPY ["Domain.Entities/Domain.Entities.csproj", "Domain.Entities/"]
|
||||
COPY ["Domain.Exceptions/Domain.Exceptions.csproj", "Domain.Exceptions/"]
|
||||
COPY ["Infrastructure/Infrastructure.Repository/Infrastructure.Repository.csproj", "Infrastructure/Infrastructure.Repository/"]
|
||||
COPY ["Infrastructure/Infrastructure.Jwt/Infrastructure.Jwt.csproj", "Infrastructure/Infrastructure.Jwt/"]
|
||||
COPY ["Infrastructure/Infrastructure.PasswordHashing/Infrastructure.PasswordHashing.csproj", "Infrastructure/Infrastructure.PasswordHashing/"]
|
||||
|
||||
@@ -12,25 +12,19 @@ Feature: User Registration
|
||||
And the response JSON should have "message" equal "User registered successfully."
|
||||
And the response JSON should have an access token
|
||||
|
||||
@Ignore
|
||||
Scenario: Registration fails with existing username
|
||||
Given the API is running
|
||||
And I have an existing account with username "existinguser"
|
||||
When I submit a registration request with values:
|
||||
| Username | FirstName | LastName | Email | DateOfBirth | Password |
|
||||
| existinguser | Existing | User | existing@example.com | 1990-01-01 | Password1! |
|
||||
| test.user | Test | User | example@example.com | 2001-11-11 | Password1! |
|
||||
Then the response has HTTP status 409
|
||||
And the response JSON should have "message" equal "Username already exists."
|
||||
|
||||
@Ignore
|
||||
Scenario: Registration fails with existing email
|
||||
Given the API is running
|
||||
And I have an existing account with email "existing@example.com"
|
||||
When I submit a registration request with values:
|
||||
| Username | FirstName | LastName | Email | DateOfBirth | Password |
|
||||
| newuser | New | User | existing@example.com | 1990-01-01 | Password1! |
|
||||
| newuser | New | User | test.user@thebiergarten.app | 1990-01-01 | Password1! |
|
||||
Then the response has HTTP status 409
|
||||
And the response JSON should have "message" equal "Email already in use."
|
||||
|
||||
Scenario: Registration fails with missing required fields
|
||||
Given the API is running
|
||||
|
||||
@@ -201,18 +201,6 @@ public class AuthSteps(ScenarioContext scenario)
|
||||
scenario[ResponseBodyKey] = responseBody;
|
||||
}
|
||||
|
||||
[Given("I have an existing account with username {string}")]
|
||||
public void GivenIHaveAnExistingAccountWithUsername(string username)
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
[Given("I have an existing account with email {string}")]
|
||||
public void GivenIHaveAnExistingAccountWithEmail(string email)
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
[When("I submit a registration request using a GET request")]
|
||||
public async Task WhenISubmitARegistrationRequestUsingAGetRequest()
|
||||
{
|
||||
|
||||
@@ -8,7 +8,8 @@
|
||||
<Project Path="Database/Database.Seed/Database.Seed.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Domain/">
|
||||
<Project Path="Domain/Domain.csproj" />
|
||||
<Project Path="Domain.Entities\Domain.Entities.csproj" />
|
||||
<Project Path="Domain.Exceptions/Domain.Exceptions.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Infrastructure/">
|
||||
<Project Path="Infrastructure/Infrastructure.Jwt/Infrastructure.Jwt.csproj" />
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\Domain\Domain.csproj" />
|
||||
<ProjectReference Include="..\..\Domain.Entities\Domain.Entities.csproj" />
|
||||
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Repository\Infrastructure.Repository.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -124,6 +124,7 @@ internal class UserSeeder : ISeeder
|
||||
int createdCredentials = 0;
|
||||
int createdVerifications = 0;
|
||||
|
||||
// create a known user for testing purposes
|
||||
{
|
||||
const string firstName = "Test";
|
||||
const string lastName = "User";
|
||||
|
||||
9
src/Core/Domain.Exceptions/Domain.Exceptions.csproj
Normal file
9
src/Core/Domain.Exceptions/Domain.Exceptions.csproj
Normal file
@@ -0,0 +1,9 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
33
src/Core/Domain.Exceptions/Exceptions.cs
Normal file
33
src/Core/Domain.Exceptions/Exceptions.cs
Normal file
@@ -0,0 +1,33 @@
|
||||
namespace Domain.Exceptions;
|
||||
|
||||
/// <summary>
|
||||
/// Exception thrown when a resource conflict occurs (e.g., duplicate username, email already in use).
|
||||
/// Maps to HTTP 409 Conflict.
|
||||
/// </summary>
|
||||
public class ConflictException(string message) : Exception(message);
|
||||
|
||||
/// <summary>
|
||||
/// Exception thrown when a requested resource is not found.
|
||||
/// Maps to HTTP 404 Not Found.
|
||||
/// </summary>
|
||||
public class NotFoundException(string message) : Exception(message);
|
||||
|
||||
// Domain.Exceptions/UnauthorizedException.cs
|
||||
|
||||
/// <summary>
|
||||
/// Exception thrown when authentication fails or is required.
|
||||
/// Maps to HTTP 401 Unauthorized.
|
||||
/// </summary>
|
||||
public class UnauthorizedException(string message) : Exception(message);
|
||||
|
||||
/// <summary>
|
||||
/// Exception thrown when a user is authenticated but lacks permission to access a resource.
|
||||
/// Maps to HTTP 403 Forbidden.
|
||||
/// </summary>
|
||||
public class ForbiddenException(string message) : Exception(message);
|
||||
|
||||
/// <summary>
|
||||
/// Exception thrown when business rule validation fails (distinct from FluentValidation).
|
||||
/// Maps to HTTP 400 Bad Request.
|
||||
/// </summary>
|
||||
public class ValidationException(string message) : Exception(message);
|
||||
@@ -1,7 +1,8 @@
|
||||
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
||||
ARG BUILD_CONFIGURATION=Release
|
||||
WORKDIR /src
|
||||
COPY ["Domain/Domain.csproj", "Domain/"]
|
||||
COPY ["Domain.Entities/Domain.Entities.csproj", "Domain.Entities/"]
|
||||
COPY ["Domain.Exceptions/Domain.Exceptions.csproj", "Domain.Exceptions/"]
|
||||
COPY ["Infrastructure/Infrastructure.Repository/Infrastructure.Repository.csproj", "Infrastructure/Infrastructure.Repository/"]
|
||||
COPY ["Infrastructure/Infrastructure.Repository.Tests/Infrastructure.Repository.Tests.csproj", "Infrastructure/Infrastructure.Repository.Tests/"]
|
||||
RUN dotnet restore "Infrastructure/Infrastructure.Repository.Tests/Infrastructure.Repository.Tests.csproj"
|
||||
|
||||
@@ -18,6 +18,6 @@
|
||||
/>
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\Domain\Domain.csproj" />
|
||||
<ProjectReference Include="..\..\Domain.Entities\Domain.Entities.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -5,5 +5,5 @@ namespace Service.Auth.Auth;
|
||||
|
||||
public interface ILoginService
|
||||
{
|
||||
Task<UserAccount?> LoginAsync(string username, string password);
|
||||
Task<UserAccount> LoginAsync(string username, string password);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Threading.Tasks;
|
||||
using Domain.Entities;
|
||||
using Domain.Exceptions;
|
||||
using Infrastructure.PasswordHashing;
|
||||
using Infrastructure.Repository.Auth;
|
||||
|
||||
@@ -11,18 +12,24 @@ public class LoginService(
|
||||
) : ILoginService
|
||||
{
|
||||
|
||||
public async Task<UserAccount?> LoginAsync(string username, string password)
|
||||
public async Task<UserAccount> LoginAsync(string username, string password)
|
||||
{
|
||||
// Attempt lookup by username
|
||||
var user = await authRepo.GetUserByUsernameAsync(username);
|
||||
|
||||
// the user was not found
|
||||
if (user is null) return null;
|
||||
if (user is null)
|
||||
throw new UnauthorizedException("Invalid username or password.");
|
||||
|
||||
// @todo handle expired passwords
|
||||
var activeCred = await authRepo.GetActiveCredentialByUserAccountIdAsync(user.UserAccountId);
|
||||
|
||||
if (activeCred is null) return null;
|
||||
return !passwordInfrastructure.Verify(password, activeCred.Hash) ? null : user;
|
||||
if (activeCred is null)
|
||||
throw new UnauthorizedException("Invalid username or password.");
|
||||
|
||||
if (!passwordInfrastructure.Verify(password, activeCred.Hash))
|
||||
throw new UnauthorizedException("Invalid username or password.");
|
||||
|
||||
return user;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Threading.Tasks;
|
||||
using Domain.Entities;
|
||||
using Domain.Exceptions;
|
||||
using Infrastructure.PasswordHashing;
|
||||
using Infrastructure.Repository.Auth;
|
||||
|
||||
@@ -13,12 +14,16 @@ public class RegisterService(
|
||||
public async Task<UserAccount> RegisterAsync(UserAccount userAccount, string password)
|
||||
{
|
||||
// Check if user already exists
|
||||
var user = await authRepo.GetUserByUsernameAsync(userAccount.Username);
|
||||
if (user is not null)
|
||||
var existingUsername = await authRepo.GetUserByUsernameAsync(userAccount.Username);
|
||||
var existingEmail = await authRepo.GetUserByEmailAsync(userAccount.Email);
|
||||
|
||||
if (existingUsername != null || existingEmail != null)
|
||||
{
|
||||
return null!;
|
||||
throw new ConflictException("Username or email already exists");
|
||||
}
|
||||
|
||||
|
||||
|
||||
// password hashing
|
||||
var hashed = passwordInfrastructure.Hash(password);
|
||||
|
||||
|
||||
@@ -10,8 +10,10 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\Domain\Domain.csproj" />
|
||||
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Repository\Infrastructure.Repository.csproj" />
|
||||
<ProjectReference Include="..\..\Domain.Entities\Domain.Entities.csproj" />
|
||||
<ProjectReference Include="..\..\Domain.Exceptions\Domain.Exceptions.csproj" />
|
||||
<ProjectReference
|
||||
Include="..\..\Infrastructure\Infrastructure.Repository\Infrastructure.Repository.csproj" />
|
||||
<ProjectReference
|
||||
Include="..\..\Infrastructure\Infrastructure.PasswordHashing\Infrastructure.PasswordHashing.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -7,7 +7,9 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Repository\Infrastructure.Repository.csproj" />
|
||||
<ProjectReference Include="..\..\Domain.Exceptions\Domain.Exceptions.csproj" />
|
||||
<ProjectReference
|
||||
Include="..\..\Infrastructure\Infrastructure.Repository\Infrastructure.Repository.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -5,7 +5,7 @@ namespace Service.UserManagement.User;
|
||||
public interface IUserService
|
||||
{
|
||||
Task<IEnumerable<UserAccount>> GetAllAsync(int? limit = null, int? offset = null);
|
||||
Task<UserAccount?> GetByIdAsync(Guid id);
|
||||
Task<UserAccount> GetByIdAsync(Guid id);
|
||||
|
||||
Task UpdateAsync(UserAccount userAccount);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Domain.Entities;
|
||||
using Domain.Exceptions;
|
||||
using Infrastructure.Repository.UserAccount;
|
||||
|
||||
namespace Service.UserManagement.User;
|
||||
@@ -10,9 +11,12 @@ public class UserService(IUserAccountRepository repository) : IUserService
|
||||
return await repository.GetAllAsync(limit, offset);
|
||||
}
|
||||
|
||||
public async Task<UserAccount?> GetByIdAsync(Guid id)
|
||||
public async Task<UserAccount> GetByIdAsync(Guid id)
|
||||
{
|
||||
return await repository.GetByIdAsync(id);
|
||||
var user = await repository.GetByIdAsync(id);
|
||||
if (user is null)
|
||||
throw new NotFoundException($"User with ID {id} not found");
|
||||
return user;
|
||||
}
|
||||
|
||||
public async Task UpdateAsync(UserAccount userAccount)
|
||||
|
||||
Reference in New Issue
Block a user