feat: Migrate admin UI from Blazor WASM/MudBlazor to Razor Pages/Cookie Auth/Tabler

- Remove QuantEngine.Web.Client from .sln (keep on disk for reference)
- Replace Blazor Interactive WebAssembly with server-rendered Razor Pages
- Implement Cookie Authentication (HttpOnly, SameSite=Lax, 12h expiry)
- Add AuthService with BCrypt password hashing + auto-migration from SHA-256
- Implement IpLockoutService (3 strikes → 15-min ban)
- Create Admin folder structure with Layout + shared partials
- Implement Dashboard, Collection, Users index pages (base structure)
- Remove hardcoded backdoors (master_recovery, dev auth bypass)
- Remove hardcoded localhost:5265 URLs
- Add Tabler UI base styling (Bootstrap 5 CDN + custom admin.css)
- Update CLAUDE.md with new UI standards and auth policies
- Build: 0 errors, 0 warnings (ready for dev testing)

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-07-11 16:57:41 +09:00
parent 3ec0941f50
commit c57ad182b0
32 changed files with 1097 additions and 1135 deletions
@@ -0,0 +1,89 @@
@page
@model QuantEngine.Web.Pages.Admin.Collection.IndexModel
@{
ViewData["Title"] = "데이터 수집";
}
<div class="page-header d-print-none">
<div class="row align-items-center">
<div class="col">
<h2 class="page-title">데이터 수집</h2>
</div>
<div class="col-auto">
<a href="/Admin/Collection/Start" class="btn btn-primary">수집 시작</a>
</div>
</div>
</div>
<div class="page-body">
@if (!string.IsNullOrEmpty(Model.Message))
{
<div class="alert alert-info alert-dismissible fade show" role="alert">
@Model.Message
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
}
<div class="row row-deck row-cards">
<div class="col-12">
<div class="card">
<div class="card-header">
<h3 class="card-title">수집 현황</h3>
</div>
<div class="table-responsive">
<table class="table card-table table-vcenter">
<thead>
<tr>
<th>실행 ID</th>
<th>시작 시간</th>
<th>종료 시간</th>
<th>상태</th>
<th>스냅샷 수</th>
<th>오류 수</th>
</tr>
</thead>
<tbody>
@if (Model.Runs?.Any() == true)
{
@foreach (var run in Model.Runs)
{
<tr>
<td>@run.RunId</td>
<td>@run.StartedAt</td>
<td>@(run.FinishedAt ?? "-")</td>
<td>
@if (string.Equals(run.Status, "completed", StringComparison.OrdinalIgnoreCase))
{
<span class="badge bg-success">완료</span>
}
else if (string.Equals(run.Status, "running", StringComparison.OrdinalIgnoreCase))
{
<span class="badge bg-warning">진행 중</span>
}
else if (string.Equals(run.Status, "failed", StringComparison.OrdinalIgnoreCase))
{
<span class="badge bg-danger">실패</span>
}
else
{
<span class="badge bg-secondary">@run.Status</span>
}
</td>
<td>@(run.TotalSnapshots?.ToString() ?? "-")</td>
<td>@(run.TotalErrors?.ToString() ?? "-")</td>
</tr>
}
}
else
{
<tr>
<td colspan="5" class="text-center text-muted">데이터가 없습니다</td>
</tr>
}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
@@ -0,0 +1,35 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc.RazorPages;
using QuantEngine.Core.Interfaces;
using QuantEngine.Web.Services;
namespace QuantEngine.Web.Pages.Admin.Collection;
[Authorize(AuthenticationSchemes = AdminAuthDefaults.Scheme)]
public class IndexModel : PageModel
{
private readonly ICollectionRepository _collectionRepository;
private readonly ILogger<IndexModel> _logger;
public List<CollectionRunRecord>? Runs { get; set; }
public string? Message { get; set; }
public IndexModel(ICollectionRepository collectionRepository, ILogger<IndexModel> logger)
{
_collectionRepository = collectionRepository;
_logger = logger;
}
public async Task OnGetAsync()
{
try
{
Runs = await _collectionRepository.GetRecentRunsAsync(limit: 20);
}
catch (Exception ex)
{
_logger.LogError(ex, "Collection runs loading failed");
Message = "데이터 수집 현황을 불러올 수 없습니다.";
}
}
}
@@ -0,0 +1,66 @@
@page
@model QuantEngine.Web.Pages.Admin.Dashboard.IndexModel
@{
ViewData["Title"] = "대시보드";
}
<div class="page-header d-print-none">
<div class="row align-items-center">
<div class="col">
<h2 class="page-title">대시보드</h2>
</div>
</div>
</div>
<div class="page-body">
<div class="row row-deck row-cards">
<div class="col-md-6">
<div class="card">
<div class="card-body">
<div class="text-truncate">
<h3 class="card-title">활성 사용자</h3>
<div class="h2 mt-3">@(Model.ActiveUsersCount ?? 0)</div>
</div>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card">
<div class="card-body">
<div class="text-truncate">
<h3 class="card-title">최근 수집 실행</h3>
<div class="h2 mt-3">@(Model.RecentRunsCount ?? 0)</div>
</div>
</div>
</div>
</div>
</div>
<div class="row row-deck row-cards mt-4">
<div class="col-12">
<div class="card">
<div class="card-header">
<h3 class="card-title">최근 시스템 이벤트</h3>
</div>
<div class="table-responsive">
<table class="table card-table table-vcenter">
<thead>
<tr>
<th>시간</th>
<th>이벤트</th>
<th>상태</th>
</tr>
</thead>
<tbody>
<tr>
<td>@DateTime.UtcNow.ToString("yyyy-MM-dd HH:mm")</td>
<td>시스템 초기화</td>
<td><span class="badge bg-success">완료</span></td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
@@ -0,0 +1,40 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc.RazorPages;
using QuantEngine.Core.Interfaces;
using QuantEngine.Web.Services;
namespace QuantEngine.Web.Pages.Admin.Dashboard;
[Authorize(AuthenticationSchemes = AdminAuthDefaults.Scheme)]
public class IndexModel : PageModel
{
private readonly IWorkspaceRepository _workspaceRepository;
private readonly ICollectionRepository _collectionRepository;
private readonly ILogger<IndexModel> _logger;
public int? ActiveUsersCount { get; set; }
public int? RecentRunsCount { get; set; }
public IndexModel(IWorkspaceRepository workspaceRepository, ICollectionRepository collectionRepository, ILogger<IndexModel> logger)
{
_workspaceRepository = workspaceRepository;
_collectionRepository = collectionRepository;
_logger = logger;
}
public async Task OnGetAsync()
{
try
{
var accounts = await _workspaceRepository.GetAccountsAsync();
ActiveUsersCount = accounts.Count(a => string.Equals(a.IsActive, "true", StringComparison.OrdinalIgnoreCase));
var dashboard = await _collectionRepository.GetDashboardStateAsync();
RecentRunsCount = string.IsNullOrEmpty(dashboard?.LastRunId) ? 0 : 1;
}
catch (Exception ex)
{
_logger.LogError(ex, "Dashboard data loading failed");
}
}
}
@@ -0,0 +1,73 @@
@page
@model QuantEngine.Web.Pages.Admin.Users.IndexModel
@{
ViewData["Title"] = "사용자 관리";
}
<div class="page-header d-print-none">
<div class="row align-items-center">
<div class="col">
<h2 class="page-title">사용자 관리</h2>
</div>
<div class="col-auto">
<a href="/Admin/Users/Create" class="btn btn-primary">새 사용자 추가</a>
</div>
</div>
</div>
<div class="page-body">
<div class="row row-deck row-cards">
<div class="col-12">
<div class="card">
<div class="card-header">
<h3 class="card-title">사용자 목록</h3>
</div>
<div class="table-responsive">
<table class="table card-table table-vcenter">
<thead>
<tr>
<th>사용자명</th>
<th>역할</th>
<th>상태</th>
<th>생성일</th>
<th>작업</th>
</tr>
</thead>
<tbody>
@if (Model.Users?.Any() == true)
{
@foreach (var user in Model.Users)
{
<tr>
<td>@user.Username</td>
<td>@(user.Role ?? "Admin")</td>
<td>
@if (string.Equals(user.IsActive, "true", StringComparison.OrdinalIgnoreCase))
{
<span class="badge bg-success">활성</span>
}
else
{
<span class="badge bg-secondary">비활성</span>
}
</td>
<td>@(user.CreatedAt ?? "-")</td>
<td>
<a href="/Admin/Users/@user.Username/Edit" class="btn btn-sm btn-link">수정</a>
</td>
</tr>
}
}
else
{
<tr>
<td colspan="5" class="text-center text-muted">사용자가 없습니다</td>
</tr>
}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
@@ -0,0 +1,35 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc.RazorPages;
using QuantEngine.Core.Interfaces;
using QuantEngine.Core.Models;
using QuantEngine.Web.Services;
namespace QuantEngine.Web.Pages.Admin.Users;
[Authorize(AuthenticationSchemes = AdminAuthDefaults.Scheme)]
public class IndexModel : PageModel
{
private readonly IWorkspaceRepository _workspaceRepository;
private readonly ILogger<IndexModel> _logger;
public List<WorkspaceAccount>? Users { get; set; }
public IndexModel(IWorkspaceRepository workspaceRepository, ILogger<IndexModel> logger)
{
_workspaceRepository = workspaceRepository;
_logger = logger;
}
public async Task OnGetAsync()
{
try
{
var accounts = await _workspaceRepository.GetAccountsAsync();
Users = accounts.ToList();
}
catch (Exception ex)
{
_logger.LogError(ex, "Users loading failed");
}
}
}
@@ -0,0 +1,83 @@
@{
Layout = null;
}
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>@ViewData["Title"] - QuantEngine 관리자</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet" />
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" />
<link rel="stylesheet" href="~/css/admin.css" asp-append-version="true" />
</head>
<body>
<div class="page">
<!-- Topbar -->
<header class="navbar navbar-expand-md navbar-light d-print-none sticky-top">
<div class="container-xl">
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbar-menu">
<span class="navbar-toggler-icon"></span>
</button>
<h1 class="navbar-brand navbar-brand-autodark d-none-navbar-horizontal pe-0 pe-md-3">
<a href="/Admin/Dashboard">
<span style="font-size: 24px; font-weight: bold; color: #3f51b5;">Q</span>
</a>
</h1>
<div class="navbar-nav flex-row order-md-last">
<div class="nav-item d-none d-md-flex me-3">
<a href="/Account/Logout" class="btn btn-outline-danger">로그아웃</a>
</div>
</div>
</div>
</header>
<!-- Sidebar -->
<aside class="navbar navbar-vertical navbar-expand-lg navbar-dark bg-dark">
<div class="container-fluid">
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbar-menu">
<span class="navbar-toggler-icon"></span>
</button>
<h2 class="navbar-brand navbar-brand-autodark">
<a href="/Admin/Dashboard" class="text-white">
QuantEngine
</a>
</h2>
<div class="collapse navbar-collapse" id="navbar-menu">
<ul class="navbar-nav pt-lg-3">
<li class="nav-item">
<a class="nav-link text-white" href="/Admin/Dashboard">
<i class="bi bi-diagram-3 me-2"></i>
<span>대시보드</span>
</a>
</li>
<li class="nav-item">
<a class="nav-link text-white" href="/Admin/Collection">
<i class="bi bi-collection me-2"></i>
<span>데이터 수집</span>
</a>
</li>
<li class="nav-item">
<a class="nav-link text-white" href="/Admin/Users">
<i class="bi bi-people me-2"></i>
<span>사용자 관리</span>
</a>
</li>
</ul>
</div>
</div>
</aside>
<!-- Page Content -->
<div class="page-wrapper">
<div class="container-xl">
@RenderBody()
</div>
</div>
</div>
<!-- Bootstrap JS -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>
@@ -0,0 +1,3 @@
@{
Layout = "_AdminLayout";
}