mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-25 16:00:02 +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:
committed by
QTom
co-authored by
Claude Opus 4.7
parent
b006e36af9
commit
b19da9c7fe
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)',
|
||||
|
||||
@@ -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)',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user