From 0d3fffdef4e2b647d16e0c550132af74ffa68ca4 Mon Sep 17 00:00:00 2001 From: Estrella Pan Date: Sat, 4 Jul 2026 18:32:01 +0200 Subject: [PATCH] fix(webui): login redirect gets stuck (passkey never navigates, guard wedge) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three overlapping defects made login "sometimes stick without redirecting" across all three methods: - Passkey login set isLoggedIn=true but never navigated, so a successful passkey login stayed on the login page every time. Add the missing router.replace({ name: 'Index' }). - useAuth.login() didn't return its promise chain, so login.vue's `await login()` resolved before the redirect ran (and the loading state cleared early). Return the promise. - The router guard awaited apiSetup.getStatus() on every navigation while setupChecked was false; when the initial check failed, this blocking network call wedged the post-login redirect. Skip the setup gate while logged in — a session can only exist after setup completed. Adds login-redirect.test.ts reproducing all three (navigate on success for password + passkey, no navigate on failure). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01FAxVyRwrY7z5NotTtgnwVs --- .../hooks/__tests__/login-redirect.test.ts | 107 ++++++++++++++++++ webui/src/hooks/useAuth.ts | 7 +- webui/src/hooks/usePasskey.ts | 4 + webui/src/router/index.ts | 7 +- 4 files changed, 120 insertions(+), 5 deletions(-) create mode 100644 webui/src/hooks/__tests__/login-redirect.test.ts diff --git a/webui/src/hooks/__tests__/login-redirect.test.ts b/webui/src/hooks/__tests__/login-redirect.test.ts new file mode 100644 index 00000000..c496f93e --- /dev/null +++ b/webui/src/hooks/__tests__/login-redirect.test.ts @@ -0,0 +1,107 @@ +/** + * Reproduces the "login sometimes gets stuck, no redirect" report: after a + * successful login the app must navigate to Index. Passkey login regressed + * because its success path set isLoggedIn but never navigated, and + * useAuth.login didn't return its promise so callers couldn't await it. + */ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { defineComponent } from 'vue'; +import { mount } from '@vue/test-utils'; +import { router } from '@/router'; +import { apiAuth } from '@/api/auth'; +import { loginWithPasskey as webauthnLogin } from '@/services/webauthn'; +import { useAuth } from '@/hooks/useAuth'; +import { usePasskey } from '@/hooks/usePasskey'; + +// vi.mock is hoisted above the imports above regardless of textual order. +vi.mock('@/router', () => ({ + router: { replace: vi.fn(), push: vi.fn() }, + markSetupComplete: vi.fn(), +})); + +vi.mock('@/api/auth', () => ({ + apiAuth: { + login: vi.fn(), + logout: vi.fn(), + refresh: vi.fn(), + update: vi.fn(), + }, +})); + +vi.mock('@/services/webauthn', () => ({ + isWebAuthnSupported: () => true, + registerPasskey: vi.fn(), + loginWithPasskey: vi.fn(), +})); + +vi.mock('@/api/passkey', () => ({ + apiPasskey: { list: vi.fn(), delete: vi.fn() }, +})); + +vi.mock('@/hooks/useMessage', () => ({ + useMessage: () => ({ success: vi.fn(), error: vi.fn(), warning: vi.fn() }), +})); + +vi.mock('@/hooks/useMyI18n', () => ({ + useMyI18n: () => ({ + t: (k: string) => k, + returnUserLangText: (x: { en: string }) => x.en, + }), +})); + +const replace = router.replace as ReturnType; +const apiLogin = apiAuth.login as ReturnType; +const passkeyLogin = webauthnLogin as unknown as ReturnType; + +/** Run a composable inside a real component instance. */ +function withSetup(fn: () => T): T { + let result!: T; + mount( + defineComponent({ + setup() { + result = fn(); + return () => null; + }, + }) + ); + return result; +} + +describe('login redirect', () => { + beforeEach(() => { + vi.clearAllMocks(); + localStorage.clear(); + }); + + it('should navigate to Index after a successful password login', async () => { + apiLogin.mockResolvedValue({ access_token: 'token' }); + const { user, login } = withSetup(() => useAuth()); + user.username = 'admin'; + user.password = 'validpassword123'; + + // login() must return a promise so the caller (login.vue) can await it + await login(); + + expect(replace).toHaveBeenCalledWith({ name: 'Index' }); + }); + + it('should navigate to Index after a successful passkey login', async () => { + passkeyLogin.mockResolvedValue(undefined); + const { loginWithPasskey } = withSetup(() => usePasskey()); + + const ok = await loginWithPasskey('admin'); + + expect(ok).toBe(true); + expect(replace).toHaveBeenCalledWith({ name: 'Index' }); + }); + + it('should not navigate when passkey login fails', async () => { + passkeyLogin.mockRejectedValue({ status: 401 }); + const { loginWithPasskey } = withSetup(() => usePasskey()); + + const ok = await loginWithPasskey('admin'); + + expect(ok).toBe(false); + expect(replace).not.toHaveBeenCalled(); + }); +}); diff --git a/webui/src/hooks/useAuth.ts b/webui/src/hooks/useAuth.ts index 1ce244a0..42dedc94 100644 --- a/webui/src/hooks/useAuth.ts +++ b/webui/src/hooks/useAuth.ts @@ -35,15 +35,16 @@ export const useAuth = createSharedComposable(() => { } function login() { - if (!formVerify()) return; + if (!formVerify()) return Promise.resolve(); - apiAuth + // 返回 promise:登录页据此维持 loading 状态、等待跳转完成 + return apiAuth .login(user.username, user.password) .then(() => { isLoggedIn.value = true; clearUser(); message.success(t('notify.login_success')); - router.replace({ name: 'Index' }); + return router.replace({ name: 'Index' }); }) .catch((err: ApiError) => { if (err.status === 404) { diff --git a/webui/src/hooks/usePasskey.ts b/webui/src/hooks/usePasskey.ts index b15c7920..45a7365f 100644 --- a/webui/src/hooks/usePasskey.ts +++ b/webui/src/hooks/usePasskey.ts @@ -1,4 +1,5 @@ import { createSharedComposable } from '@vueuse/core'; +import { router } from '@/router'; import { apiPasskey } from '@/api/passkey'; import { isWebAuthnSupported, @@ -56,6 +57,9 @@ export const usePasskey = createSharedComposable(() => { await webauthnLogin(username); isLoggedIn.value = true; message.success(t('notify.login_success')); + // 密码登录在 useAuth 里跳转;passkey 此前漏了这一步,导致登录成功 + // 却停留在登录页(#登录卡住不转跳) + await router.replace({ name: 'Index' }); return true; } catch (error: unknown) { if (error && typeof error === 'object' && 'status' in error) { diff --git a/webui/src/router/index.ts b/webui/src/router/index.ts index 051b9aa4..8d3958da 100644 --- a/webui/src/router/index.ts +++ b/webui/src/router/index.ts @@ -18,8 +18,11 @@ router.beforeEach(async (to) => { const { isLoggedIn } = useAuth(); const { type, url } = storeToRefs(usePlayerStore()); - // Check setup status once per session - if (!setupChecked && to.path !== '/setup') { + // Check setup status once per session. Skip while logged in: a session can + // only exist after setup completed, so an authed navigation never needs the + // gate — and awaiting this network call here is what wedged the post-login + // redirect when the initial check had failed (setupChecked still false). + if (!setupChecked && !isLoggedIn.value && to.path !== '/setup') { try { const status = await apiSetup.getStatus(); needSetup = status.need_setup;