feat: improve OAuth provider configuration

This commit is contained in:
fawney19
2026-05-04 00:18:05 +08:00
parent cb5b8ee1ee
commit fb642f7d62
9 changed files with 934 additions and 122 deletions

View File

@@ -68,7 +68,7 @@ export interface OAuthProviderUpsertRequest {
export interface OAuthProviderTestResponse {
authorization_url_reachable: boolean
token_url_reachable: boolean
secret_status: 'likely_valid' | 'invalid' | 'unknown' | 'not_provided' | string
secret_status: 'likely_valid' | 'configured' | 'invalid' | 'unknown' | 'not_provided' | string
details?: string
}
@@ -118,24 +118,23 @@ export const oauthApi = {
},
async getProviderConfig(providerType: string): Promise<OAuthProviderAdminConfig> {
const response = await apiClient.get<OAuthProviderAdminConfig>(`/api/admin/oauth/providers/${providerType}`)
const response = await apiClient.get<OAuthProviderAdminConfig>(`/api/admin/oauth/providers/${encodeURIComponent(providerType)}`)
return response.data
},
async upsertProviderConfig(providerType: string, payload: OAuthProviderUpsertRequest): Promise<OAuthProviderAdminConfig> {
const response = await apiClient.put<OAuthProviderAdminConfig>(`/api/admin/oauth/providers/${providerType}`, payload)
const response = await apiClient.put<OAuthProviderAdminConfig>(`/api/admin/oauth/providers/${encodeURIComponent(providerType)}`, payload)
return response.data
},
async deleteProviderConfig(providerType: string): Promise<{ message: string }> {
const response = await apiClient.delete<{ message: string }>(`/api/admin/oauth/providers/${providerType}`)
const response = await apiClient.delete<{ message: string }>(`/api/admin/oauth/providers/${encodeURIComponent(providerType)}`)
return response.data
},
async testProviderConfig(providerType: string, payload: OAuthProviderTestRequest): Promise<OAuthProviderTestResponse> {
const response = await apiClient.post<OAuthProviderTestResponse>(`/api/admin/oauth/providers/${providerType}/test`, payload)
const response = await apiClient.post<OAuthProviderTestResponse>(`/api/admin/oauth/providers/${encodeURIComponent(providerType)}/test`, payload)
return response.data
},
}
}

View File

@@ -0,0 +1,53 @@
import { describe, expect, it } from 'vitest'
import { summarizeOAuthConfigTest } from '../oauthConfigTest'
describe('summarizeOAuthConfigTest', () => {
it('marks unreachable endpoints and unsupported secret as failed', () => {
const summary = summarizeOAuthConfigTest({
authorization_url_reachable: false,
token_url_reachable: false,
secret_status: 'unsupported',
details: 'OAuth 配置测试仅支持 Rust execution runtime',
})
expect(summary.severity).toBe('error')
expect(summary.failures).toEqual([
'Authorization URL 不可达',
'Token URL 不可达',
'Secret 不受支持',
])
expect(summary.message).toBe('测试失败Authorization URL 不可达Token URL 不可达Secret 不受支持')
})
it('uses warning when only secret validation is inconclusive', () => {
const summary = summarizeOAuthConfigTest({
authorization_url_reachable: true,
token_url_reachable: true,
secret_status: 'unknown',
})
expect(summary.severity).toBe('warning')
expect(summary.warnings).toEqual(['Secret 未验证'])
})
it('marks fully reachable config with a likely valid secret as successful', () => {
const summary = summarizeOAuthConfigTest({
authorization_url_reachable: true,
token_url_reachable: true,
secret_status: 'likely_valid',
})
expect(summary.severity).toBe('success')
expect(summary.message).toBe('测试通过')
})
it('accepts a configured secret because OAuth secrets are verified during code exchange', () => {
const summary = summarizeOAuthConfigTest({
authorization_url_reachable: true,
token_url_reachable: true,
secret_status: 'configured',
})
expect(summary.severity).toBe('success')
})
})

View File

