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:
@@ -0,0 +1,15 @@
|
||||
@page
|
||||
@{
|
||||
ViewData["Title"] = "접근 거부";
|
||||
}
|
||||
|
||||
<div class="container mt-5">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-6 text-center">
|
||||
<h1 class="display-1">403</h1>
|
||||
<h2>접근이 거부되었습니다</h2>
|
||||
<p class="text-muted">이 페이지에 접근할 권한이 없습니다.</p>
|
||||
<a href="/Account/Login" class="btn btn-primary">로그인 페이지로 이동</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,169 +1,101 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||
using QuantEngine.Core.Interfaces;
|
||||
using QuantEngine.Core.Models;
|
||||
using System.Security.Claims;
|
||||
using QuantEngine.Web.Services;
|
||||
|
||||
namespace QuantEngine.Web.Pages.Account
|
||||
namespace QuantEngine.Web.Pages.Account;
|
||||
|
||||
[AllowAnonymous]
|
||||
public class LoginModel : PageModel
|
||||
{
|
||||
[AllowAnonymous]
|
||||
public class LoginModel : PageModel
|
||||
private readonly AuthService _authService;
|
||||
private readonly IIpLockoutService _lockoutService;
|
||||
private readonly ILogger<LoginModel> _logger;
|
||||
|
||||
public string? Username { get; set; }
|
||||
public bool RememberUsername { get; set; }
|
||||
public string? ErrorMessage { get; set; }
|
||||
|
||||
public LoginModel(AuthService authService, IIpLockoutService lockoutService, ILogger<LoginModel> logger)
|
||||
{
|
||||
private readonly IWorkspaceRepository _workspaceRepo;
|
||||
private readonly ILogger<LoginModel> _logger;
|
||||
_authService = authService;
|
||||
_lockoutService = lockoutService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public string? Username { get; set; }
|
||||
public bool RememberUsername { get; set; }
|
||||
public string? ErrorMessage { get; set; }
|
||||
|
||||
public LoginModel(IWorkspaceRepository workspaceRepo, ILogger<LoginModel> logger)
|
||||
public void OnGet()
|
||||
{
|
||||
if (Request.Cookies.TryGetValue("quant_admin_username", out var savedUsername))
|
||||
{
|
||||
_workspaceRepo = workspaceRepo;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public void OnGet()
|
||||
{
|
||||
if (Request.Cookies.TryGetValue("quant_admin_username", out var savedUsername))
|
||||
{
|
||||
Username = savedUsername;
|
||||
RememberUsername = true;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<IActionResult> OnPostAsync(string username, string password, bool rememberUsername)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
|
||||
{
|
||||
ErrorMessage = "아이디와 비밀번호를 모두 입력해 주세요.";
|
||||
Username = username;
|
||||
RememberUsername = rememberUsername;
|
||||
return Page();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Direct repository call — no internal HTTP round-trip.
|
||||
// Using IWorkspaceRepository injected via DI avoids any port/proxy dependency.
|
||||
WorkspaceAccount? account = null;
|
||||
try
|
||||
{
|
||||
account = await _workspaceRepo.GetAccountByUsernameAsync(username.Trim());
|
||||
}
|
||||
catch (Exception dbEx)
|
||||
{
|
||||
_logger.LogError(dbEx, "[Login] Database lookup failed for user '{Username}'", username);
|
||||
|
||||
if (string.Equals(username, "admin", StringComparison.OrdinalIgnoreCase) && string.Equals(password, "admin"))
|
||||
{
|
||||
var devToken = Guid.NewGuid().ToString("N");
|
||||
var devExpiresAt = DateTimeOffset.UtcNow.AddDays(7);
|
||||
Response.Cookies.Append(
|
||||
"quant_auth_token",
|
||||
devToken,
|
||||
new Microsoft.AspNetCore.Http.CookieOptions
|
||||
{
|
||||
HttpOnly = true,
|
||||
Secure = false,
|
||||
SameSite = Microsoft.AspNetCore.Http.SameSiteMode.Lax,
|
||||
Expires = devExpiresAt,
|
||||
Path = "/"
|
||||
}
|
||||
);
|
||||
_logger.LogInformation("[Login] Dev Database fallback authentication successful for admin");
|
||||
return Redirect("/");
|
||||
}
|
||||
|
||||
ErrorMessage = "데이터베이스 연결 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.";
|
||||
Username = username;
|
||||
RememberUsername = rememberUsername;
|
||||
return Page();
|
||||
}
|
||||
|
||||
if (account is null || !string.Equals(account.IsActive, "true", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
ErrorMessage = "로그인 실패: 아이디 또는 비밀번호가 올바르지 않습니다.";
|
||||
Username = username;
|
||||
RememberUsername = rememberUsername;
|
||||
return Page();
|
||||
}
|
||||
|
||||
var passwordHash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(password)));
|
||||
if (!string.Equals(account.PasswordHash, passwordHash, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
ErrorMessage = "로그인 실패: 아이디 또는 비밀번호가 올바르지 않습니다.";
|
||||
Username = username;
|
||||
RememberUsername = rememberUsername;
|
||||
return Page();
|
||||
}
|
||||
|
||||
// Issue session token
|
||||
var rawToken = Guid.NewGuid().ToString("N");
|
||||
var tokenHash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(rawToken)));
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var expiresAt = now.AddDays(7);
|
||||
|
||||
await _workspaceRepo.UpsertSessionAsync(new WorkspaceSession
|
||||
{
|
||||
SessionTokenHash = tokenHash,
|
||||
Username = account.Username,
|
||||
Role = account.Role,
|
||||
CreatedAt = now.ToString("O"),
|
||||
ExpiresAt = expiresAt.ToString("O"),
|
||||
RevokedAt = null
|
||||
});
|
||||
|
||||
// Set HTTP-only auth cookie (Secure=true since production is always HTTPS via Cloudflare)
|
||||
Response.Cookies.Append(
|
||||
"quant_auth_token",
|
||||
rawToken,
|
||||
new Microsoft.AspNetCore.Http.CookieOptions
|
||||
{
|
||||
HttpOnly = true,
|
||||
Secure = true,
|
||||
SameSite = Microsoft.AspNetCore.Http.SameSiteMode.Lax,
|
||||
Expires = expiresAt,
|
||||
Path = "/"
|
||||
}
|
||||
);
|
||||
|
||||
if (rememberUsername)
|
||||
{
|
||||
Response.Cookies.Append(
|
||||
"quant_admin_username",
|
||||
username,
|
||||
new Microsoft.AspNetCore.Http.CookieOptions
|
||||
{
|
||||
Expires = DateTimeOffset.UtcNow.AddDays(30),
|
||||
HttpOnly = false,
|
||||
SameSite = Microsoft.AspNetCore.Http.SameSiteMode.Strict
|
||||
}
|
||||
);
|
||||
}
|
||||
else
|
||||
{
|
||||
Response.Cookies.Delete("quant_admin_username");
|
||||
}
|
||||
|
||||
_logger.LogInformation("[Login] User '{Username}' authenticated successfully", account.Username);
|
||||
return Redirect("/");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "로그인 중 오류 발생");
|
||||
ErrorMessage = $"오류 발생: {ex.Message}";
|
||||
Username = username;
|
||||
RememberUsername = rememberUsername;
|
||||
return Page();
|
||||
}
|
||||
}
|
||||
|
||||
public IActionResult OnGetLogout()
|
||||
{
|
||||
Response.Cookies.Delete("quant_auth_token");
|
||||
return Redirect("/Account/Login");
|
||||
Username = savedUsername;
|
||||
RememberUsername = true;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<IActionResult> OnPostAsync(string username, string password, bool rememberUsername)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
|
||||
{
|
||||
ErrorMessage = "아이디와 비밀번호를 모두 입력해 주세요.";
|
||||
Username = username;
|
||||
RememberUsername = rememberUsername;
|
||||
return Page();
|
||||
}
|
||||
|
||||
var ipAddress = HttpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown";
|
||||
|
||||
var account = await _authService.AuthenticateAsync(username, password, ipAddress);
|
||||
if (account is null)
|
||||
{
|
||||
if (_lockoutService.IsLockedOut(ipAddress))
|
||||
ErrorMessage = "로그인 시도 횟수를 초과했습니다. 15분 후에 다시 시도해 주세요.";
|
||||
else
|
||||
ErrorMessage = "아이디 또는 비밀번호가 올바르지 않습니다.";
|
||||
|
||||
Username = username;
|
||||
RememberUsername = rememberUsername;
|
||||
return Page();
|
||||
}
|
||||
|
||||
var claims = new List<Claim>
|
||||
{
|
||||
new(ClaimTypes.NameIdentifier, account.Username),
|
||||
new(ClaimTypes.Name, account.Username),
|
||||
new(ClaimTypes.Role, account.Role ?? "Admin")
|
||||
};
|
||||
|
||||
var identity = new ClaimsIdentity(claims, AdminAuthDefaults.Scheme);
|
||||
var principal = new ClaimsPrincipal(identity);
|
||||
|
||||
var properties = new Microsoft.AspNetCore.Authentication.AuthenticationProperties
|
||||
{
|
||||
IsPersistent = true,
|
||||
ExpiresUtc = DateTimeOffset.UtcNow.AddHours(12)
|
||||
};
|
||||
|
||||
await HttpContext.SignInAsync(AdminAuthDefaults.Scheme, principal, properties);
|
||||
|
||||
if (rememberUsername)
|
||||
{
|
||||
Response.Cookies.Append(
|
||||
"quant_admin_username",
|
||||
username,
|
||||
new Microsoft.AspNetCore.Http.CookieOptions
|
||||
{
|
||||
Expires = DateTimeOffset.UtcNow.AddDays(30),
|
||||
HttpOnly = false,
|
||||
SameSite = Microsoft.AspNetCore.Http.SameSiteMode.Strict
|
||||
}
|
||||
);
|
||||
}
|
||||
else
|
||||
{
|
||||
Response.Cookies.Delete("quant_admin_username");
|
||||
}
|
||||
|
||||
_logger.LogInformation("[Login] User '{Username}' authenticated successfully from {IpAddress}", account.Username, ipAddress);
|
||||
return LocalRedirect("/Admin/Dashboard");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
@page
|
||||
@model QuantEngine.Web.Pages.Account.LogoutModel
|
||||
@{
|
||||
ViewData["Title"] = "로그아웃";
|
||||
}
|
||||
|
||||
<div class="container mt-5">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-6">
|
||||
<h1>로그아웃</h1>
|
||||
<p>로그아웃 중...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,17 @@
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||
using QuantEngine.Web.Services;
|
||||
|
||||
namespace QuantEngine.Web.Pages.Account;
|
||||
|
||||
[Authorize(AuthenticationSchemes = AdminAuthDefaults.Scheme)]
|
||||
public class LogoutModel : PageModel
|
||||
{
|
||||
public async Task<IActionResult> OnGetAsync()
|
||||
{
|
||||
await HttpContext.SignOutAsync(AdminAuthDefaults.Scheme);
|
||||
return RedirectToPage("/Account/Login");
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
@model string
|
||||
|
||||
<div class="empty">
|
||||
<div class="empty-header">🔍</div>
|
||||
<p class="empty-title">데이터가 없습니다</p>
|
||||
<p class="empty-subtitle">
|
||||
@(string.IsNullOrWhiteSpace(Model) ? "조건에 맞는 데이터가 없습니다." : Model)
|
||||
</p>
|
||||
</div>
|
||||
@@ -0,0 +1,64 @@
|
||||
@using QuantEngine.Web.Models
|
||||
|
||||
@model PaginationModel
|
||||
|
||||
@if (Model.TotalPages > 1)
|
||||
{
|
||||
<nav aria-label="Page navigation">
|
||||
<ul class="pagination">
|
||||
@if (Model.Page > 1)
|
||||
{
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="@Model.BuildPageUrl(1)">첫 페이지</a>
|
||||
</li>
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="@Model.BuildPageUrl(Model.Page - 1)">이전</a>
|
||||
</li>
|
||||
}
|
||||
else
|
||||
{
|
||||
<li class="page-item disabled">
|
||||
<span class="page-link">첫 페이지</span>
|
||||
</li>
|
||||
<li class="page-item disabled">
|
||||
<span class="page-link">이전</span>
|
||||
</li>
|
||||
}
|
||||
|
||||
@for (int i = Math.Max(1, Model.Page - 2); i <= Math.Min(Model.TotalPages, Model.Page + 2); i++)
|
||||
{
|
||||
if (i == Model.Page)
|
||||
{
|
||||
<li class="page-item active">
|
||||
<span class="page-link">@i</span>
|
||||
</li>
|
||||
}
|
||||
else
|
||||
{
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="@Model.BuildPageUrl(i)">@i</a>
|
||||
</li>
|
||||
}
|
||||
}
|
||||
|
||||
@if (Model.Page < Model.TotalPages)
|
||||
{
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="@Model.BuildPageUrl(Model.Page + 1)">다음</a>
|
||||
</li>
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="@Model.BuildPageUrl(Model.TotalPages)">마지막 페이지</a>
|
||||
</li>
|
||||
}
|
||||
else
|
||||
{
|
||||
<li class="page-item disabled">
|
||||
<span class="page-link">다음</span>
|
||||
</li>
|
||||
<li class="page-item disabled">
|
||||
<span class="page-link">마지막 페이지</span>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</nav>
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
@model string
|
||||
|
||||
@{
|
||||
var badgeClass = Model?.ToLowerInvariant() switch
|
||||
{
|
||||
"active" or "success" => "badge bg-success",
|
||||
"inactive" or "disabled" => "badge bg-secondary",
|
||||
"pending" or "processing" => "badge bg-warning",
|
||||
"error" or "failed" => "badge bg-danger",
|
||||
_ => "badge bg-info"
|
||||
};
|
||||
}
|
||||
|
||||
<span class="@badgeClass">@Model</span>
|
||||
@@ -0,0 +1,16 @@
|
||||
@if (!ViewData.ModelState.IsValid)
|
||||
{
|
||||
<div class="alert alert-danger alert-dismissible fade show" role="alert">
|
||||
<h4 class="alert-title">검증 오류</h4>
|
||||
<div>
|
||||
@foreach (var modelState in ViewData.ModelState.Values)
|
||||
{
|
||||
foreach (var error in modelState.Errors)
|
||||
{
|
||||
<div>@error.ErrorMessage</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
</div>
|
||||
}
|
||||
Reference in New Issue
Block a user