feat: align with upstream 1.5.9.9
This commit is contained in:
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user