feat: align with upstream 1.5.9.9

This commit is contained in:
2026-09-12 18:21:11 +08:00
commit 34f0679117
805 changed files with 376275 additions and 0 deletions
+109
View File
@@ -0,0 +1,109 @@
<template>
<div class="flex flex-col gap-2">
<label class="text-sm font-medium text-text-secondary dark:text-text-tertiary">{{ locale.label }}</label>
<div class="flex gap-2 items-start">
<!-- SVG 图片(可点击刷新) -->
<div
class="captcha-svg-container border border-border-secondary dark:border-border-tertiary cursor-pointer"
:title="locale.refreshTitle"
@click="refreshCaptcha"
>
<img v-if="svgDataUrl" :src="svgDataUrl" :alt="locale.alt" class="captcha-svg-image">
</div>
<!-- 输入框 -->
<input
v-model="inputValue"
type="text"
maxlength="4"
autocomplete="off"
:placeholder="locale.placeholder"
class="flex-1 px-3 py-2 border border-border-secondary dark:border-border-tertiary rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-primary focus:border-primary bg-bg-secondary dark:bg-bg-tertiary text-text-primary dark:text-text-primary-lighter placeholder-text-muted dark:placeholder-text-tertiary"
@input="handleInput"
>
<!-- 刷新按钮(也可直接点图片,这里提供文字按钮辅助) -->
<button
type="button"
class="px-2 py-1 text-xs text-primary-hover hover:text-primary-hover dark:text-primary"
@click="refreshCaptcha"
>
{{ locale.refresh }}
</button>
</div>
</div>
</template>
<script setup>
import { computed, ref, onMounted } from 'vue'
import { useLocale } from '~/utils/locale'
const { auth } = useLocale()
const locale = computed(() => auth.value?.captchaInput || {})
const props = defineProps({
modelValue: {
type: String,
default: ''
}
})
const emit = defineEmits(['update:modelValue', 'update:captchaId'])
const svgDataUrl = ref('')
const captchaId = ref('')
const inputValue = ref(props.modelValue || '')
function svgToDataUrl(svg) {
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`
}
function handleInput(event) {
inputValue.value = event.target.value.trim()
emit('update:modelValue', inputValue.value)
}
async function refreshCaptcha() {
try {
const res = await $fetch('/api/auth/captcha')
svgDataUrl.value = svgToDataUrl(res.svg)
captchaId.value = res.id
emit('update:captchaId', res.id)
inputValue.value = ''
emit('update:modelValue', '')
} catch (e) {
console.error(locale.value.loadFailed, e)
}
}
defineExpose({ refreshCaptcha })
onMounted(() => {
refreshCaptcha()
})
</script>
<style scoped>
/* 保证 SVG 自适应容器,不被裁剪 */
:deep(svg) {
max-width: 100%;
height: auto;
display: block;
}
.captcha-svg-image {
max-width: 100%;
height: auto;
display: block;
}
/* 可选:优化容器的最小宽度和边框间距 */
.captcha-svg-container {
min-width: 130px;
padding: 4px;
border-radius: 0.5rem;
display: flex;
align-items: center;
justify-content: center;
background-color: var(--bg-card, var(--panel-bg-overlay)); /* 不透明卡片背景,避免暗色遮罩/主题下验证码看不清 */
border: 1px solid var(--border-tertiary);
}
</style>
+328
View File
@@ -0,0 +1,328 @@
<template>
<div class="space-y-6">
<form class="space-y-5" @submit.prevent="handleChangePassword">
<div v-if="!isFirstLogin" class="space-y-2">
<label
for="current-password"
class="text-xs font-black text-text-tertiary uppercase tracking-widest ml-1"
>{{ locale.currentPassword }}</label
>
<div class="relative group">
<div
class="absolute left-4 top-1/2 -translate-y-1/2 text-text-tertiary group-focus-within:text-primary transition-colors"
>
<Lock :size="18" />
</div>
<input
id="current-password"
v-model="currentPassword"
:class="[
inputClass,
error
? 'border-error shadow-[0_0_15px_var(--auth-error-input-shadow)]'
: 'border-border-secondary focus:border-primary-30'
]"
:type="showCurrentPassword ? 'text' : 'password'"
autocomplete="current-password"
:placeholder="locale.currentPasswordPlaceholder"
required
@input="error = ''"
/>
<button
class="absolute right-4 top-1/2 -translate-y-1/2 text-text-tertiary hover:text-text-secondary transition-colors"
type="button"
:aria-label="
showCurrentPassword ? locale.hideCurrentPassword : locale.showCurrentPassword
"
@click="showCurrentPassword = !showCurrentPassword"
>
<Eye v-if="!showCurrentPassword" :size="18" />
<EyeOff v-else :size="18" />
</button>
</div>
</div>
<div class="space-y-2">
<label
for="new-password"
class="text-xs font-black text-text-tertiary uppercase tracking-widest ml-1"
>{{ locale.newPassword }}</label
>
<div class="relative group">
<div
class="absolute left-4 top-1/2 -translate-y-1/2 text-text-tertiary group-focus-within:text-primary transition-colors"
>
<KeyRound :size="18" />
</div>
<input
id="new-password"
v-model="newPassword"
:class="[
inputClass,
error
? 'border-error shadow-[0_0_15px_var(--auth-error-input-shadow)]'
: 'border-border-secondary focus:border-primary-30'
]"
:type="showNewPassword ? 'text' : 'password'"
autocomplete="new-password"
:placeholder="locale.newPasswordPlaceholder"
required
@input="handleNewPasswordInput"
/>
<button
class="absolute right-4 top-1/2 -translate-y-1/2 text-text-tertiary hover:text-text-secondary transition-colors"
type="button"
:aria-label="showNewPassword ? locale.hideNewPassword : locale.showNewPassword"
@click="showNewPassword = !showNewPassword"
>
<Eye v-if="!showNewPassword" :size="18" />
<EyeOff v-else :size="18" />
</button>
</div>
<!-- 密码强度指示器 -->
<div v-if="newPassword" class="px-1 pt-1 space-y-2">
<div class="h-1 w-full bg-bg-tertiary rounded-full overflow-hidden">
<div
class="h-full transition-all duration-500"
:class="passwordStrength.colorClass"
:style="{ width: passwordStrength.width }"
/>
</div>
<div class="flex justify-between items-center">
<span class="text-[10px] font-black uppercase tracking-widest text-text-tertiary"
>{{ locale.passwordStrength }}</span
>
<span
class="text-[10px] font-black uppercase tracking-widest"
:class="passwordStrength.textColorClass"
>
{{ passwordStrength.text }}
</span>
</div>
</div>
</div>
<div class="space-y-2">
<label
for="confirm-password"
class="text-xs font-black text-text-tertiary uppercase tracking-widest ml-1"
>{{ locale.confirmPassword }}</label
>
<div class="relative group">
<div
class="absolute left-4 top-1/2 -translate-y-1/2 text-text-tertiary group-focus-within:text-primary transition-colors"
>
<CheckCircle2 :size="18" />
</div>
<input
id="confirm-password"
v-model="confirmPassword"
:class="[
inputClass,
error || (confirmPassword && newPassword !== confirmPassword)
? 'border-error shadow-[0_0_15px_var(--auth-error-input-shadow)]'
: 'border-border-secondary focus:border-primary-30'
]"
:type="showConfirmPassword ? 'text' : 'password'"
autocomplete="new-password"
:placeholder="locale.confirmPasswordPlaceholder"
required
@input="error = ''"
/>
<button
class="absolute right-4 top-1/2 -translate-y-1/2 text-text-tertiary hover:text-text-secondary transition-colors"
type="button"
:aria-label="
showConfirmPassword ? locale.hideConfirmPassword : locale.showConfirmPassword
"
@click="showConfirmPassword = !showConfirmPassword"
>
<Eye v-if="!showConfirmPassword" :size="18" />
<EyeOff v-else :size="18" />
</button>
</div>
<!-- 密码匹配提示 -->
<div v-if="confirmPassword" class="px-1">
<div
v-if="newPassword !== confirmPassword"
class="flex items-center gap-1.5 text-error"
>
<XCircle :size="12" />
<span class="text-[10px] font-bold">{{ locale.mismatch }}</span>
</div>
<div v-else class="flex items-center gap-1.5 text-success">
<CheckCircle2 :size="12" />
<span class="text-[10px] font-bold">{{ locale.matched }}</span>
</div>
</div>
</div>
<!-- 状态消息 -->
<div
v-if="error"
aria-live="polite"
class="p-3 bg-error-10 border border-error-20 rounded-xl flex items-center gap-3"
>
<AlertCircle :size="16" class="text-error shrink-0" />
<span class="text-xs text-error font-medium">{{ error }}</span>
</div>
<div
v-if="success"
aria-live="polite"
class="p-3 bg-success-10 border border-success-20 rounded-xl flex items-center gap-3"
>
<CheckCircle2 :size="16" class="text-success shrink-0" />
<span class="text-xs text-success font-medium">{{ success }}</span>
</div>
<button
:disabled="loading || !isFormValid"
class="w-full flex items-center justify-center gap-2 py-3 bg-primary-hover hover:bg-primary text-text-primary text-sm font-black rounded-xl shadow-lg shadow-[var(--primary-glow)] transition-all active:scale-[0.98] disabled:opacity-50"
type="submit"
>
<Loader2 v-if="loading" :size="18" class="animate-spin" />
<span>{{ loading ? locale.processing : isFirstLogin ? locale.setInitial : locale.submitChange }}</span>
</button>
</form>
</div>
</template>
<script setup>
import { computed, onBeforeUnmount, ref } from 'vue'
import {
Lock,
KeyRound,
Eye,
EyeOff,
CheckCircle2,
XCircle,
AlertCircle,
Loader2
} from '@lucide/vue'
import { useLocale } from '~/utils/locale'
import { usePasswordStrength } from '~/composables/usePasswordStrength'
import { validateInitialPasswordPolicy, validatePasswordPolicy } from '~/utils/password-policy'
// 组件属性
const props = defineProps({
isFirstLogin: {
type: Boolean,
default: false
}
})
const auth = useAuth()
const router = useRouter()
const { auth: authLocale } = useLocale()
const locale = computed(() => authLocale.value?.changePasswordForm || {})
const { localize: localizeServerError } = useServerErrors()
const currentPassword = ref('')
const newPassword = ref('')
const confirmPassword = ref('')
const error = ref('')
const success = ref('')
const loading = ref(false)
let redirectTimer = null
const scheduleRedirect = (callback) => {
if (redirectTimer) clearTimeout(redirectTimer)
redirectTimer = setTimeout(callback, 2000)
}
onBeforeUnmount(() => {
if (redirectTimer) clearTimeout(redirectTimer)
})
// 样式类
const inputClass =
'w-full bg-bg-primary border rounded-xl pl-11 pr-11 py-3 text-sm text-text-primary placeholder:text-text-disabled focus:outline-none transition-all'
// 密码显示状态
const showCurrentPassword = ref(false)
const showNewPassword = ref(false)
const showConfirmPassword = ref(false)
// 密码强度计算
const passwordStrength = usePasswordStrength(newPassword)
const passwordPolicyError = computed(() =>
props.isFirstLogin
? validateInitialPasswordPolicy(newPassword.value)
: validatePasswordPolicy(newPassword.value)
)
// 表单验证
const isFormValid = computed(() => {
return Boolean(
(props.isFirstLogin || currentPassword.value) &&
newPassword.value &&
confirmPassword.value &&
newPassword.value === confirmPassword.value &&
!passwordPolicyError.value
)
})
const handleNewPasswordInput = () => {
error.value = newPassword.value ? passwordPolicyError.value || '' : ''
}
const resetForm = () => {
currentPassword.value = ''
newPassword.value = ''
confirmPassword.value = ''
}
const handleChangePassword = async () => {
if (newPassword.value !== confirmPassword.value) {
error.value = locale.value.newPasswordMismatch
return
}
if (passwordPolicyError.value) {
error.value = passwordPolicyError.value
return
}
loading.value = true
error.value = ''
success.value = ''
try {
if (props.isFirstLogin) {
await auth.setInitialPassword(newPassword.value)
success.value = locale.value.initialSuccess
resetForm()
// 密码设置完成后跳转
scheduleRedirect(async () => {
if (auth.isAdmin.value) {
router.replace('/dashboard')
} else {
router.replace('/')
}
})
} else {
await auth.changePassword(currentPassword.value, newPassword.value)
success.value = locale.value.changeSuccess
resetForm()
// 密码修改后登出
scheduleRedirect(async () => {
await auth.logout(false)
await router.replace('/login')
})
}
} catch (err) {
// 统一按错误码本地化服务端错误,未命中再回退到默认文案
error.value = localizeServerError(err, locale.value.failed)
} finally {
loading.value = false
}
}
</script>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,334 @@
<template>
<Teleport to="body">
<Transition name="bind-reminder">
<div v-if="show" class="bind-reminder-overlay" @click.self="handleIgnore">
<div class="bind-reminder-modal" role="dialog" aria-modal="true" :aria-label="title">
<button
type="button"
class="bind-reminder-close"
:aria-label="closeLabel"
@click="handleIgnore"
>
<Icon name="x" :size="18" />
</button>
<div class="bind-reminder-badge" :class="`is-${loginType}`">
<Icon :name="providerIcon" :size="34" />
</div>
<h3 class="bind-reminder-title">{{ title }}</h3>
<p class="bind-reminder-desc">{{ description }}</p>
<div class="bind-reminder-actions">
<button type="button" class="bind-reminder-btn primary" @click="handleBind">
<Link :size="16" />
<span>{{ locale.bindNow }}</span>
</button>
<button type="button" class="bind-reminder-btn ghost" @click="handleIgnore">
{{ locale.ignore }}
</button>
</div>
<button type="button" class="bind-reminder-dismiss" @click="handleNeverRemind">
{{ locale.neverRemind }}
</button>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
import Icon from '~/components/UI/Icon.vue'
import { Link } from '@lucide/vue'
import { detectEmbeddedBrowser } from '~/utils/embedded-browser'
import { getAggregateOAuthLoginTypeIcon, getAggregateOAuthLoginTypeName } from '~/utils/oauth'
import { useOAuthBindReminder } from '~/composables/useOAuthBindReminder'
import { useLocaleText } from '~/composables/useLocaleText'
import { useLocale } from '~/utils/locale'
const { isAuthenticated, user } = useAuth()
const { initSiteConfig, oauthProviders } = useSiteConfig()
const route = useRoute()
const { auth: authLocale, common } = useLocale()
const locale = computed(() => authLocale.value?.bindReminder || {})
const { format } = useLocaleText(locale)
const reminder = useOAuthBindReminder()
const show = ref(false)
const checking = ref(false)
const loginType = ref('')
const providerName = computed(() => getAggregateOAuthLoginTypeName(loginType.value))
const providerIcon = computed(() => getAggregateOAuthLoginTypeIcon(loginType.value))
const title = computed(() => format(locale.value.title, '', providerName.value))
const description = computed(() => format(locale.value.description, '', providerName.value))
const closeLabel = computed(() => common.value?.close || '关闭')
const isEligibleRoute = () => {
return route.path === '/'
}
const tryShow = async () => {
if (import.meta.server || !import.meta.client) return
if (show.value || checking.value) return
if (!isAuthenticated.value || !user.value?.id || user.value?.requirePasswordChange) return
if (!isEligibleRoute()) return
const env = detectEmbeddedBrowser()
const envType = env.isWeChat ? 'wx' : env.isQQApp ? 'qq' : ''
if (!envType) return
if (!reminder.hasPendingPasswordLogin()) return
if (reminder.isDismissed()) {
reminder.clearPending()
return
}
checking.value = true
try {
await initSiteConfig()
const enabledTypes = new Set(
(oauthProviders.value || [])
.filter((item) => item.routeProvider === 'aggregate' && item.loginType)
.map((item) => item.loginType)
)
if (!enabledTypes.has(envType)) {
reminder.clearPending()
return
}
let identities = []
try {
identities = await $fetch('/api/auth/identities')
} catch {
// 查询失败时保留待提示状态,下次进入主页再尝试
return
}
const alreadyBound = identities.some((item) => item.provider === `aggregate:${envType}`)
if (alreadyBound) {
reminder.clearPending()
return
}
if (!isEligibleRoute()) return
loginType.value = envType
show.value = true
} finally {
checking.value = false
}
}
watch([() => route.path, () => isAuthenticated.value, () => user.value?.id], () => tryShow(), {
immediate: true
})
const handleBind = () => {
const type = loginType.value
if (!type) return
reminder.clearPending()
show.value = false
const query = new URLSearchParams()
query.set('type', type)
navigateTo(`/api/auth/aggregate?${query.toString()}`, { external: true })
}
const handleIgnore = () => {
// 忽略只关闭本次弹窗,保留待提示状态,回到可提示页面时再次弹出
show.value = false
}
const handleNeverRemind = () => {
reminder.dismissForever()
show.value = false
}
</script>
<style scoped>
.bind-reminder-overlay {
position: fixed;
inset: 0;
z-index: var(--z-modal-backdrop);
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
background: rgba(0, 0, 0, 0.55);
backdrop-filter: blur(6px);
}
.bind-reminder-modal {
position: relative;
width: 100%;
max-width: 400px;
padding: 34px 28px 22px;
background: var(--bg-secondary);
border: 1px solid var(--border-primary);
border-radius: var(--radius-2xl);
box-shadow: var(--shadow-xl);
text-align: center;
}
.bind-reminder-close {
position: absolute;
top: 12px;
right: 12px;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
background: transparent;
border: none;
border-radius: var(--radius-lg);
color: var(--text-quaternary);
cursor: pointer;
transition:
background var(--transition-fast),
color var(--transition-fast);
}
.bind-reminder-close:hover {
background: var(--bg-tertiary);
color: var(--text-primary);
}
.bind-reminder-badge {
width: 68px;
height: 68px;
margin: 0 auto 18px;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-tertiary);
border: 1px solid var(--border-secondary);
border-radius: 22px;
}
.bind-reminder-badge.is-wx {
color: var(--oauth-wx);
}
.bind-reminder-badge.is-qq {
color: var(--oauth-qq);
}
.bind-reminder-title {
margin: 0 0 10px;
font-size: 19px;
font-weight: var(--font-bold);
color: var(--text-primary);
line-height: 1.4;
}
.bind-reminder-desc {
margin: 0 0 24px;
font-size: 14px;
line-height: 1.7;
color: var(--text-secondary);
}
.bind-reminder-actions {
display: flex;
gap: 12px;
}
.bind-reminder-btn {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 13px 16px;
border-radius: var(--radius-xl);
border: 1px solid transparent;
font-size: 14px;
font-weight: var(--font-semibold);
white-space: nowrap;
cursor: pointer;
transition:
filter var(--transition-fast),
background var(--transition-fast),
border-color var(--transition-fast),
transform var(--transition-fast);
}
.bind-reminder-btn.primary {
background: var(--oauth-wx);
color: #06331f;
}
.is-qq .bind-reminder-btn.primary {
background: var(--oauth-qq);
color: #083344;
}
.bind-reminder-btn.primary:hover {
filter: brightness(1.05);
}
.bind-reminder-btn.ghost {
background: var(--bg-tertiary);
border-color: var(--border-secondary);
color: var(--text-primary);
}
.bind-reminder-btn.ghost:hover {
border-color: var(--border-tertiary);
background: var(--bg-quaternary);
}
.bind-reminder-btn:active {
transform: scale(0.98);
}
.bind-reminder-dismiss {
display: block;
margin: 18px auto 0;
padding: 6px 10px;
background: transparent;
border: none;
font-size: 13px;
color: var(--text-tertiary);
cursor: pointer;
transition: color var(--transition-fast);
}
.bind-reminder-dismiss:hover {
color: var(--text-primary);
}
.bind-reminder-enter-active,
.bind-reminder-leave-active {
transition:
opacity 0.25s ease,
transform 0.25s ease;
}
.bind-reminder-enter-from,
.bind-reminder-leave-to {
opacity: 0;
transform: translateY(12px) scale(0.97);
}
@media (max-width: 480px) {
.bind-reminder-modal {
padding: 30px 20px 20px;
}
.bind-reminder-title {
font-size: 18px;
}
.bind-reminder-actions {
gap: 10px;
}
.bind-reminder-btn {
padding: 12px 10px;
font-size: 13px;
}
}
</style>
+561
View File
@@ -0,0 +1,561 @@
<template>
<div class="space-y-6">
<div v-if="loading" class="flex flex-col items-center justify-center py-12">
<Loader2 :size="24" class="text-primary animate-spin mb-3" />
<p class="text-text-tertiary text-xs font-medium">{{ locale.loading }}</p>
</div>
<div v-else class="space-y-4">
<div
v-for="provider in enabledProviders"
:key="provider.key"
:class="[itemClass, 'items-start']"
>
<div class="flex items-start gap-4 flex-1 min-w-0">
<div
class="w-10 h-10 rounded-xl bg-bg-primary flex items-center justify-center border border-border-secondary text-text-primary"
>
<AuthProvidersGitHubIcon v-if="provider.key === 'github'" class="w-5 h-5" />
<AuthProvidersCasdoorIcon v-else-if="provider.key === 'casdoor'" class="w-5 h-5" />
<AuthProvidersGoogleIcon v-else-if="provider.key === 'google'" class="w-5 h-5" />
<Icon
v-else-if="provider.routeProvider === 'aggregate'"
:name="getAggregateOAuthLoginTypeIcon(provider.loginType)"
:size="23"
:class="aggregateIconClass(provider.loginType)"
/>
<Shield v-else :size="20" />
</div>
<div class="flex flex-col flex-1 min-w-0">
<span class="text-sm font-bold text-text-primary">{{
provider.name || getProviderDisplayName(provider.key)
}}</span>
<span
v-if="getIdentitiesByProvider(provider).length === 0"
class="text-[11px] text-text-tertiary mt-0.5"
>{{ locale.unbound }}</span
>
<div
v-if="getIdentitiesByProvider(provider).length > 0"
class="flex flex-col gap-2 mt-2"
>
<div
v-for="identity in getIdentitiesByProvider(provider)"
:key="identity.id"
class="flex items-center gap-3 p-2.5 bg-bg-primary-20 border border-border-secondary rounded-xl"
>
<div
class="w-8 h-8 rounded-full bg-bg-primary border border-border-secondary overflow-hidden flex items-center justify-center text-xs font-bold text-text-secondary shrink-0"
>
<img
v-if="identity.avatar && !failedAvatarIds.includes(identity.id)"
:src="identity.avatar"
:alt="identity.providerUsername || provider.name"
class="w-full h-full object-cover"
loading="lazy"
@error="markAvatarFailed(identity.id)"
>
<span v-else>{{
(identity.providerUsername || provider.name || '?').charAt(0)
}}</span>
</div>
<div class="flex flex-col flex-1 min-w-0">
<span class="text-xs font-medium text-text-secondary truncate">{{
identity.providerUsername
}}</span>
<span
v-if="identity.isAvatarSource"
class="text-[10px] font-bold text-success bg-success-10 border border-success-20 px-2 py-0.5 rounded-full w-fit mt-1"
>{{ locale.currentAvatar }}</span
>
</div>
<div class="flex items-center gap-1.5 shrink-0">
<button
v-if="!identity.isAvatarSource"
class="px-2.5 py-1.5 bg-primary-hover hover:bg-primary text-text-primary text-[11px] font-bold rounded-lg transition-all active:scale-95 disabled:opacity-50"
:disabled="avatarLoadingId === identity.id || actionLoading"
@click="setAsAvatar(identity)"
>
{{ avatarLoadingId === identity.id ? locale.processing : locale.setAsAvatar }}
</button>
<button
class="px-2.5 py-1.5 bg-error-10 border border-error-20 hover:bg-error-20 text-error text-[11px] font-bold rounded-lg transition-all disabled:opacity-50"
:disabled="actionLoading"
@click="confirmUnbind(provider, identity)"
>
{{ locale.unbind }}
</button>
</div>
</div>
</div>
</div>
</div>
<button
v-if="getIdentitiesByProvider(provider).length === 0"
class="px-4 py-1.5 bg-primary-hover hover:bg-primary text-text-primary text-xs font-black rounded-xl shadow-lg shadow-[var(--primary-glow)] transition-all active:scale-95 disabled:opacity-50"
:disabled="actionLoading"
@click="handleBind(provider)"
>
{{ actionLoading ? locale.redirecting : locale.bindNow }}
</button>
</div>
<!-- WebAuthn / Passkey -->
<div
v-if="isWebAuthnSupported || webauthnIdentities.length > 0 || !isSecureContext"
:class="[
itemClass,
webauthnIdentities.length > 0 ? 'cursor-pointer hover:bg-bg-secondary-70' : ''
]"
@click="toggleWebAuthnList"
>
<div class="flex items-center gap-4">
<div
class="w-10 h-10 rounded-xl bg-bg-primary flex items-center justify-center border border-border-secondary text-text-primary"
>
<Fingerprint :size="20" />
</div>
<div class="flex flex-col">
<div class="flex items-center gap-2">
<span class="text-sm font-bold text-text-primary">Passkey</span>
<ChevronDown
v-if="webauthnIdentities.length > 0"
:size="14"
class="text-text-tertiary transition-transform duration-300"
:class="{ 'rotate-180': isWebAuthnExpanded }"
/>
</div>
<span class="text-[11px] text-text-tertiary mt-0.5"
>{{ locale.boundDevices }} {{ webauthnIdentities.length }} {{ locale.devices }}</span
>
</div>
</div>
<button
v-if="isWebAuthnSupported"
class="px-4 py-1.5 bg-primary-hover hover:bg-primary text-text-primary text-xs font-black rounded-xl shadow-lg shadow-[var(--primary-glow)] transition-all active:scale-95 disabled:opacity-50"
:disabled="actionLoading"
@click.stop="handleWebAuthnRegister"
>
{{ actionLoading ? locale.processing : locale.addDevice }}
</button>
<div
v-else-if="!isSecureContext"
class="flex items-center gap-1 text-warning bg-warning-10 px-3 py-1.5 rounded-lg border border-warning-20"
>
<AlertTriangle :size="12" />
<span class="text-[10px] font-medium">{{ locale.httpsRequired }}</span>
</div>
</div>
<!-- WebAuthn 设备列表 -->
<Transition name="expand">
<div
v-if="isWebAuthnExpanded && webauthnIdentities.length > 0"
class="pl-16 -mt-2 overflow-hidden"
>
<div class="space-y-2 pt-2">
<div
v-for="cred in webauthnIdentities"
:key="cred.id"
class="flex items-center justify-between p-3 bg-bg-primary-20 border border-border-secondary rounded-xl group/item"
>
<div class="flex flex-col flex-1 mr-4">
<div v-if="editingId === cred.id" class="flex items-center gap-2 mb-1">
<input
v-model="editingName"
type="text"
class="bg-bg-secondary border border-border-tertiary rounded px-2 py-0.5 text-xs text-text-primary focus:outline-none focus:border-primary w-full"
:disabled="isRenaming"
@keyup.enter="saveEditing(cred.id)"
@keyup.esc="cancelEditing"
@click.stop
ref="editInput"
/>
</div>
<div v-else class="flex items-center gap-2 mb-0.5">
<span class="text-xs font-medium text-text-secondary">{{ cred.providerUsername }}</span>
<button
class="text-text-tertiary hover:text-text-secondary opacity-0 group-hover/item:opacity-100 transition-opacity p-0.5"
@click.stop="startEditing(cred)"
:title="locale.rename"
>
<Pencil :size="12" />
</button>
</div>
<span class="text-[10px] text-text-disabled"
>{{ locale.addedAt }} {{ new Date(cred.createdAt).toLocaleString(currentLocale.value, {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false
}) }}</span
>
</div>
<div class="flex items-center gap-1">
<template v-if="editingId === cred.id">
<button
class="text-text-tertiary hover:text-success transition-colors p-1"
:disabled="isRenaming"
@click.stop="saveEditing(cred.id)"
:title="locale.save"
>
<Check :size="14" />
</button>
<button
class="text-text-tertiary hover:text-text-primary transition-colors p-1"
:disabled="isRenaming"
@click.stop="cancelEditing"
:title="locale.cancel"
>
<X :size="14" />
</button>
</template>
<button
v-else
class="text-xs text-error hover:text-error font-medium px-2 py-1 opacity-0 group-hover/item:opacity-100 transition-opacity"
@click="confirmUnbindWebAuthn(cred)"
>
{{ locale.remove }}
</button>
</div>
</div>
</div>
</div>
</Transition>
</div>
<!-- 确认对话框 -->
<ConfirmDialog
v-model:show="showConfirmDialog"
:loading="confirmDialog.loading"
:message="confirmDialog.message"
:title="confirmDialog.title"
:type="confirmDialog.type"
@cancel="confirmDialog.onCancel"
@confirm="confirmDialog.onConfirm"
/>
</div>
</template>
<script setup>
import { ref, onMounted, computed, nextTick } from 'vue'
import {
Loader2,
Shield,
Fingerprint,
ChevronDown,
Pencil,
Check,
X,
AlertTriangle
} from '@lucide/vue'
import ConfirmDialog from '~/components/UI/ConfirmDialog.vue'
import Icon from '~/components/UI/Icon.vue'
import { useToast } from '~/composables/useToast'
import { getAggregateOAuthLoginTypeIcon, getProviderDisplayName } from '~/utils/oauth'
import { browserSupportsWebAuthn } from '@simplewebauthn/browser'
import {
getWebAuthnErrorMessage,
signalUnknownWebAuthnCredential,
startWebAuthnRegistration
} from '~/utils/webauthn'
import { useLocale } from '~/utils/locale'
import { useServerErrors } from '~/composables/useLocaleText'
import { useAuth } from '~/composables/useAuth'
const { oauthProviders, refreshSiteConfig } = useSiteConfig()
const { showToast } = useToast()
const { refreshUser } = useAuth()
const { localize: localizeServerError } = useServerErrors()
const { auth, currentLocale } = useLocale()
const locale = computed(() => auth.value?.oauthBindingCard || {})
const { t: callLocale } = useLocaleText(locale)
const identities = ref([])
const loading = ref(true)
const actionLoading = ref(false)
const avatarLoadingId = ref(null)
const failedAvatarIds = ref([])
const isWebAuthnSupported = ref(false)
const isSecureContext = ref(true)
// 编辑相关
const editingId = ref(null)
const editingName = ref('')
const isRenaming = ref(false)
const editInput = ref(null)
const startEditing = async (cred) => {
editingId.value = cred.id
editingName.value = cred.providerUsername
// 聚焦输入框
await nextTick()
if (editInput.value) {
editInput.value?.focus()
}
}
const cancelEditing = () => {
editingId.value = null
editingName.value = ''
}
const saveEditing = async (id) => {
if (!editingName.value.trim()) {
showToast(locale.value.nameRequired, 'error')
return
}
if (editingName.value.trim().length > 50) {
showToast(locale.value.nameTooLong, 'error')
return
}
isRenaming.value = true
try {
await $fetch('/api/auth/webauthn/rename', {
method: 'POST',
body: { id, name: editingName.value }
})
showToast(locale.value.renameSuccess, 'success')
await fetchIdentities()
cancelEditing()
} catch (e) {
showToast(localizeServerError(e, locale.value.renameFailed), 'error')
} finally {
isRenaming.value = false
}
}
const isWebAuthnExpanded = ref(false)
const toggleWebAuthnList = () => {
if (webauthnIdentities.value.length > 0) {
isWebAuthnExpanded.value = !isWebAuthnExpanded.value
}
}
// 确认对话框相关
const showConfirmDialog = ref(false)
const confirmDialog = ref({
title: '',
message: '',
type: 'warning',
loading: false,
onConfirm: () => {},
onCancel: () => {}
})
// 样式类
const itemClass =
'flex items-center justify-between p-4 bg-bg-primary-30 border border-border-secondary rounded-2xl hover:bg-bg-secondary-50 transition-all group'
const aggregateIconClass = (loginType) => {
const classes = {
qq: 'text-[var(--oauth-qq)]',
wx: 'text-[var(--oauth-wx)]',
alipay: 'text-[var(--oauth-alipay)]',
sina: 'text-[var(--oauth-sina)]',
baidu: 'text-[var(--oauth-baidu)]',
douyin: 'text-[var(--oauth-douyin)]',
huawei: 'text-[var(--oauth-huawei)]',
xiaomi: 'text-[var(--oauth-xiaomi)]',
gitee: 'text-[var(--oauth-gitee)]',
gitea: 'text-text-primary [--gitea-cutout:var(--panel-bg-dialog)]',
bilibili: 'text-[var(--oauth-bilibili)]',
kuaishou: 'text-[var(--oauth-kuaishou)]'
}
return classes[loginType] || 'text-text-primary'
}
const enabledProviders = computed(() => oauthProviders.value || [])
const getProviderName = (provider) => {
const matched = enabledProviders.value.find((item) => item.key === provider)
return matched?.name || getProviderDisplayName(provider)
}
const getIdentitiesByProvider = (provider) =>
identities.value.filter((item) => item.provider === provider.key)
const markAvatarFailed = (id) => {
if (!failedAvatarIds.value.includes(id)) {
failedAvatarIds.value.push(id)
}
}
const setAsAvatar = async (identity) => {
avatarLoadingId.value = identity.id
try {
await $fetch('/api/user/avatar', {
method: 'POST',
body: { identityId: identity.id }
})
showToast(locale.value.avatarSetSuccess, 'success')
await Promise.all([fetchIdentities(), refreshUser()])
} catch (e) {
showToast(localizeServerError(e, locale.value.avatarSetFailed), 'error')
} finally {
avatarLoadingId.value = null
}
}
const webauthnIdentities = computed(() => identities.value.filter((i) => i.provider === 'webauthn'))
const fetchIdentities = async () => {
try {
loading.value = true
identities.value = await $fetch('/api/auth/identities')
failedAvatarIds.value = []
} catch (e) {
console.error('获取绑定信息失败', e)
} finally {
loading.value = false
}
}
const handleBind = (provider) => {
actionLoading.value = true
// 绑定也是通过 OAuth 流程,最终回调时会自动识别已登录状态并执行绑定
const routeProvider = provider.routeProvider || provider.key
const query = new URLSearchParams()
if (provider.loginType) query.set('type', provider.loginType)
const queryString = query.toString()
navigateTo(`/api/auth/${routeProvider}${queryString ? `?${queryString}` : ''}`, {
external: true
})
}
const confirmUnbind = (provider, identity) => {
const providerName = getProviderName(provider.key)
confirmDialog.value = {
title: locale.value.unbindTitle,
message: callLocale('unbindMessage', '', providerName),
type: 'danger',
loading: false,
onConfirm: () => handleUnbind(provider.key, identity.id),
onCancel: () => {
showConfirmDialog.value = false
}
}
showConfirmDialog.value = true
}
const confirmUnbindWebAuthn = (cred) => {
confirmDialog.value = {
title: locale.value.removePasskeyTitle,
message: callLocale('removePasskeyMessage', '', cred.providerUsername),
type: 'danger',
loading: false,
onConfirm: () => handleUnbind('webauthn', cred.id),
onCancel: () => {
showConfirmDialog.value = false
}
}
showConfirmDialog.value = true
}
const handleUnbind = async (provider, id = null) => {
confirmDialog.value.loading = true
actionLoading.value = true
try {
const result = await $fetch('/api/auth/unbind', {
method: 'POST',
body: { provider, id }
})
const cleanupResults = await Promise.all(
(result.passkeyCleanup || []).map(signalUnknownWebAuthnCredential)
)
await fetchIdentities()
try {
await refreshUser()
} catch (e) {
// 头像来源变化后的用户刷新失败不阻塞解绑流程
}
const deviceCleanupSucceeded = cleanupResults.length > 0 && cleanupResults.every(Boolean)
if (provider === 'webauthn' && !deviceCleanupSucceeded) {
showToast(locale.value.passkeyCleanupRequired, 'warning', 6000)
} else {
showToast(locale.value.unbindSuccess, 'success')
}
showConfirmDialog.value = false
} catch (e) {
showToast(localizeServerError(e, locale.value.unbindFailed), 'error')
} finally {
actionLoading.value = false
confirmDialog.value.loading = false
}
}
const handleWebAuthnRegister = async () => {
if (!isWebAuthnSupported.value) {
showToast(locale.value.browserNotSupported, 'error')
return
}
actionLoading.value = true
try {
const options = await $fetch('/api/auth/webauthn/register/options')
const attResp = await startWebAuthnRegistration(options)
// 提示用户输入设备名称(可选,这里先用默认的)
// attResp.label = 'Windows Hello'
await $fetch('/api/auth/webauthn/register/verify', {
method: 'POST',
body: attResp
})
showToast(locale.value.addDeviceSuccess, 'success')
await fetchIdentities()
} catch (e) {
console.error('WebAuthn 注册错误:', e)
const message = getWebAuthnErrorMessage(e, locale.value, locale.value.addDeviceFailed)
showToast(message, 'error')
} finally {
actionLoading.value = false
}
}
onMounted(async () => {
await refreshSiteConfig()
fetchIdentities()
isSecureContext.value = window.isSecureContext
const isApiSupported = browserSupportsWebAuthn()
let isPlatformAuthenticatorAvailable = false
if (isApiSupported && window.PublicKeyCredential?.isUserVerifyingPlatformAuthenticatorAvailable) {
try {
isPlatformAuthenticatorAvailable =
await window.PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable()
} catch (e) {
console.warn('WebAuthn 平台认证器检查失败:', e)
}
}
// 兼容外部安全密钥(如 YubiKey),即使没有内置平台认证器也允许尝试
isWebAuthnSupported.value = isApiSupported
})
</script>
<style scoped>
.expand-enter-active,
.expand-leave-active {
transition: all 0.3s ease-in-out;
max-height: 500px;
opacity: 1;
}
.expand-enter-from,
.expand-leave-to {
max-height: 0;
opacity: 0;
}
</style>
+117
View File
@@ -0,0 +1,117 @@
<template>
<div v-if="hasEnabledProviders" class="mt-6 w-full">
<div class="flex items-center text-center text-[var(--text-tertiary)] text-xs mb-4">
<div class="flex-1 border-b border-[var(--border-color)] opacity-20" />
<span class="px-2.5">{{ locale.divider }}</span>
<div class="flex-1 border-b border-[var(--border-color)] opacity-20" />
</div>
<div class="flex flex-wrap justify-center gap-4">
<button
v-for="provider in enabledProviders"
:key="provider.key"
type="button"
:class="providerButtonClass(provider)"
:title="formatLocale(locale.loginWith, provider.name)"
@click="loginWith(provider)"
>
<AuthProvidersGitHubIcon v-if="provider.key === 'github'" />
<AuthProvidersCasdoorIcon v-else-if="provider.key === 'casdoor'" />
<AuthProvidersGoogleIcon v-else-if="provider.key === 'google'" />
<Icon
v-else-if="provider.routeProvider === 'aggregate'"
:name="getAggregateOAuthLoginTypeIcon(provider.loginType)"
:size="26"
/>
<Shield v-else :size="18" />
</button>
</div>
</div>
</template>
<script setup>
import { Shield } from '@lucide/vue'
import { useLocale } from '~/utils/locale'
import Icon from '~/components/UI/Icon.vue'
import { getAggregateOAuthLoginTypeIcon } from '~/utils/oauth'
const { oauthProviders, refreshSiteConfig } = useSiteConfig()
const route = useRoute()
const { auth } = useLocale()
const locale = computed(() => auth.value?.oauthButtons || {})
const formatLocale = (value, ...args) => {
if (typeof value === 'function') return value(...args)
if (typeof value === 'string') {
return value.replace(/{(\d+)}/g, (match, index) =>
args[Number(index)] !== undefined ? String(args[Number(index)]) : match
)
}
return 'OAuth 登录'
}
onMounted(async () => {
await refreshSiteConfig()
})
const enabledProviders = computed(() => oauthProviders.value || [])
const hasEnabledProviders = computed(() => {
return enabledProviders.value.length > 0
})
const providerButtonClass = (provider) => {
const key = provider.routeProvider || provider.key
const baseClass =
'w-12 h-12 rounded-full border border-[var(--border-color)] bg-[var(--bg-secondary)] text-[var(--text-primary)] flex items-center justify-center cursor-pointer transition-all duration-200 hover:-translate-y-0.5 hover:shadow-sm hover:bg-[var(--bg-tertiary)]'
if (key === 'github') {
return `${baseClass} hover:bg-[var(--panel-bg-subtle)] hover:text-text-primary hover:border-[var(--panel-bg-subtle)]`
}
if (key === 'casdoor') {
return `${baseClass} hover:border-[var(--oauth-casdoor-green)]`
}
if (key === 'google') {
return `${baseClass} hover:bg-bg-secondary hover:text-black hover:border-[var(--panel-border)]`
}
if (key === 'aggregate') {
const aggregateClasses = {
qq: 'hover:bg-[var(--oauth-qq)] hover:text-text-primary hover:border-[var(--oauth-qq)]',
wx: 'hover:bg-[var(--oauth-wx)] hover:text-text-primary hover:border-[var(--oauth-wx)]',
alipay: 'hover:bg-[var(--oauth-alipay)] hover:text-text-primary hover:border-[var(--oauth-alipay)]',
sina: 'hover:bg-[var(--oauth-sina)] hover:text-text-primary hover:border-[var(--oauth-sina)]',
baidu: 'hover:bg-[var(--oauth-baidu)] hover:text-text-primary hover:border-[var(--oauth-baidu)]',
douyin: 'hover:bg-[var(--panel-bg-darkest)] hover:text-text-primary hover:border-[var(--oauth-douyin)]',
huawei: 'hover:bg-[var(--oauth-huawei)] hover:text-text-primary hover:border-[var(--oauth-huawei)]',
xiaomi: 'hover:bg-[var(--oauth-xiaomi)] hover:text-text-primary hover:border-[var(--oauth-xiaomi)]',
gitee: 'hover:bg-[var(--oauth-gitee)] hover:text-text-primary hover:border-[var(--oauth-gitee)]',
gitea:
'[--gitea-cutout:var(--bg-secondary)] hover:[--gitea-cutout:var(--text-primary)] hover:bg-bg-secondary hover:text-black hover:border-primary',
bilibili: 'hover:bg-[var(--oauth-bilibili)] hover:text-text-primary hover:border-[var(--oauth-bilibili)]',
kuaishou: 'hover:bg-[var(--oauth-kuaishou)] hover:text-text-primary hover:border-[var(--oauth-kuaishou)]'
}
return `${baseClass} ${aggregateClasses[provider.loginType] || ''}`
}
if (key === 'oauth2') {
return `${baseClass} hover:bg-[var(--status-success-icon)] hover:text-text-primary hover:border-[var(--status-success-icon)]`
}
return `${baseClass} hover:border-primary-40 hover:text-primary`
}
const loginWith = (provider) => {
const queryRedirect = route.query.redirect
const redirect = Array.isArray(queryRedirect) ? queryRedirect[0] : queryRedirect
const safeRedirect =
redirect?.startsWith('/') && !redirect.startsWith('//') && !redirect.startsWith('/\\')
? redirect
: null
const query = new URLSearchParams()
if (provider.loginType) query.set('type', provider.loginType)
if (safeRedirect) query.set('redirect', safeRedirect)
const queryString = query.toString()
const routeProvider = provider.routeProvider || provider.key
// 外部导航到 API 端点
navigateTo(`/api/auth/${routeProvider}${queryString ? `?${queryString}` : ''}`, {
external: true
})
}
</script>
+159
View File
@@ -0,0 +1,159 @@
<template>
<div v-if="hasQuickLogin" class="quick-login-section">
<div class="divider">
<span>{{ orText }}</span>
</div>
<div class="quick-login-buttons">
<button
v-if="showWeChat"
type="button"
class="quick-login-btn wechat"
@click="loginWith('wx')"
>
<Icon name="oauth-wechat" :size="20" />
<span>{{ locale.quickLoginWx }}</span>
</button>
<button
v-if="showQQ"
type="button"
class="quick-login-btn qq"
@click="loginWith('qq')"
>
<Icon name="oauth-qq" :size="20" />
<span>{{ locale.quickLoginQq }}</span>
</button>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import Icon from '~/components/UI/Icon.vue'
import { useLocale } from '~/utils/locale'
import { detectEmbeddedBrowser } from '~/utils/embedded-browser'
import { useSiteConfig } from '~/composables/useSiteConfig'
const { oauthProviders, refreshSiteConfig } = useSiteConfig()
const route = useRoute()
const { auth } = useLocale()
const locale = computed(() => auth.value?.oauthButtons || {})
// "或" 分隔文案取自 loginForm 分区
const orText = computed(() => auth.value?.loginForm?.or || '或')
// 初始为 false,SSR/首屏不渲染,onMounted 检测到微信/QQ 内置浏览器后才显示
const env = ref({ isWeChat: false, isQQApp: false })
onMounted(async () => {
env.value = detectEmbeddedBrowser()
await refreshSiteConfig()
})
const aggregateLoginTypes = computed(() => {
const types = new Set()
for (const provider of oauthProviders.value || []) {
if (provider.routeProvider === 'aggregate' && provider.loginType) {
types.add(provider.loginType)
}
}
return types
})
const showWeChat = computed(() => env.value.isWeChat && aggregateLoginTypes.value.has('wx'))
const showQQ = computed(() => env.value.isQQApp && aggregateLoginTypes.value.has('qq'))
const hasQuickLogin = computed(() => showWeChat.value || showQQ.value)
const loginWith = (loginType) => {
const queryRedirect = route.query.redirect
const redirect = Array.isArray(queryRedirect) ? queryRedirect[0] : queryRedirect
const safeRedirect =
redirect?.startsWith('/') && !redirect.startsWith('//') && !redirect.startsWith('/\\')
? redirect
: null
const query = new URLSearchParams()
query.set('type', loginType)
if (safeRedirect) query.set('redirect', safeRedirect)
navigateTo(`/api/auth/aggregate?${query.toString()}`, { external: true })
}
</script>
<style scoped>
.quick-login-section {
width: 100%;
}
.divider {
display: flex;
align-items: center;
text-align: center;
margin: 20px 0;
color: var(--text-quaternary);
font-size: 12px;
}
.divider::before,
.divider::after {
content: '';
flex: 1;
border-bottom: 1px solid var(--input-border);
}
.divider span {
padding: 0 10px;
}
.quick-login-buttons {
display: flex;
gap: 12px;
}
.quick-login-btn {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
min-width: 0;
padding: 14px;
background: var(--panel-bg-secondary);
color: var(--text-primary);
border: 1px solid var(--input-border);
border-radius: var(--radius-lg);
font-size: 15px;
font-weight: var(--font-medium);
cursor: pointer;
transition: all 0.2s ease;
}
.quick-login-btn .icon {
color: var(--text-primary);
transition: color 0.2s ease;
}
.quick-login-btn.wechat:hover {
background: var(--oauth-wx);
border-color: var(--oauth-wx);
color: white;
}
.quick-login-btn.qq:hover {
background: var(--oauth-qq);
border-color: var(--oauth-qq);
color: white;
}
.quick-login-btn.wechat:hover .icon,
.quick-login-btn.qq:hover .icon {
color: white;
}
.quick-login-btn:active {
transform: scale(0.98);
}
@media (max-width: 480px) {
.quick-login-btn {
padding: 12px;
font-size: 13px;
}
}
</style>
@@ -0,0 +1,3 @@
<template>
<img src="https://casdoor.org/img/casdoor.png" alt="Casdoor" class="h-5 w-auto object-contain" >
</template>
@@ -0,0 +1,13 @@
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
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>
</template>
@@ -0,0 +1,22 @@
<template>
<svg viewBox="0 0 24 24" width="1.2em" height="1.2em" xmlns="http://www.w3.org/2000/svg">
<g transform="matrix(1, 0, 0, 1, 27.009001, -39.238998)">
<path
fill="var(--oauth-google-blue)"
d="M -3.264 51.509 C -3.264 50.719 -3.334 49.969 -3.454 49.239 L -14.754 49.239 L -14.754 53.749 L -8.284 53.749 C -8.574 55.229 -9.424 56.479 -10.684 57.329 L -10.684 60.329 L -6.824 60.329 C -4.564 58.239 -3.264 55.159 -3.264 51.509 Z"
/>
<path
fill="var(--oauth-google-green)"
d="M -14.754 63.239 C -11.514 63.239 -8.804 62.159 -6.824 60.329 L -10.684 57.329 C -11.764 58.049 -13.134 58.489 -14.754 58.489 C -17.884 58.489 -20.534 56.379 -21.484 53.529 L -25.464 53.529 L -25.464 56.619 C -23.494 60.539 -19.444 63.239 -14.754 63.239 Z"
/>
<path
fill="var(--oauth-google-yellow)"
d="M -21.484 53.529 C -21.734 52.809 -21.864 52.039 -21.864 51.239 C -21.864 50.439 -21.724 49.669 -21.484 48.949 L -21.484 45.859 L -25.464 45.859 C -26.284 47.479 -26.754 49.299 -26.754 51.239 C -26.754 53.179 -26.284 54.999 -25.464 56.619 L -21.484 53.529 Z"
/>
<path
fill="var(--oauth-google-red)"
d="M -14.754 43.989 C -12.984 43.989 -11.404 44.599 -10.154 45.789 L -6.734 42.369 C -8.804 40.429 -11.514 39.239 -14.754 39.239 C -19.444 39.239 -23.494 41.939 -25.464 45.859 L -21.484 48.949 C -20.534 46.099 -17.884 43.989 -14.754 43.989 Z"
/>
</g>
</svg>
</template>
+109
View File
@@ -0,0 +1,109 @@
<template>
<div class="turnstile-widget">
<div ref="containerRef"></div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch } from 'vue'
import { useSiteConfig } from '~/composables/useSiteConfig'
declare global {
interface Window {
turnstile: any
}
}
const props = defineProps<{
modelValue: string
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: string): void
}>()
const { siteConfig } = useSiteConfig()
const containerRef = ref<HTMLElement | null>(null)
let widgetId: string | null = null
let retryCount = 0
const MAX_RETRIES = 50 // 最大重试 50 次,每次 100ms,共 5 秒
let retryTimer: ReturnType<typeof setTimeout> | null = null
watch(() => siteConfig.value.turnstileSiteKey, (newKey) => {
if (newKey && !widgetId) {
renderWidget()
}
}, { immediate: true })
function renderWidget() {
if (!containerRef.value || !siteConfig.value.turnstileSiteKey || widgetId !== null) return
// 确保全局存在 turnstile 对象
if (window.turnstile) {
widgetId = window.turnstile.render(containerRef.value, {
sitekey: siteConfig.value.turnstileSiteKey,
callback: (token: string) => {
emit('update:modelValue', token)
},
'error-callback': () => {
emit('update:modelValue', '')
},
'expired-callback': () => {
emit('update:modelValue', '')
}
})
} else if (retryCount < MAX_RETRIES) {
// 如果还没加载好,且未超过最大重试次数,稍微等一下
retryCount++
retryTimer = setTimeout(renderWidget, 100)
} else {
console.error('Failed to load Turnstile script: timeout exceeded.')
}
}
const reset = () => {
if (widgetId !== null && window.turnstile) {
window.turnstile.reset(widgetId)
emit('update:modelValue', '')
}
}
defineExpose({
reset
})
onMounted(() => {
// 如果之前没有加载过 Turnstile 脚本,则动态加载
if (!document.getElementById('turnstile-script')) {
const script = document.createElement('script')
script.id = 'turnstile-script'
script.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit'
script.async = true
script.defer = true
document.head.appendChild(script)
// 监听加载成功
script.onload = () => {
renderWidget()
}
} else {
renderWidget()
}
})
onUnmounted(() => {
if (retryTimer) clearTimeout(retryTimer)
if (widgetId !== null && window.turnstile) {
window.turnstile.remove(widgetId)
}
})
</script>
<style scoped>
.turnstile-widget {
display: flex;
justify-content: center;
margin-top: 8px;
margin-bottom: 8px;
}
</style>
+240
View File
@@ -0,0 +1,240 @@
<template>
<div class="space-y-6">
<!-- 状态显示 -->
<div class="flex items-center justify-between p-4 bg-bg-primary-50 rounded-xl border border-border-secondary">
<div class="flex items-center gap-3">
<div
class="p-2 rounded-lg flex items-center justify-center"
:class="isEnabled ? 'bg-success-10 text-success' : 'bg-bg-tertiary text-text-tertiary'"
>
<ShieldCheck :size="20" />
</div>
<div>
<h3 class="font-bold text-text-primary">{{ locale.title }}</h3>
<p class="text-xs text-text-tertiary">
{{ isEnabled ? locale.enabledDesc : locale.disabledDesc }}
</p>
</div>
</div>
<button
v-if="!isEnabled"
@click="startSetup"
class="px-4 py-2 bg-primary-hover hover:bg-primary text-text-primary text-sm font-bold rounded-lg transition-colors"
>
{{ locale.enable }}
</button>
<button
v-else
@click="confirmDisable"
class="px-4 py-2 bg-error-10 hover:bg-error-20 text-error border border-error-20 text-sm font-bold rounded-lg transition-colors"
>
{{ locale.disable }}
</button>
</div>
<!-- 开启流程 -->
<div v-if="showSetup" class="bg-bg-primary-30 rounded-xl border border-border-secondary p-6 space-y-6 animate-in fade-in slide-in-from-top-4">
<div class="flex items-start justify-between">
<div>
<h4 class="font-bold text-text-primary mb-1">{{ locale.setupTitle }}</h4>
<p class="text-sm text-text-tertiary">{{ locale.setupDesc }}</p>
</div>
<button @click="cancelSetup" class="text-text-tertiary hover:text-text-secondary">
<X :size="20" />
</button>
</div>
<div class="flex flex-col md:flex-row gap-8 items-center md:items-start">
<!-- 二维码区域 -->
<div class="flex-shrink-0 bg-bg-secondary p-2 rounded-lg">
<img v-if="qrCodeUrl" :src="qrCodeUrl" alt="2FA QR Code" class="w-48 h-48" />
<div v-else class="w-48 h-48 flex items-center justify-center bg-bg-secondary text-text-tertiary">
<Loader2 class="animate-spin" />
</div>
</div>
<!-- 验证输入区域 -->
<div class="flex-1 space-y-4 w-full">
<div>
<label class="block text-xs font-bold text-text-tertiary uppercase tracking-wider mb-2">
{{ locale.manualKey }}
</label>
<div class="flex items-center gap-2">
<code class="px-3 py-2 bg-bg-secondary rounded-lg text-text-secondary font-mono text-sm border border-border-secondary select-all">
{{ secret }}
</code>
<button
@click="copySecret"
class="p-2 hover:bg-bg-tertiary rounded-lg text-text-tertiary transition-colors"
:title="locale.copyKey"
>
<Copy :size="16" />
</button>
</div>
</div>
<div>
<label class="block text-xs font-bold text-text-tertiary uppercase tracking-wider mb-2">
{{ locale.code }}
</label>
<div class="flex gap-2">
<input
v-model="verificationCode"
type="text"
:placeholder="locale.codePlaceholder"
maxlength="6"
class="flex-1 bg-bg-secondary border border-border-secondary rounded-lg px-4 py-2 text-text-primary placeholder-text-disabled focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all font-mono text-center tracking-widest"
@keyup.enter="enable2FA"
/>
<button
@click="enable2FA"
:disabled="loading || verificationCode.length !== 6"
class="px-6 py-2 bg-primary-hover hover:bg-primary disabled:opacity-50 disabled:cursor-not-allowed text-text-primary font-bold rounded-lg transition-colors flex items-center gap-2"
>
<Loader2 v-if="loading" class="animate-spin" :size="16" />
<span>{{ locale.verifyAndEnable }}</span>
</button>
</div>
</div>
</div>
</div>
</div>
<UIConfirmDialog
:show="showDisableConfirm"
:title="locale.disableTitle"
:message="locale.disableContent"
:confirm-text="locale.verifyAndDisable"
type="danger"
:show-input="true"
:input-placeholder="locale.passwordPlaceholder"
input-type="password"
@confirm="disable2FA"
@cancel="showDisableConfirm = false"
/>
</div>
</template>
<script setup>
import { computed, ref } from 'vue'
import { ShieldCheck, X, Copy, Loader2 } from '@lucide/vue'
import { useToast } from '~/composables/useToast'
import { useLocale } from '~/utils/locale'
const props = defineProps({
initialEnabled: {
type: Boolean,
default: false
}
})
const isEnabled = ref(props.initialEnabled || false)
const showSetup = ref(false)
const showDisableConfirm = ref(false)
const loading = ref(false)
const qrCodeUrl = ref('')
const secret = ref('')
const verificationCode = ref('')
const { showToast } = useToast()
const { auth } = useLocale()
const locale = computed(() => auth.value?.twoFactorSetup || {})
// 获取当前状态(可选,如果父组件没传可以通过API获取,这里暂时假设父组件会传或者初始化为false,
// 实际生产中最好有一个 API check status,但为了简化,这里先依赖父组件或默认)
// 修正:我们应该在组件挂载时检查状态,但目前没有专门的 check status API,
// 通常这包含在 user profile 信息中。如果 user 对象里有 has2FA 字段最好。
// 假设父组件通过 prop 传入,或者我们添加一个 check API。
// 为了稳健,我们可以复用 generate 接口之前的逻辑,或者在 login 时返回的信息里带。
// 这里的实现先依赖 props,如果需要更精确,可以加一个 api。
const startSetup = async () => {
try {
loading.value = true
const data = await $fetch('/api/user/2fa/generate', {
method: 'POST'
})
if (data) {
qrCodeUrl.value = data.qrCode
secret.value = data.secret
showSetup.value = true
verificationCode.value = ''
}
} catch (err) {
showToast(err.data?.message || err.message || locale.value.fetchFailed, 'error')
} finally {
loading.value = false
}
}
const cancelSetup = () => {
showSetup.value = false
qrCodeUrl.value = ''
secret.value = ''
verificationCode.value = ''
}
const copySecret = async () => {
try {
await navigator.clipboard.writeText(secret.value)
showToast(locale.value.copied, 'success')
} catch (err) {
showToast(locale.value.copyFailed, 'error')
}
}
const enable2FA = async () => {
if (verificationCode.value.length !== 6) return
try {
loading.value = true
const { error } = await useFetch('/api/user/2fa/enable', {
method: 'POST',
body: {
token: verificationCode.value,
secret: secret.value
}
})
if (error.value) throw error.value
showToast(locale.value.enabled, 'success')
isEnabled.value = true
cancelSetup()
} catch (err) {
showToast(err.data?.message || err.message || locale.value.verifyFailed, 'error')
} finally {
loading.value = false
}
}
const confirmDisable = () => {
showDisableConfirm.value = true
}
const disable2FA = async (password) => {
if (!password) {
showToast(locale.value.passwordRequired, 'warning')
return
}
try {
loading.value = true
const { error } = await useFetch('/api/user/2fa/disable', {
method: 'POST',
body: { password }
})
if (error.value) throw error.value
showToast(locale.value.disabled, 'success')
isEnabled.value = false
showDisableConfirm.value = false
} catch (err) {
showToast(err.data?.message || err.message || locale.value.disableFailed, 'error')
} finally {
loading.value = false
}
}
</script>
+243
View File
@@ -0,0 +1,243 @@
<template>
<Teleport to="body">
<Transition
enter-active-class="transition duration-300 ease-out"
enter-from-class="opacity-0 scale-95"
leave-to-class="opacity-0 scale-95"
>
<div
v-if="show"
class="fixed inset-0 z-[2000] bg-bg-primary-80 backdrop-blur-sm flex items-center justify-center p-4"
@click="$emit('cancel')"
>
<div
class="w-full max-w-md bg-bg-primary border border-border-secondary rounded-2xl p-6 shadow-2xl space-y-6"
@click.stop
>
<div class="text-center space-y-2">
<h3 class="text-xl font-bold text-text-primary">{{ locale.title }}</h3>
<p class="text-sm text-text-tertiary">
{{ method === 'totp' ? locale.totpDesc : locale.emailDesc }}
</p>
</div>
<div class="space-y-4">
<!-- 邮箱输入 (仅 Email 模式) -->
<div v-if="method === 'email'" class="space-y-2">
<label class="block text-sm text-text-tertiary">
{{ locale.emailLabel }}
<span v-if="maskedEmail" class="block text-xs text-text-tertiary mt-1">
{{ locale.maskedEmailTip }} {{ maskedEmail }}
</span>
</label>
<div class="relative">
<input
v-model="emailInput"
type="email"
:placeholder="locale.emailPlaceholder"
class="w-full bg-bg-secondary border border-border-secondary rounded-lg px-4 py-3 text-text-primary placeholder-text-disabled focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all"
:disabled="emailSent && cooldown > 0"
@keyup.enter="!emailSent && sendEmailCode()"
/>
</div>
</div>
<!-- 验证码输入 -->
<div v-if="method === 'totp' || emailSent">
<div class="relative">
<input
v-model="code"
type="text"
maxlength="6"
placeholder="000000"
class="w-full bg-bg-secondary border border-border-secondary rounded-lg px-4 py-3 text-center text-2xl font-mono tracking-widest text-text-primary placeholder-text-disabled focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all"
@keyup.enter="handleVerify"
ref="inputRef"
/>
</div>
</div>
<!-- 发送邮箱验证码按钮 -->
<div v-if="method === 'email'" class="text-center">
<button
type="button"
@click="sendEmailCode"
:disabled="cooldown > 0 || sending || !emailInput"
class="text-sm text-primary hover:text-primary disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{{ resendButtonText }}
</button>
</div>
<div v-if="error" class="p-3 bg-error-10 border border-error-20 rounded-lg flex items-center gap-2 text-error text-sm">
<AlertCircle :size="16" />
<span>{{ error }}</span>
</div>
<button
@click="handleVerify"
:disabled="loading || code.length !== 6"
class="w-full py-3 bg-primary-hover hover:bg-primary disabled:opacity-50 disabled:cursor-not-allowed text-text-primary font-bold rounded-lg transition-colors flex items-center justify-center gap-2"
>
<Loader2 v-if="loading" class="animate-spin" :size="18" />
<span>{{ locale.verifyLogin }}</span>
</button>
<!-- 切换验证方式按钮 -->
<button
v-if="hasMultipleMethods"
@click="toggleMethod"
class="w-full py-2 text-primary hover:text-primary text-sm transition-colors"
>
{{ method === 'totp' ? locale.switchToEmail : locale.switchToTotp }}
</button>
<button
@click="$emit('cancel')"
class="w-full py-2 text-text-tertiary hover:text-text-secondary text-sm transition-colors"
>
{{ locale.backLogin }}
</button>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import { ref, watch, nextTick, computed, onUnmounted } from 'vue'
import { Loader2, AlertCircle } from '@lucide/vue'
import { useToast } from '~/composables/useToast'
import { useLocale } from '~/utils/locale'
const props = defineProps<{
show: boolean
userId: number
availableMethods?: string[]
maskedEmail?: string
tempToken?: string
}>()
const emit = defineEmits<{
(e: 'success', data: any): void
(e: 'cancel'): void
}>()
const { showToast } = useToast()
const { auth } = useLocale()
const locale = computed(() => auth.value?.twoFactorVerify || {})
const { format: formatLocaleValue } = useLocaleText(locale)
const resendButtonText = computed(() => {
if (cooldown.value > 0) {
return formatLocaleValue(locale.value.resendCountdown, '', cooldown.value)
}
return emailSent.value ? locale.value.resendCode : locale.value.sendCode
})
// 默认优先使用 TOTP,如果没有则使用 Email
const defaultMethod = computed(() => props.availableMethods?.includes('totp') ? 'totp' : 'email')
const method = ref(defaultMethod.value)
const methods = computed(() => props.availableMethods || ['totp', 'email'])
const hasMultipleMethods = computed(() => methods.value.length > 1)
const code = ref('')
const emailInput = ref('')
const emailSent = ref(false)
const loading = ref(false)
const sending = ref(false)
const cooldown = ref(0)
const error = ref('')
const inputRef = ref<HTMLInputElement | null>(null)
let timer: NodeJS.Timeout | undefined
onUnmounted(() => {
if (timer) {
clearInterval(timer)
timer = undefined
}
})
const toggleMethod = () => {
method.value = method.value === 'totp' ? 'email' : 'totp'
// 切换回 totp 时清空邮箱输入
if (method.value === 'totp') {
emailInput.value = ''
emailSent.value = false
}
}
const sendEmailCode = async () => {
try {
if (!emailInput.value) {
error.value = locale.value.emailRequired || '请输入邮箱'
return
}
sending.value = true
error.value = ''
await $fetch('/api/auth/2fa/send-email', {
method: 'POST',
body: {
userId: props.userId,
token: props.tempToken,
email: emailInput.value
}
})
showToast(locale.value.codeSent, 'success')
emailSent.value = true
cooldown.value = 60
timer = setInterval(() => {
cooldown.value--
if (cooldown.value <= 0) {
clearInterval(timer)
timer = undefined
}
}, 1000)
// 发送成功后自动聚焦验证码输入框
nextTick(() => inputRef.value?.focus())
} catch (err: any) {
error.value = err.data?.message || err.message || locale.value.sendFailed || '发送失败'
} finally {
sending.value = false
}
}
const handleVerify = async () => {
if (code.value.length !== 6) return
try {
loading.value = true
error.value = ''
const { verify2FA } = useAuth()
const response = await verify2FA(props.userId, code.value, method.value as 'totp' | 'email', props.tempToken)
emit('success', response)
} catch (err: any) {
error.value = err.data?.message || err.message || locale.value.verifyFailed || '验证失败'
code.value = '' // 出错清空
} finally {
loading.value = false
}
}
// 切换方式时清空输入
watch(method, () => {
code.value = ''
error.value = ''
nextTick(() => inputRef.value?.focus())
})
// 显示时聚焦输入框
watch(() => props.show, (newVal) => {
if (newVal) {
// 每次重新打开时重置为默认方式(优先 TOTP)
method.value = defaultMethod.value
nextTick(() => inputRef.value?.focus())
}
})
</script>