feat: create Blazor-based login component with EmptyLayout
- Create Login.razor component at /login path with Blazor form - Create EmptyLayout to prevent MainLayout wrapping on login page - Update Program.cs to redirect unauthenticated users to /login (Blazor route) - Integrate with CustomAuthenticationStateProvider for proper auth state management - Handle authentication response and token storage Note: Login flow still has routing issues - investigating dashboard redirect Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,40 @@
|
||||
import { chromium } from '@playwright/test';
|
||||
|
||||
(async () => {
|
||||
const browser = await chromium.launch();
|
||||
const page = await browser.newPage();
|
||||
|
||||
try {
|
||||
await page.goto('http://localhost:5265/login');
|
||||
|
||||
// Fill and submit
|
||||
await page.fill('input[type="text"]', 'admin');
|
||||
await page.fill('input[type="password"]', 'admin');
|
||||
await page.click('button[type="submit"]');
|
||||
|
||||
// Wait a bit for response
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
// Check current page content
|
||||
const content = await page.content();
|
||||
|
||||
if (content.includes('로그인 실패')) {
|
||||
console.log('✗ Error shown: Login failed');
|
||||
} else if (content.includes('오류 발생')) {
|
||||
console.log('✗ Error shown: Exception');
|
||||
} else if (content.includes('로그인 성공')) {
|
||||
console.log('✓ Login success message shown');
|
||||
} else {
|
||||
console.log('✓ Form still displayed');
|
||||
}
|
||||
|
||||
// Take screenshot
|
||||
await page.screenshot({ path: './login-attempt.png', fullPage: true });
|
||||
console.log('Screenshot saved');
|
||||
|
||||
} catch (e) {
|
||||
console.error('Error:', e.message);
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
})();
|
||||
@@ -0,0 +1,41 @@
|
||||
import { chromium } from '@playwright/test';
|
||||
|
||||
(async () => {
|
||||
const browser = await chromium.launch();
|
||||
const page = await browser.newPage();
|
||||
|
||||
try {
|
||||
console.log('Starting login test...');
|
||||
await page.goto('http://localhost:5265/login');
|
||||
|
||||
// Check page structure
|
||||
const html = await page.content();
|
||||
const hasMenu = html.includes('메뉴') && html.includes('대시보드');
|
||||
console.log(hasMenu ? '⚠ Menu visible' : '✓ Clean login page');
|
||||
|
||||
// Fill and submit
|
||||
await page.fill('input[type="text"]', 'admin');
|
||||
await page.fill('input[type="password"]', 'admin');
|
||||
await page.click('button[type="submit"]');
|
||||
|
||||
console.log('Login submitted, waiting for response...');
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Check for errors or success
|
||||
const content = await page.content();
|
||||
if (content.includes('로그인 실패')) {
|
||||
console.log('✗ Login failed');
|
||||
} else if (content.includes('오류')) {
|
||||
console.log('✗ Error occurred');
|
||||
} else {
|
||||
console.log('✓ Attempting navigation to dashboard...');
|
||||
await page.waitForNavigation({ waitUntil: 'load', timeout: 6000 });
|
||||
console.log('✓ Navigation complete to: ' + page.url());
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
console.log('Error/Navigation timeout (expected): ' + e.message.substring(0, 50));
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
})();
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 208 KiB |
@@ -0,0 +1,16 @@
|
||||
@inherits LayoutComponentBase
|
||||
|
||||
@Body
|
||||
|
||||
<style>
|
||||
:global(html, body) {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
:global(#app) {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,343 @@
|
||||
@page "/login"
|
||||
@layout EmptyLayout
|
||||
@using Microsoft.AspNetCore.Components.Authorization
|
||||
@using QuantEngine.Web.Client.Infrastructure
|
||||
@inject HttpClient Http
|
||||
@inject NavigationManager Navigation
|
||||
@inject AuthenticationStateProvider AuthStateProvider
|
||||
|
||||
<PageTitle>로그인 - QuantEngine</PageTitle>
|
||||
|
||||
<div class="login-page">
|
||||
<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(ErrorMessage))
|
||||
{
|
||||
<div class="alert alert-error show">
|
||||
@ErrorMessage
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (IsLoading)
|
||||
{
|
||||
<div class="alert alert-info show">
|
||||
로그인 중입니다...
|
||||
</div>
|
||||
}
|
||||
|
||||
<form class="login-form" @onsubmit="HandleLoginAsync">
|
||||
<div class="form-group">
|
||||
<label for="username" class="form-label">관리자 아이디</label>
|
||||
<input
|
||||
type="text"
|
||||
id="username"
|
||||
@bind="Username"
|
||||
class="form-input"
|
||||
placeholder="아이디를 입력하세요"
|
||||
disabled="@IsLoading"
|
||||
required />
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="password" class="form-label">비밀번호</label>
|
||||
<input
|
||||
type="password"
|
||||
id="password"
|
||||
@bind="Password"
|
||||
class="form-input"
|
||||
placeholder="비밀번호를 입력하세요"
|
||||
disabled="@IsLoading"
|
||||
required />
|
||||
</div>
|
||||
|
||||
<div class="form-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="rememberUsername"
|
||||
@bind="RememberUsername"
|
||||
class="checkbox-input"
|
||||
disabled="@IsLoading" />
|
||||
<label for="rememberUsername" class="checkbox-label">
|
||||
다음에 아이디 자동 입력
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary" disabled="@IsLoading" id="loginBtn">
|
||||
로그인
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="login-footer">
|
||||
<p>© 2026 QuantEngine. 모든 권리 예약.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.login-page {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, #0a0b16 0%, #13152e 100%);
|
||||
padding: 20px;
|
||||
margin: -16px;
|
||||
}
|
||||
|
||||
.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-input:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.form-checkbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.checkbox-input {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
cursor: pointer;
|
||||
accent-color: #3f51b5;
|
||||
}
|
||||
|
||||
.checkbox-input:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.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: block;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.alert-error {
|
||||
background-color: rgba(244, 67, 54, 0.15);
|
||||
border: 1px solid rgba(244, 67, 54, 0.3);
|
||||
color: #ff7675;
|
||||
}
|
||||
|
||||
.alert-info {
|
||||
background-color: rgba(33, 150, 243, 0.15);
|
||||
border: 1px solid rgba(33, 150, 243, 0.3);
|
||||
color: #64b5f6;
|
||||
}
|
||||
|
||||
.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;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
</style>
|
||||
|
||||
@code {
|
||||
private string Username = "";
|
||||
private string Password = "";
|
||||
private bool RememberUsername = false;
|
||||
private string ErrorMessage = "";
|
||||
private bool IsLoading = false;
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
var savedUsername = Console.Out;
|
||||
}
|
||||
|
||||
private async Task HandleLoginAsync()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(Username) || string.IsNullOrWhiteSpace(Password))
|
||||
{
|
||||
ErrorMessage = "아이디와 비밀번호를 입력하세요.";
|
||||
return;
|
||||
}
|
||||
|
||||
IsLoading = true;
|
||||
ErrorMessage = "";
|
||||
|
||||
try
|
||||
{
|
||||
var loginRequest = new { username = Username, password = Password };
|
||||
var response = await Http.PostAsJsonAsync("/api/auth/login", loginRequest);
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
using var stream = await response.Content.ReadAsStreamAsync();
|
||||
using var reader = new System.IO.StreamReader(stream);
|
||||
var json = await reader.ReadToEndAsync();
|
||||
var result = System.Text.Json.JsonSerializer.Deserialize<LoginResponse>(json);
|
||||
|
||||
if (result != null && !string.IsNullOrEmpty(result.AccessToken))
|
||||
{
|
||||
// Get auth provider and mark as authenticated
|
||||
if (AuthStateProvider is CustomAuthenticationStateProvider authProvider)
|
||||
{
|
||||
await authProvider.MarkUserAsAuthenticatedAsync(
|
||||
result.Username ?? Username,
|
||||
result.AccessToken,
|
||||
result.Role ?? "Admin",
|
||||
RememberUsername
|
||||
);
|
||||
}
|
||||
|
||||
// Navigate to dashboard
|
||||
Navigation.NavigateTo("/dashboard", forceLoad: false);
|
||||
}
|
||||
else
|
||||
{
|
||||
ErrorMessage = "로그인 응답이 올바르지 않습니다.";
|
||||
IsLoading = false;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
ErrorMessage = "로그인 실패: 아이디 또는 비밀번호가 올바르지 않습니다.";
|
||||
IsLoading = false;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ErrorMessage = $"오류 발생: {ex.Message}";
|
||||
IsLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private class LoginResponse
|
||||
{
|
||||
public bool Success { get; set; }
|
||||
public string? Username { get; set; }
|
||||
public string? Role { get; set; }
|
||||
public string? AccessToken { get; set; }
|
||||
public string? ExpiresAt { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -178,13 +178,13 @@ catch (Exception ex)
|
||||
Log.Warning("Hangfire setup failed: {Message}", ex.Message);
|
||||
}
|
||||
|
||||
// Root path - redirect unauthenticated to login.html
|
||||
// Root path - redirect unauthenticated to /login (Blazor component)
|
||||
app.MapGet("/", async (HttpContext ctx) =>
|
||||
{
|
||||
var isAuthenticated = ctx.User?.Identity?.IsAuthenticated ?? false;
|
||||
if (!isAuthenticated)
|
||||
{
|
||||
ctx.Response.Redirect("/login.html");
|
||||
ctx.Response.Redirect("/login");
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -194,12 +194,6 @@ app.MapGet("/", async (HttpContext ctx) =>
|
||||
await Task.CompletedTask;
|
||||
});
|
||||
|
||||
// Redirect /login to /login.html (static file)
|
||||
app.MapGet("/login", (HttpContext ctx) =>
|
||||
{
|
||||
ctx.Response.Redirect("/login.html", permanent: false);
|
||||
});
|
||||
|
||||
// Collection API Endpoints (must be before MapRazorComponents)
|
||||
app.MapCollectionEndpoints();
|
||||
|
||||
|
||||
@@ -294,11 +294,28 @@
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
// 아이디 저장
|
||||
const data = await response.json();
|
||||
|
||||
// 토큰 저장 (Blazor 인증 상태에 필요)
|
||||
if (data.accessToken) {
|
||||
localStorage.setItem('quant_admin_access_token', data.accessToken);
|
||||
}
|
||||
|
||||
// 사용자 정보 저장
|
||||
if (data.username) {
|
||||
localStorage.setItem('quant_admin_username', data.username);
|
||||
}
|
||||
|
||||
if (data.role) {
|
||||
localStorage.setItem('quant_admin_role', data.role);
|
||||
}
|
||||
|
||||
// 아이디 자동입력 설정 저장
|
||||
if (rememberUsername) {
|
||||
localStorage.setItem('quant_admin_username', username);
|
||||
localStorage.setItem('quant_admin_remember_username', 'true');
|
||||
} else {
|
||||
localStorage.removeItem('quant_admin_username');
|
||||
localStorage.setItem('quant_admin_remember_username', 'false');
|
||||
}
|
||||
|
||||
// 대시보드로 이동
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 199 KiB |
@@ -0,0 +1,42 @@
|
||||
import { chromium } from '@playwright/test';
|
||||
|
||||
(async () => {
|
||||
const browser = await chromium.launch();
|
||||
const page = await browser.newPage({ viewport: { width: 1280, height: 720 } });
|
||||
|
||||
try {
|
||||
await page.goto('http://localhost:5265/login');
|
||||
console.log('✓ Login page loaded');
|
||||
|
||||
// Check if Blazor component rendered
|
||||
const content1 = await page.content();
|
||||
if (content1.includes('관리자 아이디')) {
|
||||
console.log('✓ Blazor login form rendered');
|
||||
}
|
||||
|
||||
// Fill credentials
|
||||
await page.fill('input', 'admin');
|
||||
const inputs = await page.$$('input[type="password"]');
|
||||
if (inputs.length > 0) {
|
||||
await inputs[0].fill('admin');
|
||||
}
|
||||
|
||||
await page.click('button[type="submit"]');
|
||||
console.log('✓ Login submitted');
|
||||
|
||||
// Wait for navigation
|
||||
await page.waitForNavigation({ waitUntil: 'networkidle', timeout: 5000 });
|
||||
|
||||
console.log('✓ Navigation complete');
|
||||
console.log(' URL: ' + page.url());
|
||||
|
||||
// Take screenshot
|
||||
await page.screenshot({ path: './login-success.png', fullPage: true });
|
||||
console.log('✓ Screenshot saved');
|
||||
|
||||
} catch (e) {
|
||||
console.error('Error:', e.message);
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
})();
|
||||
@@ -0,0 +1,44 @@
|
||||
import { chromium } from '@playwright/test';
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const browser = await chromium.launch();
|
||||
const page = await browser.newPage({ viewport: { width: 1280, height: 720 } });
|
||||
|
||||
// Go to login page
|
||||
await page.goto('http://localhost:5265/login');
|
||||
console.log('Loaded login page');
|
||||
|
||||
// Fill in credentials
|
||||
await page.fill('input[name="username"]', 'admin');
|
||||
await page.fill('input[name="password"]', 'admin');
|
||||
|
||||
// Submit
|
||||
await page.click('button[type="submit"]');
|
||||
console.log('Submitted login form');
|
||||
|
||||
// Wait for navigation
|
||||
await page.waitForNavigation({ waitUntil: 'networkidle' });
|
||||
console.log('Page navigated');
|
||||
|
||||
// Check URL
|
||||
console.log('Current URL: ' + page.url());
|
||||
|
||||
// Check content
|
||||
const content = await page.content();
|
||||
if (content.includes('Not Found')) {
|
||||
console.log('ERROR: Page shows Not Found');
|
||||
} else if (content.includes('관리자 대시보드') || content.includes('Dashboard')) {
|
||||
console.log('SUCCESS: Dashboard loaded');
|
||||
} else {
|
||||
console.log('Other content loaded');
|
||||
}
|
||||
|
||||
// Take screenshot
|
||||
await page.screenshot({ path: './test-dashboard.png', fullPage: true });
|
||||
|
||||
await browser.close();
|
||||
} catch (e) {
|
||||
console.error('Test failed:', e.message);
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,42 @@
|
||||
import { chromium } from '@playwright/test';
|
||||
|
||||
(async () => {
|
||||
const browser = await chromium.launch();
|
||||
const page = await browser.newPage();
|
||||
|
||||
try {
|
||||
await page.goto('http://localhost:5265/login');
|
||||
console.log('✓ Loaded login page');
|
||||
|
||||
// Fill and submit
|
||||
await page.fill('input[type="text"]', 'admin');
|
||||
await page.fill('input[type="password"]', 'admin');
|
||||
await page.click('button[type="submit"]');
|
||||
console.log('✓ Form submitted');
|
||||
|
||||
// Wait for page load
|
||||
await page.waitForNavigation({ waitUntil: 'load', timeout: 10000 });
|
||||
|
||||
const url = page.url();
|
||||
const content = await page.content();
|
||||
|
||||
console.log('\nResult:');
|
||||
console.log(' URL: ' + url);
|
||||
|
||||
if (url.includes('/dashboard') && content.includes('관리자 대시보드')) {
|
||||
console.log('✓ Dashboard successfully loaded!');
|
||||
} else if (content.includes('Not Found')) {
|
||||
console.log('✗ Error: Not Found');
|
||||
} else {
|
||||
console.log(' Status: Other');
|
||||
}
|
||||
|
||||
await page.screenshot({ path: './login-result.png', fullPage: true });
|
||||
console.log(' Screenshot: login-result.png');
|
||||
|
||||
} catch (e) {
|
||||
console.error('Test failed:', e.message);
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
})();
|
||||
Reference in New Issue
Block a user