Files
Aether/frontend/src/features/auth/components/LoginDialog.vue
T

608 lines
17 KiB
Vue
Raw Normal View History

2025-12-10 20:52:44 +08:00
<template>
<Dialog
v-model="isOpen"
size="md"
no-padding
>
<div class="px-6 py-6 sm:px-8 sm:py-8">
2025-12-10 20:52:44 +08:00
<!-- Logo 和标题 -->
<div class="flex flex-col items-center text-center mb-8">
<img
src="/aether_adaptive.svg"
:alt="siteName"
class="h-16 w-16 mb-4"
>
<h2 class="text-2xl font-semibold text-foreground">
{{ t('auth.login.title', { siteName }) }}
2025-12-10 20:52:44 +08:00
</h2>
</div>
2025-12-11 10:03:10 +08:00
<!-- Demo 模式提示 -->
<div
v-if="isDemo"
class="rounded-lg border border-primary/20 bg-primary/5 p-3 mb-5"
>
<p class="text-xs font-medium text-foreground mb-2">
{{ t('auth.login.demoMode') }}
</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)"
>
<!-- eslint-disable vue/no-v-html -->
<span
class="oauth-icon"
v-html="getOAuthIcon(oauthProviders[0].provider_type, oauthProviders[0].icon_url)"
/>
<!-- eslint-enable vue/no-v-html -->
<span>{{ t('auth.login.oauthWithProvider', { provider: 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">{{ t('auth.login.oauthOptions') }}</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)"
>
<!-- eslint-disable vue/no-v-html -->
<span
class="oauth-icon-lg"
v-html="getOAuthIcon(p.provider_type, p.icon_url)"
/>
<!-- eslint-enable vue/no-v-html -->
</button>
2025-12-11 10:03:10 +08:00
</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">{{ t('auth.login.accountPassword') }}</span>
<div class="flex-1 h-px bg-border" />
</div>
2026-01-02 16:17:24 +08:00
<!-- 认证方式切换 -->
2026-01-06 14:38:42 +08:00
<div
2026-01-02 16:17:24 +08:00
v-if="showAuthTypeTabs"
class="auth-type-tabs mb-4"
2026-01-02 16:17:24 +08:00
>
2026-01-06 14:38:42 +08:00
<button
type="button"
class="auth-tab"
:class="[authType === 'local' && 'active']"
2026-01-06 14:38:42 +08:00
@click="authType = 'local'"
>
{{ t('auth.login.local') }}
2026-01-06 14:38:42 +08:00
</button>
<button
type="button"
class="auth-tab"
:class="[authType === 'ldap' && 'active']"
2026-01-06 14:38:42 +08:00
@click="authType = 'ldap'"
>
{{ t('auth.login.ldap') }}
2026-01-06 14:38:42 +08:00
</button>
</div>
2025-12-10 20:52:44 +08:00
<!-- 登录表单 -->
<form
ref="loginFormEl"
name="login"
action="/api/auth/login"
method="post"
class="space-y-4"
autocomplete="on"
data-form-type="login"
@submit.prevent="handleLogin"
>
<div class="space-y-1.5">
2026-01-06 14:38:42 +08:00
<div class="flex items-center justify-between">
<Label
for="username"
class="text-sm"
>
{{ emailLabel }}
</Label>
2026-01-06 14:38:42 +08:00
<button
v-if="ldapExclusive && authType === 'ldap'"
type="button"
class="text-xs text-muted-foreground/60 hover:text-muted-foreground transition-colors"
@click="authType = 'local'"
>
{{ t('auth.login.adminLocal') }}
2026-01-06 14:38:42 +08:00
</button>
<button
v-if="ldapExclusive && authType === 'local'"
type="button"
class="text-xs text-muted-foreground/60 hover:text-muted-foreground transition-colors"
@click="authType = 'ldap'"
>
{{ t('auth.login.backToLdap') }}
2026-01-06 14:38:42 +08:00
</button>
</div>
2025-12-10 20:52:44 +08:00
<Input
id="username"
2025-12-10 20:52:44 +08:00
v-model="form.email"
2026-01-06 14:38:42 +08:00
type="text"
name="username"
2025-12-10 20:52:44 +08:00
required
:placeholder="t('auth.login.usernamePlaceholder')"
autocomplete="username"
autocapitalize="none"
spellcheck="false"
:disable-autofill="false"
2025-12-10 20:52:44 +08:00
/>
</div>
<div class="space-y-1.5">
<Label
for="password"
class="text-sm"
>
{{ t('auth.login.password') }}
</Label>
2025-12-10 20:52:44 +08:00
<Input
id="password"
2025-12-10 20:52:44 +08:00
v-model="form.password"
type="password"
name="password"
2025-12-10 20:52:44 +08:00
required
:placeholder="t('auth.login.passwordPlaceholder')"
autocomplete="current-password"
:disable-autofill="false"
2025-12-10 20:52:44 +08:00
/>
</div>
<!-- 登录按钮 -->
<Button
type="submit"
:disabled="authStore.loading"
class="w-full h-12"
>
{{ authStore.loading ? t('auth.login.submitting') : t('auth.login.submit') }}
</Button>
2025-12-10 20:52:44 +08:00
<!-- 提示信息 -->
<p
2025-12-30 17:15:48 +08:00
v-if="!isDemo && !allowRegistration"
class="text-xs text-muted-foreground text-center"
>
{{ t('auth.login.contactAdmin') }}
2025-12-10 20:52:44 +08:00
</p>
</form>
2025-12-30 17:15:48 +08:00
<!-- 注册链接 -->
<div
v-if="allowRegistration"
class="mt-5 pt-5 border-t border-border text-center text-sm text-muted-foreground"
2025-12-30 17:15:48 +08:00
>
{{ t('auth.login.noAccount') }}
<button
type="button"
class="text-primary hover:text-primary/80 font-medium transition-colors"
2025-12-30 17:15:48 +08:00
@click="handleSwitchToRegister"
>
{{ t('auth.login.registerNow') }}
</button>
2025-12-30 17:15:48 +08:00
</div>
2025-12-10 20:52:44 +08:00
</div>
</Dialog>
2025-12-30 17:15:48 +08:00
<!-- Register Dialog -->
<RegisterDialog
v-model:open="showRegisterDialog"
:require-email-verification="requireEmailVerification"
:email-configured="emailConfigured"
:password-policy-level="passwordPolicyLevel"
:turnstile-enabled="turnstileEnabled"
:turnstile-site-key="turnstileSiteKey"
:privacy-policy="privacyPolicy"
2025-12-30 17:15:48 +08:00
@success="handleRegisterSuccess"
@switch-to-login="handleSwitchToLogin"
/>
2025-12-10 20:52:44 +08:00
</template>
<script setup lang="ts">
2025-12-30 17:15:48 +08:00
import { ref, watch, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
2025-12-10 20:52:44 +08:00
import { Dialog } from '@/components/ui'
import Button from '@/components/ui/button.vue'
import Input from '@/components/ui/input.vue'
import Label from '@/components/ui/label.vue'
import { useAuthStore } from '@/stores/auth'
import { useToast } from '@/composables/useToast'
import { useSiteInfo } from '@/composables/useSiteInfo'
import { normalizePasswordPolicyLevel, type PasswordPolicyLevel } from '@/utils/passwordPolicy'
2025-12-11 10:03:10 +08:00
import { isDemoMode, DEMO_ACCOUNTS } from '@/config/demo'
2025-12-30 17:15:48 +08:00
import RegisterDialog from './RegisterDialog.vue'
import { authApi, type RegistrationPrivacyPolicySettings } from '@/api/auth'
import { oauthApi, type OAuthProviderInfo } from '@/api/oauth'
import { getClientDeviceId } from '@/utils/deviceId'
import { getApiUrl } from '@/utils/url'
import { getOAuthIcon } from '@/utils/oauth-icons'
import { navigateAfterLogin } from '@/features/auth/utils/loginRedirect'
import { useI18n } from '@/i18n'
import { safeInternalNavigationPath } from '@/utils/navigationSecurity'
2025-12-10 20:52:44 +08:00
const props = defineProps<{
modelValue: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
}>()
const router = useRouter()
const route = useRoute()
2025-12-10 20:52:44 +08:00
const authStore = useAuthStore()
const { success: showSuccess, warning: showWarning, error: showError } = useToast()
const { siteName } = useSiteInfo()
const { t } = useI18n()
2025-12-10 20:52:44 +08:00
const isOpen = ref(props.modelValue)
2025-12-11 10:03:10 +08:00
const isDemo = computed(() => isDemoMode())
2025-12-30 17:15:48 +08:00
const showRegisterDialog = ref(false)
const requireEmailVerification = ref(false)
const emailConfigured = ref(true) // 邮箱服务是否已配置
const passwordPolicyLevel = ref<PasswordPolicyLevel>('weak')
const turnstileEnabled = ref(false)
const turnstileSiteKey = ref<string | null>(null)
2025-12-30 17:15:48 +08:00
const allowRegistration = ref(false) // 由系统配置控制,默认关闭
const privacyPolicy = ref<RegistrationPrivacyPolicySettings>({
enabled: false,
format: 'markdown',
content: '',
version: ''
})
2025-12-10 20:52:44 +08:00
2026-01-02 16:17:24 +08:00
// LDAP authentication settings
2026-01-06 14:38:42 +08:00
const PREFERRED_AUTH_TYPE_KEY = 'aether_preferred_auth_type'
function getStoredAuthType(): 'local' | 'ldap' {
const stored = localStorage.getItem(PREFERRED_AUTH_TYPE_KEY)
return (stored === 'ldap' || stored === 'local') ? stored : 'local'
}
const authType = ref<'local' | 'ldap'>(getStoredAuthType())
2026-01-02 16:17:24 +08:00
const localEnabled = ref(true)
const ldapEnabled = ref(false)
const ldapExclusive = ref(false)
const oauthProviders = ref<OAuthProviderInfo[]>([])
const loginFormEl = ref<HTMLFormElement | null>(null)
2026-01-06 14:38:42 +08:00
// 保存用户的认证类型偏好
watch(authType, (newType) => {
localStorage.setItem(PREFERRED_AUTH_TYPE_KEY, newType)
})
2026-01-02 16:17:24 +08:00
const showAuthTypeTabs = computed(() => {
return localEnabled.value && ldapEnabled.value && !ldapExclusive.value
})
const emailLabel = computed(() => {
return t('auth.login.usernameEmail')
2026-01-02 16:17:24 +08:00
})
2025-12-10 20:52:44 +08:00
watch(() => props.modelValue, (val) => {
isOpen.value = val
// 打开对话框时重置表单
if (val) {
form.value = {
email: '',
password: ''
}
}
})
watch(isOpen, (val) => {
emit('update:modelValue', val)
})
const form = ref({
email: '',
password: ''
})
2025-12-11 10:03:10 +08:00
function fillDemoAccount(type: 'admin' | 'user') {
const account = DEMO_ACCOUNTS[type]
form.value.email = account.email
form.value.password = account.password
}
async function handleLogin(event?: Event) {
const { email, password } = readCurrentLoginCredentials(event)
if (!email || !password) {
showWarning(t('auth.login.required'))
2025-12-10 20:52:44 +08:00
return
}
const success = await authStore.login(email, password, authType.value)
2025-12-10 20:52:44 +08:00
if (success) {
const targetPath = consumeStoredRedirectPath() ?? (authStore.canAccessAdmin ? '/admin/dashboard' : '/dashboard')
await navigateAfterLogin(router, targetPath)
showSuccess(t('auth.login.successRedirecting'))
2025-12-10 20:52:44 +08:00
// 关闭对话框
isOpen.value = false
} else {
showError(authStore.error || t('auth.login.failed'))
2025-12-10 20:52:44 +08:00
}
}
2025-12-30 17:15:48 +08:00
function readCurrentLoginCredentials(event?: Event): { email: string; password: string } {
const formElement = event?.currentTarget instanceof HTMLFormElement
? event.currentTarget
: loginFormEl.value
const emailInput = formElement?.elements.namedItem('username')
const passwordInput = formElement?.elements.namedItem('password')
const email = emailInput instanceof HTMLInputElement
? emailInput.value.trim()
: form.value.email.trim()
const password = passwordInput instanceof HTMLInputElement
? passwordInput.value
: form.value.password
form.value.email = email
form.value.password = password
return { email, password }
}
function consumeStoredRedirectPath(): string | null {
const redirectPath = sessionStorage.getItem('redirectPath')
if (redirectPath) {
sessionStorage.removeItem('redirectPath')
}
const safePath = safeInternalNavigationPath(redirectPath)
return safePath === '/' ? null : safePath
}
function handleOAuthLogin(providerType: string) {
// 如果 sessionStorage 中没有 redirectPath(用户直接点击登录而非被守卫拦截),
// 则不设置,让 AuthCallback 使用默认跳转逻辑
const authorizeUrl = new URL(
getApiUrl(`/api/oauth/${providerType}/authorize`),
window.location.origin,
)
authorizeUrl.searchParams.set('client_device_id', getClientDeviceId())
window.location.href = authorizeUrl.toString()
}
2025-12-30 17:15:48 +08:00
function handleSwitchToRegister() {
isOpen.value = false
showRegisterDialog.value = true
}
function handleRegisterSuccess() {
showRegisterDialog.value = false
showSuccess(t('auth.register.successLogin'))
2025-12-30 17:15:48 +08:00
isOpen.value = true
}
function handleSwitchToLogin() {
showRegisterDialog.value = false
isOpen.value = true
}
2026-01-02 16:17:24 +08:00
// Load authentication and registration settings on mount
2025-12-30 17:15:48 +08:00
onMounted(async () => {
try {
const [regSettings, authSettings, providers] = await Promise.all([
authApi.getRegistrationSettings(),
authApi.getAuthSettings(),
oauthApi.getProviders().catch(() => []),
])
2026-01-02 16:17:24 +08:00
allowRegistration.value = !!regSettings.enable_registration
requireEmailVerification.value = !!regSettings.require_email_verification
emailConfigured.value = !!regSettings.email_configured
passwordPolicyLevel.value = normalizePasswordPolicyLevel(regSettings.password_policy_level)
turnstileEnabled.value = !!regSettings.turnstile_enabled
turnstileSiteKey.value = regSettings.turnstile_site_key || null
privacyPolicy.value = regSettings.privacy_policy ?? {
enabled: false,
format: 'markdown',
content: '',
version: ''
}
2026-01-02 16:17:24 +08:00
localEnabled.value = authSettings.local_enabled
ldapEnabled.value = authSettings.ldap_enabled
ldapExclusive.value = authSettings.ldap_exclusive
2026-01-04 16:27:02 +08:00
// 若仅允许 LDAP 登录,则禁用本地注册入口
if (ldapExclusive.value) {
allowRegistration.value = false
}
2026-01-02 16:17:24 +08:00
// Set default auth type based on settings
if (authSettings.ldap_exclusive) {
authType.value = 'ldap'
} else if (!authSettings.local_enabled && authSettings.ldap_enabled) {
authType.value = 'ldap'
} else {
authType.value = 'local'
}
oauthProviders.value = providers
if (allowRegistration.value && (route.path === '/register' || typeof route.query.invite === 'string')) {
isOpen.value = false
showRegisterDialog.value = true
}
} catch {
2026-01-02 16:17:24 +08:00
// If获取失败,保持默认:关闭注册 & 关闭邮箱验证 & 使用本地认证
2025-12-30 17:15:48 +08:00
allowRegistration.value = false
requireEmailVerification.value = false
emailConfigured.value = false
passwordPolicyLevel.value = 'weak'
turnstileEnabled.value = false
turnstileSiteKey.value = null
privacyPolicy.value = {
enabled: false,
format: 'markdown',
content: '',
version: ''
}
2026-01-02 16:17:24 +08:00
localEnabled.value = true
ldapEnabled.value = false
ldapExclusive.value = false
authType.value = 'local'
oauthProviders.value = []
2025-12-30 17:15:48 +08:00
}
})
2025-12-10 20:52:44 +08:00
</script>
2026-01-06 14:38:42 +08:00
<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(--muted) / 0.5);
border: 1px solid hsl(var(--border) / 0.6);
border-radius: 0.75rem;
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(--muted) / 0.5);
border: 1px solid hsl(var(--border) / 0.6);
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%;
}
2026-01-06 14:38:42 +08:00
.auth-type-tabs {
display: flex;
border-bottom: 1px solid hsl(var(--border));
}
.auth-tab {
flex: 1;
padding: 0.5rem 1rem;
2026-01-06 14:38:42 +08:00
font-size: 0.875rem;
font-weight: 500;
color: hsl(var(--muted-foreground));
background: transparent;
border: none;
cursor: pointer;
transition: color 0.15s ease;
position: relative;
}
.auth-tab::after {
content: '';
position: absolute;
bottom: -1px;
left: 0;
right: 0;
height: 2px;
background: transparent;
transition: background 0.15s ease;
}
.auth-tab:hover:not(.active) {
color: hsl(var(--foreground));
}
.auth-tab.active {
color: hsl(var(--primary));
2026-01-06 14:38:42 +08:00
font-weight: 600;
}
.auth-tab.active::after {
background: hsl(var(--primary));
2026-01-06 14:38:42 +08:00
}
</style>