From b6e0add2acde0b23e73c6182b89fc0f726dfbd83 Mon Sep 17 00:00:00 2001 From: kjh2064 Date: Fri, 3 Jul 2026 13:37:42 +0900 Subject: [PATCH] fix: implement pure Blazor native login form for reliable auth state sync Problem: With prerender: true + JavaScript form submission + location.reload(), WASM hydration wasn't completing fast enough after page reload, leaving the user on the login page despite successful token storage. Solution: Complete rewrite to pure Blazor native login (prerender: false). This approach: 1. WASM boots and renders the form 2. User submits form (Blazor handles it) 3. HttpClient POST to /api/auth/login 4. Save tokens to localStorage 5. CustomAuthenticationStateProvider.LoginAsync() called directly in C# 6. Blazor detects auth state change synchronously 7. NavigateTo() redirects to dashboard 8. All in same Blazor context, no reload needed Benefits: - Auth state update is synchronous with login response - No WASM boot race conditions - Direct C# call to CustomAuthenticationStateProvider - Blazor handles redirect after auth state is confirmed Trade-off: Login page requires WASM boot (brief spinner) instead of immediate prerender display. This is acceptable for better reliability. Result: Reliable login-to-dashboard flow with no hanging spinners or 'loading' states. Co-Authored-By: Claude Haiku 4.5 --- .../Components/Admin/Pages/Login.razor | 4 +- .../Admin/Shared/AdminLoginFormNative.razor | 141 ++++++++++++++++++ tests/e2e/admin-login.spec.ts | 54 ++++++- 3 files changed, 193 insertions(+), 6 deletions(-) create mode 100644 src/TaxBaik.Web.Client/Components/Admin/Shared/AdminLoginFormNative.razor diff --git a/src/TaxBaik.Web.Client/Components/Admin/Pages/Login.razor b/src/TaxBaik.Web.Client/Components/Admin/Pages/Login.razor index 1524f6c..fe0710b 100644 --- a/src/TaxBaik.Web.Client/Components/Admin/Pages/Login.razor +++ b/src/TaxBaik.Web.Client/Components/Admin/Pages/Login.razor @@ -1,6 +1,6 @@ @page "/admin/login" @layout TaxBaik.WasmClient.Components.Admin.Layout.BlankLayout @attribute [AllowAnonymous] -@rendermode @(new InteractiveWebAssemblyRenderMode(prerender: true)) +@rendermode @(new InteractiveWebAssemblyRenderMode(prerender: false)) 로그인 - + diff --git a/src/TaxBaik.Web.Client/Components/Admin/Shared/AdminLoginFormNative.razor b/src/TaxBaik.Web.Client/Components/Admin/Shared/AdminLoginFormNative.razor new file mode 100644 index 0000000..07d40b6 --- /dev/null +++ b/src/TaxBaik.Web.Client/Components/Admin/Shared/AdminLoginFormNative.razor @@ -0,0 +1,141 @@ +@using System.Text.Json +@inject ILocalStorageService LocalStorageService +@inject NavigationManager Navigation +@inject ISnackbar Snackbar +@inject HttpClient Http +@inject AuthenticationStateProvider AuthStateProvider + + + +@code { + private MudForm? form; + private string username = ""; + private string password = ""; + private bool rememberMe = false; + private bool isLoggingIn = false; + private const string RememberedUsernameKey = "admin-remembered-username"; + private const string RememberedCheckboxKey = "admin-remember-checkbox"; + + protected override async Task OnInitializedAsync() + { + try + { + username = await LocalStorageService.GetItemAsStringAsync(RememberedUsernameKey) ?? ""; + var checkboxValue = await LocalStorageService.GetItemAsStringAsync(RememberedCheckboxKey) ?? "false"; + rememberMe = checkboxValue == "true" && !string.IsNullOrEmpty(username); + } + catch + { + username = ""; + rememberMe = false; + } + } + + private async Task HandleLogin() + { + if (form == null || isLoggingIn) return; + + await form.Validate(); + if (!form.IsValid) return; + + isLoggingIn = true; + try + { + var response = await Http.PostAsJsonAsync("/api/auth/login", new { username, password }); + if (!response.IsSuccessStatusCode) + { + Snackbar.Add("로그인 실패: 사용자명 또는 비밀번호가 올바르지 않습니다", Severity.Error); + return; + } + + var json = await response.Content.ReadAsStringAsync(); + var loginResponse = JsonSerializer.Deserialize(json); + if (loginResponse == null || string.IsNullOrEmpty(loginResponse.AccessToken)) + { + Snackbar.Add("로그인 실패: 응답 오류", Severity.Error); + return; + } + + // 로컬 저장소 저장 + await LocalStorageService.SetItemAsStringAsync("accessToken", loginResponse.AccessToken); + await LocalStorageService.SetItemAsStringAsync("refreshToken", loginResponse.RefreshToken ?? ""); + var expiryTicks = DateTimeOffset.UtcNow.AddSeconds(loginResponse.ExpiresIn).UtcTicks; + await LocalStorageService.SetItemAsStringAsync("tokenExpiry", expiryTicks.ToString()); + + // 아이디 저장 + if (rememberMe) + { + await LocalStorageService.SetItemAsStringAsync(RememberedUsernameKey, username); + await LocalStorageService.SetItemAsStringAsync(RememberedCheckboxKey, "true"); + } + else + { + await LocalStorageService.RemoveItemAsync(RememberedUsernameKey); + await LocalStorageService.RemoveItemAsync(RememberedCheckboxKey); + } + + // Blazor 인증 상태 업데이트 (직접 호출) + if (AuthStateProvider is CustomAuthenticationStateProvider customProvider) + { + await customProvider.LoginAsync(loginResponse.AccessToken, loginResponse.RefreshToken ?? "", loginResponse.ExpiresIn); + } + + // 대시보드로 이동 + Navigation.NavigateTo("/admin/dashboard", replace: true); + } + catch (Exception ex) + { + Snackbar.Add($"로그인 중 오류: {ex.Message}", Severity.Error); + } + finally + { + isLoggingIn = false; + } + } + + private class LoginResponse + { + public string AccessToken { get; set; } = ""; + public string RefreshToken { get; set; } = ""; + public int ExpiresIn { get; set; } + } +} diff --git a/tests/e2e/admin-login.spec.ts b/tests/e2e/admin-login.spec.ts index 402ce2c..b76969e 100644 --- a/tests/e2e/admin-login.spec.ts +++ b/tests/e2e/admin-login.spec.ts @@ -9,6 +9,8 @@ test.describe('admin authentication', () => { test.skip(!password, 'E2E_ADMIN_PASSWORD is required.'); const consoleErrors: string[] = []; + const networkRequests: { url: string; status?: number }[] = []; + page.on('console', message => { if (message.type() === 'error') { consoleErrors.push(message.text()); @@ -17,16 +19,60 @@ test.describe('admin authentication', () => { page.on('pageerror', error => { consoleErrors.push(error.message); }); + page.on('response', response => { + if (response.url().includes('/api/auth/login') || response.url().includes('/admin/')) { + networkRequests.push({ url: response.url(), status: response.status() }); + } + }); await page.goto(`${baseUrl}/admin/login`); await expect(page.locator('input[placeholder="사용자명"]')).toBeVisible(); await expect(page.locator('input[placeholder="비밀번호"]')).toBeVisible(); - await page.locator('input[placeholder="사용자명"]').fill(username); - await page.locator('input[placeholder="비밀번호"]').fill(password); - await page.getByRole('button', { name: '로그인' }).click(); + // Wait for form to be fully ready + await page.waitForTimeout(500); - await expect(page).toHaveURL(/\/taxbaik\/admin\/dashboard$/); + const usernameInput = page.locator('input[placeholder="사용자명"]'); + const passwordInput = page.locator('input[placeholder="비밀번호"]'); + const loginButton = page.getByRole('button', { name: '로그인' }); + + await usernameInput.fill(username); + await passwordInput.fill(password); + + // Wait for inputs to register + await page.waitForTimeout(300); + + // Verify inputs were filled + const usernameValue = await usernameInput.inputValue(); + const passwordValue = await passwordInput.inputValue(); + console.log(`Username filled: ${usernameValue === username}, Password filled: ${passwordValue === password}`); + + // Click login and wait for any navigation + await loginButton.click(); + + // Wait a bit for form submission + page reload + await page.waitForTimeout(3000); + + // Check localStorage and current state + const localStorageData = await page.evaluate(() => ({ + accessToken: localStorage.getItem('accessToken'), + refreshToken: localStorage.getItem('refreshToken'), + tokenExpiry: localStorage.getItem('tokenExpiry') + })); + + console.log(`localStorage has accessToken: ${!!localStorageData.accessToken}`); + console.log(`Current URL after login: ${page.url()}`); + console.log(`Network requests: ${JSON.stringify(networkRequests)}`); + + // If we're still on login page, something failed + if (page.url().includes('/admin/login')) { + console.log('Still on login page after 3 seconds!'); + // Try to find error message + const errorMsg = await page.locator('.login-error-message').textContent().catch(() => null); + console.log(`Error message: ${errorMsg}`); + } + + await expect(page).toHaveURL(/\/taxbaik\/admin\/dashboard$/, { timeout: 20_000 }); await expect(page.getByRole('heading', { name: '대시보드' }).first()).toBeVisible({ timeout: 20_000 }); await expect(page.getByRole('link', { name: /로그아웃/ })).toBeVisible(); expect(consoleErrors, 'browser console/page errors').toEqual([]);