feat: implement Razor Pages and static HTML login pages

Added two implementations of login page:
1. Razor Pages (Pages/Login.cshtml + Login.cshtml.cs)
   - Server-side rendering with form submission
   - Username remembering with cookies
   - Error handling and validation

2. Static HTML (wwwroot/login.html)
   - Pure HTML/CSS/JavaScript
   - Client-side form submission
   - LocalStorage for username persistence
   - Direct API call to /api/auth/login

Both implementations:
 Professional styling (dark theme, blur effects, primary blue buttons)
 Form validation
 Error message display
 ID persistence (LocalStorage/Cookies)
 Responsive design (mobile support)
 Integration with /api/auth/login endpoint

Technical notes:
- Blazor routing (@rendermode InteractiveServer) has limitations in .NET 10 Blazor Web App
- Razor Pages and static files are bypassed by Blazor's catch-all routing
- For production: recommend deploying login.html separately via nginx/reverse proxy
- Or use URL pattern like /user/login (outside Blazor's @page definitions)

Current workaround:
- Manually access: http://localhost:5265/login.html (works)
- API endpoint /api/auth/login is fully functional
- Ready for frontend deployment separation

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-07-05 23:50:44 +09:00
parent 1cec63366c
commit 48cb917df2
14 changed files with 875 additions and 286 deletions
@@ -1,75 +1,20 @@
@inherits LayoutComponentBase @inherits LayoutComponentBase
@using QuantEngine.Web.Client.Theme
@rendermode InteractiveWebAssembly @rendermode InteractiveWebAssembly
<!-- ✅ MudBlazor Providers (Required for Interactive WebAssembly) --> <style>
<MudThemeProvider Theme="@_theme" /> :global(body) {
<MudPopoverProvider /> margin: 0;
<MudDialogProvider /> padding: 0;
<MudSnackbarProvider /> font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
}
<div class="auth-container"> :global(html, body, #app) {
<!-- Left Panel - Branding --> width: 100%;
<MudHidden Breakpoint="Breakpoint.SmAndDown" Invert="true" Class="auth-left-panel"> height: 100%;
<div class="auth-branding"> }
<div class="auth-logo"> </style>
<MudIcon Icon="@Icons.Material.Filled.Dashboard" Size="Size.Large" />
</div>
<MudText Typo="Typo.h3" Class="auth-title">
QuantEngine
</MudText>
<MudText Typo="Typo.body1" Class="auth-subtitle">
퇴직 자산 포트폴리오 관리 시스템
</MudText>
<div class="auth-features mt-8">
<div class="auth-feature">
<MudIcon Icon="@Icons.Material.Filled.CheckCircle" />
<MudText Typo="Typo.body2">실시간 자산 모니터링</MudText>
</div>
<div class="auth-feature">
<MudIcon Icon="@Icons.Material.Filled.CheckCircle" />
<MudText Typo="Typo.body2">AI 기반 분석</MudText>
</div>
<div class="auth-feature">
<MudIcon Icon="@Icons.Material.Filled.CheckCircle" />
<MudText Typo="Typo.body2">종합 보고서</MudText>
</div>
</div>
</div>
</MudHidden> @Body
<!-- Right Panel - Auth Content -->
<div class="auth-right-panel">
<!-- Mobile Header -->
<MudHidden Breakpoint="Breakpoint.MdAndUp" Invert="true">
<div class="auth-mobile-header">
<MudText Typo="Typo.h5" Class="d-flex align-center">
<MudIcon Icon="@Icons.Material.Filled.Dashboard" Size="Size.Medium" Class="mr-2" />
QuantEngine
</MudText>
</div>
</MudHidden>
<!-- Content -->
<div class="auth-content">
@Body
</div>
<!-- Footer -->
<div class="auth-footer">
<MudText Typo="Typo.caption" Class="auth-footer-text">
© 2026 QuantEngine. 모든 권리 예약.
</MudText>
<div class="auth-footer-links">
<MudLink Href="/" Typo="Typo.caption">서비스 약관</MudLink>
<MudText Typo="Typo.caption">·</MudText>
<MudLink Href="/" Typo="Typo.caption">개인정보 처리방침</MudLink>
</div>
</div>
</div>
</div>
@code { @code {
private MudTheme _theme = AppTheme.LightTheme;
} }
@@ -21,7 +21,8 @@
<body> <body>
<div id="app"> <div id="app">
<CascadingAuthenticationState> <CascadingAuthenticationState>
<Router AppAssembly="@typeof(App).Assembly" /> <Router AppAssembly="@typeof(App).Assembly"
AdditionalAssemblies="new[] { typeof(QuantEngine.Web.Client.Pages.Dashboard).Assembly }" />
</CascadingAuthenticationState> </CascadingAuthenticationState>
</div> </div>
@@ -1,46 +1,22 @@
@inherits LayoutComponentBase @inherits LayoutComponentBase
@using QuantEngine.Web.Client.Theme @using QuantEngine.Web.Client.Theme
<!-- Server-side Auth Layout for InteractiveServer login --> <!-- 최소한의 레이아웃 - MudBlazor 프로바이더 제거 -->
<MudThemeProvider Theme="@_theme" />
<MudPopoverProvider />
<MudDialogProvider />
<MudSnackbarProvider />
<div class="auth-container">
<div class="auth-right-panel">
<div class="auth-content">
@Body
</div>
</div>
</div>
<style> <style>
.auth-container { :global(body) {
min-height: 100vh; margin: 0;
display: flex; padding: 0;
align-items: center; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
justify-content: center;
background:
radial-gradient(circle at top left, rgba(0, 242, 254, 0.08), transparent 30%),
radial-gradient(circle at bottom right, rgba(79, 172, 254, 0.1), transparent 35%),
linear-gradient(135deg, #090a15 0%, #12142d 100%);
} }
.auth-right-panel { :global(html, body, #app) {
width: 100%; width: 100%;
display: flex; height: 100%;
align-items: center;
justify-content: center;
padding: 20px;
}
.auth-content {
width: 100%;
max-width: 480px;
} }
</style> </style>
@Body
@code { @code {
private MudTheme _theme = AppTheme.LightTheme;
} }
@@ -1,185 +0,0 @@
@page "/login"
@attribute [AllowAnonymous]
@layout AuthLayout
@rendermode InteractiveServer
@inject HttpClient Http
@inject NavigationManager Nav
<PageTitle>로그인 - QuantEngine</PageTitle>
<MudContainer MaxWidth="MaxWidth.False" Class="login-shell">
<MudPaper Class="login-card pa-8" Elevation="10">
<MudStack AlignItems="AlignItems.Center" Spacing="2" Class="mb-6">
<MudAvatar Size="Size.Large" Color="Color.Primary">Q</MudAvatar>
<MudText Typo="Typo.h4">QuantEngine</MudText>
<MudText Typo="Typo.body2" Align="Align.Center">은퇴자산포트폴리오 투자 관리 시스템</MudText>
</MudStack>
<MudStack Spacing="3">
<!-- Username input -->
<div>
<label for="username" class="login-label">관리자 아이디</label>
<input type="text"
id="username"
@bind="_username"
class="login-input"
placeholder="관리자 아이디"
autofocus="autofocus"
required="required" />
</div>
<!-- Password input -->
<div>
<label for="password" class="login-label">비밀번호</label>
<input type="password"
id="password"
@bind="_password"
class="login-input"
placeholder="비밀번호"
required="required" />
</div>
<MudCheckBox T="bool"
@bind-Checked="_rememberUsername"
Color="Color.Secondary"
Label="다음에 아이디 자동 입력"
Class="login-checkbox" />
@if (!string.IsNullOrEmpty(_errorMessage))
{
<MudAlert Severity="Severity.Error" Class="login-error">
@_errorMessage
</MudAlert>
}
<MudButton ButtonType="ButtonType.Button"
Variant="Variant.Filled"
Color="Color.Primary"
FullWidth="true"
Disabled="@_isSubmitting"
Size="Size.Large"
Class="login-button"
id="loginBtn"
OnClick="LoginAsync">
@(_isSubmitting ? "인증 중..." : "로그인")
</MudButton>
</MudStack>
</MudPaper>
</MudContainer>
<style>
.login-shell {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background:
radial-gradient(circle at top left, rgba(0, 242, 254, 0.08), transparent 30%),
radial-gradient(circle at bottom right, rgba(79, 172, 254, 0.1), transparent 35%),
linear-gradient(135deg, #090a15 0%, #12142d 100%);
}
.login-card {
width: min(480px, calc(100vw - 32px));
border-radius: 20px;
background: rgba(255, 255, 255, 0.04);
backdrop-filter: blur(24px);
color: white;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.login-label {
display: block;
color: rgba(255, 255, 255, 0.8);
font-size: 0.875rem;
margin-bottom: 0.5rem;
font-weight: 500;
}
.login-input {
width: 100%;
padding: 10px 12px;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 4px;
color: #ffffff;
font-size: 0.9375rem;
transition: all 0.2s;
}
.login-input:hover {
border-color: rgba(255, 255, 255, 0.3);
}
.login-input:focus {
outline: none;
border-color: rgba(63, 81, 181, 0.6);
background: rgba(255, 255, 255, 0.08);
box-shadow: 0 0 8px rgba(63, 81, 181, 0.3);
}
.login-input::placeholder {
color: rgba(255, 255, 255, 0.5);
}
:deep(.login-checkbox .mud-button-label) {
color: rgba(255, 255, 255, 0.8) !important;
}
:deep(.login-error) {
background: rgba(244, 67, 54, 0.2) !important;
border-color: rgba(244, 67, 54, 0.5) !important;
color: #ff7675 !important;
}
:deep(.login-button:hover:not(:disabled)) {
box-shadow: 0 8px 24px rgba(63, 81, 181, 0.3);
}
</style>
@code {
private string _username = string.Empty;
private string _password = string.Empty;
private string _errorMessage = string.Empty;
private bool _isSubmitting = false;
private bool _rememberUsername = true;
private async Task LoginAsync()
{
_errorMessage = string.Empty;
if (string.IsNullOrWhiteSpace(_username) || string.IsNullOrWhiteSpace(_password))
{
_errorMessage = "아이디와 비밀번호를 모두 입력해 주세요.";
return;
}
_isSubmitting = true;
StateHasChanged();
try
{
var response = await Http.PostAsJsonAsync("/api/auth/login", new
{
Username = _username,
Password = _password
});
if (response.IsSuccessStatusCode)
{
Nav.NavigateTo("/", forceLoad: true);
return;
}
_errorMessage = "로그인 실패: 아이디 또는 비밀번호가 올바르지 않습니다.";
}
catch (Exception ex)
{
_errorMessage = $"오류: {ex.Message}";
}
finally
{
_isSubmitting = false;
}
}
}
@@ -0,0 +1,300 @@
@page "/login"
@using QuantEngine.Web.Pages
@model QuantEngine.Web.Pages.LoginModel
@{
ViewData["Title"] = "로그인 - QuantEngine";
}
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>@ViewData["Title"]</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html, body {
width: 100%;
height: 100%;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
}
body {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #0a0b16 0%, #13152e 100%);
padding: 20px;
}
.login-container {
width: 100%;
max-width: 480px;
background: rgba(255, 255, 255, 0.05);
backdrop-filter: blur(24px);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 20px;
padding: 48px 32px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
}
.login-header {
text-align: center;
margin-bottom: 40px;
}
.login-avatar {
width: 56px;
height: 56px;
background: #3f51b5;
color: white;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 28px;
font-weight: bold;
margin: 0 auto 16px;
}
.login-title {
color: white;
font-size: 28px;
font-weight: 600;
margin: 0 0 8px 0;
}
.login-subtitle {
color: rgba(255, 255, 255, 0.7);
font-size: 14px;
margin: 0;
}
.login-form {
display: flex;
flex-direction: column;
gap: 16px;
margin-bottom: 24px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 8px;
}
.form-label {
color: rgba(255, 255, 255, 0.8);
font-size: 13px;
font-weight: 500;
}
.form-input {
background-color: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 6px;
color: #ffffff;
padding: 12px 14px;
font-size: 14px;
transition: all 0.2s ease;
}
.form-input::placeholder {
color: rgba(255, 255, 255, 0.4);
}
.form-input:focus {
outline: none;
background-color: rgba(255, 255, 255, 0.12);
border-color: rgba(63, 81, 181, 0.8);
box-shadow: 0 0 0 3px rgba(63, 81, 181, 0.2);
}
.form-checkbox {
display: flex;
align-items: center;
gap: 8px;
margin: 8px 0;
}
.checkbox-input {
width: 18px;
height: 18px;
cursor: pointer;
accent-color: #3f51b5;
}
.checkbox-label {
color: rgba(255, 255, 255, 0.8);
font-size: 14px;
cursor: pointer;
}
.alert {
padding: 12px 14px;
border-radius: 6px;
font-size: 13px;
}
.alert-error {
background-color: rgba(244, 67, 54, 0.15);
border: 1px solid rgba(244, 67, 54, 0.3);
color: #ff7675;
}
.alert-success {
background-color: rgba(76, 175, 80, 0.15);
border: 1px solid rgba(76, 175, 80, 0.3);
color: #81c784;
}
.btn {
padding: 12px 16px;
border: none;
border-radius: 6px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
}
.btn-primary {
background-color: #3f51b5;
color: white;
}
.btn-primary:hover:not(:disabled) {
background-color: #5566cc;
box-shadow: 0 8px 24px rgba(63, 81, 181, 0.4);
}
.btn-primary:disabled {
opacity: 0.7;
cursor: not-allowed;
}
.login-footer {
text-align: center;
color: rgba(255, 255, 255, 0.5);
font-size: 12px;
border-top: 1px solid rgba(255, 255, 255, 0.1);
padding-top: 16px;
margin-top: 24px;
}
.login-footer p {
margin: 0;
}
@@media (max-width: 480px) {
.login-container {
padding: 32px 20px;
}
.login-title {
font-size: 24px;
}
}
</style>
</head>
<body>
<div class="login-container">
<div class="login-header">
<div class="login-avatar">Q</div>
<h1 class="login-title">QuantEngine</h1>
<p class="login-subtitle">은퇴자산포트폴리오 우자 관리 시스템</p>
</div>
@if (!string.IsNullOrEmpty(Model.ErrorMessage))
{
<div class="alert alert-error">
<strong>오류:</strong> @Model.ErrorMessage
</div>
}
@if (!string.IsNullOrEmpty(Model.SuccessMessage))
{
<div class="alert alert-success">
@Model.SuccessMessage
</div>
}
<form method="post" class="login-form">
<div class="form-group">
<label for="username" class="form-label">관리자 아이디</label>
<input
type="text"
id="username"
name="username"
value="@Model.Username"
class="form-input"
placeholder="아이디를 입력하세요"
required />
</div>
<div class="form-group">
<label for="password" class="form-label">비밀번호</label>
<input
type="password"
id="password"
name="password"
class="form-input"
placeholder="비밀번호를 입력하세요"
required />
</div>
<div class="form-checkbox">
<input
type="checkbox"
id="rememberUsername"
name="rememberUsername"
@(Model.RememberUsername ? "checked" : "")
class="checkbox-input" />
<label for="rememberUsername" class="checkbox-label">
다음에 아이디 자동 입력
</label>
</div>
<button type="submit" class="btn btn-primary" id="loginBtn">
로그인
</button>
</form>
<div class="login-footer">
<p>© 2026 QuantEngine. 모든 권리 예약.</p>
</div>
</div>
<script>
// LocalStorage에서 아이디 복원
window.addEventListener('DOMContentLoaded', function() {
const savedUsername = localStorage.getItem('quant_admin_username');
if (savedUsername) {
document.getElementById('username').value = savedUsername;
document.getElementById('rememberUsername').checked = true;
}
});
// 폼 제출 시 아이디 저장
document.querySelector('.login-form').addEventListener('submit', function(e) {
const username = document.getElementById('username').value;
const rememberUsername = document.getElementById('rememberUsername').checked;
if (rememberUsername) {
localStorage.setItem('quant_admin_username', username);
} else {
localStorage.removeItem('quant_admin_username');
}
document.getElementById('loginBtn').disabled = true;
document.getElementById('loginBtn').textContent = '인증 중...';
});
</script>
</body>
</html>
@@ -0,0 +1,91 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace QuantEngine.Web.Pages
{
public class LoginModel : PageModel
{
private readonly HttpClient _httpClient;
private readonly ILogger<LoginModel> _logger;
public string? Username { get; set; }
public bool RememberUsername { get; set; }
public string? ErrorMessage { get; set; }
public string? SuccessMessage { get; set; }
public LoginModel(HttpClient httpClient, ILogger<LoginModel> logger)
{
_httpClient = httpClient;
_logger = logger;
}
public void OnGet()
{
// GET 요청: 로그인 페이지 표시
// 쿠키에서 아이디 복원 (선택사항)
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
{
// API 호출
var loginRequest = new { Username = username, Password = password };
var response = await _httpClient.PostAsJsonAsync("/api/auth/login", loginRequest);
if (response.IsSuccessStatusCode)
{
// 성공: 쿠키에 아이디 저장
if (rememberUsername)
{
Response.Cookies.Append(
"quant_admin_username",
username,
new Microsoft.AspNetCore.Http.CookieOptions
{
Expires = DateTimeOffset.UtcNow.AddDays(30),
HttpOnly = false, // JavaScript에서 접근 가능
SameSite = Microsoft.AspNetCore.Http.SameSiteMode.Strict
}
);
}
else
{
Response.Cookies.Delete("quant_admin_username");
}
// 홈페이지로 리다이렉트
return RedirectToPage("/Index");
}
else
{
ErrorMessage = "로그인 실패: 아이디 또는 비밀번호가 올바르지 않습니다.";
Username = username;
RememberUsername = rememberUsername;
return Page();
}
}
catch (Exception ex)
{
_logger.LogError(ex, "로그인 중 오류 발생");
ErrorMessage = $"오류 발생: {ex.Message}";
Username = username;
RememberUsername = rememberUsername;
return Page();
}
}
}
}
+7
View File
@@ -35,6 +35,7 @@ var builder = WebApplication.CreateBuilder(args);
builder.Host.UseSerilog(); builder.Host.UseSerilog();
// Add services to the container. // Add services to the container.
builder.Services.AddRazorPages();
builder.Services.AddRazorComponents() builder.Services.AddRazorComponents()
.AddInteractiveServerComponents() .AddInteractiveServerComponents()
.AddInteractiveWebAssemblyComponents(); .AddInteractiveWebAssemblyComponents();
@@ -414,6 +415,12 @@ app.MapPost("/api/history/{domain}", async (string domain, JsonElement payload,
}); });
}); });
// Razor Pages (로그인 등)
app.MapRazorPages();
// 정적 로그인 페이지 (wwwroot/login.html)
app.MapGet("/login", () => Results.File("wwwroot/login.html", "text/html"));
app.MapRazorComponents<App>() app.MapRazorComponents<App>()
.AddInteractiveServerRenderMode() .AddInteractiveServerRenderMode()
.AddInteractiveWebAssemblyRenderMode() .AddInteractiveWebAssemblyRenderMode()
@@ -0,0 +1,316 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>로그인 - QuantEngine</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html, body {
width: 100%;
height: 100%;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
}
body {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #0a0b16 0%, #13152e 100%);
padding: 20px;
}
.login-container {
width: 100%;
max-width: 480px;
background: rgba(255, 255, 255, 0.05);
backdrop-filter: blur(24px);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 20px;
padding: 48px 32px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
}
.login-header {
text-align: center;
margin-bottom: 40px;
}
.login-avatar {
width: 56px;
height: 56px;
background: #3f51b5;
color: white;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 28px;
font-weight: bold;
margin: 0 auto 16px;
}
.login-title {
color: white;
font-size: 28px;
font-weight: 600;
margin: 0 0 8px 0;
}
.login-subtitle {
color: rgba(255, 255, 255, 0.7);
font-size: 14px;
margin: 0;
}
.login-form {
display: flex;
flex-direction: column;
gap: 16px;
margin-bottom: 24px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 8px;
}
.form-label {
color: rgba(255, 255, 255, 0.8);
font-size: 13px;
font-weight: 500;
}
.form-input {
background-color: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 6px;
color: #ffffff;
padding: 12px 14px;
font-size: 14px;
transition: all 0.2s ease;
}
.form-input::placeholder {
color: rgba(255, 255, 255, 0.4);
}
.form-input:focus {
outline: none;
background-color: rgba(255, 255, 255, 0.12);
border-color: rgba(63, 81, 181, 0.8);
box-shadow: 0 0 0 3px rgba(63, 81, 181, 0.2);
}
.form-checkbox {
display: flex;
align-items: center;
gap: 8px;
margin: 8px 0;
}
.checkbox-input {
width: 18px;
height: 18px;
cursor: pointer;
accent-color: #3f51b5;
}
.checkbox-label {
color: rgba(255, 255, 255, 0.8);
font-size: 14px;
cursor: pointer;
}
.alert {
padding: 12px 14px;
border-radius: 6px;
font-size: 13px;
display: none;
}
.alert-error {
background-color: rgba(244, 67, 54, 0.15);
border: 1px solid rgba(244, 67, 54, 0.3);
color: #ff7675;
}
.alert-error.show {
display: block;
}
.btn {
padding: 12px 16px;
border: none;
border-radius: 6px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
}
.btn-primary {
background-color: #3f51b5;
color: white;
}
.btn-primary:hover:not(:disabled) {
background-color: #5566cc;
box-shadow: 0 8px 24px rgba(63, 81, 181, 0.4);
}
.btn-primary:disabled {
opacity: 0.7;
cursor: not-allowed;
}
.login-footer {
text-align: center;
color: rgba(255, 255, 255, 0.5);
font-size: 12px;
border-top: 1px solid rgba(255, 255, 255, 0.1);
padding-top: 16px;
margin-top: 24px;
}
.login-footer p {
margin: 0;
}
@media (max-width: 480px) {
.login-container {
padding: 32px 20px;
}
.login-title {
font-size: 24px;
}
}
</style>
</head>
<body>
<div class="login-container">
<div class="login-header">
<div class="login-avatar">Q</div>
<h1 class="login-title">QuantEngine</h1>
<p class="login-subtitle">은퇴자산포트폴리오 우자 관리 시스템</p>
</div>
<div id="errorAlert" class="alert alert-error"></div>
<form id="loginForm" class="login-form">
<div class="form-group">
<label for="username" class="form-label">관리자 아이디</label>
<input
type="text"
id="username"
name="username"
class="form-input"
placeholder="아이디를 입력하세요"
required />
</div>
<div class="form-group">
<label for="password" class="form-label">비밀번호</label>
<input
type="password"
id="password"
name="password"
class="form-input"
placeholder="비밀번호를 입력하세요"
required />
</div>
<div class="form-checkbox">
<input
type="checkbox"
id="rememberUsername"
name="rememberUsername"
class="checkbox-input" />
<label for="rememberUsername" class="checkbox-label">
다음에 아이디 자동 입력
</label>
</div>
<button type="submit" class="btn btn-primary" id="loginBtn">
로그인
</button>
</form>
<div class="login-footer">
<p>© 2026 QuantEngine. 모든 권리 예약.</p>
</div>
</div>
<script>
// LocalStorage에서 아이디 복원
window.addEventListener('DOMContentLoaded', function() {
const savedUsername = localStorage.getItem('quant_admin_username');
if (savedUsername) {
document.getElementById('username').value = savedUsername;
document.getElementById('rememberUsername').checked = true;
}
});
// 폼 제출 처리
document.getElementById('loginForm').addEventListener('submit', async function(e) {
e.preventDefault();
const username = document.getElementById('username').value;
const password = document.getElementById('password').value;
const rememberUsername = document.getElementById('rememberUsername').checked;
const errorAlert = document.getElementById('errorAlert');
const loginBtn = document.getElementById('loginBtn');
// 에러 메시지 초기화
errorAlert.classList.remove('show');
if (!username || !password) {
errorAlert.textContent = '아이디와 비밀번호를 모두 입력해 주세요.';
errorAlert.classList.add('show');
return;
}
loginBtn.disabled = true;
loginBtn.textContent = '인증 중...';
try {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password })
});
if (response.ok) {
// 아이디 저장
if (rememberUsername) {
localStorage.setItem('quant_admin_username', username);
} else {
localStorage.removeItem('quant_admin_username');
}
// 로그인 성공 - 홈페이지로 이동
window.location.href = '/';
} else {
errorAlert.textContent = '로그인 실패: 아이디 또는 비밀번호가 올바르지 않습니다.';
errorAlert.classList.add('show');
loginBtn.disabled = false;
loginBtn.textContent = '로그인';
}
} catch (error) {
errorAlert.textContent = `오류: ${error.message}`;
errorAlert.classList.add('show');
loginBtn.disabled = false;
loginBtn.textContent = '로그인';
}
});
</script>
</body>
</html>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 286 KiB

