⚠️ 应用类型约束:当前实现仅支持「钉钉登录-企业内部应用」(DingTalk 开放平台
internal_app 类型)。第三方个人应用、第三方企业应用类型暂不支持——OAuth 流程
相同但 corp 校验、跨企业行为不同。backend 通过 DingTalkAppKind 校验对非
internal_app 类型 fail-closed(硬约束)。
钉钉 OAuth 登录主链
- 4 步 OAuth 链:ExchangeCodeForUserToken / GetUnionIdByUserToken /
GetUserIdByUnionId / GetStaffInfoByUserId;app token 缓存
- pending session 机制持久化 OAuth 中间态;cookie-only token 持久化
- 三种分流:bind_login_required / email_completion / choose_account_action
- corp_restriction_policy 支持 none + internal_only;stale "whitelist" 在
加载层与写入层均静默 coerce 为 none + slog.Warn
- bypass_registration 开关:企业内部模式豁免全局 REGISTRATION_DISABLED
- isReservedEmail / signup_source / canUnbindProvider / OAuth pending flow
等横切点支持 dingtalk provider
- migration 136:4 表 CHECK 约束加入 'dingtalk' provider 值
internal_only 模式同步企业邮箱/姓名/部门到用户属性
- SyncCorpEmail / SyncDisplayName / SyncDept 三个独立开关 + 对应
SyncXxxAttrKey 目标属性 key(默认 dingtalk_email / dingtalk_name /
dingtalk_department);非 internal_only policy 在写入层与加载层均
coerce 为 false,admin handler 与 setting_service 双层兜底
- 同步语义:首次注册写 users.username(昵称优先 → 企业姓名 fallback),
之后每次登录刷新 3 个属性;空值也写入以覆盖旧值
- 邮箱三级 fallback:org_email > email > extension["企业邮箱"]
(钉钉自定义字段 JSON)
- 部门路径递归向上拼接,跳过 dept_id=1 选首个真实子部门,剥离根组织名
- GetUnionIdByUserToken 同时返回 OIDC /contact/users/me 的 nick 字段;
新增 GetDeptInfo 调用 OAPI /topapi/v2/department/get
- AuthHandler 注入 UserAttributeService;OAuth pending flow 在
createPendingOAuthAccount / bindPendingOAuthLogin 分别派发到
AfterRegistration(syncUsername=true)/ AfterLogin
- migration 137 seed dingtalk_email/name/department 三个用户属性定义
附带修复(同集成路径暴露的两个 OAuth 注册回归)
- LoginOrRegisterOAuthWithTokenPair 新建用户分支用 inferLegacySignupSource
覆写 caller 显式传入的 signupSource,导致 dingtalk/linuxdo/oidc/wechat
渠道授权按 email 渠道读取;改为只在 caller 未显式传入时回退邮箱推断
- mergeProviderDefaultGrantSettings 把 parse fallback 默认值
(Concurrency=5 / Balance=0) 当作"未配置"哨兵,admin 显式设 5 时被误判
退回全局默认(复现:全局默认 1 + 渠道默认并发 5 + grant_on_signup → 新
用户实际 concurrency=1);去掉哨兵,admin 任何 >=0 值都覆盖 globalDefaults
前端
- DingTalk Login / Callback / EmailCompletion / ChoiceAccount / Error
视图;router + auth API client
- admin SettingsView:corp policy radio(none / internal_only)+ bypass
注册开关 + i18n;internal_only 下展示三同步开关 + 目标 attr key 下拉
(拉取 user attribute definitions),展示 fieldEmail /
qyapi_get_department_list 钉钉权限申请提示
- Profile:S1 主动绑定 / S5 解绑钉钉按钮 + 合成邮箱防自锁
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
567 lines
18 KiB
Vue
567 lines
18 KiB
Vue
<template>
|
|
<AuthLayout>
|
|
<div class="space-y-6">
|
|
<!-- Title -->
|
|
<div class="text-center">
|
|
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">
|
|
{{ t('auth.welcomeBack') }}
|
|
</h2>
|
|
<p class="mt-2 text-sm text-gray-500 dark:text-dark-400">
|
|
{{ t('auth.signInToAccount') }}
|
|
</p>
|
|
</div>
|
|
<!-- Login Form -->
|
|
<form @submit.prevent="handleLogin" class="space-y-5">
|
|
<!-- Email Input -->
|
|
<div>
|
|
<label for="email" class="input-label">
|
|
{{ t('auth.emailLabel') }}
|
|
</label>
|
|
<div class="relative">
|
|
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3.5">
|
|
<Icon name="mail" size="md" class="text-gray-400 dark:text-dark-500" />
|
|
</div>
|
|
<input
|
|
id="email"
|
|
v-model="formData.email"
|
|
type="email"
|
|
required
|
|
autofocus
|
|
autocomplete="email"
|
|
:disabled="authActionDisabled"
|
|
class="input pl-11"
|
|
:class="{ 'input-error': errors.email }"
|
|
:placeholder="t('auth.emailPlaceholder')"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Password Input -->
|
|
<div>
|
|
<label for="password" class="input-label">
|
|
{{ t('auth.passwordLabel') }}
|
|
</label>
|
|
<div class="relative">
|
|
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3.5">
|
|
<Icon name="lock" size="md" class="text-gray-400 dark:text-dark-500" />
|
|
</div>
|
|
<input
|
|
id="password"
|
|
v-model="formData.password"
|
|
:type="showPassword ? 'text' : 'password'"
|
|
required
|
|
autocomplete="current-password"
|
|
:disabled="authActionDisabled"
|
|
class="input pl-11 pr-11"
|
|
:class="{ 'input-error': errors.password }"
|
|
:placeholder="t('auth.passwordPlaceholder')"
|
|
/>
|
|
<button
|
|
type="button"
|
|
@click="showPassword = !showPassword"
|
|
:disabled="authActionDisabled"
|
|
class="absolute inset-y-0 right-0 flex items-center pr-3.5 text-gray-400 transition-colors hover:text-gray-600 dark:hover:text-dark-300"
|
|
>
|
|
<Icon v-if="showPassword" name="eyeOff" size="md" />
|
|
<Icon v-else name="eye" size="md" />
|
|
</button>
|
|
</div>
|
|
<div class="mt-1 flex items-center justify-between">
|
|
<span></span>
|
|
<router-link
|
|
v-if="passwordResetEnabled && !backendModeEnabled"
|
|
to="/forgot-password"
|
|
class="text-sm font-medium text-primary-600 transition-colors hover:text-primary-500 dark:text-primary-400 dark:hover:text-primary-300"
|
|
>
|
|
{{ t('auth.forgotPassword') }}
|
|
</router-link>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Turnstile Widget -->
|
|
<div v-if="turnstileEnabled && turnstileSiteKey">
|
|
<TurnstileWidget
|
|
ref="turnstileRef"
|
|
:site-key="turnstileSiteKey"
|
|
@verify="onTurnstileVerify"
|
|
@expire="onTurnstileExpire"
|
|
@error="onTurnstileError"
|
|
/>
|
|
</div>
|
|
|
|
<!-- Submit Button -->
|
|
<button
|
|
type="submit"
|
|
:disabled="authActionDisabled || (turnstileEnabled && !turnstileToken)"
|
|
class="btn btn-primary w-full"
|
|
>
|
|
<svg
|
|
v-if="isLoading"
|
|
class="-ml-1 mr-2 h-4 w-4 animate-spin text-white"
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<circle
|
|
class="opacity-25"
|
|
cx="12"
|
|
cy="12"
|
|
r="10"
|
|
stroke="currentColor"
|
|
stroke-width="4"
|
|
></circle>
|
|
<path
|
|
class="opacity-75"
|
|
fill="currentColor"
|
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
|
></path>
|
|
</svg>
|
|
<Icon v-else name="login" size="md" class="mr-2" />
|
|
{{ isLoading ? t('auth.signingIn') : t('auth.signIn') }}
|
|
</button>
|
|
|
|
<LoginAgreementPrompt
|
|
v-if="loginAgreementEnabled"
|
|
:accepted="agreementAccepted"
|
|
:documents="loginAgreementDocuments"
|
|
:mode="loginAgreementMode"
|
|
:updated-at="loginAgreementUpdatedAt"
|
|
:visible="showAgreementModal"
|
|
@accept="acceptLoginAgreement"
|
|
@reject="rejectLoginAgreement"
|
|
@open="showAgreementModal = true"
|
|
/>
|
|
|
|
<div v-if="showOAuthLogin" class="space-y-3 pt-1">
|
|
<div class="flex items-center gap-3">
|
|
<div class="h-px flex-1 bg-gray-200 dark:bg-dark-700"></div>
|
|
<span class="text-xs text-gray-500 dark:text-dark-400">
|
|
{{ t('auth.oauthOrContinue') }}
|
|
</span>
|
|
<div class="h-px flex-1 bg-gray-200 dark:bg-dark-700"></div>
|
|
</div>
|
|
|
|
<EmailOAuthButtons
|
|
:disabled="authActionDisabled"
|
|
:github-enabled="githubOAuthEnabled"
|
|
:google-enabled="googleOAuthEnabled"
|
|
:show-divider="false"
|
|
/>
|
|
|
|
<LinuxDoOAuthSection
|
|
v-if="linuxdoOAuthEnabled"
|
|
:disabled="authActionDisabled"
|
|
:show-divider="false"
|
|
/>
|
|
<DingTalkOAuthSection
|
|
v-if="dingtalkOAuthEnabled"
|
|
:disabled="authActionDisabled"
|
|
:show-divider="false"
|
|
/>
|
|
<WechatOAuthSection
|
|
v-if="wechatOAuthEnabled"
|
|
:disabled="authActionDisabled"
|
|
:show-divider="false"
|
|
/>
|
|
<OidcOAuthSection
|
|
v-if="oidcOAuthEnabled"
|
|
:disabled="authActionDisabled"
|
|
:provider-name="oidcOAuthProviderName"
|
|
:show-divider="false"
|
|
/>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
|
|
<!-- Footer -->
|
|
<template v-if="!backendModeEnabled" #footer>
|
|
<p class="text-gray-500 dark:text-dark-400">
|
|
{{ t('auth.dontHaveAccount') }}
|
|
<router-link
|
|
to="/register"
|
|
class="font-medium text-primary-600 transition-colors hover:text-primary-500 dark:text-primary-400 dark:hover:text-primary-300"
|
|
>
|
|
{{ t('auth.signUp') }}
|
|
</router-link>
|
|
</p>
|
|
</template>
|
|
</AuthLayout>
|
|
|
|
<!-- 2FA Modal -->
|
|
<TotpLoginModal
|
|
v-if="show2FAModal"
|
|
ref="totpModalRef"
|
|
:temp-token="totpTempToken"
|
|
:user-email-masked="totpUserEmailMasked"
|
|
@verify="handle2FAVerify"
|
|
@cancel="handle2FACancel"
|
|
/>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { computed, ref, reactive, onMounted, watch } from 'vue'
|
|
import { useRouter } from 'vue-router'
|
|
import { useI18n } from 'vue-i18n'
|
|
import { AuthLayout } from '@/components/layout'
|
|
import LinuxDoOAuthSection from '@/components/auth/LinuxDoOAuthSection.vue'
|
|
import DingTalkOAuthSection from '@/components/auth/DingTalkOAuthSection.vue'
|
|
import OidcOAuthSection from '@/components/auth/OidcOAuthSection.vue'
|
|
import WechatOAuthSection from '@/components/auth/WechatOAuthSection.vue'
|
|
import EmailOAuthButtons from '@/components/auth/EmailOAuthButtons.vue'
|
|
import LoginAgreementPrompt from '@/components/auth/LoginAgreementPrompt.vue'
|
|
import TotpLoginModal from '@/components/auth/TotpLoginModal.vue'
|
|
import Icon from '@/components/icons/Icon.vue'
|
|
import TurnstileWidget from '@/components/TurnstileWidget.vue'
|
|
import { useAuthStore, useAppStore } from '@/stores'
|
|
import { getPublicSettings, isTotp2FARequired, isWeChatWebOAuthEnabled } from '@/api/auth'
|
|
import type { LoginAgreementDocument, TotpLoginResponse } from '@/types'
|
|
import { extractI18nErrorMessage } from '@/utils/apiError'
|
|
import { clearAllAffiliateReferralCodes } from '@/utils/oauthAffiliate'
|
|
|
|
const { t } = useI18n()
|
|
const LOGIN_AGREEMENT_STORAGE_KEY = 'sub2api_login_agreement_consent'
|
|
|
|
// ==================== Router & Stores ====================
|
|
|
|
const router = useRouter()
|
|
const authStore = useAuthStore()
|
|
const appStore = useAppStore()
|
|
|
|
// ==================== State ====================
|
|
|
|
const isLoading = ref<boolean>(false)
|
|
const errorMessage = ref<string>('')
|
|
const showPassword = ref<boolean>(false)
|
|
const publicSettingsLoaded = ref<boolean>(false)
|
|
|
|
// Public settings
|
|
const turnstileEnabled = ref<boolean>(false)
|
|
const turnstileSiteKey = ref<string>('')
|
|
const linuxdoOAuthEnabled = ref<boolean>(false)
|
|
const dingtalkOAuthEnabled = ref<boolean>(false)
|
|
const wechatOAuthEnabled = ref<boolean>(false)
|
|
const backendModeEnabled = ref<boolean>(false)
|
|
const oidcOAuthEnabled = ref<boolean>(false)
|
|
const oidcOAuthProviderName = ref<string>('OIDC')
|
|
const githubOAuthEnabled = ref<boolean>(false)
|
|
const googleOAuthEnabled = ref<boolean>(false)
|
|
const passwordResetEnabled = ref<boolean>(false)
|
|
const loginAgreementEnabled = ref<boolean>(false)
|
|
const loginAgreementMode = ref<'modal' | 'checkbox' | string>('modal')
|
|
const loginAgreementUpdatedAt = ref<string>('')
|
|
const loginAgreementRevision = ref<string>('')
|
|
const loginAgreementDocuments = ref<LoginAgreementDocument[]>([])
|
|
const agreementAccepted = ref<boolean>(false)
|
|
const showAgreementModal = ref<boolean>(false)
|
|
|
|
// Turnstile
|
|
const turnstileRef = ref<InstanceType<typeof TurnstileWidget> | null>(null)
|
|
const turnstileToken = ref<string>('')
|
|
|
|
// 2FA state
|
|
const show2FAModal = ref<boolean>(false)
|
|
const totpTempToken = ref<string>('')
|
|
const totpUserEmailMasked = ref<string>('')
|
|
const totpModalRef = ref<InstanceType<typeof TotpLoginModal> | null>(null)
|
|
|
|
const formData = reactive({
|
|
email: '',
|
|
password: ''
|
|
})
|
|
|
|
const errors = reactive({
|
|
email: '',
|
|
password: '',
|
|
turnstile: ''
|
|
})
|
|
|
|
const validationToastMessage = computed(
|
|
() => errors.email || errors.password || errors.turnstile || ''
|
|
)
|
|
|
|
const agreementGateActive = computed(
|
|
() => loginAgreementEnabled.value && !agreementAccepted.value
|
|
)
|
|
|
|
const authActionDisabled = computed(
|
|
() => isLoading.value || !publicSettingsLoaded.value || agreementGateActive.value
|
|
)
|
|
|
|
const showOAuthLogin = computed(
|
|
() =>
|
|
!backendModeEnabled.value &&
|
|
(linuxdoOAuthEnabled.value ||
|
|
dingtalkOAuthEnabled.value ||
|
|
wechatOAuthEnabled.value ||
|
|
oidcOAuthEnabled.value ||
|
|
githubOAuthEnabled.value ||
|
|
googleOAuthEnabled.value)
|
|
)
|
|
|
|
watch(validationToastMessage, (value, previousValue) => {
|
|
if (value && value !== previousValue) {
|
|
appStore.showError(value)
|
|
}
|
|
})
|
|
|
|
// ==================== Lifecycle ====================
|
|
|
|
onMounted(async () => {
|
|
const expiredFlag = sessionStorage.getItem('auth_expired')
|
|
if (expiredFlag) {
|
|
sessionStorage.removeItem('auth_expired')
|
|
const message = t('auth.reloginRequired')
|
|
errorMessage.value = message
|
|
appStore.showWarning(message)
|
|
}
|
|
|
|
try {
|
|
const settings = await getPublicSettings()
|
|
turnstileEnabled.value = settings.turnstile_enabled
|
|
turnstileSiteKey.value = settings.turnstile_site_key || ''
|
|
linuxdoOAuthEnabled.value = settings.linuxdo_oauth_enabled
|
|
dingtalkOAuthEnabled.value = settings.dingtalk_oauth_enabled ?? false
|
|
wechatOAuthEnabled.value = isWeChatWebOAuthEnabled(settings)
|
|
backendModeEnabled.value = settings.backend_mode_enabled
|
|
oidcOAuthEnabled.value = settings.oidc_oauth_enabled
|
|
oidcOAuthProviderName.value = settings.oidc_oauth_provider_name || 'OIDC'
|
|
githubOAuthEnabled.value = settings.github_oauth_enabled
|
|
googleOAuthEnabled.value = settings.google_oauth_enabled
|
|
backendModeEnabled.value = settings.backend_mode_enabled
|
|
passwordResetEnabled.value = settings.password_reset_enabled
|
|
applyLoginAgreementSettings(settings)
|
|
} catch (error) {
|
|
console.error('Failed to load public settings:', error)
|
|
loginAgreementEnabled.value = false
|
|
agreementAccepted.value = true
|
|
} finally {
|
|
publicSettingsLoaded.value = true
|
|
}
|
|
})
|
|
|
|
// ==================== Login Agreement ====================
|
|
|
|
function applyLoginAgreementSettings(settings: {
|
|
login_agreement_enabled?: boolean
|
|
login_agreement_mode?: string
|
|
login_agreement_updated_at?: string
|
|
login_agreement_revision?: string
|
|
login_agreement_documents?: LoginAgreementDocument[]
|
|
}): void {
|
|
const documents = Array.isArray(settings.login_agreement_documents)
|
|
? settings.login_agreement_documents.filter((doc) => doc.title?.trim())
|
|
: []
|
|
loginAgreementDocuments.value = documents
|
|
loginAgreementEnabled.value = settings.login_agreement_enabled === true && documents.length > 0
|
|
loginAgreementMode.value = settings.login_agreement_mode === 'checkbox' ? 'checkbox' : 'modal'
|
|
loginAgreementUpdatedAt.value = settings.login_agreement_updated_at || ''
|
|
loginAgreementRevision.value =
|
|
settings.login_agreement_revision ||
|
|
`${loginAgreementUpdatedAt.value}:${documents.map((doc) => `${doc.id}:${doc.title}`).join('|')}`
|
|
|
|
agreementAccepted.value = !loginAgreementEnabled.value || hasAcceptedLoginAgreement(loginAgreementRevision.value)
|
|
showAgreementModal.value =
|
|
loginAgreementEnabled.value && !agreementAccepted.value && loginAgreementMode.value !== 'checkbox'
|
|
}
|
|
|
|
function hasAcceptedLoginAgreement(revision: string): boolean {
|
|
if (!revision) {
|
|
return false
|
|
}
|
|
try {
|
|
const raw = localStorage.getItem(LOGIN_AGREEMENT_STORAGE_KEY)
|
|
if (!raw) {
|
|
return false
|
|
}
|
|
const parsed = JSON.parse(raw) as { revision?: string }
|
|
return parsed.revision === revision
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
function acceptLoginAgreement(): void {
|
|
if (loginAgreementRevision.value) {
|
|
localStorage.setItem(
|
|
LOGIN_AGREEMENT_STORAGE_KEY,
|
|
JSON.stringify({
|
|
revision: loginAgreementRevision.value,
|
|
accepted_at: new Date().toISOString()
|
|
})
|
|
)
|
|
}
|
|
agreementAccepted.value = true
|
|
showAgreementModal.value = false
|
|
}
|
|
|
|
function rejectLoginAgreement(): void {
|
|
localStorage.removeItem(LOGIN_AGREEMENT_STORAGE_KEY)
|
|
agreementAccepted.value = false
|
|
showAgreementModal.value = false
|
|
appStore.showWarning('未同意最新条款前,无法输入账号密码或使用快捷登录。')
|
|
}
|
|
|
|
// ==================== Turnstile Handlers ====================
|
|
|
|
function onTurnstileVerify(token: string): void {
|
|
turnstileToken.value = token
|
|
errors.turnstile = ''
|
|
}
|
|
|
|
function onTurnstileExpire(): void {
|
|
turnstileToken.value = ''
|
|
errors.turnstile = t('auth.turnstileExpired')
|
|
}
|
|
|
|
function onTurnstileError(): void {
|
|
turnstileToken.value = ''
|
|
errors.turnstile = t('auth.turnstileFailed')
|
|
}
|
|
|
|
// ==================== Validation ====================
|
|
|
|
function validateForm(): boolean {
|
|
// Reset errors
|
|
errors.email = ''
|
|
errors.password = ''
|
|
errors.turnstile = ''
|
|
|
|
let isValid = true
|
|
|
|
if (agreementGateActive.value) {
|
|
appStore.showWarning('请先阅读并同意最新条款后再登录。')
|
|
if (loginAgreementMode.value !== 'checkbox') {
|
|
showAgreementModal.value = true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// Email validation
|
|
if (!formData.email.trim()) {
|
|
errors.email = t('auth.emailRequired')
|
|
isValid = false
|
|
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) {
|
|
errors.email = t('auth.invalidEmail')
|
|
isValid = false
|
|
}
|
|
|
|
// Password validation
|
|
if (!formData.password) {
|
|
errors.password = t('auth.passwordRequired')
|
|
isValid = false
|
|
} else if (formData.password.length < 6) {
|
|
errors.password = t('auth.passwordMinLength')
|
|
isValid = false
|
|
}
|
|
|
|
// Turnstile validation
|
|
if (turnstileEnabled.value && !turnstileToken.value) {
|
|
errors.turnstile = t('auth.completeVerification')
|
|
isValid = false
|
|
}
|
|
|
|
return isValid
|
|
}
|
|
|
|
// ==================== Form Handlers ====================
|
|
|
|
async function handleLogin(): Promise<void> {
|
|
// Clear previous error
|
|
errorMessage.value = ''
|
|
|
|
// Validate form
|
|
if (!validateForm()) {
|
|
return
|
|
}
|
|
|
|
isLoading.value = true
|
|
|
|
try {
|
|
// Call auth store login
|
|
const response = await authStore.login({
|
|
email: formData.email,
|
|
password: formData.password,
|
|
turnstile_token: turnstileEnabled.value ? turnstileToken.value : undefined
|
|
})
|
|
|
|
// Check if 2FA is required
|
|
if (isTotp2FARequired(response)) {
|
|
const totpResponse = response as TotpLoginResponse
|
|
totpTempToken.value = totpResponse.temp_token || ''
|
|
totpUserEmailMasked.value = totpResponse.user_email_masked || ''
|
|
show2FAModal.value = true
|
|
isLoading.value = false
|
|
return
|
|
}
|
|
|
|
// Show success toast
|
|
clearAllAffiliateReferralCodes()
|
|
appStore.showSuccess(t('auth.loginSuccess'))
|
|
|
|
// Redirect to dashboard or intended route
|
|
const redirectTo = (router.currentRoute.value.query.redirect as string) || '/dashboard'
|
|
await router.push(redirectTo)
|
|
} catch (error: unknown) {
|
|
// Reset Turnstile on error
|
|
if (turnstileRef.value) {
|
|
turnstileRef.value.reset()
|
|
turnstileToken.value = ''
|
|
}
|
|
|
|
errorMessage.value = extractI18nErrorMessage(error, t, 'auth.errors', t('auth.loginFailed'))
|
|
|
|
// Also show error toast
|
|
appStore.showError(errorMessage.value)
|
|
} finally {
|
|
isLoading.value = false
|
|
}
|
|
}
|
|
|
|
// ==================== 2FA Handlers ====================
|
|
|
|
async function handle2FAVerify(code: string): Promise<void> {
|
|
if (totpModalRef.value) {
|
|
totpModalRef.value.setVerifying(true)
|
|
}
|
|
|
|
try {
|
|
await authStore.login2FA(totpTempToken.value, code)
|
|
|
|
// Close modal and show success
|
|
show2FAModal.value = false
|
|
clearAllAffiliateReferralCodes()
|
|
appStore.showSuccess(t('auth.loginSuccess'))
|
|
|
|
// Redirect to dashboard or intended route
|
|
const redirectTo = (router.currentRoute.value.query.redirect as string) || '/dashboard'
|
|
await router.push(redirectTo)
|
|
} catch (error: unknown) {
|
|
const err = error as { message?: string; response?: { data?: { message?: string } } }
|
|
const message = err.response?.data?.message || err.message || t('profile.totp.loginFailed')
|
|
|
|
if (totpModalRef.value) {
|
|
totpModalRef.value.setError(message)
|
|
totpModalRef.value.setVerifying(false)
|
|
}
|
|
}
|
|
}
|
|
|
|
function handle2FACancel(): void {
|
|
show2FAModal.value = false
|
|
totpTempToken.value = ''
|
|
totpUserEmailMasked.value = ''
|
|
}
|
|
</script>
|
|
|
|
<style scoped>
|
|
.fade-enter-active,
|
|
.fade-leave-active {
|
|
transition: all 0.3s ease;
|
|
}
|
|
|
|
.fade-enter-from,
|
|
.fade-leave-to {
|
|
opacity: 0;
|
|
transform: translateY(-8px);
|
|
}
|
|
</style>
|