mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-06 16:16:42 +08:00
feat: 添加插件认证支持,优化登录流程
This commit is contained in:
+155
-1
@@ -1,4 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import type { Component } from 'vue'
|
||||||
import { VForm } from 'vuetify/components/VForm'
|
import { VForm } from 'vuetify/components/VForm'
|
||||||
import { useAuthStore, useUserStore } from '@/stores'
|
import { useAuthStore, useUserStore } from '@/stores'
|
||||||
import { authState, userState } from '@/stores/types'
|
import { authState, userState } from '@/stores/types'
|
||||||
@@ -14,6 +15,7 @@ import { getNavMenus } from '@/router/i18n-menu'
|
|||||||
import { filterMenusByPermission } from '@/utils/permission'
|
import { filterMenusByPermission } from '@/utils/permission'
|
||||||
import type { ApiResponse } from '@/api/types'
|
import type { ApiResponse } from '@/api/types'
|
||||||
import { openSharedDialog } from '@/composables/useSharedDialog'
|
import { openSharedDialog } from '@/composables/useSharedDialog'
|
||||||
|
import { loadRemoteComponentFromModule, type RemoteModule } from '@/utils/federationLoader'
|
||||||
|
|
||||||
const LoginMfaDialog = defineAsyncComponent(() => import('@/components/dialog/LoginMfaDialog.vue'))
|
const LoginMfaDialog = defineAsyncComponent(() => import('@/components/dialog/LoginMfaDialog.vue'))
|
||||||
|
|
||||||
@@ -87,6 +89,42 @@ let manualAbortController: AbortController | null = null
|
|||||||
// 标记当前是否有手动模式的 PassKey 请求正在进行
|
// 标记当前是否有手动模式的 PassKey 请求正在进行
|
||||||
let isManualPassKeyActive = false
|
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。
|
// 生成 MFA 共享弹窗使用的最新 props。
|
||||||
function getMfaDialogProps() {
|
function getMfaDialogProps() {
|
||||||
return {
|
return {
|
||||||
@@ -127,6 +165,79 @@ function closeMfaDialog() {
|
|||||||
mfaDialogController = null
|
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 认证流程
|
// PassKey 认证核心函数 - 处理 WebAuthn 认证流程
|
||||||
interface PassKeyAuthOptions {
|
interface PassKeyAuthOptions {
|
||||||
username?: string // 可选的用户名,用于 MFA 场景
|
username?: string // 可选的用户名,用于 MFA 场景
|
||||||
@@ -518,6 +629,9 @@ onMounted(async () => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 加载系统和插件声明的未登录认证入口
|
||||||
|
await loadAuthProviders()
|
||||||
|
|
||||||
// 初始化 Conditional UI 的 PassKey 自动填充
|
// 初始化 Conditional UI 的 PassKey 自动填充
|
||||||
await initConditionalPasskey()
|
await initConditionalPasskey()
|
||||||
})
|
})
|
||||||
@@ -657,12 +771,13 @@ onUnmounted(() => {
|
|||||||
</VBtn>
|
</VBtn>
|
||||||
|
|
||||||
<!-- or divider -->
|
<!-- 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>
|
<span class="or-divider-text">{{ t('login.orDivider') }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- passkey login button -->
|
<!-- passkey login button -->
|
||||||
<VBtn
|
<VBtn
|
||||||
|
v-if="showPasskeyLogin"
|
||||||
block
|
block
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
color="success"
|
color="success"
|
||||||
@@ -673,6 +788,19 @@ onUnmounted(() => {
|
|||||||
>
|
>
|
||||||
{{ t('login.loginWithPasskey') }}
|
{{ t('login.loginWithPasskey') }}
|
||||||
</VBtn>
|
</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">
|
<VAlert v-if="errorMessage" type="error" variant="tonal" class="mt-3">
|
||||||
{{ errorMessage }}
|
{{ errorMessage }}
|
||||||
</VAlert>
|
</VAlert>
|
||||||
@@ -682,6 +810,32 @@ onUnmounted(() => {
|
|||||||
</VCardText>
|
</VCardText>
|
||||||
</VCard>
|
</VCard>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -155,6 +155,7 @@ registerRoute(
|
|||||||
!url.pathname.includes('/api/v1/message/') && // 用户消息接口
|
!url.pathname.includes('/api/v1/message/') && // 用户消息接口
|
||||||
!url.pathname.includes('/api/v1/system/global') && // 系统配置接口
|
!url.pathname.includes('/api/v1/system/global') && // 系统配置接口
|
||||||
!url.pathname.includes('/api/v1/mfa/') && // 多因素认证接口
|
!url.pathname.includes('/api/v1/mfa/') && // 多因素认证接口
|
||||||
|
!url.pathname.includes('/api/v1/auth/') && // 登录认证入口与票据交换
|
||||||
!url.pathname.includes('/api/v1/dashboard/') && // Dashboard实时监控数据
|
!url.pathname.includes('/api/v1/dashboard/') && // Dashboard实时监控数据
|
||||||
!url.pathname.includes('/api/v1/plugin/')&& // 插件接口
|
!url.pathname.includes('/api/v1/plugin/')&& // 插件接口
|
||||||
!url.pathname.includes('/api/v1/subscribe/'), // 订阅接口
|
!url.pathname.includes('/api/v1/subscribe/'), // 订阅接口
|
||||||
|
|||||||
@@ -10,9 +10,10 @@ import {
|
|||||||
const federationController = new AbortController()
|
const federationController = new AbortController()
|
||||||
|
|
||||||
// 定义远程模块接口
|
// 定义远程模块接口
|
||||||
interface RemoteModule {
|
export interface RemoteModule {
|
||||||
id: string
|
id: string
|
||||||
url: 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获取远程模块列表
|
* 从API获取远程模块列表
|
||||||
*/
|
*/
|
||||||
@@ -131,7 +143,7 @@ async function fetchRemoteModules(): Promise<RemoteModule[]> {
|
|||||||
* 动态注入Federation Remote模块
|
* 动态注入Federation Remote模块
|
||||||
* @param modules 远程模块列表
|
* @param modules 远程模块列表
|
||||||
*/
|
*/
|
||||||
function injectRemoteModule(module: RemoteModule): void {
|
export function injectRemoteModule(module: RemoteModule): void {
|
||||||
// 与 API 请求一致:使用 origin + pathname 作为前缀,子路径代理时 pathname 含 /mp 等
|
// 与 API 请求一致:使用 origin + pathname 作为前缀,子路径代理时 pathname 含 /mp 等
|
||||||
const baseUrl = new URL(window.location.href)
|
const baseUrl = new URL(window.location.href)
|
||||||
const pathBase = baseUrl.pathname.replace(/\/$/, '') || ''
|
const pathBase = baseUrl.pathname.replace(/\/$/, '') || ''
|
||||||
|
|||||||
Reference in New Issue
Block a user