fix frontend wechat oauth capability recovery

This commit is contained in:
IanShaw027
2026-04-21 01:48:23 +08:00
parent 7c6491c2d3
commit cd0338fbae
8 changed files with 219 additions and 32 deletions

View File

@@ -352,6 +352,7 @@ export async function getPublicSettings(): Promise<PublicSettings> {
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

View File

@@ -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()
}

View File

@@ -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<WeChatOAuthPublicSettings | null>(() => {
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<WeChatOAuthPublicSettings | null>(() => {
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') {

View File

@@ -133,6 +133,8 @@
:oidc-enabled="oidcEnabled"
:oidc-provider-name="oidcProviderName"
:wechat-enabled="wechatEnabled"
:wechat-open-enabled="wechatOpenEnabled"
:wechat-mp-enabled="wechatMpEnabled"
/>
</div>
</div>
@@ -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,
}
)

View File

@@ -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)
})
})

View File

@@ -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<void> {
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

View File

@@ -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: '<div><slot /></div>' },
Icon: true,
RouterLink: { template: '<a><slot /></a>' },
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: '<div><slot /></div>' },
Icon: true,
RouterLink: { template: '<a><slot /></a>' },
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',

View File

@@ -28,6 +28,8 @@
:oidc-enabled="oidcOAuthEnabled"
:oidc-provider-name="oidcOAuthProviderName"
:wechat-enabled="wechatOAuthEnabled"
:wechat-open-enabled="wechatOAuthOpenEnabled"
:wechat-mp-enabled="wechatOAuthMPEnabled"
/>
<div
@@ -89,6 +91,8 @@ const balanceLowNotifyEnabled = ref(false)
const systemDefaultThreshold = ref(0)
const linuxdoOAuthEnabled = ref(false)
const wechatOAuthEnabled = ref(false)
const wechatOAuthOpenEnabled = ref<boolean | undefined>(undefined)
const wechatOAuthMPEnabled = ref<boolean | undefined>(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'
})