Tighten WeChat OAuth capability mode selection

This commit is contained in:
IanShaw027
2026-04-21 00:46:40 +08:00
parent 12f4af742f
commit 067eb23d8e
15 changed files with 317 additions and 28 deletions

View File

@@ -200,6 +200,8 @@ type PublicSettings struct {
CustomEndpoints []CustomEndpoint `json:"custom_endpoints"` CustomEndpoints []CustomEndpoint `json:"custom_endpoints"`
LinuxDoOAuthEnabled bool `json:"linuxdo_oauth_enabled"` LinuxDoOAuthEnabled bool `json:"linuxdo_oauth_enabled"`
WeChatOAuthEnabled bool `json:"wechat_oauth_enabled"` WeChatOAuthEnabled bool `json:"wechat_oauth_enabled"`
WeChatOAuthOpenEnabled bool `json:"wechat_oauth_open_enabled"`
WeChatOAuthMPEnabled bool `json:"wechat_oauth_mp_enabled"`
OIDCOAuthEnabled bool `json:"oidc_oauth_enabled"` OIDCOAuthEnabled bool `json:"oidc_oauth_enabled"`
OIDCOAuthProviderName string `json:"oidc_oauth_provider_name"` OIDCOAuthProviderName string `json:"oidc_oauth_provider_name"`
SoraClientEnabled bool `json:"sora_client_enabled"` SoraClientEnabled bool `json:"sora_client_enabled"`

View File

@@ -58,6 +58,8 @@ func (h *SettingHandler) GetPublicSettings(c *gin.Context) {
CustomEndpoints: dto.ParseCustomEndpoints(settings.CustomEndpoints), CustomEndpoints: dto.ParseCustomEndpoints(settings.CustomEndpoints),
LinuxDoOAuthEnabled: settings.LinuxDoOAuthEnabled, LinuxDoOAuthEnabled: settings.LinuxDoOAuthEnabled,
WeChatOAuthEnabled: settings.WeChatOAuthEnabled, WeChatOAuthEnabled: settings.WeChatOAuthEnabled,
WeChatOAuthOpenEnabled: settings.WeChatOAuthOpenEnabled,
WeChatOAuthMPEnabled: settings.WeChatOAuthMPEnabled,
OIDCOAuthEnabled: settings.OIDCOAuthEnabled, OIDCOAuthEnabled: settings.OIDCOAuthEnabled,
OIDCOAuthProviderName: settings.OIDCOAuthProviderName, OIDCOAuthProviderName: settings.OIDCOAuthProviderName,
BackendModeEnabled: settings.BackendModeEnabled, BackendModeEnabled: settings.BackendModeEnabled,

View File

@@ -81,3 +81,35 @@ func TestSettingHandler_GetPublicSettings_ExposesForceEmailOnThirdPartySignup(t
require.Equal(t, 0, resp.Code) require.Equal(t, 0, resp.Code)
require.True(t, resp.Data.ForceEmailOnThirdPartySignup) require.True(t, resp.Data.ForceEmailOnThirdPartySignup)
} }
func TestSettingHandler_GetPublicSettings_ExposesWeChatOAuthModeCapabilities(t *testing.T) {
gin.SetMode(gin.TestMode)
t.Setenv("WECHAT_OAUTH_OPEN_APP_ID", "wx-open-app")
t.Setenv("WECHAT_OAUTH_OPEN_APP_SECRET", "wx-open-secret")
t.Setenv("WECHAT_OAUTH_MP_APP_ID", "")
t.Setenv("WECHAT_OAUTH_MP_APP_SECRET", "")
h := NewSettingHandler(service.NewSettingService(&settingHandlerPublicRepoStub{}, &config.Config{}), "test-version")
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/settings/public", nil)
h.GetPublicSettings(c)
require.Equal(t, http.StatusOK, recorder.Code)
var resp struct {
Code int `json:"code"`
Data struct {
WeChatOAuthEnabled bool `json:"wechat_oauth_enabled"`
WeChatOAuthOpenEnabled bool `json:"wechat_oauth_open_enabled"`
WeChatOAuthMPEnabled bool `json:"wechat_oauth_mp_enabled"`
} `json:"data"`
}
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &resp))
require.Equal(t, 0, resp.Code)
require.True(t, resp.Data.WeChatOAuthEnabled)
require.True(t, resp.Data.WeChatOAuthOpenEnabled)
require.False(t, resp.Data.WeChatOAuthMPEnabled)
}

View File

