Publish from private repository
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
|
||||
<configuration>
|
||||
</configuration>
|
||||
@@ -0,0 +1,187 @@
|
||||
namespace MyOffice.Web.Controllers;
|
||||
|
||||
using AutoMapper;
|
||||
using Core;
|
||||
using Core.Extensions;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Models.Account;
|
||||
using Models.Item;
|
||||
using Models.Motion;
|
||||
using MyOffice.Web.Infrastructure.Attributes;
|
||||
using Services.Account;
|
||||
using Services.Account.Domain;
|
||||
using Services.Item;
|
||||
|
||||
[Authorize]
|
||||
[ApiController]
|
||||
[Route("api/accounts")]
|
||||
[DefaultFromBody]
|
||||
public class AccountController : BaseApiController
|
||||
{
|
||||
private readonly ILogger<AccountController> _logger;
|
||||
private readonly IMapper _mapper;
|
||||
private readonly AccountService _accountService;
|
||||
private readonly ItemService _itemService;
|
||||
|
||||
public AccountController(
|
||||
ILogger<AccountController> logger,
|
||||
IMapper mapper,
|
||||
AccountService accountService,
|
||||
ItemService itemService
|
||||
)
|
||||
{
|
||||
_logger = logger;
|
||||
_mapper = mapper;
|
||||
_accountService = accountService;
|
||||
_itemService = itemService;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<ObjectResult> AccountsGet([FromQuery] string category)
|
||||
{
|
||||
var list = await _accountService.GetByCategoryDetailedAsync(UserId, category!.AsGuid());
|
||||
|
||||
return OkResponse(_mapper.Map<List<AccountDetailedViewModel>>(list.OrderBy(x => x.Account.Name).ToList()));
|
||||
}
|
||||
|
||||
[HttpGet("{id}")]
|
||||
public async Task<ObjectResult> AccountGet(string id)
|
||||
{
|
||||
var exec = await _accountService.GetByIdDetailedAsync(UserId, id!.AsGuid());
|
||||
|
||||
return MapGeneralExec(
|
||||
exec,
|
||||
result => OkResponse(_mapper.Map<AccountDetailedViewModel>(result)),
|
||||
notFoundDetail: "Account not found.",
|
||||
failureDetail: "Failed to load account.");
|
||||
}
|
||||
|
||||
[HttpGet("{id}/motions")]
|
||||
public async Task<ObjectResult> MotionsGet(string id, [FromQuery] MotionsGetRequest request)
|
||||
{
|
||||
var exec = await _accountService.GetMotionsAsync(
|
||||
UserId,
|
||||
id!.AsGuid(),
|
||||
request.From.StartOfDay(),
|
||||
request.To.EndOfDay());
|
||||
|
||||
return MapGeneralExec(
|
||||
exec,
|
||||
result => OkResponse(_mapper.Map<List<MotionViewModel>>(result)),
|
||||
notFoundDetail: "Account not found.",
|
||||
failureDetail: "Failed to load motions.");
|
||||
}
|
||||
|
||||
[HttpPost("{id}/motions")]
|
||||
public async Task<ObjectResult> MotionsPost(string id, MotionRequest motion)
|
||||
{
|
||||
var exec = await _accountService.MotionAddAsync(
|
||||
UserId,
|
||||
id.AsGuid(),
|
||||
_mapper.Map<MotionAddUpdate>(motion));
|
||||
|
||||
switch (exec.Status)
|
||||
{
|
||||
case MotionAddStatus.account_not_found:
|
||||
return ProblemNotFoundResponse("Account not found.");
|
||||
case MotionAddStatus.forbidden:
|
||||
return ProblemForbiddenResponse("Write access required.");
|
||||
case MotionAddStatus.failure:
|
||||
return ProblemBadResponse("Adding motion failed.");
|
||||
case MotionAddStatus.success:
|
||||
return Ok(_mapper.Map<MotionViewModel[]>(exec.Result!));
|
||||
|
||||
default:
|
||||
throw new NotSupportedException(exec.Status.ToString());
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPut("{id}/motions/{motionId}")]
|
||||
public async Task<ObjectResult> MotionsPut(string id, string motionId, MotionRequest motion)
|
||||
{
|
||||
var exec = await _accountService.MotionUpdateAsync(
|
||||
UserId,
|
||||
id.AsGuid(),
|
||||
motionId.AsGuid(),
|
||||
_mapper.Map<MotionAddUpdate>(motion));
|
||||
|
||||
switch (exec.Status)
|
||||
{
|
||||
case MotionUpdateStatus.not_found:
|
||||
return ProblemNotFoundResponse("Motion not found.");
|
||||
case MotionUpdateStatus.forbidden:
|
||||
return ProblemForbiddenResponse("Write access required.");
|
||||
case MotionUpdateStatus.failure:
|
||||
return ProblemBadResponse("Updating motion failed.");
|
||||
case MotionUpdateStatus.success:
|
||||
return OkResponse(_mapper.Map<List<MotionViewModel>>(exec.Result!));
|
||||
|
||||
default:
|
||||
throw new NotSupportedException(exec.Status.ToString());
|
||||
}
|
||||
}
|
||||
|
||||
[HttpDelete("{id}/motions/{motionId}")]
|
||||
public async Task<ObjectResult> MotionsDelete(string id, string motionId)
|
||||
{
|
||||
var exec = await _accountService.MotionRemoveAsync(
|
||||
UserId,
|
||||
id.AsGuid(),
|
||||
motionId.AsGuid());
|
||||
|
||||
switch (exec.Status)
|
||||
{
|
||||
case MotionDeleteStatus.not_found:
|
||||
return ProblemNotFoundResponse("Motion not found.");
|
||||
case MotionDeleteStatus.forbidden:
|
||||
return ProblemForbiddenResponse("Write access required.");
|
||||
case MotionDeleteStatus.failure:
|
||||
return ProblemBadResponse("Deliting motion failed.");
|
||||
case MotionDeleteStatus.success:
|
||||
return OkResponse(_mapper.Map<MotionViewModel>(exec.Result!));
|
||||
|
||||
default:
|
||||
throw new NotSupportedException(exec.Status.ToString());
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet("~/api/items")]
|
||||
public async Task<ObjectResult> FindItems([FromQuery] string term, CancellationToken cancellationToken)
|
||||
{
|
||||
var itemsDto = await _itemService.FindItemsAsync(UserId, term, cancellationToken: cancellationToken);
|
||||
|
||||
var items = _mapper.Map<List<ItemViewModel>>(itemsDto);
|
||||
|
||||
if (term.StartsWith("+") && term.Length > 1)
|
||||
{
|
||||
var foundAccounts = await _accountService.FindAccountsAsync(UserId, term.Substring(1), cancellationToken);
|
||||
var accounts = foundAccounts.OrderByDescending(x => x.Name);
|
||||
|
||||
foreach (var account in accounts)
|
||||
{
|
||||
var accountName = $"+{account.Name}";
|
||||
var item = items.FirstOrDefault(x => x.Name.IsPresent() && x.Name!.Length > 1 && x.Name.Substring(1) == accountName);
|
||||
|
||||
if (item == null)
|
||||
{
|
||||
items.Add(new ItemViewModel
|
||||
{
|
||||
Name = accountName,
|
||||
AccountId = account.Id.ToShort(),
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
item.AccountId = account.Id.ToShort();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return OkResponse(_mapper
|
||||
.Map<List<ItemViewModel>>(items)
|
||||
.OrderBy(x => x.AccountId)
|
||||
.ThenBy(x => x.Name)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
namespace MyOffice.Web.Controllers;
|
||||
|
||||
using System.Security.Claims;
|
||||
using Data.Repositories.Users;
|
||||
using Microsoft.AspNetCore;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using OpenIddict.Server.AspNetCore;
|
||||
using OpenIddict.Validation.AspNetCore;
|
||||
using static OpenIddict.Abstractions.OpenIddictConstants;
|
||||
|
||||
[ApiController]
|
||||
public sealed class AuthorizationController : ControllerBase
|
||||
{
|
||||
private readonly IUserRepository _userRepository;
|
||||
|
||||
public AuthorizationController(IUserRepository userRepository)
|
||||
{
|
||||
_userRepository = userRepository;
|
||||
}
|
||||
|
||||
[Authorize(AuthenticationSchemes = OpenIddictServerAspNetCoreDefaults.AuthenticationScheme)]
|
||||
[HttpGet("~/connect/authorize")]
|
||||
[HttpPost("~/connect/authorize")]
|
||||
public async Task<IActionResult> Authorize()
|
||||
{
|
||||
var result = await HttpContext.AuthenticateAsync(
|
||||
OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
|
||||
if (!result.Succeeded)
|
||||
{
|
||||
return Forbid(
|
||||
authenticationSchemes: OpenIddictServerAspNetCoreDefaults.AuthenticationScheme,
|
||||
properties: new AuthenticationProperties(new Dictionary<string, string?>
|
||||
{
|
||||
[OpenIddictServerAspNetCoreConstants.Properties.Error] = Errors.LoginRequired,
|
||||
[OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] =
|
||||
"The user is not authenticated."
|
||||
}));
|
||||
}
|
||||
|
||||
return SignIn(result.Principal!, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
|
||||
}
|
||||
|
||||
[Authorize(AuthenticationSchemes = OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme)]
|
||||
[HttpGet("~/connect/userinfo")]
|
||||
[HttpPost("~/connect/userinfo")]
|
||||
[Produces("application/json")]
|
||||
public async Task<IActionResult> Userinfo()
|
||||
{
|
||||
var userIdValue = User.FindFirstValue(Claims.Subject);
|
||||
if (!Guid.TryParse(userIdValue, out var userId))
|
||||
return Challenge(authenticationSchemes: OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme);
|
||||
|
||||
var user = await _userRepository.GetUserAsync(userId);
|
||||
if (user is null)
|
||||
return Challenge(authenticationSchemes: OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme);
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
sub = userIdValue,
|
||||
id = userIdValue,
|
||||
email = user.Email,
|
||||
name = user.FullName ?? user.UserName,
|
||||
firstName = user.FirstName,
|
||||
lastName = user.LastName,
|
||||
fullName = user.FullName,
|
||||
phone = user.Phone
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
namespace MyOffice.Web.Controllers;
|
||||
|
||||
using System.Security.Authentication;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Core;
|
||||
using Core.Extensions;
|
||||
using IdentityModel;
|
||||
using MyOffice.Web.Infrastructure.Attributes;
|
||||
using MyOffice.Web.Models;
|
||||
|
||||
[DefaultFromBody]
|
||||
public class BaseApiController : ControllerBase
|
||||
{
|
||||
public Guid UserId
|
||||
{
|
||||
get
|
||||
{
|
||||
var subject = User.Claims.FirstOrDefault(x => x.Type == JwtClaimTypes.Subject)?.Value;
|
||||
if (subject.IsPresent() && Guid.TryParse(subject, out var guid)) return guid;
|
||||
|
||||
throw new AuthenticationException("Get UserId failed");
|
||||
}
|
||||
}
|
||||
|
||||
[NonAction]
|
||||
public ObjectResult ProblemBadResponse(string? detail = null)
|
||||
{
|
||||
return Problem(detail, statusCode: StatusCodes.Status400BadRequest);
|
||||
}
|
||||
|
||||
[NonAction]
|
||||
public ObjectResult ProblemNotFoundResponse(string? detail = null)
|
||||
{
|
||||
return Problem(detail ?? "Not found.", statusCode: StatusCodes.Status404NotFound);
|
||||
}
|
||||
|
||||
[NonAction]
|
||||
public ObjectResult ProblemForbiddenResponse(string? detail = null)
|
||||
{
|
||||
return Problem(detail ?? "Forbidden.", statusCode: StatusCodes.Status403Forbidden);
|
||||
}
|
||||
|
||||
[NonAction]
|
||||
public ObjectResult OkResponse(IResponseModel response)
|
||||
{
|
||||
return Ok(response);
|
||||
}
|
||||
|
||||
[NonAction]
|
||||
public ObjectResult OkResponse<T>(IEnumerable<T> response) where T : IResponseModel
|
||||
{
|
||||
return Ok(response);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps <see cref="GeneralExecStatus"/> to ProblemDetails status codes:
|
||||
/// not_found → 404, forbidden → 403, failure → 400.
|
||||
/// </summary>
|
||||
protected ObjectResult MapGeneralExec<T>(
|
||||
Exec<T, GeneralExecStatus> exec,
|
||||
Func<T, ObjectResult> onSuccess,
|
||||
string notFoundDetail,
|
||||
string? failureDetail = null,
|
||||
string? forbiddenDetail = null) where T : class
|
||||
{
|
||||
switch (exec.Status)
|
||||
{
|
||||
case GeneralExecStatus.success:
|
||||
return onSuccess(exec.Result!);
|
||||
case GeneralExecStatus.not_found:
|
||||
return ProblemNotFoundResponse(notFoundDetail);
|
||||
case GeneralExecStatus.forbidden:
|
||||
return ProblemForbiddenResponse(forbiddenDetail);
|
||||
case GeneralExecStatus.failure:
|
||||
return ProblemBadResponse(failureDetail ?? "Operation failed.");
|
||||
default:
|
||||
throw new NotSupportedException(exec.Status.ToString());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
namespace MyOffice.Web.Controllers;
|
||||
|
||||
using AutoMapper;
|
||||
using Core.Extensions;
|
||||
using Infrastructure.Attributes;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using MyOffice.Web.Models.Dashboard;
|
||||
using Services.Dashboard;
|
||||
|
||||
[Authorize]
|
||||
[ApiController]
|
||||
[Route("api/dashboard")]
|
||||
[DefaultFromBody]
|
||||
public class DashboardController : BaseApiController
|
||||
{
|
||||
private readonly ILogger<DashboardController> _logger;
|
||||
private readonly DashboardService _dashboardService;
|
||||
private readonly IMapper _mapper;
|
||||
|
||||
public DashboardController(
|
||||
ILogger<DashboardController> logger,
|
||||
DashboardService dashboardService,
|
||||
IMapper mapper
|
||||
)
|
||||
{
|
||||
_logger = logger;
|
||||
_dashboardService = dashboardService;
|
||||
_mapper = mapper;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<ObjectResult> Index(CancellationToken cancellationToken)
|
||||
{
|
||||
var data = await _dashboardService.GetDashboardRestDataAsync(UserId, cancellationToken);
|
||||
|
||||
return OkResponse(_mapper.Map<DashboardViewModel>(data));
|
||||
}
|
||||
|
||||
[HttpGet("income")]
|
||||
public async Task<ObjectResult> Income(
|
||||
DateTime from,
|
||||
DateTime to,
|
||||
[AsGuid(true)] string? category,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var data = await _dashboardService.GetDashboardIncomeDataAsync(
|
||||
UserId,
|
||||
from.StartOfDay(),
|
||||
to.EndOfDay(),
|
||||
category?.AsGuidNull(),
|
||||
cancellationToken);
|
||||
|
||||
return OkResponse(_mapper.Map<DashboardIncomeDataViewModel>(data));
|
||||
}
|
||||
|
||||
[HttpGet("outcome")]
|
||||
public async Task<ObjectResult> Outcome(
|
||||
DateTime from,
|
||||
DateTime to,
|
||||
[AsGuid(true)] string? category,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var data = await _dashboardService.GetDashboardOutcomeDataAsync(
|
||||
UserId,
|
||||
from.StartOfDay(),
|
||||
to.EndOfDay(),
|
||||
category?.AsGuidNull(),
|
||||
cancellationToken);
|
||||
|
||||
return OkResponse(_mapper.Map<DashboardIncomeDataViewModel>(data));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
namespace MyOffice.Web.Controllers;
|
||||
|
||||
using AutoMapper;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Models.Currency;
|
||||
using Services.Currency;
|
||||
using MyOffice.Web.Infrastructure.Attributes;
|
||||
|
||||
[Authorize]
|
||||
[ApiController]
|
||||
[Route("api/general")]
|
||||
[DefaultFromBody]
|
||||
public class GeneralController : BaseApiController
|
||||
{
|
||||
private readonly CurrencyService _currencyService;
|
||||
private readonly IMapper _mapper;
|
||||
|
||||
public GeneralController(
|
||||
CurrencyService currencyService,
|
||||
IMapper mapper
|
||||
)
|
||||
{
|
||||
_currencyService = currencyService;
|
||||
_mapper = mapper;
|
||||
}
|
||||
|
||||
[HttpGet("currencies")]
|
||||
public async Task<ObjectResult> GetGlobalCurrencies(CancellationToken cancellationToken)
|
||||
{
|
||||
var list = await _currencyService.GetGlobalAllAsync(cancellationToken);
|
||||
|
||||
return Ok(_mapper.Map<List<CurrencyGlobalViewModel>>(list));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
namespace MyOffice.Web.Controllers;
|
||||
|
||||
using AutoMapper;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
|
||||
using Core;
|
||||
using Core.Extensions;
|
||||
using Models.Account;
|
||||
using Services.Account;
|
||||
using Services.Account.Domain;
|
||||
using Infrastructure.Attributes;
|
||||
using Services.Identity;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
[Authorize]
|
||||
[ApiController]
|
||||
[Route("api/settings/account-categories")]
|
||||
[DefaultFromBody]
|
||||
public class SettingsAccountCategoryController : BaseApiController
|
||||
{
|
||||
private readonly ILogger<SettingsAccountController> _logger;
|
||||
private readonly IMapper _mapper;
|
||||
private readonly AccountService _accountService;
|
||||
private readonly IContextProvider _contextProvider;
|
||||
|
||||
public SettingsAccountCategoryController(
|
||||
ILogger<SettingsAccountController> logger,
|
||||
IMapper mapper,
|
||||
AccountService accountService,
|
||||
IContextProvider contextProvider
|
||||
)
|
||||
{
|
||||
_logger = logger;
|
||||
_mapper = mapper;
|
||||
_accountService = accountService;
|
||||
_contextProvider = contextProvider;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<ObjectResult> AccountCategories(CancellationToken cancellationToken)
|
||||
{
|
||||
var list = await _accountService.GetAllCategoriesAsync(UserId, cancellationToken);
|
||||
|
||||
return OkResponse(_mapper.Map<List<AccountCategoryViewModel>>(list).OrderBy(x => x.Name));
|
||||
}
|
||||
|
||||
[HttpGet("{id}")]
|
||||
public async Task<ObjectResult> AccountCategory([AsGuid] string id, CancellationToken cancellationToken)
|
||||
{
|
||||
var exec = await _accountService.GetCategoryAsync(UserId, id.AsGuid(), cancellationToken);
|
||||
|
||||
switch (exec.Status)
|
||||
{
|
||||
case GeneralExecStatus.not_found:
|
||||
case GeneralExecStatus.failure:
|
||||
return ProblemNotFoundResponse("Account category not found.");
|
||||
|
||||
case GeneralExecStatus.success:
|
||||
return OkResponse(_mapper.Map<AccountCategoryViewModel>(exec.Result!));
|
||||
|
||||
default:
|
||||
throw new NotSupportedException(exec.Status.ToString());
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
public async Task<ObjectResult> AccountCategoriesAdd(AccountCategoryViewModel request, CancellationToken cancellationToken)
|
||||
{
|
||||
var exec = await _accountService.CategoryAddAsync(UserId, new AccountCategoryDto { Name = request.Name! }, cancellationToken);
|
||||
switch (exec.Status)
|
||||
{
|
||||
case GeneralExecStatus.success:
|
||||
return OkResponse(_mapper.Map<AccountCategoryViewModel>(exec.Result!));
|
||||
|
||||
case GeneralExecStatus.failure:
|
||||
case GeneralExecStatus.not_found:
|
||||
return ProblemBadResponse("Adding account category failed.");
|
||||
|
||||
default:
|
||||
throw new NotSupportedException(exec.Status.ToString());
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPut("{id}")]
|
||||
public async Task<ObjectResult> AccountCategoriesUpdate([AsGuid] string id, AccountCategoryViewModel request, CancellationToken cancellationToken)
|
||||
{
|
||||
var exec = await _accountService.CategoryUpdateAsync(UserId, id.AsGuid(), new AccountCategoryDto { Name = request.Name! }, cancellationToken);
|
||||
switch (exec.Status)
|
||||
{
|
||||
case GeneralExecStatus.success:
|
||||
return OkResponse(_mapper.Map<AccountCategoryViewModel>(exec.Result!));
|
||||
|
||||
case GeneralExecStatus.failure:
|
||||
case GeneralExecStatus.not_found:
|
||||
return ProblemBadResponse("Update account category failed.");
|
||||
|
||||
default:
|
||||
throw new NotSupportedException(exec.Status.ToString());
|
||||
}
|
||||
}
|
||||
|
||||
[HttpDelete("{id}")]
|
||||
public async Task<ObjectResult> AccountCategoriesDelete([AsGuid] string id, CancellationToken cancellationToken)
|
||||
{
|
||||
var exec = await _accountService.CategoryRemoveAsync(UserId, id.AsGuid(), cancellationToken);
|
||||
switch (exec.Status)
|
||||
{
|
||||
case AccountCategoryRemoveResult.success:
|
||||
return OkResponse(_mapper.Map<AccountCategoryViewModel>(exec.Result!));
|
||||
|
||||
case AccountCategoryRemoveResult.failure:
|
||||
return ProblemBadResponse("Remove account category failed.");
|
||||
case AccountCategoryRemoveResult.not_found:
|
||||
return ProblemNotFoundResponse("Account category not found.");
|
||||
case AccountCategoryRemoveResult.accounts_exists:
|
||||
return ProblemBadResponse("Account category have accounts.");
|
||||
|
||||
default:
|
||||
throw new NotSupportedException(exec.Status.ToString());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
namespace MyOffice.Web.Controllers;
|
||||
|
||||
using AutoMapper;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
|
||||
using Core;
|
||||
using Core.Extensions;
|
||||
using Models.Account;
|
||||
using Services.Account;
|
||||
using Services.Account.Domain;
|
||||
using Infrastructure.Attributes;
|
||||
using Services.Identity;
|
||||
|
||||
[Authorize]
|
||||
[ApiController]
|
||||
[Route("api/settings/accounts")]
|
||||
[DefaultFromBody]
|
||||
public class SettingsAccountController : BaseApiController
|
||||
{
|
||||
private readonly ILogger<SettingsAccountController> _logger;
|
||||
private readonly IMapper _mapper;
|
||||
private readonly AccountService _accountService;
|
||||
private readonly IContextProvider _contextProvider;
|
||||
|
||||
public SettingsAccountController(
|
||||
ILogger<SettingsAccountController> logger,
|
||||
IMapper mapper,
|
||||
AccountService accountService,
|
||||
IContextProvider contextProvider
|
||||
)
|
||||
{
|
||||
_logger = logger;
|
||||
_mapper = mapper;
|
||||
_accountService = accountService;
|
||||
_contextProvider = contextProvider;
|
||||
}
|
||||
|
||||
[HttpGet("invites")]
|
||||
public async Task<ObjectResult> AccountInvites(CancellationToken cancellationToken)
|
||||
{
|
||||
var invites = await _accountService.InvitesGetAsync(_contextProvider.User.Email, cancellationToken);
|
||||
|
||||
return OkResponse(_mapper.Map<List<AccountAccessInviteViewModel>>(invites));
|
||||
}
|
||||
|
||||
[HttpPost("invites/{id}/accept")]
|
||||
public async Task<ObjectResult> AccountInviteAccept([AsGuid] string id, AccountInviteAcceptRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var exec = await _accountService.InviteAcceptAsync(UserId, id.AsGuid(), request.Name, cancellationToken);
|
||||
|
||||
switch (exec.Status)
|
||||
{
|
||||
case InviteAcceptStatus.invite_not_found:
|
||||
return ProblemNotFoundResponse("Invite not found.");
|
||||
case InviteAcceptStatus.account_not_found:
|
||||
return ProblemNotFoundResponse("Account not found.");
|
||||
|
||||
case InviteAcceptStatus.already_accepted:
|
||||
case InviteAcceptStatus.success:
|
||||
return OkResponse(_mapper.Map<AccountViewModel>(exec.Result!));
|
||||
|
||||
default:
|
||||
throw new NotSupportedException(exec.Status.ToString());
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("invites/{id}/reject")]
|
||||
public async Task<ObjectResult> AccountInviteReject([AsGuid] string id, CancellationToken cancellationToken)
|
||||
{
|
||||
var exec = await _accountService.InviteRejectAsync(id.AsGuid(), cancellationToken);
|
||||
|
||||
return MapGeneralExec(
|
||||
exec,
|
||||
result => OkResponse(_mapper.Map<AccountAccessInviteViewModel>(result)),
|
||||
notFoundDetail: "Invite not found.",
|
||||
failureDetail: "Invite reject failed.");
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<ObjectResult> AccountsGet([FromQuery][AsGuid(true)] string? category, CancellationToken cancellationToken)
|
||||
{
|
||||
var list = category.IsPresent()
|
||||
? await _accountService.GetByCategoryAsync(UserId, category!.AsGuid(), cancellationToken)
|
||||
: await _accountService.GetAllAccountsAsync(UserId, cancellationToken);
|
||||
|
||||
return OkResponse(_mapper.Map<List<AccountViewModel>>(list.OrderBy(x => x.Name)));
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
public async Task<ObjectResult> AccountsAdd(AccountViewModel request, CancellationToken cancellationToken)
|
||||
{
|
||||
var exec = await _accountService.AccountAddAsync(UserId, new AccountAdd
|
||||
{
|
||||
Name = request.Name,
|
||||
CurrencyId = request.CurrencyId,
|
||||
CategoryId = request.CategoryId.AsGuid(),
|
||||
Type = request.Type,
|
||||
}, cancellationToken);
|
||||
|
||||
switch (exec.Status)
|
||||
{
|
||||
case AccountAddStatus.category_not_found:
|
||||
return ProblemNotFoundResponse("Category not found.");
|
||||
case AccountAddStatus.currency_not_found:
|
||||
return ProblemNotFoundResponse("Currency not found.");
|
||||
case AccountAddStatus.failure:
|
||||
return ProblemBadResponse("Adding account failed.");
|
||||
case AccountAddStatus.success:
|
||||
return OkResponse(_mapper.Map<AccountViewModel>(exec.Result!));
|
||||
|
||||
default:
|
||||
throw new NotSupportedException(exec.Status.ToString());
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPut("{id}")]
|
||||
public async Task<ObjectResult> AccountsUpdate([AsGuid] string id, AccountEditRequestModel request, CancellationToken cancellationToken)
|
||||
{
|
||||
var exec = await _accountService.AccountUpdateAsync(UserId, id.AsGuid(), new AccountEdit
|
||||
{
|
||||
Name = request.Name,
|
||||
CurrencyId = request.CurrencyId,
|
||||
CategoryId = request.CategoryId?.AsGuidNull(),
|
||||
UserId = request.UserId?.AsGuidNull(),
|
||||
Type = request.Type,
|
||||
}, cancellationToken);
|
||||
|
||||
switch (exec.Status)
|
||||
{
|
||||
case AccountEditStatus.not_found:
|
||||
return ProblemNotFoundResponse("Account not found.");
|
||||
case AccountEditStatus.forbidden:
|
||||
return ProblemForbiddenResponse("Manage access required.");
|
||||
case AccountEditStatus.category_not_found:
|
||||
return ProblemNotFoundResponse("Category not found.");
|
||||
case AccountEditStatus.currency_not_found:
|
||||
return ProblemNotFoundResponse("Currency not found.");
|
||||
case AccountEditStatus.failure:
|
||||
return ProblemBadResponse("Adding account failed.");
|
||||
case AccountEditStatus.success:
|
||||
return OkResponse(_mapper.Map<AccountViewModel>(exec.Result!));
|
||||
|
||||
default:
|
||||
throw new NotSupportedException(exec.Status.ToString());
|
||||
}
|
||||
}
|
||||
|
||||
[HttpDelete("{id}")]
|
||||
public async Task<ObjectResult> AccountsDelete([AsGuid] string id, CancellationToken cancellationToken)
|
||||
{
|
||||
var exec = await _accountService.AccountDeleteAsync(UserId, id, cancellationToken);
|
||||
return MapGeneralExec(
|
||||
exec,
|
||||
result => OkResponse(_mapper.Map<AccountViewModel>(result)),
|
||||
notFoundDetail: "Account not found.",
|
||||
forbiddenDetail: "Manage access required.");
|
||||
}
|
||||
|
||||
[HttpDelete("{id}/category/{categoryId}")]
|
||||
public async Task<ObjectResult> AccountsCategoryDelete([AsGuid] string id, [AsGuid] string categoryId, CancellationToken cancellationToken)
|
||||
{
|
||||
var exec = await _accountService.AccountCategoryRemoveAsync(UserId, id.AsGuid(), categoryId.AsGuid(), cancellationToken);
|
||||
|
||||
return MapGeneralExec(
|
||||
exec,
|
||||
result => OkResponse(_mapper.Map<AccountViewModel>(result)),
|
||||
notFoundDetail: "Category not found.",
|
||||
failureDetail: "Category remove failed.",
|
||||
forbiddenDetail: "Manage access required.");
|
||||
}
|
||||
|
||||
[HttpPost("{id}/access")]
|
||||
public async Task<ObjectResult> AccountsAccessAdd([AsGuid] string id, AccountAccessViewModel request, CancellationToken cancellationToken)
|
||||
{
|
||||
var model = _mapper.Map<List<AccountAccessDto>>(request.Accesses);
|
||||
|
||||
var exec = await _accountService.AccessUpdateAsync(UserId, id.AsGuid(), model, cancellationToken);
|
||||
|
||||
if (exec.Status != GeneralExecStatus.success)
|
||||
{
|
||||
return MapGeneralExec(
|
||||
exec,
|
||||
_ => OkResponse(_mapper.Map<AccountViewModel>(exec.Result!)),
|
||||
notFoundDetail: "Account not found.",
|
||||
failureDetail: "Access update failed.",
|
||||
forbiddenDetail: "Manage access required.");
|
||||
}
|
||||
|
||||
if (!request.Email.IsPresent())
|
||||
{
|
||||
return OkResponse(_mapper.Map<AccountViewModel>(exec.Result!));
|
||||
}
|
||||
|
||||
var inviteExec = await _accountService.AccessInviteAsync(UserId, id.AsGuid(), request.Email!, request.AllowWrite, cancellationToken);
|
||||
|
||||
switch (inviteExec.Status)
|
||||
{
|
||||
case AccessInviteStatus.account_not_found:
|
||||
return ProblemNotFoundResponse("Account not found.");
|
||||
case AccessInviteStatus.forbidden:
|
||||
return ProblemForbiddenResponse("Manage access required.");
|
||||
|
||||
case AccessInviteStatus.access_exists:
|
||||
case AccessInviteStatus.invite_exists:
|
||||
case AccessInviteStatus.success:
|
||||
return OkResponse(_mapper.Map<AccountViewModel>(exec.Result!));
|
||||
|
||||
default:
|
||||
throw new NotSupportedException(exec.Status.ToString());
|
||||
}
|
||||
}
|
||||
|
||||
[HttpDelete("{id}/access/{userId}")]
|
||||
public async Task<ObjectResult> AccountsAccessDelete([AsGuid] string id, [AsGuid] string userId, CancellationToken cancellationToken)
|
||||
{
|
||||
var exec = await _accountService.AccessDeleteAsync(UserId, id.AsGuid(), userId.AsGuid(), cancellationToken);
|
||||
|
||||
return MapGeneralExec(
|
||||
exec,
|
||||
result => OkResponse(_mapper.Map<AccountViewModel>(result)),
|
||||
notFoundDetail: "Account not found.",
|
||||
failureDetail: "Access delete failed.",
|
||||
forbiddenDetail: "Manage access required.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
namespace MyOffice.Web.Controllers;
|
||||
|
||||
using AutoMapper;
|
||||
using Core;
|
||||
using Core.Extensions;
|
||||
using Infrastructure.Attributes;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Models.Currency;
|
||||
using Services.Currency;
|
||||
using Services.Currency.Domain;
|
||||
|
||||
[Authorize]
|
||||
[ApiController]
|
||||
[Route("api/settings/currencies")]
|
||||
[DefaultFromBody]
|
||||
public class SettingCurrencyController : BaseApiController
|
||||
{
|
||||
private readonly CurrencyService _currencyService;
|
||||
private readonly ILogger<SettingCurrencyController> _logger;
|
||||
private readonly IMapper _mapper;
|
||||
|
||||
public SettingCurrencyController(
|
||||
CurrencyService currencyService,
|
||||
ILogger<SettingCurrencyController> logger,
|
||||
IMapper mapper
|
||||
)
|
||||
{
|
||||
_currencyService = currencyService;
|
||||
_logger = logger;
|
||||
_mapper = mapper;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<ObjectResult> Get(CancellationToken cancellationToken)
|
||||
{
|
||||
var list = await _currencyService.GetAllWithRatesAsync(UserId, cancellationToken);
|
||||
|
||||
return OkResponse(_mapper.Map<List<CurrencyViewModel>>(list).OrderBy(x => x.Id));
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
public async Task<ObjectResult> Add(CurrencyAddModel currency, CancellationToken cancellationToken)
|
||||
{
|
||||
var exec = await _currencyService.CurrencyAddAsync(UserId, new CurrencyDto
|
||||
{
|
||||
UserId = UserId,
|
||||
CurrencyGlobalId = currency.Id,
|
||||
Name = currency.Name,
|
||||
ShortName = currency.ShortName,
|
||||
}, cancellationToken);
|
||||
|
||||
switch (exec.Status)
|
||||
{
|
||||
case CurrencyAddStatus.success:
|
||||
case CurrencyAddStatus.exists:
|
||||
await _currencyService.CurrencyRateAddAsync(UserId, exec.Result!.Id, new CurrencyRateDto
|
||||
{
|
||||
CurrencyId = exec.Result!.Id,
|
||||
Rate = currency.Rate,
|
||||
Quantity = currency.Quantity,
|
||||
DateTime = currency.RateDate.Date,
|
||||
}, cancellationToken);
|
||||
return OkResponse(_mapper.Map<CurrencyViewModel>(exec.Result!));
|
||||
|
||||
case CurrencyAddStatus.failed:
|
||||
return ProblemBadResponse("Adding currency failed.");
|
||||
|
||||
default:
|
||||
throw new NotSupportedException(exec.Status.ToString());
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPut("{id}")]
|
||||
public async Task<ObjectResult> Update(string id, CurrencyEditModel currency, CancellationToken cancellationToken)
|
||||
{
|
||||
var exec = await _currencyService.CurrencyUpdateAsync(
|
||||
UserId,
|
||||
id.AsGuid(),
|
||||
new CurrencyEdit { Name = currency.Name, ShortName = currency.ShortName, IsPrimary = currency.IsPrimary },
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
switch (exec.Status)
|
||||
{
|
||||
case CurrencyEditStatus.success:
|
||||
return OkResponse(_mapper.Map<CurrencyViewModel>(exec.Result!));
|
||||
|
||||
case CurrencyEditStatus.not_found:
|
||||
case CurrencyEditStatus.failed:
|
||||
return ProblemNotFoundResponse("Currency not found.");
|
||||
|
||||
default:
|
||||
throw new NotSupportedException(exec.Status.ToString());
|
||||
}
|
||||
}
|
||||
|
||||
[HttpDelete("{id}")]
|
||||
public async Task<ObjectResult> Delete([AsGuid] string id, CancellationToken cancellationToken)
|
||||
{
|
||||
var exec = await _currencyService.RemoveAsync(UserId, id.AsGuid(), cancellationToken);
|
||||
|
||||
switch (exec.Status)
|
||||
{
|
||||
case GeneralExecStatus.success:
|
||||
return OkResponse(_mapper.Map<CurrencyViewModel>(exec.Result!));
|
||||
|
||||
case GeneralExecStatus.not_found:
|
||||
case GeneralExecStatus.failure:
|
||||
return ProblemNotFoundResponse("Currency not found.");
|
||||
|
||||
default:
|
||||
throw new NotSupportedException(exec.Status.ToString());
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("{id}/rate")]
|
||||
public async Task<ObjectResult> AddRate(string id, CurrencyRateModel currencyRate, CancellationToken cancellationToken)
|
||||
{
|
||||
var exec = await _currencyService.CurrencyRateAddAsync(UserId, id.AsGuid(), new CurrencyRateDto
|
||||
{
|
||||
CurrencyId = id.AsGuid(),
|
||||
Quantity = currencyRate.Quantity,
|
||||
Rate = currencyRate.Rate,
|
||||
DateTime = currencyRate.RateDate.Date,
|
||||
}, cancellationToken);
|
||||
|
||||
switch (exec.Status)
|
||||
{
|
||||
case CurrencyAddRateStatus.failed:
|
||||
return ProblemBadResponse("Adding currency rate failed.");
|
||||
case CurrencyAddRateStatus.not_found:
|
||||
return ProblemNotFoundResponse("Currency not found.");
|
||||
|
||||
case CurrencyAddRateStatus.success:
|
||||
return OkResponse(_mapper.Map<CurrencyRateViewModel>(exec.Result!));
|
||||
|
||||
default:
|
||||
throw new NotSupportedException(exec.Status.ToString());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
namespace MyOffice.Web.Controllers;
|
||||
|
||||
using AutoMapper;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
using Models.Item;
|
||||
using Core.Extensions;
|
||||
using Core;
|
||||
using Infrastructure.Attributes;
|
||||
using Services.Item;
|
||||
using Services.Item.Domain;
|
||||
|
||||
[Authorize]
|
||||
[ApiController]
|
||||
[Route("api/settings")]
|
||||
[DefaultFromBody]
|
||||
public class SettingsItemController : BaseApiController
|
||||
{
|
||||
private ILogger<SettingsItemController> _logger;
|
||||
private IMapper _mapper;
|
||||
private ItemService _itemService;
|
||||
|
||||
public SettingsItemController(
|
||||
ILogger<SettingsItemController> logger,
|
||||
ItemService itemService,
|
||||
IMapper mapper
|
||||
)
|
||||
{
|
||||
_itemService = itemService;
|
||||
_logger = logger;
|
||||
_mapper = mapper;
|
||||
}
|
||||
|
||||
[HttpGet("item-categories")]
|
||||
public async Task<ObjectResult> ItemsCategories(CancellationToken cancellationToken)
|
||||
{
|
||||
var list = await _itemService.GetAllCategoriesAsync(UserId, cancellationToken);
|
||||
|
||||
return Ok(_mapper.Map<List<ItemCategoryViewModel>>(list)
|
||||
.OrderByDescending(x => x.SortOrder)
|
||||
.ThenBy(x => x.Name));
|
||||
}
|
||||
|
||||
[HttpPost("item-categories")]
|
||||
public async Task<ObjectResult> ItemCategoriesAdd(ItemCategoryViewModel request, CancellationToken cancellationToken)
|
||||
{
|
||||
var exec = await _itemService.CategoryAddAsync(UserId, request.FromModel(), cancellationToken);
|
||||
|
||||
switch (exec.Status)
|
||||
{
|
||||
case GeneralExecStatus.success:
|
||||
return Ok(_mapper.Map<ItemCategoryViewModel>(exec.Result!));
|
||||
|
||||
case GeneralExecStatus.failure:
|
||||
case GeneralExecStatus.not_found:
|
||||
return ProblemBadResponse("Adding item category failed.");
|
||||
|
||||
default:
|
||||
throw new NotSupportedException(exec.Status.ToString());
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPut("item-categories/{id}")]
|
||||
public async Task<ObjectResult> ItemCategoriesUpdate(
|
||||
[AsGuid] string id,
|
||||
ItemCategoryViewModel request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var exec = await _itemService.CategoryUpdateAsync(UserId, id.AsGuid(), request.FromModel(), cancellationToken);
|
||||
|
||||
switch (exec.Status)
|
||||
{
|
||||
case GeneralExecStatus.success:
|
||||
return Ok(_mapper.Map<ItemCategoryViewModel>(exec.Result!));
|
||||
|
||||
case GeneralExecStatus.failure:
|
||||
case GeneralExecStatus.not_found:
|
||||
return ProblemBadResponse("Update item category failed.");
|
||||
|
||||
default:
|
||||
throw new NotSupportedException(exec.Status.ToString());
|
||||
}
|
||||
}
|
||||
|
||||
[HttpDelete("item-categories/{id}")]
|
||||
public async Task<ObjectResult> ItemCategoriesDelete([AsGuid] string id, CancellationToken cancellationToken)
|
||||
{
|
||||
var exec = await _itemService.CategoryRemoveAsync(UserId, id.AsGuid(), cancellationToken);
|
||||
switch (exec.Status)
|
||||
{
|
||||
case ItemCategoryRemoveResult.success:
|
||||
return Ok(_mapper.Map<ItemCategoryViewModel>(exec.Result!));
|
||||
|
||||
case ItemCategoryRemoveResult.failure:
|
||||
return ProblemBadResponse("Remove item category failed.");
|
||||
case ItemCategoryRemoveResult.not_found:
|
||||
return ProblemNotFoundResponse("Account item not found.");
|
||||
case ItemCategoryRemoveResult.accounts_exists:
|
||||
return ProblemBadResponse("Account motion have accounts.");
|
||||
|
||||
default:
|
||||
throw new NotSupportedException(exec.Status.ToString());
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet("items")]
|
||||
public async Task<ObjectResult> Items([FromQuery][AsGuid] string category, CancellationToken cancellationToken)
|
||||
{
|
||||
var categoryId = category.AsGuid();
|
||||
|
||||
var list = categoryId != UserId
|
||||
? await _itemService.GetByCategoryAsync(UserId, categoryId, cancellationToken)
|
||||
: await _itemService.GetByUncategorizedAsync(UserId, cancellationToken);
|
||||
|
||||
return Ok(_mapper.Map<List<ItemViewModel>>(list.OrderBy(x => x.Name)));
|
||||
}
|
||||
|
||||
[HttpPut("items/{id}")]
|
||||
public async Task<ObjectResult> ItemsUpdate(
|
||||
[AsGuid] string id,
|
||||
ItemEditModel request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var exec = await _itemService.UpdateAsync(UserId, id.AsGuid(), request.Category.AsGuid(), cancellationToken);
|
||||
|
||||
switch (exec.Status)
|
||||
{
|
||||
case GeneralExecStatus.not_found:
|
||||
return ProblemNotFoundResponse("Item not found.");
|
||||
case GeneralExecStatus.failure:
|
||||
return ProblemBadResponse("Item update failed.");
|
||||
case GeneralExecStatus.success:
|
||||
return Ok(_mapper.Map<ItemViewModel>(exec.Result!));
|
||||
|
||||
default:
|
||||
throw new NotSupportedException(exec.Status.ToString());
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("items")]
|
||||
public async Task<ObjectResult> ItemsPost(ItemChangeCategoryModel request, CancellationToken cancellationToken)
|
||||
{
|
||||
var exec = await _itemService.UpdateItemsCategoryAsync(
|
||||
UserId,
|
||||
request.category.AsGuid(),
|
||||
request.Items.Select(x => x.AsGuid()).ToList(),
|
||||
cancellationToken);
|
||||
|
||||
switch (exec)
|
||||
{
|
||||
case GeneralExecStatus.not_found:
|
||||
return ProblemNotFoundResponse("Category not found.");
|
||||
case GeneralExecStatus.failure:
|
||||
return ProblemBadResponse("Update failed.");
|
||||
case GeneralExecStatus.success:
|
||||
return Ok(new {});
|
||||
|
||||
default:
|
||||
throw new NotSupportedException(exec.ToString());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
namespace MyOffice.Web.Controllers;
|
||||
|
||||
using Models.Auth;
|
||||
using Identity.Domain;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using System;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using MyOffice.Data.Models.Users;
|
||||
using Core.Extensions;
|
||||
using MyOffice.Core.Identity;
|
||||
using Core;
|
||||
using Services.Users;
|
||||
using Services.Users.Domain;
|
||||
using MyOffice.Data.Models.Currencies;
|
||||
using MyOffice.Web.Infrastructure.Attributes;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/user")]
|
||||
[DefaultFromBody]
|
||||
public class UserController : BaseApiController
|
||||
{
|
||||
private readonly UserManager<ApplicationUser<Guid>> _userManager;
|
||||
private readonly UserService _userService;
|
||||
private readonly IEnumerable<IExternalProviderValidator> _externalProviderValidators;
|
||||
|
||||
public UserController(
|
||||
UserManager<ApplicationUser<Guid>> userManager,
|
||||
UserService userService,
|
||||
IEnumerable<IExternalProviderValidator> externalProviderValidators
|
||||
)
|
||||
{
|
||||
_userManager = userManager;
|
||||
_userService = userService;
|
||||
_externalProviderValidators = externalProviderValidators;
|
||||
}
|
||||
|
||||
[AllowAnonymous]
|
||||
[HttpPost("register")]
|
||||
public async Task<IActionResult> Register([FromBody] RegisterModel request)
|
||||
{
|
||||
if (!ModelState.IsValid)
|
||||
return BadRequest(new
|
||||
{
|
||||
Succeeded = false,
|
||||
Errors = ModelState.Values
|
||||
.SelectMany(v => v.Errors)
|
||||
.Select(e => new { Code = "Validation", Description = e.ErrorMessage })
|
||||
});
|
||||
|
||||
var email = request.UserName.Trim();
|
||||
var user = new ApplicationUser<Guid>
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UserName = email,
|
||||
Email = email,
|
||||
IsEmailConfirmed = true,
|
||||
CurrencyId = CurrencyGlobalIdEnum.USD.ToString(),
|
||||
};
|
||||
|
||||
var result = await _userManager.CreateAsync(user, request.Password);
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
result.Succeeded,
|
||||
Errors = result.Errors
|
||||
.Where(x => !x.Code.Equals("DuplicateUserName"))
|
||||
.Select(x => new { x.Code, x.Description })
|
||||
});
|
||||
}
|
||||
|
||||
[Authorize]
|
||||
[HttpGet("profile")]
|
||||
public async Task<object> ProfileGet()
|
||||
{
|
||||
var user = await _userManager.FindByIdAsync(UserId.ToString());
|
||||
|
||||
return user.ToModel(await _userService.GetUserExternals(user.Id));
|
||||
}
|
||||
|
||||
[Authorize]
|
||||
[HttpPost("profile")]
|
||||
public async Task<object> ProfileUpdate([FromBody] ProfileModel model)
|
||||
{
|
||||
var user = await _userManager.FindByIdAsync(UserId.ToString()); ;
|
||||
|
||||
user.FirstName = model.FirstName;
|
||||
user.LastName = model.LastName;
|
||||
user.FullName = model.FullName.NullIfEmpty() ?? $"{model.FirstName} {model.LastName}";
|
||||
user.CurrencyId = model.Currency;
|
||||
|
||||
await _userManager.UpdateAsync(user);
|
||||
|
||||
return user.ToModel(await _userService.GetUserExternals(user.Id));
|
||||
}
|
||||
|
||||
[Authorize]
|
||||
[HttpPost("attach")]
|
||||
public async Task<object> AttachProvider([FromBody] AttachModel model)
|
||||
{
|
||||
var validator = _externalProviderValidators.FirstOrDefault(x => x.Provider.EqualsIgnoreCase(model.Provider));
|
||||
if (validator == null) return ProblemBadResponse("Provider not supported");
|
||||
|
||||
var result = await validator.ValidateAsync(model.Token);
|
||||
if (!result.IsSuccessed) return ProblemBadResponse("Token not valid");
|
||||
|
||||
var execResult = await _userService.AddUserExternalAsync(UserId, model.Provider, result.ExternalId!, result.Email!);
|
||||
switch (execResult.Status)
|
||||
{
|
||||
case AddUserExternalStatusEnum.success:
|
||||
return Ok(new
|
||||
{
|
||||
success = true
|
||||
});
|
||||
|
||||
case AddUserExternalStatusEnum.externalid_used:
|
||||
case AddUserExternalStatusEnum.user_not_valid:
|
||||
return ProblemBadResponse("Provider already connected");
|
||||
|
||||
default:
|
||||
throw new NotSupportedException(execResult.Status.ToString());
|
||||
}
|
||||
}
|
||||
|
||||
[Authorize]
|
||||
[HttpPost("deattach")]
|
||||
public async Task<object> DeattachProvider([FromBody] DeattachModel model)
|
||||
{
|
||||
var exec = await _userService.RemoveUserExternal(UserId, model.Provider);
|
||||
|
||||
if (exec.Status == GeneralExecStatus.not_found) return ProblemBadResponse("Provider not connected");
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
success = true
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public class DeattachModel
|
||||
{
|
||||
[Required] public string Provider { get; set; } = null!;
|
||||
}
|
||||
|
||||
public class AttachModel
|
||||
{
|
||||
[Required] public string Provider { get; set; } = null!;
|
||||
[Required] public string Token { get; set; } = null!;
|
||||
}
|
||||
|
||||
public class ProfileModel
|
||||
{
|
||||
public class ProviderModel
|
||||
{
|
||||
public string Provider { get; set; } = null!;
|
||||
|
||||
public DateTime CreatedOn { get; set; }
|
||||
}
|
||||
|
||||
public string? Email { get; set; }
|
||||
public string? FirstName { get; set; }
|
||||
public string? LastName { get; set; }
|
||||
public string? FullName { get; set; }
|
||||
public bool? IsEmailConfirmed { get; set; }
|
||||
public string? Currency { get; set; }
|
||||
public List<ProviderModel>? Providers { get; set; }
|
||||
}
|
||||
|
||||
public static class ProfileModelExtensions
|
||||
{
|
||||
public static ProfileModel ToModel(this ApplicationUser<Guid> user, List<UserExternal> userClaims)
|
||||
{
|
||||
return new ProfileModel
|
||||
{
|
||||
Email = user.Email,
|
||||
FirstName = user.FirstName,
|
||||
LastName = user.LastName,
|
||||
FullName = user.FullName,
|
||||
IsEmailConfirmed = user.IsEmailConfirmed,
|
||||
Currency = user.CurrencyId,
|
||||
Providers = userClaims?.Select(x => new ProfileModel.ProviderModel
|
||||
{
|
||||
Provider = x.Provider,
|
||||
CreatedOn = x.CreatedOn
|
||||
}).ToList()
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
namespace MyOffice.Web.Identity;
|
||||
|
||||
using Data.Repositories.Users;
|
||||
using MyOffice.Data.Models.Users;
|
||||
using MyOffice.Services.Identity;
|
||||
|
||||
public class ContextProvider : IContextProvider
|
||||
{
|
||||
private readonly IHttpContextAccessor _httpContextAccessor;
|
||||
private readonly IUserRepository _userRepository;
|
||||
|
||||
public ContextProvider(
|
||||
IHttpContextAccessor httpContextAccessor,
|
||||
IUserRepository userRepository
|
||||
)
|
||||
{
|
||||
_httpContextAccessor = httpContextAccessor;
|
||||
_userRepository = userRepository;
|
||||
}
|
||||
|
||||
private User? _user = null;
|
||||
public User User
|
||||
{
|
||||
get
|
||||
{
|
||||
_user ??= _userRepository.GetUser(UserId);
|
||||
|
||||
return _user!;
|
||||
}
|
||||
}
|
||||
|
||||
public Guid UserId
|
||||
{
|
||||
get
|
||||
{
|
||||
return Guid.Parse(_httpContextAccessor!.HttpContext!.User!.Claims!.FirstOrDefault(x => x.Type == "sub")!.Value);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
namespace MyOffice.Web.Identity.Domain;
|
||||
|
||||
public class ApplicationRole
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace MyOffice.Web.Identity.Domain;
|
||||
|
||||
public class ApplicationUser<TKey>
|
||||
{
|
||||
#pragma warning disable CS8618 // Non-nullable property 'Id' must contain a non-null value when exiting constructor. Consider declaring the property as nullable.
|
||||
public TKey Id { get; set; }
|
||||
#pragma warning restore CS8618 // Non-nullable property 'Id' must contain a non-null value when exiting constructor. Consider declaring the property as nullable.
|
||||
public string UserName { get; set; } = null!;
|
||||
public string Email { get; set; } = null!;
|
||||
public string PasswordHash { get; set; } = null!;
|
||||
|
||||
public string? FirstName { get; set; }
|
||||
public string? LastName { get; set; }
|
||||
public string? FullName { get; set; }
|
||||
public bool IsEmailConfirmed { get; set; }
|
||||
public string? CurrencyId { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
namespace MyOffice.Web.Identity.Domain;
|
||||
|
||||
public class ExternalProvidersConfig
|
||||
{
|
||||
public class Auth0Config
|
||||
{
|
||||
public string? ClientId { get; set; }
|
||||
public string? Domain { get; set; }
|
||||
public string? SecretKey { get; set; }
|
||||
}
|
||||
|
||||
public class GoogleConfig
|
||||
{
|
||||
public string? ClientId { get; set; }
|
||||
}
|
||||
|
||||
public Auth0Config? Auth0 { get; set; }
|
||||
public GoogleConfig? Google { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
namespace MyOffice.Web.Identity.ExternalProviders;
|
||||
|
||||
using Domain;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.IdentityModel.Protocols;
|
||||
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Text;
|
||||
using Core.Extensions;
|
||||
using Core.Identity;
|
||||
|
||||
public class ExternalProviderValidatorAuth0 : IExternalProviderValidator
|
||||
{
|
||||
private readonly ExternalProvidersConfig _externalProvidersConfig;
|
||||
private readonly ILogger<ExternalProviderValidatorAuth0> _logger;
|
||||
|
||||
public ExternalProviderValidatorAuth0(
|
||||
ILogger<ExternalProviderValidatorAuth0> logger,
|
||||
IOptions<ExternalProvidersConfig> externalProvidersConfig
|
||||
)
|
||||
{
|
||||
_externalProvidersConfig = externalProvidersConfig.Value;
|
||||
_logger = logger;
|
||||
|
||||
Provider = ExternalProvidersConst.PROVIDER_AUTH0;
|
||||
IsConfigured = _externalProvidersConfig.IsAuth0Configured();
|
||||
}
|
||||
|
||||
public string Provider { get; }
|
||||
public bool IsConfigured { get; }
|
||||
|
||||
public async Task<ExternalProviderValidatorResult> ValidateAsync(string token)
|
||||
{
|
||||
var auth0Domain = _externalProvidersConfig.Auth0!.Domain!;
|
||||
var secretKey = _externalProvidersConfig.Auth0!.SecretKey!;
|
||||
var securityKey = new SymmetricSecurityKey(Encoding.ASCII.GetBytes(secretKey));
|
||||
var configurationManager = new ConfigurationManager<OpenIdConnectConfiguration>(
|
||||
$"{auth0Domain}.well-known/openid-configuration", new OpenIdConnectConfigurationRetriever());
|
||||
var openIdConfig = await configurationManager.GetConfigurationAsync(CancellationToken.None);
|
||||
|
||||
var validations = new TokenValidationParameters
|
||||
{
|
||||
ValidIssuer = auth0Domain,
|
||||
ValidAudiences = new[] { _externalProvidersConfig.Auth0!.ClientId },
|
||||
IssuerSigningKeys = openIdConfig.SigningKeys,
|
||||
TokenDecryptionKey = securityKey
|
||||
};
|
||||
|
||||
var tokenHandler = new JwtSecurityTokenHandler();
|
||||
var user = tokenHandler.ValidateToken(token, validations, out var validatedToken);
|
||||
if (user.Identity?.IsAuthenticated != true)
|
||||
{
|
||||
return ExternalProviderValidatorResult.Failed();
|
||||
}
|
||||
|
||||
var email = user.Claims.GetEmail();
|
||||
var emailVerified = user.Claims.GetValue("email_verified").AsBool();
|
||||
var fullName = user.Claims.GetValue("name")?.ToString();
|
||||
var externalId = user.Claims.GetSID();
|
||||
|
||||
if (email == null || externalId == null)
|
||||
{
|
||||
return ExternalProviderValidatorResult.Failed();
|
||||
}
|
||||
|
||||
return ExternalProviderValidatorResult.Success(email, emailVerified, externalId, fullName);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
namespace MyOffice.Web.Identity.ExternalProviders;
|
||||
|
||||
using MyOffice.Core.Identity;
|
||||
using Domain;
|
||||
using Google.Apis.Auth;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
public class ExternalProviderValidatorGoogle : IExternalProviderValidator
|
||||
{
|
||||
private readonly ExternalProvidersConfig _externalProvidersConfig;
|
||||
|
||||
public ExternalProviderValidatorGoogle(
|
||||
IOptions<ExternalProvidersConfig> externalProvidersConfig
|
||||
)
|
||||
{
|
||||
_externalProvidersConfig = externalProvidersConfig.Value;
|
||||
|
||||
Provider = ExternalProvidersConst.PROVIDER_GOOGLE;
|
||||
IsConfigured = _externalProvidersConfig.IsGoogleConfigured();
|
||||
}
|
||||
|
||||
public string Provider { get; }
|
||||
public bool IsConfigured { get; }
|
||||
|
||||
public async Task<ExternalProviderValidatorResult> ValidateAsync(string token)
|
||||
{
|
||||
var settings = new GoogleJsonWebSignature.ValidationSettings()
|
||||
{
|
||||
Audience = new List<string>() { _externalProvidersConfig.Google!.ClientId! }
|
||||
};
|
||||
var result = await GoogleJsonWebSignature.ValidateAsync(token, settings);
|
||||
if (!result.EmailVerified) return ExternalProviderValidatorResult.Failed();
|
||||
|
||||
return ExternalProviderValidatorResult.Success(result.Email, result.EmailVerified, result.Subject, result.Name);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace MyOffice.Web.Identity.ExternalProviders;
|
||||
|
||||
public class ExternalProvidersConst
|
||||
{
|
||||
public const string PROVIDER_GOOGLE = "google";
|
||||
public const string PROVIDER_AUTH0 = "auth0";
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
using MyOffice.Web.Identity.Domain;
|
||||
|
||||
namespace MyOffice.Web.Identity;
|
||||
|
||||
using Core.Extensions;
|
||||
|
||||
public static class ExternalProvidersConfigExtensions
|
||||
{
|
||||
public static bool IsGoogleConfigured(this ExternalProvidersConfig? config)
|
||||
{
|
||||
return config != null
|
||||
&& config.Google != null
|
||||
&& config.Google.ClientId.IsPresent();
|
||||
}
|
||||
|
||||
public static bool IsAuth0Configured(this ExternalProvidersConfig? config)
|
||||
{
|
||||
return config != null
|
||||
&& config.Auth0 != null
|
||||
&& config.Auth0.ClientId.IsPresent()
|
||||
&& config.Auth0.Domain.IsPresent()
|
||||
&& config.Auth0.SecretKey.IsPresent();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
namespace MyOffice.Web.Auth;
|
||||
|
||||
using Core.Extensions;
|
||||
using Core.Identity;
|
||||
using Data.Models.Users;
|
||||
using Data.Repositories.Users;
|
||||
using Identity.Domain;
|
||||
using Identity.ExternalProviders;
|
||||
using Identity.Repositories;
|
||||
using Microsoft.Extensions.Options;
|
||||
using OpenIddict.Abstractions;
|
||||
using OpenIddict.Server;
|
||||
using static OpenIddict.Abstractions.OpenIddictConstants;
|
||||
using static OpenIddict.Server.OpenIddictServerEvents;
|
||||
|
||||
public sealed class ExternalGrantHandler : IOpenIddictServerHandler<HandleTokenRequestContext>
|
||||
{
|
||||
private readonly AppUserManager _userManager;
|
||||
private readonly IUserExternalRepository _userExternalRepository;
|
||||
private readonly IEnumerable<IExternalProviderValidator> _externalProviderValidators;
|
||||
private readonly IHttpContextAccessor _httpContextAccessor;
|
||||
|
||||
public ExternalGrantHandler(
|
||||
AppUserManager userManager,
|
||||
IUserExternalRepository userExternalRepository,
|
||||
IEnumerable<IExternalProviderValidator> externalProviderValidators,
|
||||
IHttpContextAccessor httpContextAccessor
|
||||
)
|
||||
{
|
||||
_userManager = userManager;
|
||||
_userExternalRepository = userExternalRepository;
|
||||
_externalProviderValidators = externalProviderValidators;
|
||||
_httpContextAccessor = httpContextAccessor;
|
||||
}
|
||||
|
||||
public async ValueTask HandleAsync(HandleTokenRequestContext context)
|
||||
{
|
||||
if (!string.Equals(context.Request.GrantType, OpenIddictAuthConstants.ExternalGrantType, StringComparison.Ordinal))
|
||||
return;
|
||||
|
||||
var provider = context.Request.GetParameter("provider")?.ToString();
|
||||
if (provider.IsMissing())
|
||||
{
|
||||
context.Reject(Errors.InvalidRequest, "The provider parameter is required.");
|
||||
return;
|
||||
}
|
||||
|
||||
var validator = _externalProviderValidators.FirstOrDefault(x => x.Provider.EqualsIgnoreCase(provider));
|
||||
if (validator is null || !validator.IsConfigured)
|
||||
{
|
||||
context.Reject(Errors.InvalidRequest, $"Provider not supported: {provider}");
|
||||
return;
|
||||
}
|
||||
|
||||
var token = context.Request.GetParameter("token")?.ToString();
|
||||
if (token.IsMissing())
|
||||
{
|
||||
context.Reject(Errors.InvalidRequest, "The token parameter is required.");
|
||||
return;
|
||||
}
|
||||
|
||||
var validationResult = await validator.ValidateAsync(token);
|
||||
if (!validationResult.IsSuccessed)
|
||||
{
|
||||
context.Reject(Errors.InvalidGrant, "Token not valid.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (_httpContextAccessor.HttpContext?.User?.Identity?.IsAuthenticated == true)
|
||||
{
|
||||
context.Reject(Errors.InvalidGrant, "Authentication failed.");
|
||||
return;
|
||||
}
|
||||
|
||||
var user = await _userManager.FindByEmailAsync(validationResult.Email!);
|
||||
if (user is null)
|
||||
{
|
||||
if (!validationResult.EmailVerified)
|
||||
{
|
||||
context.Reject(Errors.InvalidGrant, "Email not confirmed.");
|
||||
return;
|
||||
}
|
||||
|
||||
user = await CreateUserAsync(validationResult.Email!, validationResult.EmailVerified, validationResult.FullName);
|
||||
if (user is null)
|
||||
{
|
||||
context.Reject(Errors.InvalidGrant, "Unable to create user.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
var externalLogin = await _userExternalRepository.GetByUserIdAsync(user.Id, provider)
|
||||
?? AddExternalLogin(user, validationResult.Email!, validationResult.ExternalId!, provider);
|
||||
|
||||
context.SignIn(OpenIddictClaimsHelper.CreatePrincipal(user, context.Request.GetScopes()));
|
||||
}
|
||||
|
||||
private async Task<ApplicationUser<Guid>?> CreateUserAsync(string email, bool isEmailConfirmed, string? fullName)
|
||||
{
|
||||
var user = new ApplicationUser<Guid>
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UserName = email,
|
||||
Email = email,
|
||||
IsEmailConfirmed = isEmailConfirmed,
|
||||
FullName = fullName
|
||||
};
|
||||
|
||||
var password = "Qq1!_" + Guid.NewGuid();
|
||||
var result = await _userManager.CreateAsync(user, password);
|
||||
return result.Succeeded ? user : null;
|
||||
}
|
||||
|
||||
private UserExternal AddExternalLogin(
|
||||
ApplicationUser<Guid> user,
|
||||
string email,
|
||||
string externalId,
|
||||
string provider
|
||||
)
|
||||
{
|
||||
var existing = _userExternalRepository.GetByUserId(user.Id, provider);
|
||||
if (existing is not null)
|
||||
return existing;
|
||||
|
||||
var claim = new UserExternal
|
||||
{
|
||||
UserId = user.Id,
|
||||
CreatedOn = DateTime.UtcNow,
|
||||
Provider = provider.ToLower(),
|
||||
ExternalId = externalId,
|
||||
Email = email
|
||||
};
|
||||
_userExternalRepository.AddUserExternal(claim);
|
||||
|
||||
return claim;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
namespace MyOffice.Web.Auth;
|
||||
|
||||
using System.Security.Claims;
|
||||
using Identity.Domain;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using OpenIddict.Abstractions;
|
||||
using static OpenIddict.Abstractions.OpenIddictConstants;
|
||||
|
||||
public static class OpenIddictClaimsHelper
|
||||
{
|
||||
public static ClaimsPrincipal CreatePrincipal(
|
||||
ApplicationUser<Guid> user,
|
||||
IEnumerable<string> scopes
|
||||
)
|
||||
{
|
||||
var identity = new ClaimsIdentity(
|
||||
authenticationType: TokenValidationParameters.DefaultAuthenticationType,
|
||||
nameType: Claims.Name,
|
||||
roleType: Claims.Role);
|
||||
|
||||
var userId = user.Id.ToString();
|
||||
identity.SetClaim(Claims.Subject, userId);
|
||||
identity.SetClaim(Claims.Name, user.UserName);
|
||||
identity.SetClaim(Claims.PreferredUsername, user.UserName);
|
||||
identity.SetClaim(Claims.Email, user.Email);
|
||||
identity.SetClaim(Claims.EmailVerified, user.IsEmailConfirmed);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(user.FullName))
|
||||
identity.SetClaim(Claims.GivenName, user.FullName);
|
||||
|
||||
identity.SetScopes(scopes);
|
||||
identity.SetDestinations(static claim => claim.Type switch
|
||||
{
|
||||
Claims.Name or Claims.PreferredUsername or Claims.Email or Claims.EmailVerified or Claims.GivenName
|
||||
=> [Destinations.AccessToken, Destinations.IdentityToken],
|
||||
_ => [Destinations.AccessToken]
|
||||
});
|
||||
|
||||
return new ClaimsPrincipal(identity);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace MyOffice.Web.Auth;
|
||||
|
||||
public static class OpenIddictAuthConstants
|
||||
{
|
||||
public const string ApiScope = "api";
|
||||
public const string ApiFriendlyName = "MyOffice.Web API";
|
||||
public const string SpaClientId = "angulartemplate_spa";
|
||||
public const string ExternalGrantType = "external";
|
||||
public const string RolesScope = "roles";
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
namespace MyOffice.Web.Auth;
|
||||
|
||||
using DbContext;
|
||||
using Infrastructure;
|
||||
using Microsoft.Extensions.Options;
|
||||
using OpenIddict.Abstractions;
|
||||
using static OpenIddict.Abstractions.OpenIddictConstants;
|
||||
|
||||
public sealed class OpenIddictSeeder : IHostedService
|
||||
{
|
||||
private readonly IServiceProvider _serviceProvider;
|
||||
private readonly IConfiguration _configuration;
|
||||
private readonly ILogger<OpenIddictSeeder> _logger;
|
||||
|
||||
public OpenIddictSeeder(
|
||||
IServiceProvider serviceProvider,
|
||||
IConfiguration configuration,
|
||||
ILogger<OpenIddictSeeder> logger
|
||||
)
|
||||
{
|
||||
_serviceProvider = serviceProvider;
|
||||
_configuration = configuration;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await using var scope = _serviceProvider.CreateAsyncScope();
|
||||
|
||||
await RegisterScopesAsync(scope.ServiceProvider, cancellationToken);
|
||||
await RegisterClientAsync(scope.ServiceProvider, cancellationToken);
|
||||
}
|
||||
|
||||
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
|
||||
private async Task RegisterScopesAsync(IServiceProvider provider, CancellationToken cancellationToken)
|
||||
{
|
||||
var manager = provider.GetRequiredService<IOpenIddictScopeManager>();
|
||||
|
||||
if (await manager.FindByNameAsync(OpenIddictAuthConstants.ApiScope, cancellationToken) is null)
|
||||
{
|
||||
await manager.CreateAsync(new OpenIddictScopeDescriptor
|
||||
{
|
||||
Name = OpenIddictAuthConstants.ApiScope,
|
||||
DisplayName = OpenIddictAuthConstants.ApiFriendlyName,
|
||||
Resources = { OpenIddictAuthConstants.ApiScope }
|
||||
}, cancellationToken);
|
||||
|
||||
_logger.LogInformation("Created OpenIddict scope {Scope}.", OpenIddictAuthConstants.ApiScope);
|
||||
}
|
||||
|
||||
if (await manager.FindByNameAsync(OpenIddictAuthConstants.RolesScope, cancellationToken) is null)
|
||||
{
|
||||
await manager.CreateAsync(new OpenIddictScopeDescriptor
|
||||
{
|
||||
Name = OpenIddictAuthConstants.RolesScope,
|
||||
DisplayName = "User roles"
|
||||
}, cancellationToken);
|
||||
|
||||
_logger.LogInformation("Created OpenIddict scope {Scope}.", OpenIddictAuthConstants.RolesScope);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RegisterClientAsync(IServiceProvider provider, CancellationToken cancellationToken)
|
||||
{
|
||||
var manager = provider.GetRequiredService<IOpenIddictApplicationManager>();
|
||||
var globalSettings = provider.GetRequiredService<GlobalSettings>();
|
||||
|
||||
var redirectUri = BuildRedirectUri(globalSettings.Host);
|
||||
var existing = await manager.FindByClientIdAsync(OpenIddictAuthConstants.SpaClientId, cancellationToken);
|
||||
var descriptor = CreateSpaClientDescriptor(redirectUri);
|
||||
|
||||
if (existing is null)
|
||||
{
|
||||
await manager.CreateAsync(descriptor, cancellationToken);
|
||||
_logger.LogInformation("Created OpenIddict client {ClientId}.", OpenIddictAuthConstants.SpaClientId);
|
||||
return;
|
||||
}
|
||||
|
||||
var currentRedirectUris = await manager.GetRedirectUrisAsync(existing, cancellationToken);
|
||||
foreach (var uri in currentRedirectUris)
|
||||
{
|
||||
if (!string.Equals(uri, redirectUri.ToString(), StringComparison.Ordinal))
|
||||
descriptor.RedirectUris.Add(new Uri(uri, UriKind.Absolute));
|
||||
}
|
||||
|
||||
await manager.UpdateAsync(existing, descriptor, cancellationToken);
|
||||
_logger.LogInformation("Updated OpenIddict client {ClientId}.", OpenIddictAuthConstants.SpaClientId);
|
||||
}
|
||||
|
||||
internal static OpenIddictApplicationDescriptor CreateSpaClientDescriptor(Uri redirectUri)
|
||||
{
|
||||
var descriptor = new OpenIddictApplicationDescriptor
|
||||
{
|
||||
ClientId = OpenIddictAuthConstants.SpaClientId,
|
||||
DisplayName = "MyOffice SPA",
|
||||
ClientType = ClientTypes.Public,
|
||||
ConsentType = ConsentTypes.Implicit,
|
||||
Permissions =
|
||||
{
|
||||
Permissions.Endpoints.Authorization,
|
||||
Permissions.Endpoints.Token,
|
||||
Permissions.Endpoints.EndSession,
|
||||
Permissions.GrantTypes.AuthorizationCode,
|
||||
Permissions.GrantTypes.Password,
|
||||
Permissions.GrantTypes.RefreshToken,
|
||||
Permissions.Prefixes.GrantType + OpenIddictAuthConstants.ExternalGrantType,
|
||||
Permissions.ResponseTypes.Code,
|
||||
Permissions.Scopes.Email,
|
||||
Permissions.Scopes.Profile,
|
||||
Permissions.Scopes.Roles,
|
||||
Permissions.Prefixes.Scope + Scopes.OpenId,
|
||||
Permissions.Prefixes.Scope + Scopes.OfflineAccess,
|
||||
Permissions.Prefixes.Scope + OpenIddictAuthConstants.ApiScope,
|
||||
Permissions.Prefixes.Scope + OpenIddictAuthConstants.RolesScope
|
||||
}
|
||||
};
|
||||
|
||||
descriptor.RedirectUris.Add(redirectUri);
|
||||
return descriptor;
|
||||
}
|
||||
|
||||
internal static Uri BuildRedirectUri(string? host)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(host))
|
||||
return new Uri("http://localhost:4300/silent-refresh.html");
|
||||
|
||||
return new Uri($"{host.TrimEnd('/')}/silent-refresh.html");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
namespace MyOffice.Web.Auth;
|
||||
|
||||
using System.Security.Cryptography;
|
||||
using DbContext;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using OpenIddict.Server;
|
||||
using OpenIddict.Validation.AspNetCore;
|
||||
using static OpenIddict.Abstractions.OpenIddictConstants;
|
||||
using static OpenIddict.Server.OpenIddictServerEvents;
|
||||
|
||||
public static class OpenIddictServiceCollectionExtensions
|
||||
{
|
||||
public static IServiceCollection AddMyOfficeOpenIddict(
|
||||
this IServiceCollection services,
|
||||
IConfiguration configuration,
|
||||
IHostEnvironment environment
|
||||
)
|
||||
{
|
||||
services.AddOpenIddict()
|
||||
.AddCore(options =>
|
||||
{
|
||||
options.UseEntityFrameworkCore()
|
||||
.UseDbContext<AppDbContext>();
|
||||
})
|
||||
.AddServer(options =>
|
||||
{
|
||||
options.SetAuthorizationEndpointUris("/connect/authorize")
|
||||
.SetTokenEndpointUris("/connect/token")
|
||||
.SetUserInfoEndpointUris("/connect/userinfo")
|
||||
.SetEndSessionEndpointUris("/connect/logout");
|
||||
|
||||
options.AllowAuthorizationCodeFlow()
|
||||
.AllowPasswordFlow()
|
||||
.AllowRefreshTokenFlow()
|
||||
.AllowCustomFlow(OpenIddictAuthConstants.ExternalGrantType);
|
||||
|
||||
options.RegisterScopes(
|
||||
Scopes.OpenId,
|
||||
Scopes.Email,
|
||||
Scopes.Profile,
|
||||
Scopes.Roles,
|
||||
Scopes.OfflineAccess,
|
||||
OpenIddictAuthConstants.ApiScope,
|
||||
OpenIddictAuthConstants.RolesScope);
|
||||
|
||||
ConfigureCryptography(options, configuration, environment);
|
||||
|
||||
var aspNetCoreBuilder = options.UseAspNetCore()
|
||||
.EnableAuthorizationEndpointPassthrough()
|
||||
.EnableUserInfoEndpointPassthrough()
|
||||
.EnableEndSessionEndpointPassthrough();
|
||||
|
||||
// Local HTTP only. In production nginx terminates TLS and
|
||||
// ForwardedHeaders restores https:// for OpenIddict.
|
||||
// Docker demo also serves plain HTTP (OpenIddict:AllowHttp / Environment=Docker).
|
||||
if (environment.IsDevelopment()
|
||||
|| IsDockerEnvironment(environment)
|
||||
|| configuration.GetValue("OpenIddict:AllowHttp", false))
|
||||
{
|
||||
aspNetCoreBuilder.DisableTransportSecurityRequirement();
|
||||
}
|
||||
|
||||
// Password and external grants need custom handlers (separate registrations —
|
||||
// a second UseScopedHandler() on the same builder replaces the first).
|
||||
options.AddEventHandler<HandleTokenRequestContext>(builder =>
|
||||
builder.UseScopedHandler<PasswordGrantHandler>());
|
||||
|
||||
options.AddEventHandler<HandleTokenRequestContext>(builder =>
|
||||
builder.UseScopedHandler<ExternalGrantHandler>());
|
||||
})
|
||||
.AddValidation(options =>
|
||||
{
|
||||
options.UseLocalServer();
|
||||
options.UseAspNetCore();
|
||||
});
|
||||
|
||||
services.AddAuthentication(options =>
|
||||
{
|
||||
options.DefaultScheme = OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme;
|
||||
options.DefaultAuthenticateScheme = OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme;
|
||||
options.DefaultChallengeScheme = OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme;
|
||||
});
|
||||
|
||||
services.AddHostedService<OpenIddictSeeder>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
private static void ConfigureCryptography(
|
||||
OpenIddictServerBuilder options,
|
||||
IConfiguration configuration,
|
||||
IHostEnvironment environment
|
||||
)
|
||||
{
|
||||
// Dev: certs in the user profile. Docker/demo: ephemeral keys (no cert store in containers).
|
||||
if (string.IsNullOrWhiteSpace(configuration["OpenIddict:SigningKeyPath"])
|
||||
&& (environment.IsDevelopment()
|
||||
|| IsDockerEnvironment(environment)
|
||||
|| configuration.GetValue("OpenIddict:UseEphemeralKeys", false)))
|
||||
{
|
||||
if (IsDockerEnvironment(environment)
|
||||
|| configuration.GetValue("OpenIddict:UseEphemeralKeys", false))
|
||||
{
|
||||
options.AddEphemeralEncryptionKey()
|
||||
.AddEphemeralSigningKey();
|
||||
}
|
||||
else
|
||||
{
|
||||
options.AddDevelopmentEncryptionCertificate()
|
||||
.AddDevelopmentSigningCertificate();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
var contentRoot = configuration.GetValue<string>(WebHostDefaults.ContentRootKey)
|
||||
?? throw new InvalidOperationException("Content root path is not configured.");
|
||||
|
||||
var signingPath = ResolveKeyPath(
|
||||
configuration["OpenIddict:SigningKeyPath"],
|
||||
contentRoot,
|
||||
"keys/signing.pem");
|
||||
var encryptionPath = ResolveKeyPath(
|
||||
configuration["OpenIddict:EncryptionKeyPath"],
|
||||
contentRoot,
|
||||
"keys/encryption.pem");
|
||||
|
||||
if (!File.Exists(signingPath))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"OpenIddict signing key not found at '{signingPath}'. " +
|
||||
"Set OpenIddict:SigningKeyPath or place keys/signing.pem under the content root.");
|
||||
}
|
||||
|
||||
options.AddSigningKey(LoadRsaSecurityKey(signingPath));
|
||||
|
||||
if (File.Exists(encryptionPath) &&
|
||||
!string.Equals(encryptionPath, signingPath, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
options.AddEncryptionKey(LoadRsaSecurityKey(encryptionPath));
|
||||
}
|
||||
else
|
||||
{
|
||||
// Prefer a dedicated encryption key. Fallback keeps older single-key setups working.
|
||||
options.AddEncryptionKey(LoadRsaSecurityKey(signingPath));
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsDockerEnvironment(IHostEnvironment environment) =>
|
||||
string.Equals(environment.EnvironmentName, "Docker", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
private static string ResolveKeyPath(string? configuredPath, string contentRoot, string defaultRelative)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(configuredPath))
|
||||
{
|
||||
return Path.IsPathRooted(configuredPath)
|
||||
? configuredPath
|
||||
: Path.GetFullPath(Path.Combine(contentRoot, configuredPath));
|
||||
}
|
||||
|
||||
return Path.GetFullPath(Path.Combine(contentRoot, defaultRelative));
|
||||
}
|
||||
|
||||
private static RsaSecurityKey LoadRsaSecurityKey(string path)
|
||||
{
|
||||
var privateKey = File.ReadAllText(path)
|
||||
.Replace("-----BEGIN RSA PRIVATE KEY-----", string.Empty, StringComparison.Ordinal)
|
||||
.Replace("-----END RSA PRIVATE KEY-----", string.Empty, StringComparison.Ordinal)
|
||||
.Replace("-----BEGIN PRIVATE KEY-----", string.Empty, StringComparison.Ordinal)
|
||||
.Replace("-----END PRIVATE KEY-----", string.Empty, StringComparison.Ordinal)
|
||||
.Replace("\r", string.Empty, StringComparison.Ordinal)
|
||||
.Replace("\n", string.Empty, StringComparison.Ordinal);
|
||||
|
||||
var rsa = RSA.Create();
|
||||
var keyBytes = Convert.FromBase64String(privateKey);
|
||||
|
||||
try
|
||||
{
|
||||
rsa.ImportRSAPrivateKey(keyBytes, out _);
|
||||
}
|
||||
catch (CryptographicException)
|
||||
{
|
||||
rsa.ImportPkcs8PrivateKey(keyBytes, out _);
|
||||
}
|
||||
|
||||
return new RsaSecurityKey(rsa);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
namespace MyOffice.Web.Auth;
|
||||
|
||||
using Identity.Domain;
|
||||
using Identity.Repositories;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using OpenIddict.Abstractions;
|
||||
using OpenIddict.Server;
|
||||
using OpenIddict.Server.AspNetCore;
|
||||
using static OpenIddict.Abstractions.OpenIddictConstants;
|
||||
using static OpenIddict.Server.OpenIddictServerEvents;
|
||||
|
||||
public sealed class PasswordGrantHandler : IOpenIddictServerHandler<HandleTokenRequestContext>
|
||||
{
|
||||
private readonly AppUserManager _userManager;
|
||||
private readonly SignInManager<ApplicationUser<Guid>> _signInManager;
|
||||
|
||||
public PasswordGrantHandler(
|
||||
AppUserManager userManager,
|
||||
SignInManager<ApplicationUser<Guid>> signInManager
|
||||
)
|
||||
{
|
||||
_userManager = userManager;
|
||||
_signInManager = signInManager;
|
||||
}
|
||||
|
||||
public async ValueTask HandleAsync(HandleTokenRequestContext context)
|
||||
{
|
||||
if (!string.Equals(context.Request.GrantType, GrantTypes.Password, StringComparison.Ordinal))
|
||||
return;
|
||||
|
||||
var username = context.Request.Username?.Trim();
|
||||
if (string.IsNullOrWhiteSpace(username))
|
||||
{
|
||||
context.Reject(Errors.InvalidGrant, "Invalid username or password.");
|
||||
return;
|
||||
}
|
||||
|
||||
var user = await _userManager.FindByNameAsync(username)
|
||||
?? await _userManager.FindByEmailAsync(username);
|
||||
if (user is null)
|
||||
{
|
||||
context.Reject(Errors.InvalidGrant, "Invalid username or password.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!await _signInManager.CanSignInAsync(user))
|
||||
{
|
||||
context.Reject(Errors.InvalidGrant, "The specified user cannot sign in.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (_userManager.SupportsUserLockout && await _userManager.IsLockedOutAsync(user))
|
||||
{
|
||||
context.Reject(Errors.InvalidGrant, "The specified user is locked out.");
|
||||
return;
|
||||
}
|
||||
|
||||
var result = await _signInManager.CheckPasswordSignInAsync(user, context.Request.Password!, lockoutOnFailure: true);
|
||||
if (!result.Succeeded)
|
||||
{
|
||||
context.Reject(Errors.InvalidGrant, "Invalid username or password.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (_userManager.SupportsUserLockout)
|
||||
await _userManager.ResetAccessFailedCountAsync(user);
|
||||
|
||||
context.SignIn(OpenIddictClaimsHelper.CreatePrincipal(user, context.Request.GetScopes()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
namespace MyOffice.Web.Identity.Repositories;
|
||||
|
||||
using System.Security.Cryptography;
|
||||
using Domain;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
/// <summary>
|
||||
/// Identity V3 hasher for new passwords; still verifies the legacy
|
||||
/// (16-byte salt + 20-byte PBKDF2-SHA1 @ 100k) format and signals rehash.
|
||||
/// </summary>
|
||||
public class PasswordHasher : IPasswordHasher<ApplicationUser<Guid>>
|
||||
{
|
||||
private const int LegacySaltSize = 16;
|
||||
private const int LegacyHashSize = 20;
|
||||
private const int LegacyIterations = 100_000;
|
||||
private const int LegacyPayloadSize = LegacySaltSize + LegacyHashSize;
|
||||
|
||||
private readonly PasswordHasher<ApplicationUser<Guid>> _identityHasher = new();
|
||||
|
||||
public string HashPassword(ApplicationUser<Guid> user, string password) =>
|
||||
_identityHasher.HashPassword(user, password);
|
||||
|
||||
public PasswordVerificationResult VerifyHashedPassword(
|
||||
ApplicationUser<Guid> user,
|
||||
string hashedPassword,
|
||||
string providedPassword
|
||||
)
|
||||
{
|
||||
if (string.IsNullOrEmpty(hashedPassword) || providedPassword == null)
|
||||
{
|
||||
return PasswordVerificationResult.Failed;
|
||||
}
|
||||
|
||||
// Prefer modern Identity format when payload is not the legacy 36-byte blob.
|
||||
if (!IsLegacyPayload(hashedPassword))
|
||||
{
|
||||
return _identityHasher.VerifyHashedPassword(user, hashedPassword, providedPassword);
|
||||
}
|
||||
|
||||
if (VerifyLegacyHash(providedPassword, hashedPassword))
|
||||
{
|
||||
return PasswordVerificationResult.SuccessRehashNeeded;
|
||||
}
|
||||
|
||||
return PasswordVerificationResult.Failed;
|
||||
}
|
||||
|
||||
private static bool IsLegacyPayload(string hashedPassword)
|
||||
{
|
||||
try
|
||||
{
|
||||
var bytes = Convert.FromBase64String(hashedPassword);
|
||||
return bytes.Length == LegacyPayloadSize;
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
internal static string HashLegacyForTests(string password)
|
||||
{
|
||||
var salt = RandomNumberGenerator.GetBytes(LegacySaltSize);
|
||||
var hash = Rfc2898DeriveBytes.Pbkdf2(
|
||||
password,
|
||||
salt,
|
||||
LegacyIterations,
|
||||
HashAlgorithmName.SHA1,
|
||||
LegacyHashSize);
|
||||
|
||||
var payload = new byte[LegacyPayloadSize];
|
||||
Buffer.BlockCopy(salt, 0, payload, 0, LegacySaltSize);
|
||||
Buffer.BlockCopy(hash, 0, payload, LegacySaltSize, LegacyHashSize);
|
||||
return Convert.ToBase64String(payload);
|
||||
}
|
||||
|
||||
private static bool VerifyLegacyHash(string password, string passwordHash)
|
||||
{
|
||||
byte[] hashBytes;
|
||||
try
|
||||
{
|
||||
hashBytes = Convert.FromBase64String(passwordHash);
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (hashBytes.Length != LegacyPayloadSize)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var salt = hashBytes.AsSpan(0, LegacySaltSize);
|
||||
var expected = hashBytes.AsSpan(LegacySaltSize, LegacyHashSize);
|
||||
var actual = Rfc2898DeriveBytes.Pbkdf2(
|
||||
password,
|
||||
salt,
|
||||
LegacyIterations,
|
||||
HashAlgorithmName.SHA1,
|
||||
LegacyHashSize);
|
||||
|
||||
return CryptographicOperations.FixedTimeEquals(expected, actual);
|
||||
}
|
||||
}
|
||||
|
||||
public class AppUserManager : UserManager<ApplicationUser<Guid>>
|
||||
{
|
||||
public AppUserManager(
|
||||
IUserStore<ApplicationUser<Guid>> store,
|
||||
IOptions<IdentityOptions> optionsAccessor,
|
||||
IPasswordHasher<ApplicationUser<Guid>> passwordHasher,
|
||||
IEnumerable<IUserValidator<ApplicationUser<Guid>>> userValidators,
|
||||
IEnumerable<IPasswordValidator<ApplicationUser<Guid>>> passwordValidators,
|
||||
ILookupNormalizer keyNormalizer,
|
||||
IdentityErrorDescriber errors,
|
||||
IServiceProvider services,
|
||||
ILogger<UserManager<ApplicationUser<Guid>>> logger) :
|
||||
base(
|
||||
store,
|
||||
optionsAccessor,
|
||||
passwordHasher,
|
||||
userValidators,
|
||||
passwordValidators,
|
||||
keyNormalizer,
|
||||
errors,
|
||||
services,
|
||||
logger
|
||||
)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
namespace MyOffice.Web.Identity.Repositories;
|
||||
|
||||
using Domain;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
|
||||
public class RoleStore : IRoleStore<ApplicationRole>
|
||||
{
|
||||
public void Dispose()
|
||||
{
|
||||
}
|
||||
|
||||
public Task<IdentityResult> CreateAsync(ApplicationRole role, CancellationToken cancellationToken)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
public Task<IdentityResult> UpdateAsync(ApplicationRole role, CancellationToken cancellationToken)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
public Task<IdentityResult> DeleteAsync(ApplicationRole role, CancellationToken cancellationToken)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
public Task<string> GetRoleIdAsync(ApplicationRole role, CancellationToken cancellationToken)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
public Task<string> GetRoleNameAsync(ApplicationRole role, CancellationToken cancellationToken)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
public Task SetRoleNameAsync(ApplicationRole role, string roleName, CancellationToken cancellationToken)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
public Task<string> GetNormalizedRoleNameAsync(ApplicationRole role, CancellationToken cancellationToken)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
public Task SetNormalizedRoleNameAsync(ApplicationRole role, string normalizedName,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
public Task<ApplicationRole> FindByIdAsync(string roleId, CancellationToken cancellationToken)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
public Task<ApplicationRole> FindByNameAsync(string normalizedRoleName, CancellationToken cancellationToken)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
namespace MyOffice.Web.Identity.Repositories;
|
||||
|
||||
using Data.Models.Users;
|
||||
using Data.Repositories.Users;
|
||||
using Domain;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
|
||||
public class UserStore :
|
||||
IUserStore<ApplicationUser<Guid>>,
|
||||
IUserPasswordStore<ApplicationUser<Guid>>,
|
||||
IUserEmailStore<ApplicationUser<Guid>>
|
||||
{
|
||||
private readonly IUserRepository _userRepository;
|
||||
|
||||
public UserStore(
|
||||
IUserRepository userRepository
|
||||
)
|
||||
{
|
||||
_userRepository = userRepository;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
}
|
||||
|
||||
public Task<string> GetUserIdAsync(ApplicationUser<Guid> user, CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult(user.Id.ToString());
|
||||
}
|
||||
|
||||
public Task<string> GetUserNameAsync(ApplicationUser<Guid> user, CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult(user.UserName);
|
||||
}
|
||||
|
||||
public Task SetUserNameAsync(ApplicationUser<Guid> user, string userName, CancellationToken cancellationToken)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
public Task<string> GetNormalizedUserNameAsync(ApplicationUser<Guid> user, CancellationToken cancellationToken)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
public Task SetNormalizedUserNameAsync(ApplicationUser<Guid> user, string normalizedName,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
user.UserName = normalizedName;
|
||||
return Task.FromResult(0);
|
||||
}
|
||||
|
||||
public Task<IdentityResult> CreateAsync(ApplicationUser<Guid> user, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = _userRepository.AddUser(new User
|
||||
{
|
||||
Id = user.Id,
|
||||
UserName = user.UserName,
|
||||
Email = user.Email,
|
||||
PasswordHash = user.PasswordHash,
|
||||
FirstName = user.FirstName,
|
||||
LastName = user.LastName,
|
||||
FullName = user.FullName,
|
||||
CurrencyId = user.CurrencyId!,
|
||||
IsEmailConfirmed = user.IsEmailConfirmed
|
||||
});
|
||||
|
||||
return Task.FromResult(result == 1 ? IdentityResult.Success : IdentityResult.Failed());
|
||||
}
|
||||
|
||||
public Task<IdentityResult> UpdateAsync(ApplicationUser<Guid> user, CancellationToken cancellationToken)
|
||||
{
|
||||
var userDb = new User
|
||||
{
|
||||
Id = user.Id,
|
||||
UserName = user.UserName,
|
||||
Email = user.Email,
|
||||
PasswordHash = user.PasswordHash,
|
||||
FirstName = user.FirstName,
|
||||
LastName = user.LastName,
|
||||
FullName = user.FullName,
|
||||
CurrencyId = user.CurrencyId!,
|
||||
IsEmailConfirmed = user.IsEmailConfirmed
|
||||
};
|
||||
|
||||
var result = _userRepository.UpdateUser(userDb);
|
||||
return Task.FromResult(result == 1 ? IdentityResult.Success : IdentityResult.Failed());
|
||||
}
|
||||
|
||||
public Task<IdentityResult> DeleteAsync(ApplicationUser<Guid> user, CancellationToken cancellationToken)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
public async Task<ApplicationUser<Guid>> FindByIdAsync(string userId, CancellationToken cancellationToken)
|
||||
{
|
||||
var user = await _userRepository.GetUserAsync(Guid.Parse(userId));
|
||||
if (user == null)
|
||||
{
|
||||
#pragma warning disable CS8603 // Possible null reference return.
|
||||
return null;
|
||||
#pragma warning restore CS8603 // Possible null reference return.
|
||||
}
|
||||
|
||||
return new ApplicationUser<Guid>
|
||||
{
|
||||
Id = user.Id,
|
||||
UserName = user.UserName,
|
||||
Email = user.Email,
|
||||
PasswordHash = user.PasswordHash,
|
||||
FirstName = user.FirstName,
|
||||
LastName = user.LastName,
|
||||
FullName = user.FullName,
|
||||
CurrencyId = user.CurrencyId,
|
||||
IsEmailConfirmed = user.IsEmailConfirmed
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<ApplicationUser<Guid>> FindByNameAsync(string normalizedUserName,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var user = await _userRepository.GetByUserUserNameAsync(normalizedUserName);
|
||||
if (user == null)
|
||||
{
|
||||
#pragma warning disable CS8603 // Possible null reference return.
|
||||
return null;
|
||||
#pragma warning restore CS8603 // Possible null reference return.
|
||||
}
|
||||
|
||||
return new ApplicationUser<Guid>
|
||||
{
|
||||
Id = user.Id,
|
||||
UserName = user.UserName,
|
||||
Email = user.Email,
|
||||
PasswordHash = user.PasswordHash,
|
||||
FirstName = user.FirstName,
|
||||
LastName = user.LastName,
|
||||
FullName = user.FullName,
|
||||
CurrencyId = user.CurrencyId,
|
||||
IsEmailConfirmed = user.IsEmailConfirmed
|
||||
};
|
||||
}
|
||||
|
||||
public Task SetPasswordHashAsync(ApplicationUser<Guid> user, string passwordHash,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
user.PasswordHash = passwordHash;
|
||||
return Task.FromResult(0);
|
||||
}
|
||||
|
||||
public Task<string> GetPasswordHashAsync(ApplicationUser<Guid> user, CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult(user.PasswordHash);
|
||||
}
|
||||
|
||||
public Task<bool> HasPasswordAsync(ApplicationUser<Guid> user, CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult(!string.IsNullOrEmpty(user.PasswordHash));
|
||||
}
|
||||
|
||||
public Task SetEmailAsync(ApplicationUser<Guid> user, string email, CancellationToken cancellationToken)
|
||||
{
|
||||
user.Email = email;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<string> GetEmailAsync(ApplicationUser<Guid> user, CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult(user.Email);
|
||||
}
|
||||
|
||||
public Task<bool> GetEmailConfirmedAsync(ApplicationUser<Guid> user, CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult(user.IsEmailConfirmed);
|
||||
}
|
||||
|
||||
public Task SetEmailConfirmedAsync(ApplicationUser<Guid> user, bool confirmed, CancellationToken cancellationToken)
|
||||
{
|
||||
user.IsEmailConfirmed = confirmed;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<ApplicationUser<Guid>> FindByEmailAsync(string normalizedEmail, CancellationToken cancellationToken)
|
||||
{
|
||||
return FindByNameAsync(normalizedEmail, cancellationToken);
|
||||
}
|
||||
|
||||
public Task<string?> GetNormalizedEmailAsync(ApplicationUser<Guid> user, CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult(user?.Email);
|
||||
}
|
||||
|
||||
public Task SetNormalizedEmailAsync(ApplicationUser<Guid> user, string normalizedEmail,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult(0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
namespace MyOffice.Web.Infrastructure.Attributes
|
||||
{
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Core.Extensions;
|
||||
|
||||
public class AsGuidAttribute: ValidationAttribute
|
||||
{
|
||||
private readonly bool _nullable;
|
||||
|
||||
public AsGuidAttribute(bool nullable = false)
|
||||
{
|
||||
_nullable = nullable;
|
||||
}
|
||||
|
||||
protected override ValidationResult? IsValid(object? value, ValidationContext validationContext)
|
||||
{
|
||||
if (value == null && _nullable)
|
||||
{
|
||||
return ValidationResult.Success;
|
||||
}
|
||||
|
||||
if (value == null || value.ToString().IsMissing())
|
||||
{
|
||||
return new ValidationResult("Id parameter is not valid");
|
||||
}
|
||||
|
||||
if (!Guid.TryParse(value.ToString(), out _))
|
||||
{
|
||||
return new ValidationResult("Id parameter is not valid");
|
||||
}
|
||||
|
||||
return ValidationResult.Success;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
namespace MyOffice.Web.Infrastructure.Attributes;
|
||||
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.ApplicationModels;
|
||||
using Microsoft.AspNetCore.Mvc.ModelBinding;
|
||||
|
||||
[AttributeUsage(AttributeTargets.Class, Inherited = false, AllowMultiple = false)]
|
||||
public sealed class DefaultFromBodyAttribute : Attribute
|
||||
{
|
||||
}
|
||||
|
||||
public class DefaultFromBodyBindingConvention : IActionModelConvention
|
||||
{
|
||||
public void Apply(ActionModel action)
|
||||
{
|
||||
if (action == null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(action));
|
||||
}
|
||||
|
||||
if (action.Controller.Attributes.Any(x => x is DefaultFromBodyAttribute))
|
||||
{
|
||||
foreach (var parameter in action.Parameters)
|
||||
{
|
||||
if (parameter.Attributes.Any(x =>
|
||||
x is FromQueryAttribute
|
||||
or FromRouteAttribute
|
||||
or FromHeaderAttribute
|
||||
or FromFormAttribute
|
||||
or FromServicesAttribute))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Already bound (e.g. CancellationToken → Special) — do not force Body.
|
||||
if (parameter.BindingInfo?.BindingSource is { } existing
|
||||
&& existing != BindingSource.ModelBinding
|
||||
&& existing != BindingSource.Custom)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var paramType = parameter.ParameterInfo.ParameterType;
|
||||
if (paramType == typeof(CancellationToken)
|
||||
|| Nullable.GetUnderlyingType(paramType) == typeof(CancellationToken))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var isSimpleType = paramType.IsPrimitive
|
||||
|| paramType.IsEnum
|
||||
|| paramType == typeof(string)
|
||||
|| paramType == typeof(decimal)
|
||||
|| paramType == typeof(Guid)
|
||||
|| paramType == typeof(Guid?)
|
||||
|| paramType == typeof(DateTime)
|
||||
|| paramType == typeof(DateTime?)
|
||||
|| paramType == typeof(DateOnly)
|
||||
|| paramType == typeof(DateOnly?)
|
||||
|| paramType == typeof(TimeOnly)
|
||||
|| paramType == typeof(TimeOnly?);
|
||||
|
||||
if (!isSimpleType)
|
||||
{
|
||||
parameter.BindingInfo ??= new BindingInfo();
|
||||
parameter.BindingInfo.BindingSource = BindingSource.Body;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
namespace MyOffice.Web.Infrastructure.Attributes;
|
||||
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Filters;
|
||||
using Microsoft.AspNetCore.Mvc.Infrastructure;
|
||||
|
||||
public class GlobalModelStateValidatorAttribute : ActionFilterAttribute
|
||||
{
|
||||
private readonly ProblemDetailsFactory _problemDetailsFactory;
|
||||
|
||||
public GlobalModelStateValidatorAttribute(ProblemDetailsFactory problemDetailsFactory)
|
||||
{
|
||||
_problemDetailsFactory = problemDetailsFactory;
|
||||
}
|
||||
|
||||
public override void OnActionExecuting(ActionExecutingContext context)
|
||||
{
|
||||
if (!context.ModelState.IsValid)
|
||||
{
|
||||
context.Result = new ObjectResult(_problemDetailsFactory.CreateValidationProblemDetails(context.HttpContext, context.ModelState))
|
||||
{
|
||||
StatusCode = StatusCodes.Status400BadRequest
|
||||
};
|
||||
}
|
||||
|
||||
base.OnActionExecuting(context);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
namespace MyOffice.Web.Infrastructure;
|
||||
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Infrastructure;
|
||||
using Microsoft.AspNetCore.Mvc.ModelBinding;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
public class CustomProblemDetailsFactory : ProblemDetailsFactory
|
||||
{
|
||||
private readonly ApiBehaviorOptions options;
|
||||
private readonly JsonOptions jsonOptions;
|
||||
|
||||
public CustomProblemDetailsFactory(IOptions<ApiBehaviorOptions> options, IOptions<JsonOptions> jsonOptions)
|
||||
{
|
||||
this.options = options?.Value ?? throw new ArgumentNullException(nameof(options));
|
||||
this.jsonOptions = jsonOptions?.Value ?? throw new ArgumentNullException(nameof(jsonOptions));
|
||||
}
|
||||
|
||||
public override ProblemDetails CreateProblemDetails(
|
||||
HttpContext httpContext,
|
||||
int? statusCode = null,
|
||||
string? title = null,
|
||||
string? type = null,
|
||||
string? detail = null,
|
||||
string? instance = null)
|
||||
{
|
||||
statusCode ??= 500;
|
||||
|
||||
var problemDetails = new ProblemDetails
|
||||
{
|
||||
Status = statusCode,
|
||||
Title = title,
|
||||
Type = type,
|
||||
Detail = detail,
|
||||
Instance = instance,
|
||||
};
|
||||
|
||||
ApplyProblemDetailsDefaults(httpContext, problemDetails, statusCode.Value);
|
||||
|
||||
return problemDetails;
|
||||
}
|
||||
|
||||
public override ValidationProblemDetails CreateValidationProblemDetails(
|
||||
HttpContext httpContext,
|
||||
ModelStateDictionary modelStateDictionary,
|
||||
int? statusCode = null,
|
||||
string? title = null,
|
||||
string? type = null,
|
||||
string? detail = null,
|
||||
string? instance = null)
|
||||
{
|
||||
if (modelStateDictionary == null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(modelStateDictionary));
|
||||
}
|
||||
|
||||
statusCode ??= 400;
|
||||
|
||||
var errors = modelStateDictionary
|
||||
.Where(x => x.Value?.Errors.Any() == true)
|
||||
.ToDictionary(
|
||||
kvp => jsonOptions?.JsonSerializerOptions?.PropertyNamingPolicy?.ConvertName(kvp.Key) ?? kvp.Key,
|
||||
kvp => kvp.Value!.Errors.Select(x => x.ErrorMessage).ToArray()
|
||||
);
|
||||
|
||||
var problemDetails = new ValidationProblemDetails(errors)
|
||||
{
|
||||
Status = statusCode,
|
||||
Type = type,
|
||||
Detail = detail,
|
||||
Instance = instance,
|
||||
};
|
||||
|
||||
if (title != null)
|
||||
{
|
||||
// For validation problem details, don't overwrite the default title with null.
|
||||
problemDetails.Title = title;
|
||||
}
|
||||
|
||||
ApplyProblemDetailsDefaults(httpContext, problemDetails, statusCode.Value);
|
||||
|
||||
return problemDetails;
|
||||
}
|
||||
|
||||
private void ApplyProblemDetailsDefaults(HttpContext httpContext, ProblemDetails problemDetails, int statusCode)
|
||||
{
|
||||
problemDetails.Status ??= statusCode;
|
||||
|
||||
if (options.ClientErrorMapping.TryGetValue(statusCode, out var clientErrorData))
|
||||
{
|
||||
problemDetails.Title ??= clientErrorData.Title;
|
||||
problemDetails.Type ??= clientErrorData.Link;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
namespace MyOffice.Web.Infrastructure;
|
||||
|
||||
using DbContext;
|
||||
|
||||
/// <summary>
|
||||
/// Runs EF migrate + seed on startup with a scoped DbContext.
|
||||
/// </summary>
|
||||
public sealed class DatabaseInitializerHostedService : IHostedService
|
||||
{
|
||||
private readonly IServiceProvider _serviceProvider;
|
||||
private readonly ILogger<DatabaseInitializerHostedService> _logger;
|
||||
|
||||
public DatabaseInitializerHostedService(
|
||||
IServiceProvider serviceProvider,
|
||||
ILogger<DatabaseInitializerHostedService> logger
|
||||
)
|
||||
{
|
||||
_serviceProvider = serviceProvider;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await using var scope = _serviceProvider.CreateAsyncScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
|
||||
_logger.LogInformation("Applying database migrations and seed data…");
|
||||
await DatabaseBootstrapper.InitializeAsync(db, cancellationToken);
|
||||
_logger.LogInformation("Database ready.");
|
||||
}
|
||||
|
||||
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
namespace MyOffice.Web.Infrastructure.Filters;
|
||||
|
||||
using System.Collections;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Filters;
|
||||
using MyOffice.Web.Models;
|
||||
|
||||
public class ResponseFilter : IActionFilter
|
||||
{
|
||||
private readonly ILogger<ResponseFilter> _logger;
|
||||
|
||||
public ResponseFilter(ILogger<ResponseFilter> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public void OnActionExecuting(ActionExecutingContext context)
|
||||
{
|
||||
}
|
||||
|
||||
public void OnActionExecuted(ActionExecutedContext context)
|
||||
{
|
||||
// TODO: restore
|
||||
/*var route = $"{context.Controller.GetType().Name}.{context.ActionDescriptor.DisplayName}";
|
||||
if (context.Result == null)
|
||||
throw new NotSupportedException($"[{route}] Response required");
|
||||
if (!(context.Result is ObjectResult result))
|
||||
throw new NotSupportedException($"[{route}] Response must be an ObjectResult - {context.Result?.GetType().Name}");
|
||||
if (result.Value == null)
|
||||
throw new NotSupportedException($"[{route}] Response must be an ObjectResult with value");
|
||||
|
||||
if (result.Value is IResponseModel)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var type = result.Value.GetType();
|
||||
if (type.IsGenericType
|
||||
&& result.Value is IEnumerable
|
||||
&& type.GenericTypeArguments.Any(x => x.GetInterfaces().Any(y => y == typeof(IResponseModel))))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
throw new NotSupportedException($"[{route}] Response must be an ObjectResult with an IResponseModel - {result.Value?.GetType().Name}");*/
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace MyOffice.Web.Infrastructure;
|
||||
|
||||
public class GlobalSettings
|
||||
{
|
||||
public string? Host { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
namespace MyOffice.Web.Infrastructure;
|
||||
|
||||
using System.Linq.Expressions;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Filters;
|
||||
|
||||
public static class RouteHelperExtensions
|
||||
{
|
||||
public static void Bind<T>(this IRouteBuilder routeBuilder, Expression<Func<T, ObjectResult>> expression)
|
||||
{
|
||||
System.Console.WriteLine("Bind");
|
||||
System.Console.WriteLine(expression.Name);
|
||||
System.Console.WriteLine(expression.Body.ToString());
|
||||
routeBuilder.MapRoute("SettingsAccountAccountsGet", "api/settings/accounts", new { controller = "SettingsAccount", action = "AccountsGet" });
|
||||
}
|
||||
}
|
||||
|
||||
public class CustomRouter : IRouter
|
||||
{
|
||||
private readonly IRouter _defaultRouter;
|
||||
private readonly string _controller;
|
||||
private readonly string _action;
|
||||
|
||||
public CustomRouter(IRouter defaultRouter, string controller, string action)
|
||||
{
|
||||
_defaultRouter = defaultRouter;
|
||||
_controller = controller;
|
||||
_action = action;
|
||||
}
|
||||
|
||||
public VirtualPathData? GetVirtualPath(VirtualPathContext context)
|
||||
{
|
||||
Console.WriteLine($"1:{context.RouteName}");
|
||||
return null;
|
||||
}
|
||||
|
||||
public async Task RouteAsync(RouteContext context)
|
||||
{
|
||||
var headers = context.HttpContext.Request.Headers;
|
||||
var path = context.HttpContext.Request.Path.Value!.Split('/');
|
||||
|
||||
Console.WriteLine($"CustomRouter:{context.HttpContext.Request.Path.Value}");
|
||||
|
||||
context.RouteData.Values["controller"] = _controller;
|
||||
context.RouteData.Values["action"] = _action;
|
||||
|
||||
await _defaultRouter.RouteAsync(context);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
namespace MyOffice.Web.Infrastructure;
|
||||
|
||||
using Microsoft.AspNetCore.Diagnostics;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Infrastructure;
|
||||
|
||||
/// <summary>
|
||||
/// Returns ProblemDetails for unhandled exceptions (replaces missing /Error page).
|
||||
/// </summary>
|
||||
public sealed class UnhandledExceptionHandler : IExceptionHandler
|
||||
{
|
||||
private readonly IHostEnvironment _environment;
|
||||
private readonly ILogger<UnhandledExceptionHandler> _logger;
|
||||
private readonly ProblemDetailsFactory _problemDetailsFactory;
|
||||
|
||||
public UnhandledExceptionHandler(
|
||||
IHostEnvironment environment,
|
||||
ILogger<UnhandledExceptionHandler> logger,
|
||||
ProblemDetailsFactory problemDetailsFactory
|
||||
)
|
||||
{
|
||||
_environment = environment;
|
||||
_logger = logger;
|
||||
_problemDetailsFactory = problemDetailsFactory;
|
||||
}
|
||||
|
||||
public async ValueTask<bool> TryHandleAsync(
|
||||
HttpContext httpContext,
|
||||
Exception exception,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
_logger.LogError(exception, "Unhandled exception for {Method} {Path}",
|
||||
httpContext.Request.Method,
|
||||
httpContext.Request.Path);
|
||||
|
||||
var problem = _problemDetailsFactory.CreateProblemDetails(
|
||||
httpContext,
|
||||
statusCode: StatusCodes.Status500InternalServerError,
|
||||
title: "An unexpected error occurred.",
|
||||
detail: _environment.IsDevelopment() ? exception.Message : null);
|
||||
|
||||
httpContext.Response.StatusCode = problem.Status ?? StatusCodes.Status500InternalServerError;
|
||||
httpContext.Response.ContentType = "application/problem+json";
|
||||
await httpContext.Response.WriteAsJsonAsync(problem, cancellationToken);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
namespace MyOffice.Web.Models.Account;
|
||||
|
||||
using AutoMapper;
|
||||
using MyOffice.Services.Account.Domain;
|
||||
using MyOffice.Services.Identity;
|
||||
using User;
|
||||
|
||||
public class AccessRightsViewModel
|
||||
{
|
||||
public UserViewModel? User { get; set; } = null!;
|
||||
public bool AllowRead { get; set; }
|
||||
public bool AllowWrite { get; set; }
|
||||
public bool AllowManage { get; set; }
|
||||
public bool AllowDelete { get; set; }
|
||||
public bool IsOwner { get; set; }
|
||||
}
|
||||
|
||||
public class AccessRightsViewModelProfile : Profile
|
||||
{
|
||||
public AccessRightsViewModelProfile()
|
||||
{
|
||||
CreateMap<AccountAccessDto, AccessRightsViewModel>()
|
||||
.AfterMap<AccessRightsViewModelMappingAction>()
|
||||
;
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
public class AccessRightsViewModelMappingAction : IMappingAction<AccountAccessDto, AccessRightsViewModel>
|
||||
{
|
||||
private readonly IContextProvider _contextProvider;
|
||||
|
||||
public AccessRightsViewModelMappingAction(IContextProvider contextProvider)
|
||||
{
|
||||
_contextProvider = contextProvider;
|
||||
}
|
||||
|
||||
public void Process(AccountAccessDto source, AccessRightsViewModel destination, ResolutionContext context)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
namespace MyOffice.Web.Models.Account;
|
||||
|
||||
using AutoMapper;
|
||||
using MyOffice.Services.Account.Domain;
|
||||
|
||||
public class AccountAccessInviteViewModel: IResponseModel
|
||||
{
|
||||
public string? Id { get; set; }
|
||||
public string? Account { get; set; }
|
||||
public bool? AllowWrite { get; set; }
|
||||
}
|
||||
|
||||
public class AccountAccessInviteViewModelProfile: Profile
|
||||
{
|
||||
public AccountAccessInviteViewModelProfile()
|
||||
{
|
||||
CreateMap<AccountAccessInviteDto, AccountAccessInviteViewModel>()
|
||||
.ForMember(x => x.AllowWrite, o => o.MapFrom(x => x.IsAllowWrite))
|
||||
;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
using AutoMapper;
|
||||
using MyOffice.Services.Account.Domain;
|
||||
|
||||
namespace MyOffice.Web.Models.Account;
|
||||
|
||||
public class AccountAccessViewModel
|
||||
{
|
||||
public class AccountAccessItemViewModel
|
||||
{
|
||||
public string UserId { get; set; } = null!;
|
||||
public bool AllowWrite { get; set; }
|
||||
}
|
||||
|
||||
public string? Email { get; set; }
|
||||
public bool AllowWrite { get; set; }
|
||||
public List<AccountAccessItemViewModel> Accesses { get; set; } = null!;
|
||||
}
|
||||
|
||||
public class AccountAccessItemViewModelProfile : Profile
|
||||
{
|
||||
public AccountAccessItemViewModelProfile()
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
namespace MyOffice.Web.Models.Account;
|
||||
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using AutoMapper;
|
||||
using MyOffice.Services.Account.Domain;
|
||||
|
||||
public class AccountCategoryViewModel: IResponseModel
|
||||
{
|
||||
public string? Id { get; set; }
|
||||
|
||||
[Required]
|
||||
public string? Name { get; set; }
|
||||
|
||||
public bool? AllowDelete { get; set; }
|
||||
}
|
||||
|
||||
public class AccountCategoryViewModelProfile: Profile
|
||||
{
|
||||
public AccountCategoryViewModelProfile()
|
||||
{
|
||||
CreateMap<AccountAccountCategoryDto, AccountCategoryViewModel>()
|
||||
.ForMember(x => x.Id, o => o.MapFrom(x => x.CategoryId))
|
||||
.ForMember(x => x.Name, o => o.MapFrom(x => x.Category!.Name))
|
||||
;
|
||||
CreateMap<AccountCategoryDto, AccountCategoryViewModel>()
|
||||
.ForMember(x => x.Id, o => o.MapFrom(x => x.Id))
|
||||
.ForMember(x => x.Name, o => o.MapFrom(x => x.Name))
|
||||
;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
namespace MyOffice.Web.Models.Account;
|
||||
|
||||
using AutoMapper;
|
||||
using MyOffice.Services.Account.Domain;
|
||||
|
||||
public class AccountDetailedViewModel: BaseViewModel
|
||||
{
|
||||
public AccountViewModel Account { get; set; } = null!;
|
||||
public decimal Rest { get; set; }
|
||||
}
|
||||
|
||||
public class AccountDetailedViewModelProfile: Profile
|
||||
{
|
||||
public AccountDetailedViewModelProfile()
|
||||
{
|
||||
CreateMap<AccountDetailedDto, AccountDetailedViewModel>()
|
||||
.ForMember(x => x.Account, o => o.MapFrom(x => x.Account))
|
||||
;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace MyOffice.Web.Models.Account;
|
||||
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
public class AccountEditRequestModel
|
||||
{
|
||||
[Required]
|
||||
public string Name { get; set; } = null!;
|
||||
[Required]
|
||||
public string CurrencyId { get; set; } = null!;
|
||||
public string? CategoryId { get; set; }
|
||||
public string? UserId { get; set; }
|
||||
public string? Type { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace MyOffice.Web.Models.Account;
|
||||
|
||||
public class AccountInviteAcceptRequest
|
||||
{
|
||||
public string Name { get; set; } = null!;
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
namespace MyOffice.Web.Models.Account;
|
||||
|
||||
using AutoMapper;
|
||||
using MyOffice.Services.Account.Domain;
|
||||
using MyOffice.Services.Identity;
|
||||
using Newtonsoft.Json;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
public class AccountViewModel: IResponseModel
|
||||
{
|
||||
public string? Id { get; set; }
|
||||
|
||||
[Required]
|
||||
public string Name { get; set; } = null!;
|
||||
public string Type { get; set; } = null!;
|
||||
|
||||
[Required]
|
||||
public string CurrencyId { get; set; } = null!;
|
||||
public string? CurrencyName { get; set; }
|
||||
|
||||
public List<AccountCategoryViewModel>? Categories { get; set; }
|
||||
|
||||
[Required]
|
||||
public string CategoryId { get; set; } = null!;
|
||||
|
||||
#region Permissions
|
||||
|
||||
public List<AccessRightsViewModel>? AccessRights { get; set; }
|
||||
public bool AllowRead { get; set; }
|
||||
public bool AllowWrite { get; set; }
|
||||
public bool AllowDelete { get; set; }
|
||||
public bool AllowManage { get; set; }
|
||||
|
||||
#endregion Permissions
|
||||
}
|
||||
|
||||
public class AccountViewModelProfile : Profile
|
||||
{
|
||||
public AccountViewModelProfile()
|
||||
{
|
||||
CreateMap<AccountDto, AccountViewModel>()
|
||||
.ForMember(x => x.CurrencyId, o => o.MapFrom(x => x.CurrencyGlobalId))
|
||||
.ForMember(x => x.CurrencyName, o => o.MapFrom(x => x.Currency!.Name))
|
||||
.AfterMap<AccountViewModelMappingAction>()
|
||||
;
|
||||
}
|
||||
}
|
||||
|
||||
public class AccountViewModelMappingAction : IMappingAction<AccountDto, AccountViewModel>
|
||||
{
|
||||
private readonly IContextProvider _contextProvider;
|
||||
|
||||
public AccountViewModelMappingAction(IContextProvider contextProvider)
|
||||
{
|
||||
_contextProvider = contextProvider;
|
||||
}
|
||||
|
||||
public void Process(AccountDto source, AccountViewModel destination, ResolutionContext context)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
namespace MyOffice.Web.Models.Account;
|
||||
|
||||
using AutoMapper;
|
||||
|
||||
using MyOffice.Services.Currency.Domain;
|
||||
using Currency;
|
||||
using Item;
|
||||
using Motion;
|
||||
using Services.Account.Domain;
|
||||
using User;
|
||||
using MyOffice.Services.Identity;
|
||||
|
||||
/*public class CustomResolver : IValueResolver<AccountAccessDto, AccessRightsViewModel, bool>
|
||||
{
|
||||
private readonly ILogger<CustomResolver> _logger;
|
||||
private readonly IContextProvider _contextProvider;
|
||||
|
||||
public CustomResolver(
|
||||
ILogger<CustomResolver> logger,
|
||||
IContextProvider contextProvider
|
||||
)
|
||||
{
|
||||
_logger = logger;
|
||||
_contextProvider = contextProvider;
|
||||
}
|
||||
|
||||
public bool Resolve(AccountAccessDto source, AccessRightsViewModel destination, bool member, ResolutionContext context)
|
||||
{
|
||||
return source.OwnerId == _contextProvider.UserId;
|
||||
}
|
||||
}*/
|
||||
|
||||
/*public class PublicationSystemResolver : IMemberValueResolver<object, object, string, bool>
|
||||
{
|
||||
private readonly IContextProvider _contextProvider;
|
||||
|
||||
public PublicationSystemResolver(
|
||||
IContextProvider contextProvider
|
||||
)
|
||||
{
|
||||
this._contextProvider = contextProvider;
|
||||
}
|
||||
|
||||
public bool Resolve(object source, object destination, string sourceMember, bool destMember, ResolutionContext context)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}*/
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace MyOffice.Web.Models.Account
|
||||
{
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
public class MotionsGetRequest
|
||||
{
|
||||
[Required]
|
||||
public DateTime From { get; set; }
|
||||
[Required]
|
||||
public DateTime To { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace MyOffice.Web.Models.Auth;
|
||||
|
||||
public class LoginModel
|
||||
{
|
||||
public string? UserName { get; set; }
|
||||
public string? Password { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace MyOffice.Web.Models.Auth;
|
||||
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
public class RegisterModel
|
||||
{
|
||||
[Required]
|
||||
[EmailAddress]
|
||||
public string UserName { get; set; } = null!;
|
||||
|
||||
[Required]
|
||||
[MinLength(8)]
|
||||
public string Password { get; set; } = null!;
|
||||
|
||||
[Required]
|
||||
[Compare(nameof(Password))]
|
||||
public string ConfirmPassword { get; set; } = null!;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace MyOffice.Web.Models;
|
||||
|
||||
public class BaseViewModel : IResponseModel
|
||||
{
|
||||
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
namespace MyOffice.Web.Models.Currency
|
||||
{
|
||||
public class CurrencyAddModel
|
||||
{
|
||||
public string Id { get; set; } = null!;
|
||||
public string Name { get; set; } = null!;
|
||||
public string ShortName { get; set; } = null!;
|
||||
public int Quantity { get; set; }
|
||||
public decimal Rate { get; set; }
|
||||
public DateTime RateDate { get; set; }
|
||||
}
|
||||
|
||||
public class CurrencyRateModel
|
||||
{
|
||||
public int Quantity { get; set; }
|
||||
public decimal Rate { get; set; }
|
||||
public DateTime RateDate { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace MyOffice.Web.Models.Currency;
|
||||
|
||||
public class CurrencyEditModel
|
||||
{
|
||||
public string Name { get; set; } = null!;
|
||||
public string ShortName { get; set; } = null!;
|
||||
public int Quantity { get; set; }
|
||||
public decimal Rate { get; set; }
|
||||
public DateTime RateDate { get; set; }
|
||||
public bool IsPrimary { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
/// <see cref="MyOffice.Data.Models.Currencies.CurrencyGlobal"/>
|
||||
/// <see cref="MyOffice.Services.Currency.Domain.CurrencyGlobalDto"/>
|
||||
namespace MyOffice.Web.Models.Currency;
|
||||
|
||||
using AutoMapper;
|
||||
using MyOffice.Services.Currency.Domain;
|
||||
|
||||
public class CurrencyGlobalViewModel: IResponseModel
|
||||
{
|
||||
public string Id { get; set; } = null!;
|
||||
public string Name { get; set; } = null!;
|
||||
public int Quantity { get; set; }
|
||||
public string Symbol { get; set; } = null!;
|
||||
}
|
||||
|
||||
public class CurrencyGlobalViewModelProfile : Profile
|
||||
{
|
||||
public CurrencyGlobalViewModelProfile()
|
||||
{
|
||||
CreateMap<CurrencyGlobalDto, CurrencyGlobalViewModel>();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
namespace MyOffice.Web.Models.Currency;
|
||||
|
||||
using AutoMapper;
|
||||
using MyOffice.Services.Currency.Domain;
|
||||
|
||||
public class CurrencyRateViewModel: BaseViewModel
|
||||
{
|
||||
public string Currency { get; set; } = null!;
|
||||
public DateTime DateTime { get; set; }
|
||||
public int Quantity { get; set; }
|
||||
public decimal Rate { get; set; }
|
||||
}
|
||||
|
||||
public class CurrencyRateViewModelProfile: Profile
|
||||
{
|
||||
public CurrencyRateViewModelProfile()
|
||||
{
|
||||
CreateMap<CurrencyRateDto, CurrencyRateViewModel>();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
namespace MyOffice.Web.Models.Currency;
|
||||
|
||||
using AutoMapper;
|
||||
using MyOffice.Services.Currency.Domain;
|
||||
|
||||
public class CurrencyViewModel: BaseViewModel
|
||||
{
|
||||
public string Id { get; set; } = null!;
|
||||
public string Code { get; set; } = null!;
|
||||
public string Symbol { get; set; } = null!;
|
||||
public string Name { get; set; } = null!;
|
||||
public string ShortName { get; set; } = null!;
|
||||
public decimal? Rate { get; set; }
|
||||
public int? Quantity { get; set; }
|
||||
public DateTime? RateDate { get; set; }
|
||||
public bool IsPrimary { get; set; }
|
||||
}
|
||||
|
||||
public class CurrencyViewModelProfile: Profile
|
||||
{
|
||||
public CurrencyViewModelProfile()
|
||||
{
|
||||
CreateMap<CurrencyDto, CurrencyViewModel>();
|
||||
|
||||
CreateMap<CurrencyWithRateDto, CurrencyViewModel>()
|
||||
.ForMember(x => x.Id, o => o.MapFrom(x => x.Currency.Id))
|
||||
.ForMember(x => x.Code, o => o.MapFrom(x => x.Currency.CurrencyGlobalId))
|
||||
.ForMember(x => x.Name, o => o.MapFrom(x => x.Currency.Name))
|
||||
.ForMember(x => x.ShortName, o => o.MapFrom(x => x.Currency.ShortName))
|
||||
.ForMember(x => x.Rate, o => o.MapFrom(x => x.Rate == null ? (decimal?)null : x.Rate.Rate))
|
||||
.ForMember(x => x.IsPrimary, o => o.MapFrom(x => x.Currency.IsPrimary))
|
||||
.ForMember(x => x.Quantity, o => o.MapFrom(x => x.Rate == null ? (int?)null : x.Rate.Quantity))
|
||||
.ForMember(x => x.RateDate, o => o.MapFrom(x => x.Rate == null ? (DateTime?)null : x.Rate.DateTime))
|
||||
;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace MyOffice.Web.Models.Dashboard;
|
||||
|
||||
using AutoMapper;
|
||||
using MyOffice.Services.Dashboard.Domain;
|
||||
|
||||
public class DashboardIncomeDataViewModel : DashboardIncomeData, IResponseModel
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
public class DashboardIncomeDataViewModelProfile : Profile
|
||||
{
|
||||
public DashboardIncomeDataViewModelProfile()
|
||||
{
|
||||
CreateMap<DashboardIncomeData, DashboardIncomeDataViewModel>();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace MyOffice.Web.Models.Dashboard;
|
||||
|
||||
using AutoMapper;
|
||||
using MyOffice.Services.Dashboard.Domain;
|
||||
|
||||
public class DashboardViewModel : DashboardData, IResponseModel
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
public class DashboardViewModelProfile : Profile
|
||||
{
|
||||
public DashboardViewModelProfile()
|
||||
{
|
||||
CreateMap<DashboardData, DashboardViewModel>();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
namespace MyOffice.Web.Models;
|
||||
|
||||
public interface IRequestModel
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
namespace MyOffice.Web.Models;
|
||||
|
||||
public interface IResponseModel
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
namespace MyOffice.Web.Models.Item;
|
||||
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using AutoMapper;
|
||||
using Services.Account.Domain;
|
||||
|
||||
public class ItemCategoryViewModel: IResponseModel
|
||||
{
|
||||
public string? Id { get; set; }
|
||||
|
||||
[Required]
|
||||
public string Name { get; set; } = null!;
|
||||
|
||||
public bool AllowDelete { get; set; }
|
||||
public int SortOrder { get; set; }
|
||||
public bool Internal { get; set; }
|
||||
}
|
||||
|
||||
//TODO: REMOVE
|
||||
public static class ItemCategoryViewModelExtensions
|
||||
{
|
||||
public static ItemCategoryDto FromModel(this ItemCategoryViewModel input)
|
||||
{
|
||||
if (input == null)
|
||||
throw new ArgumentNullException(nameof(input));
|
||||
|
||||
return new ItemCategoryDto
|
||||
{
|
||||
Name = input.Name,
|
||||
IsInternal = input.Internal,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public class ItemCategoryViewModelProfile: Profile
|
||||
{
|
||||
public ItemCategoryViewModelProfile()
|
||||
{
|
||||
CreateMap<ItemCategoryDto, ItemCategoryViewModel>()
|
||||
.ForMember(x => x.Internal, o => o.MapFrom(x => x.IsInternal))
|
||||
.ForMember(x => x.AllowDelete, o => o.MapFrom(x => !x.Items.Any() && x.Id != x.UserId))
|
||||
.ForMember(x => x.SortOrder, o => o.MapFrom(x => x.Id == x.UserId ? 1 : 0))
|
||||
;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace MyOffice.Web.Models.Item;
|
||||
|
||||
public class ItemChangeCategoryModel
|
||||
{
|
||||
public string category { get; set; } = null!;
|
||||
public List<string> Items { get; set; } = null!;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace MyOffice.Web.Models.Item;
|
||||
|
||||
public class ItemEditModel
|
||||
{
|
||||
public string Id { get; set; } = null!;
|
||||
public string Category { get; set; } = null!;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
namespace MyOffice.Web.Models.Item;
|
||||
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using AutoMapper;
|
||||
using MyOffice.Services.Account.Domain;
|
||||
|
||||
public class ItemViewModel : BaseViewModel
|
||||
{
|
||||
public string? Id { get; set; }
|
||||
[Required]
|
||||
public string? Name { get; set; }
|
||||
|
||||
public bool AllowDelete { get; set; }
|
||||
|
||||
public string CategoryId { get; set; } = null!;
|
||||
public string? Category { get; set; } = null!;
|
||||
|
||||
public string? AccountId { get; set; }
|
||||
}
|
||||
|
||||
public class ItemViewModelProfile: Profile
|
||||
{
|
||||
public ItemViewModelProfile()
|
||||
{
|
||||
CreateMap<ItemDto, ItemViewModel>()
|
||||
.ForMember(x => x.Category, o => o.MapFrom(x => x.Category!.Name))
|
||||
;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace MyOffice.Web.Models.Motion;
|
||||
|
||||
using AutoMapper;
|
||||
using Data.Models.Accounts;
|
||||
using Services.Account.Domain;
|
||||
|
||||
public class AccountControllerProfile : Profile
|
||||
{
|
||||
public AccountControllerProfile()
|
||||
{
|
||||
CreateMap<Motion, MotionViewModel>()
|
||||
.ForMember(x => x.Date, o => o.MapFrom(x => x.DateTime))
|
||||
.ForMember(x => x.Item, o => o.MapFrom(x => x.Item.ItemGlobal.Name))
|
||||
.ForMember(x => x.Plus, o => o.MapFrom(x => x.AmountPlus))
|
||||
.ForMember(x => x.Minus, o => o.MapFrom(x => x.AmountMinus))
|
||||
;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
namespace MyOffice.Web.Models.Motion;
|
||||
|
||||
using AutoMapper;
|
||||
using MyOffice.Services.Account.Domain;
|
||||
|
||||
public class MotionRequest
|
||||
{
|
||||
public DateTime Date { get; set; }
|
||||
public string Item { get; set; } = null!;
|
||||
public string? ItemId { get; set; } = null!;
|
||||
public string? AccountId { get; set; } = null!;
|
||||
public string? Description { get; set; }
|
||||
public decimal? Plus { get; set; }
|
||||
public decimal? Minus { get; set; }
|
||||
public decimal? AmountBalancing { get; set; }
|
||||
}
|
||||
|
||||
public class MotionRequestProfile : Profile
|
||||
{
|
||||
public MotionRequestProfile()
|
||||
{
|
||||
CreateMap<MotionRequest, MotionAddUpdate>();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
namespace MyOffice.Web.Models.Motion;
|
||||
|
||||
using AutoMapper;
|
||||
using MyOffice.Services.Account.Domain;
|
||||
|
||||
public class MotionViewModel : BaseViewModel
|
||||
{
|
||||
public string Id { get; set; } = null!;
|
||||
public DateTime Date { get; set; }
|
||||
public string AccountId { get; set; } = null!;
|
||||
public string Item { get; set; } = null!;
|
||||
public string? Description { get; set; }
|
||||
public decimal Plus { get; set; }
|
||||
public decimal Minus { get; set; }
|
||||
}
|
||||
|
||||
public class MotionViewModelProfile : Profile
|
||||
{
|
||||
public MotionViewModelProfile()
|
||||
{
|
||||
CreateMap<MotionDto, MotionViewModel>()
|
||||
.ForMember(x => x.Item, x => x.MapFrom(m => m.Item.Name))
|
||||
.ForMember(x => x.Minus, x => x.MapFrom(m => m.AmountMinus))
|
||||
.ForMember(x => x.Plus, x => x.MapFrom(m => m.AmountPlus))
|
||||
;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
namespace MyOffice.Web.Models.User;
|
||||
|
||||
using AutoMapper;
|
||||
using MyOffice.Services.Account.Domain;
|
||||
|
||||
public class UserViewModel
|
||||
{
|
||||
public string Id { get; set; } = null!;
|
||||
public string UserName { get; set; } = null!;
|
||||
public string Email { get; set; } = null!;
|
||||
}
|
||||
|
||||
public class UserViewModelProfile: Profile
|
||||
{
|
||||
public UserViewModelProfile()
|
||||
{
|
||||
CreateMap<UserDto, UserViewModel>();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace MyOffice.Web.Models;
|
||||
|
||||
using AutoMapper;
|
||||
using Core.Extensions;
|
||||
|
||||
public class ViewModelProfile : Profile
|
||||
{
|
||||
public ViewModelProfile()
|
||||
{
|
||||
CreateMap<Guid, string>().ConvertUsing(x => x.ToShort());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="MyOffice.Tests" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Compile Remove="logs\**" />
|
||||
<Content Remove="logs\**" />
|
||||
<EmbeddedResource Remove="logs\**" />
|
||||
<None Remove="logs\**" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Content Update="appsettings.Production.json" CopyToPublishDirectory="Never" />
|
||||
<Content Update="appsettings.Production.sample.json" CopyToPublishDirectory="Never" />
|
||||
<Content Update="appsettings.Development.json" CopyToPublishDirectory="Never" />
|
||||
<Content Update="appsettings.Development.sample.json" CopyToPublishDirectory="Never" />
|
||||
<Content Update="web.Release.config" CopyToPublishDirectory="Never" />
|
||||
|
||||
<Content Update="appsettings.Production.json">
|
||||
<CopyToOutputDirectory>Never</CopyToOutputDirectory>
|
||||
</Content>
|
||||
<Content Remove="wwwroot\index.html" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Include="wwwroot\index.html" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="AutoMapper" Version="16.2.0" />
|
||||
<PackageReference Include="Google.Apis.Auth" Version="1.75.0" />
|
||||
<PackageReference Include="IdentityModel" Version="7.0.0" />
|
||||
<PackageReference Include="Microsoft.IdentityModel.Protocols.OpenIdConnect" Version="8.14.0" />
|
||||
<PackageReference Include="OpenIddict.AspNetCore" Version="7.6.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\MyOffice.Core\MyOffice.Core.csproj" />
|
||||
<ProjectReference Include="..\MyOffice.DbContext\MyOffice.DbContext.csproj" />
|
||||
<ProjectReference Include="..\MyOffice.Migration.Postgres\MyOffice.Migrations.Postgres.csproj" />
|
||||
<ProjectReference Include="..\MyOffice.Migration.Sqlite\MyOffice.Migrations.Sqlite.csproj" />
|
||||
<ProjectReference Include="..\MyOffice.Services\MyOffice.Services.csproj" />
|
||||
<ProjectReference Include="..\MyOffice.Shared\MyOffice.Shared.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,56 @@
|
||||
namespace MyOffice.Web;
|
||||
|
||||
public partial class Program
|
||||
{
|
||||
/// <summary>
|
||||
/// Legacy imperative MapRoute table removed — API controllers use attribute routing.
|
||||
/// Kept as a no-op so call sites stay stable while Routes constants remain for SPA/docs.
|
||||
/// </summary>
|
||||
private static void MapRoutes(WebApplication app)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Canonical API path constants (also used by SPA clients / docs).
|
||||
/// </summary>
|
||||
public static class Routes
|
||||
{
|
||||
public static readonly string GlobalCurrencies = "api/general/currencies";
|
||||
public static readonly string SettingsCurrencies = "api/settings/currencies";
|
||||
public static readonly string SettingsCurrency = "api/settings/currencies/{id}";
|
||||
public static readonly string SettingsCurrencyRate = "api/settings/currencies/{id}/rate";
|
||||
|
||||
public static readonly string SettingsAccountCategories = "api/settings/account-categories";
|
||||
public static readonly string SettingsAccountCategory = "api/settings/account-categories/{id}";
|
||||
|
||||
public static readonly string SettingsAccounts = "api/settings/accounts";
|
||||
public static readonly string SettingsAccount = "api/settings/accounts/{id}";
|
||||
public static readonly string SettingsAccountAccountCategory = "api/settings/accounts/{id}/category/{categoryId}";
|
||||
|
||||
public static readonly string SettingsAccountAccesses = "api/settings/accounts/{id}/access";
|
||||
public static readonly string SettingsAccountAccess = "api/settings/accounts/{id}/access/{userId}";
|
||||
public static readonly string SettingsAccountInvites = "api/settings/accounts/invites";
|
||||
public static readonly string SettingsAccountInviteAccept = "api/settings/accounts/invites/{id}/accept";
|
||||
public static readonly string SettingsAccountInviteReject = "api/settings/accounts/invites/{id}/reject";
|
||||
|
||||
public static readonly string SettingsItemCategories = "api/settings/item-categories";
|
||||
public static readonly string SettingsItemCategory = "api/settings/item-categories/{id}";
|
||||
public static readonly string SettingsItems = "api/settings/items";
|
||||
public static readonly string SettingsItem = "api/settings/items/{id}";
|
||||
|
||||
public static readonly string Accounts = "api/accounts";
|
||||
public static readonly string Account = "api/accounts/{id}";
|
||||
public static readonly string AccountMotions = "api/accounts/{id}/motions";
|
||||
public static readonly string AccountMotion = "api/accounts/{id}/motions/{motionId}";
|
||||
public static readonly string Items = "api/items";
|
||||
|
||||
public static readonly string Dashboard = "api/dashboard";
|
||||
public static readonly string DashboardIncome = "api/dashboard/income";
|
||||
public static readonly string DashboardOutcome = "api/dashboard/outcome";
|
||||
|
||||
public static readonly string UserRegister = "api/user/register";
|
||||
public static readonly string UserProfile = "api/user/profile";
|
||||
public static readonly string UserAttach = "api/user/attach";
|
||||
public static readonly string UserDeattach = "api/user/deattach";
|
||||
}
|
||||
@@ -0,0 +1,449 @@
|
||||
namespace MyOffice.Web;
|
||||
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Collections.Concurrent;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.Extensions.FileProviders.Physical;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.IdentityModel.Logging;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.HttpOverrides;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging.Console;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
using Identity;
|
||||
using Identity.Domain;
|
||||
using Identity.ExternalProviders;
|
||||
using Identity.Repositories;
|
||||
using Auth;
|
||||
using Core.Identity;
|
||||
using Core.Extensions;
|
||||
using Data.Repositories.Account;
|
||||
using Data.Repositories.Currency;
|
||||
using Data.Repositories.Item;
|
||||
using Data.Repositories.Users;
|
||||
using DbContext;
|
||||
using Services.Users;
|
||||
using Shared;
|
||||
using Infrastructure;
|
||||
using Models.Account;
|
||||
using MyOffice.Services.Currency;
|
||||
using Services.Account;
|
||||
using Services.Item;
|
||||
using MyOffice.Services.Dashboard;
|
||||
using MyOffice.Services.Identity;
|
||||
using MyOffice.Services.Account.Domain;
|
||||
using MyOffice.Web.Infrastructure.Attributes;
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Infrastructure;
|
||||
using MyOffice.Web.Infrastructure.Filters;
|
||||
|
||||
//TODO: Data.Model only Repository and Service, response <-> mapper <-> web <-> mapper <-> service <-> repository
|
||||
//TODO: Project management
|
||||
//TODO: Model names. Controller = xxxRequest/xxxResponse. Service xxxInput/xxxOutput.
|
||||
//TODO: Validate string as Guid when id
|
||||
//TODO: Test back
|
||||
//TODO: Test front
|
||||
//TODO: Test DB ???
|
||||
//TODO: Email confirmation
|
||||
//TODO: Password recovery
|
||||
//TODO: Account sharing
|
||||
//TODO: /dashboard/dashboard -> /dashboard/main or /dashboard
|
||||
//TODO: SPA load categories twice menu + setting (cache)
|
||||
//TODO: SPA update account category -> update menu
|
||||
//TODO: Response model from base type
|
||||
//TODO: XXXResult -> XXXStatus
|
||||
//TODO: SPA isAllowDelete -> allowDelete (remove is)
|
||||
//TODO: Repository auto registration
|
||||
//TODO: Services auto registration
|
||||
//TODO: openid-configuration failed - lock login
|
||||
//TODO: BUG some times after login redirect to dashboard but exists return url
|
||||
//TODO: automapper -> Extension ToModel() ToDbo() FromModel() FromDbo()
|
||||
//TODO: SPA all http requests -> services
|
||||
//TODO: Items, select category -> save url to allow refresh
|
||||
//TODO: Accounts, select category -> save url to allow refresh
|
||||
|
||||
public partial class Program
|
||||
{
|
||||
public static void Main(string[] args)
|
||||
{
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
AddServices(builder, args);
|
||||
|
||||
var app = builder.Build();
|
||||
Configure(app);
|
||||
|
||||
app.Run();
|
||||
}
|
||||
|
||||
private static void AddServices(WebApplicationBuilder builder, string[] args)
|
||||
{
|
||||
#region Loging
|
||||
|
||||
builder.Services.AddLogging(logging =>
|
||||
logging.AddSimpleConsole(options =>
|
||||
{
|
||||
//options.SingleLine = true;
|
||||
options.TimestampFormat = "[HH:mm:ss] ";
|
||||
options.ColorBehavior = LoggerColorBehavior.Enabled;
|
||||
})
|
||||
);
|
||||
//File Logger
|
||||
builder.Logging.AddFile(builder.Configuration.GetSection("Logging"));
|
||||
|
||||
#endregion Loging
|
||||
|
||||
builder.Services.Configure<LoggerFilterOptions>(options =>
|
||||
{
|
||||
options.AddFilter("OpenIddict", LogLevel.Warning);
|
||||
});
|
||||
|
||||
// Shared JSON after appsettings.*, then restore higher-priority sources
|
||||
builder.Configuration.AddSharedAppSettings(builder.Environment.EnvironmentName);
|
||||
builder.Configuration.AddEnvironmentVariables();
|
||||
builder.Configuration.AddCommandLine(args);
|
||||
|
||||
#region Database
|
||||
|
||||
var databaseProvider = builder.Configuration["DatabaseProvider"];
|
||||
if (databaseProvider == null)
|
||||
throw new NullReferenceException($"Configuration DatabaseProvider {databaseProvider}");
|
||||
|
||||
var connectionString = builder.Configuration.GetConnectionString(databaseProvider);
|
||||
if (connectionString == null)
|
||||
throw new NullReferenceException($"Configuration ConnectionString {databaseProvider}");
|
||||
|
||||
var connectionConfiguration = new ConnectionConfiguration(databaseProvider, connectionString);
|
||||
RepositoryInitializer.Initialize(builder.Services, connectionConfiguration);
|
||||
// Before OpenIddictSeeder so the schema exists when clients/scopes are registered.
|
||||
builder.Services.AddHostedService<DatabaseInitializerHostedService>();
|
||||
|
||||
#endregion Database
|
||||
|
||||
// Configurations
|
||||
builder.Services.Configure<ExternalProvidersConfig>(
|
||||
builder.Configuration.GetSection("ExternalProviders")
|
||||
);
|
||||
|
||||
InitializeGlobalSettings(builder);
|
||||
|
||||
AddIdentityServices(builder);
|
||||
|
||||
builder.Services.AddScoped<IContextProvider, ContextProvider>();
|
||||
|
||||
AddAutoMapper(builder);
|
||||
|
||||
AddRepositories(builder);
|
||||
|
||||
AddBusinessServices(builder);
|
||||
|
||||
builder.Services
|
||||
.AddControllers()
|
||||
.AddJsonOptions(options =>
|
||||
{
|
||||
options.AllowInputFormatterExceptionMessages = builder.Environment.IsDevelopment();
|
||||
options.JsonSerializerOptions.MaxDepth = 0;
|
||||
options.JsonSerializerOptions.ReferenceHandler = ReferenceHandler.IgnoreCycles;
|
||||
options.JsonSerializerOptions.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull;
|
||||
options.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase;
|
||||
options.JsonSerializerOptions.DictionaryKeyPolicy = JsonNamingPolicy.CamelCase;
|
||||
});
|
||||
|
||||
builder.Services.AddCors();
|
||||
|
||||
builder.Services.AddControllersWithViews(options =>
|
||||
{
|
||||
options.Conventions.Add(new DefaultFromBodyBindingConvention());
|
||||
options.Filters.Add(typeof(GlobalModelStateValidatorAttribute));
|
||||
options.Filters.Add(typeof(ResponseFilter));
|
||||
});
|
||||
|
||||
builder.Services.Configure<MvcOptions>(options =>
|
||||
{
|
||||
});
|
||||
|
||||
builder.Services.Configure<ApiBehaviorOptions>(x => {
|
||||
});
|
||||
|
||||
builder.Services.AddProblemDetails();
|
||||
builder.Services.AddExceptionHandler<UnhandledExceptionHandler>();
|
||||
builder.Services.AddSingleton<ProblemDetailsFactory, CustomProblemDetailsFactory>();
|
||||
}
|
||||
|
||||
private static void InitializeGlobalSettings(WebApplicationBuilder builder)
|
||||
{
|
||||
var frontEndHost = builder.Configuration.GetValue<string>("FrontEnd:Host");
|
||||
if (frontEndHost.IsMissing())
|
||||
{
|
||||
frontEndHost = "http://localhost:4300";
|
||||
if (!builder.Environment.IsDevelopment())
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"FrontEnd:Host must be set outside Development (do not derive it from Referer).");
|
||||
}
|
||||
}
|
||||
|
||||
frontEndHost = frontEndHost!.TrimEnd('/');
|
||||
builder.Services.Configure<GlobalSettings>(options => options.Host = frontEndHost);
|
||||
builder.Services.AddSingleton(resolver => resolver.GetRequiredService<IOptions<GlobalSettings>>().Value);
|
||||
}
|
||||
|
||||
private static void AddIdentityServices(WebApplicationBuilder builder)
|
||||
{
|
||||
builder.Services.AddHttpContextAccessor();
|
||||
|
||||
// add identity
|
||||
builder.Services
|
||||
.AddIdentity<ApplicationUser<Guid>, ApplicationRole>()
|
||||
.AddUserStore<UserStore>()
|
||||
.AddRoleStore<RoleStore>()
|
||||
.AddUserManager<AppUserManager>()
|
||||
.AddSignInManager<SignInManager<ApplicationUser<Guid>>>()
|
||||
.AddDefaultTokenProviders();
|
||||
|
||||
builder.Services.AddScoped<IPasswordHasher<ApplicationUser<Guid>>, PasswordHasher>();
|
||||
|
||||
// Identity Services
|
||||
builder.Services.AddScoped<IUserStore<ApplicationUser<Guid>>, UserStore>();
|
||||
builder.Services.AddScoped<IRoleStore<ApplicationRole>, RoleStore>();
|
||||
|
||||
// External providers
|
||||
builder.Services.AddScoped<IExternalProviderValidator, ExternalProviderValidatorAuth0>();
|
||||
builder.Services.AddScoped<IExternalProviderValidator, ExternalProviderValidatorGoogle>();
|
||||
|
||||
// Configure Identity options and password complexity here
|
||||
builder.Services.Configure<IdentityOptions>(options =>
|
||||
{
|
||||
// User settings
|
||||
options.User.RequireUniqueEmail = true;
|
||||
|
||||
// Email+password accounts can sign in immediately after register
|
||||
options.SignIn.RequireConfirmedAccount = false;
|
||||
options.SignIn.RequireConfirmedEmail = false;
|
||||
|
||||
// Password settings
|
||||
options.Password.RequireDigit = true;
|
||||
options.Password.RequiredLength = 8;
|
||||
options.Password.RequireNonAlphanumeric = true;
|
||||
options.Password.RequireUppercase = true;
|
||||
options.Password.RequireLowercase = true;
|
||||
|
||||
// Lockout settings
|
||||
options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(30);
|
||||
options.Lockout.MaxFailedAccessAttempts = 10;
|
||||
});
|
||||
|
||||
builder.Services.Configure<ForwardedHeadersOptions>(options =>
|
||||
{
|
||||
options.ForwardedHeaders =
|
||||
ForwardedHeaders.XForwardedFor
|
||||
| ForwardedHeaders.XForwardedProto
|
||||
| ForwardedHeaders.XForwardedHost;
|
||||
// Trust nginx in Docker; headers are only present behind the proxy.
|
||||
options.KnownNetworks.Clear();
|
||||
options.KnownProxies.Clear();
|
||||
});
|
||||
|
||||
builder.Services.AddMyOfficeOpenIddict(builder.Configuration, builder.Environment);
|
||||
}
|
||||
|
||||
private static void AddAutoMapper(WebApplicationBuilder builder)
|
||||
{
|
||||
// AutoMapper 15+ dual license: runs without a key (warning logs only).
|
||||
// Set AutoMapper:LicenseKey (or AUTOMAPPER_LICENSE_KEY) from https://automapper.io — free Community tier if under $5M revenue.
|
||||
var licenseKey = builder.Configuration["AutoMapper:LicenseKey"];
|
||||
|
||||
builder.Services.AddAutoMapper(
|
||||
c =>
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(licenseKey))
|
||||
{
|
||||
c.LicenseKey = licenseKey;
|
||||
}
|
||||
|
||||
c.AllowNullCollections = true;
|
||||
c.AllowNullDestinationValues = true;
|
||||
},
|
||||
typeof(AccountDto).Assembly,
|
||||
typeof(AccountViewModel).Assembly
|
||||
);
|
||||
}
|
||||
|
||||
private static void AddRepositories(WebApplicationBuilder builder)
|
||||
{
|
||||
builder.Services.AddScoped<IUserRepository, UserRepository>();
|
||||
builder.Services.AddScoped<IUserExternalRepository, UserExternalRepository>();
|
||||
|
||||
builder.Services.AddScoped<ICurrencyGlobalRepository, CurrencyGlobalRepository>();
|
||||
builder.Services.AddScoped<ICurrencyRepository, CurrencyRepository>();
|
||||
builder.Services.AddScoped<ICurrencyRateRepository, CurrencyRateRepository>();
|
||||
|
||||
builder.Services.AddScoped<IAccountCategoryRepository, AccountCategoryRepository>();
|
||||
builder.Services.AddScoped<IAccountRepository, AccountRepository>();
|
||||
builder.Services.AddScoped<IAccountAccessRepository, AccountAccessRepository>();
|
||||
builder.Services.AddScoped<IAccountAccessInviteRepository, AccountAccessInviteRepository>();
|
||||
builder.Services.AddScoped<IAccountAccountCategoryRepository, AccountAccountCategoryRepository>();
|
||||
|
||||
builder.Services.AddScoped<IItemCategoryRepository, ItemCategoryRepository>();
|
||||
builder.Services.AddScoped<IItemRepository, ItemRepository>();
|
||||
builder.Services.AddScoped<IItemGlobalRepository, ItemGlobalRepository>();
|
||||
builder.Services.AddScoped<IMotionRepository, MotionRepository>();
|
||||
|
||||
builder.Services.AddScoped<IVerificationCodeRepository, VerificationCodeRepository>();
|
||||
builder.Services.AddScoped<IEmailTemplateRepository, EmailTemplateRepository>();
|
||||
}
|
||||
|
||||
private static void AddBusinessServices(WebApplicationBuilder builder)
|
||||
{
|
||||
builder.Services.AddScoped<UserService, UserService>();
|
||||
builder.Services.AddScoped<CurrencyService, CurrencyService>();
|
||||
builder.Services.AddScoped<AccountService, AccountService>();
|
||||
builder.Services.AddScoped<ItemService, ItemService>();
|
||||
builder.Services.AddScoped<DashboardService, DashboardService>();
|
||||
}
|
||||
|
||||
private static readonly ConcurrentDictionary<string, PhysicalFileInfo> _staticFilesCache = new();
|
||||
|
||||
private static void Configure(WebApplication app)
|
||||
{
|
||||
// nginx (prod) terminates TLS and forwards X-Forwarded-Proto/Host
|
||||
app.UseForwardedHeaders();
|
||||
|
||||
// Configure the HTTP request pipeline.
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
app.UseDeveloperExceptionPage();
|
||||
IdentityModelEventSource.ShowPII = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
app.UseExceptionHandler();
|
||||
app.UseStatusCodePages();
|
||||
}
|
||||
|
||||
// Explicit routing so CORS runs before endpoint matching (preflight / OPTIONS).
|
||||
app.UseRouting();
|
||||
ConfigureCors(app);
|
||||
|
||||
app.UseDefaultFiles();
|
||||
app.UseStaticFiles();
|
||||
|
||||
var logger = app.Logger;
|
||||
var globalSettings = app.Services.GetRequiredService<GlobalSettings>();
|
||||
logger.LogInformation("FrontEnd host: {Host}", globalSettings.Host);
|
||||
|
||||
// send index.html / static files for non-API routes
|
||||
var coreRoutes = new[]
|
||||
{
|
||||
// API routes
|
||||
new PathString("/api"),
|
||||
// Identity server routes
|
||||
new PathString("/.well-known"),
|
||||
new PathString("/connect"),
|
||||
new PathString("/silent-refresh.html")
|
||||
};
|
||||
|
||||
var webRootPath = app.Configuration.GetValue<string>(WebHostDefaults.ContentRootKey);
|
||||
var wwwrootPath = Path.Combine(webRootPath!, "wwwroot");
|
||||
app.Use(async (context, next) =>
|
||||
{
|
||||
var path = context.Request.Path;
|
||||
|
||||
if (path.Value == null)
|
||||
{
|
||||
await next();
|
||||
return;
|
||||
}
|
||||
|
||||
if (coreRoutes.Any(x => path.StartsWithSegments(x, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
await next();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!_staticFilesCache.TryGetValue(path.Value, out var physicalFileInfo))
|
||||
{
|
||||
var segments = path.Value.Split('/', '\\');
|
||||
var fileName = segments.LastOrDefault();
|
||||
var fileInfo = string.IsNullOrEmpty(fileName)
|
||||
? null
|
||||
: new FileInfo(Path.Combine(wwwrootPath, fileName));
|
||||
|
||||
if (fileInfo is null || !fileInfo.Exists)
|
||||
{
|
||||
// SPA deep link fallback
|
||||
var indexInfo = new FileInfo(Path.Combine(wwwrootPath, "index.html"));
|
||||
if (!indexInfo.Exists)
|
||||
{
|
||||
context.Response.StatusCode = StatusCodes.Status404NotFound;
|
||||
return;
|
||||
}
|
||||
|
||||
physicalFileInfo = new PhysicalFileInfo(indexInfo);
|
||||
}
|
||||
else
|
||||
{
|
||||
physicalFileInfo = new PhysicalFileInfo(fileInfo);
|
||||
_staticFilesCache.TryAdd(path.Value, physicalFileInfo);
|
||||
}
|
||||
}
|
||||
|
||||
await context.Response.SendFileAsync(physicalFileInfo);
|
||||
});
|
||||
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
|
||||
MapRoutes(app);
|
||||
|
||||
app.MapControllers();
|
||||
}
|
||||
|
||||
private static void ConfigureCors(WebApplication app)
|
||||
{
|
||||
var allowedOrigins = (app.Configuration.GetSection("Cors:AllowedOrigins").Get<string[]>() ?? [])
|
||||
.Where(x => !string.IsNullOrWhiteSpace(x))
|
||||
.Select(x => x.Trim().TrimEnd('/'))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
var frontEndHost = app.Configuration.GetValue<string>("FrontEnd:Host")?.Trim().TrimEnd('/');
|
||||
if (!string.IsNullOrWhiteSpace(frontEndHost)
|
||||
&& !allowedOrigins.Contains(frontEndHost, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
allowedOrigins.Add(frontEndHost);
|
||||
}
|
||||
|
||||
var isDocker = string.Equals(
|
||||
app.Environment.EnvironmentName,
|
||||
"Docker",
|
||||
StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
app.Logger.LogInformation(
|
||||
"CORS origins: {Origins}",
|
||||
allowedOrigins.Count > 0 ? string.Join(", ", allowedOrigins) : "(none)");
|
||||
|
||||
app.UseCors(policy =>
|
||||
{
|
||||
if (allowedOrigins.Count > 0)
|
||||
{
|
||||
policy.WithOrigins(allowedOrigins.ToArray())
|
||||
.AllowAnyHeader()
|
||||
.AllowAnyMethod();
|
||||
}
|
||||
else if (app.Environment.IsDevelopment() || isDocker)
|
||||
{
|
||||
// Demo / local: SPA and API are on different host ports.
|
||||
policy.AllowAnyOrigin()
|
||||
.AllowAnyHeader()
|
||||
.AllowAnyMethod();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"profiles": {
|
||||
"MyOffice.Web": {
|
||||
"commandName": "Project",
|
||||
"launchUrl": "swagger",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
},
|
||||
"applicationUrl": "http://localhost:9100",
|
||||
"dotnetRunMessages": true
|
||||
},
|
||||
"MyOffice.SPA": {
|
||||
"commandName": "Project"
|
||||
},
|
||||
"IIS Express": {
|
||||
"commandName": "IISExpress",
|
||||
"launchUrl": "swagger",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
},
|
||||
"IIS": {
|
||||
"commandName": "IIS",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
},
|
||||
"watch": {
|
||||
"commandName": "Executable",
|
||||
"executablePath": "dotnet",
|
||||
"workingDirectory": "$(ProjectDir)",
|
||||
"hotReloadEnabled": true,
|
||||
"hotReloadProfile": "aspnetcore",
|
||||
"commandLineArgs": "watch run",
|
||||
"launchBrowser": false,
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
}
|
||||
},
|
||||
"$schema": "https://json.schemastore.org/launchsettings.json",
|
||||
"iisSettings": {
|
||||
"windowsAuthentication": false,
|
||||
"anonymousAuthentication": true,
|
||||
"iis": {
|
||||
"applicationUrl": "http://localhost:9300"
|
||||
},
|
||||
"iisExpress": {
|
||||
"applicationUrl": "http://localhost:9300",
|
||||
"sslPort": 0
|
||||
},
|
||||
"watch": {
|
||||
"applicationUrl": "http://localhost:9300"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"DatabaseProvider": "npgsql",
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"Kestrel": {
|
||||
"EndPoints": {
|
||||
"Http": {
|
||||
"Url": "http://*:8080"
|
||||
}
|
||||
}
|
||||
},
|
||||
"FrontEnd": {
|
||||
"Host": "http://localhost:32080"
|
||||
},
|
||||
"Cors": {
|
||||
"AllowedOrigins": [
|
||||
"http://localhost:32080",
|
||||
"http://127.0.0.1:32080"
|
||||
]
|
||||
},
|
||||
"OpenIddict": {
|
||||
"SigningKeyPath": "",
|
||||
"EncryptionKeyPath": "",
|
||||
"UseEphemeralKeys": true,
|
||||
"AllowHttp": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"DatabaseProvider": "npgsql",
|
||||
/*"DatabaseProvider": "sqlite",*/
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"Kestrel": {
|
||||
"EndPoints": {
|
||||
"Http": {
|
||||
"Url": "http://*:9100"
|
||||
}
|
||||
/*,
|
||||
"Https": {
|
||||
"Url": "https://*:9101"
|
||||
}*/
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*",
|
||||
"FrontEnd": {
|
||||
"Host": "http://localhost:4300"
|
||||
},
|
||||
"AutoMapper": {
|
||||
"LicenseKey": ""
|
||||
},
|
||||
"OpenIddict": {
|
||||
"SigningKeyPath": "",
|
||||
"EncryptionKeyPath": ""
|
||||
},
|
||||
"Cors": {
|
||||
"AllowedOrigins": [
|
||||
"http://localhost:4300"
|
||||
]
|
||||
},
|
||||
"ExternalProviders": {
|
||||
"Auth0": {
|
||||
"ClientId": "",
|
||||
"Domain": "",
|
||||
"SecretKey": ""
|
||||
},
|
||||
"Google": {
|
||||
"ClientId": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
|
||||
<configuration xmlns:xdt="http://schemas.microsoft.com/XML-Document-Transform">
|
||||
<location path="." inheritInChildApplications="false">
|
||||
<system.webServer>
|
||||
<handlers>
|
||||
<add name="aspNetCore" path="*" verb="*" modules="AspNetCoreModuleV2" resourceType="Unspecified" />
|
||||
</handlers>
|
||||
<aspNetCore processPath="bin\Debug\net6.0\MyOffice.Web.exe" arguments="" stdoutLogEnabled="true"
|
||||
stdoutLogFile=".\logs\stdout" hostingModel="InProcess">
|
||||
<environmentVariables xdt:Transform="InsertIfMissing">
|
||||
<environmentVariable name="ASPNETCORE_ENVIRONMENT" value="" xdt:Locator="Match(name)" xdt:Transform="Remove" />
|
||||
<environmentVariable name="ASPNETCORE_HTTPS_PORT" value="" xdt:Locator="Match(name)" xdt:Transform="Remove" />
|
||||
</environmentVariables>
|
||||
</aspNetCore>
|
||||
</system.webServer>
|
||||
</location>
|
||||
</configuration>
|
||||
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<configuration>
|
||||
<location path="." inheritInChildApplications="false">
|
||||
<system.webServer>
|
||||
<handlers>
|
||||
<add name="aspNetCore" path="*" verb="*" modules="AspNetCoreModuleV2" resourceType="Unspecified" />
|
||||
</handlers>
|
||||
<aspNetCore processPath="bin\Debug\net6.0\MyOffice.Web.exe" arguments="" stdoutLogEnabled="true" stdoutLogFile=".\logs\stdout" hostingModel="InProcess">
|
||||
<environmentVariables>
|
||||
<environmentVariable name="ASPNETCORE_ENVIRONMENT" value="Development" />
|
||||
<environmentVariable name="ASPNETCORE_HTTPS_PORT" value="9301" />
|
||||
</environmentVariables>
|
||||
</aspNetCore>
|
||||
</system.webServer>
|
||||
</location>
|
||||
</configuration>
|
||||
@@ -0,0 +1,23 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>MyOffice</title>
|
||||
<base href="/">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<style>
|
||||
html, body { height: 100% }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: Roboto, Helvetica Neue, sans-serif
|
||||
}
|
||||
</style><link rel="stylesheet" href="styles.08cfaa5c1b59bc73.css" media="print" onload="this.media = 'all'">
|
||||
<noscript>
|
||||
<link rel="stylesheet" href="styles.08cfaa5c1b59bc73.css">
|
||||
</noscript>
|
||||
</head>
|
||||
<body>
|
||||
<h1>DEVELOPER INDEX.HTML</h1>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user