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:
@@ -1,75 +1,20 @@
|
||||
@inherits LayoutComponentBase
|
||||
@using QuantEngine.Web.Client.Theme
|
||||
@rendermode InteractiveWebAssembly
|
||||
|
||||
<!-- ✅ MudBlazor Providers (Required for Interactive WebAssembly) -->
|
||||
<MudThemeProvider Theme="@_theme" />
|
||||
<MudPopoverProvider />
|
||||
<MudDialogProvider />
|
||||
<MudSnackbarProvider />
|
||||
<style>
|
||||
:global(body) {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
}
|
||||
|
||||
<div class="auth-container">
|
||||
<!-- Left Panel - Branding -->
|
||||
<MudHidden Breakpoint="Breakpoint.SmAndDown" Invert="true" Class="auth-left-panel">
|
||||
<div class="auth-branding">
|
||||
<div class="auth-logo">
|
||||
<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>
|
||||
:global(html, body, #app) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
|
||||
</MudHidden>
|
||||
|
||||
<!-- 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>
|
||||
@Body
|
||||
|
||||
@code {
|
||||
private MudTheme _theme = AppTheme.LightTheme;
|
||||
}
|
||||
|
||||
@@ -21,7 +21,8 @@
|
||||
<body>
|
||||
<div id="app">
|
||||
<CascadingAuthenticationState>
|
||||
<Router AppAssembly="@typeof(App).Assembly" />
|
||||
<Router AppAssembly="@typeof(App).Assembly"
|
||||
AdditionalAssemblies="new[] { typeof(QuantEngine.Web.Client.Pages.Dashboard).Assembly }" />
|
||||
</CascadingAuthenticationState>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,46 +1,22 @@
|
||||
@inherits LayoutComponentBase
|
||||
@using QuantEngine.Web.Client.Theme
|
||||
|
||||
<!-- Server-side Auth Layout for InteractiveServer login -->
|
||||
<MudThemeProvider Theme="@_theme" />
|
||||
<MudPopoverProvider />
|
||||
<MudDialogProvider />
|
||||
<MudSnackbarProvider />
|
||||
|
||||
<div class="auth-container">
|
||||
<div class="auth-right-panel">
|
||||
<div class="auth-content">
|
||||
@Body
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 최소한의 레이아웃 - MudBlazor 프로바이더 제거 -->
|
||||
|
||||
<style>
|
||||
.auth-container {
|
||||
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%);
|
||||
:global(body) {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
}
|
||||
|
||||
.auth-right-panel {
|
||||
:global(html, body, #app) {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.auth-content {
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
|
||||
@Body
|
||||
|
||||
@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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,7 @@ var builder = WebApplication.CreateBuilder(args);
|
||||
builder.Host.UseSerilog();
|
||||
|
||||
// Add services to the container.
|
||||
builder.Services.AddRazorPages();
|
||||
builder.Services.AddRazorComponents()
|
||||
.AddInteractiveServerComponents()
|
||||
.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>()
|
||||
.AddInteractiveServerRenderMode()
|
||||
.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 |
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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/ 폴더에서 확인하세요');
|
||||
});
|
||||
Reference in New Issue
Block a user