fix(auth): wait for initialization status before login

This commit is contained in:
jxxghp
2026-08-30 20:58:56 +08:00
parent ca48c14642
commit 1f2b3e908f
8 changed files with 168 additions and 13 deletions
+1
View File
@@ -4558,6 +4558,7 @@ export default {
title: 'Welcome to MoviePilot',
subtitle: 'Create the single super administrator account before you start managing your media library.',
checking: 'Checking instance status…',
statusRetrying: 'The service is starting. Retrying automatically…',
accountSection: 'Administrator account',
username: 'Super administrator username',
usernamePlaceholder: 'For example: admin',
+1
View File
@@ -4486,6 +4486,7 @@ export default {
title: '欢迎使用 MoviePilot',
subtitle: '先创建唯一的超级管理员,再开始管理你的媒体库。整个过程只需一步。',
checking: '正在检查实例状态…',
statusRetrying: '服务正在启动,稍后将自动重试…',
accountSection: '管理员账号',
username: '超级管理员用户名',
usernamePlaceholder: '例如:admin',
+1
View File
@@ -4483,6 +4483,7 @@ export default {
title: '歡迎使用 MoviePilot',
subtitle: '先建立唯一的超級管理員,再開始管理你的媒體庫。整個過程只需一步。',
checking: '正在檢查實例狀態…',
statusRetrying: '服務正在啟動,稍後將自動重試…',
accountSection: '管理員帳號',
username: '超級管理員使用者名稱',
usernamePlaceholder: '例如:admin',
+63
View File
@@ -0,0 +1,63 @@
import InitializePage from '@/pages/initialize.vue'
import { renderWithProviders } from '@tests/support/render'
import { screen } from '@testing-library/vue'
import { nextTick } from 'vue'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
api: { post: vi.fn() },
getApiBusinessErrorMessage: vi.fn(),
getInitializationState: vi.fn(),
markInitialized: vi.fn(),
router: { replace: vi.fn() },
toast: { success: vi.fn() },
}))
vi.mock('@/api', () => ({
default: mocks.api,
getApiBusinessErrorMessage: mocks.getApiBusinessErrorMessage,
}))
vi.mock('@/router', () => ({
default: mocks.router,
}))
vi.mock('@/utils/initialization', () => ({
getInitializationState: mocks.getInitializationState,
markInitialized: mocks.markInitialized,
}))
vi.mock('vue-toastification', () => ({
useToast: () => mocks.toast,
}))
describe('initialization page', () => {
beforeEach(() => {
mocks.getInitializationState.mockReset()
mocks.router.replace.mockReset()
})
afterEach(() => {
vi.useRealTimers()
})
it('waits for the backend and retries before enabling the form', async () => {
vi.useFakeTimers()
mocks.getInitializationState.mockRejectedValueOnce(new Error('service starting')).mockResolvedValueOnce(false)
await renderWithProviders(InitializePage)
await nextTick()
await Promise.resolve()
await nextTick()
expect(screen.getByText('服务正在启动,稍后将自动重试…')).toBeInTheDocument()
expect(screen.getByRole('textbox', { name: '超级管理员用户名' })).toBeDisabled()
await vi.advanceTimersByTimeAsync(1500)
await nextTick()
expect(mocks.getInitializationState).toHaveBeenCalledTimes(2)
expect(screen.queryByText('服务正在启动,稍后将自动重试…')).not.toBeInTheDocument()
expect(screen.getByRole('textbox', { name: '超级管理员用户名' })).toBeEnabled()
})
})
+55 -12
View File
@@ -27,9 +27,13 @@ const isPasswordVisible = ref(false)
const isConfirmPasswordVisible = ref(false)
const loading = ref(false)
const checking = ref(true)
const statusRetrying = ref(false)
const errorMessage = ref('')
const apiKeyCopied = ref(false)
const formRef = ref<HTMLFormElement | null>(null)
const INITIALIZATION_STATUS_RETRY_MS = 1500
let statusRetryTimer: ReturnType<typeof setTimeout> | undefined
let disposed = false
const form = ref<InitializationPayload>({
username: '',
@@ -40,6 +44,9 @@ const form = ref<InitializationPayload>({
const currentTheme = computed(() => theme.global.name.value)
const themeClass = computed(() => 'initialize-page--' + currentTheme.value)
const statusMessage = computed(() =>
t(statusRetrying.value ? 'initialization.statusRetrying' : 'initialization.checking'),
)
/** 使用浏览器密码学随机源生成一次性 API Key,避免把凭据交给第三方服务。 */
function generateApiKey(): string {
@@ -73,6 +80,26 @@ function getErrorMessage(error: unknown): string {
return getApiBusinessErrorMessage(error) || t('initialization.saveFailed')
}
/** 持续确认实例状态;服务尚未就绪时保持表单锁定并自动重试。 */
async function checkInitializationStatus() {
try {
const initialized = await getInitializationState(true)
if (disposed) return
statusRetrying.value = false
if (initialized) {
await router.replace('/login')
return
}
checking.value = false
} catch {
if (disposed) return
statusRetrying.value = true
statusRetryTimer = setTimeout(() => {
void checkInitializationStatus()
}, INITIALIZATION_STATUS_RETRY_MS)
}
}
/** 提交首次初始化;后端会再次校验“零用户”条件,防止重复认领实例。 */
async function submit() {
if (loading.value) return
@@ -97,15 +124,14 @@ async function submit() {
}
}
onMounted(async () => {
onMounted(() => {
form.value.api_key = generateApiKey()
try {
if (await getInitializationState(true)) await router.replace('/login')
} catch (error) {
errorMessage.value = getErrorMessage(error)
} finally {
checking.value = false
}
void checkInitializationStatus()
})
onBeforeUnmount(() => {
disposed = true
if (statusRetryTimer) clearTimeout(statusRetryTimer)
})
</script>
@@ -159,12 +185,19 @@ onMounted(async () => {
</VCardItem>
<VCardText class="px-0 pt-6">
<VAlert v-if="errorMessage" type="error" variant="tonal" class="mb-5" closable @click:close="errorMessage = ''">
<VAlert
v-if="errorMessage"
type="error"
variant="tonal"
class="mb-5"
closable
@click:close="errorMessage = ''"
>
{{ errorMessage }}
</VAlert>
<VAlert v-if="checking" type="info" variant="tonal" class="mb-5">
{{ t('initialization.checking') }}
{{ statusMessage }}
</VAlert>
<form ref="formRef" class="initialize-form" novalidate @submit.prevent="submit">
@@ -254,11 +287,21 @@ onMounted(async () => {
minlength="16"
>
<template #append-inner>
<VBtn icon="mdi-content-copy" variant="text" size="small" :aria-label="t('initialization.copyApiKey')" @click="copyApiKey" />
<VBtn
icon="mdi-content-copy"
variant="text"
size="small"
:aria-label="t('initialization.copyApiKey')"
@click="copyApiKey"
/>
</template>
</VTextField>
<div class="initialize-key-panel__actions">
<span><VIcon icon="mdi-information-outline" size="15" class="me-1" />{{ t('initialization.apiKeyWarning') }}</span>
<span
><VIcon icon="mdi-information-outline" size="15" class="me-1" />{{
t('initialization.apiKeyWarning')
}}</span
>
<VBtn variant="text" size="small" prepend-icon="mdi-refresh" @click="regenerateApiKey">
{{ t('initialization.regenerate') }}
</VBtn>
+34
View File
@@ -18,6 +18,7 @@ type Redirect = () => string
const routerMocks = vi.hoisted(() => ({
afterEach: undefined as AfterEachHook | undefined,
getInitializationState: vi.fn(),
guard: undefined as NavigationGuard | undefined,
next: vi.fn(),
routes: [] as Array<{ path: string; redirect?: Redirect }>,
@@ -43,6 +44,10 @@ vi.mock('@/api/nprogress', () => ({
configureNProgress: vi.fn(),
}))
vi.mock('@/utils/initialization', () => ({
getInitializationState: routerMocks.getInitializationState,
}))
vi.mock('@/utils/requestOptimizer', () => ({
abortAllRequests: vi.fn(),
initializeRequestOptimizer: vi.fn(),
@@ -66,6 +71,8 @@ describe('authentication route guard', () => {
beforeEach(() => {
setActivePinia(createPinia())
routerMocks.next.mockReset()
routerMocks.getInitializationState.mockReset()
routerMocks.getInitializationState.mockResolvedValue(true)
routerMocks.setRequestNavigatingState.mockReset()
})
@@ -106,6 +113,33 @@ describe('authentication route guard', () => {
expect(routerMocks.next).toHaveBeenCalledWith()
})
it('redirects to initialization when the instance has no user', async () => {
routerMocks.getInitializationState.mockResolvedValue(false)
await runGuard(route({ fullPath: '/login', path: '/login' }))
expect(routerMocks.next).toHaveBeenCalledOnce()
expect(routerMocks.next).toHaveBeenCalledWith('/initialize')
expect(routerMocks.setRequestNavigatingState).toHaveBeenLastCalledWith(false)
})
it('redirects to initialization while the status endpoint is unavailable', async () => {
routerMocks.getInitializationState.mockRejectedValue(new Error('service starting'))
await runGuard(route({ fullPath: '/login', path: '/login' }))
expect(routerMocks.next).toHaveBeenCalledOnce()
expect(routerMocks.next).toHaveBeenCalledWith('/initialize')
expect(routerMocks.setRequestNavigatingState).toHaveBeenLastCalledWith(false)
})
it('keeps initialized instances out of the initialization page', async () => {
await runGuard(route({ fullPath: '/initialize', path: '/initialize' }))
expect(routerMocks.next).toHaveBeenCalledOnce()
expect(routerMocks.next).toHaveBeenCalledWith('/login')
})
it('allows ordinary protected routes and enforces declared permissions', async () => {
const authStore = useAuthStore()
const userStore = useUserStore()
@@ -13,6 +13,7 @@ type NavigationGuard = (
) => Promise<void>
const routerMocks = vi.hoisted(() => ({
getInitializationState: vi.fn(),
guard: undefined as NavigationGuard | undefined,
next: vi.fn(),
push: vi.fn(),
@@ -34,6 +35,10 @@ vi.mock('@/api/nprogress', () => ({
configureNProgress: vi.fn(),
}))
vi.mock('@/utils/initialization', () => ({
getInitializationState: routerMocks.getInitializationState,
}))
vi.mock('@/utils/requestOptimizer', () => ({
abortAllRequests: vi.fn(),
initializeRequestOptimizer: vi.fn(),
@@ -70,6 +75,8 @@ describe('plugin sidebar route permission', () => {
beforeEach(() => {
setActivePinia(createPinia())
useAuthStore().login({ remember: false, token: 'test-token' })
routerMocks.getInitializationState.mockReset()
routerMocks.getInitializationState.mockResolvedValue(true)
routerMocks.next.mockReset()
})
+6 -1
View File
@@ -360,7 +360,12 @@ router.beforeEach(async (to: any, from: any, next: any) => {
try {
initialized = await getInitializationState()
} catch {
// 老版本后端或服务暂不可用时保留原有导航,页面自身会显示请求错误
// 未确认初始化状态时必须进入初始化页,由页面持续等待后端就绪,避免启动竞态误放行登录页
if (to.path !== '/initialize') {
setRequestNavigatingState(false)
next('/initialize')
return
}
}
if (initialized === false && to.path !== '/initialize') {