frontend: route wechat oauth entry by public settings
This commit is contained in:
@@ -349,6 +349,61 @@ export async function getPublicSettings(): Promise<PublicSettings> {
|
|||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type WeChatOAuthMode = 'open' | 'mp'
|
||||||
|
export type WeChatOAuthUnavailableReason =
|
||||||
|
| 'not_configured'
|
||||||
|
| 'external_browser_required'
|
||||||
|
| 'wechat_browser_required'
|
||||||
|
|
||||||
|
export interface ResolvedWeChatOAuthStart {
|
||||||
|
mode: WeChatOAuthMode | null
|
||||||
|
openEnabled: boolean
|
||||||
|
mpEnabled: boolean
|
||||||
|
isWeChatBrowser: boolean
|
||||||
|
unavailableReason: WeChatOAuthUnavailableReason | null
|
||||||
|
}
|
||||||
|
|
||||||
|
type WeChatOAuthPublicSettings = {
|
||||||
|
wechat_oauth_enabled?: boolean
|
||||||
|
wechat_oauth_open_enabled?: boolean
|
||||||
|
wechat_oauth_mp_enabled?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveWeChatOAuthStart(
|
||||||
|
settings: WeChatOAuthPublicSettings | null | undefined,
|
||||||
|
userAgent?: string
|
||||||
|
): ResolvedWeChatOAuthStart {
|
||||||
|
const normalizedUserAgent = (userAgent
|
||||||
|
?? (typeof navigator !== 'undefined' ? navigator.userAgent : '')
|
||||||
|
?? '').trim()
|
||||||
|
const isWeChatBrowser = /MicroMessenger/i.test(normalizedUserAgent)
|
||||||
|
const legacyEnabled = settings?.wechat_oauth_enabled ?? false
|
||||||
|
const openEnabled = typeof settings?.wechat_oauth_open_enabled === 'boolean'
|
||||||
|
? settings.wechat_oauth_open_enabled
|
||||||
|
: legacyEnabled
|
||||||
|
const mpEnabled = typeof settings?.wechat_oauth_mp_enabled === 'boolean'
|
||||||
|
? settings.wechat_oauth_mp_enabled
|
||||||
|
: legacyEnabled
|
||||||
|
|
||||||
|
if (isWeChatBrowser) {
|
||||||
|
if (mpEnabled) {
|
||||||
|
return { mode: 'mp', openEnabled, mpEnabled, isWeChatBrowser, unavailableReason: null }
|
||||||
|
}
|
||||||
|
if (openEnabled) {
|
||||||
|
return { mode: null, openEnabled, mpEnabled, isWeChatBrowser, unavailableReason: 'external_browser_required' }
|
||||||
|
}
|
||||||
|
return { mode: null, openEnabled, mpEnabled, isWeChatBrowser, unavailableReason: 'not_configured' }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (openEnabled) {
|
||||||
|
return { mode: 'open', openEnabled, mpEnabled, isWeChatBrowser, unavailableReason: null }
|
||||||
|
}
|
||||||
|
if (mpEnabled) {
|
||||||
|
return { mode: null, openEnabled, mpEnabled, isWeChatBrowser, unavailableReason: 'wechat_browser_required' }
|
||||||
|
}
|
||||||
|
return { mode: null, openEnabled, mpEnabled, isWeChatBrowser, unavailableReason: 'not_configured' }
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Send verification code to email
|
* Send verification code to email
|
||||||
* @param request - Email and optional Turnstile token
|
* @param request - Email and optional Turnstile token
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<button type="button" :disabled="disabled" class="btn btn-secondary w-full" @click="startLogin">
|
<button type="button" :disabled="buttonDisabled" class="btn btn-secondary w-full" @click="startLogin">
|
||||||
<span
|
<span
|
||||||
class="mr-2 inline-flex h-5 w-5 items-center justify-center rounded-full bg-green-100 text-xs font-semibold text-green-700 dark:bg-green-900/30 dark:text-green-300"
|
class="mr-2 inline-flex h-5 w-5 items-center justify-center rounded-full bg-green-100 text-xs font-semibold text-green-700 dark:bg-green-900/30 dark:text-green-300"
|
||||||
>
|
>
|
||||||
@@ -9,6 +9,14 @@
|
|||||||
{{ t('auth.oidc.signIn', { providerName }) }}
|
{{ t('auth.oidc.signIn', { providerName }) }}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<p
|
||||||
|
v-if="disabledHint"
|
||||||
|
data-testid="wechat-oauth-hint"
|
||||||
|
class="text-sm text-amber-600 dark:text-amber-400"
|
||||||
|
>
|
||||||
|
{{ disabledHint }}
|
||||||
|
</p>
|
||||||
|
|
||||||
<div v-if="showDivider" class="flex items-center gap-3">
|
<div v-if="showDivider" class="flex items-center gap-3">
|
||||||
<div class="h-px flex-1 bg-gray-200 dark:bg-dark-700"></div>
|
<div class="h-px flex-1 bg-gray-200 dark:bg-dark-700"></div>
|
||||||
<span class="text-xs text-gray-500 dark:text-dark-400">
|
<span class="text-xs text-gray-500 dark:text-dark-400">
|
||||||
@@ -20,33 +28,69 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted } from 'vue'
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { resolveWeChatOAuthStart } from '@/api/auth'
|
||||||
|
import { useAppStore } from '@/stores'
|
||||||
|
|
||||||
withDefaults(defineProps<{
|
const props = withDefaults(defineProps<{
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
showDivider?: boolean
|
showDivider?: boolean
|
||||||
}>(), {
|
}>(), {
|
||||||
showDivider: true,
|
showDivider: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const appStore = useAppStore()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const { t } = useI18n()
|
const { locale, t } = useI18n()
|
||||||
|
|
||||||
const providerName = 'WeChat'
|
const providerName = 'WeChat'
|
||||||
|
|
||||||
function resolveWeChatOAuthMode(): 'open' | 'mp' {
|
const resolvedStart = computed(() => resolveWeChatOAuthStart(appStore.cachedPublicSettings))
|
||||||
if (typeof navigator === 'undefined') {
|
const buttonDisabled = computed(() => props.disabled || resolvedStart.value.mode === null)
|
||||||
return 'open'
|
const disabledHint = computed(() => {
|
||||||
|
if (props.disabled) {
|
||||||
|
return ''
|
||||||
}
|
}
|
||||||
return /MicroMessenger/i.test(navigator.userAgent) ? 'mp' : 'open'
|
switch (resolvedStart.value.unavailableReason) {
|
||||||
|
case 'external_browser_required':
|
||||||
|
return localizeWeChatHint(
|
||||||
|
'当前仅配置网站微信登录,请在系统浏览器中打开此页面后再继续。',
|
||||||
|
'This site only has WeChat website login configured. Open this page in your browser to continue.',
|
||||||
|
)
|
||||||
|
case 'wechat_browser_required':
|
||||||
|
return localizeWeChatHint(
|
||||||
|
'当前仅配置微信内登录,请在微信中打开此页面后再继续。',
|
||||||
|
'This site only has WeChat in-app login configured. Open this page inside WeChat to continue.',
|
||||||
|
)
|
||||||
|
case 'not_configured':
|
||||||
|
return localizeWeChatHint(
|
||||||
|
'管理员尚未配置微信登录。',
|
||||||
|
'WeChat sign-in is not configured yet.',
|
||||||
|
)
|
||||||
|
default:
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function localizeWeChatHint(zh: string, en: string): string {
|
||||||
|
return locale.value.toLowerCase().startsWith('zh') ? zh : en
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (!appStore.cachedPublicSettings && !appStore.publicSettingsLoaded) {
|
||||||
|
appStore.fetchPublicSettings()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
function startLogin(): void {
|
function startLogin(): void {
|
||||||
|
if (buttonDisabled.value || !resolvedStart.value.mode) {
|
||||||
|
return
|
||||||
|
}
|
||||||
const redirectTo = (route.query.redirect as string) || '/dashboard'
|
const redirectTo = (route.query.redirect as string) || '/dashboard'
|
||||||
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 = resolveWeChatOAuthMode()
|
const mode = resolvedStart.value.mode
|
||||||
const startURL = `${normalized}/auth/oauth/wechat/start?mode=${mode}&redirect=${encodeURIComponent(redirectTo)}`
|
const startURL = `${normalized}/auth/oauth/wechat/start?mode=${mode}&redirect=${encodeURIComponent(redirectTo)}`
|
||||||
window.location.href = startURL
|
window.location.href = startURL
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
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 WechatOAuthSection from '@/components/auth/WechatOAuthSection.vue'
|
import WechatOAuthSection from '@/components/auth/WechatOAuthSection.vue'
|
||||||
|
import { useAppStore } from '@/stores'
|
||||||
|
import type { PublicSettings } from '@/types'
|
||||||
|
|
||||||
const routeState = vi.hoisted(() => ({
|
const routeState = vi.hoisted(() => ({
|
||||||
query: {} as Record<string, unknown>,
|
query: {} as Record<string, unknown>,
|
||||||
@@ -10,26 +13,84 @@ const locationState = vi.hoisted(() => ({
|
|||||||
current: { href: 'http://localhost/login' } as { href: string },
|
current: { href: 'http://localhost/login' } as { href: string },
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
let pinia: ReturnType<typeof createPinia>
|
||||||
|
|
||||||
vi.mock('vue-router', () => ({
|
vi.mock('vue-router', () => ({
|
||||||
useRoute: () => routeState,
|
useRoute: () => routeState,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('vue-i18n', () => ({
|
vi.mock('vue-i18n', async () => {
|
||||||
useI18n: () => ({
|
const actual = await vi.importActual<typeof import('vue-i18n')>('vue-i18n')
|
||||||
t: (key: string, params?: Record<string, string>) => {
|
return {
|
||||||
if (key === 'auth.oidc.signIn') {
|
...actual,
|
||||||
return `Continue with ${params?.providerName ?? ''}`.trim()
|
useI18n: () => ({
|
||||||
}
|
locale: { value: 'en' },
|
||||||
if (key === 'auth.oauthOrContinue') {
|
t: (key: string, params?: Record<string, string>) => {
|
||||||
return 'or continue'
|
if (key === 'auth.oidc.signIn') {
|
||||||
}
|
return `Continue with ${params?.providerName ?? ''}`.trim()
|
||||||
return key
|
}
|
||||||
},
|
if (key === 'auth.oauthOrContinue') {
|
||||||
}),
|
return 'or continue'
|
||||||
}))
|
}
|
||||||
|
return key
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
type WeChatPublicSettings = PublicSettings & {
|
||||||
|
wechat_oauth_open_enabled?: boolean
|
||||||
|
wechat_oauth_mp_enabled?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPublicSettings(overrides: Partial<WeChatPublicSettings> = {}): WeChatPublicSettings {
|
||||||
|
return {
|
||||||
|
registration_enabled: true,
|
||||||
|
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: '/api/v1',
|
||||||
|
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,
|
||||||
|
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,
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function seedPublicSettings(overrides: Partial<WeChatPublicSettings> = {}): void {
|
||||||
|
const appStore = useAppStore()
|
||||||
|
const settings = buildPublicSettings(overrides)
|
||||||
|
appStore.cachedPublicSettings = settings
|
||||||
|
appStore.publicSettingsLoaded = true
|
||||||
|
}
|
||||||
|
|
||||||
describe('WechatOAuthSection', () => {
|
describe('WechatOAuthSection', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
pinia = createPinia()
|
||||||
|
setActivePinia(pinia)
|
||||||
routeState.query = { redirect: '/billing?plan=pro' }
|
routeState.query = { redirect: '/billing?plan=pro' }
|
||||||
locationState.current = { href: 'http://localhost/login' }
|
locationState.current = { href: 'http://localhost/login' }
|
||||||
Object.defineProperty(window, 'location', {
|
Object.defineProperty(window, 'location', {
|
||||||
@@ -46,8 +107,16 @@ describe('WechatOAuthSection', () => {
|
|||||||
vi.unstubAllGlobals()
|
vi.unstubAllGlobals()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('starts the open WeChat OAuth flow with the current redirect target', async () => {
|
it('starts the open WeChat OAuth flow with the current redirect target when open mode is configured', async () => {
|
||||||
const wrapper = mount(WechatOAuthSection)
|
seedPublicSettings({
|
||||||
|
wechat_oauth_open_enabled: true,
|
||||||
|
wechat_oauth_mp_enabled: false,
|
||||||
|
})
|
||||||
|
const wrapper = mount(WechatOAuthSection, {
|
||||||
|
global: {
|
||||||
|
plugins: [pinia],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
expect(wrapper.text()).toContain('WeChat')
|
expect(wrapper.text()).toContain('WeChat')
|
||||||
|
|
||||||
@@ -58,12 +127,20 @@ describe('WechatOAuthSection', () => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('uses mp mode inside the WeChat browser', async () => {
|
it('uses mp mode inside the WeChat browser when mp mode is configured', async () => {
|
||||||
Object.defineProperty(window.navigator, 'userAgent', {
|
Object.defineProperty(window.navigator, 'userAgent', {
|
||||||
configurable: true,
|
configurable: true,
|
||||||
value: 'Mozilla/5.0 MicroMessenger',
|
value: 'Mozilla/5.0 MicroMessenger',
|
||||||
})
|
})
|
||||||
const wrapper = mount(WechatOAuthSection)
|
seedPublicSettings({
|
||||||
|
wechat_oauth_open_enabled: false,
|
||||||
|
wechat_oauth_mp_enabled: true,
|
||||||
|
})
|
||||||
|
const wrapper = mount(WechatOAuthSection, {
|
||||||
|
global: {
|
||||||
|
plugins: [pinia],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
await wrapper.get('button').trigger('click')
|
await wrapper.get('button').trigger('click')
|
||||||
|
|
||||||
@@ -71,4 +148,63 @@ describe('WechatOAuthSection', () => {
|
|||||||
'/api/v1/auth/oauth/wechat/start?mode=mp&redirect=%2Fbilling%3Fplan%3Dpro'
|
'/api/v1/auth/oauth/wechat/start?mode=mp&redirect=%2Fbilling%3Fplan%3Dpro'
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('disables the button outside the WeChat browser when only mp mode is configured', async () => {
|
||||||
|
seedPublicSettings({
|
||||||
|
wechat_oauth_open_enabled: false,
|
||||||
|
wechat_oauth_mp_enabled: true,
|
||||||
|
})
|
||||||
|
const wrapper = mount(WechatOAuthSection, {
|
||||||
|
global: {
|
||||||
|
plugins: [pinia],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(wrapper.get('button').attributes('disabled')).toBeDefined()
|
||||||
|
expect(wrapper.text()).toContain('Open this page inside WeChat to continue.')
|
||||||
|
|
||||||
|
await wrapper.get('button').trigger('click')
|
||||||
|
|
||||||
|
expect(locationState.current.href).toBe('http://localhost/login')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('disables the button inside the WeChat browser when only open mode is configured', async () => {
|
||||||
|
Object.defineProperty(window.navigator, 'userAgent', {
|
||||||
|
configurable: true,
|
||||||
|
value: 'Mozilla/5.0 MicroMessenger',
|
||||||
|
})
|
||||||
|
seedPublicSettings({
|
||||||
|
wechat_oauth_open_enabled: true,
|
||||||
|
wechat_oauth_mp_enabled: false,
|
||||||
|
})
|
||||||
|
const wrapper = mount(WechatOAuthSection, {
|
||||||
|
global: {
|
||||||
|
plugins: [pinia],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(wrapper.get('button').attributes('disabled')).toBeDefined()
|
||||||
|
expect(wrapper.text()).toContain('This site only has WeChat website login configured. Open this page in your browser to continue.')
|
||||||
|
|
||||||
|
await wrapper.get('button').trigger('click')
|
||||||
|
|
||||||
|
expect(locationState.current.href).toBe('http://localhost/login')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses the legacy overall enabled flag when per-mode settings are not present', async () => {
|
||||||
|
seedPublicSettings({
|
||||||
|
wechat_oauth_enabled: true,
|
||||||
|
})
|
||||||
|
const wrapper = mount(WechatOAuthSection, {
|
||||||
|
global: {
|
||||||
|
plugins: [pinia],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await wrapper.get('button').trigger('click')
|
||||||
|
|
||||||
|
expect(locationState.current.href).toContain(
|
||||||
|
'/api/v1/auth/oauth/wechat/start?mode=open&redirect=%2Fbilling%3Fplan%3Dpro'
|
||||||
|
)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user