@@ -0,0 +1,65 @@
import type { OAuthProviderTestResponse } from '@/api/oauth'
export type OAuthConfigTestSeverity = 'success' | 'warning' | 'error'
export interface OAuthConfigTestSummary {
severity: OAuthConfigTestSeverity
message: string
failures: string[]
warnings: string[]
}
function describeSecretStatus(status: string | undefined): string | null {
const normalized = (status || '').trim().toLowerCase()
if (!normalized || normalized === 'likely_valid' || normalized === 'configured') return null
if (normalized === 'invalid') return 'Secret 无效'
if (normalized === 'unsupported') return 'Secret 不受支持'
if (normalized === 'not_provided') return 'Secret 未提供'
if (normalized === 'unknown') return 'Secret 未验证'
return `Secret: ${status}`
}
export function summarizeOAuthConfigTest(result: OAuthProviderTestResponse): OAuthConfigTestSummary {
const failures: string[] = []
const warnings: string[] = []
if (!result.authorization_url_reachable) {
failures.push('Authorization URL 不可达')
}
if (!result.token_url_reachable) {
failures.push('Token URL 不可达')
}
const secretStatus = (result.secret_status || '').trim().toLowerCase()
const secretMessage = describeSecretStatus(result.secret_status)
if (secretMessage && (secretStatus === 'invalid' || secretStatus === 'unsupported')) {
failures.push(secretMessage)
} else if (secretMessage) {
warnings.push(secretMessage)
}
if (failures.length > 0) {
return {
severity: 'error',
message: `测试失败:${failures.join('')}`,
failures,
warnings,
}
}
if (warnings.length > 0) {
return {
severity: 'warning',
message: `测试完成,但有未确认项:${warnings.join('')}`,
failures,
warnings,
}
}
return {
severity: 'success',
message: '测试通过',
failures,
warnings,
}
}

View File

