Files
BYW-VoiceHub/app/components/Admin/OAuthConfigManager.vue
T

576 lines
21 KiB
Vue

<template>
<section :class="cardClass">
<h3
class="text-sm font-black text-text-primary uppercase tracking-widest flex items-center gap-2 border-b border-border-secondary pb-4"
>
<Shield :size="16" class="text-success" /> {{ locale.title }}
</h3>
<div class="flex items-start gap-3 p-4 bg-primary-5 border border-primary-10 rounded-xl">
<AlertCircle :size="14" class="text-primary shrink-0 mt-0.5" />
<p class="text-[10px] text-text-tertiary leading-relaxed">
{{
locale.runtimeConfigNotice ||
'OAuth 运行时配置已迁移到后台。环境变量仅用于兼容旧部署和一键导入,导入后以此页面保存的配置为准。'
}}
</p>
</div>
<!-- 基础配置 -->
<div class="space-y-4 mb-6 pb-6 border-b border-border-secondary">
<div class="flex items-center justify-between">
<h4 class="text-xs font-bold text-text-tertiary uppercase tracking-widest">{{ locale.baseSettings }}</h4>
<button
v-if="envData.hasBaseConfig"
type="button"
class="text-[10px] px-2 py-1 bg-primary-10 text-primary hover:bg-primary-20 border border-primary-20 rounded-md transition-colors font-bold flex items-center gap-1"
@click="importEnvData('base')"
>
<Download :size="12" />
{{ locale.importEnv }}
</button>
</div>
<div class="rounded-xl border border-border-secondary bg-bg-primary-50">
<div class="flex items-center justify-between p-3">
<div class="pr-4">
<p class="text-xs font-bold text-text-primary">{{ locale.allowRegistration }}</p>
<p class="text-[10px] text-text-tertiary mt-0.5 leading-relaxed">{{ locale.allowRegistrationDesc }}</p>
</div>
<div class="flex items-center gap-2">
<span
:class="[
'text-[10px] font-bold',
formData.allowOAuthRegistration ? 'text-success' : 'text-text-tertiary'
]"
>
{{ formData.allowOAuthRegistration ? locale.allowed : locale.notAllowed }}
</span>
<input
v-model="formData.allowOAuthRegistration"
type="checkbox"
class="w-4 h-4 rounded border-border-secondary bg-bg-secondary accent-green-600 cursor-pointer"
/>
</div>
</div>
<div
v-if="formData.allowOAuthRegistration"
class="ml-4 mr-2 mb-2 pl-4 border-l border-border-secondary space-y-2 transition-opacity"
>
<div class="flex items-center justify-between p-3 bg-bg-primary border border-border-secondary rounded-xl">
<div class="pr-4">
<p class="text-xs font-bold text-text-primary">{{ locale.oauthRegisterRequiresApproval }}</p>
<p class="text-[10px] text-text-tertiary mt-0.5 leading-relaxed">{{ locale.oauthRegisterRequiresApprovalDesc }}</p>
</div>
<input
v-model="formData.oauthRegisterRequiresApproval"
type="checkbox"
:disabled="!formData.allowOAuthRegistration"
class="w-4 h-4 rounded border-border-secondary bg-bg-secondary cursor-pointer disabled:cursor-not-allowed"
/>
</div>
<div class="flex items-center justify-between p-3 bg-bg-primary border border-border-secondary rounded-xl">
<div class="pr-4">
<p class="text-xs font-bold text-text-primary">{{ siteLocale.registerRequiresGradeClass }}</p>
<p class="text-[10px] text-text-tertiary mt-0.5 leading-relaxed">{{ siteLocale.registerRequiresGradeClassDesc }}</p>
</div>
<input
v-model="formData.registerRequiresGradeClass"
type="checkbox"
class="w-4 h-4 rounded border-border-secondary bg-bg-secondary cursor-pointer"
/>
</div>
</div>
</div>
<div>
<label :class="labelClass">{{ locale.redirectUri }}</label>
<p class="text-[10px] text-text-disabled px-1 mb-2">
{{ locale.redirectExample }} <code class="bg-bg-primary px-2 py-1 rounded">https://yourdomain.com/api/auth/[provider]/callback</code>
</p>
<input
v-model="formData.oauthRedirectUri"
type="text"
placeholder="https://yourdomain.com/api/auth/[provider]/callback"
:class="inputClass"
/>
</div>
<div>
<label :class="labelClass">{{ locale.stateSecret }}</label>
<p class="text-[10px] text-text-disabled px-1 mb-2">{{ locale.stateSecretDesc }}</p>
<div class="flex gap-2">
<input
v-model="formData.oauthStateSecret"
:type="showSecrets.state ? 'text' : 'password'"
:placeholder="locale.stateSecretPlaceholder"
:class="inputClass"
/>
<button
type="button"
class="px-4 py-2.5 bg-bg-tertiary hover:bg-bg-quaternary text-text-tertiary text-xs font-bold rounded-xl transition-all"
@click="showSecrets.state = !showSecrets.state"
>
{{ showSecrets.state ? locale.hide : locale.show }}
</button>
</div>
</div>
</div>
<!-- 循环渲染的基础 OAuth 提供商配置 -->
<AdminProviderConfigSection
v-for="provider in oauthProviders"
:key="provider.id"
:title="provider.title"
:has-env-config="provider.hasEnvConfig"
v-model:enabled="formData[provider.enabledKey]"
v-model:clientId="formData[provider.clientIdKey]"
v-model:clientSecret="formData[provider.clientSecretKey]"
:client-id-label="provider.clientIdLabel"
:client-id-placeholder="provider.clientIdPlaceholder"
:client-secret-label="provider.clientSecretLabel"
:client-secret-placeholder="provider.clientSecretPlaceholder"
:doc-url="provider.docUrl"
:doc-label="provider.docLabel"
@import-env="importEnvData(provider.id)"
>
<template #before-fields v-if="provider.id === 'casdoor'">
<div>
<label :class="labelClass">{{ locale.casdoorServerUrl }}</label>
<input
v-model="formData.casdoorServerUrl"
type="text"
placeholder="https://casdoor.example.com"
:class="inputClass"
/>
</div>
</template>
<template #after-fields v-if="provider.id === 'casdoor'">
<div>
<label :class="labelClass">{{ locale.casdoorOrganizationName }}</label>
<input
v-model="formData.casdoorOrganizationName"
type="text"
:placeholder="locale.organizationPlaceholder"
:class="inputClass"
/>
</div>
</template>
<template #after-fields v-else-if="provider.id === 'aggregate'">
<div>
<CustomSelect
v-model="formData.aggregateOAuthLoginType"
:label="locale.aggregateLoginTypeLabel || '登录方式'"
:options="aggregateLoginTypes"
:placeholder="locale.aggregateLoginTypePlaceholder || '请选择至少一种登录方式'"
multiple
/>
<p class="text-[10px] text-text-disabled px-1 mt-2">
{{
locale.aggregateLoginTypeDesc ||
'请选择当前聚合登录服务已接入并开通的登录方式;启用服务商尚未支持的平台会导致授权失败。每种登录方式会独立记录账号绑定关系。'
}}
</p>
</div>
<div>
<label :class="labelClass">{{ locale.aggregateEndpointLabel || '接口地址' }}</label>
<input
v-model="formData.aggregateOAuthEndpoint"
type="url"
placeholder="https://example.com/connect.php"
:class="inputClass"
/>
<p class="text-[10px] text-text-disabled px-1 mt-2">
{{
locale.aggregateEndpointDesc ||
'兼容彩虹聚合登录协议的服务端 connect.php 地址;公网应使用 HTTPS,可信内网可使用 HTTP。'
}}
</p>
</div>
<div class="pt-2 border-t border-border-secondary/60">
<div
class="flex flex-col sm:flex-row sm:items-center justify-between gap-3 p-3.5 bg-error-5 border border-error-15 rounded-xl"
>
<div>
<p class="text-xs font-bold text-error">
{{ locale.clearBindingsTitle || '清除所有聚合登录绑定' }}
</p>
<p class="text-[10px] text-text-tertiary mt-0.5 leading-relaxed">
{{
locale.clearBindingsDesc ||
'更换聚合登录提供商时使用,将清除系统中所有用户的聚合登录绑定关系并要求其重新绑定。'
}}
</p>
</div>
<button
type="button"
:disabled="clearingBindings"
class="shrink-0 px-3.5 py-2 bg-error-10 hover:bg-error-20 text-error border border-error-20 text-xs font-bold rounded-xl transition-all flex items-center gap-1.5 self-start sm:self-auto disabled:opacity-50 disabled:cursor-not-allowed"
@click="showClearConfirm = true"
>
<Trash2 :size="13" />
{{ locale.clearBindingsBtn || '清除绑定' }}
</button>
</div>
</div>
</template>
</AdminProviderConfigSection>
<!-- Custom OAuth2 -->
<div class="space-y-4">
<div class="flex items-center justify-between">
<h4 class="text-xs font-bold text-text-tertiary uppercase tracking-widest">{{ locale.customOAuth }}</h4>
<div class="flex items-center gap-2">
<span
:class="[
'text-[10px] font-bold',
formData.customOAuthEnabled ? 'text-success' : 'text-error'
]"
>
{{ formData.customOAuthEnabled ? locale.enabled : locale.disabled }}
</span>
<input
v-model="formData.customOAuthEnabled"
type="checkbox"
class="w-4 h-4 rounded border-border-secondary bg-bg-secondary accent-green-600 cursor-pointer"
/>
</div>
</div>
<div v-if="formData.customOAuthEnabled" class="space-y-4">
<div>
<label :class="labelClass">{{ locale.displayName }}</label>
<input
v-model="formData.customOAuthDisplayName"
type="text"
:placeholder="locale.displayNamePlaceholder"
:class="inputClass"
/>
</div>
<div>
<label :class="labelClass">{{ locale.authorizeUrl }}</label>
<input
v-model="formData.customOAuthAuthorizeUrl"
type="text"
placeholder="https://oauth.example.com/authorize"
:class="inputClass"
/>
</div>
<div>
<label :class="labelClass">{{ locale.tokenUrl }}</label>
<input
v-model="formData.customOAuthTokenUrl"
type="text"
placeholder="https://oauth.example.com/token"
:class="inputClass"
/>
</div>
<div>
<label :class="labelClass">{{ locale.userInfoUrl }}</label>
<input
v-model="formData.customOAuthUserInfoUrl"
type="text"
placeholder="https://oauth.example.com/userinfo"
:class="inputClass"
/>
</div>
<div>
<label :class="labelClass">{{ locale.scope }}</label>
<input
v-model="formData.customOAuthScope"
type="text"
placeholder="openid profile email"
:class="inputClass"
/>
</div>
<div>
<label :class="labelClass">{{ locale.clientId }}</label>
<input
v-model="formData.customOAuthClientId"
type="text"
:placeholder="locale.clientIdPlaceholder"
:class="inputClass"
/>
</div>
<div>
<label :class="labelClass">{{ locale.clientSecret }}</label>
<div class="flex gap-2">
<input
v-model="formData.customOAuthClientSecret"
:type="showSecrets.custom ? 'text' : 'password'"
:placeholder="showSecrets.custom ? locale.clientSecretPlaceholder : '••••••••••••••••'"
:class="inputClass"
/>
<button
type="button"
class="px-4 py-2.5 bg-bg-tertiary hover:bg-bg-quaternary text-text-tertiary text-xs font-bold rounded-xl transition-all"
@click="showSecrets.custom = !showSecrets.custom"
>
{{ showSecrets.custom ? locale.hide : locale.show }}
</button>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label :class="labelClass">{{ locale.userIdField }}</label>
<input
v-model="formData.customOAuthUserIdField"
type="text"
placeholder="sub"
:class="inputClass"
/>
</div>
<div>
<label :class="labelClass">{{ locale.usernameField }}</label>
<input
v-model="formData.customOAuthUsernameField"
type="text"
placeholder="preferred_username"
:class="inputClass"
/>
</div>
<div>
<label :class="labelClass">{{ locale.nameField }}</label>
<input
v-model="formData.customOAuthNameField"
type="text"
placeholder="name"
:class="inputClass"
/>
</div>
<div>
<label :class="labelClass">{{ locale.emailField }}</label>
<input
v-model="formData.customOAuthEmailField"
type="text"
placeholder="email"
:class="inputClass"
/>
</div>
<div class="md:col-span-2">
<label :class="labelClass">{{ locale.avatarField }}</label>
<input
v-model="formData.customOAuthAvatarField"
type="text"
placeholder="picture"
:class="inputClass"
/>
</div>
</div>
</div>
</div>
<!-- 信息提示 -->
<div
class="mt-6 p-4 bg-warning-5 border border-warning-10 rounded-xl flex items-start gap-3"
>
<AlertCircle class="text-warning shrink-0 mt-0.5" :size="14" />
<div class="text-[10px] text-text-tertiary leading-relaxed space-y-1">
<p>
{{ locale.brokerHintPrefix }}
<a href="https://github.com/laoshuikaixue/VoiceHub-Auth-Broker" target="_blank" class="text-primary hover:underline">VoiceHub-Auth-Broker</a>
{{ locale.brokerHintSuffix }}
</p>
</div>
</div>
<!-- 清除聚合登录绑定二次确认弹窗 -->
<ConfirmDialog
v-model:show="showClearConfirm"
type="danger"
:title="locale.clearBindingsConfirmTitle || '确认清除所有聚合登录绑定?'"
:message="
locale.clearBindingsConfirmMessage ||
'此操作将永久清除系统中所有用户的聚合登录绑定关系。\n若更换了聚合登录提供商,旧数据已失效,清除后用户需重新绑定方可通过聚合登录访问账号。\n此操作不可撤销,是否继续?'
"
:confirm-text="locale.clearBindingsBtn || '清除绑定'"
:loading="clearingBindings"
@confirm="handleClearBindings"
@cancel="showClearConfirm = false"
/>
</section>
</template>
<script setup>
import { computed, ref, onMounted } from 'vue'
import { AlertCircle, Shield, Download, Trash2 } from '@lucide/vue'
import { useToast } from '~/composables/useToast'
import { useLocale } from '~/utils/locale'
import { useServerErrors, useLocaleText } from '~/composables/useLocaleText'
import CustomSelect from '~/components/UI/Common/CustomSelect.vue'
import ConfirmDialog from '~/components/UI/ConfirmDialog.vue'
import {
AGGREGATE_OAUTH_LOGIN_TYPE_OPTIONS,
getAggregateOAuthLoginTypesOrDefault
} from '~/utils/oauth'
const props = defineProps({
modelValue: {
type: Object,
required: true
}
})
const emits = defineEmits(['update:modelValue'])
const { showToast } = useToast()
const { admin, siteConfig: siteLocale } = useLocale()
const { localize: localizeServerError } = useServerErrors()
const locale = computed(() => admin.value?.oauthConfig || {})
const { t: callLocale } = useLocaleText(locale)
const getLogMessage = (key) => locale.value?.logs?.[key] || key
const inputClass =
'w-full bg-bg-primary border border-border-secondary rounded-xl px-4 py-2.5 text-sm text-text-primary focus:outline-none focus:border-primary-30 transition-all placeholder:text-text-primary'
const labelClass = 'text-[10px] font-black text-text-disabled uppercase tracking-widest px-1 block mb-2'
const cardClass = 'bg-bg-secondary-40 border border-border-secondary rounded-2xl p-6 shadow-xl space-y-6'
const showSecrets = ref({
state: false,
custom: false
})
const showClearConfirm = ref(false)
const clearingBindings = ref(false)
const formData = computed({
get: () => props.modelValue,
set: (val) => emits('update:modelValue', val)
})
const envData = ref({
hasBaseConfig: false,
hasGithubConfig: false,
hasCasdoorConfig: false,
hasGoogleConfig: false,
hasAggregateConfig: false
})
const oauthProviders = computed(() => [
{
id: 'github',
title: locale.value?.githubTitle || 'GitHub OAuth',
hasEnvConfig: envData.value.hasGithubConfig,
enabledKey: 'githubOAuthEnabled',
clientIdKey: 'githubClientId',
clientSecretKey: 'githubClientSecret',
clientIdLabel: locale.value?.githubClientId || 'GitHub Client ID',
clientIdPlaceholder: locale.value?.githubClientIdPlaceholder || 'Enter GitHub Client ID',
clientSecretLabel: locale.value?.githubClientSecret || 'GitHub Client Secret',
clientSecretPlaceholder: locale.value?.githubClientSecretPlaceholder || 'Enter GitHub Client Secret',
},
{
id: 'casdoor',
title: locale.value?.casdoorTitle || 'Casdoor OAuth',
hasEnvConfig: envData.value.hasCasdoorConfig,
enabledKey: 'casdoorOAuthEnabled',
clientIdKey: 'casdoorClientId',
clientSecretKey: 'casdoorClientSecret',
clientIdLabel: locale.value?.casdoorClientId || 'Casdoor Client ID',
clientIdPlaceholder: locale.value?.clientIdPlaceholder || 'Enter Client ID',
clientSecretLabel: locale.value?.casdoorClientSecret || 'Casdoor Client Secret',
clientSecretPlaceholder: locale.value?.clientSecretPlaceholder || 'Enter Client Secret',
},
{
id: 'google',
title: locale.value?.googleTitle || 'Google OAuth',
hasEnvConfig: envData.value.hasGoogleConfig,
enabledKey: 'googleOAuthEnabled',
clientIdKey: 'googleClientId',
clientSecretKey: 'googleClientSecret',
clientIdLabel: locale.value?.googleClientId || 'Google Client ID',
clientIdPlaceholder: locale.value?.googleClientIdPlaceholder || 'Enter Google Client ID',
clientSecretLabel: locale.value?.googleClientSecret || 'Google Client Secret',
clientSecretPlaceholder: locale.value?.googleClientSecretPlaceholder || 'Enter Google Client Secret',
},
{
id: 'aggregate',
title: locale.value?.aggregateTitle || '聚合登录',
hasEnvConfig: envData.value.hasAggregateConfig,
enabledKey: 'aggregateOAuthEnabled',
clientIdKey: 'aggregateOAuthAppId',
clientSecretKey: 'aggregateOAuthAppKey',
clientIdLabel: 'AppID',
clientIdPlaceholder: locale.value?.aggregateClientIdPlaceholder || '输入聚合登录 AppID',
clientSecretLabel: 'AppKey',
clientSecretPlaceholder:
locale.value?.aggregateClientSecretPlaceholder || '输入聚合登录 AppKey'
}
])
const aggregateLoginTypes = computed(() =>
AGGREGATE_OAUTH_LOGIN_TYPE_OPTIONS.map((option) => ({
...option,
label: locale.value?.aggregateLoginTypes?.[option.value] || option.label
}))
)
const fetchEnvData = async () => {
try {
const data = await $fetch('/api/admin/system-settings/env-oauth')
envData.value = data
} catch (e) {
console.error(getLogMessage('fetchEnvFailed'), e)
}
}
const importEnvData = async (provider) => {
try {
const data = await $fetch('/api/admin/system-settings/env-oauth-import', {
method: 'POST',
body: { provider }
})
const importedData = { ...data }
if (provider === 'aggregate') {
importedData.aggregateOAuthLoginType = getAggregateOAuthLoginTypesOrDefault(
data.aggregateOAuthLoginType
)
}
formData.value = {
...formData.value,
...importedData
}
} catch (e) {
console.error(getLogMessage('importEnvFailed'), e)
showToast(locale.value?.importFailed || 'OAuth 配置导入失败', 'error')
}
}
const handleClearBindings = async () => {
clearingBindings.value = true
try {
const res = await $fetch('/api/admin/system-settings/clear-aggregate-bindings', {
method: 'POST'
})
showClearConfirm.value = false
showToast(callLocale('clearBindingsSuccess', `已成功清除 ${res.count} 条聚合登录绑定数据(影响 ${res.usersAffected} 位用户)`, res.count, res.usersAffected), 'success')
} catch (err) {
console.error('清除聚合登录绑定失败:', err)
showToast(
localizeServerError(err) || locale.value?.clearBindingsFailed || '清除聚合登录绑定失败',
'error'
)
} finally {
clearingBindings.value = false
}
}
onMounted(() => {
fetchEnvData()
})
</script>