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:
DaydreamCoding
2026-05-19 15:27:47 +08:00
committed by QTom
co-authored by Claude Opus 4.7
parent b006e36af9
commit b19da9c7fe
54 changed files with 5545 additions and 365 deletions
+47 -1
View File
@@ -22,7 +22,8 @@ export type AuthSourceType =
| "oidc"
| "wechat"
| "github"
| "google";
| "google"
| "dingtalk";
export interface AuthSourceDefaultsValue {
balance: number;
@@ -64,6 +65,7 @@ const AUTH_SOURCE_TYPES: AuthSourceType[] = [
"wechat",
"github",
"google",
"dingtalk",
];
const AUTH_SOURCE_DEFAULT_BALANCE = 0;
const AUTH_SOURCE_DEFAULT_CONCURRENCY = 5;
@@ -352,6 +354,11 @@ export interface SystemSettings {
auth_source_default_wechat_subscriptions?: DefaultSubscriptionSetting[];
auth_source_default_wechat_grant_on_signup?: boolean;
auth_source_default_wechat_grant_on_first_bind?: boolean;
auth_source_default_dingtalk_balance?: number;
auth_source_default_dingtalk_concurrency?: number;
auth_source_default_dingtalk_subscriptions?: DefaultSubscriptionSetting[];
auth_source_default_dingtalk_grant_on_signup?: boolean;
auth_source_default_dingtalk_grant_on_first_bind?: boolean;
auth_source_default_github_balance?: number;
auth_source_default_github_concurrency?: number;
auth_source_default_github_subscriptions?: DefaultSubscriptionSetting[];
@@ -396,6 +403,24 @@ export interface SystemSettings {
linuxdo_connect_client_secret_configured: boolean;
linuxdo_connect_redirect_url: string;
// DingTalk Connect OAuth settings
dingtalk_connect_enabled: boolean;
dingtalk_connect_client_id: string;
dingtalk_connect_client_secret_configured: boolean;
dingtalk_connect_redirect_url: string;
dingtalk_connect_corp_restriction_policy: string;
dingtalk_connect_internal_corp_id: string;
dingtalk_connect_bypass_registration: boolean;
dingtalk_connect_sync_corp_email: boolean;
dingtalk_connect_sync_display_name: boolean;
dingtalk_connect_sync_dept: boolean;
dingtalk_connect_sync_corp_email_attr_key: string;
dingtalk_connect_sync_display_name_attr_key: string;
dingtalk_connect_sync_dept_attr_key: string;
dingtalk_connect_sync_corp_email_attr_name: string;
dingtalk_connect_sync_display_name_attr_name: string;
dingtalk_connect_sync_dept_attr_name: string;
// WeChat Connect OAuth settings
wechat_connect_enabled: boolean;
wechat_connect_app_id: string;
@@ -571,6 +596,11 @@ export interface UpdateSettingsRequest {
auth_source_default_wechat_subscriptions?: DefaultSubscriptionSetting[];
auth_source_default_wechat_grant_on_signup?: boolean;
auth_source_default_wechat_grant_on_first_bind?: boolean;
auth_source_default_dingtalk_balance?: number;
auth_source_default_dingtalk_concurrency?: number;
auth_source_default_dingtalk_subscriptions?: DefaultSubscriptionSetting[];
auth_source_default_dingtalk_grant_on_signup?: boolean;
auth_source_default_dingtalk_grant_on_first_bind?: boolean;
auth_source_default_github_balance?: number;
auth_source_default_github_concurrency?: number;
auth_source_default_github_subscriptions?: DefaultSubscriptionSetting[];
@@ -609,6 +639,22 @@ export interface UpdateSettingsRequest {
linuxdo_connect_client_id?: string;
linuxdo_connect_client_secret?: string;
linuxdo_connect_redirect_url?: string;
dingtalk_connect_enabled?: boolean;
dingtalk_connect_client_id?: string;
dingtalk_connect_client_secret?: string;
dingtalk_connect_redirect_url?: string;
dingtalk_connect_corp_restriction_policy?: string;
dingtalk_connect_internal_corp_id?: string;
dingtalk_connect_bypass_registration?: boolean;
dingtalk_connect_sync_corp_email?: boolean;
dingtalk_connect_sync_display_name?: boolean;
dingtalk_connect_sync_dept?: boolean;
dingtalk_connect_sync_corp_email_attr_key?: string;
dingtalk_connect_sync_display_name_attr_key?: string;
dingtalk_connect_sync_dept_attr_key?: string;
dingtalk_connect_sync_corp_email_attr_name?: string;
dingtalk_connect_sync_display_name_attr_name?: string;
dingtalk_connect_sync_dept_attr_name?: string;
wechat_connect_enabled?: boolean;
wechat_connect_app_id?: string;
wechat_connect_app_secret?: string;
+11 -2
View File
@@ -592,7 +592,7 @@ export async function completeWeChatOAuthRegistration(
}
async function createPendingOAuthAccount(
provider: 'linuxdo' | 'oidc' | 'wechat',
provider: 'linuxdo' | 'oidc' | 'wechat' | 'dingtalk',
invitationCode: string,
decision?: OAuthAdoptionDecision,
affiliateCode?: string
@@ -633,6 +633,14 @@ export async function createPendingWeChatOAuthAccount(
return createPendingOAuthAccount('wechat', invitationCode, decision, affiliateCode)
}
export async function createPendingDingTalkOAuthAccount(
invitationCode: string,
decision?: OAuthAdoptionDecision,
affiliateCode?: string
): Promise<PendingOAuthCreateAccountResponse> {
return createPendingOAuthAccount('dingtalk', invitationCode, decision, affiliateCode)
}
export async function completePendingOAuthBindLogin(
decision?: OAuthAdoptionDecision
): Promise<PendingOAuthBindLoginResponse> {
@@ -683,7 +691,8 @@ export const authAPI = {
exchangePendingOAuthCompletion,
completeLinuxDoOAuthRegistration,
completeOIDCOAuthRegistration,
completeWeChatOAuthRegistration
completeWeChatOAuthRegistration,
createPendingDingTalkOAuthAccount
}
export default authAPI
@@ -0,0 +1,61 @@
<template>
<div class="space-y-4">
<button type="button" :disabled="disabled" class="btn btn-secondary w-full" @click="startLogin">
<svg
class="icon mr-2"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
aria-hidden="true"
style="flex-shrink: 0"
>
<circle cx="12" cy="12" r="12" fill="#1677FF" />
<text
x="12"
y="17"
font-family="sans-serif"
font-size="13"
font-weight="bold"
fill="white"
text-anchor="middle"
>钉</text>
</svg>
{{ t('auth.dingtalk.signIn') }}
</button>
<div v-if="showDivider" 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>
</div>
</template>
<script setup lang="ts">
import { useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { resolveAffiliateReferralCode, storeOAuthAffiliateCode } from '@/utils/oauthAffiliate'
const props = withDefaults(defineProps<{
disabled?: boolean
affCode?: string
showDivider?: boolean
}>(), {
showDivider: true
})
const route = useRoute()
const { t } = useI18n()
function startLogin(): void {
const redirectTo = (route.query.redirect as string) || '/dashboard'
storeOAuthAffiliateCode(resolveAffiliateReferralCode(props.affCode, route.query.aff, route.query.aff_code))
const apiBase = (import.meta.env.VITE_API_BASE_URL as string | undefined) || '/api/v1'
const normalized = apiBase.replace(/\/$/, '')
const startURL = `${normalized}/auth/oauth/dingtalk/start?redirect=${encodeURIComponent(redirectTo)}`
window.location.href = startURL
}
</script>
@@ -2,6 +2,7 @@
<ProfileIdentityBindingsSection
:user="user"
:linuxdo-enabled="linuxdoEnabled"
:dingtalk-enabled="dingtalkEnabled"
:oidc-enabled="oidcEnabled"
:oidc-provider-name="oidcProviderName"
:wechat-enabled="wechatEnabled"
@@ -18,6 +19,7 @@ withDefaults(
defineProps<{
user: User | null
linuxdoEnabled?: boolean
dingtalkEnabled?: boolean
oidcEnabled?: boolean
oidcProviderName?: string
wechatEnabled?: boolean
@@ -26,6 +28,7 @@ withDefaults(
}>(),
{
linuxdoEnabled: false,
dingtalkEnabled: false,
oidcEnabled: false,
oidcProviderName: 'OIDC',
wechatEnabled: false,
@@ -217,6 +217,7 @@ const props = withDefaults(
defineProps<{
user: User | null
linuxdoEnabled?: boolean
dingtalkEnabled?: boolean
oidcEnabled?: boolean
oidcProviderName?: string
wechatEnabled?: boolean
@@ -227,6 +228,7 @@ const props = withDefaults(
}>(),
{
linuxdoEnabled: false,
dingtalkEnabled: false,
oidcEnabled: false,
oidcProviderName: 'OIDC',
wechatEnabled: false,
@@ -406,6 +408,9 @@ function isProviderEnabledForBinding(provider: BindableProvider): boolean {
if (provider === 'linuxdo') {
return props.linuxdoEnabled
}
if (provider === 'dingtalk') {
return props.dingtalkEnabled
}
if (provider === 'oidc') {
return props.oidcEnabled
}
@@ -432,6 +437,17 @@ const providerItems = computed(() => [
canUnbind: Boolean(getBindingStatus('linuxdo') && getBindingDetails('linuxdo')?.can_unbind),
details: getBindingDetails('linuxdo'),
},
{
provider: 'dingtalk' as const,
label: t('profile.authBindings.providers.dingtalk'),
bound: getBindingStatus('dingtalk'),
canBind:
!getBindingStatus('dingtalk') &&
isProviderEnabledForBinding('dingtalk') &&
(getBindingDetails('dingtalk')?.can_bind ?? true),
canUnbind: Boolean(getBindingStatus('dingtalk') && getBindingDetails('dingtalk')?.can_unbind),
details: getBindingDetails('dingtalk'),
},
{
provider: 'oidc' as const,
label: t('profile.authBindings.providers.oidc', { providerName: props.oidcProviderName }),
@@ -460,6 +476,9 @@ function providerInitial(provider: UserAuthProvider): string {
if (provider === 'linuxdo') {
return 'L'
}
if (provider === 'dingtalk') {
return 'D'
}
if (provider === 'wechat') {
return 'W'
}
@@ -473,6 +492,9 @@ function providerIconClass(provider: UserAuthProvider): string {
if (provider === 'linuxdo') {
return 'bg-orange-100 text-orange-600 dark:bg-orange-900/20 dark:text-orange-300'
}
if (provider === 'dingtalk') {
return 'bg-blue-100 text-blue-600 dark:bg-blue-900/20 dark:text-blue-300'
}
if (provider === 'wechat') {
return 'bg-green-100 text-green-600 dark:bg-green-900/20 dark:text-green-300'
}
@@ -139,6 +139,7 @@
<ProfileIdentityBindingsSection
:user="user"
:linuxdo-enabled="linuxdoEnabled"
:dingtalk-enabled="dingtalkEnabled"
:oidc-enabled="oidcEnabled"
:oidc-provider-name="oidcProviderName"
:wechat-enabled="wechatEnabled"
@@ -190,6 +191,7 @@ import type { User, UserAuthBindingStatus, UserAuthProvider, UserProfileSourceCo
const props = withDefaults(defineProps<{
user: User | null
linuxdoEnabled?: boolean
dingtalkEnabled?: boolean
oidcEnabled?: boolean
oidcProviderName?: string
wechatEnabled?: boolean
@@ -197,6 +199,7 @@ const props = withDefaults(defineProps<{
wechatMpEnabled?: boolean
}>(), {
linuxdoEnabled: false,
dingtalkEnabled: false,
oidcEnabled: false,
oidcProviderName: 'OIDC',
wechatEnabled: false,
@@ -262,6 +265,7 @@ const memberSinceLabel = computed(() => {
const providerLabels = computed<Record<UserAuthProvider, string>>(() => ({
email: t('profile.authBindings.providers.email'),
linuxdo: t('profile.authBindings.providers.linuxdo'),
dingtalk: t('profile.authBindings.providers.dingtalk'),
oidc: t('profile.authBindings.providers.oidc', { providerName: props.oidcProviderName }),
wechat: t('profile.authBindings.providers.wechat'),
github: 'GitHub',
+72 -1
View File
@@ -461,7 +461,7 @@ export default {
invitationCodeInvalid: 'Invalid or used invitation code',
invitationCodeValidating: 'Validating invitation code...',
invitationCodeInvalidCannotRegister: 'Invalid invitation code. Please check and try again',
oauthOrContinue: 'or continue with email',
oauthOrContinue: 'or continue with others',
linuxdo: {
signIn: 'Continue with Linux.do',
orContinue: 'or continue with email',
@@ -476,6 +476,34 @@ export default {
completing: 'Completing registration…',
completeRegistrationFailed: 'Registration failed. Please check your invitation code and try again.'
},
dingtalk: {
signIn: 'Continue with DingTalk',
callbackTitle: 'Signing you in with DingTalk',
callbackProcessing: 'Completing DingTalk login, please wait...',
callbackHint: 'If you are not redirected automatically, go back to the login page and try again.',
callbackMissingToken: 'Missing login token, please try again.',
backToLogin: 'Back to Login',
invitationRequired: 'This DingTalk account is not yet registered. The site requires an invitation code — please enter one to complete registration.',
invalidPendingToken: 'The registration token has expired. Please sign in with DingTalk again.',
completeRegistration: 'Complete Registration',
completing: 'Completing registration…',
completeRegistrationFailed: 'Registration failed. Please check your invitation code and try again.',
createAccountTitle: 'Create DingTalk Account',
registrationDisabledRedirectToBind: 'New account registration is currently disabled. Please bind to your existing account with its email and password.',
error: {
title: 'DingTalk Sign-in Failed',
csrf: 'Login session expired, please scan again',
corp_rejected: 'Your DingTalk account is not part of this organization. Please contact administrator',
dingtalk_not_enabled: 'DingTalk login is not enabled',
upstream_error: 'DingTalk service is temporarily unavailable. Please try again later',
missing_browser_session: 'Browser session lost. Please login again',
missing_params: 'Request parameters are incomplete',
invalid_state: 'Invalid login state',
provider_error: 'DingTalk authorization failed',
session_error: 'Failed to create session. Please retry',
retry: 'Retry Login'
}
},
emailOAuth: {
signIn: 'Continue with {providerName}'
},
@@ -524,6 +552,7 @@ export default {
wechatNotConfigured: 'WeChat sign-in is not configured yet.'
},
linuxdoCallbackPageTitle: 'LinuxDo Sign-In Callback',
dingtalkCallbackPageTitle: 'DingTalk Sign-In Callback',
oidcCallbackPageTitle: 'OIDC Sign-In Callback',
oauthCallbackPageTitle: 'OAuth Callback',
wechatProviderName: 'WeChat',
@@ -1244,6 +1273,7 @@ export default {
providers: {
email: 'Email',
linuxdo: 'LinuxDo',
dingtalk: 'DingTalk',
oidc: '{providerName}',
wechat: 'WeChat',
},
@@ -5268,6 +5298,47 @@ export default {
quickSetCopy: 'Generate & Copy (current site)',
redirectUrlSetAndCopied: 'Redirect URL generated and copied to clipboard'
},
dingtalk: {
title: 'DingTalk Login',
description: 'Configure DingTalk OAuth for Sub2API end-user login',
enable: 'Enable DingTalk Login (Internal Corporate App)',
enableHint: 'Show DingTalk login on the login/register pages',
clientId: 'Client ID (AppKey)',
clientIdPlaceholder: 'e.g., dingxxxxxxxxxxxxxxxx',
clientIdHint: 'Get this from the DingTalk Open Platform app details',
clientSecret: 'Client Secret (AppSecret)',
clientSecretPlaceholder: '********',
clientSecretHint: 'Used by backend to exchange tokens (keep it secret)',
clientSecretConfiguredPlaceholder: '********',
clientSecretConfiguredHint: 'Secret configured. Leave empty to keep the current value.',
redirectUrl: 'Redirect URL',
redirectUrlPlaceholder: 'https://your-domain.com/api/v1/auth/oauth/dingtalk/callback',
redirectUrlHint:
'Must match the redirect URL configured in DingTalk Open Platform (must be an absolute http(s) URL)',
corpPolicy: {
label: 'Corp Restriction Policy',
hint: 'Control which DingTalk accounts (orgs) are allowed to sign in',
none: 'No restriction (all DingTalk accounts allowed)',
internalOnly: 'Internal only (single corp)'
},
bypassRegistration: 'Enable DingTalk signup',
bypassRegistrationHint: 'Allow new users to register via DingTalk even when public registration is disabled.',
syncDisplayName: 'Sync DingTalk display name',
syncDisplayNameHint: 'Overwrite username with the DingTalk staff name on each login (also stored in the dingtalk_name attribute).',
syncCorpEmail: 'Sync corporate email',
syncCorpEmailHint: 'Write the DingTalk corporate email to the dingtalk_email attribute on each login (does not change the login email).',
syncCorpEmailPermissionHint: 'Requires the OAPI permission "Personal info incl. email (fieldEmail)" to be granted to the app on the DingTalk open platform, otherwise OAPI will not return the email field.',
syncDept: 'Sync department',
syncDeptHint: 'Write the full DingTalk department path to the dingtalk_department attribute on each login (fetched live each time).',
syncDeptPermissionHint: 'Requires the OAPI "Department info read (qyapi_get_department_list)" permission to be granted to the app on the DingTalk open platform, otherwise the department path cannot be resolved.',
syncDisplayNameTarget: 'Attribute key',
syncDisplayNameTargetHint: 'Defaults to dingtalk_name / DingTalk Name. Saving settings auto-creates the user attribute by the key and display name above (existing definition only has its display name synced).',
syncCorpEmailTarget: 'Attribute key',
syncCorpEmailTargetHint: 'Defaults to dingtalk_email / DingTalk Corporate Email. Saving settings auto-creates the user attribute by the key and display name above (existing definition only has its display name synced).',
syncDeptTarget: 'Attribute key',
syncDeptTargetHint: 'Defaults to dingtalk_department / DingTalk Department. Saving settings auto-creates the user attribute by the key and display name above (existing definition only has its display name synced).',
syncAttrDisplayName: 'Display name'
},
oidc: {
title: 'OIDC Login',
description: 'Configure a standard OIDC provider (for example Keycloak)',
+71 -1
View File
@@ -460,7 +460,7 @@ export default {
invitationCodeInvalid: '邀请码无效或已被使用',
invitationCodeValidating: '正在验证邀请码...',
invitationCodeInvalidCannotRegister: '邀请码无效,请检查后重试',
oauthOrContinue: '或使用邮箱密码继续',
oauthOrContinue: '或使用其他继续',
linuxdo: {
signIn: '使用 Linux.do 登录',
orContinue: '或使用邮箱密码继续',
@@ -475,6 +475,34 @@ export default {
completing: '正在完成注册...',
completeRegistrationFailed: '注册失败,请检查邀请码后重试。'
},
dingtalk: {
signIn: '钉钉登录',
callbackTitle: '正在完成钉钉登录',
callbackProcessing: '正在验证钉钉登录信息,请稍候...',
callbackHint: '如果页面未自动跳转,请返回登录页重试。',
callbackMissingToken: '登录信息缺失,请返回重试。',
backToLogin: '返回登录',
invitationRequired: '该钉钉账号尚未注册,站点已开启邀请码注册,请输入邀请码以完成注册。',
invalidPendingToken: '注册凭证已失效,请重新使用钉钉登录。',
completeRegistration: '完成注册',
completing: '正在完成注册...',
completeRegistrationFailed: '注册失败,请检查邀请码后重试。',
createAccountTitle: '创建钉钉账户',
registrationDisabledRedirectToBind: '当前已禁止注册新账户,请使用已有账户邮箱和密码绑定钉钉登录',
error: {
title: '钉钉登录失败',
csrf: '登录会话已过期,请重新扫码登录',
corp_rejected: '您的钉钉账号不属于本企业,请联系管理员',
dingtalk_not_enabled: '钉钉登录暂未启用',
upstream_error: '钉钉服务暂时不可用,请稍后重试',
missing_browser_session: '浏览器会话丢失,请重新登录',
missing_params: '请求参数不完整',
invalid_state: '登录状态异常',
provider_error: '钉钉授权失败',
session_error: '会话创建失败,请重试',
retry: '重新登录'
}
},
emailOAuth: {
signIn: '使用 {providerName} 登录'
},
@@ -522,6 +550,7 @@ export default {
wechatNotConfigured: '微信登录尚未配置。'
},
linuxdoCallbackPageTitle: 'LinuxDo 登录回调',
dingtalkCallbackPageTitle: '钉钉登录回调',
oidcCallbackPageTitle: 'OIDC 登录回调',
oauthCallbackPageTitle: 'OAuth 回调',
wechatProviderName: '微信',
@@ -1248,6 +1277,7 @@ export default {
providers: {
email: '邮箱',
linuxdo: 'LinuxDo',
dingtalk: '钉钉',
oidc: '{providerName}',
wechat: '微信',
},
@@ -5431,6 +5461,46 @@ export default {
quickSetCopy: '使用当前站点生成并复制',
redirectUrlSetAndCopied: '已使用当前站点生成回调地址并复制到剪贴板'
},
dingtalk: {
title: '钉钉登录',
description: '配置钉钉 OAuth,用于 Sub2API 用户登录',
enable: '启用钉钉登录-企业内部应用',
enableHint: '在登录/注册页面显示钉钉登录入口',
clientId: 'Client ID(AppKey)',
clientIdPlaceholder: '例如:dingxxxxxxxxxxxxxxxx',
clientIdHint: '从钉钉开放平台应用详情页获取',
clientSecret: 'Client Secret(AppSecret)',
clientSecretPlaceholder: '********',
clientSecretHint: '用于后端交换 token(请保密)',
clientSecretConfiguredPlaceholder: '********',
clientSecretConfiguredHint: '密钥已配置,留空以保留当前值。',
redirectUrl: '回调地址(Redirect URL)',
redirectUrlPlaceholder: 'https://your-domain.com/api/v1/auth/oauth/dingtalk/callback',
redirectUrlHint: '需与钉钉开放平台中配置的回调地址一致(必须是 http(s) 完整 URL)',
corpPolicy: {
label: '企业限制策略',
hint: '控制哪些钉钉账号(企业)可以登录',
none: '不限制(所有钉钉账号均可登录)',
internalOnly: '仅本企业(Internal Only)'
},
bypassRegistration: '开放钉钉注册',
bypassRegistrationHint: '即使「开放注册」关闭时也可以通过钉钉登录来注册',
syncDisplayName: '同步钉钉姓名',
syncDisplayNameHint: '登录时将钉钉姓名写入 username 字段(同时记录到 dingtalk_name 属性)',
syncCorpEmail: '同步企业邮箱',
syncCorpEmailHint: '登录时将钉钉企业邮箱写入 dingtalk_email 属性(不影响登录邮箱)',
syncCorpEmailPermissionHint: '需在钉钉开放平台 → 应用 → 权限管理中为本应用申请「邮箱等个人信息(fieldEmail)」权限,否则 OAPI 不会返回企业邮箱字段',
syncDept: '同步部门',
syncDeptHint: '登录时将钉钉首个部门完整路径写入 dingtalk_department 属性(每次登录实时拉取)',
syncDeptPermissionHint: '需在钉钉开放平台 → 应用 → 权限管理中为本应用申请「通讯录部门信息读权限(qyapi_get_department_list)」,否则无法递归出部门路径',
syncDisplayNameTarget: '属性键',
syncDisplayNameTargetHint: '默认 dingtalk_name / 钉钉姓名;保存设置时按上述属性键和显示名称自动创建用户属性(已存在则仅同步显示名称)',
syncCorpEmailTarget: '属性键',
syncCorpEmailTargetHint: '默认 dingtalk_email / 钉钉企业邮箱;保存设置时按上述属性键和显示名称自动创建用户属性(已存在则仅同步显示名称)',
syncDeptTarget: '属性键',
syncDeptTargetHint: '默认 dingtalk_department / 钉钉部门;保存设置时按上述属性键和显示名称自动创建用户属性(已存在则仅同步显示名称)',
syncAttrDisplayName: '显示名称'
},
oidc: {
title: 'OIDC 登录',
description: '配置标准 OIDC Provider(例如 Keycloak)',
+21
View File
@@ -106,6 +106,25 @@ const routes: RouteRecordRaw[] = [
titleKey: 'auth.wechatPaymentCallbackPageTitle'
}
},
{
path: '/auth/dingtalk/callback',
name: 'DingTalkOAuthCallback',
component: () => import('@/views/auth/DingTalkCallbackView.vue'),
meta: {
requiresAuth: false,
title: 'DingTalk OAuth Callback',
titleKey: 'auth.dingtalkCallbackPageTitle'
}
},
{
path: '/auth/dingtalk/email-completion',
name: 'dingtalk-email-completion',
component: () => import('@/views/auth/DingTalkEmailCompletionView.vue'),
meta: {
requiresAuth: false,
title: 'DingTalk Email Completion'
}
},
{
path: '/auth/oidc/callback',
name: 'OIDCOAuthCallback',
@@ -672,6 +691,8 @@ const BACKEND_MODE_ALLOWED_PATHS = ['/login', '/key-usage', '/setup', '/payment/
const BACKEND_MODE_CALLBACK_PATHS = [
'/auth/callback',
'/auth/linuxdo/callback',
'/auth/dingtalk/callback',
'/auth/dingtalk/email-completion',
'/auth/oidc/callback',
'/auth/wechat/callback',
'/auth/wechat/payment/callback',
+2 -1
View File
@@ -34,7 +34,7 @@ export interface NotifyEmailEntry {
// ==================== User & Auth Types ====================
export type UserAuthProvider = 'email' | 'linuxdo' | 'oidc' | 'wechat' | 'github' | 'google'
export type UserAuthProvider = 'email' | 'linuxdo' | 'oidc' | 'wechat' | 'github' | 'google' | 'dingtalk'
export interface UserAuthBindingStatus {
bound?: boolean
@@ -215,6 +215,7 @@ export interface PublicSettings {
custom_menu_items: CustomMenuItem[]
custom_endpoints: CustomEndpoint[]
linuxdo_oauth_enabled: boolean
dingtalk_oauth_enabled?: boolean
wechat_oauth_enabled: boolean
wechat_oauth_open_enabled?: boolean
wechat_oauth_mp_enabled?: boolean
+350
View File
@@ -2333,6 +2333,294 @@
</div>
</div>
<!-- DingTalk Connect OAuth 登录 -->
<div class="card">
<div
class="border-b border-gray-100 px-6 py-4 dark:border-dark-700"
>
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
{{ t("admin.settings.dingtalk.title") }}
</h2>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
{{ t("admin.settings.dingtalk.description") }}
</p>
</div>
<div class="space-y-5 p-6">
<div class="flex items-center justify-between">
<div>
<label class="font-medium text-gray-900 dark:text-white">{{
t("admin.settings.dingtalk.enable")
}}</label>
<p class="text-sm text-gray-500 dark:text-gray-400">
{{ t("admin.settings.dingtalk.enableHint") }}
</p>
</div>
<Toggle v-model="form.dingtalk_connect_enabled" />
</div>
<div
v-if="form.dingtalk_connect_enabled"
class="border-t border-gray-100 pt-4 dark:border-dark-700"
>
<div class="grid grid-cols-1 gap-6">
<div>
<label
class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"
>
{{ t("admin.settings.dingtalk.clientId") }}
</label>
<input
v-model="form.dingtalk_connect_client_id"
type="text"
class="input font-mono text-sm"
:placeholder="
t('admin.settings.dingtalk.clientIdPlaceholder')
"
/>
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
{{ t("admin.settings.dingtalk.clientIdHint") }}
</p>
</div>
<div>
<label
class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"
>
{{ t("admin.settings.dingtalk.clientSecret") }}
</label>
<input
v-model="form.dingtalk_connect_client_secret"
type="password"
class="input font-mono text-sm"
:placeholder="
form.dingtalk_connect_client_secret_configured
? t(
'admin.settings.dingtalk.clientSecretConfiguredPlaceholder',
)
: t('admin.settings.dingtalk.clientSecretPlaceholder')
"
/>
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
{{
form.dingtalk_connect_client_secret_configured
? t(
"admin.settings.dingtalk.clientSecretConfiguredHint",
)
: t("admin.settings.dingtalk.clientSecretHint")
}}
</p>
</div>
<div>
<label
class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"
>
{{ t("admin.settings.dingtalk.redirectUrl") }}
</label>
<input
v-model="form.dingtalk_connect_redirect_url"
type="url"
class="input font-mono text-sm"
:placeholder="
t('admin.settings.dingtalk.redirectUrlPlaceholder')
"
/>
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
{{ t("admin.settings.dingtalk.redirectUrlHint") }}
</p>
</div>
<!-- Corp Restriction Policy -->
<div class="border-t border-gray-100 pt-4 dark:border-dark-700">
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t("admin.settings.dingtalk.corpPolicy.label") }}
</label>
<p class="mb-3 text-xs text-gray-500 dark:text-gray-400">
{{ t("admin.settings.dingtalk.corpPolicy.hint") }}
</p>
<div class="space-y-2">
<label class="flex cursor-pointer items-center gap-3">
<input
v-model="form.dingtalk_connect_corp_restriction_policy"
type="radio"
value="none"
class="h-4 w-4 text-primary-600"
/>
<span class="text-sm text-gray-700 dark:text-gray-300">
{{ t("admin.settings.dingtalk.corpPolicy.none") }}
</span>
</label>
<label class="flex cursor-pointer items-center gap-3">
<input
v-model="form.dingtalk_connect_corp_restriction_policy"
type="radio"
value="internal_only"
class="h-4 w-4 text-primary-600"
/>
<span class="text-sm text-gray-700 dark:text-gray-300">
{{ t("admin.settings.dingtalk.corpPolicy.internalOnly") }}
</span>
</label>
</div>
</div>
<!-- bypass_registration toggle(仅 internal_only 模式下可见可用) -->
<div
v-if="form.dingtalk_connect_corp_restriction_policy === 'internal_only'"
class="flex items-center justify-between pt-4 border-t border-gray-100 dark:border-dark-700"
>
<div>
<label class="font-medium text-gray-900 dark:text-white">{{
t("admin.settings.dingtalk.bypassRegistration")
}}</label>
<p class="text-sm text-gray-500 dark:text-gray-400">
{{ t("admin.settings.dingtalk.bypassRegistrationHint") }}
</p>
</div>
<Toggle v-model="form.dingtalk_connect_bypass_registration" />
</div>
<!-- 身份同步开关(仅 internal_only 模式下可见) -->
<div
v-if="form.dingtalk_connect_corp_restriction_policy === 'internal_only'"
class="pt-4 border-t border-gray-100 dark:border-dark-700 space-y-2"
>
<div class="flex items-center justify-between">
<div>
<label class="font-medium text-gray-900 dark:text-white">{{
t("admin.settings.dingtalk.syncDisplayName")
}}</label>
<p class="text-sm text-gray-500 dark:text-gray-400">
{{ t("admin.settings.dingtalk.syncDisplayNameHint") }}
</p>
</div>
<Toggle v-model="form.dingtalk_connect_sync_display_name" />
</div>
<div v-if="form.dingtalk_connect_sync_display_name" class="space-y-2">
<div class="flex items-center gap-2">
<label class="text-sm text-gray-600 dark:text-gray-400 whitespace-nowrap min-w-[5rem]">
{{ t("admin.settings.dingtalk.syncDisplayNameTarget") }}
</label>
<input
v-model="form.dingtalk_connect_sync_display_name_attr_key"
type="text"
placeholder="dingtalk_name"
class="input text-sm flex-1 max-w-xs"
/>
</div>
<div class="flex items-center gap-2">
<label class="text-sm text-gray-600 dark:text-gray-400 whitespace-nowrap min-w-[5rem]">
{{ t("admin.settings.dingtalk.syncAttrDisplayName") }}
</label>
<input
v-model="form.dingtalk_connect_sync_display_name_attr_name"
type="text"
placeholder="钉钉姓名"
class="input text-sm flex-1 max-w-xs"
/>
</div>
</div>
<p v-if="form.dingtalk_connect_sync_display_name" class="text-xs text-gray-400 dark:text-gray-500">
{{ t("admin.settings.dingtalk.syncDisplayNameTargetHint") }}
</p>
</div>
<div
v-if="form.dingtalk_connect_corp_restriction_policy === 'internal_only'"
class="pt-4 border-t border-gray-100 dark:border-dark-700 space-y-2"
>
<div class="flex items-center justify-between">
<div>
<label class="font-medium text-gray-900 dark:text-white">{{
t("admin.settings.dingtalk.syncCorpEmail")
}}</label>
<p class="text-sm text-gray-500 dark:text-gray-400">
{{ t("admin.settings.dingtalk.syncCorpEmailHint") }}
</p>
<p class="text-xs text-amber-600 dark:text-amber-400 mt-1">
{{ t("admin.settings.dingtalk.syncCorpEmailPermissionHint") }}
</p>
</div>
<Toggle v-model="form.dingtalk_connect_sync_corp_email" />
</div>
<div v-if="form.dingtalk_connect_sync_corp_email" class="space-y-2">
<div class="flex items-center gap-2">
<label class="text-sm text-gray-600 dark:text-gray-400 whitespace-nowrap min-w-[5rem]">
{{ t("admin.settings.dingtalk.syncCorpEmailTarget") }}
</label>
<input
v-model="form.dingtalk_connect_sync_corp_email_attr_key"
type="text"
placeholder="dingtalk_email"
class="input text-sm flex-1 max-w-xs"
/>
</div>
<div class="flex items-center gap-2">
<label class="text-sm text-gray-600 dark:text-gray-400 whitespace-nowrap min-w-[5rem]">
{{ t("admin.settings.dingtalk.syncAttrDisplayName") }}
</label>
<input
v-model="form.dingtalk_connect_sync_corp_email_attr_name"
type="text"
placeholder="钉钉企业邮箱"
class="input text-sm flex-1 max-w-xs"
/>
</div>
</div>
<p v-if="form.dingtalk_connect_sync_corp_email" class="text-xs text-gray-400 dark:text-gray-500">
{{ t("admin.settings.dingtalk.syncCorpEmailTargetHint") }}
</p>
</div>
<div
v-if="form.dingtalk_connect_corp_restriction_policy === 'internal_only'"
class="pt-4 border-t border-gray-100 dark:border-dark-700 space-y-2"
>
<div class="flex items-center justify-between">
<div>
<label class="font-medium text-gray-900 dark:text-white">{{
t("admin.settings.dingtalk.syncDept")
}}</label>
<p class="text-sm text-gray-500 dark:text-gray-400">
{{ t("admin.settings.dingtalk.syncDeptHint") }}
</p>
<p class="text-xs text-amber-600 dark:text-amber-400 mt-1">
{{ t("admin.settings.dingtalk.syncDeptPermissionHint") }}
</p>
</div>
<Toggle v-model="form.dingtalk_connect_sync_dept" />
</div>
<div v-if="form.dingtalk_connect_sync_dept" class="space-y-2">
<div class="flex items-center gap-2">
<label class="text-sm text-gray-600 dark:text-gray-400 whitespace-nowrap min-w-[5rem]">
{{ t("admin.settings.dingtalk.syncDeptTarget") }}
</label>
<input
v-model="form.dingtalk_connect_sync_dept_attr_key"
type="text"
placeholder="dingtalk_department"
class="input text-sm flex-1 max-w-xs"
/>
</div>
<div class="flex items-center gap-2">
<label class="text-sm text-gray-600 dark:text-gray-400 whitespace-nowrap min-w-[5rem]">
{{ t("admin.settings.dingtalk.syncAttrDisplayName") }}
</label>
<input
v-model="form.dingtalk_connect_sync_dept_attr_name"
type="text"
placeholder="钉钉部门"
class="input text-sm flex-1 max-w-xs"
/>
</div>
</div>
<p v-if="form.dingtalk_connect_sync_dept" class="text-xs text-gray-400 dark:text-gray-500">
{{ t("admin.settings.dingtalk.syncDeptTargetHint") }}
</p>
</div>
</div>
</div>
</div>
</div>
<!-- Generic OIDC OAuth 登录 -->
<div class="card">
<div
@@ -6417,6 +6705,7 @@ type SettingsForm = Omit<
smtp_password: string;
turnstile_secret_key: string;
linuxdo_connect_client_secret: string;
dingtalk_connect_client_secret: string;
wechat_connect_app_secret: string;
wechat_connect_open_app_secret: string;
wechat_connect_mp_app_secret: string;
@@ -6518,6 +6807,24 @@ const form = reactive<SettingsForm>({
linuxdo_connect_client_secret: "",
linuxdo_connect_client_secret_configured: false,
linuxdo_connect_redirect_url: "",
// DingTalk Connect OAuth 登录
dingtalk_connect_enabled: false,
dingtalk_connect_client_id: "",
dingtalk_connect_client_secret: "",
dingtalk_connect_client_secret_configured: false,
dingtalk_connect_redirect_url: "",
dingtalk_connect_corp_restriction_policy: "none",
dingtalk_connect_internal_corp_id: "",
dingtalk_connect_bypass_registration: false,
dingtalk_connect_sync_corp_email: false,
dingtalk_connect_sync_display_name: false,
dingtalk_connect_sync_dept: false,
dingtalk_connect_sync_corp_email_attr_key: "dingtalk_email",
dingtalk_connect_sync_display_name_attr_key: "dingtalk_name",
dingtalk_connect_sync_dept_attr_key: "dingtalk_department",
dingtalk_connect_sync_corp_email_attr_name: "钉钉企业邮箱",
dingtalk_connect_sync_display_name_attr_name: "钉钉姓名",
dingtalk_connect_sync_dept_attr_name: "钉钉部门",
wechat_connect_enabled: false,
wechat_connect_app_id: "",
wechat_connect_app_secret: "",
@@ -6658,6 +6965,14 @@ const authSourceDefaultsMeta = computed(() => [
"Applied on first signup or first bind through a verified Google email.",
),
},
{
source: "dingtalk" as AuthSourceType,
title: "钉钉",
description: localText(
"通过钉钉首次注册或首次绑定时应用。",
"Applied on first signup or first bind through DingTalk.",
),
},
]);
// Proxies for web search emulation ProxySelector
@@ -7241,6 +7556,7 @@ async function loadSettings() {
smtpPasswordManuallyEdited.value = false;
form.turnstile_secret_key = "";
form.linuxdo_connect_client_secret = "";
form.dingtalk_connect_client_secret = "";
form.github_oauth_client_secret = "";
form.google_oauth_client_secret = "";
form.wechat_connect_app_secret = "";
@@ -7593,6 +7909,24 @@ async function saveSettings() {
linuxdo_connect_client_secret:
form.linuxdo_connect_client_secret || undefined,
linuxdo_connect_redirect_url: form.linuxdo_connect_redirect_url,
dingtalk_connect_enabled: form.dingtalk_connect_enabled,
dingtalk_connect_client_id: form.dingtalk_connect_client_id,
dingtalk_connect_client_secret:
form.dingtalk_connect_client_secret || undefined,
dingtalk_connect_redirect_url: form.dingtalk_connect_redirect_url,
dingtalk_connect_corp_restriction_policy:
form.dingtalk_connect_corp_restriction_policy,
dingtalk_connect_internal_corp_id: form.dingtalk_connect_internal_corp_id,
dingtalk_connect_bypass_registration: form.dingtalk_connect_bypass_registration,
dingtalk_connect_sync_corp_email: form.dingtalk_connect_sync_corp_email,
dingtalk_connect_sync_display_name: form.dingtalk_connect_sync_display_name,
dingtalk_connect_sync_dept: form.dingtalk_connect_sync_dept,
dingtalk_connect_sync_corp_email_attr_key: form.dingtalk_connect_sync_corp_email_attr_key,
dingtalk_connect_sync_display_name_attr_key: form.dingtalk_connect_sync_display_name_attr_key,
dingtalk_connect_sync_dept_attr_key: form.dingtalk_connect_sync_dept_attr_key,
dingtalk_connect_sync_corp_email_attr_name: form.dingtalk_connect_sync_corp_email_attr_name,
dingtalk_connect_sync_display_name_attr_name: form.dingtalk_connect_sync_display_name_attr_name,
dingtalk_connect_sync_dept_attr_name: form.dingtalk_connect_sync_dept_attr_name,
wechat_connect_enabled: form.wechat_connect_enabled,
wechat_connect_app_id:
form.wechat_connect_open_app_id ||
@@ -7775,6 +8109,7 @@ async function saveSettings() {
smtpPasswordManuallyEdited.value = false;
form.turnstile_secret_key = "";
form.linuxdo_connect_client_secret = "";
form.dingtalk_connect_client_secret = "";
form.github_oauth_client_secret = "";
form.google_oauth_client_secret = "";
form.wechat_connect_app_secret = "";
@@ -8992,6 +9327,21 @@ watch(
}
},
);
// bypass_registration 与身份同步三开关仅在 internal_only 模式下生效。切换 policy 到其它值时,
// 立即把相关字段重置为 false,避免保存请求里残留旧值。后端 admin handler 与
// 配置加载层都有 coerce 兜底,这里是 UX 层的同步而非安全防线。
watch(
() => form.dingtalk_connect_corp_restriction_policy,
(policy) => {
if (policy !== "internal_only") {
if (form.dingtalk_connect_bypass_registration) form.dingtalk_connect_bypass_registration = false;
if (form.dingtalk_connect_sync_corp_email) form.dingtalk_connect_sync_corp_email = false;
if (form.dingtalk_connect_sync_display_name) form.dingtalk_connect_sync_display_name = false;
if (form.dingtalk_connect_sync_dept) form.dingtalk_connect_sync_dept = false;
}
},
);
</script>
<style scoped>
@@ -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>
+9
View File
@@ -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
+3
View File
@@ -7,6 +7,7 @@
<ProfileInfoCard
:user="user"
:linuxdo-enabled="linuxdoOAuthEnabled"
:dingtalk-enabled="dingtalkOAuthEnabled"
:oidc-enabled="oidcOAuthEnabled"
:oidc-provider-name="oidcOAuthProviderName"
:wechat-enabled="wechatOAuthEnabled"
@@ -69,6 +70,7 @@ const contactInfo = ref('')
const balanceLowNotifyEnabled = ref(false)
const systemDefaultThreshold = ref(0)
const linuxdoOAuthEnabled = ref(false)
const dingtalkOAuthEnabled = ref(false)
const wechatOAuthEnabled = ref(false)
const wechatOAuthOpenEnabled = ref<boolean | undefined>(undefined)
const wechatOAuthMPEnabled = ref<boolean | undefined>(undefined)
@@ -89,6 +91,7 @@ onMounted(async () => {
balanceLowNotifyEnabled.value = settings.balance_low_notify_enabled ?? false
systemDefaultThreshold.value = settings.balance_low_notify_threshold ?? 0
linuxdoOAuthEnabled.value = settings.linuxdo_oauth_enabled ?? false
dingtalkOAuthEnabled.value = settings.dingtalk_oauth_enabled ?? false
wechatOAuthEnabled.value = isWeChatWebOAuthEnabled(settings)
wechatOAuthOpenEnabled.value = typeof settings.wechat_oauth_open_enabled === 'boolean'
? settings.wechat_oauth_open_enabled