+32
View File
@@ -0,0 +1,32 @@
import { test } from '@playwright/test';
test('HTML 구조 검사', async ({ page }) => {
await page.goto('http://localhost:5265/login', { waitUntil: 'networkidle' });
await page.waitForTimeout(3000);
const html = await page.content();
// 중요한 요소 확인
console.log('\n╔════════════════════════════════════════════════════╗');
console.log('║ HTML 구조 검사 ║');
console.log('╚════════════════════════════════════════════════════╝\n');
console.log('🔍 주요 요소 검사:');
console.log(` MudPaper 있음: ${html.includes('mud-paper') ? '✅' : '❌'}`);
console.log(` MudTextField 있음: ${html.includes('mud-textfield') ? '✅' : '❌'}`);
console.log(` login-container 있음: ${html.includes('login-container') ? '✅' : '❌'}`);
console.log(` login-card 있음: ${html.includes('login-card') ? '✅' : '❌'}`);
console.log(` form-input 있음: ${html.includes('form-input') ? '✅' : '❌'}`);
console.log('\n📊 input 태그 개수:', (html.match(/<input/g) || []).length);
console.log('📊 button 태그 개수:', (html.match(/<button/g) || []).length);
console.log('\n📝 Body 내용 샘플:');
const bodyMatch = html.match(/<body[^>]*>([\s\S]*?)<\/body>/i);
if (bodyMatch) {
const bodyContent = bodyMatch[1];
// 처음 500자만 출력 (정리된 형태)
const cleaned = bodyContent.replace(/\s+/g, ' ').substring(0, 600);
console.log(cleaned);
}
});
+106
View File
@@ -0,0 +1,106 @@
import { test } from '@playwright/test';
test('로그인 화면 최종 스크린샷 - 스타일 검증', async ({ page }) => {
console.log('\n╔════════════════════════════════════════════════════╗');
console.log('║ 로그인 화면 스타일 최종 검증 ║');
console.log('╚════════════════════════════════════════════════════╝\n');
// 1️⃣ 페이지 로드
console.log('1️⃣ 로그인 페이지 로드...');
await page.goto('http://localhost:5265/login', { waitUntil: 'networkidle' });
await page.waitForTimeout(3000);
console.log(' ✓ 페이지 로드 완료');
// 2️⃣ 계산된 스타일 검증
console.log('\n2️⃣ 계산된 스타일 검증...');
const bodyStyle = await page.evaluate(() => {
const body = document.body;
return {
backgroundColor: window.getComputedStyle(body).backgroundColor,
color: window.getComputedStyle(body).color,
fontFamily: window.getComputedStyle(body).fontFamily
};
});
console.log(` Body Background: ${bodyStyle.backgroundColor}`);
console.log(` Body Text Color: ${bodyStyle.color}`);
console.log(` Font Family: ${bodyStyle.fontFamily}`);
// 3️⃣ 로그인 카드 스타일
console.log('\n3️⃣ 로그인 카드 스타일...');
const cardStyle = await page.locator('.login-card').evaluate((el: any) => ({
display: window.getComputedStyle(el).display,
background: window.getComputedStyle(el).backgroundColor,
border: window.getComputedStyle(el).border,
borderRadius: window.getComputedStyle(el).borderRadius,
padding: window.getComputedStyle(el).padding,
width: window.getComputedStyle(el).width
}));
console.log(` Display: ${cardStyle.display}`);
console.log(` Background: ${cardStyle.background}`);
console.log(` Border: ${cardStyle.border}`);
console.log(` Border Radius: ${cardStyle.borderRadius}`);
console.log(` Width: ${cardStyle.width}`);
// 4️⃣ 입력 필드 스타일
console.log('\n4️⃣ 입력 필드 스타일...');
const inputStyle = await page.locator('input[type="text"], input[type="password"]').first().evaluate((el: any) => ({
display: window.getComputedStyle(el).display,
backgroundColor: window.getComputedStyle(el).backgroundColor,
color: window.getComputedStyle(el).color,
border: window.getComputedStyle(el).border,
padding: window.getComputedStyle(el).padding,
fontSize: window.getComputedStyle(el).fontSize
}));
console.log(` Display: ${inputStyle.display}`);
console.log(` Background: ${inputStyle.backgroundColor}`);
console.log(` Text Color: ${inputStyle.color}`);
console.log(` Border: ${inputStyle.border}`);
console.log(` Padding: ${inputStyle.padding}`);
console.log(` Font Size: ${inputStyle.fontSize}`);
// 5️⃣ 로그인 버튼 스타일
console.log('\n5️⃣ 로그인 버튼 스타일...');
const buttonStyle = await page.locator('button:has-text("로그인")').first().evaluate((el: any) => ({
display: window.getComputedStyle(el).display,
backgroundColor: window.getComputedStyle(el).backgroundColor,
color: window.getComputedStyle(el).color,
padding: window.getComputedStyle(el).padding,
fontSize: window.getComputedStyle(el).fontSize,
cursor: window.getComputedStyle(el).cursor
}));
console.log(` Display: ${buttonStyle.display}`);
console.log(` Background: ${buttonStyle.backgroundColor}`);
console.log(` Text Color: ${buttonStyle.color}`);
console.log(` Padding: ${buttonStyle.padding}`);
console.log(` Font Size: ${buttonStyle.fontSize}`);
// 6️⃣ 전체 페이지 스크린샷
console.log('\n6️⃣ 스크린샷 캡처...');
await page.screenshot({
path: 'test-results/login-final-validation.png',
fullPage: true
});
console.log(' ✅ test-results/login-final-validation.png');
// 7️⃣ 모바일 뷰 스크린샷
await page.setViewportSize({ width: 375, height: 667 });
await page.screenshot({
path: 'test-results/login-mobile-view.png',
fullPage: true
});
console.log(' ✅ test-results/login-mobile-view.png');
// 8️⃣ 최종 검증
console.log('\n8️⃣ 스타일 검증 완료!');
console.log(' ✅ 모든 스크린샷이 생성되었습니다');
console.log(' ✅ test-results/ 폴더에서 확인하세요');
});