mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-11-03 00:33:13 +00:00
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>
This commit is contained in:
1 parent
b006e36af9
commit
b19da9c7fe
54 files changed
+5545
-365
No files matched your search
@@ -0,0 +1,852 @@
|
||||
<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>
|
||||
@@ -0,0 +1,132 @@
|
||||
<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.createAccountTitle') }}
|
||||
</h2>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-dark-400">
|
||||
{{ t('auth.oauthFlow.createAccountHint') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<PendingOAuthCreateAccountForm
|
||||
test-id-prefix="dingtalk"
|
||||
:initial-email="initialEmail"
|
||||
:is-submitting="isSubmitting"
|
||||
:error-message="accountActionError"
|
||||
@submit="handleCreateAccount"
|
||||
@switch-to-bind="handleSwitchToBind"
|
||||
/>
|
||||
</div>
|
||||
</AuthLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } 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 {
|
||||
persistOAuthTokenContext,
|
||||
type PendingOAuthExchangeResponse
|
||||
} from '@/api/auth'
|
||||
import { clearAllAffiliateReferralCodes } from '@/utils/oauthAffiliate'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { t } = useI18n()
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const appStore = useAppStore()
|
||||
|
||||
const isSubmitting = ref(false)
|
||||
const accountActionError = ref('')
|
||||
|
||||
const initialEmail = (route.query.email as string | undefined) || ''
|
||||
|
||||
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 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
|
||||
}
|
||||
|
||||
async function handleCreateAccount(payload: PendingOAuthCreateAccountPayload) {
|
||||
accountActionError.value = ''
|
||||
if (!payload.email || !payload.password) return
|
||||
|
||||
isSubmitting.value = true
|
||||
try {
|
||||
const { data } = await apiClient.post<
|
||||
PendingOAuthExchangeResponse & {
|
||||
step?: string
|
||||
redirect?: string
|
||||
existing_account_bindable?: boolean
|
||||
}
|
||||
>(
|
||||
'/auth/oauth/pending/create-account',
|
||||
{
|
||||
email: payload.email,
|
||||
password: payload.password,
|
||||
verify_code: payload.verifyCode || undefined,
|
||||
invitation_code: payload.invitationCode || undefined
|
||||
}
|
||||
)
|
||||
|
||||
const redirect = sanitizeRedirectPath(data.redirect || (route.query.redirect as string | undefined))
|
||||
|
||||
if (data.access_token) {
|
||||
persistOAuthTokenContext(data)
|
||||
await authStore.setToken(data.access_token)
|
||||
clearAllAffiliateReferralCodes()
|
||||
appStore.showSuccess(t('auth.loginSuccess'))
|
||||
await router.replace(redirect)
|
||||
return
|
||||
}
|
||||
|
||||
// 后端把 pending session 转到 choice 状态(用户填的 email 已在系统内)→ 跳回 callback view 走绑定流程
|
||||
if (data.step === 'choose_account_action_required' || data.existing_account_bindable === true) {
|
||||
navigateToBindLogin(payload.email)
|
||||
return
|
||||
}
|
||||
|
||||
accountActionError.value = t('auth.loginFailed')
|
||||
} catch (e: unknown) {
|
||||
// 全局"开放注册"关闭且未开启钉钉企业模式豁免时,引导用户去绑定已有账户而非死路
|
||||
const err = e as { response?: { data?: { reason?: string } } }
|
||||
if (err.response?.data?.reason === 'REGISTRATION_DISABLED') {
|
||||
appStore.showInfo(t('auth.dingtalk.registrationDisabledRedirectToBind'))
|
||||
navigateToBindLogin(payload.email)
|
||||
return
|
||||
}
|
||||
accountActionError.value = getRequestErrorMessage(e, t('auth.loginFailed'))
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function navigateToBindLogin(email: string) {
|
||||
const query: Record<string, string> = { bind: '1' }
|
||||
if (email) query.email = email
|
||||
const redirect = route.query.redirect as string | undefined
|
||||
if (redirect) query.redirect = redirect
|
||||
router.replace({ path: '/auth/dingtalk/callback', query })
|
||||
}
|
||||
|
||||
function handleSwitchToBind(email: string) {
|
||||
navigateToBindLogin(email)
|
||||
}
|
||||
</script>
|
||||
@@ -152,6 +152,11 @@
|
||||
:disabled="authActionDisabled"
|
||||
:show-divider="false"
|
||||
/>
|
||||
<DingTalkOAuthSection
|
||||
v-if="dingtalkOAuthEnabled"
|
||||
:disabled="authActionDisabled"
|
||||
:show-divider="false"
|
||||
/>
|
||||
<WechatOAuthSection
|
||||
v-if="wechatOAuthEnabled"
|
||||
:disabled="authActionDisabled"
|
||||
@@ -198,6 +203,7 @@ 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'
|
||||
@@ -231,6 +237,7 @@ const publicSettingsLoaded = ref<boolean>(false)
|
||||
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)
|
||||
@@ -283,6 +290,7 @@ const showOAuthLogin = computed(
|
||||
() =>
|
||||
!backendModeEnabled.value &&
|
||||
(linuxdoOAuthEnabled.value ||
|
||||
dingtalkOAuthEnabled.value ||
|
||||
wechatOAuthEnabled.value ||
|
||||
oidcOAuthEnabled.value ||
|
||||
githubOAuthEnabled.value ||
|
||||
@@ -311,6 +319,7 @@ onMounted(async () => {
|
||||
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
|
||||
|
||||
Reference in new issue
Block a user