Refine admin login flow and verification harness
TaxBaik CI/CD / build-and-deploy (push) Successful in 2m21s

This commit is contained in:
2026-07-07 14:38:30 +09:00
parent b7cb442937
commit 35842b6765
60 changed files with 1043 additions and 495 deletions
@@ -19,7 +19,6 @@
window.location.pathname.toLowerCase().endsWith('/admin/login'));
</script>
<link rel="stylesheet" href="css/admin.css" />
<component type="typeof(HeadOutlet)" render-mode="InteractiveWebAssembly" />
</head>
<body>
<div id="components-reconnect-modal" class="admin-reconnect-modal">
@@ -0,0 +1,217 @@
namespace TaxBaik.WasmClient.Components.Admin.Forms;
using System.ComponentModel.DataAnnotations;
public sealed class CompanyFormModel
{
[Required(ErrorMessage = "회사 코드를 입력하세요.")]
[StringLength(50, ErrorMessage = "회사 코드는 최대 50자까지 입력 가능합니다.")]
public string CompanyCode { get; set; } = "";
[Required(ErrorMessage = "회사명을 입력하세요.")]
[StringLength(100, ErrorMessage = "회사명은 최대 100자까지 입력 가능합니다.")]
public string CompanyName { get; set; } = "";
[StringLength(100, ErrorMessage = "담당자명은 최대 100자까지 입력 가능합니다.")]
public string? ContactPerson { get; set; }
[StringLength(20, ErrorMessage = "전화번호는 최대 20자까지 입력 가능합니다.")]
public string? Phone { get; set; }
[EmailAddress(ErrorMessage = "올바른 이메일 형식이 아닙니다.")]
public string? Email { get; set; }
[StringLength(1000, ErrorMessage = "메모는 최대 1000자까지 입력 가능합니다.")]
public string? Memo { get; set; }
public bool IsActive { get; set; } = true;
}
public sealed class InquiryFormModel
{
[Required(ErrorMessage = "이름을 입력하세요.")]
[StringLength(100, ErrorMessage = "이름은 최대 100자까지 입력 가능합니다.")]
public string Name { get; set; } = "";
[Required(ErrorMessage = "전화번호를 입력하세요.")]
[RegularExpression(
@"^(0(?:2|3[1-3]|4[1-4]|5[1-5]|6[1-4]|70|50[5-9]|[7-9](?:\d{1,2})?)\d{7,8}|0\d{9,10})$",
ErrorMessage = " .")]
public string Phone { get; set; } = "";
[EmailAddress(ErrorMessage = "올바른 이메일 형식이 아닙니다.")]
public string? Email { get; set; }
[StringLength(50, ErrorMessage = "상담분야는 최대 50자까지 입력 가능합니다.")]
public string ServiceType { get; set; } = "기타";
[Required(ErrorMessage = "문의 내용을 입력하세요.")]
[StringLength(5000, MinimumLength = 10, ErrorMessage = "문의 내용은 최소 10자, 최대 5000자까지 입력 가능합니다.")]
public string Message { get; set; } = "";
[Required(ErrorMessage = "상태를 선택하세요.")]
public string Status { get; set; } = "new";
[StringLength(1000, ErrorMessage = "관리 메모는 최대 1000자까지 입력 가능합니다.")]
public string? AdminMemo { get; set; }
}
public sealed class BlogFormModel
{
[Required(ErrorMessage = "제목을 입력하세요.")]
[StringLength(100, ErrorMessage = "제목은 최대 100자까지 입력 가능합니다.")]
public string Title { get; set; } = "";
[Required(ErrorMessage = "본문 내용을 입력하세요.")]
[StringLength(30000, MinimumLength = 10, ErrorMessage = "본문은 최소 10자 이상이어야 합니다.")]
public string Content { get; set; } = "";
public int? CategoryId { get; set; }
[StringLength(500, ErrorMessage = "태그는 최대 500자까지 입력 가능합니다.")]
public string? Tags { get; set; }
[StringLength(120, ErrorMessage = "SEO 제목은 최대 120자까지 입력 가능합니다.")]
public string? SeoTitle { get; set; }
[StringLength(300, ErrorMessage = "SEO 설명은 최대 300자까지 입력 가능합니다.")]
public string? SeoDescription { get; set; }
public bool IsPublished { get; set; }
}
public sealed class FaqFormModel
{
[Required(ErrorMessage = "질문을 입력하세요.")]
[StringLength(300, ErrorMessage = "질문은 최대 300자까지 입력 가능합니다.")]
public string Question { get; set; } = "";
[Required(ErrorMessage = "답변을 입력하세요.")]
[StringLength(5000, MinimumLength = 10, ErrorMessage = "답변은 최소 10자, 최대 5000자까지 입력 가능합니다.")]
public string Answer { get; set; } = "";
[StringLength(50, ErrorMessage = "카테고리는 최대 50자까지 입력 가능합니다.")]
public string? Category { get; set; }
[Range(0, 9999, ErrorMessage = "정렬 순서는 0 이상 9999 이하이어야 합니다.")]
public int SortOrder { get; set; }
public bool IsActive { get; set; }
}
public sealed class ClientFormModel
{
[Required(ErrorMessage = "고객명을 입력하세요.")]
[StringLength(100, ErrorMessage = "고객명은 최대 100자까지 입력 가능합니다.")]
public string Name { get; set; } = "";
[StringLength(100, ErrorMessage = "회사명은 최대 100자까지 입력 가능합니다.")]
public string? CompanyName { get; set; }
[StringLength(20, ErrorMessage = "전화번호는 최대 20자까지 입력 가능합니다.")]
public string? Phone { get; set; }
[EmailAddress(ErrorMessage = "올바른 이메일 형식이 아닙니다.")]
public string? Email { get; set; }
[StringLength(50, ErrorMessage = "서비스 유형은 최대 50자까지 입력 가능합니다.")]
public string? ServiceType { get; set; }
[StringLength(50, ErrorMessage = "세금 유형은 최대 50자까지 입력 가능합니다.")]
public string? TaxType { get; set; }
[Required(ErrorMessage = "상태를 선택하세요.")]
public string Status { get; set; } = "active";
[StringLength(50, ErrorMessage = "유입 경로는 최대 50자까지 입력 가능합니다.")]
public string? Source { get; set; }
[StringLength(1000, ErrorMessage = "메모는 최대 1000자까지 입력 가능합니다.")]
public string? Memo { get; set; }
}
public sealed class ContractFormModel
{
[Required(ErrorMessage = "고객을 선택하세요.")]
public int? ClientId { get; set; }
[Required(ErrorMessage = "계약번호를 입력하세요.")]
[StringLength(50, ErrorMessage = "계약번호는 최대 50자까지 입력 가능합니다.")]
public string ContractNumber { get; set; } = "";
[Required(ErrorMessage = "서비스 유형을 선택하세요.")]
[StringLength(50, ErrorMessage = "서비스 유형은 최대 50자까지 입력 가능합니다.")]
public string ServiceType { get; set; } = "";
[Required(ErrorMessage = "계약 시작일을 입력하세요.")]
public DateTime? StartDate { get; set; }
[Range(0, 999999999, ErrorMessage = "월 수수료는 0 이상이어야 합니다.")]
public decimal? MonthlyFee { get; set; }
}
public sealed class TaxFilingScheduleFormModel
{
[Required(ErrorMessage = "고객을 선택하세요.")]
public int? ClientId { get; set; }
[Required(ErrorMessage = "신고 유형을 선택하세요.")]
[StringLength(50, ErrorMessage = "신고 유형은 최대 50자까지 입력 가능합니다.")]
public string FilingType { get; set; } = "";
[Required(ErrorMessage = "마감일을 입력하세요.")]
public DateTime? DueDate { get; set; }
[Range(2000, 2100, ErrorMessage = "신고연도는 2000년부터 2100년 사이여야 합니다.")]
public int FilingYear { get; set; } = DateTime.Now.Year;
}
public sealed class TaxProfileFormModel
{
[Required(ErrorMessage = "고객을 선택하세요.")]
public int? ClientId { get; set; }
[Required(ErrorMessage = "사업 유형을 선택하세요.")]
[StringLength(50, ErrorMessage = "사업 유형은 최대 50자까지 입력 가능합니다.")]
public string BusinessType { get; set; } = "";
[Required(ErrorMessage = "위험도를 선택하세요.")]
[StringLength(20, ErrorMessage = "위험도는 최대 20자까지 입력 가능합니다.")]
public string TaxRiskLevel { get; set; } = "normal";
public DateTime? NextFilingDueDate { get; set; }
[StringLength(1000, ErrorMessage = "특수 사항은 최대 1000자까지 입력 가능합니다.")]
public string? SpecialNotes { get; set; }
}
public sealed class SiteSettingsFormModel
{
[Required(ErrorMessage = "전화번호를 입력하세요.")]
[StringLength(20, ErrorMessage = "전화번호는 최대 20자까지 입력 가능합니다.")]
public string Phone { get; set; } = "";
[Required(ErrorMessage = "이메일을 입력하세요.")]
[EmailAddress(ErrorMessage = "올바른 이메일 형식이 아닙니다.")]
public string Email { get; set; } = "";
[Url(ErrorMessage = "올바른 카카오채널 URL 형식이 아닙니다.")]
public string? KakaoUrl { get; set; }
[Url(ErrorMessage = "올바른 인스타그램 URL 형식이 아닙니다.")]
public string? InstagramUrl { get; set; }
}
public sealed class PasswordChangeFormModel
{
[Required(ErrorMessage = "현재 비밀번호를 입력하세요.")]
public string CurrentPassword { get; set; } = "";
[Required(ErrorMessage = "새 비밀번호를 입력하세요.")]
[StringLength(128, MinimumLength = 12, ErrorMessage = "새 비밀번호는 최소 12자 이상이어야 합니다.")]
public string NewPassword { get; set; } = "";
[Required(ErrorMessage = "새 비밀번호 확인을 입력하세요.")]
[Compare(nameof(NewPassword), ErrorMessage = "새 비밀번호와 확인이 일치하지 않습니다.")]
public string ConfirmNewPassword { get; set; } = "";
}
@@ -75,14 +75,4 @@
await OnSubmit.InvokeAsync(model);
}
public class CompanyFormModel
{
public string CompanyCode { get; set; } = "";
public string CompanyName { get; set; } = "";
public string? ContactPerson { get; set; }
public string? Phone { get; set; }
public string? Email { get; set; }
public string? Memo { get; set; }
public bool IsActive { get; set; } = true;
}
}
@@ -83,14 +83,4 @@
await OnSubmit.InvokeAsync(model);
}
public class InquiryFormModel
{
public string Name { get; set; } = "";
public string Phone { get; set; } = "";
public string? Email { get; set; }
public string ServiceType { get; set; } = "기타";
public string Message { get; set; } = "";
public string Status { get; set; } = "new";
public string? AdminMemo { get; set; }
}
}
@@ -2,6 +2,7 @@
@page "/admin/announcements/{Id:int}/edit"
@rendermode @(new InteractiveWebAssemblyRenderMode(prerender: true))
@attribute [Authorize]
@using System.ComponentModel.DataAnnotations
@using TaxBaik.Application.DTOs
@using TaxBaik.Web.Services
@using TaxBaik.WasmClient.Components.Admin.Shared
@@ -22,7 +22,7 @@
@code {
private IReadOnlyList<Domain.Entities.Category> categories = [];
private BlogForm.BlogFormModel model = new();
private BlogFormModel model = new();
protected override async Task OnInitializedAsync()
{
@@ -38,7 +38,7 @@ else
private TaxBaik.Application.DTOs.BlogPostResponseDto? post;
private IReadOnlyList<Domain.Entities.Category> categories = [];
private BlogForm.BlogFormModel model = new();
private BlogFormModel model = new();
private bool isLoading = true;
private RenderFragment EditorSkeleton => builder =>
@@ -129,9 +129,9 @@ else
return;
var result = await DialogService.ShowMessageBox(
"포스트 삭제",
"정말 삭제하시겠습니까? 이 작업은 취소할습니다.",
"삭제", "취소");
"포스트 임시 내리기",
"정말 이 포스트를 임시로 내리시겠습니까? 필요하면 나중에 다시 올릴습니다.",
"임시 내리기", "취소");
if (result != true)
return;
@@ -141,15 +141,15 @@ else
var deleted = await BlogClient.DeleteAsync(post.Id);
if (!deleted)
{
Snackbar.Add("삭제 실패: 포스트를 삭제하지 못했습니다.", Severity.Error);
Snackbar.Add("임시 내리기 실패: 포스트를 내리지 못했습니다.", Severity.Error);
return;
}
Snackbar.Add("포스트가 삭제되었습니다.", Severity.Success);
Snackbar.Add("포스트를 임시로 내렸습니다.", Severity.Success);
Navigation.NavigateTo("/taxbaik/admin/blog");
}
catch (Exception ex)
{
Snackbar.Add($"삭제 실패: {ex.Message}", Severity.Error);
Snackbar.Add($"임시 내리기 실패: {ex.Message}", Severity.Error);
}
}
}
@@ -72,14 +72,4 @@
await OnSubmit.InvokeAsync();
}
public class BlogFormModel
{
public string Title { get; set; } = "";
public string Content { get; set; } = "";
public int? CategoryId { get; set; }
public string? Tags { get; set; }
public string? SeoTitle { get; set; }
public string? SeoDescription { get; set; }
public bool IsPublished { get; set; }
}
}
@@ -10,7 +10,7 @@
<ChildContent>
<MudButton Variant="Variant.Outlined" Color="Color.Secondary" StartIcon="@Icons.Material.Filled.Restore"
OnClick="ToggleArchiveView">
@(showArchived ? "전체 글 보기" : "숨김 글 보기")
@(showArchived ? "전체 글 보기" : "내린 글 보기")
</MudButton>
<MudButton Variant="Variant.Outlined" Color="Color.Secondary" StartIcon="@Icons.Material.Filled.Refresh"
OnClick="Reload">
@@ -50,12 +50,12 @@
@if (showArchived)
{
<MudButton Variant="Variant.Text" Size="Size.Small" Color="Color.Success"
@onclick="@(async () => await RestorePost(cell.Item.Id))">복원</MudButton>
@onclick="@(async () => await RestorePost(cell.Item.Id))">다시 올리기</MudButton>
}
else
{
<MudButton Variant="Variant.Text" Size="Size.Small" Color="Color.Error"
@onclick="@(async () => await DeletePost(cell.Item.Id))">삭제</MudButton>
@onclick="@(async () => await DeletePost(cell.Item.Id))">임시 내리기</MudButton>
}
</CellTemplate>
</TemplateColumn>
@@ -177,10 +177,10 @@
var deleted = await BlogClient.DeleteAsync(postId);
if (!deleted)
{
Snackbar.Add("포스트 삭제에 실패했습니다.", Severity.Error);
Snackbar.Add("포스트를 내리지 못했습니다.", Severity.Error);
return;
}
Snackbar.Add("포스트가 삭제되었습니다.", Severity.Success);
Snackbar.Add("포스트를 임시로 내렸습니다.", Severity.Success);
await LoadPosts();
}
@@ -189,11 +189,11 @@
var restored = await BlogClient.RestoreAsync(postId);
if (!restored)
{
Snackbar.Add("포스트 복원에 실패했습니다.", Severity.Error);
Snackbar.Add("포스트를 다시 올리지 못했습니다.", Severity.Error);
return;
}
Snackbar.Add("포스트가 복원되었습니다.", Severity.Success);
Snackbar.Add("포스트를 다시 올렸습니다.", Severity.Success);
await LoadPosts();
}
@@ -98,7 +98,7 @@
[Parameter] public int? Id { get; set; }
private MudForm form = null!;
private CreateClientDto dto = new() { Status = "active" };
private ClientFormModel dto = new() { Status = "active" };
private bool isValid;
private bool isLoading = true;
private bool isSaving;
@@ -123,7 +123,7 @@
Navigation.NavigateTo("/taxbaik/admin/clients");
return;
}
dto = new CreateClientDto
dto = new ClientFormModel
{
Name = client.Name,
CompanyName = client.CompanyName,
@@ -156,7 +156,18 @@
{
if (Id.HasValue)
{
var result = await ClientClient.UpdateAsync(Id.Value, dto);
var result = await ClientClient.UpdateAsync(Id.Value, new CreateClientDto
{
Name = dto.Name,
CompanyName = dto.CompanyName,
Phone = dto.Phone,
Email = dto.Email,
ServiceType = dto.ServiceType,
TaxType = dto.TaxType,
Status = dto.Status,
Source = dto.Source,
Memo = dto.Memo
});
if (result != null)
Snackbar.Add("고객 정보가 수정되었습니다.", Severity.Success);
else
@@ -164,7 +175,18 @@
}
else
{
var result = await ClientClient.CreateAsync(dto);
var result = await ClientClient.CreateAsync(new CreateClientDto
{
Name = dto.Name,
CompanyName = dto.CompanyName,
Phone = dto.Phone,
Email = dto.Email,
ServiceType = dto.ServiceType,
TaxType = dto.TaxType,
Status = dto.Status,
Source = dto.Source,
Memo = dto.Memo
});
if (result != null)
Snackbar.Add("고객이 등록되었습니다.", Severity.Success);
else
@@ -27,7 +27,7 @@
Navigation.NavigateTo("/taxbaik/admin/companies");
}
private async Task HandleCreate(CompanyForm.CompanyFormModel model)
private async Task HandleCreate(CompanyFormModel model)
{
try
{
@@ -41,7 +41,7 @@
[Parameter]
public int Id { get; set; }
private CompanyForm.CompanyFormModel? formModel;
private CompanyFormModel? formModel;
private bool isLoading = true;
private RenderFragment CompanySkeleton => builder =>
@@ -60,7 +60,7 @@
IDictionary<string, object>? dict = company as IDictionary<string, object>;
if (dict != null)
{
formModel = new CompanyForm.CompanyFormModel
formModel = new CompanyFormModel
{
CompanyCode = (string)dict["companyCode"],
CompanyName = (string)dict["companyName"],
@@ -87,7 +87,7 @@
Navigation.NavigateTo("/taxbaik/admin/companies");
}
private async Task HandleUpdate(CompanyForm.CompanyFormModel model)
private async Task HandleUpdate(CompanyFormModel model)
{
try
{
@@ -147,7 +147,7 @@ else
private MudForm? form;
private bool isEditMode;
private Contract? selectedContract;
private ContractForm contractForm = new();
private ContractFormModel contractForm = new();
private RenderFragment ContractSkeleton => builder =>
{
@@ -198,7 +198,7 @@ else
{
selectedContract = null;
isEditMode = false;
contractForm = new ContractForm
contractForm = new ContractFormModel
{
ClientId = clients.FirstOrDefault()?.Id,
StartDate = DateTime.Today
@@ -210,7 +210,7 @@ else
if (contract == null) return;
selectedContract = contract;
isEditMode = true;
contractForm = new ContractForm
contractForm = new ContractFormModel
{
ClientId = contract.ClientId,
ContractNumber = contract.ContractNumber,
@@ -292,12 +292,4 @@ else
? client.Name
: $"Client #{client.Id}";
private class ContractForm
{
public int? ClientId { get; set; }
public string ContractNumber { get; set; } = "";
public string ServiceType { get; set; } = "";
public DateTime? StartDate { get; set; }
public decimal? MonthlyFee { get; set; }
}
}
@@ -3,7 +3,6 @@
@rendermode @(new InteractiveWebAssemblyRenderMode(prerender: true))
@attribute [Authorize]
@using TaxBaik.Web.Services
@using TaxBaik.Domain.Entities
@inject IFaqBrowserClient FaqClient
@inject NavigationManager Navigation
@inject ISnackbar Snackbar
@@ -24,7 +23,7 @@
<MudForm @ref="form" @bind-IsValid="isValid">
<MudGrid Spacing="3">
<MudItem xs="12">
<MudTextField @bind-Value="faq.Question"
<MudTextField @bind-Value="model.Question"
Label="질문 *" Required="true"
RequiredError="질문을 입력하세요."
Counter="300" MaxLength="300"
@@ -32,24 +31,24 @@
Placeholder="예: 기장료가 얼마인지 미리 알 수 있나요?" />
</MudItem>
<MudItem xs="12">
<MudTextField @bind-Value="faq.Answer"
<MudTextField @bind-Value="model.Answer"
Label="답변 *" Required="true"
RequiredError="답변을 입력하세요."
Lines="5" AutoGrow="true"
Placeholder="방문자에게 보여질 답변을 입력하세요." />
</MudItem>
<MudItem xs="12" md="6">
<CommonCodeSelect @bind-Value="faq.Category" Group="FAQ_CATEGORY" Label="카테고리" Clearable="true" Placeholder="전체" />
<CommonCodeSelect @bind-Value="model.Category" Group="FAQ_CATEGORY" Label="카테고리" Clearable="true" Placeholder="전체" />
</MudItem>
<MudItem xs="12" md="3">
<MudNumericField @bind-Value="faq.SortOrder"
<MudNumericField @bind-Value="model.SortOrder"
Label="정렬 순서"
HelperText="작을수록 위에 노출"
Min="0" Max="9999" />
</MudItem>
<MudItem xs="12" md="3" Class="d-flex align-center">
<MudSwitch T="bool" @bind-Value="faq.IsActive" Color="Color.Success"
Label="@(faq.IsActive ? "노출 중" : "비활성")" />
<MudSwitch T="bool" @bind-Value="model.IsActive" Color="Color.Success"
Label="@(model.IsActive ? "노출 중" : "비활성")" />
</MudItem>
<MudItem xs="12" Class="d-flex gap-2 mt-2">
@@ -71,7 +70,7 @@
[Parameter] public int? Id { get; set; }
private MudForm form = null!;
private Faq faq = new() { SortOrder = 10, IsActive = true };
private FaqFormModel model = new() { SortOrder = 10, IsActive = true };
private bool isValid;
private bool isLoading = true;
private bool isSaving;
@@ -97,7 +96,14 @@
Navigation.NavigateTo("/taxbaik/admin/faqs");
return;
}
faq = existing;
model = new FaqFormModel
{
Question = existing.Question,
Answer = existing.Answer,
Category = existing.Category,
SortOrder = existing.SortOrder,
IsActive = existing.IsActive
};
}
catch (Exception ex)
{
@@ -117,6 +123,16 @@
isSaving = true;
try
{
var faq = new Faq
{
Id = Id ?? 0,
Question = model.Question,
Answer = model.Answer,
Category = model.Category,
SortOrder = model.SortOrder,
IsActive = model.IsActive
};
if (Id.HasValue)
{
var result = await FaqClient.UpdateAsync(Id.Value, faq);
@@ -144,4 +160,5 @@
isSaving = false;
}
}
}
@@ -25,10 +25,11 @@
Navigation.NavigateTo("/taxbaik/admin/inquiries");
}
private async Task HandleCreate(InquiryForm.InquiryFormModel model)
private async Task HandleCreate(InquiryFormModel model)
{
try
{
// ViewModel validation is the first line; DTO validation remains the contract boundary.
var result = await InquiryClient.CreateAsync(new SubmitInquiryDto
{
Name = model.Name,
@@ -39,7 +39,7 @@ else
public int Id { get; set; }
private Domain.Entities.Inquiry? inquiry;
private InquiryForm.InquiryFormModel? formModel;
private InquiryFormModel? formModel;
private bool isLoading = true;
private RenderFragment EditorSkeleton => builder =>
@@ -57,7 +57,7 @@ else
inquiry = await InquiryClient.GetByIdAsync(Id);
if (inquiry != null)
{
formModel = new InquiryForm.InquiryFormModel
formModel = new InquiryFormModel
{
Name = inquiry.Name,
Phone = inquiry.Phone,
@@ -84,7 +84,7 @@ else
Navigation.NavigateTo("/taxbaik/admin/inquiries");
}
private async Task HandleUpdate(InquiryForm.InquiryFormModel model)
private async Task HandleUpdate(InquiryFormModel model)
{
if (inquiry == null)
return;
@@ -109,7 +109,7 @@ else
}
inquiry = updated;
formModel = new InquiryForm.InquiryFormModel
formModel = new InquiryFormModel
{
Name = inquiry.Name,
Phone = inquiry.Phone,
@@ -1,6 +0,0 @@
@page "/admin/login"
@layout TaxBaik.WasmClient.Components.Admin.Layout.BlankLayout
@attribute [AllowAnonymous]
@rendermode @(new InteractiveWebAssemblyRenderMode(prerender: true))
<PageTitle>로그인</PageTitle>
<AdminLoginForm />
@@ -1,7 +1,6 @@
@page "/settings"
@rendermode @(new InteractiveWebAssemblyRenderMode(prerender: true))
@attribute [Authorize]
@using System.ComponentModel.DataAnnotations
@using System.Collections.Generic
@using TaxBaik.Web.Services
@using TaxBaik.Domain.Interfaces
@@ -30,16 +29,16 @@
</div>
</div>
<MudForm>
<MudTextField @bind-Value="phone" Label="전화번호"
<MudTextField @bind-Value="form.Phone" Label="전화번호"
Variant="Variant.Outlined" Class="mb-4" />
<MudTextField @bind-Value="email" Label="이메일"
<MudTextField @bind-Value="form.Email" Label="이메일"
Variant="Variant.Outlined" Class="mb-4" />
<MudTextField @bind-Value="kakaoUrl" Label="카카오채널 URL"
<MudTextField @bind-Value="form.KakaoUrl" Label="카카오채널 URL"
Variant="Variant.Outlined" Class="mb-4" />
<MudTextField @bind-Value="instagramUrl" Label="인스타그램"
<MudTextField @bind-Value="form.InstagramUrl" Label="인스타그램"
Variant="Variant.Outlined" Class="mb-4" />
<MudButton Variant="Variant.Filled" Color="Color.Primary"
@@ -59,13 +58,13 @@
</div>
<MudForm>
<MudTextField @bind-Value="currentPassword" Label="현재 비밀번호" InputType="InputType.Password"
<MudTextField @bind-Value="passwordForm.CurrentPassword" Label="현재 비밀번호" InputType="InputType.Password"
Variant="Variant.Outlined" Class="mb-4" />
<MudTextField @bind-Value="newPassword" Label="새 비밀번호" InputType="InputType.Password"
<MudTextField @bind-Value="passwordForm.NewPassword" Label="새 비밀번호" InputType="InputType.Password"
Variant="Variant.Outlined" Class="mb-4" />
<MudTextField @bind-Value="confirmNewPassword" Label="새 비밀번호 확인" InputType="InputType.Password"
<MudTextField @bind-Value="passwordForm.ConfirmNewPassword" Label="새 비밀번호 확인" InputType="InputType.Password"
Variant="Variant.Outlined" Class="mb-4" />
<MudButton Variant="Variant.Filled" Color="Color.Primary"
@@ -80,15 +79,16 @@
</MudGrid>
@code {
private string phone = "010-4122-8268";
private string email = "taxbaik5668@gmail.com";
private string kakaoUrl = "http://pf.kakao.com/_xoxchTX";
private string instagramUrl = "https://www.instagram.com/taxtory5668/";
private string currentPassword = "";
private string newPassword = "";
private string confirmNewPassword = "";
private bool isChangingPassword;
private bool isLoadingSettings;
private SiteSettingsFormModel form = new()
{
Phone = "010-4122-8268",
Email = "taxbaik5668@gmail.com",
KakaoUrl = "http://pf.kakao.com/_xoxchTX",
InstagramUrl = "https://www.instagram.com/taxtory5668/"
};
private PasswordChangeFormModel passwordForm = new();
protected override async Task OnInitializedAsync()
{
@@ -106,16 +106,16 @@
return;
if (settings.TryGetValue("PhoneNumber", out var loadedPhone) && !string.IsNullOrWhiteSpace(loadedPhone))
phone = loadedPhone;
form.Phone = loadedPhone;
if (settings.TryGetValue("EmailAddress", out var loadedEmail) && !string.IsNullOrWhiteSpace(loadedEmail))
email = loadedEmail;
form.Email = loadedEmail;
if (settings.TryGetValue("KakaoChannelUrl", out var loadedKakao) && !string.IsNullOrWhiteSpace(loadedKakao))
kakaoUrl = loadedKakao;
form.KakaoUrl = loadedKakao;
if (settings.TryGetValue("InstagramUrl", out var loadedInstagram) && !string.IsNullOrWhiteSpace(loadedInstagram))
instagramUrl = loadedInstagram;
form.InstagramUrl = loadedInstagram;
}
catch
{
@@ -134,10 +134,10 @@
var response = await ApiClient.PutAsync<SaveSettingsResponse>("site-settings", new
{
Phone = phone,
Email = email,
KakaoUrl = kakaoUrl,
InstagramUrl = instagramUrl
Phone = form.Phone,
Email = form.Email,
KakaoUrl = form.KakaoUrl,
InstagramUrl = form.InstagramUrl
});
if (response?.Message is null)
@@ -154,13 +154,13 @@
if (isChangingPassword)
return;
if (string.IsNullOrWhiteSpace(currentPassword) || string.IsNullOrWhiteSpace(newPassword))
if (string.IsNullOrWhiteSpace(passwordForm.CurrentPassword) || string.IsNullOrWhiteSpace(passwordForm.NewPassword))
{
Snackbar.Add("현재 비밀번호와 새 비밀번호를 입력하세요.", Severity.Warning);
return;
}
if (newPassword != confirmNewPassword)
if (passwordForm.NewPassword != passwordForm.ConfirmNewPassword)
{
Snackbar.Add("새 비밀번호 확인이 일치하지 않습니다.", Severity.Warning);
return;
@@ -172,8 +172,8 @@
{
var response = await ApiClient.PostAsync<ChangePasswordResponse>("auth/change-password", new
{
CurrentPassword = currentPassword,
NewPassword = newPassword
CurrentPassword = passwordForm.CurrentPassword,
NewPassword = passwordForm.NewPassword
});
if (response?.Message == null)
@@ -183,9 +183,7 @@
}
Snackbar.Add(response.Message, Severity.Success);
currentPassword = "";
newPassword = "";
confirmNewPassword = "";
passwordForm = new PasswordChangeFormModel();
}
catch
{
@@ -206,4 +204,5 @@
{
public string Message { get; set; } = "";
}
}
@@ -150,7 +150,7 @@ else
private MudForm? form;
private bool isEditMode;
private TaxFilingSchedule? selectedSchedule;
private TaxFilingScheduleForm scheduleForm = new();
private TaxFilingScheduleFormModel scheduleForm = new();
private RenderFragment ScheduleSkeleton => builder =>
{
@@ -200,7 +200,7 @@ else
{
selectedSchedule = null;
isEditMode = false;
scheduleForm = new TaxFilingScheduleForm
scheduleForm = new TaxFilingScheduleFormModel
{
FilingYear = DateTime.Now.Year,
DueDate = DateTime.Today,
@@ -214,7 +214,7 @@ else
if (schedule == null) return;
selectedSchedule = schedule;
isEditMode = true;
scheduleForm = new TaxFilingScheduleForm
scheduleForm = new TaxFilingScheduleFormModel
{
ClientId = schedule.ClientId,
FilingType = schedule.FilingType,
@@ -315,11 +315,4 @@ else
? client.Name
: $"Client #{client.Id}";
private class TaxFilingScheduleForm
{
public int? ClientId { get; set; }
public string FilingType { get; set; } = "";
public DateTime? DueDate { get; set; }
public int FilingYear { get; set; } = DateTime.Now.Year;
}
}
@@ -1,5 +1,6 @@
@page "/admin/tax-profiles"
@rendermode @(new InteractiveWebAssemblyRenderMode(prerender: true))
@using System.ComponentModel.DataAnnotations
@using TaxBaik.Web.Services.AdminClients
@using TaxBaik.WasmClient.Components.Admin.Shared
@inject ITaxProfileBrowserClient TaxProfileClient
@@ -123,7 +124,7 @@ else
private MudForm? form;
private bool isEditMode;
private TaxProfile? selectedProfile;
private TaxProfileForm profileForm = new();
private TaxProfileFormModel profileForm = new();
private RenderFragment ProfileSkeleton => builder =>
{
@@ -174,7 +175,7 @@ else
{
selectedProfile = null;
isEditMode = false;
profileForm = new TaxProfileForm
profileForm = new TaxProfileFormModel
{
ClientId = clients.FirstOrDefault()?.Id,
TaxRiskLevel = "normal",
@@ -187,7 +188,7 @@ else
if (profile == null) return;
selectedProfile = profile;
isEditMode = true;
profileForm = new TaxProfileForm
profileForm = new TaxProfileFormModel
{
ClientId = profile.ClientId,
BusinessType = profile.BusinessType ?? "",
@@ -290,13 +291,4 @@ else
? client.Name
: $"Client #{client.Id}";
private class TaxProfileForm
{
public int? ClientId { get; set; }
public string BusinessType { get; set; } = "";
public string TaxRiskLevel { get; set; } = "normal";
public DateTime? NextFilingDueDate { get; set; }
public string? SpecialNotes { get; set; }
}
}
@@ -1,5 +1,4 @@
@inject NavigationManager Navigation
@inject IJSRuntime Js
@code {
protected override async Task OnAfterRenderAsync(bool firstRender)
@@ -7,7 +6,6 @@
if (!firstRender)
return;
await Js.InvokeVoidAsync("taxbaikAdminSession.clearAuthToken");
var returnUrl = Uri.EscapeDataString(Navigation.ToBaseRelativePath(Navigation.Uri));
Navigation.NavigateTo($"/taxbaik/admin/login?returnUrl={returnUrl}", replace: true);
}
@@ -7,18 +7,15 @@ namespace TaxBaik.Web.Client.Components.Admin.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;
@@ -30,24 +27,6 @@ public class CustomAuthenticationStateProvider : AuthenticationStateProvider
{
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 (TryNormalizeExpiryTicks(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()));
@@ -125,11 +104,6 @@ public class CustomAuthenticationStateProvider : AuthenticationStateProvider
_tokenStore.RefreshToken = refreshToken;
_tokenStore.TokenExpiryTicks = tokenExpiryTicks;
// localStorage에도 저장 (페이지 리로드 후 복원)
await _localStorage.SetItemAsStringAsync("accessToken", accessToken);
await _localStorage.SetItemAsStringAsync("refreshToken", refreshToken);
await _localStorage.SetItemAsStringAsync("tokenExpiry", tokenExpiryTicks.ToString());
// Blazor에 인증 상태 변경을 알림 - 이 호출 자체는 async이지만 fire-and-forget OK
// (NotifyAuthenticationStateChanged는 내부적으로 Task를 구독함)
NotifyAuthenticationStateChanged(GetAuthenticationStateAsync());
@@ -183,11 +157,6 @@ public class CustomAuthenticationStateProvider : AuthenticationStateProvider
// TokenStore 초기화
_tokenStore.Clear();
// localStorage 초기화
await _localStorage.RemoveItemAsync("accessToken");
await _localStorage.RemoveItemAsync("refreshToken");
await _localStorage.RemoveItemAsync("tokenExpiry");
NotifyAuthenticationStateChanged(GetAuthenticationStateAsync());
}
@@ -1,6 +1,3 @@
@inject ILocalStorageService LocalStorageService
@inject IJSRuntime Js
<MudContainer MaxWidth="MaxWidth.Small" Class="admin-login-page d-flex align-center justify-center" Style="min-height: 100vh;">
<MudPaper Class="pa-8" Elevation="3" Style="width: 100%; max-width: 400px;">
<MudText Typo="Typo.h4" Class="mb-6 text-center">관리자 로그인</MudText>
@@ -10,8 +7,7 @@
style="width: 100%; min-height: 56px; padding: 16px 14px;"
placeholder="사용자명"
autocomplete="username"
name="username"
value="@rememberedUsername" />
name="username" />
<input type="password"
class="mud-input mud-input-outlined mud-input-root mud-input-root-adorned-start mb-4"
@@ -20,11 +16,6 @@
autocomplete="current-password"
name="password" />
<div class="mb-4">
<input class="mud-checkbox" type="checkbox" name="rememberMe" />
<label style="margin-left: 8px; cursor: pointer;">아이디 저장</label>
</div>
<div class="mud-alert mud-alert-filled-error mb-4 login-error-message" style="display:none;">로그인 중 오류가 발생했습니다.</div>
<button type="submit"
@@ -38,39 +29,4 @@
</MudContainer>
@code {
private string rememberedUsername = "";
private bool isRememberChecked = false;
private const string RememberedUsernameKey = "admin-remembered-username";
private const string RememberedCheckboxKey = "admin-remember-checkbox";
protected override async Task OnInitializedAsync()
{
try
{
rememberedUsername = await LocalStorageService.GetItemAsStringAsync(RememberedUsernameKey) ?? "";
var checkboxValue = await LocalStorageService.GetItemAsStringAsync(RememberedCheckboxKey) ?? "false";
isRememberChecked = checkboxValue == "true" && !string.IsNullOrEmpty(rememberedUsername);
}
catch
{
rememberedUsername = "";
isRememberChecked = false;
}
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
try
{
await Js.InvokeVoidAsync("taxbaikAdminSession.syncRouteClass");
await Js.InvokeVoidAsync("taxbaikAdminSession.bindLoginForm");
}
catch
{
// Login UI must remain visible even if JS binding fails.
}
}
}
}
@@ -1,5 +1,3 @@
@using System.Text.RegularExpressions
@inject IJSRuntime Js
@inject NavigationManager Navigation
@code {
@@ -13,95 +11,5 @@
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
try
{
var route = GetRoute();
var context = ResolveContext(route);
await Js.InvokeVoidAsync("taxbaikAdminSession.setContext",
string.IsNullOrWhiteSpace(Screen) ? context.Screen : Screen,
string.IsNullOrWhiteSpace(Feature) ? context.Feature : Feature,
string.IsNullOrWhiteSpace(Action) ? context.Action : Action,
string.IsNullOrWhiteSpace(Step) ? context.Step : Step,
string.IsNullOrWhiteSpace(Entity) ? context.Entity : Entity,
string.IsNullOrWhiteSpace(EntityId) ? context.EntityId : EntityId,
string.IsNullOrWhiteSpace(DataKey) ? context.DataKey : DataKey);
}
catch
{
// telemetry must never block rendering
}
}
}
private string GetRoute()
{
var path = Navigation.ToBaseRelativePath(Navigation.Uri);
return string.IsNullOrWhiteSpace(path) ? "/" : "/" + path.TrimStart('/');
}
private static (string Screen, string Feature, string Action, string Step, string Entity, string EntityId, string DataKey) ResolveContext(string route)
=> route.ToLowerInvariant() switch
{
"/" => ("admin/index", "shell", "load", "index", "admin", "", "index"),
"/admin/login" => ("admin/login", "auth", "render", "login page", "auth", "", "login"),
"/admin/dashboard" => ("admin/dashboard", "dashboard", "load", "summary", "dashboard", "", "summary"),
"/admin/common-codes" => ("admin/common-codes", "common-code", "load", "group list", "common_code", "", "group"),
"/admin/blog" => ("admin/blog", "content", "load", "list", "blog", "", "list"),
"/admin/blog/create" => ("admin/blog/create", "content", "create", "form", "blog", "", "create"),
"/admin/blog/0/edit" => ("admin/blog/edit", "content", "edit", "form", "blog", "0", "edit"),
"/admin/inquiries" => ("admin/inquiries", "customer-request", "load", "list", "inquiry", "", "list"),
"/admin/inquiries/create" => ("admin/inquiries/create", "customer-request", "create", "form", "inquiry", "", "create"),
"/admin/settings" => ("admin/settings", "system", "load", "settings", "site_setting", "", "settings"),
"/admin/announcements" => ("admin/announcements", "content", "load", "list", "announcement", "", "list"),
"/admin/announcements/create" => ("admin/announcements/create", "content", "create", "form", "announcement", "", "create"),
"/admin/companies" => ("admin/companies", "company", "load", "list", "company", "", "list"),
"/admin/faqs" => ("admin/faqs", "faq", "load", "list", "faq", "", "list"),
"/admin/tax-profiles" => ("admin/tax-profiles", "tax-profile", "load", "list", "tax_profile", "", "list"),
"/admin/tax-filing-schedules" => ("admin/tax-filing-schedules", "schedule", "load", "list", "tax_filing_schedule", "", "list"),
"/admin/contracts" => ("admin/contracts", "crm", "load", "list", "contract", "", "list"),
"/admin/consulting-activities" => ("admin/consulting-activities", "crm", "load", "list", "consulting_activity", "", "list"),
"/admin/revenue-trackings" => ("admin/revenue-trackings", "crm", "load", "list", "revenue_tracking", "", "list"),
"/admin/clients" => ("admin/clients", "customer", "load", "list", "client", "", "list"),
"/admin/tax-filings" => ("admin/tax-filings", "tax-filing", "load", "list", "tax_filing", "", "list"),
"/admin/season-simulator" => ("admin/season-simulator", "schedule", "load", "simulator", "season", "", "simulator"),
_ => ResolveDynamicContext(route)
};
private static (string Screen, string Feature, string Action, string Step, string Entity, string EntityId, string DataKey) ResolveDynamicContext(string route)
{
var normalized = route.ToLowerInvariant().TrimEnd('/');
foreach (var pattern in new[]
{
("/admin/blog/", "admin/blog/edit", "content", "edit", "form", "blog", "edit"),
("/admin/announcements/", "admin/announcements/edit", "content", "edit", "form", "announcement", "edit"),
("/admin/inquiries/", "admin/inquiries/edit", "customer-request", "edit", "form", "inquiry", "edit"),
("/admin/clients/", "admin/clients/detail", "customer", "view", "detail", "client", "detail"),
("/admin/companies/", "admin/companies/edit", "company", "edit", "form", "company", "edit"),
("/admin/faqs/", "admin/faqs/edit", "faq", "edit", "form", "faq", "edit"),
("/admin/tax-profiles/", "admin/tax-profiles/edit", "tax-profile", "edit", "form", "tax_profile", "edit"),
("/admin/tax-filing-schedules/", "admin/tax-filing-schedules/edit", "schedule", "edit", "form", "tax_filing_schedule", "edit"),
})
{
if (!normalized.StartsWith(pattern.Item1, StringComparison.OrdinalIgnoreCase))
continue;
var remainder = normalized[pattern.Item1.Length..].Trim('/');
var id = ExtractLeadingId(remainder);
if (string.IsNullOrWhiteSpace(id))
id = remainder.Split('/', StringSplitOptions.RemoveEmptyEntries).FirstOrDefault() ?? "";
return (pattern.Item2, pattern.Item3, pattern.Item4, pattern.Item5, pattern.Item6, id, pattern.Item7);
}
return (route.Trim('/'), "admin", "load", "view", "admin", "", route.Trim('/'));
}
private static string ExtractLeadingId(string value)
{
var match = Regex.Match(value, @"^\d+");
return match.Success ? match.Value : "";
}
}
@@ -12,6 +12,7 @@
@using TaxBaik.Application.Services
@using TaxBaik.Application.Utils
@using TaxBaik.Domain.Entities
@using TaxBaik.WasmClient.Components.Admin.Forms
@using TaxBaik.Web.Services
@using TaxBaik.Web.Services.AdminClients
@using TaxBaik.WasmClient.Components.Admin.Shared
@@ -7,18 +7,15 @@ 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;
@@ -30,24 +27,6 @@ public class CustomAuthenticationStateProvider : AuthenticationStateProvider
{
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 (TryNormalizeExpiryTicks(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()));
@@ -122,11 +101,6 @@ public class CustomAuthenticationStateProvider : AuthenticationStateProvider
_tokenStore.RefreshToken = refreshToken;
_tokenStore.TokenExpiryTicks = tokenExpiryTicks;
// localStorage에도 저장 (페이지 리로드 후 복원)
await _localStorage.SetItemAsStringAsync("accessToken", accessToken);
await _localStorage.SetItemAsStringAsync("refreshToken", refreshToken);
await _localStorage.SetItemAsStringAsync("tokenExpiry", tokenExpiryTicks.ToString());
// Blazor에 인증 상태 변경을 알림 - 이 호출 자체는 async이지만 fire-and-forget OK
// (NotifyAuthenticationStateChanged는 내부적으로 Task를 구독함)
NotifyAuthenticationStateChanged(GetAuthenticationStateAsync());
@@ -180,11 +154,6 @@ public class CustomAuthenticationStateProvider : AuthenticationStateProvider
// TokenStore 초기화
_tokenStore.Clear();
// localStorage 초기화
await _localStorage.RemoveItemAsync("accessToken");
await _localStorage.RemoveItemAsync("refreshToken");
await _localStorage.RemoveItemAsync("tokenExpiry");
NotifyAuthenticationStateChanged(GetAuthenticationStateAsync());
}