From cd0338fbae08b2641f17ad1d4b29ebabffca64fc Mon Sep 17 00:00:00 2001 From: IanShaw027 Date: Tue, 21 Apr 2026 01:48:23 +0800 Subject: [PATCH] fix frontend wechat oauth capability recovery --- frontend/src/api/auth.ts | 33 +++++++++ frontend/src/api/user.ts | 4 +- .../ProfileIdentityBindingsSection.vue | 16 ++--- .../user/profile/ProfileInfoCard.vue | 6 ++ .../ProfileIdentityBindingsSection.spec.ts | 72 +++++++++++++++++++ .../src/views/auth/WechatCallbackView.vue | 55 ++++++++------ .../auth/__tests__/WechatCallbackView.spec.ts | 55 ++++++++++++++ frontend/src/views/user/ProfileView.vue | 10 +++ 8 files changed, 219 insertions(+), 32 deletions(-) diff --git a/frontend/src/api/auth.ts b/frontend/src/api/auth.ts index 383bdcce..9cf0c7ad 100644 --- a/frontend/src/api/auth.ts +++ b/frontend/src/api/auth.ts @@ -352,6 +352,7 @@ export async function getPublicSettings(): Promise { export type WeChatOAuthMode = 'open' | 'mp' export type WeChatOAuthUnavailableReason = | 'not_configured' + | 'capability_unknown' | 'external_browser_required' | 'wechat_browser_required' @@ -369,6 +370,16 @@ export type WeChatOAuthPublicSettings = { wechat_oauth_mp_enabled?: boolean } +export function hasExplicitWeChatOAuthCapabilities( + settings: WeChatOAuthPublicSettings | null | undefined, +): settings is WeChatOAuthPublicSettings & { + wechat_oauth_open_enabled: boolean + wechat_oauth_mp_enabled: boolean +} { + return typeof settings?.wechat_oauth_open_enabled === 'boolean' + && typeof settings?.wechat_oauth_mp_enabled === 'boolean' +} + export function resolveWeChatOAuthStart( settings: WeChatOAuthPublicSettings | null | undefined, userAgent?: string @@ -404,6 +415,28 @@ export function resolveWeChatOAuthStart( return { mode: null, openEnabled, mpEnabled, isWeChatBrowser, unavailableReason: 'not_configured' } } +export function resolveWeChatOAuthStartStrict( + settings: WeChatOAuthPublicSettings | null | undefined, + userAgent?: string, +): ResolvedWeChatOAuthStart { + const normalizedUserAgent = (userAgent + ?? (typeof navigator !== 'undefined' ? navigator.userAgent : '') + ?? '').trim() + const isWeChatBrowser = /MicroMessenger/i.test(normalizedUserAgent) + + if (!hasExplicitWeChatOAuthCapabilities(settings)) { + return { + mode: null, + openEnabled: false, + mpEnabled: false, + isWeChatBrowser, + unavailableReason: 'capability_unknown', + } + } + + return resolveWeChatOAuthStart(settings, normalizedUserAgent) +} + /** * Send verification code to email * @param request - Email and optional Turnstile token diff --git a/frontend/src/api/user.ts b/frontend/src/api/user.ts index 41ebea05..7b498303 100644 --- a/frontend/src/api/user.ts +++ b/frontend/src/api/user.ts @@ -5,8 +5,8 @@ import { apiClient } from './client' import { + resolveWeChatOAuthStartStrict, prepareOAuthBindAccessTokenCookie, - resolveWeChatOAuthStart, type WeChatOAuthPublicSettings, } from './auth' import type { User, ChangePasswordRequest, NotifyEmailEntry, UserAuthProvider } from '@/types' @@ -107,7 +107,7 @@ function resolveWeChatOAuthBindingMode( settings?: WeChatOAuthPublicSettings | null ): 'open' | 'mp' | null { if (settings) { - return resolveWeChatOAuthStart(settings).mode + return resolveWeChatOAuthStartStrict(settings).mode } return resolveWeChatOAuthMode() } diff --git a/frontend/src/components/user/profile/ProfileIdentityBindingsSection.vue b/frontend/src/components/user/profile/ProfileIdentityBindingsSection.vue index d3d9814d..76c94ba1 100644 --- a/frontend/src/components/user/profile/ProfileIdentityBindingsSection.vue +++ b/frontend/src/components/user/profile/ProfileIdentityBindingsSection.vue @@ -52,7 +52,11 @@ import { computed } from 'vue' import { useI18n } from 'vue-i18n' import { useRoute } from 'vue-router' -import { resolveWeChatOAuthStart, type WeChatOAuthPublicSettings } from '@/api/auth' +import { + hasExplicitWeChatOAuthCapabilities, + resolveWeChatOAuthStartStrict, + type WeChatOAuthPublicSettings, +} from '@/api/auth' import { startOAuthBinding } from '@/api/user' import { useAppStore } from '@/stores' import type { User, UserAuthBindingStatus, UserAuthProvider } from '@/types' @@ -82,15 +86,11 @@ const route = useRoute() const appStore = useAppStore() const wechatOAuthSettings = computed(() => { - if (appStore.cachedPublicSettings) { + if (hasExplicitWeChatOAuthCapabilities(appStore.cachedPublicSettings)) { return appStore.cachedPublicSettings } - if ( - typeof props.wechatEnabled === 'boolean' || - typeof props.wechatOpenEnabled === 'boolean' || - typeof props.wechatMpEnabled === 'boolean' - ) { + if (typeof props.wechatOpenEnabled === 'boolean' && typeof props.wechatMpEnabled === 'boolean') { return { wechat_oauth_enabled: props.wechatEnabled, wechat_oauth_open_enabled: props.wechatOpenEnabled, @@ -101,7 +101,7 @@ const wechatOAuthSettings = computed(() => { return null }) -const resolvedWeChatBinding = computed(() => resolveWeChatOAuthStart(wechatOAuthSettings.value)) +const resolvedWeChatBinding = computed(() => resolveWeChatOAuthStartStrict(wechatOAuthSettings.value)) function normalizeBindingStatus(binding: boolean | UserAuthBindingStatus | undefined): boolean | null { if (typeof binding === 'boolean') { diff --git a/frontend/src/components/user/profile/ProfileInfoCard.vue b/frontend/src/components/user/profile/ProfileInfoCard.vue index d6273431..460e5c7f 100644 --- a/frontend/src/components/user/profile/ProfileInfoCard.vue +++ b/frontend/src/components/user/profile/ProfileInfoCard.vue @@ -133,6 +133,8 @@ :oidc-enabled="oidcEnabled" :oidc-provider-name="oidcProviderName" :wechat-enabled="wechatEnabled" + :wechat-open-enabled="wechatOpenEnabled" + :wechat-mp-enabled="wechatMpEnabled" /> @@ -156,12 +158,16 @@ const props = withDefaults( oidcEnabled?: boolean oidcProviderName?: string wechatEnabled?: boolean + wechatOpenEnabled?: boolean + wechatMpEnabled?: boolean }>(), { linuxdoEnabled: false, oidcEnabled: false, oidcProviderName: 'OIDC', wechatEnabled: false, + wechatOpenEnabled: undefined, + wechatMpEnabled: undefined, } ) diff --git a/frontend/src/components/user/profile/__tests__/ProfileIdentityBindingsSection.spec.ts b/frontend/src/components/user/profile/__tests__/ProfileIdentityBindingsSection.spec.ts index 7db2ecd7..4e194a39 100644 --- a/frontend/src/components/user/profile/__tests__/ProfileIdentityBindingsSection.spec.ts +++ b/frontend/src/components/user/profile/__tests__/ProfileIdentityBindingsSection.spec.ts @@ -100,6 +100,8 @@ describe('ProfileIdentityBindingsSection', () => { oidcEnabled: true, oidcProviderName: 'ExampleID', wechatEnabled: true, + wechatOpenEnabled: true, + wechatMpEnabled: false, }, }) @@ -152,4 +154,74 @@ describe('ProfileIdentityBindingsSection', () => { expect(wrapper.find('[data-testid="profile-binding-wechat-action"]').exists()).toBe(false) }) + + it('hides the WeChat bind action when only the legacy aggregate setting is present', () => { + const wrapper = mount(ProfileIdentityBindingsSection, { + global: { + plugins: [pinia], + }, + props: { + user: createUser(), + linuxdoEnabled: false, + oidcEnabled: false, + wechatEnabled: true, + }, + }) + + expect(wrapper.find('[data-testid="profile-binding-wechat-action"]').exists()).toBe(false) + }) + + it('uses explicit cached WeChat capabilities and ignores legacy prop fallbacks', () => { + const appStore = useAppStore() + appStore.cachedPublicSettings = { + registration_enabled: false, + email_verify_enabled: false, + force_email_on_third_party_signup: false, + registration_email_suffix_whitelist: [], + promo_code_enabled: true, + password_reset_enabled: false, + invitation_code_enabled: false, + turnstile_enabled: false, + turnstile_site_key: '', + site_name: 'Sub2API', + site_logo: '', + site_subtitle: '', + api_base_url: '', + contact_info: '', + doc_url: '', + home_content: '', + hide_ccs_import_button: false, + payment_enabled: false, + table_default_page_size: 20, + table_page_size_options: [10, 20, 50, 100], + custom_menu_items: [], + custom_endpoints: [], + linuxdo_oauth_enabled: false, + wechat_oauth_enabled: true, + wechat_oauth_open_enabled: true, + wechat_oauth_mp_enabled: false, + oidc_oauth_enabled: false, + oidc_oauth_provider_name: 'OIDC', + backend_mode_enabled: false, + version: 'test', + balance_low_notify_enabled: false, + account_quota_notify_enabled: false, + balance_low_notify_threshold: 0, + } + appStore.publicSettingsLoaded = true + + const wrapper = mount(ProfileIdentityBindingsSection, { + global: { + plugins: [pinia], + }, + props: { + user: createUser(), + linuxdoEnabled: false, + oidcEnabled: false, + wechatEnabled: true, + }, + }) + + expect(wrapper.find('[data-testid="profile-binding-wechat-action"]').exists()).toBe(true) + }) }) diff --git a/frontend/src/views/auth/WechatCallbackView.vue b/frontend/src/views/auth/WechatCallbackView.vue index a5da35e5..35cd0032 100644 --- a/frontend/src/views/auth/WechatCallbackView.vue +++ b/frontend/src/views/auth/WechatCallbackView.vue @@ -292,12 +292,13 @@ import { completeWeChatOAuthRegistration, exchangePendingOAuthCompletion, getAuthToken, + hasExplicitWeChatOAuthCapabilities, getOAuthCompletionKind, isOAuthLoginCompletion, login2FA, prepareOAuthBindAccessTokenCookie, persistOAuthTokenContext, - resolveWeChatOAuthStart, + resolveWeChatOAuthStartStrict, type OAuthAdoptionDecision, type PendingOAuthExchangeResponse } from '@/api/auth' @@ -368,41 +369,32 @@ function sanitizeRedirectPath(path: string | null | undefined): string { return path } -function resolveWeChatOAuthMode(): 'open' | 'mp' { - if (typeof navigator === 'undefined') { - return 'open' - } - return /MicroMessenger/i.test(navigator.userAgent) ? 'mp' : 'open' -} - -function normalizeWeChatOAuthMode(value: unknown): 'open' | 'mp' | null { - return value === 'open' || value === 'mp' ? value : null -} - async function ensurePublicSettingsLoaded(): Promise { - if (appStore.cachedPublicSettings || appStore.publicSettingsLoaded) { + if (hasExplicitWeChatOAuthCapabilities(appStore.cachedPublicSettings)) { return } - try { - await appStore.fetchPublicSettings() - } catch { - // Fall back to legacy mode selection when public settings are unavailable. + if (appStore.publicSettingsLoaded) { + return } + + await appStore.fetchPublicSettings() } function resolveConfiguredWeChatOAuthMode(): 'open' | 'mp' | null { - if (!appStore.cachedPublicSettings && !appStore.publicSettingsLoaded) { + if (!hasExplicitWeChatOAuthCapabilities(appStore.cachedPublicSettings)) { return null } - return resolveWeChatOAuthStart(appStore.cachedPublicSettings).mode + return resolveWeChatOAuthStartStrict(appStore.cachedPublicSettings).mode } function resolveWeChatOAuthUnavailableMessage(): string { - const resolved = resolveWeChatOAuthStart(appStore.cachedPublicSettings) + const resolved = resolveWeChatOAuthStartStrict(appStore.cachedPublicSettings) switch (resolved.unavailableReason) { + case 'capability_unknown': + return 'WeChat sign-in availability could not be confirmed. Refresh and retry.' case 'external_browser_required': return 'This WeChat sign-in flow is only available in your system browser.' case 'wechat_browser_required': @@ -414,6 +406,17 @@ function resolveWeChatOAuthUnavailableMessage(): string { } } +function resolveRuntimeWeChatOAuthMode(): 'open' | 'mp' { + if (typeof navigator === 'undefined') { + return 'open' + } + return /MicroMessenger/i.test(navigator.userAgent) ? 'mp' : 'open' +} + +function normalizeWeChatOAuthMode(value: unknown): 'open' | 'mp' | null { + return value === 'open' || value === 'mp' ? value : null +} + function resolveRequestedWeChatOAuthMode(): 'open' | 'mp' | null { const configuredMode = resolveConfiguredWeChatOAuthMode() if (configuredMode) { @@ -421,7 +424,11 @@ function resolveRequestedWeChatOAuthMode(): 'open' | 'mp' | null { } const queryMode = normalizeWeChatOAuthMode(route.query.mode) - return queryMode || resolveWeChatOAuthMode() + if (queryMode) { + return queryMode + } + + return resolveRuntimeWeChatOAuthMode() } function resolveRedirectTarget(): string { @@ -786,7 +793,11 @@ async function handleSubmitTotpChallenge() { } onMounted(async () => { - await ensurePublicSettingsLoaded() + try { + await ensurePublicSettingsLoaded() + } catch { + // Binding recovery requires confirmed capability flags. Use the strict guard below. + } if (typeof route.query.email === 'string') { existingAccountEmail.value = route.query.email diff --git a/frontend/src/views/auth/__tests__/WechatCallbackView.spec.ts b/frontend/src/views/auth/__tests__/WechatCallbackView.spec.ts index 7f26f3c8..fed88890 100644 --- a/frontend/src/views/auth/__tests__/WechatCallbackView.spec.ts +++ b/frontend/src/views/auth/__tests__/WechatCallbackView.spec.ts @@ -201,6 +201,61 @@ describe('WechatCallbackView', () => { expect(locationState.current.href).not.toContain('mode=mp') }) + it('falls back to the query mode when capability settings cannot be confirmed', async () => { + routeState.query = { + wechat_bind_existing: '1', + mode: 'mp', + redirect: '/profile', + } + fetchPublicSettingsMock.mockResolvedValue(null) + getAuthTokenMock.mockReturnValue('current-auth-token') + + mount(WechatCallbackView, { + global: { + stubs: { + AuthLayout: { template: '
' }, + Icon: true, + RouterLink: { template: '' }, + transition: false, + }, + }, + }) + + await flushPromises() + + expect(prepareOAuthBindAccessTokenCookieMock).toHaveBeenCalledTimes(1) + expect(locationState.current.href).toContain('mode=mp') + }) + + it('ignores legacy aggregate wechat settings and reuses the query mode during bind recovery', async () => { + routeState.query = { + wechat_bind_existing: '1', + mode: 'open', + redirect: '/profile', + } + appStoreState.cachedPublicSettings = { + wechat_oauth_enabled: true, + } + appStoreState.publicSettingsLoaded = true + getAuthTokenMock.mockReturnValue('current-auth-token') + + mount(WechatCallbackView, { + global: { + stubs: { + AuthLayout: { template: '
' }, + Icon: true, + RouterLink: { template: '' }, + transition: false, + }, + }, + }) + + await flushPromises() + + expect(prepareOAuthBindAccessTokenCookieMock).toHaveBeenCalledTimes(1) + expect(locationState.current.href).toContain('mode=open') + }) + it('does not send adoption decisions during the initial exchange', async () => { exchangePendingOAuthCompletionMock.mockResolvedValue({ access_token: 'access-token', diff --git a/frontend/src/views/user/ProfileView.vue b/frontend/src/views/user/ProfileView.vue index 14d7efea..9e72776f 100644 --- a/frontend/src/views/user/ProfileView.vue +++ b/frontend/src/views/user/ProfileView.vue @@ -28,6 +28,8 @@ :oidc-enabled="oidcOAuthEnabled" :oidc-provider-name="oidcOAuthProviderName" :wechat-enabled="wechatOAuthEnabled" + :wechat-open-enabled="wechatOAuthOpenEnabled" + :wechat-mp-enabled="wechatOAuthMPEnabled" />
(undefined) +const wechatOAuthMPEnabled = ref(undefined) const oidcOAuthEnabled = ref(false) const oidcOAuthProviderName = ref('OIDC') @@ -132,6 +136,12 @@ onMounted(async () => { systemDefaultThreshold.value = settings.balance_low_notify_threshold ?? 0 linuxdoOAuthEnabled.value = settings.linuxdo_oauth_enabled ?? false wechatOAuthEnabled.value = settings.wechat_oauth_enabled ?? false + wechatOAuthOpenEnabled.value = typeof settings.wechat_oauth_open_enabled === 'boolean' + ? settings.wechat_oauth_open_enabled + : undefined + wechatOAuthMPEnabled.value = typeof settings.wechat_oauth_mp_enabled === 'boolean' + ? settings.wechat_oauth_mp_enabled + : undefined oidcOAuthEnabled.value = settings.oidc_oauth_enabled ?? false oidcOAuthProviderName.value = settings.oidc_oauth_provider_name || 'OIDC' })