@@ -15,46 +15,93 @@
</template>
</PageHeader>
<div class="mt-6">
<!-- Provider 选择 Tab -->
<div class="flex flex-wrap gap-2 mb-6">
<div class="mt-6 flex gap-6">
<!-- 左侧边栏 -->
<div class="w-56 shrink-0 flex flex-col gap-2">
<button
v-for="t in supportedTypes"
:key="t.provider_type"
class="flex items-center gap-3 px-4 py-2 rounded-lg text-sm font-medium transition-colors border"
:class="selectedType === t.provider_type
? 'border-primary text-primary'
: 'border-border text-muted-foreground hover:border-primary/50 hover:text-foreground'"
@click="handleTabClick(t.provider_type)"
class="flex items-center justify-center gap-1.5 w-full px-3 py-2 rounded-lg border border-dashed border-border text-sm text-muted-foreground hover:border-primary/50 hover:text-primary transition-colors"
@click="handleClickAdd"
>
<div class="flex flex-col items-center leading-none">
<span>{{ t.display_name }}</span>
<span class="text-[10px] text-muted-foreground">
{{ configs[t.provider_type]
? (configs[t.provider_type]?.is_enabled ? '点击禁用' : '点击启用')
: '未配置' }}
</span>
</div>
<span
class="w-2 h-2 rounded-full"
:class="configs[t.provider_type]?.is_enabled ? 'bg-green-500' : 'bg-gray-300'"
/>
<Plus class="w-3.5 h-3.5" />
添加配置
</button>
<div
v-if="configuredList.length === 0 && !loading"
class="text-sm text-muted-foreground px-2 py-4 text-center"
>
暂无配置
</div>
<div class="space-y-0.5">
<!-- 新建临时条目 -->
<button
v-if="newConfigPending"
class="w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm transition-colors"
:class="selectedType === '__new__' ? 'bg-primary/10 text-primary' : 'text-foreground hover:bg-muted'"
@click="selectNewConfig()"
>
<div class="w-7 h-7 rounded-md flex items-center justify-center text-xs font-semibold shrink-0"
:class="selectedType === '__new__' ? 'bg-primary text-primary-foreground' : 'bg-muted text-muted-foreground'"
>+</div>
<div class="flex-1 min-w-0 text-left">
<div class="truncate font-medium text-sm">新配置</div>
<div class="text-[10px] text-muted-foreground">未保存</div>
</div>
</button>
<button
v-for="item in sidebarList"
:key="item.provider_type"
class="w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm transition-colors"
:class="selectedType === item.provider_type
? 'bg-primary/10 text-primary'
: 'text-foreground hover:bg-muted'"
@click="selectProvider(item.provider_type)"
>
<!-- Logo / 首字母 -->
<div
class="w-7 h-7 rounded-md shrink-0 flex items-center justify-center text-xs font-semibold overflow-hidden relative"
:class="selectedType === item.provider_type ? 'bg-primary text-primary-foreground' : 'bg-muted text-muted-foreground'"
>
{{ item.display_name.charAt(0).toUpperCase() }}
<img
v-if="item.provider_type === 'linuxdo'"
src="https://cdn.linux.do/uploads/default/optimized/3X/9/d/9dd49731091ce8656243f3c2b6e5d5e5a7e3e3e3_2_32x32.png"
class="absolute inset-0 w-full h-full object-cover"
@error="($event.target as HTMLImageElement).remove()"
/>
</div>
<div class="flex-1 min-w-0 text-left">
<div class="truncate font-medium text-sm">{{ item.display_name }}</div>
<div class="text-[10px] text-muted-foreground">
{{ item.configured ? (item.is_enabled ? '已启用' : '已禁用') : '未配置' }}
</div>
</div>
<!-- 开关 -->
<Switch
v-if="item.configured"
:model-value="item.is_enabled"
:disabled="saving"
@click.stop
@update:model-value="toggleProviderEnabled(item.provider_type, $event)"
/>
<span
v-else
class="w-1.5 h-1.5 rounded-full shrink-0 bg-gray-200"
/>
</button>
</div>
</div>
<!-- Provider 提示 -->
<div
v-if="supportedTypes.length === 0 && !loading"
class="text-center py-12 text-muted-foreground"
>
未发现可用的 OAuth Provider
</div>
<!-- 右侧内容区 -->
<div class="flex-1 min-w-0">
<!-- 配置表单 -->
<CardSection
v-if="selectedType"
:title="selectedTypeMeta?.display_name || selectedType"
:description="configs[selectedType]?.is_enabled ? '已启用' : '未配置'"
:title="selectedType === '__new__' ? '新建配置' : (selectedTypeMeta?.display_name || selectedType)"
:description="selectedType === '__new__' ? '填写后点击保存' : (configs[selectedType]?.is_enabled ? '已启用' : '已禁用')"
>
<template #actions>
<div class="flex gap-2">
@@ -77,6 +124,32 @@
</template>
<div class="space-y-6">
<!-- 新建时的 Display Name -->
<div
v-if="selectedType === '__new__'"
class="grid grid-cols-1 md:grid-cols-2 gap-4"
>
<div>
<Label class="block text-sm font-medium">显示名称</Label>
<Input
v-model="form.new_display_name"
class="mt-1"
placeholder="例如My OIDC Provider"
autocomplete="off"
/>
</div>
<div>
<Label class="block text-sm font-medium">配置标识</Label>
<Input
v-model="form.new_provider_type"
class="mt-1"
placeholder="custom_oidc_work"
autocomplete="off"
@blur="normalizeNewProviderType"
/>
</div>
</div>
<!-- 凭证配置 -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
@@ -121,6 +194,40 @@
</div>
</div>
<!-- custom_oidc 必填端点 -->
<div
v-if="isSelectedCustomProvider"
class="grid grid-cols-1 md:grid-cols-3 gap-4"
>
<div>
<Label class="block text-sm font-medium">Authorization URL</Label>
<Input
v-model="form.authorization_url_override"
class="mt-1"
placeholder="https://example.com/oauth/authorize"
autocomplete="off"
/>
</div>
<div>
<Label class="block text-sm font-medium">Token URL</Label>
<Input
v-model="form.token_url_override"
class="mt-1"
placeholder="https://example.com/oauth/token"
autocomplete="off"
/>
</div>
<div>
<Label class="block text-sm font-medium">Userinfo URL</Label>
<Input
v-model="form.userinfo_url_override"
class="mt-1"
placeholder="https://example.com/api/user"
autocomplete="off"
/>
</div>
</div>
<!-- 高级选项折叠 -->
<details class="group">
<summary class="cursor-pointer text-sm font-medium text-muted-foreground hover:text-foreground transition-colors">
@@ -140,7 +247,11 @@
</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<!-- linuxdo 的可选端点覆盖 -->
<div
v-if="!isSelectedCustomProvider"
class="grid grid-cols-1 md:grid-cols-3 gap-4"
>
<div>
<Label class="block text-sm font-medium">Authorization URL</Label>
<Input
@@ -181,13 +292,21 @@
/>
</div>
<div>
<Label class="block text-sm font-medium">Extra Config</Label>
<Label class="block text-sm font-medium">
{{ isSelectedCustomProvider ? 'Allowed Domains / Extra Config' : 'Extra Config' }}
</Label>
<Textarea
v-model="form.extra_config_json"
class="mt-1 font-mono text-xs"
rows="3"
placeholder="{&quot;min_trust_level&quot;: 1}"
:placeholder="extraConfigPlaceholder"
/>
<p
v-if="isSelectedCustomProvider"
class="mt-1 text-xs text-muted-foreground"
>
自定义 OIDC 必填填写 Authorization / Token / Userinfo URL 所属域名
</p>
</div>
</div>
</div>
@@ -233,36 +352,59 @@
</div>
</div>
</CardSection>
</div>
</div>
</PageContainer>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { Plus } from 'lucide-vue-next'
import { oauthApi, type OAuthProviderAdminConfig, type OAuthProviderTestResponse, type SupportedOAuthType } from '@/api/oauth'
import { PageContainer, PageHeader, CardSection } from '@/components/layout'
import Button from '@/components/ui/button.vue'
import Input from '@/components/ui/input.vue'
import Label from '@/components/ui/label.vue'
import Switch from '@/components/ui/switch.vue'
import Textarea from '@/components/ui/textarea.vue'
import { useToast } from '@/composables/useToast'
import { useConfirm } from '@/composables/useConfirm'
import { log } from '@/utils/logger'
import { getErrorMessage, getErrorStatus, isApiError } from '@/types/api-error'
import { summarizeOAuthConfigTest } from '@/utils/oauthConfigTest'
const { success, error: showError } = useToast()
const { success, warning, error: showError } = useToast()
const { confirmWarning } = useConfirm()
const loading = ref(false)
const saving = ref(false)
const testing = ref(false)
const BUILTIN_OAUTH_PROVIDER_TYPES = new Set(['linuxdo'])
const CUSTOM_OIDC_TEMPLATE_TYPE = 'custom_oidc'
interface OAuthConfigForm {
client_id: string
client_secret: string
authorization_url_override: string
token_url_override: string
userinfo_url_override: string
scopes_input: string
redirect_uri: string
frontend_callback_url: string
attribute_mapping_json: string
extra_config_json: string
new_provider_type: string
new_display_name: string
}
const supportedTypes = ref<SupportedOAuthType[]>([])
const configs = ref<Record<string, OAuthProviderAdminConfig>>({})
const selectedType = ref<string>('')
const lastTestResult = ref<OAuthProviderTestResponse | null>(null)
const newConfigPending = ref(false)
const form = ref({
const form = ref<OAuthConfigForm>({
client_id: '',
client_secret: '',
authorization_url_override: '',
@@ -273,10 +415,178 @@ const form = ref({
frontend_callback_url: '',
attribute_mapping_json: '',
extra_config_json: '',
new_provider_type: '',
new_display_name: '',
})
const newConfigForm = ref<OAuthConfigForm | null>(null)
const configuredList = computed(() => Object.values(configs.value))
const customOidcTemplate = computed(() =>
supportedTypes.value.find((type) => type.provider_type === CUSTOM_OIDC_TEMPLATE_TYPE)
)
function isBuiltinProviderType(providerType: string): boolean {
return BUILTIN_OAUTH_PROVIDER_TYPES.has(providerType)
}
function isCustomProviderType(providerType: string): boolean {
return !!providerType && !isBuiltinProviderType(providerType)
}
const sidebarList = computed(() => {
const builtins = supportedTypes.value
.filter((t) => isBuiltinProviderType(t.provider_type))
.map((t) => ({
...t,
...(configs.value[t.provider_type] || {}),
configured: !!configs.value[t.provider_type],
is_enabled: configs.value[t.provider_type]?.is_enabled ?? false,
}))
const customTemplate = customOidcTemplate.value
const customs = Object.values(configs.value)
.filter((config) => isCustomProviderType(config.provider_type))
.map((config) => ({
...(customTemplate || {
provider_type: config.provider_type,
display_name: config.display_name,
default_authorization_url: '',
default_token_url: '',
default_userinfo_url: '',
default_scopes: ['openid', 'profile', 'email'],
}),
...config,
configured: true,
is_enabled: config.is_enabled,
}))
return [...builtins, ...customs]
})
const hasSecret = computed(() => !!configs.value[selectedType.value]?.has_secret)
const selectedTypeMeta = computed(() => supportedTypes.value.find((t) => t.provider_type === selectedType.value))
const selectedTypeMeta = computed(() => {
if (selectedType.value === '__new__') {
return customOidcTemplate.value
}
const builtin = supportedTypes.value.find((t) => t.provider_type === selectedType.value)
if (builtin) {
return builtin
}
const config = configs.value[selectedType.value]
if (!config) {
return undefined
}
return {
...(customOidcTemplate.value || {
default_authorization_url: '',
default_token_url: '',
default_userinfo_url: '',
default_scopes: ['openid', 'profile', 'email'],
}),
provider_type: config.provider_type,
display_name: config.display_name,
}
})
const isSelectedCustomProvider = computed(() =>
selectedType.value === '__new__' || isCustomProviderType(selectedType.value)
)
const extraConfigPlaceholder = computed(() =>
isSelectedCustomProvider.value
? '{\n "allowed_domains": ["example.com"]\n}'
: '{}'
)
function selectProvider(providerType: string) {
if (selectedType.value !== providerType) {
if (selectedType.value === '__new__') {
newConfigForm.value = { ...form.value }
}
selectedType.value = providerType
syncFormFromSelected()
}
}
function selectNewConfig() {
if (selectedType.value !== '__new__') {
selectedType.value = '__new__'
if (newConfigForm.value) {
form.value = { ...newConfigForm.value }
}
lastTestResult.value = null
}
}
function normalizeProviderType(value: string): string {
return value
.trim()
.toLowerCase()
.replace(/[^a-z0-9_-]+/g, '_')
.replace(/_+/g, '_')
.replace(/^[-_]+|[-_]+$/g, '')
}
function isAllowedNewCustomProviderType(providerType: string): boolean {
return providerType === CUSTOM_OIDC_TEMPLATE_TYPE
|| providerType.startsWith('custom_oidc_')
|| providerType.startsWith('custom_')
|| providerType.startsWith('oidc_')
}
function generateUniqueCustomProviderType(base = CUSTOM_OIDC_TEMPLATE_TYPE): string {
const rawBase = normalizeProviderType(base) || CUSTOM_OIDC_TEMPLATE_TYPE
const normalizedBase = isAllowedNewCustomProviderType(rawBase)
? rawBase
: `custom_${rawBase}`
const used = new Set(Object.keys(configs.value))
if (!used.has(normalizedBase)) {
return normalizedBase
}
let index = 2
while (used.has(`${normalizedBase}_${index}`)) {
index += 1
}
return `${normalizedBase}_${index}`
}
function ensureNewProviderType(): string {
const normalized = normalizeProviderType(form.value.new_provider_type)
const providerType = normalized && isAllowedNewCustomProviderType(normalized) && !configs.value[normalized]
? normalized
: generateUniqueCustomProviderType(normalized || CUSTOM_OIDC_TEMPLATE_TYPE)
form.value.new_provider_type = providerType
return providerType
}
function normalizeNewProviderType() {
const providerType = ensureNewProviderType()
const redirectUri = form.value.redirect_uri.trim()
if (!redirectUri || /\/api\/oauth\/[^/]+\/callback\/?$/.test(redirectUri)) {
form.value.redirect_uri = defaultRedirectUri(providerType)
}
newConfigForm.value = { ...form.value }
}
function handleClickAdd() {
const providerType = generateUniqueCustomProviderType()
selectedType.value = '__new__'
newConfigPending.value = true
form.value = {
client_id: '',
client_secret: '',
authorization_url_override: '',
token_url_override: '',
userinfo_url_override: '',
scopes_input: 'openid profile email',
redirect_uri: defaultRedirectUri(providerType),
frontend_callback_url: defaultFrontendCallbackUrl(),
attribute_mapping_json: '',
extra_config_json: '',
new_provider_type: providerType,
new_display_name: '',
}
newConfigForm.value = { ...form.value }
lastTestResult.value = null
}
function defaultRedirectUri(providerType: string): string {
return new URL(`/api/oauth/${providerType}/callback`, window.location.origin).toString()
@@ -299,16 +609,6 @@ function parseJsonOrNull(input: string): Record<string, unknown> | null {
return JSON.parse(raw)
}
function handleTabClick(providerType: string) {
// 如果点击的是当前选中的 Provider且已配置则切换启用状态
if (selectedType.value === providerType && configs.value[providerType]) {
toggleProviderEnabled(providerType, !configs.value[providerType].is_enabled)
return
}
// 否则切换到该 Provider
selectedType.value = providerType
syncFormFromSelected()
}
function syncFormFromSelected() {
lastTestResult.value = null
@@ -325,6 +625,8 @@ function syncFormFromSelected() {
frontend_callback_url: cfg?.frontend_callback_url || defaultFrontendCallbackUrl(),
attribute_mapping_json: cfg?.attribute_mapping ? JSON.stringify(cfg.attribute_mapping, null, 2) : '',
extra_config_json: cfg?.extra_config ? JSON.stringify(cfg.extra_config, null, 2) : '',
new_provider_type: '',
new_display_name: '',
}
}
@@ -340,8 +642,14 @@ async function toggleProviderEnabled(providerType: string, enabled: boolean, for
const payload = {
display_name: cfg.display_name,
client_id: cfg.client_id,
authorization_url_override: cfg.authorization_url_override || null,
token_url_override: cfg.token_url_override || null,
userinfo_url_override: cfg.userinfo_url_override || null,
scopes: cfg.scopes || null,
redirect_uri: cfg.redirect_uri,
frontend_callback_url: cfg.frontend_callback_url,
attribute_mapping: cfg.attribute_mapping || null,
extra_config: cfg.extra_config || null,
is_enabled: enabled,
force,
}
@@ -379,13 +687,14 @@ async function loadAll() {
])
supportedTypes.value = types
configs.value = Object.fromEntries(list.map((c) => [c.provider_type, c]))
newConfigPending.value = false
if (!selectedType.value && supportedTypes.value.length > 0) {
selectedType.value = supportedTypes.value[0].provider_type
}
if (selectedType.value) {
syncFormFromSelected()
if (!selectedType.value || selectedType.value === '__new__') {
const first = list[0]?.provider_type || types[0]?.provider_type
if (first) {
selectedType.value = first
syncFormFromSelected()
}
}
} catch (err: unknown) {
log.error('加载 OAuth 配置失败:', err)
@@ -400,10 +709,11 @@ async function handleSave() {
saving.value = true
lastTestResult.value = null
try {
const typeMeta = supportedTypes.value.find((t) => t.provider_type === selectedType.value)
const existingConfig = configs.value[selectedType.value]
const isNew = selectedType.value === '__new__'
const providerType = isNew ? ensureNewProviderType() : selectedType.value
const existingConfig = configs.value[providerType]
const payload = {
display_name: typeMeta?.display_name || selectedType.value,
display_name: isNew ? (form.value.new_display_name.trim() || 'Custom OIDC') : (configs.value[providerType]?.display_name || supportedTypes.value.find((t) => t.provider_type === providerType)?.display_name || providerType),
client_id: form.value.client_id.trim(),
client_secret: form.value.client_secret.trim() || undefined,
authorization_url_override: form.value.authorization_url_override.trim() || null,
@@ -417,9 +727,12 @@ async function handleSave() {
is_enabled: existingConfig?.is_enabled || false,
}
await oauthApi.admin.upsertProviderConfig(selectedType.value, payload)
await oauthApi.admin.upsertProviderConfig(providerType, payload)
success('保存成功')
const savedType = providerType
await loadAll()
selectedType.value = savedType
syncFormFromSelected()
} catch (err: unknown) {
showError(getErrorMessage(err, '保存失败'))
} finally {
@@ -432,6 +745,7 @@ async function handleTest() {
if (!selectedType.value) return
testing.value = true
try {
const providerType = selectedType.value === '__new__' ? ensureNewProviderType() : selectedType.value
const testPayload = {
client_id: form.value.client_id.trim(),
client_secret: form.value.client_secret.trim() || undefined,
@@ -439,8 +753,16 @@ async function handleTest() {
token_url_override: form.value.token_url_override.trim() || null,
redirect_uri: form.value.redirect_uri.trim(),
}
lastTestResult.value = await oauthApi.admin.testProviderConfig(selectedType.value, testPayload)
success('测试完成')
const result = await oauthApi.admin.testProviderConfig(providerType, testPayload)
lastTestResult.value = result
const summary = summarizeOAuthConfigTest(result)
if (summary.severity === 'success') {
success(summary.message)
} else if (summary.severity === 'warning') {
warning(summary.message)
} else {
showError(summary.message)
}
} catch (err: unknown) {
showError(getErrorMessage(err, '测试失败'))
} finally {