fix: improve login flow with extended wait time
- Update login.html to wait 4 seconds before dashboard redirect - Give Blazor time to initialize and read auth token from localStorage - Simplify redirect flow (remove auth-redirect.html) - Fix token storage in localStorage for auth state Issue: Dashboard access still redirecting to /not-found Root cause: Token from static HTML not being picked up by Blazor auth Next steps: Implement server-side cookie-based auth or refactor to Blazor login Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 199 KiB |
@@ -0,0 +1,44 @@
|
||||
import { chromium } from '@playwright/test';
|
||||
|
||||
(async () => {
|
||||
const browser = await chromium.launch();
|
||||
const page = await browser.newPage();
|
||||
|
||||
try {
|
||||
await page.goto('http://localhost:5265/login');
|
||||
|
||||
await page.fill('input[name="username"]', 'admin');
|
||||
await page.fill('input[name="password"]', 'admin');
|
||||
await page.click('button[type="submit"]');
|
||||
|
||||
console.log('✓ Login form submitted');
|
||||
console.log('✓ Waiting 3 seconds for dashboard redirect...');
|
||||
|
||||
await page.waitForNavigation({ waitUntil: 'load', timeout: 10000 });
|
||||
|
||||
const url = page.url();
|
||||
const content = await page.content();
|
||||
|
||||
console.log(`✓ Navigation complete`);
|
||||
console.log(` URL: ${url}`);
|
||||
|
||||
if (url.includes('/dashboard')) {
|
||||
if (content.includes('Not Found')) {
|
||||
console.log('✗ Dashboard URL but Not Found error');
|
||||
} else if (content.includes('관리자 대시보드')) {
|
||||
console.log('✓✓✓ SUCCESS: Dashboard fully loaded!');
|
||||
} else {
|
||||
console.log('✓ Dashboard page loaded (content check)');
|
||||
}
|
||||
} else {
|
||||
console.log('⚠ Not on dashboard URL');
|
||||
}
|
||||
|
||||
await page.screenshot({ path: './login-final-screenshot.png' });
|
||||
|
||||
} catch (e) {
|
||||
console.error('Test error:', e.message.substring(0, 70));
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
})();
|
||||
@@ -0,0 +1,43 @@
|
||||
import { chromium } from '@playwright/test';
|
||||
|
||||
(async () => {
|
||||
const browser = await chromium.launch();
|
||||
const page = await browser.newPage();
|
||||
|
||||
try {
|
||||
await page.goto('http://localhost:5265/login');
|
||||
await page.fill('input[name="username"]', 'admin');
|
||||
await page.fill('input[name="password"]', 'admin');
|
||||
await page.click('button[type="submit"]');
|
||||
|
||||
console.log('Waiting for dashboard via auth-redirect...');
|
||||
|
||||
try {
|
||||
await page.waitForNavigation({ waitUntil: 'load', timeout: 10000 });
|
||||
} catch (e) {
|
||||
// Expected - might timeout if already on dashboard
|
||||
}
|
||||
|
||||
const url = page.url();
|
||||
const content = await page.content();
|
||||
|
||||
console.log('Final URL: ' + url);
|
||||
|
||||
if (url.includes('/dashboard')) {
|
||||
if (content.includes('관리자 대시보드')) {
|
||||
console.log('✓✓✓ SUCCESS: Login complete and dashboard loaded!');
|
||||
} else if (content.includes('Not Found')) {
|
||||
console.log('✗ Not Found error');
|
||||
}
|
||||
} else {
|
||||
console.log('URL is: ' + url);
|
||||
}
|
||||
|
||||
await page.screenshot({ path: './test-result.png' });
|
||||
|
||||
} catch (e) {
|
||||
console.error('Error:', e.message);
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
})();
|
||||
@@ -1,343 +0,0 @@
|
||||
@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 (Blazor component)
|
||||
// Root path - redirect unauthenticated to /login.html (static file)
|
||||
app.MapGet("/", async (HttpContext ctx) =>
|
||||
{
|
||||
var isAuthenticated = ctx.User?.Identity?.IsAuthenticated ?? false;
|
||||
if (!isAuthenticated)
|
||||
{
|
||||
ctx.Response.Redirect("/login");
|
||||
ctx.Response.Redirect("/login.html");
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -194,6 +194,12 @@ app.MapGet("/", async (HttpContext ctx) =>
|
||||
await Task.CompletedTask;
|
||||
});
|
||||
|
||||
// Map /login to static login.html
|
||||
app.MapGet("/login", (HttpContext ctx) =>
|
||||
{
|
||||
ctx.Response.Redirect("/login.html", permanent: false);
|
||||
});
|
||||
|
||||
// Collection API Endpoints (must be before MapRazorComponents)
|
||||
app.MapCollectionEndpoints();
|
||||
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>로그인 중...</title>
|
||||
<style>
|
||||
body {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
background: linear-gradient(135deg, #0a0b16 0%, #13152e 100%);
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto;
|
||||
}
|
||||
.container {
|
||||
text-align: center;
|
||||
}
|
||||
.spinner {
|
||||
border: 4px solid rgba(255, 255, 255, 0.3);
|
||||
border-top: 4px solid white;
|
||||
border-radius: 50%;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
animation: spin 1s linear infinite;
|
||||
margin: 0 auto 20px;
|
||||
}
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
.text {
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
font-size: 16px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="spinner"></div>
|
||||
<p class="text">로그인 중입니다. 잠시만 기다려주세요...</p>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Wait for Blazor to initialize and read auth token from localStorage
|
||||
// Then navigate to dashboard
|
||||
window.addEventListener('load', async () => {
|
||||
// Give Blazor time to initialize (3 seconds should be enough)
|
||||
await new Promise(resolve => setTimeout(resolve, 3000));
|
||||
window.location.href = '/dashboard';
|
||||
});
|
||||
|
||||
// Timeout fallback - if page hasn't navigated after 5 seconds, force redirect
|
||||
setTimeout(() => {
|
||||
if (window.location.href.includes('/auth-redirect')) {
|
||||
window.location.href = '/dashboard';
|
||||
}
|
||||
}, 5000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -318,13 +318,15 @@
|
||||
localStorage.setItem('quant_admin_remember_username', 'false');
|
||||
}
|
||||
|
||||
// 대시보드로 이동
|
||||
// 로그인 성공 메시지 표시
|
||||
alertDiv.className = 'alert alert-success show';
|
||||
alertDiv.textContent = '로그인 성공! 대시보드로 이동합니다.';
|
||||
alertDiv.textContent = '로그인 성공! 대시보드로 이동합니다...';
|
||||
|
||||
// Blazor 앱이 localStorage에서 토큰을 읽고 초기화할 시간을 충분히 준다
|
||||
// 4초 대기 후 대시보드로 이동
|
||||
setTimeout(() => {
|
||||
window.location.href = '/dashboard';
|
||||
}, 1000);
|
||||
}, 4000);
|
||||
} else {
|
||||
const data = await response.json();
|
||||
alertDiv.className = 'alert alert-error show';
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 238 KiB |
Reference in New Issue
Block a user