feat: 添加 OAuth 认证支持及相关改进

- 新增 OAuth 模块,支持 LinuxDo/GitHub/Google 等第三方登录
- 用户邮箱改为可选字段,支持无邮箱注册
- 新增模块配置验证状态 (config_validated/config_error)
- 系统设置界面改为分块独立保存
- 用户设置新增 OAuth 绑定管理和首次密码设置
- 登录界面支持 OAuth 按钮展示
- 邮箱验证设置移至邮件设置页面
This commit is contained in:
fawney19
2026-01-19 03:19:17 +08:00
parent e2e14fd09c
commit 3d88dfd98a
61 changed files with 4548 additions and 950 deletions

View File

@@ -1,75 +1,111 @@
<template>
<Dialog
v-model="isOpen"
size="lg"
size="md"
no-padding
>
<div class="space-y-6">
<div class="px-6 py-6 sm:px-8 sm:py-8">
<!-- Logo 和标题 -->
<div class="flex flex-col items-center text-center">
<div class="mb-4 rounded-3xl border border-primary/30 dark:border-[#cc785c]/30 bg-primary/5 dark:bg-transparent p-4 shadow-inner shadow-white/40 dark:shadow-[#cc785c]/10">
<img
src="/aether_adaptive.svg"
alt="Logo"
class="h-16 w-16"
>
</div>
<h2 class="text-2xl font-semibold text-slate-900 dark:text-white">
欢迎回来
<div class="flex flex-col items-center text-center mb-6">
<img
src="/aether_adaptive.svg"
alt="Aether"
class="h-10 w-10 mb-3"
>
<h2 class="text-xl font-semibold text-foreground">
登录到 Aether
</h2>
</div>
<!-- Demo 模式提示 -->
<div
v-if="isDemo"
class="rounded-lg border border-primary/20 dark:border-primary/30 bg-primary/5 dark:bg-primary/10 p-4"
class="rounded-lg border border-primary/20 bg-primary/5 p-3 mb-5"
>
<div class="flex items-start gap-3">
<div class="flex-shrink-0 text-primary dark:text-primary/90">
<svg
class="h-5 w-5"
viewBox="0 0 20 20"
fill="currentColor"
<p class="text-xs font-medium text-foreground mb-2">
演示模式
</p>
<div class="space-y-1.5">
<button
type="button"
class="flex items-center gap-2 text-xs text-muted-foreground hover:text-foreground transition-colors w-full"
@click="fillDemoAccount('admin')"
>
<span class="inline-flex items-center justify-center w-4 h-4 rounded bg-primary/20 text-primary text-[10px] font-bold">A</span>
<span>admin@demo.aether.io / demo123</span>
</button>
<button
type="button"
class="flex items-center gap-2 text-xs text-muted-foreground hover:text-foreground transition-colors w-full"
@click="fillDemoAccount('user')"
>
<span class="inline-flex items-center justify-center w-4 h-4 rounded bg-muted text-muted-foreground text-[10px] font-bold">U</span>
<span>user@demo.aether.io / demo123</span>
</button>
</div>
</div>
<!-- OAuth 登录按钮 -->
<div
v-if="oauthProviders.length > 0"
class="mb-5"
>
<!-- 单个 provider: 完整按钮 -->
<div
v-if="oauthProviders.length === 1"
class="space-y-2"
>
<button
type="button"
class="oauth-btn"
@click="handleOAuthLogin(oauthProviders[0].provider_type)"
>
<span
class="oauth-icon"
v-html="getOAuthIcon(oauthProviders[0].provider_type)"
/>
<span>使用 {{ oauthProviders[0].display_name }} 登录</span>
</button>
</div>
<!-- 多个 provider: 图标按钮组 -->
<div
v-else
class="flex flex-col items-center gap-3"
>
<span class="text-xs text-muted-foreground">使用以下方式登录</span>
<div class="flex items-center justify-center gap-3">
<button
v-for="p in oauthProviders"
:key="p.provider_type"
type="button"
class="oauth-icon-btn"
:title="p.display_name"
@click="handleOAuthLogin(p.provider_type)"
>
<path
fill-rule="evenodd"
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a.75.75 0 000 1.5h.253a.25.25 0 01.244.304l-.459 2.066A1.75 1.75 0 0010.747 15H11a.75.75 0 000-1.5h-.253a.25.25 0 01-.244-.304l.459-2.066A1.75 1.75 0 009.253 9H9z"
clip-rule="evenodd"
<span
class="oauth-icon-lg"
v-html="getOAuthIcon(p.provider_type)"
/>
</svg>
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-foreground">
演示模式
</p>
<p class="mt-1 text-xs text-muted-foreground">
当前处于演示模式所有数据均为模拟数据
</p>
<div class="mt-3 space-y-2">
<button
type="button"
class="flex items-center gap-2 text-xs text-muted-foreground hover:text-foreground transition-colors group"
@click="fillDemoAccount('admin')"
>
<span class="inline-flex items-center justify-center w-5 h-5 rounded bg-primary/20 dark:bg-primary/30 text-primary text-[10px] font-bold group-hover:bg-primary/30 dark:group-hover:bg-primary/40 transition-colors">A</span>
<span>管理员admin@demo.aether.io / demo123</span>
</button>
<button
type="button"
class="flex items-center gap-2 text-xs text-muted-foreground hover:text-foreground transition-colors group"
@click="fillDemoAccount('user')"
>
<span class="inline-flex items-center justify-center w-5 h-5 rounded bg-muted text-muted-foreground text-[10px] font-bold group-hover:bg-muted/80 transition-colors">U</span>
<span>普通用户user@demo.aether.io / demo123</span>
</button>
</div>
</button>
</div>
</div>
</div>
<!-- 分隔线 -->
<div
v-if="oauthProviders.length > 0"
class="flex items-center gap-3 mb-5"
>
<div class="flex-1 h-px bg-border" />
<span class="text-xs text-muted-foreground px-2">或使用账号密码</span>
<div class="flex-1 h-px bg-border" />
</div>
<!-- 认证方式切换 -->
<div
v-if="showAuthTypeTabs"
class="auth-type-tabs"
class="auth-type-tabs mb-4"
>
<button
type="button"
@@ -89,15 +125,19 @@
</button>
</div>
<!-- 登录表单 -->
<form
class="space-y-4"
@submit.prevent="handleLogin"
>
<div class="space-y-2">
<div class="space-y-1.5">
<div class="flex items-center justify-between">
<Label for="login-email">{{ emailLabel }}</Label>
<Label
for="login-email"
class="text-sm"
>
{{ emailLabel }}
</Label>
<button
v-if="ldapExclusive && authType === 'ldap'"
type="button"
@@ -120,72 +160,69 @@
v-model="form.email"
type="text"
required
placeholder="username 或 email"
placeholder="用户名或邮箱"
autocomplete="off"
/>
</div>
<div class="space-y-2">
<Label for="login-password">密码</Label>
<div class="space-y-1.5">
<Label
for="login-password"
class="text-sm"
>
密码
</Label>
<Input
id="login-password"
v-model="form.password"
type="password"
required
placeholder="********"
placeholder="输入密码"
autocomplete="off"
@keyup.enter="handleLogin"
/>
</div>
<!-- 登录按钮 -->
<Button
type="submit"
:disabled="authStore.loading"
class="w-full h-10"
>
{{ authStore.loading ? '登录中...' : '登录' }}
</Button>
<!-- 提示信息 -->
<p
v-if="!isDemo && !allowRegistration"
class="text-xs text-slate-400 dark:text-muted-foreground/80"
class="text-xs text-muted-foreground text-center"
>
如需开通账户请联系管理员配置访问权限
如需开通账户请联系管理员
</p>
</form>
<!-- 注册链接 -->
<div
v-if="allowRegistration"
class="mt-4 text-center text-sm"
class="mt-5 pt-5 border-t border-border text-center text-sm text-muted-foreground"
>
还没有账户
<Button
variant="link"
class="h-auto p-0"
<button
type="button"
class="text-primary hover:text-primary/80 font-medium transition-colors"
@click="handleSwitchToRegister"
>
立即注册
</Button>
</button>
</div>
</div>
<template #footer>
<Button
type="button"
variant="outline"
class="w-full sm:w-auto border-slate-200 dark:border-slate-600 text-slate-500 dark:text-slate-400 hover:text-primary hover:border-primary/50 hover:bg-primary/5 dark:hover:text-primary dark:hover:border-primary/50 dark:hover:bg-primary/10"
@click="isOpen = false"
>
取消
</Button>
<Button
:disabled="authStore.loading"
class="w-full sm:w-auto bg-primary hover:bg-primary/90 text-white border-0"
@click="handleLogin"
>
{{ authStore.loading ? '登录中...' : '登录' }}
</Button>
</template>
</Dialog>
<!-- Register Dialog -->
<RegisterDialog
v-model:open="showRegisterDialog"
:require-email-verification="requireEmailVerification"
:email-configured="emailConfigured"
@success="handleRegisterSuccess"
@switch-to-login="handleSwitchToLogin"
/>
@@ -203,6 +240,19 @@ import { useToast } from '@/composables/useToast'
import { isDemoMode, DEMO_ACCOUNTS } from '@/config/demo'
import RegisterDialog from './RegisterDialog.vue'
import { authApi } from '@/api/auth'
import { oauthApi, type OAuthProviderInfo } from '@/api/oauth'
import { getApiUrl } from '@/utils/url'
// OAuth provider icons
const OAUTH_ICONS: Record<string, string> = {
linuxdo: `<svg viewBox="0 0 120 120" xmlns="http://www.w3.org/2000/svg"><clipPath id="ld"><circle cx="60" cy="60" r="47"/></clipPath><circle fill="#f0f0f0" cx="60" cy="60" r="50"/><rect fill="#1c1c1e" clip-path="url(#ld)" x="10" y="10" width="100" height="30"/><rect fill="#f0f0f0" clip-path="url(#ld)" x="10" y="40" width="100" height="40"/><rect fill="#ffb003" clip-path="url(#ld)" x="10" y="80" width="100" height="30"/></svg>`,
github: `<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/></svg>`,
google: `<svg viewBox="0 0 24 24"><path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/><path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/><path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/><path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/></svg>`,
}
function getOAuthIcon(providerType: string): string {
return OAUTH_ICONS[providerType] || OAUTH_ICONS.github
}
const props = defineProps<{
modelValue: boolean
@@ -220,6 +270,7 @@ const isOpen = ref(props.modelValue)
const isDemo = computed(() => isDemoMode())
const showRegisterDialog = ref(false)
const requireEmailVerification = ref(false)
const emailConfigured = ref(true) // 邮箱服务是否已配置
const allowRegistration = ref(false) // 由系统配置控制,默认关闭
// LDAP authentication settings
@@ -233,6 +284,8 @@ const localEnabled = ref(true)
const ldapEnabled = ref(false)
const ldapExclusive = ref(false)
const oauthProviders = ref<OAuthProviderInfo[]>([])
// 保存用户的认证类型偏好
watch(authType, (newType) => {
localStorage.setItem(PREFERRED_AUTH_TYPE_KEY, newType)
@@ -296,6 +349,12 @@ async function handleLogin() {
}
}
function handleOAuthLogin(providerType: string) {
// 如果 sessionStorage 中没有 redirectPath用户直接点击登录而非被守卫拦截
// 则不设置,让 AuthCallback 使用默认跳转逻辑
window.location.href = getApiUrl(`/api/oauth/${providerType}/authorize`)
}
function handleSwitchToRegister() {
isOpen.value = false
showRegisterDialog.value = true
@@ -315,13 +374,16 @@ function handleSwitchToLogin() {
// Load authentication and registration settings on mount
onMounted(async () => {
try {
// Load registration settings
const regSettings = await authApi.getRegistrationSettings()
const [regSettings, authSettings, providers] = await Promise.all([
authApi.getRegistrationSettings(),
authApi.getAuthSettings(),
oauthApi.getProviders().catch(() => []),
])
allowRegistration.value = !!regSettings.enable_registration
requireEmailVerification.value = !!regSettings.require_email_verification
emailConfigured.value = !!regSettings.email_configured
// Load authentication settings
const authSettings = await authApi.getAuthSettings()
localEnabled.value = authSettings.local_enabled
ldapEnabled.value = authSettings.ldap_enabled
ldapExclusive.value = authSettings.ldap_exclusive
@@ -338,19 +400,85 @@ onMounted(async () => {
} else {
authType.value = 'local'
}
oauthProviders.value = providers
} catch {
// If获取失败保持默认关闭注册 & 关闭邮箱验证 & 使用本地认证
allowRegistration.value = false
requireEmailVerification.value = false
emailConfigured.value = false
localEnabled.value = true
ldapEnabled.value = false
ldapExclusive.value = false
authType.value = 'local'
oauthProviders.value = []
}
})
</script>
<style scoped>
.oauth-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 0.75rem;
width: 100%;
padding: 0.625rem 1rem;
font-size: 0.875rem;
font-weight: 500;
color: hsl(var(--foreground));
background: hsl(var(--background));
border: 1px solid hsl(var(--border));
border-radius: 0.5rem;
cursor: pointer;
transition: all 0.15s ease;
}
.oauth-btn:hover {
background: hsl(var(--muted));
border-color: hsl(var(--primary) / 0.5);
}
.oauth-icon {
width: 1.25rem;
height: 1.25rem;
flex-shrink: 0;
}
.oauth-icon :deep(svg) {
width: 100%;
height: 100%;
}
.oauth-icon-btn {
display: flex;
align-items: center;
justify-content: center;
width: 3rem;
height: 3rem;
background: hsl(var(--background));
border: 1px solid hsl(var(--border));
border-radius: 0.75rem;
cursor: pointer;
transition: all 0.15s ease;
}
.oauth-icon-btn:hover {
background: hsl(var(--muted));
border-color: hsl(var(--primary) / 0.5);
transform: translateY(-1px);
}
.oauth-icon-lg {
width: 1.5rem;
height: 1.5rem;
}
.oauth-icon-lg :deep(svg) {
width: 100%;
height: 100%;
}
.auth-type-tabs {
display: flex;
border-bottom: 1px solid hsl(var(--border));
@@ -358,7 +486,7 @@ onMounted(async () => {
.auth-tab {
flex: 1;
padding: 0.625rem 1rem;
padding: 0.5rem 1rem;
font-size: 0.875rem;
font-weight: 500;
color: hsl(var(--muted-foreground));
@@ -385,11 +513,11 @@ onMounted(async () => {
}
.auth-tab.active {
color: var(--book-cloth);
color: hsl(var(--primary));
font-weight: 600;
}
.auth-tab.active::after {
background: var(--book-cloth);
background: hsl(var(--primary));
}
</style>

View File

@@ -17,7 +17,7 @@
注册新账户
</h2>
<p class="mt-1 text-sm text-muted-foreground">
请填写您的邮箱和个人信息完成注册
{{ emailConfigured ? '请填写您的信息完成注册' : '请填写用户名和密码完成注册' }}
</p>
</div>
@@ -28,27 +28,40 @@
data-form-type="other"
@submit.prevent="handleSubmit"
>
<!-- Email -->
<div class="space-y-2">
<Label for="reg-email">邮箱 <span class="text-muted-foreground">*</span></Label>
<!-- Email (仅当邮箱服务已配置时显示) -->
<div
v-if="emailConfigured"
class="space-y-2"
>
<Label for="reg-email">
邮箱
<span
v-if="requireEmailVerification"
class="text-destructive"
>*</span>
<span
v-else
class="text-muted-foreground text-xs"
>可选</span>
</Label>
<Input
id="reg-email"
v-model="formData.email"
type="email"
placeholder="hello@example.com"
required
:required="requireEmailVerification"
disable-autofill
:disabled="isLoading || emailVerified"
/>
</div>
<!-- Verification Code Section -->
<!-- Verification Code Section (仅当需要邮箱验证时显示) -->
<div
v-if="requireEmailVerification"
v-if="emailConfigured && requireEmailVerification"
class="space-y-3"
>
<div class="flex items-center justify-between">
<Label>验证码 <span class="text-muted-foreground">*</span></Label>
<Label>验证码 <span class="text-destructive">*</span></Label>
<Button
type="button"
variant="link"
@@ -113,7 +126,7 @@
<!-- Username -->
<div class="space-y-2">
<Label for="reg-uname">用户名 <span class="text-muted-foreground">*</span></Label>
<Label for="reg-uname">用户名 <span class="text-destructive">*</span></Label>
<Input
id="reg-uname"
v-model="formData.username"
@@ -127,7 +140,7 @@
<!-- Password -->
<div class="space-y-2">
<Label :for="`pwd-${formNonce}`">密码 <span class="text-muted-foreground">*</span></Label>
<Label :for="`pwd-${formNonce}`">密码 <span class="text-destructive">*</span></Label>
<Input
:id="`pwd-${formNonce}`"
v-model="formData.password"
@@ -146,7 +159,7 @@
<!-- Confirm Password -->
<div class="space-y-2">
<Label :for="`pwd-confirm-${formNonce}`">确认密码 <span class="text-muted-foreground">*</span></Label>
<Label :for="`pwd-confirm-${formNonce}`">确认密码 <span class="text-destructive">*</span></Label>
<Input
:id="`pwd-confirm-${formNonce}`"
v-model="formData.confirmPassword"
@@ -210,6 +223,7 @@ import Label from '@/components/ui/label.vue'
interface Props {
open?: boolean
requireEmailVerification?: boolean
emailConfigured?: boolean
}
interface Emits {
@@ -220,7 +234,8 @@ interface Emits {
const props = withDefaults(defineProps<Props>(), {
open: false,
requireEmailVerification: false
requireEmailVerification: false,
emailConfigured: true
})
const emit = defineEmits<Emits>()
@@ -353,17 +368,19 @@ const sendCodeButtonText = computed(() => {
})
const canSubmit = computed(() => {
// 基本信息:用户名和密码必填
const hasBasicInfo =
formData.value.email &&
formData.value.username &&
formData.value.password &&
formData.value.confirmPassword
if (!hasBasicInfo) return false
// If email verification is required, check if verified
if (props.requireEmailVerification && !emailVerified.value) {
return false
// 如果需要邮箱验证,邮箱和验证都必须完成
if (props.requireEmailVerification) {
if (!formData.value.email || !emailVerified.value) {
return false
}
}
// Check password match
@@ -608,11 +625,17 @@ const handleSubmit = async () => {
loadingText.value = '注册中...'
try {
const response = await authApi.register({
email: formData.value.email,
// 构建请求数据:邮箱可选
const registerData: { email?: string; username: string; password: string } = {
username: formData.value.username,
password: formData.value.password
})
}
// 只有当邮箱有值时才添加
if (formData.value.email && formData.value.email.trim()) {
registerData.email = formData.value.email
}
const response = await authApi.register(registerData)
success(response.message || '欢迎加入!请登录以继续', '注册成功')

View File

@@ -118,14 +118,13 @@
<Label
for="form-email"
class="text-sm font-medium"
>邮箱 <span class="text-muted-foreground">*</span></Label>
>邮箱</Label>
<Input
id="form-email"
v-model="form.email"
type="email"
autocomplete="off"
data-form-type="other"
required
class="h-10"
/>
</div>
@@ -472,11 +471,10 @@ const { isEditMode, handleDialogUpdate, handleCancel } = useFormDialog({
// 表单验证
const isFormValid = computed(() => {
const hasUsername = form.value.username.trim().length > 0
const hasEmail = form.value.email.trim().length > 0
const hasPassword = isEditMode.value || form.value.password.length >= 6
// 编辑模式下如果填写了密码,必须确认密码一致
const passwordConfirmed = !isEditMode.value || form.value.password.length === 0 || form.value.password === form.value.confirmPassword
return hasUsername && hasEmail && hasPassword && passwordConfirmed
return hasUsername && hasPassword && passwordConfirmed
})
// 加载访问控制选项
@@ -508,16 +506,11 @@ function toggleSelection(field: 'allowed_providers' | 'allowed_api_formats' | 'a
// 提交表单
async function handleSubmit() {
// 验证邮箱必填
if (!form.value.email || !form.value.email.trim()) {
return
}
saving.value = true
try {
const data: UserFormData & { password?: string; unlimited?: boolean } = {
username: form.value.username,
email: form.value.email.trim(),
email: form.value.email.trim() || '',
quota_usd: form.value.unlimited ? null : form.value.quota,
role: form.value.role,
allowed_providers: form.value.allowed_providers.length > 0 ? form.value.allowed_providers : null,