@@ -274,7 +274,9 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings
if oidcProviderName == "" { if oidcProviderName == "" {
oidcProviderName = "OIDC" oidcProviderName = "OIDC"
} }
weChatEnabled := isWeChatOAuthConfigured() weChatOpenEnabled := isWeChatOAuthOpenConfigured()
weChatMPEnabled := isWeChatOAuthMPConfigured()
weChatEnabled := weChatOpenEnabled || weChatMPEnabled
// Password reset requires email verification to be enabled // Password reset requires email verification to be enabled
emailVerifyEnabled := settings[SettingKeyEmailVerifyEnabled] == "true" emailVerifyEnabled := settings[SettingKeyEmailVerifyEnabled] == "true"
@@ -319,6 +321,8 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings
CustomEndpoints: settings[SettingKeyCustomEndpoints], CustomEndpoints: settings[SettingKeyCustomEndpoints],
LinuxDoOAuthEnabled: linuxDoEnabled, LinuxDoOAuthEnabled: linuxDoEnabled,
WeChatOAuthEnabled: weChatEnabled, WeChatOAuthEnabled: weChatEnabled,
WeChatOAuthOpenEnabled: weChatOpenEnabled,
WeChatOAuthMPEnabled: weChatMPEnabled,
BackendModeEnabled: settings[SettingKeyBackendModeEnabled] == "true", BackendModeEnabled: settings[SettingKeyBackendModeEnabled] == "true",
PaymentEnabled: settings[SettingPaymentEnabled] == "true", PaymentEnabled: settings[SettingPaymentEnabled] == "true",
OIDCOAuthEnabled: oidcEnabled, OIDCOAuthEnabled: oidcEnabled,
@@ -376,6 +380,8 @@ func (s *SettingService) GetPublicSettingsForInjection(ctx context.Context) (any
CustomEndpoints json.RawMessage `json:"custom_endpoints"` CustomEndpoints json.RawMessage `json:"custom_endpoints"`
LinuxDoOAuthEnabled bool `json:"linuxdo_oauth_enabled"` LinuxDoOAuthEnabled bool `json:"linuxdo_oauth_enabled"`
WeChatOAuthEnabled bool `json:"wechat_oauth_enabled"` WeChatOAuthEnabled bool `json:"wechat_oauth_enabled"`
WeChatOAuthOpenEnabled bool `json:"wechat_oauth_open_enabled"`
WeChatOAuthMPEnabled bool `json:"wechat_oauth_mp_enabled"`
BackendModeEnabled bool `json:"backend_mode_enabled"` BackendModeEnabled bool `json:"backend_mode_enabled"`
PaymentEnabled bool `json:"payment_enabled"` PaymentEnabled bool `json:"payment_enabled"`
OIDCOAuthEnabled bool `json:"oidc_oauth_enabled"` OIDCOAuthEnabled bool `json:"oidc_oauth_enabled"`
@@ -411,6 +417,8 @@ func (s *SettingService) GetPublicSettingsForInjection(ctx context.Context) (any
CustomEndpoints: safeRawJSONArray(settings.CustomEndpoints), CustomEndpoints: safeRawJSONArray(settings.CustomEndpoints),
LinuxDoOAuthEnabled: settings.LinuxDoOAuthEnabled, LinuxDoOAuthEnabled: settings.LinuxDoOAuthEnabled,
WeChatOAuthEnabled: settings.WeChatOAuthEnabled, WeChatOAuthEnabled: settings.WeChatOAuthEnabled,
WeChatOAuthOpenEnabled: settings.WeChatOAuthOpenEnabled,
WeChatOAuthMPEnabled: settings.WeChatOAuthMPEnabled,
BackendModeEnabled: settings.BackendModeEnabled, BackendModeEnabled: settings.BackendModeEnabled,
PaymentEnabled: settings.PaymentEnabled, PaymentEnabled: settings.PaymentEnabled,
OIDCOAuthEnabled: settings.OIDCOAuthEnabled, OIDCOAuthEnabled: settings.OIDCOAuthEnabled,
@@ -460,11 +468,17 @@ func filterUserVisibleMenuItems(raw string) json.RawMessage {
} }
func isWeChatOAuthConfigured() bool { func isWeChatOAuthConfigured() bool {
openConfigured := strings.TrimSpace(os.Getenv("WECHAT_OAUTH_OPEN_APP_ID")) != "" && return isWeChatOAuthOpenConfigured() || isWeChatOAuthMPConfigured()
}
func isWeChatOAuthOpenConfigured() bool {
return strings.TrimSpace(os.Getenv("WECHAT_OAUTH_OPEN_APP_ID")) != "" &&
strings.TrimSpace(os.Getenv("WECHAT_OAUTH_OPEN_APP_SECRET")) != "" strings.TrimSpace(os.Getenv("WECHAT_OAUTH_OPEN_APP_SECRET")) != ""
mpConfigured := strings.TrimSpace(os.Getenv("WECHAT_OAUTH_MP_APP_ID")) != "" && }
func isWeChatOAuthMPConfigured() bool {
return strings.TrimSpace(os.Getenv("WECHAT_OAUTH_MP_APP_ID")) != "" &&
strings.TrimSpace(os.Getenv("WECHAT_OAUTH_MP_APP_SECRET")) != "" strings.TrimSpace(os.Getenv("WECHAT_OAUTH_MP_APP_SECRET")) != ""
return openConfigured || mpConfigured
} }
// safeRawJSONArray returns raw as json.RawMessage if it's valid JSON, otherwise "[]". // safeRawJSONArray returns raw as json.RawMessage if it's valid JSON, otherwise "[]".

View File

@@ -90,3 +90,18 @@ func TestSettingService_GetPublicSettings_ExposesForceEmailOnThirdPartySignup(t
require.NoError(t, err) require.NoError(t, err)
require.True(t, settings.ForceEmailOnThirdPartySignup) require.True(t, settings.ForceEmailOnThirdPartySignup)
} }
func TestSettingService_GetPublicSettings_ExposesWeChatOAuthModeCapabilities(t *testing.T) {
t.Setenv("WECHAT_OAUTH_OPEN_APP_ID", "wx-open-app")
t.Setenv("WECHAT_OAUTH_OPEN_APP_SECRET", "wx-open-secret")
t.Setenv("WECHAT_OAUTH_MP_APP_ID", "")
t.Setenv("WECHAT_OAUTH_MP_APP_SECRET", "")
svc := NewSettingService(&settingPublicRepoStub{}, &config.Config{})
settings, err := svc.GetPublicSettings(context.Background())
require.NoError(t, err)
require.True(t, settings.WeChatOAuthEnabled)
require.True(t, settings.WeChatOAuthOpenEnabled)
require.False(t, settings.WeChatOAuthMPEnabled)
}

View File

@@ -161,13 +161,15 @@ type PublicSettings struct {
CustomMenuItems string // JSON array of custom menu items CustomMenuItems string // JSON array of custom menu items
CustomEndpoints string // JSON array of custom endpoints CustomEndpoints string // JSON array of custom endpoints
LinuxDoOAuthEnabled bool LinuxDoOAuthEnabled bool
WeChatOAuthEnabled bool WeChatOAuthEnabled bool
BackendModeEnabled bool WeChatOAuthOpenEnabled bool
PaymentEnabled bool WeChatOAuthMPEnabled bool
OIDCOAuthEnabled bool BackendModeEnabled bool
OIDCOAuthProviderName string PaymentEnabled bool
Version string OIDCOAuthEnabled bool
OIDCOAuthProviderName string
Version string
BalanceLowNotifyEnabled bool BalanceLowNotifyEnabled bool
AccountQuotaNotifyEnabled bool AccountQuotaNotifyEnabled bool

View File

@@ -363,7 +363,7 @@ export interface ResolvedWeChatOAuthStart {
unavailableReason: WeChatOAuthUnavailableReason | null unavailableReason: WeChatOAuthUnavailableReason | null
} }
type WeChatOAuthPublicSettings = { export type WeChatOAuthPublicSettings = {
wechat_oauth_enabled?: boolean wechat_oauth_enabled?: boolean
wechat_oauth_open_enabled?: boolean wechat_oauth_open_enabled?: boolean
wechat_oauth_mp_enabled?: boolean wechat_oauth_mp_enabled?: boolean

View File

@@ -4,7 +4,11 @@
*/ */
import { apiClient } from './client' import { apiClient } from './client'
import { prepareOAuthBindAccessTokenCookie } from './auth' import {
prepareOAuthBindAccessTokenCookie,
resolveWeChatOAuthStart,
type WeChatOAuthPublicSettings,
} from './auth'
import type { User, ChangePasswordRequest, NotifyEmailEntry, UserAuthProvider } from '@/types' import type { User, ChangePasswordRequest, NotifyEmailEntry, UserAuthProvider } from '@/types'
/** /**
@@ -89,6 +93,7 @@ export type BindableOAuthProvider = Exclude<UserAuthProvider, 'email'>
interface BuildOAuthBindingStartURLOptions { interface BuildOAuthBindingStartURLOptions {
redirectTo?: string redirectTo?: string
wechatOAuthSettings?: WeChatOAuthPublicSettings | null
} }
export function resolveWeChatOAuthMode(): 'open' | 'mp' { export function resolveWeChatOAuthMode(): 'open' | 'mp' {
@@ -98,10 +103,19 @@ export function resolveWeChatOAuthMode(): 'open' | 'mp' {
return /MicroMessenger/i.test(navigator.userAgent) ? 'mp' : 'open' return /MicroMessenger/i.test(navigator.userAgent) ? 'mp' : 'open'
} }
function resolveWeChatOAuthBindingMode(
settings?: WeChatOAuthPublicSettings | null
): 'open' | 'mp' | null {
if (settings) {
return resolveWeChatOAuthStart(settings).mode
}
return resolveWeChatOAuthMode()
}
export function buildOAuthBindingStartURL( export function buildOAuthBindingStartURL(
provider: BindableOAuthProvider, provider: BindableOAuthProvider,
options: BuildOAuthBindingStartURLOptions = {} options: BuildOAuthBindingStartURLOptions = {}
): string { ): string | null {
const redirectTo = options.redirectTo?.trim() || '/profile' const redirectTo = options.redirectTo?.trim() || '/profile'
const apiBase = (import.meta.env.VITE_API_BASE_URL as string | undefined) || '/api/v1' const apiBase = (import.meta.env.VITE_API_BASE_URL as string | undefined) || '/api/v1'
const normalized = apiBase.replace(/\/$/, '') const normalized = apiBase.replace(/\/$/, '')
@@ -111,7 +125,11 @@ export function buildOAuthBindingStartURL(
}) })
if (provider === 'wechat') { if (provider === 'wechat') {
params.set('mode', resolveWeChatOAuthMode()) const mode = resolveWeChatOAuthBindingMode(options.wechatOAuthSettings)
if (!mode) {
return null
}
params.set('mode', mode)
} }
return `${normalized}/auth/oauth/${provider}/start?${params.toString()}` return `${normalized}/auth/oauth/${provider}/start?${params.toString()}`
@@ -124,8 +142,12 @@ export function startOAuthBinding(
if (typeof window === 'undefined') { if (typeof window === 'undefined') {
return return
} }
const startURL = buildOAuthBindingStartURL(provider, options)
if (!startURL) {
return
}
prepareOAuthBindAccessTokenCookie() prepareOAuthBindAccessTokenCookie()
window.location.href = buildOAuthBindingStartURL(provider, options) window.location.href = startURL
} }
export const userAPI = { export const userAPI = {

View File

@@ -52,7 +52,9 @@
import { computed } from 'vue' import { computed } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import { resolveWeChatOAuthStart, type WeChatOAuthPublicSettings } from '@/api/auth'
import { startOAuthBinding } from '@/api/user' import { startOAuthBinding } from '@/api/user'
import { useAppStore } from '@/stores'
import type { User, UserAuthBindingStatus, UserAuthProvider } from '@/types' import type { User, UserAuthBindingStatus, UserAuthProvider } from '@/types'
const props = withDefaults( const props = withDefaults(
@@ -62,17 +64,44 @@ const props = withDefaults(
oidcEnabled?: boolean oidcEnabled?: boolean
oidcProviderName?: string oidcProviderName?: string
wechatEnabled?: boolean wechatEnabled?: boolean
wechatOpenEnabled?: boolean
wechatMpEnabled?: boolean
}>(), }>(),
{ {
linuxdoEnabled: false, linuxdoEnabled: false,
oidcEnabled: false, oidcEnabled: false,
oidcProviderName: 'OIDC', oidcProviderName: 'OIDC',
wechatEnabled: false, wechatEnabled: false,
wechatOpenEnabled: undefined,
wechatMpEnabled: undefined,
} }
) )
const { t } = useI18n() const { t } = useI18n()
const route = useRoute() const route = useRoute()
const appStore = useAppStore()
const wechatOAuthSettings = computed<WeChatOAuthPublicSettings | null>(() => {
if (appStore.cachedPublicSettings) {
return appStore.cachedPublicSettings
}
if (
typeof props.wechatEnabled === 'boolean' ||
typeof props.wechatOpenEnabled === 'boolean' ||
typeof props.wechatMpEnabled === 'boolean'
) {
return {
wechat_oauth_enabled: props.wechatEnabled,
wechat_oauth_open_enabled: props.wechatOpenEnabled,
wechat_oauth_mp_enabled: props.wechatMpEnabled,
}
}
return null
})
const resolvedWeChatBinding = computed(() => resolveWeChatOAuthStart(wechatOAuthSettings.value))
function normalizeBindingStatus(binding: boolean | UserAuthBindingStatus | undefined): boolean | null { function normalizeBindingStatus(binding: boolean | UserAuthBindingStatus | undefined): boolean | null {
if (typeof binding === 'boolean') { if (typeof binding === 'boolean') {
@@ -129,7 +158,7 @@ const providerItems = computed(() => [
provider: 'wechat' as const, provider: 'wechat' as const,
label: t('profile.authBindings.providers.wechat'), label: t('profile.authBindings.providers.wechat'),
bound: getBindingStatus('wechat'), bound: getBindingStatus('wechat'),
canBind: props.wechatEnabled && !getBindingStatus('wechat'), canBind: resolvedWeChatBinding.value.mode !== null && !getBindingStatus('wechat'),
}, },
]) ])
@@ -139,6 +168,7 @@ function startBinding(provider: UserAuthProvider): void {
} }
startOAuthBinding(provider, { startOAuthBinding(provider, {
redirectTo: route.fullPath || '/profile', redirectTo: route.fullPath || '/profile',
wechatOAuthSettings: provider === 'wechat' ? wechatOAuthSettings.value : null,
}) })
} }
</script> </script>

View File

@@ -1,6 +1,8 @@
import { mount } from '@vue/test-utils' import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import ProfileIdentityBindingsSection from '@/components/user/profile/ProfileIdentityBindingsSection.vue' import ProfileIdentityBindingsSection from '@/components/user/profile/ProfileIdentityBindingsSection.vue'
import { useAppStore } from '@/stores'
import type { User } from '@/types' import type { User } from '@/types'
const routeState = vi.hoisted(() => ({ const routeState = vi.hoisted(() => ({
@@ -11,6 +13,8 @@ const locationState = vi.hoisted(() => ({
current: { href: 'http://localhost/profile' } as { href: string }, current: { href: 'http://localhost/profile' } as { href: string },
})) }))
let pinia: ReturnType<typeof createPinia>
vi.mock('vue-router', () => ({ vi.mock('vue-router', () => ({
useRoute: () => routeState, useRoute: () => routeState,
})) }))
@@ -57,6 +61,8 @@ function createUser(overrides: Partial<User> = {}): User {
describe('ProfileIdentityBindingsSection', () => { describe('ProfileIdentityBindingsSection', () => {
beforeEach(() => { beforeEach(() => {
pinia = createPinia()
setActivePinia(pinia)
routeState.fullPath = '/profile' routeState.fullPath = '/profile'
locationState.current = { href: 'http://localhost/profile' } locationState.current = { href: 'http://localhost/profile' }
Object.defineProperty(window, 'location', { Object.defineProperty(window, 'location', {
@@ -67,6 +73,9 @@ describe('ProfileIdentityBindingsSection', () => {
configurable: true, configurable: true,
value: 'Mozilla/5.0', value: 'Mozilla/5.0',
}) })
const appStore = useAppStore()
appStore.cachedPublicSettings = null
appStore.publicSettingsLoaded = false
}) })
afterEach(() => { afterEach(() => {
@@ -75,6 +84,9 @@ describe('ProfileIdentityBindingsSection', () => {
it('renders provider binding states and provider-specific bind actions', () => { it('renders provider binding states and provider-specific bind actions', () => {
const wrapper = mount(ProfileIdentityBindingsSection, { const wrapper = mount(ProfileIdentityBindingsSection, {
global: {
plugins: [pinia],
},
props: { props: {
user: createUser({ user: createUser({
auth_bindings: { auth_bindings: {
@@ -102,11 +114,16 @@ describe('ProfileIdentityBindingsSection', () => {
it('starts the WeChat bind flow for the current profile page', async () => { it('starts the WeChat bind flow for the current profile page', async () => {
const wrapper = mount(ProfileIdentityBindingsSection, { const wrapper = mount(ProfileIdentityBindingsSection, {
global: {
plugins: [pinia],
},
props: { props: {
user: createUser(), user: createUser(),
linuxdoEnabled: false, linuxdoEnabled: false,
oidcEnabled: false, oidcEnabled: false,
wechatEnabled: true, wechatEnabled: true,
wechatOpenEnabled: true,
wechatMpEnabled: false,
}, },
}) })
@@ -117,4 +134,22 @@ describe('ProfileIdentityBindingsSection', () => {
expect(locationState.current.href).toContain('intent=bind_current_user') expect(locationState.current.href).toContain('intent=bind_current_user')
expect(locationState.current.href).toContain('redirect=%2Fprofile') expect(locationState.current.href).toContain('redirect=%2Fprofile')
}) })
it('hides the WeChat bind action outside the WeChat browser when only mp mode is configured', () => {
const wrapper = mount(ProfileIdentityBindingsSection, {
global: {
plugins: [pinia],
},
props: {
user: createUser(),
linuxdoEnabled: false,
oidcEnabled: false,
wechatEnabled: true,
wechatOpenEnabled: false,
wechatMpEnabled: true,
},
})
expect(wrapper.find('[data-testid="profile-binding-wechat-action"]').exists()).toBe(false)
})
}) })

View File

@@ -338,6 +338,8 @@ export const useAppStore = defineStore('app', () => {
custom_endpoints: [], custom_endpoints: [],
linuxdo_oauth_enabled: false, linuxdo_oauth_enabled: false,
wechat_oauth_enabled: false, wechat_oauth_enabled: false,
wechat_oauth_open_enabled: false,
wechat_oauth_mp_enabled: false,
oidc_oauth_enabled: false, oidc_oauth_enabled: false,
oidc_oauth_provider_name: 'OIDC', oidc_oauth_provider_name: 'OIDC',
backend_mode_enabled: false, backend_mode_enabled: false,

View File

@@ -165,6 +165,8 @@ export interface PublicSettings {
custom_endpoints: CustomEndpoint[] custom_endpoints: CustomEndpoint[]
linuxdo_oauth_enabled: boolean linuxdo_oauth_enabled: boolean
wechat_oauth_enabled: boolean wechat_oauth_enabled: boolean
wechat_oauth_open_enabled?: boolean
wechat_oauth_mp_enabled?: boolean
oidc_oauth_enabled: boolean oidc_oauth_enabled: boolean
oidc_oauth_provider_name: string oidc_oauth_provider_name: string
backend_mode_enabled: boolean backend_mode_enabled: boolean

View File

@@ -297,6 +297,7 @@ import {
login2FA, login2FA,
prepareOAuthBindAccessTokenCookie, prepareOAuthBindAccessTokenCookie,
persistOAuthTokenContext, persistOAuthTokenContext,
resolveWeChatOAuthStart,
type OAuthAdoptionDecision, type OAuthAdoptionDecision,
type PendingOAuthExchangeResponse type PendingOAuthExchangeResponse
} from '@/api/auth' } from '@/api/auth'
@@ -378,7 +379,47 @@ function normalizeWeChatOAuthMode(value: unknown): 'open' | 'mp' | null {
return value === 'open' || value === 'mp' ? value : null return value === 'open' || value === 'mp' ? value : null
} }
function resolveRequestedWeChatOAuthMode(): 'open' | 'mp' { async function ensurePublicSettingsLoaded(): Promise<void> {
if (appStore.cachedPublicSettings || appStore.publicSettingsLoaded) {
return
}
try {
await appStore.fetchPublicSettings()
} catch {
// Fall back to legacy mode selection when public settings are unavailable.
}
}
function resolveConfiguredWeChatOAuthMode(): 'open' | 'mp' | null {
if (!appStore.cachedPublicSettings && !appStore.publicSettingsLoaded) {
return null
}
return resolveWeChatOAuthStart(appStore.cachedPublicSettings).mode
}
function resolveWeChatOAuthUnavailableMessage(): string {
const resolved = resolveWeChatOAuthStart(appStore.cachedPublicSettings)
switch (resolved.unavailableReason) {
case 'external_browser_required':
return 'This WeChat sign-in flow is only available in your system browser.'
case 'wechat_browser_required':
return 'This WeChat sign-in flow is only available inside the WeChat browser.'
case 'not_configured':
return 'WeChat sign-in is not configured yet.'
default:
return t('auth.loginFailed')
}
}
function resolveRequestedWeChatOAuthMode(): 'open' | 'mp' | null {
const configuredMode = resolveConfiguredWeChatOAuthMode()
if (configuredMode) {
return configuredMode
}
const queryMode = normalizeWeChatOAuthMode(route.query.mode) const queryMode = normalizeWeChatOAuthMode(route.query.mode)
return queryMode || resolveWeChatOAuthMode() return queryMode || resolveWeChatOAuthMode()
} }
@@ -389,11 +430,15 @@ function resolveRedirectTarget(): string {
) )
} }
function resolveWeChatStartURL(intent: 'bind_current_user' | 'adopt_existing_user_by_email'): string { function resolveWeChatStartURL(intent: 'bind_current_user' | 'adopt_existing_user_by_email'): string | null {
const apiBase = (import.meta.env.VITE_API_BASE_URL as string | undefined) || '/api/v1' const apiBase = (import.meta.env.VITE_API_BASE_URL as string | undefined) || '/api/v1'
const normalized = apiBase.replace(/\/$/, '') const normalized = apiBase.replace(/\/$/, '')
const mode = resolveRequestedWeChatOAuthMode()
if (!mode) {
return null
}
const params = new URLSearchParams({ const params = new URLSearchParams({
mode: resolveRequestedWeChatOAuthMode(), mode,
redirect: resolveRedirectTarget(), redirect: resolveRedirectTarget(),
intent, intent,
}) })
@@ -406,11 +451,15 @@ function resolveWeChatStartURL(intent: 'bind_current_user' | 'adopt_existing_use
return `${normalized}/auth/oauth/wechat/start?${params.toString()}` return `${normalized}/auth/oauth/wechat/start?${params.toString()}`
} }
function buildExistingAccountResumePath(): string { function buildExistingAccountResumePath(): string | null {
const mode = resolveRequestedWeChatOAuthMode()
if (!mode) {
return null
}
const params = new URLSearchParams({ const params = new URLSearchParams({
wechat_bind_existing: '1', wechat_bind_existing: '1',
redirect: resolveRedirectTarget(), redirect: resolveRedirectTarget(),
mode: resolveRequestedWeChatOAuthMode(), mode,
}) })
const email = existingAccountEmail.value.trim() const email = existingAccountEmail.value.trim()
@@ -444,14 +493,31 @@ function serializeAdoptionDecision(decision: OAuthAdoptionDecision): Record<stri
} }
async function handleExistingAccountBinding() { async function handleExistingAccountBinding() {
const unavailableMessage = resolveConfiguredWeChatOAuthMode() === null
? resolveWeChatOAuthUnavailableMessage()
: ''
if (getAuthToken()) { if (getAuthToken()) {
const startURL = resolveWeChatStartURL('bind_current_user')
if (!startURL) {
errorMessage.value = unavailableMessage || resolveWeChatOAuthUnavailableMessage()
appStore.showError(errorMessage.value)
return
}
prepareOAuthBindAccessTokenCookie() prepareOAuthBindAccessTokenCookie()
window.location.href = resolveWeChatStartURL('bind_current_user') window.location.href = startURL
return
}
const resumePath = buildExistingAccountResumePath()
if (!resumePath) {
errorMessage.value = unavailableMessage || resolveWeChatOAuthUnavailableMessage()
appStore.showError(errorMessage.value)
return return
} }
const params = new URLSearchParams({ const params = new URLSearchParams({
redirect: buildExistingAccountResumePath(), redirect: resumePath,
}) })
const email = existingAccountEmail.value.trim() const email = existingAccountEmail.value.trim()
if (email) { if (email) {
@@ -720,19 +786,36 @@ async function handleSubmitTotpChallenge() {
} }
onMounted(async () => { onMounted(async () => {
await ensurePublicSettingsLoaded()
if (typeof route.query.email === 'string') { if (typeof route.query.email === 'string') {
existingAccountEmail.value = route.query.email existingAccountEmail.value = route.query.email
} }
if (route.query.wechat_bind_existing === '1') { if (route.query.wechat_bind_existing === '1') {
if (getAuthToken()) { if (getAuthToken()) {
const startURL = resolveWeChatStartURL('bind_current_user')
if (!startURL) {
errorMessage.value = resolveWeChatOAuthUnavailableMessage()
appStore.showError(errorMessage.value)
isProcessing.value = false
return
}
prepareOAuthBindAccessTokenCookie() prepareOAuthBindAccessTokenCookie()
window.location.href = resolveWeChatStartURL('bind_current_user') window.location.href = startURL
return
}
const resumePath = buildExistingAccountResumePath()
if (!resumePath) {
errorMessage.value = resolveWeChatOAuthUnavailableMessage()
appStore.showError(errorMessage.value)
isProcessing.value = false
return return
} }
const params = new URLSearchParams({ const params = new URLSearchParams({
redirect: buildExistingAccountResumePath(), redirect: resumePath,
}) })
const email = existingAccountEmail.value.trim() const email = existingAccountEmail.value.trim()
if (email) { if (email) {

View File

@@ -14,8 +14,10 @@ const {
setTokenMock, setTokenMock,
showSuccessMock, showSuccessMock,
showErrorMock, showErrorMock,
fetchPublicSettingsMock,
routeState, routeState,
locationState, locationState,
appStoreState,
} = vi.hoisted(() => ({ } = vi.hoisted(() => ({
exchangePendingOAuthCompletionMock: vi.fn(), exchangePendingOAuthCompletionMock: vi.fn(),
completeWeChatOAuthRegistrationMock: vi.fn(), completeWeChatOAuthRegistrationMock: vi.fn(),
@@ -28,6 +30,7 @@ const {
setTokenMock: vi.fn(), setTokenMock: vi.fn(),
showSuccessMock: vi.fn(), showSuccessMock: vi.fn(),
showErrorMock: vi.fn(), showErrorMock: vi.fn(),
fetchPublicSettingsMock: vi.fn(),
routeState: { routeState: {
query: {} as Record<string, unknown>, query: {} as Record<string, unknown>,
}, },
@@ -39,6 +42,10 @@ const {
pathname: '/auth/wechat/callback' pathname: '/auth/wechat/callback'
} as { href: string; hash: string; search: string; pathname: string }, } as { href: string; hash: string; search: string; pathname: string },
}, },
appStoreState: {
cachedPublicSettings: null as null | Record<string, unknown>,
publicSettingsLoaded: false,
},
})) }))
vi.mock('vue-router', () => ({ vi.mock('vue-router', () => ({
@@ -102,8 +109,10 @@ vi.mock('@/stores', () => ({
setToken: setTokenMock, setToken: setTokenMock,
}), }),
useAppStore: () => ({ useAppStore: () => ({
...appStoreState,
showSuccess: showSuccessMock, showSuccess: showSuccessMock,
showError: showErrorMock, showError: showErrorMock,
fetchPublicSettings: fetchPublicSettingsMock,
}), }),
})) }))
@@ -139,7 +148,10 @@ describe('WechatCallbackView', () => {
showErrorMock.mockReset() showErrorMock.mockReset()
prepareOAuthBindAccessTokenCookieMock.mockReset() prepareOAuthBindAccessTokenCookieMock.mockReset()
getAuthTokenMock.mockReset() getAuthTokenMock.mockReset()
fetchPublicSettingsMock.mockReset()
routeState.query = {} routeState.query = {}
appStoreState.cachedPublicSettings = null
appStoreState.publicSettingsLoaded = false
localStorage.clear() localStorage.clear()
locationState.current = { locationState.current = {
href: 'http://localhost/auth/wechat/callback', href: 'http://localhost/auth/wechat/callback',
@@ -157,6 +169,38 @@ describe('WechatCallbackView', () => {
}) })
}) })
it('overrides an incompatible query mode with the configured open capability during bind recovery', async () => {
routeState.query = {
wechat_bind_existing: '1',
mode: 'mp',
redirect: '/profile',
}
appStoreState.cachedPublicSettings = {
wechat_oauth_enabled: true,
wechat_oauth_open_enabled: true,
wechat_oauth_mp_enabled: false,
}
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')
expect(locationState.current.href).not.toContain('mode=mp')
})
it('does not send adoption decisions during the initial exchange', async () => { it('does not send adoption decisions during the initial exchange', async () => {
exchangePendingOAuthCompletionMock.mockResolvedValue({ exchangePendingOAuthCompletionMock.mockResolvedValue({
access_token: 'access-token', access_token: 'access-token',

View File

@@ -67,7 +67,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, h, onMounted, ref } from 'vue' import { computed, h, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { authAPI } from '@/api'
import { Icon } from '@/components/icons' import { Icon } from '@/components/icons'
import StatCard from '@/components/common/StatCard.vue' import StatCard from '@/components/common/StatCard.vue'
import AppLayout from '@/components/layout/AppLayout.vue' import AppLayout from '@/components/layout/AppLayout.vue'
@@ -76,10 +75,12 @@ import ProfileEditForm from '@/components/user/profile/ProfileEditForm.vue'
import ProfileInfoCard from '@/components/user/profile/ProfileInfoCard.vue' import ProfileInfoCard from '@/components/user/profile/ProfileInfoCard.vue'
import ProfilePasswordForm from '@/components/user/profile/ProfilePasswordForm.vue' import ProfilePasswordForm from '@/components/user/profile/ProfilePasswordForm.vue'
import ProfileTotpCard from '@/components/user/profile/ProfileTotpCard.vue' import ProfileTotpCard from '@/components/user/profile/ProfileTotpCard.vue'
import { useAppStore } from '@/stores/app'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { formatDate } from '@/utils/format' import { formatDate } from '@/utils/format'
const { t } = useI18n() const { t } = useI18n()
const appStore = useAppStore()
const authStore = useAuthStore() const authStore = useAuthStore()
const user = computed(() => authStore.user) const user = computed(() => authStore.user)
@@ -121,8 +122,11 @@ onMounted(async () => {
console.error('Failed to refresh profile:', error) console.error('Failed to refresh profile:', error)
}) })
const settingsLoad = authAPI.getPublicSettings() const settingsLoad = appStore.fetchPublicSettings()
.then((settings) => { .then((settings) => {
if (!settings) {
return
}
contactInfo.value = settings.contact_info || '' contactInfo.value = settings.contact_info || ''
balanceLowNotifyEnabled.value = settings.balance_low_notify_enabled ?? false balanceLowNotifyEnabled.value = settings.balance_low_notify_enabled ?? false
systemDefaultThreshold.value = settings.balance_low_notify_threshold ?? 0 systemDefaultThreshold.value = settings.balance_low_notify_threshold ?? 0