Commit 067eb23d authored by IanShaw027's avatar IanShaw027
Browse files

Tighten WeChat OAuth capability mode selection

parent 12f4af74
...@@ -200,6 +200,8 @@ type PublicSettings struct { ...@@ -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"`
......
...@@ -58,6 +58,8 @@ func (h *SettingHandler) GetPublicSettings(c *gin.Context) { ...@@ -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,
......
...@@ -81,3 +81,35 @@ func TestSettingHandler_GetPublicSettings_ExposesForceEmailOnThirdPartySignup(t ...@@ -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)
}
...@@ -274,7 +274,9 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings ...@@ -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 ...@@ -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 ...@@ -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 ...@@ -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 { ...@@ -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 "[]".
......
...@@ -90,3 +90,18 @@ func TestSettingService_GetPublicSettings_ExposesForceEmailOnThirdPartySignup(t ...@@ -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)
}
...@@ -161,13 +161,15 @@ type PublicSettings struct { ...@@ -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
......
...@@ -363,7 +363,7 @@ export interface ResolvedWeChatOAuthStart { ...@@ -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
......
...@@ -4,7 +4,11 @@ ...@@ -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'> ...@@ -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' { ...@@ -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( ...@@ -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( ...@@ -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 = {
......
...@@ -52,7 +52,9 @@ ...@@ -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( ...@@ -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(() => [ ...@@ -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 { ...@@ -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>
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(() => ({ ...@@ -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 { ...@@ -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', () => { ...@@ -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', () => { ...@@ -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', () => { ...@@ -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', () => { ...@@ -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)
})
}) })
...@@ -338,6 +338,8 @@ export const useAppStore = defineStore('app', () => { ...@@ -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,
......
...@@ -165,6 +165,8 @@ export interface PublicSettings { ...@@ -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
......
...@@ -297,6 +297,7 @@ import { ...@@ -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 { ...@@ -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 { ...@@ -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 ...@@ -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 ...@@ -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() { ...@@ -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) {
......
...@@ -14,8 +14,10 @@ const { ...@@ -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 { ...@@ -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 { ...@@ -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', () => ({ ...@@ -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', () => { ...@@ -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', () => { ...@@ -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',
......
...@@ -67,7 +67,6 @@ ...@@ -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' ...@@ -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 () => { ...@@ -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
......
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment