feat(admin): restore Blazor WebAssembly architecture for admin pages with hybrid Server routing
TaxBaik CI/CD / build-and-deploy (push) Successful in 2m40s

This commit is contained in:
2026-07-01 11:21:10 +09:00
parent 62ce89359a
commit 2a046d0393
30 changed files with 168 additions and 10 deletions
+2
View File
@@ -0,0 +1,2 @@
global using System.Net.Http;
global using System.Net.Http.Json;
+13
View File
@@ -0,0 +1,13 @@
@* WASM 기반(M3) 검증용 컴포넌트. 라우팅/렌더모드 전면 적용은 M4에서 처리한다. *@
@rendermode InteractiveWebAssembly
<MudPaper Class="pa-6 ma-4" Elevation="2">
<MudText Typo="Typo.h5" GutterBottom="true">WebAssembly 렌더 모드 점검</MudText>
<MudText Typo="Typo.body2" Class="mb-4">이 컴포넌트가 클릭에 반응하면 Interactive WebAssembly 기반이 정상 동작하는 것입니다.</MudText>
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="Increment">카운트: @count</MudButton>
</MudPaper>
@code {
private int count;
private void Increment() => count++;
}
+51
View File
@@ -0,0 +1,51 @@
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
using MudBlazor.Services;
using TaxBaik.Application.Services;
using TaxBaik.Web.Services;
using TaxBaik.Web.Services.AdminClients;
var builder = WebAssemblyHostBuilder.CreateDefault(args);
// MudBlazor (WASM 측 인터랙티브 컴포넌트용)
builder.Services.AddMudServices(config =>
{
config.SnackbarConfiguration.HideTransitionDuration = 400;
config.SnackbarConfiguration.ShowTransitionDuration = 300;
config.PopoverOptions.ThrowOnDuplicateProvider = false;
});
// API Base Url 동적 구성 (호스트 기준 /taxbaik/api/)
var apiBaseUrl = builder.HostEnvironment.BaseAddress.TrimEnd('/') + "/taxbaik/api/";
// HTTP Client for API (with automatic token refresh)
builder.Services.AddScoped<ITokenStore, TokenStore>();
builder.Services.AddScoped<TokenRefreshHandler>();
builder.Services.AddHttpClient<IApiClient, ApiClient>(client =>
{
client.BaseAddress = new Uri(apiBaseUrl);
}).AddHttpMessageHandler<TokenRefreshHandler>();
// 각 Browser API Client 등록
builder.Services.AddHttpClient<IAdminDashboardClient, AdminDashboardClient>(client => client.BaseAddress = new Uri(apiBaseUrl)).AddHttpMessageHandler<TokenRefreshHandler>();
builder.Services.AddHttpClient<IInquiryBrowserClient, InquiryBrowserClient>(client => client.BaseAddress = new Uri(apiBaseUrl)).AddHttpMessageHandler<TokenRefreshHandler>();
builder.Services.AddHttpClient<IClientBrowserClient, ClientBrowserClient>(client => client.BaseAddress = new Uri(apiBaseUrl)).AddHttpMessageHandler<TokenRefreshHandler>();
builder.Services.AddHttpClient<ITaxFilingBrowserClient, TaxFilingBrowserClient>(client => client.BaseAddress = new Uri(apiBaseUrl)).AddHttpMessageHandler<TokenRefreshHandler>();
builder.Services.AddHttpClient<IFaqBrowserClient, FaqBrowserClient>(client => client.BaseAddress = new Uri(apiBaseUrl)).AddHttpMessageHandler<TokenRefreshHandler>();
builder.Services.AddHttpClient<IAnnouncementBrowserClient, AnnouncementBrowserClient>(client => client.BaseAddress = new Uri(apiBaseUrl)).AddHttpMessageHandler<TokenRefreshHandler>();
builder.Services.AddHttpClient<ITaxProfileBrowserClient, TaxProfileBrowserClient>(client => client.BaseAddress = new Uri(apiBaseUrl)).AddHttpMessageHandler<TokenRefreshHandler>();
builder.Services.AddHttpClient<ITaxFilingScheduleBrowserClient, TaxFilingScheduleBrowserClient>(client => client.BaseAddress = new Uri(apiBaseUrl)).AddHttpMessageHandler<TokenRefreshHandler>();
builder.Services.AddHttpClient<IConsultingActivityBrowserClient, ConsultingActivityBrowserClient>(client => client.BaseAddress = new Uri(apiBaseUrl)).AddHttpMessageHandler<TokenRefreshHandler>();
builder.Services.AddHttpClient<IContractBrowserClient, ContractBrowserClient>(client => client.BaseAddress = new Uri(apiBaseUrl)).AddHttpMessageHandler<TokenRefreshHandler>();
builder.Services.AddHttpClient<IRevenueTrackingBrowserClient, RevenueTrackingBrowserClient>(client => client.BaseAddress = new Uri(apiBaseUrl)).AddHttpMessageHandler<TokenRefreshHandler>();
builder.Services.AddHttpClient<ICommonCodeBrowserClient, CommonCodeBrowserClient>(client => client.BaseAddress = new Uri(apiBaseUrl)).AddHttpMessageHandler<TokenRefreshHandler>();
// Blazor 인증 (WASM 측 클라이언트)
builder.Services.AddScoped<CustomAuthenticationStateProvider>();
builder.Services.AddScoped<AuthenticationStateProvider>(sp => sp.GetRequiredService<CustomAuthenticationStateProvider>());
builder.Services.AddScoped<ILocalStorageService, LocalStorageService>();
builder.Services.AddCascadingAuthenticationState();
builder.Services.AddAuthorizationCore();
await builder.Build().RunAsync();
@@ -0,0 +1,56 @@
namespace TaxBaik.Web.Services.AdminClients;
using System.Collections.Generic;
using System.Net.Http.Json;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using TaxBaik.Domain.Entities;
using Microsoft.Extensions.Logging;
public interface ICommonCodeBrowserClient
{
Task<List<CommonCode>> GetAllActiveAsync(CancellationToken ct = default);
Task<List<CommonCode>> GetByGroupAsync(string group, CancellationToken ct = default);
}
public class CommonCodeBrowserClient(HttpClient httpClient, ITokenStore tokenStore, ILogger<CommonCodeBrowserClient> logger) : ICommonCodeBrowserClient
{
private const string BaseUrl = "/api/commoncode";
private void EnsureAuthHeader()
{
if (!string.IsNullOrEmpty(tokenStore.AccessToken))
httpClient.DefaultRequestHeaders.Authorization = new("Bearer", tokenStore.AccessToken);
else
httpClient.DefaultRequestHeaders.Authorization = null;
}
public async Task<List<CommonCode>> GetAllActiveAsync(CancellationToken ct = default)
{
try
{
EnsureAuthHeader();
return await httpClient.GetFromJsonAsync<List<CommonCode>>($"{BaseUrl}", ct) ?? [];
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to get all active common codes");
return [];
}
}
public async Task<List<CommonCode>> GetByGroupAsync(string group, CancellationToken ct = default)
{
try
{
EnsureAuthHeader();
return await httpClient.GetFromJsonAsync<List<CommonCode>>($"{BaseUrl}/group/{group}", ct) ?? [];
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to get common codes for group {Group}", group);
return [];
}
}
}
@@ -0,0 +1,122 @@
namespace TaxBaik.Web.Services.AdminClients;
using System.Text.Json;
using TaxBaik.Domain.Entities;
public interface IConsultingActivityBrowserClient
{
Task<List<ConsultingActivity>> GetAllAsync(CancellationToken ct = default);
Task<List<ConsultingActivity>> GetByClientIdAsync(int clientId, CancellationToken ct = default);
Task<List<ConsultingActivity>> GetPendingFollowupsAsync(CancellationToken ct = default);
Task<int> CreateAsync(int clientId, string activityType, DateTime activityDate, string description,
int? consultantId = null, DateTime? nextFollowupDate = null, CancellationToken ct = default);
Task UpdateAsync(int id, string? outcome = null, DateTime? nextFollowupDate = null, CancellationToken ct = default);
Task DeleteAsync(int id, CancellationToken ct = default);
}
public class ConsultingActivityBrowserClient(HttpClient httpClient, ITokenStore tokenStore, ILogger<ConsultingActivityBrowserClient> logger)
: IConsultingActivityBrowserClient
{
private const string BaseUrl = "/api/consultingactivity";
private void EnsureAuthHeader()
{
if (!string.IsNullOrEmpty(tokenStore.AccessToken))
httpClient.DefaultRequestHeaders.Authorization = new("Bearer", tokenStore.AccessToken);
else
httpClient.DefaultRequestHeaders.Authorization = null;
}
public async Task<List<ConsultingActivity>> GetAllAsync(CancellationToken ct = default)
{
try
{
EnsureAuthHeader();
return await httpClient.GetFromJsonAsync<List<ConsultingActivity>>($"{BaseUrl}", ct) ?? [];
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to get consulting activities");
return [];
}
}
public async Task<List<ConsultingActivity>> GetByClientIdAsync(int clientId, CancellationToken ct = default)
{
try
{
EnsureAuthHeader();
return await httpClient.GetFromJsonAsync<List<ConsultingActivity>>($"{BaseUrl}/client/{clientId}", ct) ?? [];
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to get activities for client {ClientId}", clientId);
return [];
}
}
public async Task<List<ConsultingActivity>> GetPendingFollowupsAsync(CancellationToken ct = default)
{
try
{
EnsureAuthHeader();
var response = await httpClient.GetFromJsonAsync<JsonElement>($"{BaseUrl}/pending-followups", ct);
if (response.TryGetProperty("data", out var data))
return System.Text.Json.JsonSerializer.Deserialize<List<ConsultingActivity>>(data.GetRawText()) ?? [];
return [];
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to get pending followups");
return [];
}
}
public async Task<int> CreateAsync(int clientId, string activityType, DateTime activityDate, string description,
int? consultantId = null, DateTime? nextFollowupDate = null, CancellationToken ct = default)
{
try
{
EnsureAuthHeader();
var request = new { clientId, activityType, activityDate, description, consultantId, nextFollowupDate };
var response = await httpClient.PostAsJsonAsync(BaseUrl, request, ct);
response.EnsureSuccessStatusCode();
var result = await response.Content.ReadFromJsonAsync<JsonElement>(cancellationToken: ct);
return result.TryGetProperty("id", out var idProp) ? idProp.GetInt32() : 0;
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to create consulting activity");
return 0;
}
}
public async Task UpdateAsync(int id, string? outcome = null, DateTime? nextFollowupDate = null, CancellationToken ct = default)
{
try
{
EnsureAuthHeader();
var request = new { outcome, nextFollowupDate };
var response = await httpClient.PutAsJsonAsync($"{BaseUrl}/{id}", request, ct);
response.EnsureSuccessStatusCode();
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to update consulting activity {Id}", id);
}
}
public async Task DeleteAsync(int id, CancellationToken ct = default)
{
try
{
EnsureAuthHeader();
var response = await httpClient.DeleteAsync($"{BaseUrl}/{id}", ct);
response.EnsureSuccessStatusCode();
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to delete consulting activity {Id}", id);
}
}
}
@@ -0,0 +1,157 @@
namespace TaxBaik.Web.Services.AdminClients;
using System.Text.Json;
using TaxBaik.Domain.Entities;
public interface IContractBrowserClient
{
Task<List<Contract>> GetAllAsync(CancellationToken ct = default);
Task<Contract?> GetByIdAsync(int id, CancellationToken ct = default);
Task<List<Contract>> GetByClientIdAsync(int clientId, CancellationToken ct = default);
Task<List<Contract>> GetActiveContractsAsync(CancellationToken ct = default);
Task<List<Contract>> GetExpiringContractsAsync(int daysAhead = 30, CancellationToken ct = default);
Task<decimal> GetMonthlyRecurringRevenueAsync(CancellationToken ct = default);
Task<int> CreateAsync(int clientId, string contractNumber, string serviceType, DateTime startDate,
decimal? monthlyFee = null, decimal? totalAmount = null, CancellationToken ct = default);
Task DeleteAsync(int id, CancellationToken ct = default);
}
public class ContractBrowserClient(HttpClient httpClient, ITokenStore tokenStore, ILogger<ContractBrowserClient> logger)
: IContractBrowserClient
{
private const string BaseUrl = "/api/contract";
private void EnsureAuthHeader()
{
if (!string.IsNullOrEmpty(tokenStore.AccessToken))
httpClient.DefaultRequestHeaders.Authorization = new("Bearer", tokenStore.AccessToken);
else
httpClient.DefaultRequestHeaders.Authorization = null;
}
public async Task<List<Contract>> GetAllAsync(CancellationToken ct = default)
{
try
{
EnsureAuthHeader();
return await httpClient.GetFromJsonAsync<List<Contract>>($"{BaseUrl}", ct) ?? [];
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to get contracts");
return [];
}
}
public async Task<Contract?> GetByIdAsync(int id, CancellationToken ct = default)
{
try
{
EnsureAuthHeader();
return await httpClient.GetFromJsonAsync<Contract>($"{BaseUrl}/{id}", ct);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to get contract {Id}", id);
return null;
}
}
public async Task<List<Contract>> GetByClientIdAsync(int clientId, CancellationToken ct = default)
{
try
{
EnsureAuthHeader();
return await httpClient.GetFromJsonAsync<List<Contract>>($"{BaseUrl}/client/{clientId}", ct) ?? [];
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to get contracts for client {ClientId}", clientId);
return [];
}
}
public async Task<List<Contract>> GetActiveContractsAsync(CancellationToken ct = default)
{
try
{
EnsureAuthHeader();
var response = await httpClient.GetFromJsonAsync<JsonElement>($"{BaseUrl}/active", ct);
if (response.TryGetProperty("data", out var data))
return System.Text.Json.JsonSerializer.Deserialize<List<Contract>>(data.GetRawText()) ?? [];
return [];
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to get active contracts");
return [];
}
}
public async Task<List<Contract>> GetExpiringContractsAsync(int daysAhead = 30, CancellationToken ct = default)
{
try
{
EnsureAuthHeader();
var response = await httpClient.GetFromJsonAsync<JsonElement>($"{BaseUrl}/expiring?daysAhead={daysAhead}", ct);
if (response.TryGetProperty("data", out var data))
return System.Text.Json.JsonSerializer.Deserialize<List<Contract>>(data.GetRawText()) ?? [];
return [];
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to get expiring contracts");
return [];
}
}
public async Task<decimal> GetMonthlyRecurringRevenueAsync(CancellationToken ct = default)
{
try
{
EnsureAuthHeader();
var response = await httpClient.GetFromJsonAsync<JsonElement>($"{BaseUrl}/mrr", ct);
if (response.TryGetProperty("mrr", out var mrrValue))
return System.Text.Json.JsonSerializer.Deserialize<decimal>(mrrValue.GetRawText());
return 0;
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to get MRR");
return 0;
}
}
public async Task<int> CreateAsync(int clientId, string contractNumber, string serviceType, DateTime startDate,
decimal? monthlyFee = null, decimal? totalAmount = null, CancellationToken ct = default)
{
try
{
EnsureAuthHeader();
var request = new { clientId, contractNumber, serviceType, startDate, monthlyFee, totalAmount };
var response = await httpClient.PostAsJsonAsync(BaseUrl, request, ct);
response.EnsureSuccessStatusCode();
var result = await response.Content.ReadFromJsonAsync<JsonElement>(cancellationToken: ct);
return result.TryGetProperty("id", out var idProp) ? idProp.GetInt32() : 0;
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to create contract");
return 0;
}
}
public async Task DeleteAsync(int id, CancellationToken ct = default)
{
try
{
EnsureAuthHeader();
var response = await httpClient.DeleteAsync($"{BaseUrl}/{id}", ct);
response.EnsureSuccessStatusCode();
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to delete contract {Id}", id);
}
}
}
@@ -0,0 +1,159 @@
namespace TaxBaik.Web.Services.AdminClients;
using System.Text.Json;
using TaxBaik.Domain.Entities;
public interface IRevenueTrackingBrowserClient
{
Task<List<RevenueTracking>> GetAllAsync(CancellationToken ct = default);
Task<List<RevenueTracking>> GetByClientIdAsync(int clientId, CancellationToken ct = default);
Task<List<RevenueTracking>> GetPendingPaymentsAsync(CancellationToken ct = default);
Task<List<RevenueTracking>> GetMonthlyRevenueAsync(int year, int month, CancellationToken ct = default);
Task<decimal> GetTotalRevenueAsync(DateTime startDate, DateTime endDate, CancellationToken ct = default);
Task<int> CreateAsync(int clientId, string invoiceNumber, DateTime invoiceDate, decimal amount,
string? serviceType = null, DateTime? dueDate = null, CancellationToken ct = default);
Task MarkPaidAsync(int id, DateTime paymentDate, CancellationToken ct = default);
Task DeleteAsync(int id, CancellationToken ct = default);
}
public class RevenueTrackingBrowserClient(HttpClient httpClient, ITokenStore tokenStore, ILogger<RevenueTrackingBrowserClient> logger)
: IRevenueTrackingBrowserClient
{
private const string BaseUrl = "/api/revenuetracking";
private void EnsureAuthHeader()
{
if (!string.IsNullOrEmpty(tokenStore.AccessToken))
httpClient.DefaultRequestHeaders.Authorization = new("Bearer", tokenStore.AccessToken);
else
httpClient.DefaultRequestHeaders.Authorization = null;
}
public async Task<List<RevenueTracking>> GetAllAsync(CancellationToken ct = default)
{
try
{
EnsureAuthHeader();
return await httpClient.GetFromJsonAsync<List<RevenueTracking>>($"{BaseUrl}", ct) ?? [];
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to get revenue tracking");
return [];
}
}
public async Task<List<RevenueTracking>> GetByClientIdAsync(int clientId, CancellationToken ct = default)
{
try
{
EnsureAuthHeader();
return await httpClient.GetFromJsonAsync<List<RevenueTracking>>($"{BaseUrl}/client/{clientId}", ct) ?? [];
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to get revenue for client {ClientId}", clientId);
return [];
}
}
public async Task<List<RevenueTracking>> GetPendingPaymentsAsync(CancellationToken ct = default)
{
try
{
EnsureAuthHeader();
var response = await httpClient.GetFromJsonAsync<JsonElement>($"{BaseUrl}/pending", ct);
if (response.TryGetProperty("data", out var data))
return System.Text.Json.JsonSerializer.Deserialize<List<RevenueTracking>>(data.GetRawText()) ?? [];
return [];
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to get pending payments");
return [];
}
}
public async Task<List<RevenueTracking>> GetMonthlyRevenueAsync(int year, int month, CancellationToken ct = default)
{
try
{
EnsureAuthHeader();
var response = await httpClient.GetFromJsonAsync<JsonElement>($"{BaseUrl}/monthly?year={year}&month={month}", ct);
if (response.TryGetProperty("data", out var data))
return System.Text.Json.JsonSerializer.Deserialize<List<RevenueTracking>>(data.GetRawText()) ?? [];
return [];
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to get monthly revenue {Year}-{Month}", year, month);
return [];
}
}
public async Task<decimal> GetTotalRevenueAsync(DateTime startDate, DateTime endDate, CancellationToken ct = default)
{
try
{
EnsureAuthHeader();
var response = await httpClient.GetFromJsonAsync<JsonElement>(
$"{BaseUrl}/total?startDate={startDate:yyyy-MM-dd}&endDate={endDate:yyyy-MM-dd}", ct);
if (response.TryGetProperty("total", out var totalValue))
return System.Text.Json.JsonSerializer.Deserialize<decimal>(totalValue.GetRawText());
return 0;
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to get total revenue");
return 0;
}
}
public async Task<int> CreateAsync(int clientId, string invoiceNumber, DateTime invoiceDate, decimal amount,
string? serviceType = null, DateTime? dueDate = null, CancellationToken ct = default)
{
try
{
EnsureAuthHeader();
var request = new { clientId, invoiceNumber, invoiceDate, amount, serviceType, dueDate };
var response = await httpClient.PostAsJsonAsync(BaseUrl, request, ct);
response.EnsureSuccessStatusCode();
var result = await response.Content.ReadFromJsonAsync<JsonElement>(cancellationToken: ct);
return result.TryGetProperty("id", out var idProp) ? idProp.GetInt32() : 0;
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to create revenue tracking");
return 0;
}
}
public async Task MarkPaidAsync(int id, DateTime paymentDate, CancellationToken ct = default)
{
try
{
EnsureAuthHeader();
var request = new { paymentDate };
var response = await httpClient.PutAsJsonAsync($"{BaseUrl}/{id}/paid", request, ct);
response.EnsureSuccessStatusCode();
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to mark payment {Id}", id);
}
}
public async Task DeleteAsync(int id, CancellationToken ct = default)
{
try
{
EnsureAuthHeader();
var response = await httpClient.DeleteAsync($"{BaseUrl}/{id}", ct);
response.EnsureSuccessStatusCode();
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to delete revenue tracking {Id}", id);
}
}
}
@@ -0,0 +1,136 @@
namespace TaxBaik.Web.Services.AdminClients;
using System.Text.Json;
using TaxBaik.Domain.Entities;
public interface ITaxFilingScheduleBrowserClient
{
Task<List<TaxFilingSchedule>> GetAllAsync(CancellationToken ct = default);
Task<TaxFilingSchedule?> GetByIdAsync(int id, CancellationToken ct = default);
Task<List<TaxFilingSchedule>> GetByClientIdAsync(int clientId, CancellationToken ct = default);
Task<List<TaxFilingSchedule>> GetUpcomingDuesAsync(int daysAhead = 30, CancellationToken ct = default);
Task<int> CreateAsync(int clientId, string filingType, DateTime dueDate, int filingYear,
int? assignedTo = null, CancellationToken ct = default);
Task MarkCompletedAsync(int id, CancellationToken ct = default);
Task DeleteAsync(int id, CancellationToken ct = default);
}
public class TaxFilingScheduleBrowserClient(HttpClient httpClient, ITokenStore tokenStore, ILogger<TaxFilingScheduleBrowserClient> logger)
: ITaxFilingScheduleBrowserClient
{
private const string BaseUrl = "/api/taxfilingschedule";
private void EnsureAuthHeader()
{
if (!string.IsNullOrEmpty(tokenStore.AccessToken))
httpClient.DefaultRequestHeaders.Authorization = new("Bearer", tokenStore.AccessToken);
else
httpClient.DefaultRequestHeaders.Authorization = null;
}
public async Task<List<TaxFilingSchedule>> GetAllAsync(CancellationToken ct = default)
{
try
{
EnsureAuthHeader();
return await httpClient.GetFromJsonAsync<List<TaxFilingSchedule>>($"{BaseUrl}", ct) ?? [];
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to get tax filing schedules");
return [];
}
}
public async Task<TaxFilingSchedule?> GetByIdAsync(int id, CancellationToken ct = default)
{
try
{
EnsureAuthHeader();
return await httpClient.GetFromJsonAsync<TaxFilingSchedule>($"{BaseUrl}/{id}", ct);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to get tax filing schedule {Id}", id);
return null;
}
}
public async Task<List<TaxFilingSchedule>> GetByClientIdAsync(int clientId, CancellationToken ct = default)
{
try
{
EnsureAuthHeader();
return await httpClient.GetFromJsonAsync<List<TaxFilingSchedule>>($"{BaseUrl}/client/{clientId}", ct) ?? [];
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to get filing schedules for client {ClientId}", clientId);
return [];
}
}
public async Task<List<TaxFilingSchedule>> GetUpcomingDuesAsync(int daysAhead = 30, CancellationToken ct = default)
{
try
{
EnsureAuthHeader();
var response = await httpClient.GetFromJsonAsync<JsonElement>($"{BaseUrl}/upcoming?daysAhead={daysAhead}", ct);
if (response.TryGetProperty("data", out var data))
return System.Text.Json.JsonSerializer.Deserialize<List<TaxFilingSchedule>>(data.GetRawText()) ?? [];
return [];
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to get upcoming filings");
return [];
}
}
public async Task<int> CreateAsync(int clientId, string filingType, DateTime dueDate, int filingYear,
int? assignedTo = null, CancellationToken ct = default)
{
try
{
EnsureAuthHeader();
var request = new { clientId, filingType, dueDate, filingYear, assignedTo };
var response = await httpClient.PostAsJsonAsync(BaseUrl, request, ct);
response.EnsureSuccessStatusCode();
var result = await response.Content.ReadFromJsonAsync<JsonElement>(cancellationToken: ct);
return result.TryGetProperty("id", out var idProp) ? idProp.GetInt32() : 0;
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to create tax filing schedule");
return 0;
}
}
public async Task MarkCompletedAsync(int id, CancellationToken ct = default)
{
try
{
EnsureAuthHeader();
var response = await httpClient.PutAsJsonAsync($"{BaseUrl}/{id}/complete", new { }, ct);
response.EnsureSuccessStatusCode();
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to mark filing as completed {Id}", id);
}
}
public async Task DeleteAsync(int id, CancellationToken ct = default)
{
try
{
EnsureAuthHeader();
var response = await httpClient.DeleteAsync($"{BaseUrl}/{id}", ct);
response.EnsureSuccessStatusCode();
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to delete tax filing schedule {Id}", id);
}
}
}
@@ -0,0 +1,156 @@
namespace TaxBaik.Web.Services.AdminClients;
using System.Text.Json;
using TaxBaik.Domain.Entities;
public interface ITaxProfileBrowserClient
{
Task<List<TaxProfile>> GetAllAsync(CancellationToken ct = default);
Task<TaxProfile?> GetByIdAsync(int id, CancellationToken ct = default);
Task<List<TaxProfile>> GetByClientIdAsync(int clientId, CancellationToken ct = default);
Task<List<TaxProfile>> GetHighRiskProfilesAsync(CancellationToken ct = default);
Task<List<TaxProfile>> GetUpcomingFilingDuesAsync(int daysAhead = 30, CancellationToken ct = default);
Task<int> CreateAsync(int clientId, string businessType, string? businessRegistration = null,
string? accountingMethod = null, DateTime? establishmentDate = null, CancellationToken ct = default);
Task UpdateAsync(int id, string? businessType = null, string? accountingMethod = null,
DateTime? nextFilingDueDate = null, string? taxRiskLevel = null, CancellationToken ct = default);
Task DeleteAsync(int id, CancellationToken ct = default);
}
public class TaxProfileBrowserClient(HttpClient httpClient, ITokenStore tokenStore, ILogger<TaxProfileBrowserClient> logger) : ITaxProfileBrowserClient
{
private const string BaseUrl = "/api/taxprofile";
private void EnsureAuthHeader()
{
if (!string.IsNullOrEmpty(tokenStore.AccessToken))
httpClient.DefaultRequestHeaders.Authorization = new("Bearer", tokenStore.AccessToken);
else
httpClient.DefaultRequestHeaders.Authorization = null;
}
public async Task<List<TaxProfile>> GetAllAsync(CancellationToken ct = default)
{
try
{
EnsureAuthHeader();
return await httpClient.GetFromJsonAsync<List<TaxProfile>>($"{BaseUrl}", ct) ?? [];
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to get tax profiles");
return [];
}
}
public async Task<TaxProfile?> GetByIdAsync(int id, CancellationToken ct = default)
{
try
{
EnsureAuthHeader();
return await httpClient.GetFromJsonAsync<TaxProfile>($"{BaseUrl}/{id}", ct);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to get tax profile {Id}", id);
return null;
}
}
public async Task<List<TaxProfile>> GetByClientIdAsync(int clientId, CancellationToken ct = default)
{
try
{
EnsureAuthHeader();
return await httpClient.GetFromJsonAsync<List<TaxProfile>>($"{BaseUrl}/client/{clientId}", ct) ?? [];
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to get tax profiles for client {ClientId}", clientId);
return [];
}
}
public async Task<List<TaxProfile>> GetHighRiskProfilesAsync(CancellationToken ct = default)
{
try
{
EnsureAuthHeader();
var response = await httpClient.GetFromJsonAsync<JsonElement>($"{BaseUrl}/high-risk", ct);
if (response.TryGetProperty("data", out var data))
return System.Text.Json.JsonSerializer.Deserialize<List<TaxProfile>>(data.GetRawText()) ?? [];
return [];
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to get high-risk profiles");
return [];
}
}
public async Task<List<TaxProfile>> GetUpcomingFilingDuesAsync(int daysAhead = 30, CancellationToken ct = default)
{
try
{
EnsureAuthHeader();
var response = await httpClient.GetFromJsonAsync<JsonElement>($"{BaseUrl}/upcoming-filings?daysAhead={daysAhead}", ct);
if (response.TryGetProperty("data", out var data))
return System.Text.Json.JsonSerializer.Deserialize<List<TaxProfile>>(data.GetRawText()) ?? [];
return [];
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to get upcoming filings");
return [];
}
}
public async Task<int> CreateAsync(int clientId, string businessType, string? businessRegistration = null,
string? accountingMethod = null, DateTime? establishmentDate = null, CancellationToken ct = default)
{
try
{
EnsureAuthHeader();
var request = new { clientId, businessType, businessRegistration, accountingMethod, establishmentDate };
var response = await httpClient.PostAsJsonAsync(BaseUrl, request, ct);
response.EnsureSuccessStatusCode();
var result = await response.Content.ReadFromJsonAsync<JsonElement>(cancellationToken: ct);
return result.TryGetProperty("id", out var idProp) ? idProp.GetInt32() : 0;
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to create tax profile");
return 0;
}
}
public async Task UpdateAsync(int id, string? businessType = null, string? accountingMethod = null,
DateTime? nextFilingDueDate = null, string? taxRiskLevel = null, CancellationToken ct = default)
{
try
{
EnsureAuthHeader();
var request = new { businessType, accountingMethod, nextFilingDueDate, taxRiskLevel };
var response = await httpClient.PutAsJsonAsync($"{BaseUrl}/{id}", request, ct);
response.EnsureSuccessStatusCode();
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to update tax profile {Id}", id);
}
}
public async Task DeleteAsync(int id, CancellationToken ct = default)
{
try
{
EnsureAuthHeader();
var response = await httpClient.DeleteAsync($"{BaseUrl}/{id}", ct);
response.EnsureSuccessStatusCode();
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to delete tax profile {Id}", id);
}
}
}
@@ -0,0 +1,119 @@
using System.Net.Http;
using System.Net.Http.Json;
using TaxBaik.Application.Services;
using TaxBaik.Domain.Entities;
namespace TaxBaik.Web.Services;
/// <summary>
/// Admin Dashboard API Client
/// SOLID: Single Responsibility - Dashboard API 호출만 담당
/// Dependency Inversion - 추상화된 인터페이스 사용
/// </summary>
public interface IAdminDashboardClient
{
Task<AdminDashboardSummary> GetSummaryAsync(CancellationToken ct = default);
Task<IEnumerable<TaxFiling>> GetUpcomingFilingsAsync(int days = 30, CancellationToken ct = default);
Task<IEnumerable<Inquiry>> GetRecentInquiriesAsync(int limit = 10, CancellationToken ct = default);
Task<object> GetMonthlyStatsAsync(string? month = null, CancellationToken ct = default);
}
public class AdminDashboardClient : IAdminDashboardClient
{
private readonly HttpClient _http;
private readonly ILogger<AdminDashboardClient> _logger;
private readonly ITokenStore _tokenStore;
public AdminDashboardClient(HttpClient http, ILogger<AdminDashboardClient> logger, ITokenStore tokenStore)
{
_http = http;
_logger = logger;
_tokenStore = tokenStore;
}
private void EnsureAuthHeader()
{
if (!string.IsNullOrEmpty(_tokenStore.AccessToken))
_http.DefaultRequestHeaders.Authorization = new("Bearer", _tokenStore.AccessToken);
else
_http.DefaultRequestHeaders.Authorization = null;
}
public async Task<AdminDashboardSummary> GetSummaryAsync(CancellationToken ct = default)
{
try
{
EnsureAuthHeader();
var result = await _http.GetFromJsonAsync<AdminDashboardSummary>(
"admin-dashboard/summary", cancellationToken: ct);
return result ?? new(0, 0, 0, 0, []);
}
catch (HttpRequestException ex)
{
_logger.LogError(ex, "Failed to fetch dashboard summary");
throw;
}
}
public async Task<IEnumerable<TaxFiling>> GetUpcomingFilingsAsync(int days = 30, CancellationToken ct = default)
{
try
{
EnsureAuthHeader();
var result = await _http.GetFromJsonAsync<ApiResponse<TaxFiling>>(
$"admin-dashboard/upcoming-filings?days={days}", cancellationToken: ct);
return result?.Data ?? [];
}
catch (HttpRequestException ex)
{
_logger.LogError(ex, "Failed to fetch upcoming filings");
throw;
}
}
public async Task<IEnumerable<Inquiry>> GetRecentInquiriesAsync(int limit = 10, CancellationToken ct = default)
{
try
{
EnsureAuthHeader();
var result = await _http.GetFromJsonAsync<ApiResponse<Inquiry>>(
$"admin-dashboard/recent-inquiries?limit={limit}", cancellationToken: ct);
return result?.Data ?? [];
}
catch (HttpRequestException ex)
{
_logger.LogError(ex, "Failed to fetch recent inquiries");
throw;
}
}
public async Task<object> GetMonthlyStatsAsync(string? month = null, CancellationToken ct = default)
{
try
{
EnsureAuthHeader();
var url = "admin-dashboard/monthly-stats";
if (!string.IsNullOrEmpty(month))
url += $"?month={month}";
var result = await _http.GetFromJsonAsync<object>(url, cancellationToken: ct);
return result ?? new();
}
catch (HttpRequestException ex)
{
_logger.LogError(ex, "Failed to fetch monthly stats");
throw;
}
}
}
/// <summary>
/// API Response wrapper
/// </summary>
internal class ApiResponse<T>
{
public IEnumerable<T>? Data { get; set; }
public int Total { get; set; }
public int Page { get; set; }
public int PageSize { get; set; }
}
@@ -0,0 +1,126 @@
namespace TaxBaik.Web.Services;
using System.Net.Http;
using System.Net.Http.Json;
using TaxBaik.Application.DTOs;
using TaxBaik.Domain.Entities;
public interface IAnnouncementBrowserClient
{
Task<IEnumerable<Announcement>> GetAllAsync(CancellationToken ct = default);
Task<Announcement?> GetByIdAsync(int id, CancellationToken ct = default);
Task<Announcement?> CreateAsync(AnnouncementDto dto, CancellationToken ct = default);
Task<Announcement?> UpdateAsync(int id, AnnouncementDto dto, CancellationToken ct = default);
Task<bool> DeleteAsync(int id, CancellationToken ct = default);
}
public class AnnouncementBrowserClient : IAnnouncementBrowserClient
{
private readonly HttpClient _http;
private readonly ILogger<AnnouncementBrowserClient> _logger;
private readonly ITokenStore _tokenStore;
public AnnouncementBrowserClient(HttpClient http, ILogger<AnnouncementBrowserClient> logger, ITokenStore tokenStore)
{
_http = http;
_logger = logger;
_tokenStore = tokenStore;
}
private void EnsureAuthHeader()
{
if (!string.IsNullOrEmpty(_tokenStore.AccessToken))
_http.DefaultRequestHeaders.Authorization = new("Bearer", _tokenStore.AccessToken);
else
_http.DefaultRequestHeaders.Authorization = null;
}
public async Task<IEnumerable<Announcement>> GetAllAsync(CancellationToken ct = default)
{
try
{
EnsureAuthHeader();
var result = await _http.GetFromJsonAsync<AnnouncementListResponse>("announcement", cancellationToken: ct);
return result?.Data ?? [];
}
catch (HttpRequestException ex)
{
_logger.LogError(ex, "Failed to fetch announcements");
throw;
}
}
public async Task<Announcement?> GetByIdAsync(int id, CancellationToken ct = default)
{
try
{
EnsureAuthHeader();
return await _http.GetFromJsonAsync<Announcement>($"announcement/{id}", cancellationToken: ct);
}
catch (HttpRequestException ex)
{
_logger.LogError(ex, "Failed to fetch announcement {AnnouncementId}", id);
throw;
}
}
public async Task<Announcement?> CreateAsync(AnnouncementDto dto, CancellationToken ct = default)
{
try
{
EnsureAuthHeader();
var response = await _http.PostAsJsonAsync("announcement", dto, cancellationToken: ct);
if (!response.IsSuccessStatusCode) return null;
var content = await response.Content.ReadAsStringAsync(ct);
return System.Text.Json.JsonSerializer.Deserialize<Announcement>(
content,
new System.Text.Json.JsonSerializerOptions { PropertyNameCaseInsensitive = true });
}
catch (HttpRequestException ex)
{
_logger.LogError(ex, "Failed to create announcement");
throw;
}
}
public async Task<Announcement?> UpdateAsync(int id, AnnouncementDto dto, CancellationToken ct = default)
{
try
{
EnsureAuthHeader();
var response = await _http.PutAsJsonAsync($"announcement/{id}", dto, cancellationToken: ct);
if (!response.IsSuccessStatusCode) return null;
var content = await response.Content.ReadAsStringAsync(ct);
return System.Text.Json.JsonSerializer.Deserialize<Announcement>(
content,
new System.Text.Json.JsonSerializerOptions { PropertyNameCaseInsensitive = true });
}
catch (HttpRequestException ex)
{
_logger.LogError(ex, "Failed to update announcement {AnnouncementId}", id);
throw;
}
}
public async Task<bool> DeleteAsync(int id, CancellationToken ct = default)
{
try
{
EnsureAuthHeader();
var response = await _http.DeleteAsync($"announcement/{id}", cancellationToken: ct);
return response.IsSuccessStatusCode;
}
catch (HttpRequestException ex)
{
_logger.LogError(ex, "Failed to delete announcement {AnnouncementId}", id);
throw;
}
}
private class AnnouncementListResponse
{
public List<Announcement> Data { get; set; } = [];
}
}
+110
View File
@@ -0,0 +1,110 @@
namespace TaxBaik.Web.Services;
using Microsoft.AspNetCore.Components;
using System.Text.Json;
public interface IApiClient
{
Task<T?> GetAsync<T>(string endpoint);
Task<T?> PostAsync<T>(string endpoint, object data);
Task<T?> PutAsync<T>(string endpoint, object data);
Task DeleteAsync(string endpoint);
Task SetAuthToken(string? token);
}
public class ApiClient : IApiClient
{
private readonly HttpClient _httpClient;
private readonly NavigationManager _navigationManager;
private string? _authToken;
public ApiClient(HttpClient httpClient, NavigationManager navigationManager)
{
_httpClient = httpClient;
_navigationManager = navigationManager;
}
public async Task SetAuthToken(string? token)
{
_authToken = token;
if (token != null)
_httpClient.DefaultRequestHeaders.Authorization = new("Bearer", token);
else
_httpClient.DefaultRequestHeaders.Authorization = null;
}
public async Task<T?> GetAsync<T>(string endpoint)
{
try
{
var response = await _httpClient.GetAsync(BuildApiUri(endpoint));
if (!response.IsSuccessStatusCode)
return default;
var content = await response.Content.ReadAsStringAsync();
return JsonSerializer.Deserialize<T>(content, new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
}
catch
{
return default;
}
}
public async Task<T?> PostAsync<T>(string endpoint, object data)
{
try
{
var json = JsonSerializer.Serialize(data);
var content = new StringContent(json, System.Text.Encoding.UTF8, "application/json");
var response = await _httpClient.PostAsync(BuildApiUri(endpoint), content);
if (!response.IsSuccessStatusCode)
return default;
var responseContent = await response.Content.ReadAsStringAsync();
return JsonSerializer.Deserialize<T>(responseContent, new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
}
catch
{
return default;
}
}
public async Task<T?> PutAsync<T>(string endpoint, object data)
{
try
{
var json = JsonSerializer.Serialize(data);
var content = new StringContent(json, System.Text.Encoding.UTF8, "application/json");
var response = await _httpClient.PutAsync(BuildApiUri(endpoint), content);
if (!response.IsSuccessStatusCode)
return default;
var responseContent = await response.Content.ReadAsStringAsync();
return JsonSerializer.Deserialize<T>(responseContent, new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
}
catch
{
return default;
}
}
public async Task DeleteAsync(string endpoint)
{
try
{
await _httpClient.DeleteAsync(BuildApiUri(endpoint));
}
catch
{
// Ignore
}
}
private Uri BuildApiUri(string endpoint)
{
var relative = $"api/{endpoint.TrimStart('/')}";
return new Uri(new Uri(_navigationManager.BaseUri), relative);
}
}
@@ -0,0 +1,141 @@
namespace TaxBaik.Web.Services;
using System.Net.Http;
using System.Net.Http.Json;
using TaxBaik.Application.DTOs;
using TaxBaik.Domain.Entities;
/// <summary>
/// Client API Client for Admin Blazor
/// SOLID: Single Responsibility - Client API calls only
/// </summary>
public interface IClientBrowserClient
{
Task<(IEnumerable<Client> Items, int Total)> GetPagedAsync(
int page = 1, int pageSize = 20, string? status = null, string? search = null, CancellationToken ct = default);
Task<Client?> GetByIdAsync(int id, CancellationToken ct = default);
Task<Client?> CreateAsync(CreateClientDto dto, CancellationToken ct = default);
Task<Client?> UpdateAsync(int id, CreateClientDto dto, CancellationToken ct = default);
Task<bool> DeleteAsync(int id, CancellationToken ct = default);
}
public class ClientBrowserClient : IClientBrowserClient
{
private readonly HttpClient _http;
private readonly ILogger<ClientBrowserClient> _logger;
private readonly ITokenStore _tokenStore;
public ClientBrowserClient(HttpClient http, ILogger<ClientBrowserClient> logger, ITokenStore tokenStore)
{
_http = http;
_logger = logger;
_tokenStore = tokenStore;
}
private void EnsureAuthHeader()
{
if (!string.IsNullOrEmpty(_tokenStore.AccessToken))
_http.DefaultRequestHeaders.Authorization = new("Bearer", _tokenStore.AccessToken);
else
_http.DefaultRequestHeaders.Authorization = null;
}
public async Task<(IEnumerable<Client> Items, int Total)> GetPagedAsync(
int page = 1, int pageSize = 20, string? status = null, string? search = null, CancellationToken ct = default)
{
try
{
EnsureAuthHeader();
var query = $"client?page={page}&pageSize={pageSize}";
if (!string.IsNullOrEmpty(status))
query += $"&status={status}";
if (!string.IsNullOrEmpty(search))
query += $"&search={Uri.EscapeDataString(search)}";
var result = await _http.GetFromJsonAsync<ClientPagedResponse>(query, cancellationToken: ct);
return result != null ? (result.Data, result.Total) : ([], 0);
}
catch (HttpRequestException ex)
{
_logger.LogError(ex, "Failed to fetch clients");
throw;
}
}
public async Task<Client?> GetByIdAsync(int id, CancellationToken ct = default)
{
try
{
EnsureAuthHeader();
return await _http.GetFromJsonAsync<Client>($"client/{id}", cancellationToken: ct);
}
catch (HttpRequestException ex)
{
_logger.LogError(ex, "Failed to fetch client {ClientId}", id);
throw;
}
}
public async Task<Client?> CreateAsync(CreateClientDto dto, CancellationToken ct = default)
{
try
{
EnsureAuthHeader();
var response = await _http.PostAsJsonAsync("client", dto, cancellationToken: ct);
if (!response.IsSuccessStatusCode)
return null;
var content = await response.Content.ReadAsStringAsync(ct);
return System.Text.Json.JsonSerializer.Deserialize<Client>(
content,
new System.Text.Json.JsonSerializerOptions { PropertyNameCaseInsensitive = true });
}
catch (HttpRequestException ex)
{
_logger.LogError(ex, "Failed to create client");
throw;
}
}
public async Task<Client?> UpdateAsync(int id, CreateClientDto dto, CancellationToken ct = default)
{
try
{
EnsureAuthHeader();
var response = await _http.PutAsJsonAsync($"client/{id}", dto, cancellationToken: ct);
if (!response.IsSuccessStatusCode)
return null;
var content = await response.Content.ReadAsStringAsync(ct);
return System.Text.Json.JsonSerializer.Deserialize<Client>(
content,
new System.Text.Json.JsonSerializerOptions { PropertyNameCaseInsensitive = true });
}
catch (HttpRequestException ex)
{
_logger.LogError(ex, "Failed to update client {ClientId}", id);
throw;
}
}
public async Task<bool> DeleteAsync(int id, CancellationToken ct = default)
{
try
{
EnsureAuthHeader();
var response = await _http.DeleteAsync($"client/{id}", cancellationToken: ct);
return response.IsSuccessStatusCode;
}
catch (HttpRequestException ex)
{
_logger.LogError(ex, "Failed to delete client {ClientId}", id);
throw;
}
}
private class ClientPagedResponse
{
public List<Client> Data { get; set; } = [];
public int Total { get; set; }
}
}
@@ -0,0 +1,192 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using Microsoft.AspNetCore.Components.Authorization;
using TaxBaik.Application.Services;
namespace TaxBaik.Web.Services;
public class CustomAuthenticationStateProvider : AuthenticationStateProvider
{
private readonly ILocalStorageService _localStorage;
private readonly ITokenStore _tokenStore;
private readonly IApiClient _apiClient;
private readonly ILogger<CustomAuthenticationStateProvider> _logger;
public CustomAuthenticationStateProvider(
ILocalStorageService localStorage,
ITokenStore tokenStore,
IApiClient apiClient,
ILogger<CustomAuthenticationStateProvider> logger)
{
_localStorage = localStorage;
_tokenStore = tokenStore;
_apiClient = apiClient;
_logger = logger;
}
public override async Task<AuthenticationState> GetAuthenticationStateAsync()
{
try
{
var accessToken = _tokenStore.AccessToken;
// TokenStore가 비어있으면 localStorage에서 복원 (페이지 리로드 후)
if (string.IsNullOrEmpty(accessToken))
{
var storedToken = await _localStorage.GetItemAsStringAsync("accessToken");
if (!string.IsNullOrEmpty(storedToken))
{
var refreshToken = await _localStorage.GetItemAsStringAsync("refreshToken");
var ticksStr = await _localStorage.GetItemAsStringAsync("tokenExpiry");
if (long.TryParse(ticksStr, out var ticks))
{
_tokenStore.AccessToken = storedToken;
_tokenStore.RefreshToken = refreshToken;
_tokenStore.TokenExpiryTicks = ticks;
accessToken = storedToken;
}
}
}
if (string.IsNullOrEmpty(_tokenStore.AccessToken))
{
return new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity()));
}
// 토큰이 만료되면 로그아웃
if (_tokenStore.IsAccessTokenExpired())
{
_logger.LogWarning("Access token 만료됨 - 자동 로그아웃");
await LogoutAsync();
return new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity()));
}
// 토큰이 5분 이내로 만료되면 자동 갱신 시도 (사용자 경험 향상)
if (!string.IsNullOrEmpty(_tokenStore.RefreshToken) && ShouldRefreshToken())
{
_logger.LogInformation("토큰 만료 5분 전 - 자동 갱신 시작");
var request = new { RefreshToken = _tokenStore.RefreshToken };
var newTokenPair = await _apiClient.PostAsync<WasmAuthTokenPair>("auth/refresh", request);
if (newTokenPair != null && !string.IsNullOrEmpty(newTokenPair.AccessToken))
{
await LoginAsync(newTokenPair.AccessToken, newTokenPair.RefreshToken, newTokenPair.ExpiresIn);
_logger.LogInformation("토큰 자동 갱신 성공");
accessToken = newTokenPair.AccessToken;
}
else
{
_logger.LogWarning("토큰 자동 갱신 실패 - 로그아웃");
await LogoutAsync();
return new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity()));
}
}
var principal = ValidateTokenWithoutDb(accessToken ?? string.Empty);
if (principal == null)
{
await LogoutAsync();
return new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity()));
}
return new AuthenticationState(principal);
}
catch (Exception ex)
{
_logger.LogError(ex, "인증 상태 조회 중 오류 발생");
return new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity()));
}
}
private ClaimsPrincipal? ValidateTokenWithoutDb(string token)
{
try
{
var handler = new JwtSecurityTokenHandler();
var jwtToken = handler.ReadJwtToken(token);
var identity = new ClaimsIdentity(jwtToken.Claims, "jwt");
return new ClaimsPrincipal(identity);
}
catch
{
return null;
}
}
public async Task LoginAsync(string accessToken, string refreshToken, int expiresIn)
{
var tokenExpiryTicks = DateTime.UtcNow.AddSeconds(expiresIn).Ticks;
// TokenStore에 저장 (DelegatingHandler에서 사용)
_tokenStore.AccessToken = accessToken;
_tokenStore.RefreshToken = refreshToken;
_tokenStore.TokenExpiryTicks = tokenExpiryTicks;
// localStorage에도 저장 (페이지 리로드 후 복원)
await _localStorage.SetItemAsStringAsync("accessToken", accessToken);
await _localStorage.SetItemAsStringAsync("refreshToken", refreshToken);
await _localStorage.SetItemAsStringAsync("tokenExpiry", tokenExpiryTicks.ToString());
NotifyAuthenticationStateChanged(GetAuthenticationStateAsync());
}
private bool ShouldRefreshToken()
{
// 토큰이 5분 이내로 만료되면 갱신 (300초 = 5분)
if (!_tokenStore.TokenExpiryTicks.HasValue || _tokenStore.TokenExpiryTicks.Value <= 0)
return false;
const int refreshThresholdSeconds = 300;
try
{
var expiryTime = new DateTime(_tokenStore.TokenExpiryTicks.Value, DateTimeKind.Utc);
var timeUntilExpiry = expiryTime - DateTime.UtcNow;
return timeUntilExpiry.TotalSeconds <= refreshThresholdSeconds && timeUntilExpiry.TotalSeconds > 0;
}
catch
{
return false;
}
}
public async Task LogoutAsync()
{
// TokenStore 초기화
_tokenStore.Clear();
// localStorage 초기화
await _localStorage.RemoveItemAsync("accessToken");
await _localStorage.RemoveItemAsync("refreshToken");
await _localStorage.RemoveItemAsync("tokenExpiry");
NotifyAuthenticationStateChanged(GetAuthenticationStateAsync());
}
private bool IsTokenExpired(string token)
{
try
{
var handler = new JwtSecurityTokenHandler();
var jwtToken = handler.ReadJwtToken(token);
return jwtToken.ValidTo < DateTime.UtcNow;
}
catch
{
return true;
}
}
}
public class WasmAuthTokenPair
{
public WasmAuthTokenPair() { }
public WasmAuthTokenPair(string accessToken, string refreshToken, int expiresIn)
{
AccessToken = accessToken;
RefreshToken = refreshToken;
ExpiresIn = expiresIn;
}
public string AccessToken { get; set; } = "";
public string RefreshToken { get; set; } = "";
public int ExpiresIn { get; set; }
}
@@ -0,0 +1,125 @@
namespace TaxBaik.Web.Services;
using System.Net.Http;
using System.Net.Http.Json;
using TaxBaik.Domain.Entities;
public interface IFaqBrowserClient
{
Task<IEnumerable<Faq>> GetAllAsync(CancellationToken ct = default);
Task<Faq?> GetByIdAsync(int id, CancellationToken ct = default);
Task<Faq?> CreateAsync(Faq faq, CancellationToken ct = default);
Task<Faq?> UpdateAsync(int id, Faq faq, CancellationToken ct = default);
Task<bool> DeleteAsync(int id, CancellationToken ct = default);
}
public class FaqBrowserClient : IFaqBrowserClient
{
private readonly HttpClient _http;
private readonly ILogger<FaqBrowserClient> _logger;
private readonly ITokenStore _tokenStore;
public FaqBrowserClient(HttpClient http, ILogger<FaqBrowserClient> logger, ITokenStore tokenStore)
{
_http = http;
_logger = logger;
_tokenStore = tokenStore;
}
private void EnsureAuthHeader()
{
if (!string.IsNullOrEmpty(_tokenStore.AccessToken))
_http.DefaultRequestHeaders.Authorization = new("Bearer", _tokenStore.AccessToken);
else
_http.DefaultRequestHeaders.Authorization = null;
}
public async Task<IEnumerable<Faq>> GetAllAsync(CancellationToken ct = default)
{
try
{
EnsureAuthHeader();
var result = await _http.GetFromJsonAsync<FaqListResponse>("faq", cancellationToken: ct);
return result?.Data ?? [];
}
catch (HttpRequestException ex)
{
_logger.LogError(ex, "Failed to fetch FAQs");
throw;
}
}
public async Task<Faq?> GetByIdAsync(int id, CancellationToken ct = default)
{
try
{
EnsureAuthHeader();
return await _http.GetFromJsonAsync<Faq>($"faq/{id}", cancellationToken: ct);
}
catch (HttpRequestException ex)
{
_logger.LogError(ex, "Failed to fetch FAQ {FaqId}", id);
throw;
}
}
public async Task<Faq?> CreateAsync(Faq faq, CancellationToken ct = default)
{
try
{
EnsureAuthHeader();
var response = await _http.PostAsJsonAsync("faq", faq, cancellationToken: ct);
if (!response.IsSuccessStatusCode) return null;
var content = await response.Content.ReadAsStringAsync(ct);
return System.Text.Json.JsonSerializer.Deserialize<Faq>(
content,
new System.Text.Json.JsonSerializerOptions { PropertyNameCaseInsensitive = true });
}
catch (HttpRequestException ex)
{
_logger.LogError(ex, "Failed to create FAQ");
throw;
}
}
public async Task<Faq?> UpdateAsync(int id, Faq faq, CancellationToken ct = default)
{
try
{
EnsureAuthHeader();
var response = await _http.PutAsJsonAsync($"faq/{id}", faq, cancellationToken: ct);
if (!response.IsSuccessStatusCode) return null;
var content = await response.Content.ReadAsStringAsync(ct);
return System.Text.Json.JsonSerializer.Deserialize<Faq>(
content,
new System.Text.Json.JsonSerializerOptions { PropertyNameCaseInsensitive = true });
}
catch (HttpRequestException ex)
{
_logger.LogError(ex, "Failed to update FAQ {FaqId}", id);
throw;
}
}
public async Task<bool> DeleteAsync(int id, CancellationToken ct = default)
{
try
{
EnsureAuthHeader();
var response = await _http.DeleteAsync($"faq/{id}", cancellationToken: ct);
return response.IsSuccessStatusCode;
}
catch (HttpRequestException ex)
{
_logger.LogError(ex, "Failed to delete FAQ {FaqId}", id);
throw;
}
}
private class FaqListResponse
{
public List<Faq> Data { get; set; } = [];
}
}
@@ -0,0 +1,8 @@
namespace TaxBaik.Web.Services;
public interface ILocalStorageService
{
Task<string?> GetItemAsStringAsync(string key);
Task SetItemAsStringAsync(string key, string value);
Task RemoveItemAsync(string key);
}
@@ -0,0 +1,39 @@
namespace TaxBaik.Web.Services;
/// <summary>
/// Scoped in-memory token store for Blazor Server.
/// SOLID: Single Responsibility - Token lifecycle management
/// Avoids JS interop from DelegatingHandler (which runs on non-circuit thread)
/// </summary>
public interface ITokenStore
{
string? AccessToken { get; set; }
string? RefreshToken { get; set; }
long? TokenExpiryTicks { get; set; }
bool IsAccessTokenExpired();
void Clear();
}
public class TokenStore : ITokenStore
{
public string? AccessToken { get; set; }
public string? RefreshToken { get; set; }
public long? TokenExpiryTicks { get; set; }
public bool IsAccessTokenExpired()
{
if (TokenExpiryTicks == null)
return true;
var expiryTime = new DateTime(TokenExpiryTicks.Value, DateTimeKind.Utc);
return expiryTime <= DateTime.UtcNow;
}
public void Clear()
{
AccessToken = null;
RefreshToken = null;
TokenExpiryTicks = null;
}
}
@@ -0,0 +1,158 @@
namespace TaxBaik.Web.Services;
using System.Net.Http;
using System.Net.Http.Json;
using TaxBaik.Domain.Entities;
/// <summary>
/// Inquiry API Client for Admin Blazor
/// SOLID: Single Responsibility - Inquiry API calls only
/// Dependency Inversion - abstraction via interface
/// </summary>
public interface IInquiryBrowserClient
{
Task<(IEnumerable<Inquiry> Items, int Total)> GetPagedAsync(int page = 1, int pageSize = 20, CancellationToken ct = default);
Task<Inquiry?> GetByIdAsync(int id, CancellationToken ct = default);
Task<bool> UpdateStatusAsync(int id, string status, CancellationToken ct = default);
Task<bool> UpdateAdminMemoAsync(int id, string adminMemo, CancellationToken ct = default);
Task<int> ConvertToClientAsync(int id, string name, string phone, string serviceType, CancellationToken ct = default);
}
public class InquiryBrowserClient : IInquiryBrowserClient
{
private readonly HttpClient _http;
private readonly ILogger<InquiryBrowserClient> _logger;
private readonly ITokenStore _tokenStore;
public InquiryBrowserClient(HttpClient http, ILogger<InquiryBrowserClient> logger, ITokenStore tokenStore)
{
_http = http;
_logger = logger;
_tokenStore = tokenStore;
}
private void EnsureAuthHeader()
{
if (!string.IsNullOrEmpty(_tokenStore.AccessToken))
_http.DefaultRequestHeaders.Authorization = new("Bearer", _tokenStore.AccessToken);
else
_http.DefaultRequestHeaders.Authorization = null;
}
public async Task<(IEnumerable<Inquiry> Items, int Total)> GetPagedAsync(
int page = 1, int pageSize = 20, CancellationToken ct = default)
{
try
{
EnsureAuthHeader();
var result = await _http.GetFromJsonAsync<InquiryPagedResponse>(
$"inquiry?page={page}&pageSize={pageSize}",
cancellationToken: ct);
return result != null
? (result.Data, result.Total)
: ([], 0);
}
catch (HttpRequestException ex)
{
_logger.LogError(ex, "Failed to fetch inquiries");
throw;
}
}
public async Task<Inquiry?> GetByIdAsync(int id, CancellationToken ct = default)
{
try
{
EnsureAuthHeader();
return await _http.GetFromJsonAsync<Inquiry>(
$"inquiry/{id}",
cancellationToken: ct);
}
catch (HttpRequestException ex)
{
_logger.LogError(ex, "Failed to fetch inquiry {InquiryId}", id);
throw;
}
}
public async Task<bool> UpdateStatusAsync(int id, string status, CancellationToken ct = default)
{
try
{
EnsureAuthHeader();
var request = new { status };
var response = await _http.PutAsJsonAsync(
$"inquiry/{id}/status",
request,
cancellationToken: ct);
return response.IsSuccessStatusCode;
}
catch (HttpRequestException ex)
{
_logger.LogError(ex, "Failed to update inquiry {InquiryId} status", id);
throw;
}
}
public async Task<bool> UpdateAdminMemoAsync(int id, string adminMemo, CancellationToken ct = default)
{
try
{
EnsureAuthHeader();
var request = new { adminMemo };
var response = await _http.PutAsJsonAsync(
$"inquiry/{id}/memo",
request,
cancellationToken: ct);
return response.IsSuccessStatusCode;
}
catch (HttpRequestException ex)
{
_logger.LogError(ex, "Failed to update inquiry {InquiryId} memo", id);
throw;
}
}
public async Task<int> ConvertToClientAsync(int id, string name, string phone, string serviceType, CancellationToken ct = default)
{
try
{
EnsureAuthHeader();
var response = await _http.PostAsJsonAsync(
$"inquiry/{id}/convert-to-client",
new { name, phone, serviceType },
cancellationToken: ct);
if (!response.IsSuccessStatusCode)
return 0;
var content = await response.Content.ReadAsStringAsync(ct);
var result = System.Text.Json.JsonSerializer.Deserialize<ConvertToClientResponse>(
content,
new System.Text.Json.JsonSerializerOptions { PropertyNameCaseInsensitive = true });
return result?.ClientId ?? 0;
}
catch (HttpRequestException ex)
{
_logger.LogError(ex, "Failed to convert inquiry {InquiryId} to client", id);
throw;
}
}
private class InquiryPagedResponse
{
public List<Inquiry> Data { get; set; } = [];
public int Total { get; set; }
public int Page { get; set; }
public int PageSize { get; set; }
}
private class ConvertToClientResponse
{
public int ClientId { get; set; }
}
}
@@ -0,0 +1,43 @@
using Microsoft.JSInterop;
namespace TaxBaik.Web.Services;
public class LocalStorageService : ILocalStorageService
{
private readonly IJSRuntime _jsRuntime;
public LocalStorageService(IJSRuntime jsRuntime)
{
_jsRuntime = jsRuntime;
}
public async Task<string?> GetItemAsStringAsync(string key)
{
try
{
return await _jsRuntime.InvokeAsync<string>("localStorage.getItem", key);
}
catch
{
return null;
}
}
public async Task SetItemAsStringAsync(string key, string value)
{
try
{
await _jsRuntime.InvokeVoidAsync("localStorage.setItem", key, value);
}
catch { }
}
public async Task RemoveItemAsync(string key)
{
try
{
await _jsRuntime.InvokeVoidAsync("localStorage.removeItem", key);
}
catch { }
}
}
@@ -0,0 +1,149 @@
namespace TaxBaik.Web.Services;
using System.Net.Http;
using System.Net.Http.Json;
using TaxBaik.Domain.Entities;
/// <summary>
/// TaxFiling API Client for Admin Blazor
/// </summary>
public interface ITaxFilingBrowserClient
{
Task<IEnumerable<TaxFiling>> GetUpcomingAsync(int daysAhead = 30, CancellationToken ct = default);
Task<IEnumerable<TaxFiling>> GetByClientIdAsync(int clientId, CancellationToken ct = default);
Task<TaxFiling?> GetByIdAsync(int id, CancellationToken ct = default);
Task<TaxFiling?> CreateAsync(TaxFiling filing, CancellationToken ct = default);
Task<TaxFiling?> UpdateAsync(int id, TaxFiling filing, CancellationToken ct = default);
Task<bool> DeleteAsync(int id, CancellationToken ct = default);
}
public class TaxFilingBrowserClient : ITaxFilingBrowserClient
{
private readonly HttpClient _http;
private readonly ILogger<TaxFilingBrowserClient> _logger;
private readonly ITokenStore _tokenStore;
public TaxFilingBrowserClient(HttpClient http, ILogger<TaxFilingBrowserClient> logger, ITokenStore tokenStore)
{
_http = http;
_logger = logger;
_tokenStore = tokenStore;
}
private void EnsureAuthHeader()
{
if (!string.IsNullOrEmpty(_tokenStore.AccessToken))
_http.DefaultRequestHeaders.Authorization = new("Bearer", _tokenStore.AccessToken);
else
_http.DefaultRequestHeaders.Authorization = null;
}
public async Task<IEnumerable<TaxFiling>> GetUpcomingAsync(int daysAhead = 30, CancellationToken ct = default)
{
try
{
EnsureAuthHeader();
var result = await _http.GetFromJsonAsync<TaxFilingListResponse>(
$"taxfiling/upcoming?daysAhead={daysAhead}", cancellationToken: ct);
return result?.Data ?? [];
}
catch (HttpRequestException ex)
{
_logger.LogError(ex, "Failed to fetch upcoming filings");
throw;
}
}
public async Task<IEnumerable<TaxFiling>> GetByClientIdAsync(int clientId, CancellationToken ct = default)
{
try
{
EnsureAuthHeader();
var result = await _http.GetFromJsonAsync<TaxFilingListResponse>(
$"taxfiling/client/{clientId}", cancellationToken: ct);
return result?.Data ?? [];
}
catch (HttpRequestException ex)
{
_logger.LogError(ex, "Failed to fetch filings for client {ClientId}", clientId);
throw;
}
}
public async Task<TaxFiling?> GetByIdAsync(int id, CancellationToken ct = default)
{
try
{
EnsureAuthHeader();
return await _http.GetFromJsonAsync<TaxFiling>(
$"taxfiling/{id}", cancellationToken: ct);
}
catch (HttpRequestException ex)
{
_logger.LogError(ex, "Failed to fetch filing {FilingId}", id);
throw;
}
}
public async Task<TaxFiling?> CreateAsync(TaxFiling filing, CancellationToken ct = default)
{
try
{
EnsureAuthHeader();
var response = await _http.PostAsJsonAsync("taxfiling", filing, cancellationToken: ct);
if (!response.IsSuccessStatusCode)
return null;
var content = await response.Content.ReadAsStringAsync(ct);
return System.Text.Json.JsonSerializer.Deserialize<TaxFiling>(
content,
new System.Text.Json.JsonSerializerOptions { PropertyNameCaseInsensitive = true });
}
catch (HttpRequestException ex)
{
_logger.LogError(ex, "Failed to create filing");
throw;
}
}
public async Task<TaxFiling?> UpdateAsync(int id, TaxFiling filing, CancellationToken ct = default)
{
try
{
EnsureAuthHeader();
var response = await _http.PutAsJsonAsync($"taxfiling/{id}", filing, cancellationToken: ct);
if (!response.IsSuccessStatusCode)
return null;
var content = await response.Content.ReadAsStringAsync(ct);
return System.Text.Json.JsonSerializer.Deserialize<TaxFiling>(
content,
new System.Text.Json.JsonSerializerOptions { PropertyNameCaseInsensitive = true });
}
catch (HttpRequestException ex)
{
_logger.LogError(ex, "Failed to update filing {FilingId}", id);
throw;
}
}
public async Task<bool> DeleteAsync(int id, CancellationToken ct = default)
{
try
{
EnsureAuthHeader();
var response = await _http.DeleteAsync($"taxfiling/{id}", cancellationToken: ct);
return response.IsSuccessStatusCode;
}
catch (HttpRequestException ex)
{
_logger.LogError(ex, "Failed to delete filing {FilingId}", id);
throw;
}
}
private class TaxFilingListResponse
{
public List<TaxFiling> Data { get; set; } = [];
}
}
@@ -0,0 +1,106 @@
namespace TaxBaik.Web.Services;
using System.Net;
using System.Text.Json;
/// <summary>
/// HTTP 요청 시 자동으로 access token을 추가하고,
/// 401 응답을 받으면 refresh token으로 새 토큰을 획득한 후 재시도합니다.
/// SOLID: Single Responsibility - 토큰 갱신 로직만 담당
/// </summary>
public class TokenRefreshHandler : DelegatingHandler
{
private readonly IServiceProvider _serviceProvider;
private readonly ILogger<TokenRefreshHandler> _logger;
public TokenRefreshHandler(IServiceProvider serviceProvider, ILogger<TokenRefreshHandler> logger)
{
_serviceProvider = serviceProvider;
_logger = logger;
}
protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
// 최신 Scoped ITokenStore 실시간 해석 (Scope Capture 차단 및 기존 Blazor 회로 수명 공유)
var tokenStore = Microsoft.Extensions.DependencyInjection.ServiceProviderServiceExtensions.GetRequiredService<ITokenStore>(_serviceProvider);
// 요청에 access token 추가
if (!string.IsNullOrEmpty(tokenStore.AccessToken))
{
request.Headers.Authorization = new("Bearer", tokenStore.AccessToken);
}
var response = await base.SendAsync(request, cancellationToken);
// 401 응답이면 토큰 갱신 시도
if (response.StatusCode == HttpStatusCode.Unauthorized)
{
if (!string.IsNullOrEmpty(tokenStore.RefreshToken))
{
var newTokenPair = await RefreshTokenAsync(tokenStore.RefreshToken, request, cancellationToken);
if (newTokenPair != null)
{
// TokenStore에 토큰 저장
tokenStore.AccessToken = newTokenPair.AccessToken;
tokenStore.RefreshToken = newTokenPair.RefreshToken;
tokenStore.TokenExpiryTicks = DateTime.UtcNow.AddSeconds(newTokenPair.ExpiresIn).Ticks;
// 새 토큰으로 재요청
request.Headers.Authorization = new("Bearer", newTokenPair.AccessToken);
response = await base.SendAsync(request, cancellationToken);
}
else
{
_logger.LogWarning("토큰 갱신 실패 - 로그아웃");
tokenStore.Clear();
}
}
}
return response;
}
private async Task<WasmAuthTokenPair?> RefreshTokenAsync(string refreshToken, HttpRequestMessage originalRequest, CancellationToken ct)
{
try
{
// 원래 요청의 호스트 정보 추출
var authority = originalRequest.RequestUri?.Authority ?? "localhost:5001";
var scheme = originalRequest.RequestUri?.Scheme ?? "http";
using var httpClient = new HttpClient();
var refreshUri = new Uri($"{scheme}://{authority}/taxbaik/api/auth/refresh");
var json = JsonSerializer.Serialize(new { refreshToken });
var content = new StringContent(json, System.Text.Encoding.UTF8, "application/json");
var response = await httpClient.PostAsync(refreshUri, content, ct);
if (!response.IsSuccessStatusCode)
{
_logger.LogWarning($"Token refresh failed with status {response.StatusCode}");
return null;
}
var responseContent = await response.Content.ReadAsStringAsync(ct);
var result = JsonSerializer.Deserialize<AuthTokenResponse>(responseContent,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
return result != null
? new WasmAuthTokenPair(result.AccessToken, result.RefreshToken, result.ExpiresIn)
: null;
}
catch (Exception ex)
{
_logger.LogError(ex, "Exception during token refresh");
return null;
}
}
}
internal class AuthTokenResponse
{
public string AccessToken { get; set; } = string.Empty;
public string RefreshToken { get; set; } = string.Empty;
public int ExpiresIn { get; set; }
}
@@ -0,0 +1,24 @@
<Project Sdk="Microsoft.NET.Sdk.BlazorWebAssembly">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>TaxBaik.WasmClient</RootNamespace>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\TaxBaik.Application\TaxBaik.Application.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="10.0.9" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="10.0.9" PrivateAssets="all" />
<PackageReference Include="Microsoft.AspNetCore.Components.Authorization" Version="10.0.9" />
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.9" />
<PackageReference Include="Microsoft.IdentityModel.Tokens" Version="8.19.1" />
<PackageReference Include="MudBlazor" Version="6.10.0" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.19.1" />
</ItemGroup>
</Project>
+13
View File
@@ -0,0 +1,13 @@
@using System.Net.Http
@using System.Net.Http.Json
@using Microsoft.AspNetCore.Authorization
@using Microsoft.AspNetCore.Components.Authorization
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.AspNetCore.Components.Routing
@using Microsoft.AspNetCore.Components.Web
@using Microsoft.AspNetCore.Components.Web.Virtualization
@using Microsoft.AspNetCore.Components.WebAssembly.Http
@using Microsoft.JSInterop
@using MudBlazor
@using TaxBaik.WasmClient
@using static Microsoft.AspNetCore.Components.Web.RenderMode