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',
|
title: 'Welcome to MoviePilot',
|
||||||
subtitle: 'Create the single super administrator account before you start managing your media library.',
|
subtitle: 'Create the single super administrator account before you start managing your media library.',
|
||||||
checking: 'Checking instance status…',
|
checking: 'Checking instance status…',
|
||||||
|
statusRetrying: 'The service is starting. Retrying automatically…',
|
||||||
accountSection: 'Administrator account',
|
accountSection: 'Administrator account',
|
||||||
username: 'Super administrator username',
|
username: 'Super administrator username',
|
||||||
usernamePlaceholder: 'For example: admin',
|
usernamePlaceholder: 'For example: admin',
|
||||||
|
|||||||
@@ -4486,6 +4486,7 @@ export default {
|
|||||||
title: '欢迎使用 MoviePilot',
|
title: '欢迎使用 MoviePilot',
|
||||||
subtitle: '先创建唯一的超级管理员,再开始管理你的媒体库。整个过程只需一步。',
|
subtitle: '先创建唯一的超级管理员,再开始管理你的媒体库。整个过程只需一步。',
|
||||||
checking: '正在检查实例状态…',
|
checking: '正在检查实例状态…',
|
||||||
|
statusRetrying: '服务正在启动,稍后将自动重试…',
|
||||||
accountSection: '管理员账号',
|
accountSection: '管理员账号',
|
||||||
username: '超级管理员用户名',
|
username: '超级管理员用户名',
|
||||||
usernamePlaceholder: '例如:admin',
|
usernamePlaceholder: '例如:admin',
|
||||||
|
|||||||
@@ -4483,6 +4483,7 @@ export default {
|
|||||||
title: '歡迎使用 MoviePilot',
|
title: '歡迎使用 MoviePilot',
|
||||||
subtitle: '先建立唯一的超級管理員,再開始管理你的媒體庫。整個過程只需一步。',
|
subtitle: '先建立唯一的超級管理員,再開始管理你的媒體庫。整個過程只需一步。',
|
||||||
checking: '正在檢查實例狀態…',
|
checking: '正在檢查實例狀態…',
|
||||||
|
statusRetrying: '服務正在啟動,稍後將自動重試…',
|
||||||
accountSection: '管理員帳號',
|
accountSection: '管理員帳號',
|
||||||
username: '超級管理員使用者名稱',
|
username: '超級管理員使用者名稱',
|
||||||
usernamePlaceholder: '例如:admin',
|
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 isConfirmPasswordVisible = ref(false)
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const checking = ref(true)
|
const checking = ref(true)
|
||||||
|
const statusRetrying = ref(false)
|
||||||
const errorMessage = ref('')
|
const errorMessage = ref('')
|
||||||
const apiKeyCopied = ref(false)
|
const apiKeyCopied = ref(false)
|
||||||
const formRef = ref<HTMLFormElement | null>(null)
|
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>({
|
const form = ref<InitializationPayload>({
|
||||||
username: '',
|
username: '',
|
||||||
@@ -40,6 +44,9 @@ const form = ref<InitializationPayload>({
|
|||||||
|
|
||||||
const currentTheme = computed(() => theme.global.name.value)
|
const currentTheme = computed(() => theme.global.name.value)
|
||||||
const themeClass = computed(() => 'initialize-page--' + currentTheme.value)
|
const themeClass = computed(() => 'initialize-page--' + currentTheme.value)
|
||||||
|
const statusMessage = computed(() =>
|
||||||
|
t(statusRetrying.value ? 'initialization.statusRetrying' : 'initialization.checking'),
|
||||||
|
)
|
||||||
|
|
||||||
/** 使用浏览器密码学随机源生成一次性 API Key,避免把凭据交给第三方服务。 */
|
/** 使用浏览器密码学随机源生成一次性 API Key,避免把凭据交给第三方服务。 */
|
||||||
function generateApiKey(): string {
|
function generateApiKey(): string {
|
||||||
@@ -73,6 +80,26 @@ function getErrorMessage(error: unknown): string {
|
|||||||
return getApiBusinessErrorMessage(error) || t('initialization.saveFailed')
|
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() {
|
async function submit() {
|
||||||
if (loading.value) return
|
if (loading.value) return
|
||||||
@@ -97,15 +124,14 @@ async function submit() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(() => {
|
||||||
form.value.api_key = generateApiKey()
|
form.value.api_key = generateApiKey()
|
||||||
try {
|
void checkInitializationStatus()
|
||||||
if (await getInitializationState(true)) await router.replace('/login')
|
})
|
||||||
} catch (error) {
|
|
||||||
errorMessage.value = getErrorMessage(error)
|
onBeforeUnmount(() => {
|
||||||
} finally {
|
disposed = true
|
||||||
checking.value = false
|
if (statusRetryTimer) clearTimeout(statusRetryTimer)
|
||||||
}
|
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -159,12 +185,19 @@ onMounted(async () => {
|
|||||||
</VCardItem>
|
</VCardItem>
|
||||||
|
|
||||||
<VCardText class="px-0 pt-6">
|
<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 }}
|
{{ errorMessage }}
|
||||||
</VAlert>
|
</VAlert>
|
||||||
|
|
||||||
<VAlert v-if="checking" type="info" variant="tonal" class="mb-5">
|
<VAlert v-if="checking" type="info" variant="tonal" class="mb-5">
|
||||||
{{ t('initialization.checking') }}
|
{{ statusMessage }}
|
||||||
</VAlert>
|
</VAlert>
|
||||||
|
|
||||||
<form ref="formRef" class="initialize-form" novalidate @submit.prevent="submit">
|
<form ref="formRef" class="initialize-form" novalidate @submit.prevent="submit">
|
||||||
@@ -254,11 +287,21 @@ onMounted(async () => {
|
|||||||
minlength="16"
|
minlength="16"
|
||||||
>
|
>
|
||||||
<template #append-inner>
|
<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>
|
</template>
|
||||||
</VTextField>
|
</VTextField>
|
||||||
<div class="initialize-key-panel__actions">
|
<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">
|
<VBtn variant="text" size="small" prepend-icon="mdi-refresh" @click="regenerateApiKey">
|
||||||
{{ t('initialization.regenerate') }}
|
{{ t('initialization.regenerate') }}
|
||||||
</VBtn>
|
</VBtn>
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ type Redirect = () => string
|
|||||||
|
|
||||||
const routerMocks = vi.hoisted(() => ({
|
const routerMocks = vi.hoisted(() => ({
|
||||||
afterEach: undefined as AfterEachHook | undefined,
|
afterEach: undefined as AfterEachHook | undefined,
|
||||||
|
getInitializationState: vi.fn(),
|
||||||
guard: undefined as NavigationGuard | undefined,
|
guard: undefined as NavigationGuard | undefined,
|
||||||
next: vi.fn(),
|
next: vi.fn(),
|
||||||
routes: [] as Array<{ path: string; redirect?: Redirect }>,
|
routes: [] as Array<{ path: string; redirect?: Redirect }>,
|
||||||
@@ -43,6 +44,10 @@ vi.mock('@/api/nprogress', () => ({
|
|||||||
configureNProgress: vi.fn(),
|
configureNProgress: vi.fn(),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/utils/initialization', () => ({
|
||||||
|
getInitializationState: routerMocks.getInitializationState,
|
||||||
|
}))
|
||||||
|
|
||||||
vi.mock('@/utils/requestOptimizer', () => ({
|
vi.mock('@/utils/requestOptimizer', () => ({
|
||||||
abortAllRequests: vi.fn(),
|
abortAllRequests: vi.fn(),
|
||||||
initializeRequestOptimizer: vi.fn(),
|
initializeRequestOptimizer: vi.fn(),
|
||||||
@@ -66,6 +71,8 @@ describe('authentication route guard', () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
setActivePinia(createPinia())
|
setActivePinia(createPinia())
|
||||||
routerMocks.next.mockReset()
|
routerMocks.next.mockReset()
|
||||||
|
routerMocks.getInitializationState.mockReset()
|
||||||
|
routerMocks.getInitializationState.mockResolvedValue(true)
|
||||||
routerMocks.setRequestNavigatingState.mockReset()
|
routerMocks.setRequestNavigatingState.mockReset()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -106,6 +113,33 @@ describe('authentication route guard', () => {
|
|||||||
expect(routerMocks.next).toHaveBeenCalledWith()
|
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 () => {
|
it('allows ordinary protected routes and enforces declared permissions', async () => {
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ type NavigationGuard = (
|
|||||||
) => Promise<void>
|
) => Promise<void>
|
||||||
|
|
||||||
const routerMocks = vi.hoisted(() => ({
|
const routerMocks = vi.hoisted(() => ({
|
||||||
|
getInitializationState: vi.fn(),
|
||||||
guard: undefined as NavigationGuard | undefined,
|
guard: undefined as NavigationGuard | undefined,
|
||||||
next: vi.fn(),
|
next: vi.fn(),
|
||||||
push: vi.fn(),
|
push: vi.fn(),
|
||||||
@@ -34,6 +35,10 @@ vi.mock('@/api/nprogress', () => ({
|
|||||||
configureNProgress: vi.fn(),
|
configureNProgress: vi.fn(),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/utils/initialization', () => ({
|
||||||
|
getInitializationState: routerMocks.getInitializationState,
|
||||||
|
}))
|
||||||
|
|
||||||
vi.mock('@/utils/requestOptimizer', () => ({
|
vi.mock('@/utils/requestOptimizer', () => ({
|
||||||
abortAllRequests: vi.fn(),
|
abortAllRequests: vi.fn(),
|
||||||
initializeRequestOptimizer: vi.fn(),
|
initializeRequestOptimizer: vi.fn(),
|
||||||
@@ -70,6 +75,8 @@ describe('plugin sidebar route permission', () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
setActivePinia(createPinia())
|
setActivePinia(createPinia())
|
||||||
useAuthStore().login({ remember: false, token: 'test-token' })
|
useAuthStore().login({ remember: false, token: 'test-token' })
|
||||||
|
routerMocks.getInitializationState.mockReset()
|
||||||
|
routerMocks.getInitializationState.mockResolvedValue(true)
|
||||||
routerMocks.next.mockReset()
|
routerMocks.next.mockReset()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
+6
-1
@@ -360,7 +360,12 @@ router.beforeEach(async (to: any, from: any, next: any) => {
|
|||||||
try {
|
try {
|
||||||
initialized = await getInitializationState()
|
initialized = await getInitializationState()
|
||||||
} catch {
|
} catch {
|
||||||
// 老版本后端或服务暂不可用时保留原有导航,页面自身会显示请求错误。
|
// 未确认初始化状态时必须进入初始化页,由页面持续等待后端就绪,避免启动竞态误放行登录页。
|
||||||
|
if (to.path !== '/initialize') {
|
||||||
|
setRequestNavigatingState(false)
|
||||||
|
next('/initialize')
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (initialized === false && to.path !== '/initialize') {
|
if (initialized === false && to.path !== '/initialize') {
|
||||||
|
|||||||
Reference in New Issue
Block a user