sub2api/frontend/src/views/auth/DingTalkCallbackView.vue
DaydreamCoding b19da9c7fe feat(dingtalk): 钉钉 OAuth 登录接入与 internal_only 用户属性同步
⚠️ 应用类型约束:当前实现仅支持「钉钉登录-企业内部应用」(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>
2026-05-19 15:27:47 +08:00

853 lines
28 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<AuthLayout>
<div class="space-y-6">
<div class="text-center">
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">
{{ t('auth.dingtalk.callbackTitle') }}
</h2>
<p class="mt-2 text-sm text-gray-500 dark:text-dark-400">
{{ isProcessing ? t('auth.dingtalk.callbackProcessing') : t('auth.dingtalk.callbackHint') }}
</p>
</div>
<transition name="fade">
<div
v-if="
needsInvitation ||
needsAdoptionConfirmation ||
needsChooser ||
needsCreateAccount ||
needsBindLogin ||
needsTotpChallenge
"
class="space-y-4"
>
<div
v-if="adoptionRequired && (suggestedDisplayName || suggestedAvatarUrl)"
class="rounded-xl border border-gray-200 bg-gray-50 p-4 dark:border-dark-600 dark:bg-dark-800/60"
>
<div class="space-y-3">
<div class="space-y-1">
<p class="text-sm font-medium text-gray-900 dark:text-white">
{{ t('auth.oauthFlow.profileDetailsTitle', { providerName }) }}
</p>
<p class="text-xs text-gray-500 dark:text-dark-400">
{{ t('auth.oauthFlow.profileDetailsDescription', { providerName }) }}
</p>
</div>
<label
v-if="suggestedDisplayName"
class="flex items-start gap-3 rounded-lg border border-gray-200 bg-white p-3 text-sm dark:border-dark-600 dark:bg-dark-900/50"
>
<input v-model="adoptDisplayName" type="checkbox" class="mt-1 h-4 w-4" />
<span class="space-y-1">
<span class="block font-medium text-gray-900 dark:text-white">
{{ t('auth.oauthFlow.useDisplayName') }}
</span>
<span class="block text-gray-500 dark:text-dark-400">
{{ suggestedDisplayName }}
</span>
</span>
</label>
<label
v-if="suggestedAvatarUrl"
class="flex items-start gap-3 rounded-lg border border-gray-200 bg-white p-3 text-sm dark:border-dark-600 dark:bg-dark-900/50"
>
<input v-model="adoptAvatar" type="checkbox" class="mt-1 h-4 w-4" />
<img
:src="suggestedAvatarUrl"
:alt="t('auth.oauthFlow.avatarAlt', { providerName })"
class="h-10 w-10 rounded-full border border-gray-200 object-cover dark:border-dark-600"
/>
<span class="space-y-1">
<span class="block font-medium text-gray-900 dark:text-white">
{{ t('auth.oauthFlow.useAvatar') }}
</span>
<span class="block break-all text-gray-500 dark:text-dark-400">
{{ suggestedAvatarUrl }}
</span>
</span>
</label>
</div>
</div>
<template v-if="needsInvitation">
<p class="text-sm text-gray-700 dark:text-gray-300">
{{ t('auth.dingtalk.invitationRequired') }}
</p>
<div>
<input
v-model="invitationCode"
type="text"
class="input w-full"
:placeholder="t('auth.invitationCodePlaceholder')"
:disabled="isSubmitting"
@keyup.enter="handleSubmitInvitation"
/>
</div>
<button
class="btn btn-primary w-full"
:disabled="isSubmitting || !invitationCode.trim()"
@click="handleSubmitInvitation"
>
{{ isSubmitting ? t('auth.dingtalk.completing') : t('auth.dingtalk.completeRegistration') }}
</button>
</template>
<template v-else-if="needsAdoptionConfirmation">
<p class="text-sm text-gray-700 dark:text-gray-300">
{{ t('auth.oauthFlow.reviewProfileBeforeContinue', { providerName }) }}
</p>
<button class="btn btn-primary w-full" :disabled="isSubmitting" @click="handleContinueLogin">
{{ isSubmitting ? t('common.processing') : t('auth.continue') }}
</button>
</template>
<template v-else-if="needsChooser">
<div class="rounded-xl border border-gray-200 bg-gray-50 p-4 dark:border-dark-600 dark:bg-dark-800/60">
<div class="space-y-4">
<div class="space-y-1">
<p class="text-sm font-medium text-gray-900 dark:text-white">
{{ t('auth.oauthFlow.chooseHowToContinue') }}
</p>
<p class="text-xs text-gray-500 dark:text-dark-400">
{{
pendingAccountEmail
? t('auth.oauthFlow.suggestedEmail', { email: pendingAccountEmail })
: t('auth.oauthFlow.chooseAccountActionHint')
}}
</p>
</div>
<div class="grid gap-3 sm:grid-cols-2">
<button
class="btn btn-secondary w-full"
:disabled="isSubmitting"
@click="switchToBindLoginMode()"
>
{{ t('auth.oauthFlow.bindExistingAccount') }}
</button>
<button
class="btn btn-primary w-full"
:disabled="isSubmitting"
@click="switchToCreateAccountMode"
>
{{ t('auth.oauthFlow.createNewAccount') }}
</button>
</div>
</div>
</div>
</template>
<template v-else-if="needsCreateAccount">
<p class="text-sm text-gray-700 dark:text-gray-300">
{{ t('auth.oauthFlow.createAccountHint') }}
</p>
<PendingOAuthCreateAccountForm
test-id-prefix="dingtalk"
:initial-email="pendingAccountEmail"
:is-submitting="isSubmitting"
:error-message="accountActionError"
@submit="handleCreateAccount"
@switch-to-bind="switchToBindLoginMode"
/>
</template>
<template v-else-if="needsBindLogin">
<p class="text-sm text-gray-700 dark:text-gray-300">
{{ t('auth.oauthFlow.bindLoginHint', { providerName }) }}
</p>
<div class="space-y-3">
<input
v-model="bindLoginEmail"
data-testid="dingtalk-bind-login-email"
type="email"
class="input w-full"
:placeholder="t('auth.emailPlaceholder')"
:disabled="isSubmitting"
@keyup.enter="handleBindLogin"
/>
<input
v-model="bindLoginPassword"
data-testid="dingtalk-bind-login-password"
type="password"
class="input w-full"
:placeholder="t('auth.passwordPlaceholder')"
:disabled="isSubmitting"
@keyup.enter="handleBindLogin"
/>
<button
data-testid="dingtalk-bind-login-submit"
class="btn btn-primary w-full"
:disabled="isSubmitting || !bindLoginEmail.trim() || !bindLoginPassword"
@click="handleBindLogin"
>
{{ isSubmitting ? t('common.processing') : t('auth.oauthFlow.logInAndBind') }}
</button>
<button
v-if="canReturnToCreateAccount"
class="btn btn-secondary w-full"
:disabled="isSubmitting"
@click="switchToCreateAccountMode"
>
{{ t('auth.oauthFlow.useDifferentEmail') }}
</button>
</div>
</template>
<template v-else-if="needsTotpChallenge">
<p class="text-sm text-gray-700 dark:text-gray-300">
{{
t('auth.oauthFlow.totpHint', {
providerName,
account: totpUserEmailMasked || t('auth.oauthFlow.yourAccount')
})
}}
</p>
<div class="space-y-3">
<input
v-model="totpCode"
data-testid="dingtalk-bind-login-totp"
type="text"
inputmode="numeric"
maxlength="6"
class="input w-full"
placeholder="123456"
:disabled="isSubmitting"
@keyup.enter="handleSubmitTotpChallenge"
/>
<button
data-testid="dingtalk-bind-login-totp-submit"
class="btn btn-primary w-full"
:disabled="isSubmitting || totpCode.trim().length !== 6"
@click="handleSubmitTotpChallenge"
>
{{ isSubmitting ? t('common.processing') : t('auth.oauthFlow.verifyAndContinue') }}
</button>
</div>
</template>
</div>
</transition>
</div>
</AuthLayout>
</template>
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { AuthLayout } from '@/components/layout'
import PendingOAuthCreateAccountForm, {
type PendingOAuthCreateAccountPayload
} from '@/components/auth/PendingOAuthCreateAccountForm.vue'
import { apiClient } from '@/api/client'
import { useAuthStore, useAppStore } from '@/stores'
import {
exchangePendingOAuthCompletion,
getOAuthCompletionKind,
isOAuthLoginCompletion,
login2FA,
persistOAuthTokenContext,
type OAuthAdoptionDecision,
type OAuthTokenResponse,
type PendingOAuthExchangeResponse
} from '@/api/auth'
import {
clearAllAffiliateReferralCodes,
loadOAuthAffiliateCode,
oauthAffiliatePayload
} from '@/utils/oauthAffiliate'
const route = useRoute()
const router = useRouter()
const { t, te } = useI18n()
const authStore = useAuthStore()
const appStore = useAppStore()
const isProcessing = ref(true)
const errorMessage = ref('')
// Invitation code flow state
const needsInvitation = ref(false)
const invitationCode = ref('')
const isSubmitting = ref(false)
const invitationError = ref('')
const redirectTo = ref('/dashboard')
const adoptionRequired = ref(false)
const suggestedDisplayName = ref('')
const suggestedAvatarUrl = ref('')
const adoptDisplayName = ref(true)
const adoptAvatar = ref(true)
const needsAdoptionConfirmation = ref(false)
const pendingAccountAction = ref<'none' | 'choose_account_action' | 'create_account' | 'bind_login'>('none')
const pendingAccountEmail = ref('')
const bindLoginEmail = ref('')
const bindLoginPassword = ref('')
const legacyPendingOAuthToken = ref('')
const accountActionError = ref('')
const canReturnToCreateAccount = ref(false)
const bindSuccessMessage = t('profile.authBindings.bindSuccess')
const needsTotpChallenge = ref(false)
const totpTempToken = ref('')
const totpCode = ref('')
const totpError = ref('')
const totpUserEmailMasked = ref('')
const providerName = '钉钉'
const needsCreateAccount = computed(() => pendingAccountAction.value === 'create_account')
const needsChooser = computed(() => pendingAccountAction.value === 'choose_account_action')
const needsBindLogin = computed(() => pendingAccountAction.value === 'bind_login')
watch(invitationError, value => {
if (value) {
appStore.showError(value)
}
})
watch(accountActionError, value => {
if (value) {
appStore.showError(value)
}
})
watch(totpError, value => {
if (value) {
appStore.showError(value)
}
})
watch(errorMessage, value => {
if (value) {
appStore.showError(value)
}
})
type DingTalkPendingActionResponse = PendingOAuthExchangeResponse & {
step?: string
intent?: string
email?: string
resolved_email?: string
pending_email?: string
existing_account_email?: string
suggested_email?: string
}
function persistPendingAuthSession(redirect?: string) {
authStore.setPendingAuthSession({
token: '',
token_field: 'pending_oauth_token',
provider: 'dingtalk',
redirect: sanitizeRedirectPath(redirect || redirectTo.value)
})
}
function clearPendingAuthSession() {
authStore.clearPendingAuthSession()
}
function parseFragmentParams(): URLSearchParams {
const raw = typeof window !== 'undefined' ? window.location.hash : ''
const hash = raw.startsWith('#') ? raw.slice(1) : raw
return new URLSearchParams(hash)
}
function readLegacyFragmentLogin(params: URLSearchParams): OAuthTokenResponse | null {
const accessToken = params.get('access_token')?.trim() || ''
if (!accessToken) {
return null
}
const completion: OAuthTokenResponse = {
access_token: accessToken
}
const refreshToken = params.get('refresh_token')?.trim() || ''
if (refreshToken) {
completion.refresh_token = refreshToken
}
const expiresIn = Number.parseInt(params.get('expires_in')?.trim() || '', 10)
if (Number.isFinite(expiresIn) && expiresIn > 0) {
completion.expires_in = expiresIn
}
const tokenType = params.get('token_type')?.trim() || ''
if (tokenType) {
completion.token_type = tokenType
}
return completion
}
function sanitizeRedirectPath(path: string | null | undefined): string {
if (!path) return '/dashboard'
if (!path.startsWith('/')) return '/dashboard'
if (path.startsWith('//')) return '/dashboard'
if (path.includes('://')) return '/dashboard'
if (path.includes('\n') || path.includes('\r')) return '/dashboard'
return path
}
function currentAdoptionDecision(): OAuthAdoptionDecision {
return {
adoptDisplayName: adoptDisplayName.value,
adoptAvatar: adoptAvatar.value
}
}
function serializeAdoptionDecision(decision: OAuthAdoptionDecision): Record<string, boolean> {
const payload: Record<string, boolean> = {}
if (typeof decision.adoptDisplayName === 'boolean') {
payload.adopt_display_name = decision.adoptDisplayName
}
if (typeof decision.adoptAvatar === 'boolean') {
payload.adopt_avatar = decision.adoptAvatar
}
return payload
}
function applyAdoptionSuggestionState(completion: {
adoption_required?: boolean
suggested_display_name?: string
suggested_avatar_url?: string
}) {
adoptionRequired.value = completion.adoption_required === true
suggestedDisplayName.value = completion.suggested_display_name || ''
suggestedAvatarUrl.value = completion.suggested_avatar_url || ''
if (!suggestedDisplayName.value) {
adoptDisplayName.value = false
}
if (!suggestedAvatarUrl.value) {
adoptAvatar.value = false
}
}
function hasSuggestedProfile(completion: {
suggested_display_name?: string
suggested_avatar_url?: string
}): boolean {
return Boolean(completion.suggested_display_name || completion.suggested_avatar_url)
}
function normalizedPendingState(value: string | null | undefined): string {
return value?.trim().toLowerCase() || ''
}
function extractPendingAccountEmail(completion: DingTalkPendingActionResponse): string {
return (
completion.pending_email ||
completion.existing_account_email ||
completion.email ||
completion.resolved_email ||
completion.suggested_email ||
''
).trim()
}
function resolvePendingAccountAction(
completion: DingTalkPendingActionResponse
): 'none' | 'choose_account_action' | 'create_account' | 'bind_login' {
const raw = normalizedPendingState(completion.step || completion.error || completion.intent)
if (
raw === 'choice' ||
raw === 'choose_account_action_required' ||
raw === 'choose_account_action' ||
raw === 'choose_account' ||
raw === 'choose'
) {
return 'choose_account_action'
}
if (raw === 'email_required' || raw === 'create_account_required' || raw === 'create_account') {
return 'create_account'
}
if (
raw === 'bind_login_required' ||
raw === 'bind_login' ||
raw === 'existing_account' ||
raw === 'existing_account_required' ||
raw === 'existing_account_binding_required' ||
raw === 'adopt_existing_user_by_email'
) {
return 'bind_login'
}
return 'none'
}
function applyPendingAccountAction(completion: DingTalkPendingActionResponse) {
const action = resolvePendingAccountAction(completion)
pendingAccountAction.value = action
accountActionError.value = ''
needsTotpChallenge.value = false
totpTempToken.value = ''
totpCode.value = ''
totpError.value = ''
totpUserEmailMasked.value = ''
const email = extractPendingAccountEmail(completion)
if (action === 'choose_account_action') {
pendingAccountEmail.value = email
bindLoginEmail.value = email
bindLoginPassword.value = ''
canReturnToCreateAccount.value = false
return
}
if (action === 'create_account') {
pendingAccountEmail.value = email
canReturnToCreateAccount.value = true
return
}
if (action === 'bind_login') {
bindLoginEmail.value = email
bindLoginPassword.value = ''
canReturnToCreateAccount.value = false
return
}
canReturnToCreateAccount.value = false
}
function applyTotpChallenge(completion: DingTalkPendingActionResponse): boolean {
if (completion.requires_2fa !== true || !completion.temp_token) {
return false
}
pendingAccountAction.value = 'none'
needsInvitation.value = false
needsAdoptionConfirmation.value = false
needsTotpChallenge.value = true
totpTempToken.value = completion.temp_token
totpCode.value = ''
totpError.value = ''
totpUserEmailMasked.value = completion.user_email_masked || ''
isProcessing.value = false
return true
}
function switchToBindLoginMode(nextEmail?: string) {
pendingAccountAction.value = 'bind_login'
bindLoginEmail.value = bindLoginEmail.value.trim() || nextEmail?.trim() || pendingAccountEmail.value.trim()
bindLoginPassword.value = ''
accountActionError.value = ''
canReturnToCreateAccount.value = true
}
function switchToCreateAccountMode() {
pendingAccountAction.value = 'create_account'
pendingAccountEmail.value = pendingAccountEmail.value.trim() || bindLoginEmail.value.trim()
accountActionError.value = ''
}
function getRequestErrorMessage(error: unknown, fallback: string): string {
const err = error as { message?: string; response?: { data?: { detail?: string; message?: string } } }
return err.response?.data?.detail || err.response?.data?.message || err.message || fallback
}
function isCreateAccountRecoveryError(error: unknown): boolean {
const data = (error as {
response?: {
data?: {
reason?: string
error?: string
code?: string
step?: string
intent?: string
}
}
}).response?.data
const states = [data?.reason, data?.error, data?.code, data?.step, data?.intent]
.map(value => value?.trim().toLowerCase())
.filter((value): value is string => Boolean(value))
return states.includes('email_exists') ||
states.includes('bind_login_required') ||
states.includes('bind_login') ||
states.includes('adopt_existing_user_by_email') ||
states.includes('existing_account_required') ||
states.includes('existing_account_binding_required')
}
async function finalizeCompletion(completion: PendingOAuthExchangeResponse, redirect: string) {
if (getOAuthCompletionKind(completion) === 'bind') {
const bindRedirect = sanitizeRedirectPath(completion.redirect || '/profile')
clearPendingAuthSession()
clearAllAffiliateReferralCodes()
appStore.showSuccess(bindSuccessMessage)
await router.replace(bindRedirect)
return
}
if (!isOAuthLoginCompletion(completion)) {
throw new Error(t('auth.dingtalk.callbackMissingToken'))
}
persistOAuthTokenContext(completion)
await authStore.setToken(completion.access_token)
clearAllAffiliateReferralCodes()
appStore.showSuccess(t('auth.loginSuccess'))
await router.replace(redirect)
}
async function finalizePendingAccountResponse(completion: DingTalkPendingActionResponse) {
applyAdoptionSuggestionState(completion)
const redirect = sanitizeRedirectPath(completion.redirect || redirectTo.value)
// step=email_completion: 用户无邮箱,需要跳到补邮箱页面
if (completion.step === 'email_completion' || (completion as Record<string, unknown>)['requires_email_completion'] === true) {
await router.replace('/auth/dingtalk/email-completion?redirect=' + encodeURIComponent(redirect))
return
}
if (completion.error === 'invitation_required') {
pendingAccountAction.value = 'none'
needsInvitation.value = true
needsAdoptionConfirmation.value = false
isProcessing.value = false
persistPendingAuthSession(redirect)
return
}
if (applyTotpChallenge(completion)) {
persistPendingAuthSession(redirect)
return
}
applyPendingAccountAction(completion)
if (pendingAccountAction.value !== 'none') {
needsInvitation.value = false
needsAdoptionConfirmation.value = false
isProcessing.value = false
persistPendingAuthSession(redirect)
return
}
if (completion.auth_result === 'pending_session') {
needsInvitation.value = false
needsAdoptionConfirmation.value = false
isProcessing.value = false
persistPendingAuthSession(redirect)
return
}
await finalizeCompletion(completion, redirect)
}
async function handleSubmitInvitation() {
invitationError.value = ''
if (!invitationCode.value.trim()) return
isSubmitting.value = true
try {
const affCode = loadOAuthAffiliateCode()
const decision = currentAdoptionDecision()
const { data: completion } = await apiClient.post<DingTalkPendingActionResponse>(
'/auth/oauth/dingtalk/complete-registration',
{
pending_oauth_token: legacyPendingOAuthToken.value || undefined,
invitation_code: invitationCode.value.trim(),
...oauthAffiliatePayload(affCode),
...serializeAdoptionDecision(decision)
}
)
await finalizePendingAccountResponse(completion)
} catch (e: unknown) {
const err = e as { message?: string; response?: { data?: { message?: string } } }
invitationError.value =
err.response?.data?.message || err.message || t('auth.dingtalk.completeRegistrationFailed')
} finally {
isSubmitting.value = false
}
}
async function handleContinueLogin() {
isSubmitting.value = true
try {
const completion = await exchangePendingOAuthCompletion(currentAdoptionDecision()) as DingTalkPendingActionResponse
await finalizePendingAccountResponse(completion)
} catch (e: unknown) {
errorMessage.value = getRequestErrorMessage(e, t('auth.loginFailed'))
needsAdoptionConfirmation.value = false
} finally {
isSubmitting.value = false
}
}
async function handleCreateAccount(payload: PendingOAuthCreateAccountPayload) {
accountActionError.value = ''
if (!payload.email || !payload.password) return
isSubmitting.value = true
try {
const { data } = await apiClient.post<DingTalkPendingActionResponse>('/auth/oauth/pending/create-account', {
email: payload.email,
password: payload.password,
verify_code: payload.verifyCode || undefined,
invitation_code: payload.invitationCode || undefined,
...oauthAffiliatePayload(loadOAuthAffiliateCode()),
...serializeAdoptionDecision(currentAdoptionDecision())
})
await finalizePendingAccountResponse(data)
} catch (e: unknown) {
if (isCreateAccountRecoveryError(e)) {
switchToBindLoginMode(payload.email.trim())
return
}
accountActionError.value = getRequestErrorMessage(e, t('auth.loginFailed'))
} finally {
isSubmitting.value = false
}
}
async function handleBindLogin() {
accountActionError.value = ''
const email = bindLoginEmail.value.trim()
const password = bindLoginPassword.value
if (!email || !password) return
isSubmitting.value = true
try {
const { data } = await apiClient.post<DingTalkPendingActionResponse>('/auth/oauth/pending/bind-login', {
email,
password,
...serializeAdoptionDecision(currentAdoptionDecision())
})
await finalizePendingAccountResponse(data)
} catch (e: unknown) {
accountActionError.value = getRequestErrorMessage(e, t('auth.loginFailed'))
} finally {
isSubmitting.value = false
}
}
async function handleSubmitTotpChallenge() {
totpError.value = ''
const code = totpCode.value.trim()
if (!totpTempToken.value || code.length !== 6) return
isSubmitting.value = true
try {
const completion = await login2FA({
temp_token: totpTempToken.value,
totp_code: code
})
await authStore.setToken(completion.access_token)
clearAllAffiliateReferralCodes()
appStore.showSuccess(t('auth.loginSuccess'))
await router.replace(redirectTo.value)
} catch (e: unknown) {
totpError.value = getRequestErrorMessage(e, t('auth.loginFailed'))
} finally {
isSubmitting.value = false
}
}
onMounted(async () => {
const params = parseFragmentParams()
const legacyLogin = readLegacyFragmentLogin(params)
const legacyPendingToken = params.get('pending_oauth_token')?.trim() || ''
const error = params.get('error')
const errorDesc = params.get('error_description') || params.get('error_message') || ''
const redirect = sanitizeRedirectPath(
params.get('redirect') || (route.query.redirect as string | undefined) || '/dashboard'
)
try {
if (legacyLogin) {
persistOAuthTokenContext(legacyLogin)
await authStore.setToken(legacyLogin.access_token)
clearAllAffiliateReferralCodes()
appStore.showSuccess(t('auth.loginSuccess'))
await router.replace(redirect)
return
}
if (error === 'invitation_required' && legacyPendingToken) {
legacyPendingOAuthToken.value = legacyPendingToken
redirectTo.value = redirect
needsInvitation.value = true
isProcessing.value = false
return
}
if (error) {
const i18nKey = `auth.dingtalk.error.${error}`
errorMessage.value = te(i18nKey) ? t(i18nKey) : (errorDesc || error)
isProcessing.value = false
return
}
const completion = await exchangePendingOAuthCompletion()
const completionRedirect = sanitizeRedirectPath(
completion.redirect || (route.query.redirect as string | undefined) || '/dashboard'
)
applyAdoptionSuggestionState(completion)
redirectTo.value = completionRedirect
const completionData = completion as DingTalkPendingActionResponse
// 用户从补邮箱页"我已有账户"按钮跳回时携带 bind=1跳过 email_completion 自动 redirect
// 直接进入 bind_login 输入密码绑定已有账户。
const wantsBindExisting = (route.query.bind as string | undefined) === '1'
const presetEmail = ((route.query.email as string | undefined) || '').trim()
if (completionData.step === 'email_completion' || (completionData as unknown as Record<string, unknown>)['requires_email_completion'] === true) {
if (wantsBindExisting) {
pendingAccountAction.value = 'bind_login'
bindLoginEmail.value = presetEmail
bindLoginPassword.value = ''
canReturnToCreateAccount.value = true
isProcessing.value = false
persistPendingAuthSession(completionRedirect)
return
}
await router.replace('/auth/dingtalk/email-completion?redirect=' + encodeURIComponent(completionRedirect))
return
}
if (completion.error === 'invitation_required') {
needsInvitation.value = true
isProcessing.value = false
persistPendingAuthSession(completionRedirect)
return
}
if (applyTotpChallenge(completion as DingTalkPendingActionResponse)) {
persistPendingAuthSession(completionRedirect)
return
}
applyPendingAccountAction(completion as DingTalkPendingActionResponse)
if (pendingAccountAction.value !== 'none') {
isProcessing.value = false
persistPendingAuthSession(completionRedirect)
return
}
if (adoptionRequired.value && hasSuggestedProfile(completion)) {
needsAdoptionConfirmation.value = true
isProcessing.value = false
persistPendingAuthSession(completionRedirect)
return
}
await finalizeCompletion(completion, completionRedirect)
} catch (e: unknown) {
clearPendingAuthSession()
errorMessage.value = getRequestErrorMessage(e, t('auth.loginFailed'))
isProcessing.value = false
}
})
</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>