mirror of
https://github.com/EstrellaXD/Auto_Bangumi.git
synced 2026-10-30 20:24:47 +08:00
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:
107
webui/src/hooks/__tests__/login-redirect.test.ts
Normal file
107
webui/src/hooks/__tests__/login-redirect.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user