diff --git a/eslint-suppressions.json b/eslint-suppressions.json index c479f58f..3412cdd6 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -673,11 +673,6 @@ "count": 1 } }, - "src/pages/login.vue": { - "@typescript-eslint/no-unused-vars": { - "count": 1 - } - }, "src/pages/resource.vue": { "@typescript-eslint/no-explicit-any": { "count": 6 diff --git a/src/components/auth/__tests__/LoginMfaStep.spec.ts b/src/components/auth/__tests__/LoginMfaStep.spec.ts index e6c0f2c2..ceab239c 100644 --- a/src/components/auth/__tests__/LoginMfaStep.spec.ts +++ b/src/components/auth/__tests__/LoginMfaStep.spec.ts @@ -9,11 +9,14 @@ vi.mock('vue-i18n', () => ({ const slotStub = { template: '
' } const buttonStub = { emits: ['click'], - props: ['loading'], - template: '', + props: ['disabled', 'loading', 'type'], + template: '', } -function mountStep(methods: Array<'otp'>) { +function mountStep( + methods: Array<'otp'>, + props: Partial<{ errorMessage: string; otpLoading: boolean; otpPassword: string }> = {}, +) { return shallowMount(LoginMfaStep, { global: { stubs: { @@ -28,6 +31,7 @@ function mountStep(methods: Array<'otp'>) { methods, otpLoading: false, otpPassword: '', + ...props, }, }) } @@ -44,4 +48,44 @@ describe('LoginMfaStep', () => { expect(wrapper.find('[data-testid="mfa-otp-form"]').exists()).toBe(false) }) + + it('emits the OTP value and submits the current verification step', async () => { + const wrapper = mountStep(['otp']) + const input = wrapper.get('input[name="otp"]') + + await input.setValue('123456') + await wrapper.setProps({ otpPassword: '123456' }) + await wrapper.get('[data-testid="mfa-otp-form"]').trigger('submit') + + expect(wrapper.emitted('update:otpPassword')).toEqual([['123456']]) + expect(wrapper.emitted('otp')).toHaveLength(1) + }) + + it('disables submission until an OTP is present', () => { + const wrapper = mountStep(['otp']) + + expect(wrapper.get('button[type="submit"]').attributes('disabled')).toBeDefined() + }) + + it('locks input, back and submit actions while OTP verification is pending', () => { + const wrapper = mountStep(['otp'], { otpLoading: true, otpPassword: '123456' }) + + expect(wrapper.get('input[name="otp"]').attributes('disabled')).toBeDefined() + expect(wrapper.get('[data-testid="mfa-back"]').attributes('disabled')).toBeDefined() + expect(wrapper.get('button[type="submit"]').attributes('disabled')).toBeDefined() + }) + + it('emits back when the user returns to password login', async () => { + const wrapper = mountStep(['otp']) + + await wrapper.get('[data-testid="mfa-back"]').trigger('click') + + expect(wrapper.emitted('back')).toHaveLength(1) + }) + + it('shows the current verification error', () => { + const wrapper = mountStep(['otp'], { errorMessage: '验证码错误' }) + + expect(wrapper.text()).toContain('验证码错误') + }) }) diff --git a/src/pages/__tests__/login.spec.ts b/src/pages/__tests__/login.spec.ts new file mode 100644 index 00000000..9a57f87e --- /dev/null +++ b/src/pages/__tests__/login.spec.ts @@ -0,0 +1,996 @@ +import LoginPage from '@/pages/login.vue' +import { useAuthStore, useUserStore } from '@/stores' +import { renderWithProviders } from '@tests/support/render' +import { fireEvent, screen, waitFor } from '@testing-library/vue' +import { defineComponent, h, nextTick } from 'vue' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + api: { + get: vi.fn(), + post: vi.fn(), + }, + loadRemoteComponentFromModule: vi.fn(), + navMenus: [{ title: 'Home', to: '/home' }], + router: { + currentRoute: { value: { query: {} } }, + push: vi.fn(), + resolve: vi.fn((target: string) => ({ path: target })), + }, +})) + +vi.mock('@/api', () => ({ + default: mocks.api, +})) + +vi.mock('@/router', () => ({ + default: mocks.router, +})) + +vi.mock('@/router/i18n-menu', () => ({ + getNavMenus: () => mocks.navMenus, +})) + +vi.mock('@/utils/federationLoader', () => ({ + loadRemoteComponentFromModule: (...args: unknown[]) => mocks.loadRemoteComponentFromModule(...args), +})) + +function deferred() { + let resolve!: (value: T) => void + let reject!: (reason?: unknown) => void + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise + reject = rejectPromise + }) + return { promise, reject, resolve } +} + +function remoteAuthPage(label: string) { + return defineComponent({ + name: `${label}AuthPage`, + setup: () => () => h('div', label), + }) +} + +function ticketAuthPage(ticket?: string) { + return defineComponent({ + name: 'TicketAuthPage', + emits: ['authenticated'], + setup(_, { emit }) { + return () => h('button', { onClick: () => emit('authenticated', { ticket }) }, 'Complete Plugin Login') + }, + }) +} + +function failingAuthPage(error: unknown) { + return defineComponent({ + name: 'FailingAuthPage', + emits: ['error'], + setup(_, { emit }) { + return () => h('button', { onClick: () => emit('error', error) }, 'Fail Plugin Login') + }, + }) +} + +function loginResponse(overrides: Record = {}) { + return { + access_token: 'synthetic-token', + avatar: 'avatar.png', + level: 2, + permissions: { + discovery: true, + features: { 'discovery.recommend': true }, + manage: true, + search: true, + subscribe: true, + }, + super_user: false, + user_id: 7, + user_name: 'alice', + wizard: false, + ...overrides, + } +} + +function mfaRequired(methods: unknown = ['otp']) { + return { + response: { + data: { detail: '需要二次验证', mfa_methods: methods }, + headers: { 'x-mfa-required': 'true' }, + status: 401, + }, + } +} + +async function submitPassword(container: Element) { + await fireEvent.update(screen.getByRole('textbox', { name: '用户名' }), 'alice') + await fireEvent.update(screen.getByLabelText('密码'), 'secret') + const form = container.querySelector('form.login-form') + expect(form).not.toBeNull() + await fireEvent.submit(form!) +} + +async function renderLogin(initialState: Record> = {}) { + return renderWithProviders(LoginPage, { + global: { + stubs: { + OpticalLogoLab: { template: '
' }, + }, + }, + initialState, + stubActions: false, + }) +} + +describe('login page orchestration', () => { + beforeEach(() => { + mocks.api.get.mockReset() + mocks.api.post.mockReset() + mocks.loadRemoteComponentFromModule.mockReset() + mocks.router.push.mockReset() + mocks.router.resolve.mockClear() + mocks.router.currentRoute.value.query = {} + mocks.api.get.mockResolvedValue([]) + mocks.navMenus = [{ title: 'Home', to: '/home' }] + vi.spyOn(console, 'error').mockImplementation(() => {}) + }) + + it('sends only one password request while the current submission is pending', async () => { + const pendingLogin = deferred() + mocks.api.post.mockReturnValue(pendingLogin.promise) + const { container } = await renderLogin() + + await fireEvent.update(screen.getByRole('textbox', { name: '用户名' }), 'alice') + await fireEvent.update(screen.getByLabelText('密码'), 'secret') + const form = container.querySelector('form.login-form') + expect(form).not.toBeNull() + + await fireEvent.submit(form!) + await fireEvent.submit(form!) + + expect(mocks.api.post).toHaveBeenCalledTimes(1) + }) + + it('does not submit an incomplete password form', async () => { + const { container } = await renderLogin() + + await fireEvent.submit(container.querySelector('form.login-form')!) + + expect(mocks.api.post).not.toHaveBeenCalled() + }) + + it('toggles password visibility without submitting the form', async () => { + await renderLogin() + const password = screen.getByLabelText('密码') + + expect(password).toHaveAttribute('type', 'password') + await fireEvent.click(screen.getByRole('button', { name: '显示密码' })) + expect(password).toHaveAttribute('type', 'text') + await fireEvent.click(screen.getByRole('button', { name: '隐藏密码' })) + + expect(password).toHaveAttribute('type', 'password') + expect(mocks.api.post).not.toHaveBeenCalled() + }) + + it('keeps the latest plugin AuthPage when an earlier provider resolves late', async () => { + const alpha = deferred>() + const beta = deferred>() + mocks.api.get.mockResolvedValue([ + { + id: 'plugin:alpha', + type: 'plugin', + name: 'Alpha Login', + enabled: true, + remote: { id: 'alpha', url: '/alpha/remoteEntry.js' }, + }, + { + id: 'plugin:beta', + type: 'plugin', + name: 'Beta Login', + enabled: true, + remote: { id: 'beta', url: '/beta/remoteEntry.js' }, + }, + ]) + mocks.loadRemoteComponentFromModule.mockImplementation((remote: { id: string }) => + remote.id === 'alpha' ? alpha.promise : beta.promise, + ) + await renderLogin() + + await fireEvent.click(await screen.findByRole('button', { name: 'Alpha Login' })) + await fireEvent.click(screen.getByRole('button', { name: 'Beta Login' })) + expect(mocks.loadRemoteComponentFromModule).toHaveBeenCalledTimes(2) + + beta.resolve(remoteAuthPage('Beta Auth Page')) + expect(await screen.findByText('Beta Auth Page')).toBeInTheDocument() + + alpha.resolve(remoteAuthPage('Alpha Auth Page')) + await alpha.promise + await nextTick() + await waitFor(() => { + expect(screen.queryByText('Alpha Auth Page')).not.toBeInTheDocument() + expect(screen.getByText('Beta Auth Page')).toBeInTheDocument() + }) + }) + + it('keeps password login available when provider discovery fails', async () => { + mocks.api.get.mockRejectedValue(new Error('provider unavailable')) + + await renderLogin() + + expect(await screen.findByRole('button', { name: '登录' })).toBeInTheDocument() + expect(screen.queryByText('provider unavailable')).not.toBeInTheDocument() + expect(console.error).toHaveBeenCalledOnce() + }) + + it('does not let delayed provider discovery cancel an explicit password login', async () => { + const providers = deferred() + const passwordLogin = deferred() + let passwordSignal: AbortSignal | undefined + const conditionalAvailability = vi.fn().mockResolvedValue(true) + vi.stubGlobal( + 'PublicKeyCredential', + class PublicKeyCredentialStub { + static isConditionalMediationAvailable = conditionalAvailability + }, + ) + mocks.api.get.mockReturnValue(providers.promise) + mocks.api.post.mockImplementation((url: string, _data: unknown, config?: { signal?: AbortSignal }) => { + if (url === '/login/access-token') { + passwordSignal = config?.signal + return passwordLogin.promise + } + return Promise.resolve({ success: false }) + }) + const { container } = await renderLogin() + + await submitPassword(container) + providers.resolve([]) + await providers.promise + await nextTick() + + expect(passwordSignal?.aborted).toBe(false) + expect(conditionalAvailability).not.toHaveBeenCalled() + expect(mocks.api.post).toHaveBeenCalledTimes(1) + }) + + it('skips provider and Conditional UI startup for a remembered session', async () => { + await renderLogin({ auth: { remember: true, token: 'remembered-token' } }) + + expect(mocks.router.push).toHaveBeenCalledWith('/') + expect(mocks.api.get).not.toHaveBeenCalled() + expect(mocks.api.post).not.toHaveBeenCalled() + }) + + it('stores the password login session and consumes the original destination', async () => { + mocks.api.post.mockResolvedValue(loginResponse()) + const { container } = await renderLogin() + const authStore = useAuthStore() + authStore.setOriginalPath('/recommend?tab=trending#today') + + await submitPassword(container) + + await waitFor(() => expect(mocks.router.push).toHaveBeenCalledWith('/recommend?tab=trending#today')) + expect(authStore.token).toBe('synthetic-token') + expect(authStore.originalPath).toBeNull() + expect(useUserStore().userName).toBe('alice') + expect(useUserStore().permissions.features).toEqual({ 'discovery.recommend': true }) + const formData = mocks.api.post.mock.calls[0][1] as FormData + expect(formData.get('username')).toBe('alice') + expect(formData.get('password')).toBe('secret') + expect(formData.get('otp_password')).toBe('') + }) + + it('does not create a session when no navigation entry is permitted', async () => { + mocks.navMenus = [] + mocks.api.post.mockResolvedValue(loginResponse()) + const { container } = await renderLogin() + + await submitPassword(container) + + expect(await screen.findByText('登录失败,您没有任何功能权限,请联系管理员!')).toBeInTheDocument() + expect(useAuthStore().token).toBeNull() + expect(mocks.router.push).not.toHaveBeenCalled() + }) + + it('routes a login that requires setup to the wizard before any menu', async () => { + mocks.api.post.mockResolvedValue(loginResponse({ wizard: true })) + const { container } = await renderLogin() + + await submitPassword(container) + + await waitFor(() => expect(mocks.router.push).toHaveBeenCalledWith('/setup-wizard')) + expect(mocks.router.push).not.toHaveBeenCalledWith('/home') + }) + + it('maps password HTTP failures to a visible retryable error', async () => { + mocks.api.post.mockRejectedValue({ response: { data: {}, status: 403 } }) + const { container } = await renderLogin() + + await submitPassword(container) + + expect(await screen.findByRole('alert')).toHaveTextContent('登录失败,您没有权限访问!') + expect(screen.getByRole('textbox', { name: '用户名' })).toHaveValue('alice') + expect(useAuthStore().token).toBeNull() + }) + + it('maps a password server failure to the dedicated retryable error', async () => { + mocks.api.post.mockRejectedValue({ response: { data: {}, status: 500 } }) + const { container } = await renderLogin() + + await submitPassword(container) + + expect(await screen.findByRole('alert')).toHaveTextContent('登录失败,服务器错误!') + expect(screen.getByRole('textbox', { name: '用户名' })).toHaveValue('alice') + expect(useAuthStore().token).toBeNull() + }) + + it('maps a password network failure without leaving the login form', async () => { + mocks.api.post.mockRejectedValue(new Error('offline')) + const { container } = await renderLogin() + + await submitPassword(container) + + expect(await screen.findByRole('alert')).toHaveTextContent('登录失败,请检查网络连接!') + expect(screen.getByLabelText('密码')).toHaveValue('secret') + }) + + it('prefers a structured backend message for password failures', async () => { + mocks.api.post.mockRejectedValue({ response: { data: { message: '账号已被停用' }, status: 403 } }) + const { container } = await renderLogin() + + await submitPassword(container) + + expect(await screen.findByRole('alert')).toHaveTextContent('账号已被停用') + }) + + it('includes an unknown HTTP status in the fallback password error', async () => { + mocks.api.post.mockRejectedValue({ response: { data: {}, status: 418 } }) + const { container } = await renderLogin() + + await submitPassword(container) + + expect(await screen.findByRole('alert')).toHaveTextContent('Status: 418') + }) + + it('switches the login page locale from the language menu', async () => { + await renderLogin() + + await fireEvent.click(screen.getByRole('button', { name: /简体中文/ })) + await fireEvent.click(await screen.findByText('English')) + + expect(await screen.findByRole('button', { name: /English/ })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Login' })).toBeInTheDocument() + }) + + it('enters the OTP step only for a supported server-declared method', async () => { + mocks.api.post.mockRejectedValue(mfaRequired(['otp', 'unknown', 'otp'])) + const { container } = await renderLogin() + + await submitPassword(container) + + expect(await screen.findByTestId('mfa-otp-form')).toBeInTheDocument() + expect(screen.queryByRole('textbox', { name: '用户名' })).not.toBeInTheDocument() + }) + + it('keeps password login visible when the MFA response has no supported method', async () => { + mocks.api.post.mockRejectedValue(mfaRequired(['sms'])) + const { container } = await renderLogin() + + await submitPassword(container) + + expect(await screen.findByText('无法获取验证方式,请重新登录')).toBeInTheDocument() + expect(screen.getByRole('textbox', { name: '用户名' })).toBeInTheDocument() + expect(screen.queryByTestId('mfa-otp-form')).not.toBeInTheDocument() + }) + + it('submits the original password with the OTP and completes login', async () => { + mocks.api.post.mockRejectedValueOnce(mfaRequired()).mockResolvedValueOnce(loginResponse()) + const { container } = await renderLogin() + await submitPassword(container) + const otpInput = await screen.findByRole('textbox', { name: '验证码' }) + + await fireEvent.update(otpInput, '123456') + await fireEvent.submit(screen.getByTestId('mfa-otp-form')) + + await waitFor(() => expect(useAuthStore().token).toBe('synthetic-token')) + const formData = mocks.api.post.mock.calls[1][1] as FormData + expect(formData.get('username')).toBe('alice') + expect(formData.get('password')).toBe('secret') + expect(formData.get('otp_password')).toBe('123456') + }) + + it('keeps the OTP step retryable and clears an invalid code', async () => { + mocks.api.post.mockRejectedValueOnce(mfaRequired()).mockRejectedValueOnce({ response: { data: {}, status: 401 } }) + const { container } = await renderLogin() + await submitPassword(container) + const otpInput = await screen.findByRole('textbox', { name: '验证码' }) + + await fireEvent.update(otpInput, '654321') + await fireEvent.submit(screen.getByTestId('mfa-otp-form')) + + expect(await screen.findByText('验证失败,请检查验证码后重试')).toBeInTheDocument() + expect(screen.getByRole('textbox', { name: '验证码' })).toHaveValue('') + expect(screen.getByTestId('mfa-otp-form')).toBeInTheDocument() + }) + + it('keeps the OTP step retryable after a network failure', async () => { + mocks.api.post.mockRejectedValueOnce(mfaRequired()).mockRejectedValueOnce(new Error('offline')) + const { container } = await renderLogin() + await submitPassword(container) + + await fireEvent.update(await screen.findByRole('textbox', { name: '验证码' }), '654321') + await fireEvent.submit(screen.getByTestId('mfa-otp-form')) + + expect(await screen.findByText('登录失败,请检查网络连接!')).toBeInTheDocument() + expect(screen.getByRole('textbox', { name: '验证码' })).toHaveValue('') + expect(screen.getByTestId('mfa-otp-form')).toBeInTheDocument() + }) + + it('returns from OTP verification to a clean password step', async () => { + mocks.api.post.mockRejectedValue(mfaRequired()) + const { container } = await renderLogin() + await submitPassword(container) + + await fireEvent.click(await screen.findByTestId('mfa-back')) + + expect(await screen.findByRole('textbox', { name: '用户名' })).toHaveValue('alice') + expect(screen.queryByTestId('mfa-otp-form')).not.toBeInTheDocument() + }) + + it('ignores an obsolete provider failure after the latest AuthPage loads', async () => { + const alpha = deferred>() + mocks.api.get.mockResolvedValue([ + { + id: 'plugin:alpha', + type: 'plugin', + name: 'Alpha Login', + enabled: true, + remote: { id: 'alpha', url: '/alpha/remoteEntry.js' }, + }, + { + id: 'plugin:beta', + type: 'plugin', + name: 'Beta Login', + enabled: true, + remote: { id: 'beta', url: '/beta/remoteEntry.js' }, + }, + ]) + mocks.loadRemoteComponentFromModule.mockImplementation((remote: { id: string }) => + remote.id === 'alpha' ? alpha.promise : Promise.resolve(remoteAuthPage('Beta Auth Page')), + ) + await renderLogin() + + await fireEvent.click(await screen.findByRole('button', { name: 'Alpha Login' })) + await fireEvent.click(screen.getByRole('button', { name: 'Beta Login' })) + expect(await screen.findByText('Beta Auth Page')).toBeInTheDocument() + alpha.reject(new Error('obsolete failure')) + await alpha.promise.catch(() => undefined) + await nextTick() + + expect(screen.queryByText('obsolete failure')).not.toBeInTheDocument() + expect(screen.getByText('Beta Auth Page')).toBeInTheDocument() + expect(console.error).not.toHaveBeenCalled() + }) + + it('exchanges a plugin ticket through the shared login completion path', async () => { + mocks.api.get.mockResolvedValue([ + { + id: 'plugin:ticket', + type: 'plugin', + name: 'Ticket Login', + enabled: true, + remote: { id: 'ticket', url: '/ticket/remoteEntry.js' }, + }, + ]) + mocks.loadRemoteComponentFromModule.mockResolvedValue(ticketAuthPage('plugin-ticket')) + mocks.api.post.mockResolvedValue(loginResponse({ user_name: 'plugin-user' })) + await renderLogin() + + await fireEvent.click(await screen.findByRole('button', { name: 'Ticket Login' })) + await fireEvent.click(await screen.findByRole('button', { name: 'Complete Plugin Login' })) + + await waitFor(() => expect(useAuthStore().token).toBe('synthetic-token')) + expect(mocks.api.post).toHaveBeenCalledWith('auth/exchange', { ticket: 'plugin-ticket' }) + expect(useUserStore().userName).toBe('plugin-user') + expect(mocks.router.push).toHaveBeenCalledWith('/home') + }) + + it('exchanges only once when the current AuthPage emits the same completion twice', async () => { + const exchange = deferred>() + mocks.api.get.mockResolvedValue([ + { + id: 'plugin:ticket', + type: 'plugin', + name: 'Ticket Login', + enabled: true, + remote: { id: 'ticket', url: '/ticket/remoteEntry.js' }, + }, + ]) + mocks.loadRemoteComponentFromModule.mockResolvedValue(ticketAuthPage('plugin-ticket')) + mocks.api.post.mockReturnValue(exchange.promise) + await renderLogin() + + await fireEvent.click(await screen.findByRole('button', { name: 'Ticket Login' })) + const complete = await screen.findByRole('button', { name: 'Complete Plugin Login' }) + await fireEvent.click(complete) + await fireEvent.click(complete) + + expect(mocks.api.post).toHaveBeenCalledTimes(1) + }) + + it('ignores an earlier password response after plugin login completes', async () => { + const passwordLogin = deferred>() + let passwordSignal: AbortSignal | undefined + mocks.api.get.mockResolvedValue([ + { + id: 'plugin:ticket', + type: 'plugin', + name: 'Ticket Login', + enabled: true, + remote: { id: 'ticket', url: '/ticket/remoteEntry.js' }, + }, + ]) + mocks.loadRemoteComponentFromModule.mockResolvedValue(ticketAuthPage('plugin-ticket')) + mocks.api.post.mockImplementation((url: string, _data: unknown, config?: { signal?: AbortSignal }) => { + if (url === '/login/access-token') { + passwordSignal = config?.signal + return passwordLogin.promise + } + return Promise.resolve(loginResponse({ access_token: 'plugin-token', user_name: 'plugin-user' })) + }) + const { container } = await renderLogin() + + await submitPassword(container) + await fireEvent.click(await screen.findByRole('button', { name: 'Ticket Login' })) + await fireEvent.click(await screen.findByRole('button', { name: 'Complete Plugin Login' })) + await waitFor(() => expect(useUserStore().userName).toBe('plugin-user')) + expect(passwordSignal?.aborted).toBe(true) + + passwordLogin.resolve(loginResponse({ access_token: 'password-token', user_name: 'password-user' })) + await passwordLogin.promise + await nextTick() + + expect(useAuthStore().token).toBe('plugin-token') + expect(useUserStore().userName).toBe('plugin-user') + }) + + it('shows the current plugin ticket exchange failure without closing the dialog', async () => { + mocks.api.get.mockResolvedValue([ + { + id: 'plugin:ticket', + type: 'plugin', + name: 'Ticket Login', + enabled: true, + remote: { id: 'ticket', url: '/ticket/remoteEntry.js' }, + }, + ]) + mocks.loadRemoteComponentFromModule.mockResolvedValue(ticketAuthPage('expired-ticket')) + mocks.api.post.mockRejectedValue({ response: { data: { detail: '认证票据无效或已过期' }, status: 401 } }) + await renderLogin() + + await fireEvent.click(await screen.findByRole('button', { name: 'Ticket Login' })) + await fireEvent.click(await screen.findByRole('button', { name: 'Complete Plugin Login' })) + + expect(await screen.findByText('认证票据无效或已过期')).toBeInTheDocument() + expect(useAuthStore().token).toBeNull() + }) + + it('shows an error emitted by the current plugin AuthPage', async () => { + mocks.api.get.mockResolvedValue([ + { + id: 'plugin:error', + type: 'plugin', + name: 'Error Login', + enabled: true, + remote: { id: 'error', url: '/error/remoteEntry.js' }, + }, + ]) + mocks.loadRemoteComponentFromModule.mockResolvedValue(failingAuthPage(new Error('remote authorization failed'))) + await renderLogin() + + await fireEvent.click(await screen.findByRole('button', { name: 'Error Login' })) + await fireEvent.click(await screen.findByRole('button', { name: 'Fail Plugin Login' })) + + expect(await screen.findByText('remote authorization failed')).toBeInTheDocument() + expect(mocks.api.post).not.toHaveBeenCalled() + }) + + it('shows the current plugin AuthPage load failure', async () => { + mocks.api.get.mockResolvedValue([ + { + id: 'plugin:broken', + type: 'plugin', + name: 'Broken Login', + enabled: true, + remote: { id: 'broken', url: '/broken/remoteEntry.js' }, + }, + ]) + mocks.loadRemoteComponentFromModule.mockRejectedValue(new Error('remote entry unavailable')) + await renderLogin() + + await fireEvent.click(await screen.findByRole('button', { name: 'Broken Login' })) + + expect(await screen.findByText('remote entry unavailable')).toBeInTheDocument() + expect(console.error).toHaveBeenCalledOnce() + }) + + it('rejects a plugin authenticated event without a ticket', async () => { + mocks.api.get.mockResolvedValue([ + { + id: 'plugin:ticket', + type: 'plugin', + name: 'Ticket Login', + enabled: true, + remote: { id: 'ticket', url: '/ticket/remoteEntry.js' }, + }, + ]) + mocks.loadRemoteComponentFromModule.mockResolvedValue(ticketAuthPage()) + await renderLogin() + + await fireEvent.click(await screen.findByRole('button', { name: 'Ticket Login' })) + await fireEvent.click(await screen.findByRole('button', { name: 'Complete Plugin Login' })) + + expect(await screen.findByText('登录失败,请检查用户名、密码或验证码')).toBeInTheDocument() + expect(mocks.api.post).not.toHaveBeenCalled() + }) + + it('does not wait for WebPush subscription before completing an administrator login', async () => { + const webPushRequest = deferred() + const subscription = { endpoint: 'https://push.invalid/synthetic' } + vi.stubGlobal('PushManager', class PushManagerStub {}) + vi.stubGlobal('navigator', { + serviceWorker: { + ready: Promise.resolve({ + pushManager: { + getSubscription: vi.fn().mockResolvedValue(subscription), + }, + }), + }, + }) + mocks.api.post.mockImplementation((url: string) => { + if (url === '/message/webpush/subscribe') return webPushRequest.promise + return Promise.resolve(loginResponse({ super_user: true })) + }) + const { container } = await renderLogin() + + await submitPassword(container) + + await waitFor(() => expect(mocks.router.push).toHaveBeenCalledWith('/home')) + expect(useAuthStore().token).toBe('synthetic-token') + await waitFor(() => expect(mocks.api.post).toHaveBeenCalledWith('/message/webpush/subscribe', subscription)) + }) + + it('converts Passkey options and credential buffers for the authentication endpoints', async () => { + const credentialGet = vi.fn().mockResolvedValue({ + id: 'credential-id', + rawId: new Uint8Array([1, 2]).buffer, + response: { + authenticatorData: new Uint8Array([3]).buffer, + clientDataJSON: new Uint8Array([4]).buffer, + signature: new Uint8Array([5]).buffer, + userHandle: new Uint8Array([6]).buffer, + }, + type: 'public-key', + }) + vi.stubGlobal('PublicKeyCredential', class PublicKeyCredentialStub {}) + vi.stubGlobal('navigator', { credentials: { get: credentialGet } }) + mocks.api.get.mockResolvedValue([ + { + id: 'system:passkey', + type: 'system', + method: 'passkey', + name: '通行密钥', + enabled: true, + }, + ]) + mocks.api.post.mockImplementation((url: string) => { + if (url === '/mfa/passkey/authenticate/start') { + return Promise.resolve({ + success: true, + data: { + options: JSON.stringify({ + allowCredentials: [{ id: 'AwQ', type: 'public-key' }], + challenge: 'AQI', + timeout: 60_000, + }), + transaction_token: 'transaction-1', + }, + }) + } + return Promise.resolve(loginResponse({ user_name: 'passkey-user' })) + }) + const { container } = await renderLogin() + + await fireEvent.click(await waitFor(() => container.querySelector('.passkey-btn')!)) + + await waitFor(() => expect(useAuthStore().token).toBe('synthetic-token')) + const credentialOptions = credentialGet.mock.calls[0][0] as CredentialRequestOptions + expect(Array.from(new Uint8Array(credentialOptions.publicKey!.challenge as ArrayBuffer))).toEqual([1, 2]) + expect(Array.from(new Uint8Array(credentialOptions.publicKey!.allowCredentials![0].id as ArrayBuffer))).toEqual([ + 3, 4, + ]) + expect(credentialOptions.mediation).toBeUndefined() + expect(mocks.api.post).toHaveBeenNthCalledWith( + 1, + '/mfa/passkey/authenticate/start', + {}, + { + signal: expect.any(AbortSignal), + }, + ) + expect(mocks.api.post).toHaveBeenNthCalledWith( + 2, + '/mfa/passkey/authenticate/finish', + { + credential: { + id: 'credential-id', + rawId: 'AQI', + response: { + authenticatorData: 'Aw', + clientDataJSON: 'BA', + signature: 'BQ', + userHandle: 'Bg', + }, + type: 'public-key', + }, + transaction_token: 'transaction-1', + }, + { + signal: expect.any(AbortSignal), + }, + ) + expect(useUserStore().userName).toBe('passkey-user') + }) + + it('shows a visible error when a manual Passkey start is rejected', async () => { + vi.stubGlobal('PublicKeyCredential', class PublicKeyCredentialStub {}) + mocks.api.get.mockResolvedValue([ + { + id: 'system:passkey', + type: 'system', + method: 'passkey', + name: '通行密钥', + enabled: true, + }, + ]) + mocks.api.post.mockResolvedValue({ success: false, message: '认证失败' }) + const { container } = await renderLogin() + + await fireEvent.click(await waitFor(() => container.querySelector('.passkey-btn')!)) + + expect(await screen.findByRole('alert')).toHaveTextContent('登录失败,请检查用户名、密码或验证码') + expect(useAuthStore().token).toBeNull() + }) + + it('aborts an in-flight Passkey finish request when password login takes ownership', async () => { + const finish = deferred>() + let finishSignal: AbortSignal | undefined + vi.stubGlobal('PublicKeyCredential', class PublicKeyCredentialStub {}) + vi.stubGlobal('navigator', { + credentials: { + get: vi.fn().mockResolvedValue({ + id: 'credential-id', + rawId: new Uint8Array([1]).buffer, + response: { + authenticatorData: new Uint8Array([2]).buffer, + clientDataJSON: new Uint8Array([3]).buffer, + signature: new Uint8Array([4]).buffer, + userHandle: null, + }, + type: 'public-key', + }), + }, + }) + mocks.api.get.mockResolvedValue([ + { + id: 'system:passkey', + type: 'system', + method: 'passkey', + name: '通行密钥', + enabled: true, + }, + ]) + mocks.api.post.mockImplementation((url: string, _data: unknown, config?: { signal?: AbortSignal }) => { + if (url === '/mfa/passkey/authenticate/start') { + return Promise.resolve({ + success: true, + data: { + options: JSON.stringify({ challenge: 'AQI' }), + transaction_token: 'finish-transaction', + }, + }) + } + if (url === '/mfa/passkey/authenticate/finish') { + finishSignal = config?.signal + return finish.promise + } + return new Promise(() => {}) + }) + const { container } = await renderLogin() + + await fireEvent.click(await waitFor(() => container.querySelector('.passkey-btn')!)) + await waitFor(() => + expect(mocks.api.post).toHaveBeenCalledWith( + '/mfa/passkey/authenticate/finish', + expect.any(Object), + expect.any(Object), + ), + ) + await submitPassword(container) + + expect(finishSignal?.aborted).toBe(true) + finish.resolve(loginResponse({ access_token: 'passkey-token', user_name: 'passkey-user' })) + await finish.promise + await nextTick() + expect(useAuthStore().token).toBeNull() + expect(useUserStore().userName).toBe('') + }) + + it('shows a cancellation message when the user dismisses manual Passkey selection', async () => { + const canceled = new Error('cancelled') + canceled.name = 'NotAllowedError' + vi.stubGlobal('PublicKeyCredential', class PublicKeyCredentialStub {}) + vi.stubGlobal('navigator', { credentials: { get: vi.fn().mockRejectedValue(canceled) } }) + mocks.api.get.mockResolvedValue([ + { + id: 'system:passkey', + type: 'system', + method: 'passkey', + name: '通行密钥', + enabled: true, + }, + ]) + mocks.api.post.mockResolvedValue({ + success: true, + data: { + options: JSON.stringify({ challenge: 'AQI' }), + transaction_token: 'cancel-transaction', + }, + }) + const { container } = await renderLogin() + + await fireEvent.click(await waitFor(() => container.querySelector('.passkey-btn')!)) + + expect(await screen.findByRole('alert')).toHaveTextContent('通行密钥认证被取消') + expect(useAuthStore().token).toBeNull() + }) + + it('reports unsupported manual Passkey capability without starting an API request', async () => { + mocks.api.get.mockResolvedValue([ + { + id: 'system:passkey', + type: 'system', + method: 'passkey', + name: '通行密钥', + enabled: true, + }, + ]) + const { container } = await renderLogin() + + await fireEvent.click(await waitFor(() => container.querySelector('.passkey-btn')!)) + + expect(await screen.findByText('通行密钥需要 HTTPS 安全连接')).toBeInTheDocument() + expect(mocks.api.post).not.toHaveBeenCalled() + }) + + it('aborts an unfinished Conditional UI request when the page unmounts', async () => { + let capturedSignal: AbortSignal | undefined + const credentialGet = vi.fn((options: CredentialRequestOptions) => { + capturedSignal = options.signal + return new Promise(() => {}) + }) + vi.stubGlobal( + 'PublicKeyCredential', + class PublicKeyCredentialStub { + static isConditionalMediationAvailable = vi.fn().mockResolvedValue(true) + }, + ) + vi.stubGlobal('navigator', { credentials: { get: credentialGet } }) + mocks.api.post.mockResolvedValue({ + success: true, + data: { + options: JSON.stringify({ challenge: 'AQI' }), + transaction_token: 'conditional-transaction', + }, + }) + const { unmount } = await renderLogin() + await waitFor(() => expect(credentialGet).toHaveBeenCalledOnce()) + + unmount() + + expect(capturedSignal?.aborted).toBe(true) + }) + + it('does not start Conditional UI after its capability check resolves on an unmounted page', async () => { + const capability = deferred() + vi.stubGlobal( + 'PublicKeyCredential', + class PublicKeyCredentialStub { + static isConditionalMediationAvailable = vi.fn(() => capability.promise) + }, + ) + vi.stubGlobal('navigator', { credentials: { get: vi.fn() } }) + const { unmount } = await renderLogin() + await waitFor(() => expect(PublicKeyCredential.isConditionalMediationAvailable).toHaveBeenCalledOnce()) + + unmount() + capability.resolve(true) + await capability.promise + await nextTick() + + expect(mocks.api.post).not.toHaveBeenCalled() + }) + + it('aborts Conditional UI when the user submits password login', async () => { + let capturedSignal: AbortSignal | undefined + const credentialGet = vi.fn((options: CredentialRequestOptions) => { + capturedSignal = options.signal + return new Promise(() => {}) + }) + vi.stubGlobal( + 'PublicKeyCredential', + class PublicKeyCredentialStub { + static isConditionalMediationAvailable = vi.fn().mockResolvedValue(true) + }, + ) + vi.stubGlobal('navigator', { credentials: { get: credentialGet } }) + mocks.api.post.mockImplementation((url: string) => { + if (url === '/mfa/passkey/authenticate/start') { + return Promise.resolve({ + success: true, + data: { + options: JSON.stringify({ challenge: 'AQI' }), + transaction_token: 'conditional-transaction', + }, + }) + } + return new Promise(() => {}) + }) + const { container } = await renderLogin() + await waitFor(() => expect(credentialGet).toHaveBeenCalledOnce()) + + await submitPassword(container) + + expect(capturedSignal?.aborted).toBe(true) + expect(mocks.api.post).toHaveBeenCalledWith('/login/access-token', expect.any(FormData), expect.any(Object)) + }) + + it('aborts manual Passkey before password login can take ownership', async () => { + let capturedSignal: AbortSignal | undefined + const credentialGet = vi.fn((options: CredentialRequestOptions) => { + capturedSignal = options.signal + return new Promise(() => {}) + }) + vi.stubGlobal('PublicKeyCredential', class PublicKeyCredentialStub {}) + vi.stubGlobal('navigator', { credentials: { get: credentialGet } }) + mocks.api.get.mockResolvedValue([ + { + id: 'system:passkey', + type: 'system', + method: 'passkey', + name: '通行密钥', + enabled: true, + }, + ]) + mocks.api.post.mockImplementation((url: string) => { + if (url === '/mfa/passkey/authenticate/start') { + return Promise.resolve({ + success: true, + data: { + options: JSON.stringify({ challenge: 'AQI' }), + transaction_token: 'manual-transaction', + }, + }) + } + return new Promise(() => {}) + }) + const { container } = await renderLogin() + + await fireEvent.click(await waitFor(() => container.querySelector('.passkey-btn')!)) + await waitFor(() => expect(credentialGet).toHaveBeenCalledOnce()) + await submitPassword(container) + + expect(capturedSignal?.aborted).toBe(true) + expect(mocks.api.post).not.toHaveBeenCalledWith('/mfa/passkey/authenticate/finish', expect.any(Object)) + }) +}) diff --git a/src/pages/login.vue b/src/pages/login.vue index 6eb6ddca..03f6168c 100644 --- a/src/pages/login.vue +++ b/src/pages/login.vue @@ -53,7 +53,7 @@ function handleLabTap(target: LabTapTarget) { } // 国际化 -const { t, te } = useI18n() +const { t } = useI18n() // 应用版本号(构建时注入,形如 v2.13.10) const appVersion = typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : '' @@ -111,6 +111,9 @@ let conditionalAbortController: AbortController | null = null // 手动模式的 AbortController(用于防止重复点击) let manualAbortController: AbortController | null = null +// 密码与 OTP 请求必须可取消,认证方式切换后不得接收旧会话的资源 Cookie。 +let passwordAbortController: AbortController | null = null + // 标记当前是否有手动模式的 PassKey 请求正在进行 let isManualPassKeyActive = false @@ -165,6 +168,31 @@ const RemoteAuthView = shallowRef(null) const pluginAuthDialog = ref(false) const pluginAuthLoading = ref(false) const pluginAuthError = ref('') +// Remote generation 只保护组件加载;login generation 统一保护不同认证方式的会话写入。 +let pluginAuthLoadGeneration = 0 +let loginAttemptGeneration = 0 +let pluginLoginAttemptGeneration = 0 +let isPageActive = true + +function beginLoginAttempt() { + loginAttemptGeneration += 1 + return loginAttemptGeneration +} + +function isCurrentLoginAttempt(generation: number) { + return generation === loginAttemptGeneration +} + +function abortPasswordLoginRequest() { + passwordAbortController?.abort() + passwordAbortController = null +} + +function beginPasswordLoginRequest() { + abortPasswordLoginRequest() + passwordAbortController = new AbortController() + return passwordAbortController +} const systemPasskeyProvider = computed(() => authProviders.value.find(provider => provider.type === 'system' && provider.method === 'passkey'), @@ -221,6 +249,8 @@ function enterMfaStep(methodsValue: unknown) { // 用户主动返回账号密码步骤时清理未完成的二次验证。 function leaveMfaStep() { + beginLoginAttempt() + abortPasswordLoginRequest() manualAbortController?.abort() manualAbortController = null mfaOtpLoading.value = false @@ -244,27 +274,43 @@ async function loadAuthProviders() { // 打开插件认证联邦页面。 async function openPluginAuth(provider: LoginAuthProvider) { if (!provider.remote) return + abortPasswordLoginRequest() + conditionalAbortController?.abort() + conditionalAbortController = null + manualAbortController?.abort() + manualAbortController = null + isManualPassKeyActive = false + passkeyLoading.value = false + pluginLoginAttemptGeneration = beginLoginAttempt() + loading.value = false + const loadGeneration = ++pluginAuthLoadGeneration selectedAuthProvider.value = provider RemoteAuthView.value = null pluginAuthError.value = '' pluginAuthLoading.value = true pluginAuthDialog.value = true try { - RemoteAuthView.value = (await loadRemoteComponentFromModule( + const remoteAuthView = (await loadRemoteComponentFromModule( provider.remote, provider.component || 'AuthPage', )) as Component + if (loadGeneration !== pluginAuthLoadGeneration) return + RemoteAuthView.value = remoteAuthView } catch (error: unknown) { + if (loadGeneration !== pluginAuthLoadGeneration) return console.error('加载插件认证页面失败:', error) pluginAuthError.value = getErrorMessage(error) || t('login.authFailure') } finally { - pluginAuthLoading.value = false + if (loadGeneration === pluginAuthLoadGeneration) pluginAuthLoading.value = false } } // 关闭插件认证弹窗。 function closePluginAuth() { + beginLoginAttempt() + pluginAuthLoadGeneration += 1 pluginAuthDialog.value = false + pluginAuthLoading.value = false selectedAuthProvider.value = null RemoteAuthView.value = null pluginAuthError.value = '' @@ -272,19 +318,22 @@ function closePluginAuth() { // 兑换插件认证票据并完成系统登录。 async function exchangePluginAuthTicket(ticket: string) { + const loginGeneration = pluginLoginAttemptGeneration pluginAuthLoading.value = true try { const response = (await api.post('auth/exchange', { ticket })) as PassKeyFinishResponse + if (!isCurrentLoginAttempt(loginGeneration)) return closePluginAuth() await handleLoginSuccess(response) } catch (error: unknown) { + if (!isCurrentLoginAttempt(loginGeneration)) return console.error('插件认证票据兑换失败:', error) const apiError = asApiError(error) const message = apiError.response?.data?.message || apiError.response?.data?.detail pluginAuthError.value = (typeof message === 'string' ? message : undefined) || getErrorMessage(error) || t('login.authFailure') } finally { - pluginAuthLoading.value = false + if (isCurrentLoginAttempt(loginGeneration)) pluginAuthLoading.value = false } } @@ -294,6 +343,7 @@ async function handlePluginAuthenticated(payload: PluginAuthPayload) { pluginAuthError.value = t('login.authFailure') return } + if (pluginAuthLoading.value) return await exchangePluginAuthTicket(payload.ticket) } @@ -331,10 +381,9 @@ async function authenticateWithPassKey(options: PassKeyAuthOptions = {}): Promis const { username, isConditional = false, signal } = options // 1. 开始认证流程 - const startResponse = (await api.post( - '/mfa/passkey/authenticate/start', - username ? { username } : {}, - )) as ApiResponse + const startResponse = (await api.post('/mfa/passkey/authenticate/start', username ? { username } : {}, { + signal, + })) as ApiResponse if (!startResponse.success) { throw new Error(startResponse.message || 'PassKey start failed') @@ -355,13 +404,11 @@ async function authenticateWithPassKey(options: PassKeyAuthOptions = {}): Promis }, } - // 如果是 Conditional UI 模式,添加 mediation 和 signal + // Conditional UI 额外声明 mediation;两种模式都必须响应认证方式切换产生的取消信号。 if (isConditional) { credentialRequestOptions.mediation = 'conditional' - if (signal) { - credentialRequestOptions.signal = signal - } } + if (signal) credentialRequestOptions.signal = signal const credential = await navigator.credentials.get(credentialRequestOptions) @@ -373,6 +420,7 @@ async function authenticateWithPassKey(options: PassKeyAuthOptions = {}): Promis if (!credential) { throw new Error('No credential selected') } + if (signal?.aborted) throw new DOMException('PassKey authentication aborted', 'AbortError') // 3. 转换credential为可传输格式 const publicKeyCredential = credential as PublicKeyCredential @@ -390,10 +438,14 @@ async function authenticateWithPassKey(options: PassKeyAuthOptions = {}): Promis } // 4. 完成认证 - const finishResponse = (await api.post('/mfa/passkey/authenticate/finish', { - credential: credentialJSON, - transaction_token: transactionToken, - })) as PassKeyFinishResponse + const finishResponse = (await api.post( + '/mfa/passkey/authenticate/finish', + { + credential: credentialJSON, + transaction_token: transactionToken, + }, + { signal }, + )) as PassKeyFinishResponse if (!finishResponse || !finishResponse.access_token) { throw new Error('PassKey finish failed: No access token') @@ -408,6 +460,9 @@ async function handlePassKeyAuth( setLoading: (loading: boolean) => void, onSuccess: (response: PassKeyFinishResponse) => Promise, ) { + abortPasswordLoginRequest() + const loginGeneration = beginLoginAttempt() + loading.value = false const { isConditional = false } = authOptions errorMessage.value = '' @@ -455,8 +510,10 @@ async function handlePassKeyAuth( : undefined, }) + if (!isCurrentLoginAttempt(loginGeneration)) return await onSuccess(finishResponse) } catch (error: unknown) { + if (!isCurrentLoginAttempt(loginGeneration)) return const errorName = error instanceof Error ? error.name : '' const message = getErrorMessage(error) @@ -485,16 +542,18 @@ async function handlePassKeyAuth( errorMessage.value = t('login.authFailure') } } finally { - // 清除 loading 状态 - if (!isConditional) { - // 手动模式:始终清除,并取消手动活跃标记 - isManualPassKeyActive = false - setLoading(false) - manualAbortController = null - } else { - // Conditional UI 模式:只有在没有手动请求活跃时才清除 - if (!isManualPassKeyActive && passkeyLoading.value) { - passkeyLoading.value = false + if (isCurrentLoginAttempt(loginGeneration)) { + // 清除 loading 状态 + if (!isConditional) { + // 手动模式:始终清除,并取消手动活跃标记 + isManualPassKeyActive = false + setLoading(false) + manualAbortController = null + } else { + // Conditional UI 模式:只有在没有手动请求活跃时才清除 + if (!isManualPassKeyActive && passkeyLoading.value) { + passkeyLoading.value = false + } } } } @@ -601,13 +660,14 @@ async function handleLoginSuccess(response: PassKeyFinishResponse) { await afterLogin(userPayload.superUser, userPayload, filteredMenus) } -async function requestPasswordLogin(): Promise { +async function requestPasswordLogin(signal: AbortSignal): Promise { const formData = new FormData() formData.append('username', form.value.username) formData.append('password', form.value.password) formData.append('otp_password', form.value.otp_password) return (await api.post('/login/access-token', formData, { + signal, headers: { Accept: 'application/json', }, @@ -643,17 +703,29 @@ function setLoginError(error: unknown) { } async function login() { + if (loading.value) return + errorMessage.value = '' syncLoginCredentialValues() if (!form.value.username || !form.value.password) return + conditionalAbortController?.abort() + conditionalAbortController = null + manualAbortController?.abort() + manualAbortController = null + isManualPassKeyActive = false + passkeyLoading.value = false + const loginGeneration = beginLoginAttempt() + const passwordController = beginPasswordLoginRequest() form.value.otp_password = '' loading.value = true try { - const response = await requestPasswordLogin() + const response = await requestPasswordLogin(passwordController.signal) + if (!isCurrentLoginAttempt(loginGeneration)) return await handleLoginSuccess(response) } catch (error: unknown) { + if (!isCurrentLoginAttempt(loginGeneration)) return const apiError = asApiError(error) if (apiError.response?.headers?.['x-mfa-required'] === 'true') { enterMfaStep(apiError.response.data?.mfa_methods) @@ -661,7 +733,8 @@ async function login() { } setLoginError(error) } finally { - loading.value = false + if (passwordAbortController === passwordController) passwordAbortController = null + if (isCurrentLoginAttempt(loginGeneration)) loading.value = false } } @@ -669,12 +742,16 @@ async function login() { async function loginWithOTP() { if (!form.value.otp_password || mfaOtpLoading.value) return + const loginGeneration = beginLoginAttempt() + const passwordController = beginPasswordLoginRequest() errorMessage.value = '' mfaOtpLoading.value = true try { - const response = await requestPasswordLogin() + const response = await requestPasswordLogin(passwordController.signal) + if (!isCurrentLoginAttempt(loginGeneration)) return await handleLoginSuccess(response) } catch (error: unknown) { + if (!isCurrentLoginAttempt(loginGeneration)) return const apiError = asApiError(error) if (!apiError.response) { errorMessage.value = t('login.networkError') @@ -685,7 +762,8 @@ async function loginWithOTP() { } form.value.otp_password = '' } finally { - mfaOtpLoading.value = false + if (passwordAbortController === passwordController) passwordAbortController = null + if (isCurrentLoginAttempt(loginGeneration)) mfaOtpLoading.value = false } } @@ -701,8 +779,12 @@ onMounted(async () => { return } + // Conditional UI 是后台增强,只能在页面仍存活且没有显式认证尝试取得所有权时启动。 + const startupGeneration = loginAttemptGeneration + // 加载系统和插件声明的未登录认证入口 await loadAuthProviders() + if (!isPageActive || !isCurrentLoginAttempt(startupGeneration)) return // 初始化 Conditional UI 的 PassKey 自动填充 await initConditionalPasskey() @@ -716,8 +798,9 @@ async function initConditionalPasskey() { } try { + const startupGeneration = loginAttemptGeneration const available = await PublicKeyCredential.isConditionalMediationAvailable() - if (!available) { + if (!available || !isPageActive || !isCurrentLoginAttempt(startupGeneration)) { return } @@ -739,6 +822,9 @@ async function initConditionalPasskey() { // 组件卸载时清理 onUnmounted(() => { + isPageActive = false + beginLoginAttempt() + abortPasswordLoginRequest() if (conditionalAbortController) { conditionalAbortController.abort() conditionalAbortController = null diff --git a/vite.config.ts b/vite.config.ts index bc40505d..f744f2a5 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -314,6 +314,8 @@ export default defineConfig(({ command, mode, isPreview }) => ({ 'src/stores/auth.ts', 'src/stores/user.ts', 'src/stores/pluginSidebarNav.ts', + 'src/components/auth/LoginMfaStep.vue', + 'src/pages/login.vue', 'src/pages/appcenter.vue', 'src/pages/recommend.vue', 'src/pages/discover.vue', @@ -640,6 +642,18 @@ export default defineConfig(({ command, mode, isPreview }) => ({ lines: 90, statements: 90, }, + 'src/components/auth/LoginMfaStep.vue': { + branches: 85, + functions: 90, + lines: 90, + statements: 90, + }, + 'src/pages/login.vue': { + branches: 75, + functions: 80, + lines: 80, + statements: 80, + }, 'src/stores/pluginSidebarNav.ts': { branches: 85, functions: 90,