mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-06-17 13:42:07 +08:00
feat: 添加插件认证支持,优化登录流程
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import type { Component } from 'vue'
|
||||
import { VForm } from 'vuetify/components/VForm'
|
||||
import { useAuthStore, useUserStore } from '@/stores'
|
||||
import { authState, userState } from '@/stores/types'
|
||||
@@ -14,6 +15,7 @@ import { getNavMenus } from '@/router/i18n-menu'
|
||||
import { filterMenusByPermission } from '@/utils/permission'
|
||||
import type { ApiResponse } from '@/api/types'
|
||||
import { openSharedDialog } from '@/composables/useSharedDialog'
|
||||
import { loadRemoteComponentFromModule, type RemoteModule } from '@/utils/federationLoader'
|
||||
|
||||
const LoginMfaDialog = defineAsyncComponent(() => import('@/components/dialog/LoginMfaDialog.vue'))
|
||||
|
||||
@@ -87,6 +89,42 @@ let manualAbortController: AbortController | null = null
|
||||
// 标记当前是否有手动模式的 PassKey 请求正在进行
|
||||
let isManualPassKeyActive = false
|
||||
|
||||
interface LoginAuthProvider {
|
||||
id: string
|
||||
type: 'system' | 'plugin'
|
||||
method?: string
|
||||
name: string
|
||||
icon?: string
|
||||
enabled?: boolean
|
||||
plugin_id?: string
|
||||
component?: string
|
||||
remote?: RemoteModule
|
||||
}
|
||||
|
||||
interface PluginAuthPayload {
|
||||
ticket?: string
|
||||
}
|
||||
|
||||
// 登录认证提供方
|
||||
const authProviders = ref<LoginAuthProvider[]>([])
|
||||
const authProvidersLoaded = ref(false)
|
||||
const authProvidersFailed = ref(false)
|
||||
const selectedAuthProvider = ref<LoginAuthProvider | null>(null)
|
||||
const RemoteAuthView = shallowRef<Component | null>(null)
|
||||
const pluginAuthDialog = ref(false)
|
||||
const pluginAuthLoading = ref(false)
|
||||
const pluginAuthError = ref('')
|
||||
|
||||
const systemPasskeyProvider = computed(() =>
|
||||
authProviders.value.find(provider => provider.type === 'system' && provider.method === 'passkey'),
|
||||
)
|
||||
const pluginAuthProviders = computed(() =>
|
||||
authProviders.value.filter(provider => provider.type === 'plugin' && provider.remote && provider.enabled !== false),
|
||||
)
|
||||
const showPasskeyLogin = computed(
|
||||
() => !authProvidersLoaded.value || authProvidersFailed.value || !!systemPasskeyProvider.value?.enabled,
|
||||
)
|
||||
|
||||
// 生成 MFA 共享弹窗使用的最新 props。
|
||||
function getMfaDialogProps() {
|
||||
return {
|
||||
@@ -127,6 +165,79 @@ function closeMfaDialog() {
|
||||
mfaDialogController = null
|
||||
}
|
||||
|
||||
// 加载未登录可用的认证提供方。
|
||||
async function loadAuthProviders() {
|
||||
try {
|
||||
const result = (await api.get('auth/providers')) as LoginAuthProvider[]
|
||||
authProviders.value = Array.isArray(result) ? result : []
|
||||
authProvidersFailed.value = false
|
||||
} catch (error) {
|
||||
console.error('加载认证提供方失败:', error)
|
||||
authProviders.value = []
|
||||
authProvidersFailed.value = true
|
||||
} finally {
|
||||
authProvidersLoaded.value = true
|
||||
}
|
||||
}
|
||||
|
||||
// 打开插件认证联邦页面。
|
||||
async function openPluginAuth(provider: LoginAuthProvider) {
|
||||
if (!provider.remote) return
|
||||
selectedAuthProvider.value = provider
|
||||
RemoteAuthView.value = null
|
||||
pluginAuthError.value = ''
|
||||
pluginAuthLoading.value = true
|
||||
pluginAuthDialog.value = true
|
||||
try {
|
||||
RemoteAuthView.value = (await loadRemoteComponentFromModule(
|
||||
provider.remote,
|
||||
provider.component || 'AuthPage',
|
||||
)) as Component
|
||||
} catch (error: any) {
|
||||
console.error('加载插件认证页面失败:', error)
|
||||
pluginAuthError.value = error?.message || t('login.authFailure')
|
||||
} finally {
|
||||
pluginAuthLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 关闭插件认证弹窗。
|
||||
function closePluginAuth() {
|
||||
pluginAuthDialog.value = false
|
||||
selectedAuthProvider.value = null
|
||||
RemoteAuthView.value = null
|
||||
pluginAuthError.value = ''
|
||||
}
|
||||
|
||||
// 兑换插件认证票据并完成系统登录。
|
||||
async function exchangePluginAuthTicket(ticket: string) {
|
||||
pluginAuthLoading.value = true
|
||||
try {
|
||||
const response: any = await api.post('auth/exchange', { ticket })
|
||||
closePluginAuth()
|
||||
await handleLoginSuccess(response)
|
||||
} catch (error: any) {
|
||||
console.error('插件认证票据兑换失败:', error)
|
||||
pluginAuthError.value = error?.response?.data?.detail || error?.message || t('login.authFailure')
|
||||
} finally {
|
||||
pluginAuthLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 处理插件认证成功事件。
|
||||
async function handlePluginAuthenticated(payload: PluginAuthPayload) {
|
||||
if (!payload?.ticket) {
|
||||
pluginAuthError.value = t('login.authFailure')
|
||||
return
|
||||
}
|
||||
await exchangePluginAuthTicket(payload.ticket)
|
||||
}
|
||||
|
||||
// 处理插件认证失败事件。
|
||||
function handlePluginAuthError(error: any) {
|
||||
pluginAuthError.value = error?.message || String(error || '') || t('login.authFailure')
|
||||
}
|
||||
|
||||
// PassKey 认证核心函数 - 处理 WebAuthn 认证流程
|
||||
interface PassKeyAuthOptions {
|
||||
username?: string // 可选的用户名,用于 MFA 场景
|
||||
@@ -518,6 +629,9 @@ onMounted(async () => {
|
||||
return
|
||||
}
|
||||
|
||||
// 加载系统和插件声明的未登录认证入口
|
||||
await loadAuthProviders()
|
||||
|
||||
// 初始化 Conditional UI 的 PassKey 自动填充
|
||||
await initConditionalPasskey()
|
||||
})
|
||||
@@ -657,12 +771,13 @@ onUnmounted(() => {
|
||||
</VBtn>
|
||||
|
||||
<!-- or divider -->
|
||||
<div class="or-divider my-4">
|
||||
<div v-if="showPasskeyLogin || pluginAuthProviders.length > 0" class="or-divider my-4">
|
||||
<span class="or-divider-text">{{ t('login.orDivider') }}</span>
|
||||
</div>
|
||||
|
||||
<!-- passkey login button -->
|
||||
<VBtn
|
||||
v-if="showPasskeyLogin"
|
||||
block
|
||||
variant="outlined"
|
||||
color="success"
|
||||
@@ -673,6 +788,19 @@ onUnmounted(() => {
|
||||
>
|
||||
{{ t('login.loginWithPasskey') }}
|
||||
</VBtn>
|
||||
<VBtn
|
||||
v-for="provider in pluginAuthProviders"
|
||||
:key="provider.id"
|
||||
block
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
class="mt-3"
|
||||
:prepend-icon="provider.icon || 'mdi-login-variant'"
|
||||
:loading="pluginAuthLoading && selectedAuthProvider?.id === provider.id"
|
||||
@click="openPluginAuth(provider)"
|
||||
>
|
||||
{{ provider.name }}
|
||||
</VBtn>
|
||||
<VAlert v-if="errorMessage" type="error" variant="tonal" class="mt-3">
|
||||
{{ errorMessage }}
|
||||
</VAlert>
|
||||
@@ -682,6 +810,32 @@ onUnmounted(() => {
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</div>
|
||||
<VDialog v-model="pluginAuthDialog" max-width="520" persistent>
|
||||
<VCard>
|
||||
<VCardItem>
|
||||
<VCardTitle>{{ selectedAuthProvider?.name }}</VCardTitle>
|
||||
<template #append>
|
||||
<VBtn icon="mdi-close" variant="text" @click="closePluginAuth" />
|
||||
</template>
|
||||
</VCardItem>
|
||||
<VCardText>
|
||||
<VSkeletonLoader v-if="pluginAuthLoading && !RemoteAuthView" type="article" />
|
||||
<VAlert v-else-if="pluginAuthError" type="error" variant="tonal">
|
||||
{{ pluginAuthError }}
|
||||
</VAlert>
|
||||
<component
|
||||
v-else-if="RemoteAuthView && selectedAuthProvider"
|
||||
:is="RemoteAuthView"
|
||||
:api="api"
|
||||
:provider="selectedAuthProvider"
|
||||
:plugin-id="selectedAuthProvider.plugin_id"
|
||||
@authenticated="handlePluginAuthenticated"
|
||||
@error="handlePluginAuthError"
|
||||
@close="closePluginAuth"
|
||||
/>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -155,6 +155,7 @@ registerRoute(
|
||||
!url.pathname.includes('/api/v1/message/') && // 用户消息接口
|
||||
!url.pathname.includes('/api/v1/system/global') && // 系统配置接口
|
||||
!url.pathname.includes('/api/v1/mfa/') && // 多因素认证接口
|
||||
!url.pathname.includes('/api/v1/auth/') && // 登录认证入口与票据交换
|
||||
!url.pathname.includes('/api/v1/dashboard/') && // Dashboard实时监控数据
|
||||
!url.pathname.includes('/api/v1/plugin/')&& // 插件接口
|
||||
!url.pathname.includes('/api/v1/subscribe/'), // 订阅接口
|
||||
|
||||
@@ -10,9 +10,10 @@ import {
|
||||
const federationController = new AbortController()
|
||||
|
||||
// 定义远程模块接口
|
||||
interface RemoteModule {
|
||||
export interface RemoteModule {
|
||||
id: string
|
||||
url: string
|
||||
name?: string
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -112,6 +113,17 @@ export async function loadRemoteComponent(id: string, componentName: string = 'P
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用后端发现接口返回的 remote 信息加载指定组件。
|
||||
* @param remoteModule 远程模块信息
|
||||
* @param componentName 组件名称
|
||||
*/
|
||||
export async function loadRemoteComponentFromModule(remoteModule: RemoteModule, componentName: string = 'Page') {
|
||||
injectRemoteModule(remoteModule)
|
||||
const module = await __federation_method_getRemote(remoteModule.id, `./${componentName}`)
|
||||
return __federation_method_unwrapDefault(module)
|
||||
}
|
||||
|
||||
/**
|
||||
* 从API获取远程模块列表
|
||||
*/
|
||||
@@ -131,7 +143,7 @@ async function fetchRemoteModules(): Promise<RemoteModule[]> {
|
||||
* 动态注入Federation Remote模块
|
||||
* @param modules 远程模块列表
|
||||
*/
|
||||
function injectRemoteModule(module: RemoteModule): void {
|
||||
export function injectRemoteModule(module: RemoteModule): void {
|
||||
// 与 API 请求一致:使用 origin + pathname 作为前缀,子路径代理时 pathname 含 /mp 等
|
||||
const baseUrl = new URL(window.location.href)
|
||||
const pathBase = baseUrl.pathname.replace(/\/$/, '') || ''
|
||||
|
||||
Reference in New Issue
Block a user