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
@@ -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; }
}
}