Publish from private repository

This commit is contained in:
Gitea Actions
2026-08-01 11:53:53 +00:00
commit ac7c7bc8ea
694 changed files with 69367 additions and 0 deletions
+4
View File
@@ -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());
}
}
}
+189
View File
@@ -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()
};
}
}
+39
View File
@@ -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; }
}
}
+7
View File
@@ -0,0 +1,7 @@
namespace MyOffice.Web.Models.Auth;
public class LoginModel
{
public string? UserName { get; set; }
public string? Password { get; set; }
}
+18
View File
@@ -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!;
}
+6
View File
@@ -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>();
}
}
+5
View File
@@ -0,0 +1,5 @@
namespace MyOffice.Web.Models;
public interface IRequestModel
{
}
+5
View File
@@ -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!;
}
+29
View File
@@ -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))
;
}
}
+19
View File
@@ -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>();
}
}
+12
View File
@@ -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());
}
}
+54
View File
@@ -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>
+56
View File
@@ -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";
}
+449
View File
@@ -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"
}
}
}
+31
View File
@@ -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
}
}
+47
View File
@@ -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": ""
}
}
}
+18
View File
@@ -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>
+16
View File
@@ -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>
+23
View File
@@ -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>