Skip to content
GitLab
Menu
Projects
Groups
Snippets
Loading...
Help
Help
Support
Community forum
Keyboard shortcuts
?
Submit feedback
Sign in / Register
Toggle navigation
Menu
Open sidebar
陈曦
sub2api
Commits
4f6966d7
Commit
4f6966d7
authored
Apr 21, 2026
by
IanShaw027
Browse files
frontend: route wechat oauth entry by public settings
parent
9e84e2fd
Changes
3
Show whitespace changes
Inline
Side-by-side
frontend/src/api/auth.ts
View file @
4f6966d7
...
@@ -349,6 +349,61 @@ export async function getPublicSettings(): Promise<PublicSettings> {
...
@@ -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
...
...
frontend/src/components/auth/WechatOAuthSection.vue
View file @
4f6966d7
<
template
>
<
template
>
<div
class=
"space-y-4"
>
<div
class=
"space-y-4"
>
<button
type=
"button"
:disabled=
"
d
isabled"
class=
"btn btn-secondary w-full"
@
click=
"startLogin"
>
<button
type=
"button"
:disabled=
"
buttonD
isabled"
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 @@
...
@@ -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 @@
...
@@ -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
=
resolve
WeChatOAuthM
ode
()
const
mode
=
resolve
dStart
.
value
.
m
ode
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
}
}
...
...
frontend/src/components/auth/__tests__/WechatOAuthSection.spec.ts
View file @
4f6966d7
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,12 +13,18 @@ const locationState = vi.hoisted(() => ({
...
@@ -10,12 +13,18 @@ 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
()
=>
{
const
actual
=
await
vi
.
importActual
<
typeof
import
(
'
vue-i18n
'
)
>
(
'
vue-i18n
'
)
return
{
...
actual
,
useI18n
:
()
=>
({
useI18n
:
()
=>
({
locale
:
{
value
:
'
en
'
},
t
:
(
key
:
string
,
params
?:
Record
<
string
,
string
>
)
=>
{
t
:
(
key
:
string
,
params
?:
Record
<
string
,
string
>
)
=>
{
if
(
key
===
'
auth.oidc.signIn
'
)
{
if
(
key
===
'
auth.oidc.signIn
'
)
{
return
`Continue with
${
params
?.
providerName
??
''
}
`
.
trim
()
return
`Continue with
${
params
?.
providerName
??
''
}
`
.
trim
()
...
@@ -26,10 +35,62 @@ vi.mock('vue-i18n', () => ({
...
@@ -26,10 +35,62 @@ vi.mock('vue-i18n', () => ({
return
key
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', () => {
...
@@ -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', () => {
...
@@ -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', () => {
...
@@ -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
'
)
})
})
})
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
.
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment