fix(webui): login redirect gets stuck (passkey never navigates, guard wedge)

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FAxVyRwrY7z5NotTtgnwVs
This commit is contained in:
Estrella Pan
2026-07-04 18:32:01 +02:00
parent 9a0347cb95
commit 0d3fffdef4
4 changed files with 120 additions and 5 deletions

View File

@@ -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<typeof vi.fn>;
const apiLogin = apiAuth.login as ReturnType<typeof vi.fn>;
const passkeyLogin = webauthnLogin as unknown as ReturnType<typeof vi.fn>;
/** Run a composable inside a real component instance. */
function withSetup<T>(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();
});
});

View File

@@ -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) {

View File

@@ -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) {

View File

@@ -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;