mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-10 02:06:44 +08:00
fix(auth): wait for initialization status before login
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -4486,6 +4486,7 @@ export default {
|
||||
title: '欢迎使用 MoviePilot',
|
||||
subtitle: '先创建唯一的超级管理员,再开始管理你的媒体库。整个过程只需一步。',
|
||||
checking: '正在检查实例状态…',
|
||||
statusRetrying: '服务正在启动,稍后将自动重试…',
|
||||
accountSection: '管理员账号',
|
||||
username: '超级管理员用户名',
|
||||
usernamePlaceholder: '例如:admin',
|
||||
|
||||
@@ -4483,6 +4483,7 @@ export default {
|
||||
title: '歡迎使用 MoviePilot',
|
||||
subtitle: '先建立唯一的超級管理員,再開始管理你的媒體庫。整個過程只需一步。',
|
||||
checking: '正在檢查實例狀態…',
|
||||
statusRetrying: '服務正在啟動,稍後將自動重試…',
|
||||
accountSection: '管理員帳號',
|
||||
username: '超級管理員使用者名稱',
|
||||
usernamePlaceholder: '例如:admin',
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
@@ -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') {
|
||||
|
||||
Reference in New Issue
Block a user