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
e9de839d
Commit
e9de839d
authored
Apr 20, 2026
by
IanShaw027
Browse files
feat: rebuild auth identity foundation flow
parent
fbd0a2e3
Changes
123
Show whitespace changes
Inline
Side-by-side
frontend/src/api/__tests__/auth-oauth-adoption.spec.ts
0 → 100644
View file @
e9de839d
import
{
beforeEach
,
describe
,
expect
,
it
,
vi
}
from
'
vitest
'
const
post
=
vi
.
fn
()
vi
.
mock
(
'
@/api/client
'
,
()
=>
({
apiClient
:
{
post
}
}))
describe
(
'
oauth adoption auth api
'
,
()
=>
{
beforeEach
(()
=>
{
post
.
mockReset
()
post
.
mockResolvedValue
({
data
:
{}
})
})
it
(
'
posts adoption decisions when exchanging pending oauth completion
'
,
async
()
=>
{
const
{
exchangePendingOAuthCompletion
}
=
await
import
(
'
@/api/auth
'
)
await
exchangePendingOAuthCompletion
({
adoptDisplayName
:
false
,
adoptAvatar
:
true
})
expect
(
post
).
toHaveBeenCalledWith
(
'
/auth/oauth/pending/exchange
'
,
{
adopt_display_name
:
false
,
adopt_avatar
:
true
})
})
it
(
'
posts linuxdo invitation completion with adoption decisions
'
,
async
()
=>
{
const
{
completeLinuxDoOAuthRegistration
}
=
await
import
(
'
@/api/auth
'
)
await
completeLinuxDoOAuthRegistration
(
'
invite-code
'
,
{
adoptDisplayName
:
true
,
adoptAvatar
:
false
})
expect
(
post
).
toHaveBeenCalledWith
(
'
/auth/oauth/linuxdo/complete-registration
'
,
{
invitation_code
:
'
invite-code
'
,
adopt_display_name
:
true
,
adopt_avatar
:
false
})
})
it
(
'
posts oidc invitation completion with adoption decisions
'
,
async
()
=>
{
const
{
completeOIDCOAuthRegistration
}
=
await
import
(
'
@/api/auth
'
)
await
completeOIDCOAuthRegistration
(
'
invite-code
'
,
{
adoptDisplayName
:
false
,
adoptAvatar
:
true
})
expect
(
post
).
toHaveBeenCalledWith
(
'
/auth/oauth/oidc/complete-registration
'
,
{
invitation_code
:
'
invite-code
'
,
adopt_display_name
:
false
,
adopt_avatar
:
true
})
})
})
frontend/src/api/__tests__/settings.authSourceDefaults.spec.ts
0 → 100644
View file @
e9de839d
import
{
describe
,
expect
,
it
}
from
'
vitest
'
import
{
appendAuthSourceDefaultsToUpdateRequest
,
buildAuthSourceDefaultsState
,
type
UpdateSettingsRequest
,
}
from
'
@/api/admin/settings
'
describe
(
'
admin settings auth source defaults helpers
'
,
()
=>
{
it
(
'
builds auth source defaults state from flat settings fields
'
,
()
=>
{
const
state
=
buildAuthSourceDefaultsState
({
auth_source_default_email_balance
:
9.5
,
auth_source_default_email_concurrency
:
3
,
auth_source_default_email_subscriptions
:
[
{
group_id
:
1
,
validity_days
:
30
},
],
auth_source_default_email_grant_on_signup
:
false
,
auth_source_default_email_grant_on_first_bind
:
true
,
auth_source_default_linuxdo_balance
:
6
,
auth_source_default_linuxdo_concurrency
:
8
,
auth_source_default_linuxdo_subscriptions
:
[
{
group_id
:
2
,
validity_days
:
60
},
],
auth_source_default_linuxdo_grant_on_signup
:
true
,
auth_source_default_linuxdo_grant_on_first_bind
:
false
,
})
expect
(
state
.
email
).
toEqual
({
balance
:
9.5
,
concurrency
:
3
,
subscriptions
:
[{
group_id
:
1
,
validity_days
:
30
}],
grant_on_signup
:
false
,
grant_on_first_bind
:
true
,
})
expect
(
state
.
linuxdo
).
toEqual
({
balance
:
6
,
concurrency
:
8
,
subscriptions
:
[{
group_id
:
2
,
validity_days
:
60
}],
grant_on_signup
:
true
,
grant_on_first_bind
:
false
,
})
expect
(
state
.
oidc
).
toEqual
({
balance
:
0
,
concurrency
:
5
,
subscriptions
:
[],
grant_on_signup
:
true
,
grant_on_first_bind
:
false
,
})
expect
(
state
.
wechat
).
toEqual
({
balance
:
0
,
concurrency
:
5
,
subscriptions
:
[],
grant_on_signup
:
true
,
grant_on_first_bind
:
false
,
})
})
it
(
'
appends auth source defaults back onto update payload
'
,
()
=>
{
const
payload
:
UpdateSettingsRequest
=
{
site_name
:
'
Sub2API
'
,
}
appendAuthSourceDefaultsToUpdateRequest
(
payload
,
{
email
:
{
balance
:
1.25
,
concurrency
:
2
,
subscriptions
:
[{
group_id
:
3
,
validity_days
:
7
}],
grant_on_signup
:
true
,
grant_on_first_bind
:
false
,
},
linuxdo
:
{
balance
:
0
,
concurrency
:
6
,
subscriptions
:
[],
grant_on_signup
:
false
,
grant_on_first_bind
:
true
,
},
oidc
:
{
balance
:
4
,
concurrency
:
9
,
subscriptions
:
[{
group_id
:
9
,
validity_days
:
90
}],
grant_on_signup
:
true
,
grant_on_first_bind
:
true
,
},
wechat
:
{
balance
:
2
,
concurrency
:
5
,
subscriptions
:
[],
grant_on_signup
:
false
,
grant_on_first_bind
:
false
,
},
})
expect
(
payload
).
toMatchObject
({
site_name
:
'
Sub2API
'
,
auth_source_default_email_balance
:
1.25
,
auth_source_default_email_concurrency
:
2
,
auth_source_default_email_subscriptions
:
[{
group_id
:
3
,
validity_days
:
7
}],
auth_source_default_email_grant_on_signup
:
true
,
auth_source_default_email_grant_on_first_bind
:
false
,
auth_source_default_linuxdo_balance
:
0
,
auth_source_default_linuxdo_concurrency
:
6
,
auth_source_default_linuxdo_subscriptions
:
[],
auth_source_default_linuxdo_grant_on_signup
:
false
,
auth_source_default_linuxdo_grant_on_first_bind
:
true
,
auth_source_default_oidc_balance
:
4
,
auth_source_default_oidc_concurrency
:
9
,
auth_source_default_oidc_subscriptions
:
[{
group_id
:
9
,
validity_days
:
90
}],
auth_source_default_oidc_grant_on_signup
:
true
,
auth_source_default_oidc_grant_on_first_bind
:
true
,
auth_source_default_wechat_balance
:
2
,
auth_source_default_wechat_concurrency
:
5
,
auth_source_default_wechat_subscriptions
:
[],
auth_source_default_wechat_grant_on_signup
:
false
,
auth_source_default_wechat_grant_on_first_bind
:
false
,
})
})
})
frontend/src/api/admin/settings.ts
View file @
e9de839d
...
...
@@ -11,6 +11,81 @@ export interface DefaultSubscriptionSetting {
validity_days
:
number
}
export
type
AuthSourceType
=
'
email
'
|
'
linuxdo
'
|
'
oidc
'
|
'
wechat
'
export
interface
AuthSourceDefaultsValue
{
balance
:
number
concurrency
:
number
subscriptions
:
DefaultSubscriptionSetting
[]
grant_on_signup
:
boolean
grant_on_first_bind
:
boolean
}
export
type
AuthSourceDefaultsState
=
Record
<
AuthSourceType
,
AuthSourceDefaultsValue
>
const
AUTH_SOURCE_TYPES
:
AuthSourceType
[]
=
[
'
email
'
,
'
linuxdo
'
,
'
oidc
'
,
'
wechat
'
]
const
AUTH_SOURCE_DEFAULT_BALANCE
=
0
const
AUTH_SOURCE_DEFAULT_CONCURRENCY
=
5
export
function
normalizeDefaultSubscriptionSettings
(
subscriptions
:
DefaultSubscriptionSetting
[]
|
null
|
undefined
):
DefaultSubscriptionSetting
[]
{
if
(
!
Array
.
isArray
(
subscriptions
))
return
[]
return
subscriptions
.
filter
((
item
)
=>
item
.
group_id
>
0
&&
item
.
validity_days
>
0
)
.
map
((
item
)
=>
({
group_id
:
Math
.
floor
(
item
.
group_id
),
validity_days
:
Math
.
min
(
36500
,
Math
.
max
(
1
,
Math
.
floor
(
item
.
validity_days
)))
}))
}
export
function
buildAuthSourceDefaultsState
(
settings
:
Partial
<
SystemSettings
>
):
AuthSourceDefaultsState
{
const
raw
=
settings
as
Record
<
string
,
unknown
>
return
AUTH_SOURCE_TYPES
.
reduce
((
acc
,
source
)
=>
{
const
subscriptions
=
raw
[
`auth_source_default_
${
source
}
_subscriptions`
]
acc
[
source
]
=
{
balance
:
Number
(
raw
[
`auth_source_default_
${
source
}
_balance`
]
??
AUTH_SOURCE_DEFAULT_BALANCE
),
concurrency
:
Math
.
max
(
1
,
Number
(
raw
[
`auth_source_default_
${
source
}
_concurrency`
]
??
AUTH_SOURCE_DEFAULT_CONCURRENCY
)
),
subscriptions
:
normalizeDefaultSubscriptionSettings
(
Array
.
isArray
(
subscriptions
)
?
(
subscriptions
as
DefaultSubscriptionSetting
[])
:
[]
),
grant_on_signup
:
raw
[
`auth_source_default_
${
source
}
_grant_on_signup`
]
!==
false
,
grant_on_first_bind
:
raw
[
`auth_source_default_
${
source
}
_grant_on_first_bind`
]
===
true
,
}
return
acc
},
{}
as
AuthSourceDefaultsState
)
}
export
function
appendAuthSourceDefaultsToUpdateRequest
(
payload
:
UpdateSettingsRequest
,
authSourceDefaults
:
AuthSourceDefaultsState
):
UpdateSettingsRequest
{
const
target
=
payload
as
Record
<
string
,
unknown
>
for
(
const
source
of
AUTH_SOURCE_TYPES
)
{
const
current
=
authSourceDefaults
[
source
]
target
[
`auth_source_default_
${
source
}
_balance`
]
=
Number
(
current
.
balance
)
||
0
target
[
`auth_source_default_
${
source
}
_concurrency`
]
=
Math
.
max
(
1
,
Math
.
floor
(
Number
(
current
.
concurrency
)
||
AUTH_SOURCE_DEFAULT_CONCURRENCY
)
)
target
[
`auth_source_default_
${
source
}
_subscriptions`
]
=
normalizeDefaultSubscriptionSettings
(
current
.
subscriptions
)
target
[
`auth_source_default_
${
source
}
_grant_on_signup`
]
=
current
.
grant_on_signup
target
[
`auth_source_default_
${
source
}
_grant_on_first_bind`
]
=
current
.
grant_on_first_bind
}
return
payload
}
/**
* System settings interface
*/
...
...
@@ -29,6 +104,27 @@ export interface SystemSettings {
default_balance
:
number
default_concurrency
:
number
default_subscriptions
:
DefaultSubscriptionSetting
[]
auth_source_default_email_balance
?:
number
auth_source_default_email_concurrency
?:
number
auth_source_default_email_subscriptions
?:
DefaultSubscriptionSetting
[]
auth_source_default_email_grant_on_signup
?:
boolean
auth_source_default_email_grant_on_first_bind
?:
boolean
auth_source_default_linuxdo_balance
?:
number
auth_source_default_linuxdo_concurrency
?:
number
auth_source_default_linuxdo_subscriptions
?:
DefaultSubscriptionSetting
[]
auth_source_default_linuxdo_grant_on_signup
?:
boolean
auth_source_default_linuxdo_grant_on_first_bind
?:
boolean
auth_source_default_oidc_balance
?:
number
auth_source_default_oidc_concurrency
?:
number
auth_source_default_oidc_subscriptions
?:
DefaultSubscriptionSetting
[]
auth_source_default_oidc_grant_on_signup
?:
boolean
auth_source_default_oidc_grant_on_first_bind
?:
boolean
auth_source_default_wechat_balance
?:
number
auth_source_default_wechat_concurrency
?:
number
auth_source_default_wechat_subscriptions
?:
DefaultSubscriptionSetting
[]
auth_source_default_wechat_grant_on_signup
?:
boolean
auth_source_default_wechat_grant_on_first_bind
?:
boolean
force_email_on_third_party_signup
?:
boolean
// OEM settings
site_name
:
string
site_logo
:
string
...
...
@@ -137,6 +233,11 @@ export interface SystemSettings {
payment_cancel_rate_limit_window
:
number
payment_cancel_rate_limit_unit
:
string
payment_cancel_rate_limit_window_mode
:
string
payment_visible_method_alipay_source
?:
string
payment_visible_method_wxpay_source
?:
string
payment_visible_method_alipay_enabled
?:
boolean
payment_visible_method_wxpay_enabled
?:
boolean
openai_advanced_scheduler_enabled
?:
boolean
// Balance & quota notification
balance_low_notify_enabled
:
boolean
...
...
@@ -158,6 +259,27 @@ export interface UpdateSettingsRequest {
default_balance
?:
number
default_concurrency
?:
number
default_subscriptions
?:
DefaultSubscriptionSetting
[]
auth_source_default_email_balance
?:
number
auth_source_default_email_concurrency
?:
number
auth_source_default_email_subscriptions
?:
DefaultSubscriptionSetting
[]
auth_source_default_email_grant_on_signup
?:
boolean
auth_source_default_email_grant_on_first_bind
?:
boolean
auth_source_default_linuxdo_balance
?:
number
auth_source_default_linuxdo_concurrency
?:
number
auth_source_default_linuxdo_subscriptions
?:
DefaultSubscriptionSetting
[]
auth_source_default_linuxdo_grant_on_signup
?:
boolean
auth_source_default_linuxdo_grant_on_first_bind
?:
boolean
auth_source_default_oidc_balance
?:
number
auth_source_default_oidc_concurrency
?:
number
auth_source_default_oidc_subscriptions
?:
DefaultSubscriptionSetting
[]
auth_source_default_oidc_grant_on_signup
?:
boolean
auth_source_default_oidc_grant_on_first_bind
?:
boolean
auth_source_default_wechat_balance
?:
number
auth_source_default_wechat_concurrency
?:
number
auth_source_default_wechat_subscriptions
?:
DefaultSubscriptionSetting
[]
auth_source_default_wechat_grant_on_signup
?:
boolean
auth_source_default_wechat_grant_on_first_bind
?:
boolean
force_email_on_third_party_signup
?:
boolean
site_name
?:
string
site_logo
?:
string
site_subtitle
?:
string
...
...
@@ -245,6 +367,11 @@ export interface UpdateSettingsRequest {
payment_cancel_rate_limit_window
?:
number
payment_cancel_rate_limit_unit
?:
string
payment_cancel_rate_limit_window_mode
?:
string
payment_visible_method_alipay_source
?:
string
payment_visible_method_wxpay_source
?:
string
payment_visible_method_alipay_enabled
?:
boolean
payment_visible_method_wxpay_enabled
?:
boolean
openai_advanced_scheduler_enabled
?:
boolean
// Balance & quota notification
balance_low_notify_enabled
?:
boolean
balance_low_notify_threshold
?:
number
...
...
frontend/src/api/auth.ts
View file @
e9de839d
...
...
@@ -198,6 +198,26 @@ export interface PendingOAuthExchangeResponse {
suggested_avatar_url
?:
string
}
export
interface
OAuthAdoptionDecision
{
adoptDisplayName
?:
boolean
adoptAvatar
?:
boolean
}
function
serializeOAuthAdoptionDecision
(
decision
?:
OAuthAdoptionDecision
):
Record
<
string
,
boolean
>
{
const
payload
:
Record
<
string
,
boolean
>
=
{}
if
(
typeof
decision
?.
adoptDisplayName
===
'
boolean
'
)
{
payload
.
adopt_display_name
=
decision
.
adoptDisplayName
}
if
(
typeof
decision
?.
adoptAvatar
===
'
boolean
'
)
{
payload
.
adopt_avatar
=
decision
.
adoptAvatar
}
return
payload
}
/**
* Refresh the access token using the refresh token
* @returns New token pair
...
...
@@ -353,7 +373,8 @@ export async function resetPassword(request: ResetPasswordRequest): Promise<Rese
* @returns Token pair on success
*/
export
async
function
completeLinuxDoOAuthRegistration
(
invitationCode
:
string
invitationCode
:
string
,
decision
?:
OAuthAdoptionDecision
):
Promise
<
{
access_token
:
string
;
refresh_token
:
string
;
expires_in
:
number
;
token_type
:
string
}
>
{
const
{
data
}
=
await
apiClient
.
post
<
{
access_token
:
string
...
...
@@ -361,7 +382,8 @@ export async function completeLinuxDoOAuthRegistration(
expires_in
:
number
token_type
:
string
}
>
(
'
/auth/oauth/linuxdo/complete-registration
'
,
{
invitation_code
:
invitationCode
invitation_code
:
invitationCode
,
...
serializeOAuthAdoptionDecision
(
decision
)
})
return
data
}
...
...
@@ -372,7 +394,8 @@ export async function completeLinuxDoOAuthRegistration(
* @returns Token pair on success
*/
export
async
function
completeOIDCOAuthRegistration
(
invitationCode
:
string
invitationCode
:
string
,
decision
?:
OAuthAdoptionDecision
):
Promise
<
{
access_token
:
string
;
refresh_token
:
string
;
expires_in
:
number
;
token_type
:
string
}
>
{
const
{
data
}
=
await
apiClient
.
post
<
{
access_token
:
string
...
...
@@ -380,13 +403,19 @@ export async function completeOIDCOAuthRegistration(
expires_in
:
number
token_type
:
string
}
>
(
'
/auth/oauth/oidc/complete-registration
'
,
{
invitation_code
:
invitationCode
invitation_code
:
invitationCode
,
...
serializeOAuthAdoptionDecision
(
decision
)
})
return
data
}
export
async
function
exchangePendingOAuthCompletion
():
Promise
<
PendingOAuthExchangeResponse
>
{
const
{
data
}
=
await
apiClient
.
post
<
PendingOAuthExchangeResponse
>
(
'
/auth/oauth/pending/exchange
'
,
{})
export
async
function
exchangePendingOAuthCompletion
(
decision
?:
OAuthAdoptionDecision
):
Promise
<
PendingOAuthExchangeResponse
>
{
const
{
data
}
=
await
apiClient
.
post
<
PendingOAuthExchangeResponse
>
(
'
/auth/oauth/pending/exchange
'
,
serializeOAuthAdoptionDecision
(
decision
)
)
return
data
}
...
...
frontend/src/components/auth/WechatOAuthSection.vue
0 → 100644
View file @
e9de839d
<
template
>
<div
class=
"space-y-4"
>
<button
type=
"button"
:disabled=
"disabled"
class=
"btn btn-secondary w-full"
@
click=
"startLogin"
>
<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"
>
W
</span>
{{
t
(
'
auth.oidc.signIn
'
,
{
providerName
}
)
}}
<
/button
>
<
div
v
-
if
=
"
showDivider
"
class
=
"
flex items-center gap-3
"
>
<
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
"
>
{{
t
(
'
auth.oauthOrContinue
'
)
}}
<
/span
>
<
div
class
=
"
h-px flex-1 bg-gray-200 dark:bg-dark-700
"
><
/div
>
<
/div
>
<
/div
>
<
/template
>
<
script
setup
lang
=
"
ts
"
>
import
{
useRoute
}
from
'
vue-router
'
import
{
useI18n
}
from
'
vue-i18n
'
withDefaults
(
defineProps
<
{
disabled
?:
boolean
showDivider
?:
boolean
}
>
(),
{
showDivider
:
true
,
}
)
const
route
=
useRoute
()
const
{
t
}
=
useI18n
()
const
providerName
=
'
WeChat
'
function
resolveWeChatOAuthMode
():
'
open
'
|
'
mp
'
{
if
(
typeof
navigator
===
'
undefined
'
)
{
return
'
open
'
}
return
/MicroMessenger/i
.
test
(
navigator
.
userAgent
)
?
'
mp
'
:
'
open
'
}
function
startLogin
():
void
{
const
redirectTo
=
(
route
.
query
.
redirect
as
string
)
||
'
/dashboard
'
const
apiBase
=
(
import
.
meta
.
env
.
VITE_API_BASE_URL
as
string
|
undefined
)
||
'
/api/v1
'
const
normalized
=
apiBase
.
replace
(
/
\/
$/
,
''
)
const
mode
=
resolveWeChatOAuthMode
()
const
startURL
=
`${normalized
}
/auth/oauth/wechat/start?mode=${mode
}
&redirect=${encodeURIComponent(redirectTo)
}
`
window
.
location
.
href
=
startURL
}
<
/script
>
frontend/src/components/auth/__tests__/WechatOAuthSection.spec.ts
0 → 100644
View file @
e9de839d
import
{
mount
}
from
'
@vue/test-utils
'
import
{
afterEach
,
beforeEach
,
describe
,
expect
,
it
,
vi
}
from
'
vitest
'
import
WechatOAuthSection
from
'
@/components/auth/WechatOAuthSection.vue
'
const
routeState
=
vi
.
hoisted
(()
=>
({
query
:
{}
as
Record
<
string
,
unknown
>
,
}))
const
locationState
=
vi
.
hoisted
(()
=>
({
current
:
{
href
:
'
http://localhost/login
'
}
as
{
href
:
string
},
}))
vi
.
mock
(
'
vue-router
'
,
()
=>
({
useRoute
:
()
=>
routeState
,
}))
vi
.
mock
(
'
vue-i18n
'
,
()
=>
({
useI18n
:
()
=>
({
t
:
(
key
:
string
,
params
?:
Record
<
string
,
string
>
)
=>
{
if
(
key
===
'
auth.oidc.signIn
'
)
{
return
`Continue with
${
params
?.
providerName
??
''
}
`
.
trim
()
}
if
(
key
===
'
auth.oauthOrContinue
'
)
{
return
'
or continue
'
}
return
key
},
}),
}))
describe
(
'
WechatOAuthSection
'
,
()
=>
{
beforeEach
(()
=>
{
routeState
.
query
=
{
redirect
:
'
/billing?plan=pro
'
}
locationState
.
current
=
{
href
:
'
http://localhost/login
'
}
Object
.
defineProperty
(
window
,
'
location
'
,
{
configurable
:
true
,
value
:
locationState
.
current
,
})
Object
.
defineProperty
(
window
.
navigator
,
'
userAgent
'
,
{
configurable
:
true
,
value
:
'
Mozilla/5.0
'
,
})
})
afterEach
(()
=>
{
vi
.
unstubAllGlobals
()
})
it
(
'
starts the open WeChat OAuth flow with the current redirect target
'
,
async
()
=>
{
const
wrapper
=
mount
(
WechatOAuthSection
)
expect
(
wrapper
.
text
()).
toContain
(
'
WeChat
'
)
await
wrapper
.
get
(
'
button
'
).
trigger
(
'
click
'
)
expect
(
locationState
.
current
.
href
).
toContain
(
'
/api/v1/auth/oauth/wechat/start?mode=open&redirect=%2Fbilling%3Fplan%3Dpro
'
)
})
it
(
'
uses mp mode inside the WeChat browser
'
,
async
()
=>
{
Object
.
defineProperty
(
window
.
navigator
,
'
userAgent
'
,
{
configurable
:
true
,
value
:
'
Mozilla/5.0 MicroMessenger
'
,
})
const
wrapper
=
mount
(
WechatOAuthSection
)
await
wrapper
.
get
(
'
button
'
).
trigger
(
'
click
'
)
expect
(
locationState
.
current
.
href
).
toContain
(
'
/api/v1/auth/oauth/wechat/start?mode=mp&redirect=%2Fbilling%3Fplan%3Dpro
'
)
})
})
frontend/src/components/payment/PaymentStatusPanel.vue
View file @
e9de839d
...
...
@@ -141,7 +141,9 @@ const props = defineProps<{
orderType
?:
string
}
>
()
const
emit
=
defineEmits
<
{
done
:
[];
success
:
[]
}
>
()
type
PaymentOutcome
=
'
success
'
|
'
cancelled
'
|
'
expired
'
const
emit
=
defineEmits
<
{
done
:
[];
success
:
[];
settled
:
[
outcome
:
PaymentOutcome
]
}
>
()
const
{
t
}
=
useI18n
()
const
paymentStore
=
usePaymentStore
()
...
...
@@ -154,7 +156,7 @@ const cancelling = ref(false)
const
paidOrder
=
ref
<
PaymentOrder
|
null
>
(
null
)
// Terminal outcome: null = still active, 'success' | 'cancelled' | 'expired'
const
outcome
=
ref
<
'
success
'
|
'
cancelled
'
|
'
expired
'
|
null
>
(
null
)
const
outcome
=
ref
<
PaymentOutcome
|
null
>
(
null
)
let
pollTimer
:
ReturnType
<
typeof
setInterval
>
|
null
=
null
let
countdownTimer
:
ReturnType
<
typeof
setInterval
>
|
null
=
null
...
...
@@ -194,10 +196,19 @@ const countdownDisplay = computed(() => {
function
reopenPopup
()
{
if
(
props
.
payUrl
)
{
window
.
open
(
props
.
payUrl
,
'
paymentPopup
'
,
POPUP_WINDOW_FEATURES
)
const
win
=
window
.
open
(
props
.
payUrl
,
'
paymentPopup
'
,
POPUP_WINDOW_FEATURES
)
if
(
!
win
||
win
.
closed
)
{
window
.
location
.
href
=
props
.
payUrl
}
}
}
function
setOutcome
(
next
:
PaymentOutcome
)
{
if
(
outcome
.
value
===
next
)
return
outcome
.
value
=
next
emit
(
'
settled
'
,
next
)
}
async
function
renderQR
()
{
await
nextTick
()
if
(
!
qrCanvas
.
value
||
!
qrUrl
.
value
)
return
...
...
@@ -214,23 +225,23 @@ async function pollStatus() {
if
(
order
.
status
===
'
COMPLETED
'
||
order
.
status
===
'
PAID
'
)
{
cleanup
()
paidOrder
.
value
=
order
o
utcome
.
value
=
'
success
'
setO
utcome
(
'
success
'
)
emit
(
'
success
'
)
}
else
if
(
order
.
status
===
'
CANCELLED
'
)
{
cleanup
()
o
utcome
.
value
=
'
cancelled
'
setO
utcome
(
'
cancelled
'
)
}
else
if
(
order
.
status
===
'
EXPIRED
'
||
order
.
status
===
'
FAILED
'
)
{
cleanup
()
o
utcome
.
value
=
'
expired
'
setO
utcome
(
'
expired
'
)
}
}
function
startCountdown
(
seconds
:
number
)
{
remainingSeconds
.
value
=
Math
.
max
(
0
,
seconds
)
if
(
remainingSeconds
.
value
<=
0
)
{
o
utcome
.
value
=
'
expired
'
;
return
}
if
(
remainingSeconds
.
value
<=
0
)
{
setO
utcome
(
'
expired
'
)
;
return
}
countdownTimer
=
setInterval
(()
=>
{
remainingSeconds
.
value
--
if
(
remainingSeconds
.
value
<=
0
)
{
o
utcome
.
value
=
'
expired
'
;
cleanup
()
}
if
(
remainingSeconds
.
value
<=
0
)
{
setO
utcome
(
'
expired
'
)
;
cleanup
()
}
},
1000
)
}
...
...
@@ -240,7 +251,7 @@ async function handleCancel() {
try
{
await
paymentAPI
.
cancelOrder
(
props
.
orderId
)
cleanup
()
o
utcome
.
value
=
'
cancelled
'
setO
utcome
(
'
cancelled
'
)
}
catch
(
err
:
unknown
)
{
appStore
.
showError
(
extractApiErrorMessage
(
err
,
t
(
'
common.error
'
)))
}
finally
{
...
...
frontend/src/components/payment/__tests__/paymentFlow.spec.ts
0 → 100644
View file @
e9de839d
import
{
describe
,
expect
,
it
}
from
'
vitest
'
import
type
{
CreateOrderResult
,
MethodLimit
}
from
'
@/types/payment
'
import
{
decidePaymentLaunch
,
getVisibleMethods
,
readPaymentRecoverySnapshot
,
type
PaymentRecoverySnapshot
,
}
from
'
@/components/payment/paymentFlow
'
function
methodLimit
(
overrides
:
Partial
<
MethodLimit
>
=
{}):
MethodLimit
{
return
{
daily_limit
:
0
,
daily_used
:
0
,
daily_remaining
:
0
,
single_min
:
0
,
single_max
:
0
,
fee_rate
:
0
,
available
:
true
,
...
overrides
,
}
}
function
createOrderResult
(
overrides
:
Partial
<
CreateOrderResult
>
=
{}):
CreateOrderResult
{
return
{
order_id
:
101
,
amount
:
88
,
pay_amount
:
88
,
fee_rate
:
0
,
expires_at
:
'
2099-01-01T00:10:00.000Z
'
,
...
overrides
,
}
}
describe
(
'
getVisibleMethods
'
,
()
=>
{
it
(
'
filters hidden provider methods and normalizes aliases
'
,
()
=>
{
const
visible
=
getVisibleMethods
({
alipay_direct
:
methodLimit
({
single_min
:
5
}),
wxpay
:
methodLimit
({
single_max
:
100
}),
stripe
:
methodLimit
({
fee_rate
:
3
}),
})
expect
(
visible
).
toEqual
({
alipay
:
methodLimit
({
single_min
:
5
}),
wxpay
:
methodLimit
({
single_max
:
100
}),
})
})
it
(
'
prefers canonical visible methods over aliases when both exist
'
,
()
=>
{
const
visible
=
getVisibleMethods
({
alipay
:
methodLimit
({
single_min
:
2
}),
alipay_direct
:
methodLimit
({
single_min
:
9
}),
wxpay_direct
:
methodLimit
({
fee_rate
:
1.2
}),
})
expect
(
visible
.
alipay
.
single_min
).
toBe
(
2
)
expect
(
visible
.
wxpay
.
fee_rate
).
toBe
(
1.2
)
})
})
describe
(
'
decidePaymentLaunch
'
,
()
=>
{
it
(
'
uses Stripe popup waiting flow for desktop Alipay client secret
'
,
()
=>
{
const
decision
=
decidePaymentLaunch
(
createOrderResult
({
client_secret
:
'
cs_test
'
,
resume_token
:
'
resume-1
'
,
}),
{
visibleMethod
:
'
alipay
'
,
orderType
:
'
balance
'
,
isMobile
:
false
,
})
expect
(
decision
.
kind
).
toBe
(
'
stripe_popup
'
)
expect
(
decision
.
paymentState
.
paymentType
).
toBe
(
'
alipay
'
)
expect
(
decision
.
stripeMethod
).
toBe
(
'
alipay
'
)
expect
(
decision
.
recovery
.
resumeToken
).
toBe
(
'
resume-1
'
)
})
it
(
'
uses Stripe route flow for mobile WeChat client secret
'
,
()
=>
{
const
decision
=
decidePaymentLaunch
(
createOrderResult
({
client_secret
:
'
cs_test
'
,
}),
{
visibleMethod
:
'
wxpay
'
,
orderType
:
'
subscription
'
,
isMobile
:
true
,
})
expect
(
decision
.
kind
).
toBe
(
'
stripe_route
'
)
expect
(
decision
.
stripeMethod
).
toBe
(
'
wechat_pay
'
)
expect
(
decision
.
paymentState
.
orderType
).
toBe
(
'
subscription
'
)
})
it
(
'
keeps hosted redirect metadata for recovery flows
'
,
()
=>
{
const
decision
=
decidePaymentLaunch
(
createOrderResult
({
pay_url
:
'
https://pay.example.com/session/abc
'
,
payment_mode
:
'
popup
'
,
resume_token
:
'
resume-2
'
,
}),
{
visibleMethod
:
'
wxpay
'
,
orderType
:
'
balance
'
,
isMobile
:
false
,
})
expect
(
decision
.
kind
).
toBe
(
'
redirect_waiting
'
)
expect
(
decision
.
paymentState
.
payUrl
).
toBe
(
'
https://pay.example.com/session/abc
'
)
expect
(
decision
.
recovery
.
paymentMode
).
toBe
(
'
popup
'
)
expect
(
decision
.
recovery
.
resumeToken
).
toBe
(
'
resume-2
'
)
})
})
describe
(
'
readPaymentRecoverySnapshot
'
,
()
=>
{
it
(
'
restores an unexpired snapshot when the resume token matches
'
,
()
=>
{
const
snapshot
:
PaymentRecoverySnapshot
=
{
orderId
:
33
,
amount
:
18
,
qrCode
:
''
,
expiresAt
:
'
2099-01-01T00:10:00.000Z
'
,
paymentType
:
'
alipay
'
,
payUrl
:
'
https://pay.example.com/session/33
'
,
clientSecret
:
''
,
payAmount
:
18
,
orderType
:
'
balance
'
,
paymentMode
:
'
popup
'
,
resumeToken
:
'
resume-33
'
,
createdAt
:
Date
.
UTC
(
2099
,
0
,
1
,
0
,
0
,
0
),
}
const
restored
=
readPaymentRecoverySnapshot
(
JSON
.
stringify
(
snapshot
),
{
now
:
Date
.
UTC
(
2099
,
0
,
1
,
0
,
1
,
0
),
resumeToken
:
'
resume-33
'
,
})
expect
(
restored
?.
orderId
).
toBe
(
33
)
})
it
(
'
drops expired or mismatched recovery snapshots
'
,
()
=>
{
const
expiredSnapshot
:
PaymentRecoverySnapshot
=
{
orderId
:
55
,
amount
:
18
,
qrCode
:
''
,
expiresAt
:
'
2024-01-01T00:10:00.000Z
'
,
paymentType
:
'
wxpay
'
,
payUrl
:
'
https://pay.example.com/session/55
'
,
clientSecret
:
''
,
payAmount
:
18
,
orderType
:
'
balance
'
,
paymentMode
:
'
popup
'
,
resumeToken
:
'
resume-55
'
,
createdAt
:
Date
.
UTC
(
2024
,
0
,
1
,
0
,
0
,
0
),
}
expect
(
readPaymentRecoverySnapshot
(
JSON
.
stringify
(
expiredSnapshot
),
{
now
:
Date
.
UTC
(
2024
,
0
,
1
,
0
,
20
,
0
),
resumeToken
:
'
resume-55
'
,
})).
toBeNull
()
expect
(
readPaymentRecoverySnapshot
(
JSON
.
stringify
({
...
expiredSnapshot
,
expiresAt
:
'
2099-01-01T00:10:00.000Z
'
,
}),
{
now
:
Date
.
UTC
(
2099
,
0
,
1
,
0
,
1
,
0
),
resumeToken
:
'
other-token
'
,
})).
toBeNull
()
})
})
frontend/src/components/payment/paymentFlow.ts
0 → 100644
View file @
e9de839d
import
type
{
CreateOrderResult
,
MethodLimit
,
OrderType
}
from
'
@/types/payment
'
export
const
PAYMENT_RECOVERY_STORAGE_KEY
=
'
payment.recovery.current
'
const
VISIBLE_METHOD_ALIASES
=
{
alipay
:
'
alipay
'
,
alipay_direct
:
'
alipay
'
,
wxpay
:
'
wxpay
'
,
wxpay_direct
:
'
wxpay
'
,
}
as
const
export
type
VisiblePaymentMethod
=
'
alipay
'
|
'
wxpay
'
export
type
StripeVisibleMethod
=
'
alipay
'
|
'
wechat_pay
'
export
type
PaymentLaunchKind
=
|
'
qr_waiting
'
|
'
redirect_waiting
'
|
'
stripe_popup
'
|
'
stripe_route
'
|
'
unhandled
'
export
interface
PaymentRecoverySnapshot
{
orderId
:
number
amount
:
number
qrCode
:
string
expiresAt
:
string
paymentType
:
string
payUrl
:
string
clientSecret
:
string
payAmount
:
number
orderType
:
OrderType
|
''
paymentMode
:
string
resumeToken
:
string
createdAt
:
number
}
export
interface
PaymentLaunchContext
{
visibleMethod
:
string
orderType
:
OrderType
isMobile
:
boolean
now
?:
number
stripePopupUrl
?:
string
stripeRouteUrl
?:
string
}
export
interface
PaymentLaunchDecision
{
kind
:
PaymentLaunchKind
paymentState
:
PaymentRecoverySnapshot
recovery
:
PaymentRecoverySnapshot
stripeMethod
?:
StripeVisibleMethod
}
type
CreateOrderFlowResult
=
CreateOrderResult
&
{
resume_token
?:
string
}
type
StorageWriter
=
Pick
<
Storage
,
'
removeItem
'
|
'
setItem
'
>
export
function
normalizeVisibleMethod
(
method
:
string
):
VisiblePaymentMethod
|
''
{
const
normalized
=
VISIBLE_METHOD_ALIASES
[
method
.
trim
()
as
keyof
typeof
VISIBLE_METHOD_ALIASES
]
return
normalized
??
''
}
export
function
getVisibleMethods
(
methods
:
Record
<
string
,
MethodLimit
>
):
Record
<
string
,
MethodLimit
>
{
const
visible
:
Record
<
string
,
MethodLimit
>
=
{}
Object
.
entries
(
methods
).
forEach
(([
type
,
limit
])
=>
{
const
normalized
=
normalizeVisibleMethod
(
type
)
if
(
!
normalized
)
return
const
isCanonical
=
type
===
normalized
const
existing
=
visible
[
normalized
]
if
(
!
existing
||
isCanonical
)
{
visible
[
normalized
]
=
{
...
limit
}
}
})
return
visible
}
export
function
decidePaymentLaunch
(
result
:
CreateOrderFlowResult
,
context
:
PaymentLaunchContext
,
):
PaymentLaunchDecision
{
const
visibleMethod
=
normalizeVisibleMethod
(
context
.
visibleMethod
)
||
context
.
visibleMethod
const
baseState
=
createPaymentRecoverySnapshot
({
orderId
:
result
.
order_id
,
amount
:
result
.
amount
,
qrCode
:
result
.
qr_code
||
''
,
expiresAt
:
result
.
expires_at
||
''
,
paymentType
:
visibleMethod
,
payUrl
:
result
.
pay_url
||
''
,
clientSecret
:
result
.
client_secret
||
''
,
payAmount
:
result
.
pay_amount
,
orderType
:
context
.
orderType
,
paymentMode
:
(
result
.
payment_mode
||
''
).
trim
(),
resumeToken
:
result
.
resume_token
||
''
,
},
context
.
now
)
if
(
baseState
.
clientSecret
)
{
const
stripeMethod
:
StripeVisibleMethod
=
visibleMethod
===
'
wxpay
'
?
'
wechat_pay
'
:
'
alipay
'
const
kind
:
PaymentLaunchKind
=
stripeMethod
===
'
alipay
'
&&
!
context
.
isMobile
?
'
stripe_popup
'
:
'
stripe_route
'
const
payUrl
=
kind
===
'
stripe_popup
'
?
context
.
stripePopupUrl
||
context
.
stripeRouteUrl
||
''
:
context
.
stripeRouteUrl
||
context
.
stripePopupUrl
||
''
const
paymentState
=
{
...
baseState
,
payUrl
}
return
{
kind
,
paymentState
,
recovery
:
paymentState
,
stripeMethod
}
}
if
(
baseState
.
qrCode
)
{
return
{
kind
:
'
qr_waiting
'
,
paymentState
:
baseState
,
recovery
:
baseState
}
}
if
(
baseState
.
payUrl
)
{
return
{
kind
:
'
redirect_waiting
'
,
paymentState
:
baseState
,
recovery
:
baseState
}
}
return
{
kind
:
'
unhandled
'
,
paymentState
:
baseState
,
recovery
:
baseState
}
}
export
function
createPaymentRecoverySnapshot
(
state
:
Omit
<
PaymentRecoverySnapshot
,
'
createdAt
'
>
,
now
=
Date
.
now
(),
):
PaymentRecoverySnapshot
{
return
{
...
state
,
createdAt
:
now
,
}
}
export
function
writePaymentRecoverySnapshot
(
storage
:
StorageWriter
,
snapshot
:
PaymentRecoverySnapshot
,
key
=
PAYMENT_RECOVERY_STORAGE_KEY
,
):
void
{
storage
.
setItem
(
key
,
JSON
.
stringify
(
snapshot
))
}
export
function
clearPaymentRecoverySnapshot
(
storage
:
Pick
<
Storage
,
'
removeItem
'
>
,
key
=
PAYMENT_RECOVERY_STORAGE_KEY
,
):
void
{
storage
.
removeItem
(
key
)
}
export
function
readPaymentRecoverySnapshot
(
raw
:
string
|
null
|
undefined
,
options
:
{
now
?:
number
;
resumeToken
?:
string
}
=
{},
):
PaymentRecoverySnapshot
|
null
{
if
(
!
raw
)
return
null
try
{
const
parsed
=
JSON
.
parse
(
raw
)
as
Partial
<
PaymentRecoverySnapshot
>
if
(
typeof
parsed
.
orderId
!==
'
number
'
||
typeof
parsed
.
amount
!==
'
number
'
||
typeof
parsed
.
qrCode
!==
'
string
'
||
typeof
parsed
.
expiresAt
!==
'
string
'
||
typeof
parsed
.
paymentType
!==
'
string
'
||
typeof
parsed
.
payUrl
!==
'
string
'
||
typeof
parsed
.
clientSecret
!==
'
string
'
||
typeof
parsed
.
payAmount
!==
'
number
'
||
typeof
parsed
.
paymentMode
!==
'
string
'
||
typeof
parsed
.
resumeToken
!==
'
string
'
||
typeof
parsed
.
createdAt
!==
'
number
'
)
{
return
null
}
const
now
=
options
.
now
??
Date
.
now
()
const
expiresAt
=
Date
.
parse
(
parsed
.
expiresAt
)
if
(
Number
.
isFinite
(
expiresAt
)
&&
expiresAt
<=
now
)
{
return
null
}
if
(
options
.
resumeToken
&&
parsed
.
resumeToken
&&
parsed
.
resumeToken
!==
options
.
resumeToken
)
{
return
null
}
return
{
orderId
:
parsed
.
orderId
,
amount
:
parsed
.
amount
,
qrCode
:
parsed
.
qrCode
,
expiresAt
:
parsed
.
expiresAt
,
paymentType
:
parsed
.
paymentType
,
payUrl
:
parsed
.
payUrl
,
clientSecret
:
parsed
.
clientSecret
,
payAmount
:
parsed
.
payAmount
,
orderType
:
parsed
.
orderType
===
'
subscription
'
?
'
subscription
'
:
'
balance
'
,
paymentMode
:
parsed
.
paymentMode
,
resumeToken
:
parsed
.
resumeToken
,
createdAt
:
parsed
.
createdAt
,
}
}
catch
{
return
null
}
}
frontend/src/router/__tests__/wechat-route.spec.ts
0 → 100644
View file @
e9de839d
import
{
describe
,
expect
,
it
,
vi
}
from
'
vitest
'
const
authStore
=
vi
.
hoisted
(()
=>
({
checkAuth
:
vi
.
fn
(),
isAuthenticated
:
false
,
isAdmin
:
false
,
isSimpleMode
:
false
,
}))
const
appStore
=
vi
.
hoisted
(()
=>
({
siteName
:
'
Sub2API
'
,
backendModeEnabled
:
false
,
cachedPublicSettings
:
null
as
null
|
Record
<
string
,
unknown
>
,
}))
vi
.
mock
(
'
@/stores/auth
'
,
()
=>
({
useAuthStore
:
()
=>
authStore
,
}))
vi
.
mock
(
'
@/stores/app
'
,
()
=>
({
useAppStore
:
()
=>
appStore
,
}))
vi
.
mock
(
'
@/stores/adminSettings
'
,
()
=>
({
useAdminSettingsStore
:
()
=>
({
customMenuItems
:
[],
}),
}))
vi
.
mock
(
'
@/composables/useNavigationLoading
'
,
()
=>
({
useNavigationLoadingState
:
()
=>
({
startNavigation
:
vi
.
fn
(),
endNavigation
:
vi
.
fn
(),
isLoading
:
{
value
:
false
},
}),
}))
vi
.
mock
(
'
@/composables/useRoutePrefetch
'
,
()
=>
({
useRoutePrefetch
:
()
=>
({
triggerPrefetch
:
vi
.
fn
(),
cancelPendingPrefetch
:
vi
.
fn
(),
resetPrefetchState
:
vi
.
fn
(),
}),
}))
describe
(
'
router WeChat OAuth route
'
,
()
=>
{
it
(
'
registers the WeChat callback route as a public route
'
,
async
()
=>
{
const
{
default
:
router
}
=
await
import
(
'
@/router
'
)
const
route
=
router
.
getRoutes
().
find
((
record
)
=>
record
.
name
===
'
WeChatOAuthCallback
'
)
expect
(
route
?.
path
).
toBe
(
'
/auth/wechat/callback
'
)
expect
(
route
?.
meta
.
requiresAuth
).
toBe
(
false
)
expect
(
route
?.
meta
.
title
).
toBe
(
'
WeChat OAuth Callback
'
)
})
})
frontend/src/router/index.ts
View file @
e9de839d
...
...
@@ -83,6 +83,15 @@ const routes: RouteRecordRaw[] = [
title
:
'
LinuxDo OAuth Callback
'
}
},
{
path
:
'
/auth/wechat/callback
'
,
name
:
'
WeChatOAuthCallback
'
,
component
:
()
=>
import
(
'
@/views/auth/WechatCallbackView.vue
'
),
meta
:
{
requiresAuth
:
false
,
title
:
'
WeChat OAuth Callback
'
}
},
{
path
:
'
/auth/oidc/callback
'
,
name
:
'
OIDCOAuthCallback
'
,
...
...
frontend/src/stores/app.ts
View file @
e9de839d
...
...
@@ -336,6 +336,7 @@ export const useAppStore = defineStore('app', () => {
custom_menu_items
:
[],
custom_endpoints
:
[],
linuxdo_oauth_enabled
:
false
,
wechat_oauth_enabled
:
false
,
oidc_oauth_enabled
:
false
,
oidc_oauth_provider_name
:
'
OIDC
'
,
backend_mode_enabled
:
false
,
...
...
frontend/src/types/index.ts
View file @
e9de839d
...
...
@@ -123,6 +123,7 @@ export interface PublicSettings {
custom_menu_items
:
CustomMenuItem
[]
custom_endpoints
:
CustomEndpoint
[]
linuxdo_oauth_enabled
:
boolean
wechat_oauth_enabled
:
boolean
oidc_oauth_enabled
:
boolean
oidc_oauth_provider_name
:
string
backend_mode_enabled
:
boolean
...
...
frontend/src/views/admin/SettingsView.vue
View file @
e9de839d
...
...
@@ -1586,6 +1586,221 @@
<
/div
>
<
/div
>
<
/div
>
<
div
class
=
"
card
"
>
<
div
class
=
"
border-b border-gray-100 px-6 py-4 dark:border-dark-700
"
>
<
h2
class
=
"
text-lg font-semibold text-gray-900 dark:text-white
"
>
{{
localText
(
'
认证来源默认值
'
,
'
Auth Source Defaults
'
)
}}
<
/h2
>
<
p
class
=
"
mt-1 text-sm text-gray-500 dark:text-gray-400
"
>
{{
localText
(
'
按注册来源配置新用户默认余额、并发、订阅与授权策略。
'
,
'
Configure per-source default balance, concurrency, subscriptions, and grant rules.
'
)
}}
<
/p
>
<
/div
>
<
div
class
=
"
space-y-6 p-6
"
>
<
div
class
=
"
flex items-center justify-between rounded border border-gray-200 px-4 py-3 dark:border-dark-700
"
>
<
div
>
<
label
class
=
"
font-medium text-gray-900 dark:text-white
"
>
{{
localText
(
'
第三方注册强制补充邮箱
'
,
'
Require email on third-party signup
'
)
}}
<
/label
>
<
p
class
=
"
text-sm text-gray-500 dark:text-gray-400
"
>
{{
localText
(
'
启用后,Linux DO、OIDC、微信注册缺少邮箱时必须先补充邮箱地址。
'
,
'
When enabled, Linux DO, OIDC, and WeChat signups must provide an email before account creation.
'
)
}}
<
/p
>
<
/div
>
<
Toggle
v
-
model
=
"
form.force_email_on_third_party_signup
"
/>
<
/div
>
<
div
class
=
"
grid grid-cols-1 gap-4 xl:grid-cols-2
"
>
<
div
v
-
for
=
"
authSource in authSourceDefaultsMeta
"
:
key
=
"
authSource.source
"
class
=
"
rounded-xl border border-gray-200 p-4 dark:border-dark-700
"
>
<
div
class
=
"
mb-4
"
>
<
div
class
=
"
font-medium text-gray-900 dark:text-white
"
>
{{
authSource
.
title
}}
<
/div
>
<
p
class
=
"
mt-1 text-sm text-gray-500 dark:text-gray-400
"
>
{{
authSource
.
description
}}
<
/p
>
<
/div
>
<
div
class
=
"
grid grid-cols-1 gap-4 md:grid-cols-2
"
>
<
div
>
<
label
class
=
"
mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300
"
>
{{
t
(
'
admin.settings.defaults.defaultBalance
'
)
}}
<
/label
>
<
input
v
-
model
.
number
=
"
authSourceDefaults[authSource.source].balance
"
type
=
"
number
"
step
=
"
0.01
"
min
=
"
0
"
class
=
"
input
"
placeholder
=
"
0.00
"
/>
<
/div
>
<
div
>
<
label
class
=
"
mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300
"
>
{{
t
(
'
admin.settings.defaults.defaultConcurrency
'
)
}}
<
/label
>
<
input
v
-
model
.
number
=
"
authSourceDefaults[authSource.source].concurrency
"
type
=
"
number
"
min
=
"
1
"
class
=
"
input
"
placeholder
=
"
5
"
/>
<
/div
>
<
/div
>
<
div
class
=
"
mt-4 grid grid-cols-1 gap-3 md:grid-cols-2
"
>
<
div
class
=
"
flex items-center justify-between rounded border border-gray-200 px-4 py-3 dark:border-dark-700
"
>
<
div
>
<
label
class
=
"
font-medium text-gray-900 dark:text-white
"
>
{{
localText
(
'
注册即授权
'
,
'
Grant on signup
'
)
}}
<
/label
>
<
p
class
=
"
mt-0.5 text-xs text-gray-500 dark:text-gray-400
"
>
{{
localText
(
'
来源首次注册成功后立即发放默认权益。
'
,
'
Grant default entitlements immediately after signup.
'
)
}}
<
/p
>
<
/div
>
<
Toggle
v
-
model
=
"
authSourceDefaults[authSource.source].grant_on_signup
"
/>
<
/div
>
<
div
class
=
"
flex items-center justify-between rounded border border-gray-200 px-4 py-3 dark:border-dark-700
"
>
<
div
>
<
label
class
=
"
font-medium text-gray-900 dark:text-white
"
>
{{
localText
(
'
首次绑定时授权
'
,
'
Grant on first bind
'
)
}}
<
/label
>
<
p
class
=
"
mt-0.5 text-xs text-gray-500 dark:text-gray-400
"
>
{{
localText
(
'
来源首次绑定到现有账号时发放默认权益。
'
,
'
Grant default entitlements when the source is first bound to an existing user.
'
)
}}
<
/p
>
<
/div
>
<
Toggle
v
-
model
=
"
authSourceDefaults[authSource.source].grant_on_first_bind
"
/>
<
/div
>
<
/div
>
<
div
class
=
"
mt-4 border-t border-gray-100 pt-4 dark:border-dark-700
"
>
<
div
class
=
"
mb-3 flex items-center justify-between
"
>
<
div
>
<
label
class
=
"
font-medium text-gray-900 dark:text-white
"
>
{{
localText
(
'
默认订阅
'
,
'
Default subscriptions
'
)
}}
<
/label
>
<
p
class
=
"
text-sm text-gray-500 dark:text-gray-400
"
>
{{
localText
(
'
仅对当前认证来源生效,未配置时不追加来源专属订阅。
'
,
'
Applies only to this auth source. Leave empty to skip source-specific subscriptions.
'
)
}}
<
/p
>
<
/div
>
<
button
type
=
"
button
"
class
=
"
btn btn-secondary btn-sm
"
@
click
=
"
addAuthSourceDefaultSubscription(authSource.source)
"
:
disabled
=
"
subscriptionGroups.length === 0
"
>
{{
t
(
'
admin.settings.defaults.addDefaultSubscription
'
)
}}
<
/button
>
<
/div
>
<
div
v
-
if
=
"
authSourceDefaults[authSource.source].subscriptions.length === 0
"
class
=
"
rounded border border-dashed border-gray-300 px-4 py-3 text-sm text-gray-500 dark:border-dark-600 dark:text-gray-400
"
>
{{
localText
(
'
当前来源未配置专属默认订阅。
'
,
'
No source-specific default subscriptions configured.
'
)
}}
<
/div
>
<
div
v
-
else
class
=
"
space-y-3
"
>
<
div
v
-
for
=
"
(item, index) in authSourceDefaults[authSource.source].subscriptions
"
:
key
=
"
`${authSource.source
}
-sub-${index
}
`
"
class
=
"
grid grid-cols-1 gap-3 rounded border border-gray-200 p-3 md:grid-cols-[1fr_160px_auto] dark:border-dark-600
"
>
<
div
>
<
label
class
=
"
mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400
"
>
{{
t
(
'
admin.settings.defaults.subscriptionGroup
'
)
}}
<
/label
>
<
Select
v
-
model
=
"
item.group_id
"
class
=
"
default-sub-group-select
"
:
options
=
"
defaultSubscriptionGroupOptions
"
:
placeholder
=
"
t('admin.settings.defaults.subscriptionGroup')
"
>
<
template
#
selected
=
"
{ option
}
"
>
<
GroupBadge
v
-
if
=
"
option
"
:
name
=
"
(option as unknown as DefaultSubscriptionGroupOption).label
"
:
platform
=
"
(option as unknown as DefaultSubscriptionGroupOption).platform
"
:
subscription
-
type
=
"
(option as unknown as DefaultSubscriptionGroupOption).subscriptionType
"
:
rate
-
multiplier
=
"
(option as unknown as DefaultSubscriptionGroupOption).rate
"
/>
<
span
v
-
else
class
=
"
text-gray-400
"
>
{{
t
(
'
admin.settings.defaults.subscriptionGroup
'
)
}}
<
/span
>
<
/template
>
<
template
#
option
=
"
{ option, selected
}
"
>
<
GroupOptionItem
:
name
=
"
(option as unknown as DefaultSubscriptionGroupOption).label
"
:
platform
=
"
(option as unknown as DefaultSubscriptionGroupOption).platform
"
:
subscription
-
type
=
"
(option as unknown as DefaultSubscriptionGroupOption).subscriptionType
"
:
rate
-
multiplier
=
"
(option as unknown as DefaultSubscriptionGroupOption).rate
"
:
description
=
"
(option as unknown as DefaultSubscriptionGroupOption).description
"
:
selected
=
"
selected
"
/>
<
/template
>
<
/Select
>
<
/div
>
<
div
>
<
label
class
=
"
mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400
"
>
{{
t
(
'
admin.settings.defaults.subscriptionValidityDays
'
)
}}
<
/label
>
<
input
v
-
model
.
number
=
"
item.validity_days
"
type
=
"
number
"
min
=
"
1
"
max
=
"
36500
"
class
=
"
input h-[42px]
"
/>
<
/div
>
<
div
class
=
"
flex items-end
"
>
<
button
type
=
"
button
"
class
=
"
btn btn-secondary w-full text-red-600 hover:text-red-700 dark:text-red-400
"
@
click
=
"
removeAuthSourceDefaultSubscription(authSource.source, index)
"
>
{{
t
(
'
common.delete
'
)
}}
<
/button
>
<
/div
>
<
/div
>
<
/div
>
<
/div
>
<
/div
>
<
/div
>
<
/div
>
<
/div
>
<
/div><!-- /
Tab
:
Users
-->
<!--
Tab
:
Gateway
—
Claude
Code
,
Scheduling
-->
...
...
@@ -1643,6 +1858,7 @@
<
/p
>
<
/div
>
<
div
class
=
"
p-6
"
>
<
div
class
=
"
space-y-4
"
>
<
div
class
=
"
flex items-center justify-between
"
>
<
div
>
<
label
class
=
"
text-sm font-medium text-gray-700 dark:text-gray-300
"
>
...
...
@@ -1657,6 +1873,24 @@
<
span
class
=
"
toggle-slider
"
><
/span
>
<
/label
>
<
/div
>
<
div
class
=
"
flex items-center justify-between rounded border border-gray-200 px-4 py-3 dark:border-dark-700
"
>
<
div
>
<
label
class
=
"
text-sm font-medium text-gray-700 dark:text-gray-300
"
>
{{
localText
(
'
OpenAI 高级调度器
'
,
'
OpenAI advanced scheduler
'
)
}}
<
/label
>
<
p
class
=
"
mt-0.5 text-xs text-gray-500 dark:text-gray-400
"
>
{{
localText
(
'
切换 OpenAI 侧新增的高级调度开关,供当前分支实验性调度逻辑使用。
'
,
'
Toggles the new OpenAI advanced scheduler flag for the experimental routing logic on this branch.
'
)
}}
<
/p
>
<
/div
>
<
Toggle
v
-
model
=
"
form.openai_advanced_scheduler_enabled
"
/>
<
/div
>
<
/div
>
<
/div
>
<
/div
>
...
...
@@ -2450,6 +2684,59 @@
<
/a
>
<
/p
>
<
/div
>
<
div
class
=
"
grid grid-cols-1 gap-3 lg:grid-cols-2
"
>
<
div
v
-
for
=
"
visibleMethod in paymentVisibleMethodCards
"
:
key
=
"
visibleMethod.key
"
class
=
"
rounded-lg border border-gray-200 p-4 dark:border-dark-700
"
>
<
div
class
=
"
flex items-center justify-between
"
>
<
div
>
<
label
class
=
"
font-medium text-gray-900 dark:text-white
"
>
{{
localText
(
`${visibleMethod.title
}
可见方式`
,
`${visibleMethod.title
}
visible method`
)
}}
<
/label
>
<
p
class
=
"
mt-1 text-sm text-gray-500 dark:text-gray-400
"
>
{{
localText
(
'
控制前台结算页是否展示该方式,以及展示时使用的来源键。
'
,
'
Controls whether checkout shows this method and which source key it exposes.
'
)
}}
<
/p
>
<
/div
>
<
Toggle
:
model
-
value
=
"
getPaymentVisibleMethodEnabled(visibleMethod.key)
"
@
update
:
model
-
value
=
"
setPaymentVisibleMethodEnabled(visibleMethod.key, $event)
"
/>
<
/div
>
<
div
class
=
"
mt-4
"
>
<
label
class
=
"
input-label
"
>
{{
localText
(
'
来源键
'
,
'
Source key
'
)
}}
<
/label
>
<
input
:
value
=
"
getPaymentVisibleMethodSource(visibleMethod.key)
"
@
input
=
"
setPaymentVisibleMethodSource(visibleMethod.key, ($event.target as HTMLInputElement).value)
"
type
=
"
text
"
class
=
"
input
"
:
placeholder
=
"
visibleMethod.key
"
/>
<
p
class
=
"
mt-1.5 text-xs text-gray-400
"
>
{{
localText
(
'
留空表示由后端使用默认来源;可填 easypay、alipay、wxpay 等来源标识。
'
,
'
Leave blank to let the backend decide. Typical values are easypay, alipay, or wxpay.
'
)
}}
<
/p
>
<
/div
>
<
/div
>
<
/div
>
<!--
Row
5
:
Help
image
+
text
-->
<
div
class
=
"
grid grid-cols-2 gap-3
"
>
<
div
>
...
...
@@ -2827,7 +3114,14 @@
import
{
ref
,
reactive
,
computed
,
onMounted
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
adminAPI
}
from
'
@/api
'
import
{
appendAuthSourceDefaultsToUpdateRequest
,
buildAuthSourceDefaultsState
,
normalizeDefaultSubscriptionSettings
,
}
from
'
@/api/admin/settings
'
import
type
{
AuthSourceDefaultsState
,
AuthSourceType
,
SystemSettings
,
UpdateSettingsRequest
,
DefaultSubscriptionSetting
,
...
...
@@ -2864,6 +3158,10 @@ const { t, locale } = useI18n()
const
appStore
=
useAppStore
()
const
adminSettingsStore
=
useAdminSettingsStore
()
function
localText
(
zh
:
string
,
en
:
string
):
string
{
return
locale
.
value
.
startsWith
(
'
zh
'
)
?
zh
:
en
}
type
SettingsTab
=
'
general
'
|
'
security
'
|
'
users
'
|
'
gateway
'
|
'
payment
'
|
'
email
'
|
'
backup
'
const
activeTab
=
ref
<
SettingsTab
>
(
'
general
'
)
const
settingsTabs
=
[
...
...
@@ -2960,6 +3258,12 @@ type SettingsForm = SystemSettings & {
turnstile_secret_key
:
string
linuxdo_connect_client_secret
:
string
oidc_connect_client_secret
:
string
force_email_on_third_party_signup
:
boolean
payment_visible_method_alipay_source
:
string
payment_visible_method_wxpay_source
:
string
payment_visible_method_alipay_enabled
:
boolean
payment_visible_method_wxpay_enabled
:
boolean
openai_advanced_scheduler_enabled
:
boolean
}
const
form
=
reactive
<
SettingsForm
>
({
...
...
@@ -2974,6 +3278,7 @@ const form = reactive<SettingsForm>({
default_balance
:
0
,
default_concurrency
:
1
,
default_subscriptions
:
[],
force_email_on_third_party_signup
:
false
,
site_name
:
'
Sub2API
'
,
site_logo
:
''
,
site_subtitle
:
'
Subscription to API Conversion Platform
'
,
...
...
@@ -2983,7 +3288,7 @@ const form = reactive<SettingsForm>({
home_content
:
''
,
backend_mode_enabled
:
false
,
hide_ccs_import_button
:
false
,
payment_enabled
:
false
,
payment_min_amount
:
1
,
payment_max_amount
:
10000
,
payment_daily_limit
:
50000
,
payment_max_pending_orders
:
3
,
payment_order_timeout_minutes
:
30
,
payment_balance_disabled
:
false
,
payment_balance_recharge_multiplier
:
1
,
payment_recharge_fee_rate
:
0
,
payment_enabled_types
:
[],
payment_help_image_url
:
''
,
payment_help_text
:
''
,
payment_product_name_prefix
:
''
,
payment_product_name_suffix
:
''
,
payment_load_balance_strategy
:
'
round-robin
'
,
payment_cancel_rate_limit_enabled
:
false
,
payment_cancel_rate_limit_max
:
10
,
payment_cancel_rate_limit_window
:
1
,
payment_cancel_rate_limit_unit
:
'
day
'
,
payment_cancel_rate_limit_window_mode
:
'
rolling
'
,
payment_enabled
:
false
,
payment_min_amount
:
1
,
payment_max_amount
:
10000
,
payment_daily_limit
:
50000
,
payment_max_pending_orders
:
3
,
payment_order_timeout_minutes
:
30
,
payment_balance_disabled
:
false
,
payment_balance_recharge_multiplier
:
1
,
payment_recharge_fee_rate
:
0
,
payment_enabled_types
:
[],
payment_help_image_url
:
''
,
payment_help_text
:
''
,
payment_product_name_prefix
:
''
,
payment_product_name_suffix
:
''
,
payment_load_balance_strategy
:
'
round-robin
'
,
payment_cancel_rate_limit_enabled
:
false
,
payment_cancel_rate_limit_max
:
10
,
payment_cancel_rate_limit_window
:
1
,
payment_cancel_rate_limit_unit
:
'
day
'
,
payment_cancel_rate_limit_window_mode
:
'
rolling
'
,
payment_visible_method_alipay_source
:
''
,
payment_visible_method_wxpay_source
:
''
,
payment_visible_method_alipay_enabled
:
false
,
payment_visible_method_wxpay_enabled
:
false
,
table_default_page_size
:
tablePageSizeDefault
,
table_page_size_options
:
[
10
,
20
,
50
,
100
],
custom_menu_items
:
[]
as
Array
<
{
id
:
string
;
label
:
string
;
icon_svg
:
string
;
url
:
string
;
visibility
:
'
user
'
|
'
admin
'
;
sort_order
:
number
}
>
,
...
...
@@ -3051,6 +3356,7 @@ const form = reactive<SettingsForm>({
max_claude_code_version
:
''
,
// 分组隔离
allow_ungrouped_key_scheduling
:
false
,
openai_advanced_scheduler_enabled
:
false
,
// Gateway forwarding behavior
enable_fingerprint_unification
:
true
,
enable_metadata_passthrough
:
false
,
...
...
@@ -3063,6 +3369,74 @@ const form = reactive<SettingsForm>({
account_quota_notify_emails
:
[]
as
NotifyEmailEntry
[]
}
)
const
authSourceDefaults
=
reactive
<
AuthSourceDefaultsState
>
(
buildAuthSourceDefaultsState
({
}
))
const
authSourceDefaultsMeta
=
computed
(()
=>
[
{
source
:
'
email
'
as
AuthSourceType
,
title
:
localText
(
'
邮箱注册
'
,
'
Email signup
'
),
description
:
localText
(
'
适用于邮箱密码注册的新用户默认配额。
'
,
'
Default quota grants for email-password signups.
'
)
}
,
{
source
:
'
linuxdo
'
as
AuthSourceType
,
title
:
localText
(
'
Linux DO 登录
'
,
'
Linux DO signup
'
),
description
:
localText
(
'
适用于 Linux DO 第三方注册的新用户默认配额。
'
,
'
Default quota grants for Linux DO signups.
'
)
}
,
{
source
:
'
oidc
'
as
AuthSourceType
,
title
:
localText
(
'
OIDC 登录
'
,
'
OIDC signup
'
),
description
:
localText
(
'
适用于 OIDC 第三方注册的新用户默认配额。
'
,
'
Default quota grants for OIDC signups.
'
)
}
,
{
source
:
'
wechat
'
as
AuthSourceType
,
title
:
localText
(
'
微信登录
'
,
'
WeChat signup
'
),
description
:
localText
(
'
适用于微信第三方注册的新用户默认配额。
'
,
'
Default quota grants for WeChat signups.
'
)
}
,
])
const
paymentVisibleMethodCards
=
computed
(()
=>
[
{
key
:
'
alipay
'
as
const
,
title
:
t
(
'
payment.methods.alipay
'
),
enabledField
:
'
payment_visible_method_alipay_enabled
'
as
const
,
sourceField
:
'
payment_visible_method_alipay_source
'
as
const
,
}
,
{
key
:
'
wxpay
'
as
const
,
title
:
t
(
'
payment.methods.wxpay
'
),
enabledField
:
'
payment_visible_method_wxpay_enabled
'
as
const
,
sourceField
:
'
payment_visible_method_wxpay_source
'
as
const
,
}
,
])
function
getPaymentVisibleMethodEnabled
(
method
:
'
alipay
'
|
'
wxpay
'
):
boolean
{
return
method
===
'
alipay
'
?
form
.
payment_visible_method_alipay_enabled
:
form
.
payment_visible_method_wxpay_enabled
}
function
setPaymentVisibleMethodEnabled
(
method
:
'
alipay
'
|
'
wxpay
'
,
enabled
:
boolean
)
{
if
(
method
===
'
alipay
'
)
{
form
.
payment_visible_method_alipay_enabled
=
enabled
return
}
form
.
payment_visible_method_wxpay_enabled
=
enabled
}
function
getPaymentVisibleMethodSource
(
method
:
'
alipay
'
|
'
wxpay
'
):
string
{
return
method
===
'
alipay
'
?
form
.
payment_visible_method_alipay_source
:
form
.
payment_visible_method_wxpay_source
}
function
setPaymentVisibleMethodSource
(
method
:
'
alipay
'
|
'
wxpay
'
,
source
:
string
)
{
if
(
method
===
'
alipay
'
)
{
form
.
payment_visible_method_alipay_source
=
source
return
}
form
.
payment_visible_method_wxpay_source
=
source
}
// Proxies for web search emulation ProxySelector
const
webSearchProxies
=
ref
<
Proxy
[]
>
([])
...
...
@@ -3428,15 +3802,9 @@ async function loadSettings() {
(
form
as
Record
<
string
,
unknown
>
)[
key
]
=
value
}
}
Object
.
assign
(
authSourceDefaults
,
buildAuthSourceDefaultsState
(
settings
))
form
.
backend_mode_enabled
=
settings
.
backend_mode_enabled
form
.
default_subscriptions
=
Array
.
isArray
(
settings
.
default_subscriptions
)
?
settings
.
default_subscriptions
.
filter
((
item
)
=>
item
.
group_id
>
0
&&
item
.
validity_days
>
0
)
.
map
((
item
)
=>
({
group_id
:
item
.
group_id
,
validity_days
:
item
.
validity_days
}
))
:
[]
form
.
default_subscriptions
=
normalizeDefaultSubscriptionSettings
(
settings
.
default_subscriptions
)
registrationEmailSuffixWhitelistTags
.
value
=
normalizeRegistrationEmailSuffixDomains
(
settings
.
registration_email_suffix_whitelist
)
...
...
@@ -3471,10 +3839,18 @@ async function loadSubscriptionGroups() {
}
}
function
findNextAvailableSubscriptionGroup
(
existingGroupIDs
:
number
[]
):
AdminGroup
|
undefined
{
const
existing
=
new
Set
(
existingGroupIDs
)
return
subscriptionGroups
.
value
.
find
((
group
)
=>
!
existing
.
has
(
group
.
id
))
}
function
addDefaultSubscription
()
{
if
(
subscriptionGroups
.
value
.
length
===
0
)
return
const
existing
=
new
Set
(
form
.
default_subscriptions
.
map
((
item
)
=>
item
.
group_id
))
const
candidate
=
subscriptionGroups
.
value
.
find
((
group
)
=>
!
existing
.
has
(
group
.
id
))
const
candidate
=
findNextAvailableSubscriptionGroup
(
form
.
default_subscriptions
.
map
((
item
)
=>
item
.
group_id
)
)
if
(
!
candidate
)
return
form
.
default_subscriptions
.
push
({
group_id
:
candidate
.
id
,
...
...
@@ -3486,6 +3862,36 @@ function removeDefaultSubscription(index: number) {
form
.
default_subscriptions
.
splice
(
index
,
1
)
}
function
addAuthSourceDefaultSubscription
(
source
:
AuthSourceType
)
{
if
(
subscriptionGroups
.
value
.
length
===
0
)
return
const
candidate
=
findNextAvailableSubscriptionGroup
(
authSourceDefaults
[
source
].
subscriptions
.
map
((
item
)
=>
item
.
group_id
)
)
if
(
!
candidate
)
return
authSourceDefaults
[
source
].
subscriptions
.
push
({
group_id
:
candidate
.
id
,
validity_days
:
30
}
)
}
function
removeAuthSourceDefaultSubscription
(
source
:
AuthSourceType
,
index
:
number
)
{
authSourceDefaults
[
source
].
subscriptions
.
splice
(
index
,
1
)
}
function
findDuplicateDefaultSubscription
(
subscriptions
:
DefaultSubscriptionSetting
[]
):
DefaultSubscriptionSetting
|
undefined
{
const
seenGroupIDs
=
new
Set
<
number
>
()
return
subscriptions
.
find
((
item
)
=>
{
if
(
seenGroupIDs
.
has
(
item
.
group_id
))
{
return
true
}
seenGroupIDs
.
add
(
item
.
group_id
)
return
false
}
)
}
async
function
saveSettings
()
{
saving
.
value
=
true
try
{
...
...
@@ -3520,21 +3926,12 @@ async function saveSettings() {
form
.
table_default_page_size
=
normalizedTableDefaultPageSize
form
.
table_page_size_options
=
normalizedTablePageSizeOptions
const
normalizedDefaultSubscriptions
=
form
.
default_subscriptions
.
filter
((
item
)
=>
item
.
group_id
>
0
&&
item
.
validity_days
>
0
)
.
map
((
item
:
DefaultSubscriptionSetting
)
=>
({
group_id
:
item
.
group_id
,
validity_days
:
Math
.
min
(
36500
,
Math
.
max
(
1
,
Math
.
floor
(
item
.
validity_days
)))
}
))
const
seenGroupIDs
=
new
Set
<
number
>
()
const
duplicateDefaultSubscription
=
normalizedDefaultSubscriptions
.
find
((
item
)
=>
{
if
(
seenGroupIDs
.
has
(
item
.
group_id
))
{
return
true
}
seenGroupIDs
.
add
(
item
.
group_id
)
return
false
}
)
const
normalizedDefaultSubscriptions
=
normalizeDefaultSubscriptionSettings
(
form
.
default_subscriptions
)
const
duplicateDefaultSubscription
=
findDuplicateDefaultSubscription
(
normalizedDefaultSubscriptions
)
if
(
duplicateDefaultSubscription
)
{
appStore
.
showError
(
t
(
'
admin.settings.defaults.defaultSubscriptionsDuplicate
'
,
{
...
...
@@ -3544,6 +3941,23 @@ async function saveSettings() {
return
}
for
(
const
authSource
of
authSourceDefaultsMeta
.
value
)
{
authSourceDefaults
[
authSource
.
source
].
subscriptions
=
normalizeDefaultSubscriptionSettings
(
authSourceDefaults
[
authSource
.
source
].
subscriptions
)
const
duplicate
=
findDuplicateDefaultSubscription
(
authSourceDefaults
[
authSource
.
source
].
subscriptions
)
if
(
duplicate
)
{
appStore
.
showError
(
`${authSource.title
}
: ${t('admin.settings.defaults.defaultSubscriptionsDuplicate', {
groupId: duplicate.group_id
}
)
}
`
)
return
}
}
// Validate URL fields — novalidate disables browser-native checks, so we validate here
const
isValidHttpUrl
=
(
url
:
string
):
boolean
=>
{
if
(
!
url
)
return
true
...
...
@@ -3571,6 +3985,7 @@ async function saveSettings() {
default_balance
:
form
.
default_balance
,
default_concurrency
:
form
.
default_concurrency
,
default_subscriptions
:
normalizedDefaultSubscriptions
,
force_email_on_third_party_signup
:
form
.
force_email_on_third_party_signup
,
site_name
:
form
.
site_name
,
site_logo
:
form
.
site_logo
,
site_subtitle
:
form
.
site_subtitle
,
...
...
@@ -3655,6 +4070,11 @@ async function saveSettings() {
payment_cancel_rate_limit_window
:
Number
(
form
.
payment_cancel_rate_limit_window
)
||
1
,
payment_cancel_rate_limit_unit
:
form
.
payment_cancel_rate_limit_unit
,
payment_cancel_rate_limit_window_mode
:
form
.
payment_cancel_rate_limit_window_mode
,
payment_visible_method_alipay_source
:
form
.
payment_visible_method_alipay_source
,
payment_visible_method_wxpay_source
:
form
.
payment_visible_method_wxpay_source
,
payment_visible_method_alipay_enabled
:
form
.
payment_visible_method_alipay_enabled
,
payment_visible_method_wxpay_enabled
:
form
.
payment_visible_method_wxpay_enabled
,
openai_advanced_scheduler_enabled
:
form
.
openai_advanced_scheduler_enabled
,
// Balance & quota notification
balance_low_notify_enabled
:
form
.
balance_low_notify_enabled
,
balance_low_notify_threshold
:
Number
(
form
.
balance_low_notify_threshold
)
||
0
,
...
...
@@ -3663,12 +4083,15 @@ async function saveSettings() {
account_quota_notify_emails
:
(
form
.
account_quota_notify_emails
||
[]).
filter
((
e
)
=>
e
.
email
.
trim
()
!==
''
),
}
appendAuthSourceDefaultsToUpdateRequest
(
payload
,
authSourceDefaults
)
const
updated
=
await
adminAPI
.
settings
.
updateSettings
(
payload
)
for
(
const
[
key
,
value
]
of
Object
.
entries
(
updated
))
{
if
(
value
!==
null
&&
value
!==
undefined
)
{
(
form
as
Record
<
string
,
unknown
>
)[
key
]
=
value
}
}
Object
.
assign
(
authSourceDefaults
,
buildAuthSourceDefaultsState
(
updated
))
registrationEmailSuffixWhitelistTags
.
value
=
normalizeRegistrationEmailSuffixDomains
(
updated
.
registration_email_suffix_whitelist
)
...
...
frontend/src/views/auth/LinuxDoCallbackView.vue
View file @
e9de839d
...
...
@@ -11,7 +11,59 @@
</div>
<transition
name=
"fade"
>
<div
v-if=
"needsInvitation"
class=
"space-y-4"
>
<div
v-if=
"needsInvitation || needsAdoptionConfirmation"
class=
"space-y-4"
>
<div
v-if=
"adoptionRequired && (suggestedDisplayName || suggestedAvatarUrl)"
class=
"rounded-xl border border-gray-200 bg-gray-50 p-4 dark:border-dark-600 dark:bg-dark-800/60"
>
<div
class=
"space-y-3"
>
<div
class=
"space-y-1"
>
<p
class=
"text-sm font-medium text-gray-900 dark:text-white"
>
Use LinuxDo profile details
</p>
<p
class=
"text-xs text-gray-500 dark:text-dark-400"
>
Choose whether to apply the nickname or avatar from LinuxDo to this account.
</p>
</div>
<label
v-if=
"suggestedDisplayName"
class=
"flex items-start gap-3 rounded-lg border border-gray-200 bg-white p-3 text-sm dark:border-dark-600 dark:bg-dark-900/50"
>
<input
v-model=
"adoptDisplayName"
type=
"checkbox"
class=
"mt-1 h-4 w-4"
/>
<span
class=
"space-y-1"
>
<span
class=
"block font-medium text-gray-900 dark:text-white"
>
Use display name
</span>
<span
class=
"block text-gray-500 dark:text-dark-400"
>
{{
suggestedDisplayName
}}
</span>
</span>
</label>
<label
v-if=
"suggestedAvatarUrl"
class=
"flex items-start gap-3 rounded-lg border border-gray-200 bg-white p-3 text-sm dark:border-dark-600 dark:bg-dark-900/50"
>
<input
v-model=
"adoptAvatar"
type=
"checkbox"
class=
"mt-1 h-4 w-4"
/>
<img
:src=
"suggestedAvatarUrl"
alt=
"LinuxDo avatar"
class=
"h-10 w-10 rounded-full border border-gray-200 object-cover dark:border-dark-600"
/>
<span
class=
"space-y-1"
>
<span
class=
"block font-medium text-gray-900 dark:text-white"
>
Use avatar
</span>
<span
class=
"block break-all text-gray-500 dark:text-dark-400"
>
{{
suggestedAvatarUrl
}}
</span>
</span>
</label>
</div>
</div>
<template
v-if=
"needsInvitation"
>
<p
class=
"text-sm text-gray-700 dark:text-gray-300"
>
{{
t
(
'
auth.linuxdo.invitationRequired
'
)
}}
</p>
...
...
@@ -37,6 +89,16 @@
>
{{
isSubmitting
?
t
(
'
auth.linuxdo.completing
'
)
:
t
(
'
auth.linuxdo.completeRegistration
'
)
}}
</button>
</
template
>
<
template
v-else-if=
"needsAdoptionConfirmation"
>
<p
class=
"text-sm text-gray-700 dark:text-gray-300"
>
Review the LinuxDo profile details before continuing.
</p>
<button
class=
"btn btn-primary w-full"
:disabled=
"isSubmitting"
@
click=
"handleContinueLogin"
>
{{
isSubmitting
?
t
(
'
common.processing
'
)
:
'
Continue
'
}}
</button>
</
template
>
</div>
</transition>
...
...
@@ -71,7 +133,12 @@ import { useI18n } from 'vue-i18n'
import
{
AuthLayout
}
from
'
@/components/layout
'
import
Icon
from
'
@/components/icons/Icon.vue
'
import
{
useAuthStore
,
useAppStore
}
from
'
@/stores
'
import
{
completeLinuxDoOAuthRegistration
}
from
'
@/api/auth
'
import
{
completeLinuxDoOAuthRegistration
,
exchangePendingOAuthCompletion
,
type
OAuthAdoptionDecision
,
type
PendingOAuthExchangeResponse
}
from
'
@/api/auth
'
const
route
=
useRoute
()
const
router
=
useRouter
()
...
...
@@ -85,11 +152,16 @@ const errorMessage = ref('')
// Invitation code flow state
const
needsInvitation
=
ref
(
false
)
const
pendingOAuthToken
=
ref
(
''
)
const
invitationCode
=
ref
(
''
)
const
isSubmitting
=
ref
(
false
)
const
invitationError
=
ref
(
''
)
const
redirectTo
=
ref
(
'
/dashboard
'
)
const
adoptionRequired
=
ref
(
false
)
const
suggestedDisplayName
=
ref
(
''
)
const
suggestedAvatarUrl
=
ref
(
''
)
const
adoptDisplayName
=
ref
(
true
)
const
adoptAvatar
=
ref
(
true
)
const
needsAdoptionConfirmation
=
ref
(
false
)
function
parseFragmentParams
():
URLSearchParams
{
const
raw
=
typeof
window
!==
'
undefined
'
?
window
.
location
.
hash
:
''
...
...
@@ -106,6 +178,54 @@ function sanitizeRedirectPath(path: string | null | undefined): string {
return
path
}
function
currentAdoptionDecision
():
OAuthAdoptionDecision
{
return
{
adoptDisplayName
:
adoptDisplayName
.
value
,
adoptAvatar
:
adoptAvatar
.
value
}
}
function
applyAdoptionSuggestionState
(
completion
:
{
adoption_required
?:
boolean
suggested_display_name
?:
string
suggested_avatar_url
?:
string
})
{
adoptionRequired
.
value
=
completion
.
adoption_required
===
true
suggestedDisplayName
.
value
=
completion
.
suggested_display_name
||
''
suggestedAvatarUrl
.
value
=
completion
.
suggested_avatar_url
||
''
if
(
!
suggestedDisplayName
.
value
)
{
adoptDisplayName
.
value
=
false
}
if
(
!
suggestedAvatarUrl
.
value
)
{
adoptAvatar
.
value
=
false
}
}
function
hasSuggestedProfile
(
completion
:
{
suggested_display_name
?:
string
suggested_avatar_url
?:
string
}):
boolean
{
return
Boolean
(
completion
.
suggested_display_name
||
completion
.
suggested_avatar_url
)
}
async
function
finalizeLogin
(
completion
:
PendingOAuthExchangeResponse
,
redirect
:
string
)
{
if
(
!
completion
.
access_token
)
{
throw
new
Error
(
t
(
'
auth.linuxdo.callbackMissingToken
'
))
}
if
(
completion
.
refresh_token
)
{
localStorage
.
setItem
(
'
refresh_token
'
,
completion
.
refresh_token
)
}
if
(
completion
.
expires_in
)
{
localStorage
.
setItem
(
'
token_expires_at
'
,
String
(
Date
.
now
()
+
completion
.
expires_in
*
1000
))
}
await
authStore
.
setToken
(
completion
.
access_token
)
appStore
.
showSuccess
(
t
(
'
auth.loginSuccess
'
))
await
router
.
replace
(
redirect
)
}
async
function
handleSubmitInvitation
()
{
invitationError
.
value
=
''
if
(
!
invitationCode
.
value
.
trim
())
return
...
...
@@ -113,8 +233,8 @@ async function handleSubmitInvitation() {
isSubmitting
.
value
=
true
try
{
const
tokenData
=
await
completeLinuxDoOAuthRegistration
(
pendingOAuthToken
.
value
,
invitationCode
.
value
.
trim
()
invitationCode
.
value
.
trim
()
,
currentAdoptionDecision
()
)
if
(
tokenData
.
refresh_token
)
{
localStorage
.
setItem
(
'
refresh_token
'
,
tokenData
.
refresh_token
)
...
...
@@ -134,63 +254,65 @@ async function handleSubmitInvitation() {
}
}
async
function
handleContinueLogin
()
{
isSubmitting
.
value
=
true
try
{
const
completion
=
await
exchangePendingOAuthCompletion
(
currentAdoptionDecision
())
await
finalizeLogin
(
completion
,
redirectTo
.
value
)
}
catch
(
e
:
unknown
)
{
const
err
=
e
as
{
message
?:
string
;
response
?:
{
data
?:
{
detail
?:
string
;
message
?:
string
}
}
}
errorMessage
.
value
=
err
.
response
?.
data
?.
detail
||
err
.
response
?.
data
?.
message
||
err
.
message
||
t
(
'
auth.loginFailed
'
)
appStore
.
showError
(
errorMessage
.
value
)
needsAdoptionConfirmation
.
value
=
false
}
finally
{
isSubmitting
.
value
=
false
}
}
onMounted
(
async
()
=>
{
const
params
=
parseFragmentParams
()
const
token
=
params
.
get
(
'
access_token
'
)
||
''
const
refreshToken
=
params
.
get
(
'
refresh_token
'
)
||
''
const
expiresInStr
=
params
.
get
(
'
expires_in
'
)
||
''
const
redirect
=
sanitizeRedirectPath
(
params
.
get
(
'
redirect
'
)
||
(
route
.
query
.
redirect
as
string
|
undefined
)
||
'
/dashboard
'
)
const
error
=
params
.
get
(
'
error
'
)
const
errorDesc
=
params
.
get
(
'
error_description
'
)
||
params
.
get
(
'
error_message
'
)
||
''
if
(
error
)
{
if
(
error
===
'
invitation_required
'
)
{
pendingOAuthToken
.
value
=
params
.
get
(
'
pending_oauth_token
'
)
||
''
redirectTo
.
value
=
sanitizeRedirectPath
(
params
.
get
(
'
redirect
'
))
if
(
!
pendingOAuthToken
.
value
)
{
errorMessage
.
value
=
t
(
'
auth.linuxdo.invalidPendingToken
'
)
errorMessage
.
value
=
errorDesc
||
error
appStore
.
showError
(
errorMessage
.
value
)
isProcessing
.
value
=
false
return
}
try
{
const
completion
=
await
exchangePendingOAuthCompletion
()
const
redirect
=
sanitizeRedirectPath
(
completion
.
redirect
||
(
route
.
query
.
redirect
as
string
|
undefined
)
||
'
/dashboard
'
)
applyAdoptionSuggestionState
(
completion
)
redirectTo
.
value
=
redirect
if
(
completion
.
error
===
'
invitation_required
'
)
{
needsInvitation
.
value
=
true
isProcessing
.
value
=
false
return
}
errorMessage
.
value
=
errorDesc
||
error
appStore
.
showError
(
errorMessage
.
value
)
isProcessing
.
value
=
false
return
}
if
(
!
token
)
{
errorMessage
.
value
=
t
(
'
auth.linuxdo.callbackMissingToken
'
)
appStore
.
showError
(
errorMessage
.
value
)
if
(
adoptionRequired
.
value
&&
hasSuggestedProfile
(
completion
))
{
needsAdoptionConfirmation
.
value
=
true
isProcessing
.
value
=
false
return
}
try
{
// Store refresh token and expires_at (convert to timestamp) if provided
if
(
refreshToken
)
{
localStorage
.
setItem
(
'
refresh_token
'
,
refreshToken
)
}
if
(
expiresInStr
)
{
const
expiresIn
=
parseInt
(
expiresInStr
,
10
)
if
(
!
isNaN
(
expiresIn
))
{
localStorage
.
setItem
(
'
token_expires_at
'
,
String
(
Date
.
now
()
+
expiresIn
*
1000
))
}
}
await
authStore
.
setToken
(
token
)
appStore
.
showSuccess
(
t
(
'
auth.loginSuccess
'
))
await
router
.
replace
(
redirect
)
await
finalizeLogin
(
completion
,
redirect
)
}
catch
(
e
:
unknown
)
{
const
err
=
e
as
{
message
?:
string
;
response
?:
{
data
?:
{
detail
?:
string
}
}
}
errorMessage
.
value
=
err
.
response
?.
data
?.
detail
||
err
.
message
||
t
(
'
auth.loginFailed
'
)
const
err
=
e
as
{
message
?:
string
;
response
?:
{
data
?:
{
detail
?:
string
;
message
?:
string
}
}
}
errorMessage
.
value
=
err
.
response
?.
data
?.
detail
||
err
.
response
?.
data
?.
message
||
err
.
message
||
t
(
'
auth.loginFailed
'
)
appStore
.
showError
(
errorMessage
.
value
)
isProcessing
.
value
=
false
}
...
...
@@ -209,4 +331,3 @@ onMounted(async () => {
transform
:
translateY
(
-8px
);
}
</
style
>
frontend/src/views/auth/LoginView.vue
View file @
e9de839d
...
...
@@ -11,12 +11,17 @@
</p>
</div>
<div
v-if=
"!backendModeEnabled && (linuxdoOAuthEnabled || oidcOAuthEnabled)"
class=
"space-y-4"
>
<div
v-if=
"!backendModeEnabled && (linuxdoOAuthEnabled ||
wechatOAuthEnabled ||
oidcOAuthEnabled)"
class=
"space-y-4"
>
<LinuxDoOAuthSection
v-if=
"linuxdoOAuthEnabled"
:disabled=
"isLoading"
:show-divider=
"false"
/>
<WechatOAuthSection
v-if=
"wechatOAuthEnabled"
:disabled=
"isLoading"
:show-divider=
"false"
/>
<OidcOAuthSection
v-if=
"oidcOAuthEnabled"
:disabled=
"isLoading"
...
...
@@ -200,6 +205,7 @@ import { useI18n } from 'vue-i18n'
import
{
AuthLayout
}
from
'
@/components/layout
'
import
LinuxDoOAuthSection
from
'
@/components/auth/LinuxDoOAuthSection.vue
'
import
OidcOAuthSection
from
'
@/components/auth/OidcOAuthSection.vue
'
import
WechatOAuthSection
from
'
@/components/auth/WechatOAuthSection.vue
'
import
TotpLoginModal
from
'
@/components/auth/TotpLoginModal.vue
'
import
Icon
from
'
@/components/icons/Icon.vue
'
import
TurnstileWidget
from
'
@/components/TurnstileWidget.vue
'
...
...
@@ -225,6 +231,7 @@ const showPassword = ref<boolean>(false)
const
turnstileEnabled
=
ref
<
boolean
>
(
false
)
const
turnstileSiteKey
=
ref
<
string
>
(
''
)
const
linuxdoOAuthEnabled
=
ref
<
boolean
>
(
false
)
const
wechatOAuthEnabled
=
ref
<
boolean
>
(
false
)
const
backendModeEnabled
=
ref
<
boolean
>
(
false
)
const
oidcOAuthEnabled
=
ref
<
boolean
>
(
false
)
const
oidcOAuthProviderName
=
ref
<
string
>
(
'
OIDC
'
)
...
...
@@ -267,6 +274,7 @@ onMounted(async () => {
turnstileEnabled
.
value
=
settings
.
turnstile_enabled
turnstileSiteKey
.
value
=
settings
.
turnstile_site_key
||
''
linuxdoOAuthEnabled
.
value
=
settings
.
linuxdo_oauth_enabled
wechatOAuthEnabled
.
value
=
settings
.
wechat_oauth_enabled
backendModeEnabled
.
value
=
settings
.
backend_mode_enabled
oidcOAuthEnabled
.
value
=
settings
.
oidc_oauth_enabled
oidcOAuthProviderName
.
value
=
settings
.
oidc_oauth_provider_name
||
'
OIDC
'
...
...
frontend/src/views/auth/OidcCallbackView.vue
View file @
e9de839d
...
...
@@ -15,7 +15,60 @@
<
/div
>
<
transition
name
=
"
fade
"
>
<
div
v
-
if
=
"
needsInvitation
"
class
=
"
space-y-4
"
>
<
div
v
-
if
=
"
needsInvitation || needsAdoptionConfirmation
"
class
=
"
space-y-4
"
>
<
div
v
-
if
=
"
adoptionRequired && (suggestedDisplayName || suggestedAvatarUrl)
"
class
=
"
rounded-xl border border-gray-200 bg-gray-50 p-4 dark:border-dark-600 dark:bg-dark-800/60
"
>
<
div
class
=
"
space-y-3
"
>
<
div
class
=
"
space-y-1
"
>
<
p
class
=
"
text-sm font-medium text-gray-900 dark:text-white
"
>
Use
{{
providerName
}}
profile
details
<
/p
>
<
p
class
=
"
text-xs text-gray-500 dark:text-dark-400
"
>
Choose
whether
to
apply
the
nickname
or
avatar
from
{{
providerName
}}
to
this
account
.
<
/p
>
<
/div
>
<
label
v
-
if
=
"
suggestedDisplayName
"
class
=
"
flex items-start gap-3 rounded-lg border border-gray-200 bg-white p-3 text-sm dark:border-dark-600 dark:bg-dark-900/50
"
>
<
input
v
-
model
=
"
adoptDisplayName
"
type
=
"
checkbox
"
class
=
"
mt-1 h-4 w-4
"
/>
<
span
class
=
"
space-y-1
"
>
<
span
class
=
"
block font-medium text-gray-900 dark:text-white
"
>
Use
display
name
<
/span
>
<
span
class
=
"
block text-gray-500 dark:text-dark-400
"
>
{{
suggestedDisplayName
}}
<
/span
>
<
/span
>
<
/label
>
<
label
v
-
if
=
"
suggestedAvatarUrl
"
class
=
"
flex items-start gap-3 rounded-lg border border-gray-200 bg-white p-3 text-sm dark:border-dark-600 dark:bg-dark-900/50
"
>
<
input
v
-
model
=
"
adoptAvatar
"
type
=
"
checkbox
"
class
=
"
mt-1 h-4 w-4
"
/>
<
img
:
src
=
"
suggestedAvatarUrl
"
:
alt
=
"
`${providerName
}
avatar`
"
class
=
"
h-10 w-10 rounded-full border border-gray-200 object-cover dark:border-dark-600
"
/>
<
span
class
=
"
space-y-1
"
>
<
span
class
=
"
block font-medium text-gray-900 dark:text-white
"
>
Use
avatar
<
/span
>
<
span
class
=
"
block break-all text-gray-500 dark:text-dark-400
"
>
{{
suggestedAvatarUrl
}}
<
/span
>
<
/span
>
<
/label
>
<
/div
>
<
/div
>
<
template
v
-
if
=
"
needsInvitation
"
>
<
p
class
=
"
text-sm text-gray-700 dark:text-gray-300
"
>
{{
t
(
'
auth.oidc.invitationRequired
'
,
{
providerName
}
)
}}
<
/p
>
...
...
@@ -45,6 +98,16 @@
:
t
(
'
auth.oidc.completeRegistration
'
)
}}
<
/button
>
<
/template
>
<
template
v
-
else
-
if
=
"
needsAdoptionConfirmation
"
>
<
p
class
=
"
text-sm text-gray-700 dark:text-gray-300
"
>
Review
the
{{
providerName
}}
profile
details
before
continuing
.
<
/p
>
<
button
class
=
"
btn btn-primary w-full
"
:
disabled
=
"
isSubmitting
"
@
click
=
"
handleContinueLogin
"
>
{{
isSubmitting
?
t
(
'
common.processing
'
)
:
'
Continue
'
}}
<
/button
>
<
/template
>
<
/div
>
<
/transition
>
...
...
@@ -81,7 +144,10 @@ import Icon from '@/components/icons/Icon.vue'
import
{
useAuthStore
,
useAppStore
}
from
'
@/stores
'
import
{
completeOIDCOAuthRegistration
,
getPublicSettings
exchangePendingOAuthCompletion
,
getPublicSettings
,
type
OAuthAdoptionDecision
,
type
PendingOAuthExchangeResponse
}
from
'
@/api/auth
'
const
route
=
useRoute
()
...
...
@@ -95,12 +161,17 @@ const isProcessing = ref(true)
const
errorMessage
=
ref
(
''
)
const
needsInvitation
=
ref
(
false
)
const
pendingOAuthToken
=
ref
(
''
)
const
invitationCode
=
ref
(
''
)
const
isSubmitting
=
ref
(
false
)
const
invitationError
=
ref
(
''
)
const
redirectTo
=
ref
(
'
/dashboard
'
)
const
providerName
=
ref
(
'
OIDC
'
)
const
adoptionRequired
=
ref
(
false
)
const
suggestedDisplayName
=
ref
(
''
)
const
suggestedAvatarUrl
=
ref
(
''
)
const
adoptDisplayName
=
ref
(
true
)
const
adoptAvatar
=
ref
(
true
)
const
needsAdoptionConfirmation
=
ref
(
false
)
function
parseFragmentParams
():
URLSearchParams
{
const
raw
=
typeof
window
!==
'
undefined
'
?
window
.
location
.
hash
:
''
...
...
@@ -129,6 +200,54 @@ async function loadProviderName() {
}
}
function
currentAdoptionDecision
():
OAuthAdoptionDecision
{
return
{
adoptDisplayName
:
adoptDisplayName
.
value
,
adoptAvatar
:
adoptAvatar
.
value
}
}
function
applyAdoptionSuggestionState
(
completion
:
{
adoption_required
?:
boolean
suggested_display_name
?:
string
suggested_avatar_url
?:
string
}
)
{
adoptionRequired
.
value
=
completion
.
adoption_required
===
true
suggestedDisplayName
.
value
=
completion
.
suggested_display_name
||
''
suggestedAvatarUrl
.
value
=
completion
.
suggested_avatar_url
||
''
if
(
!
suggestedDisplayName
.
value
)
{
adoptDisplayName
.
value
=
false
}
if
(
!
suggestedAvatarUrl
.
value
)
{
adoptAvatar
.
value
=
false
}
}
function
hasSuggestedProfile
(
completion
:
{
suggested_display_name
?:
string
suggested_avatar_url
?:
string
}
):
boolean
{
return
Boolean
(
completion
.
suggested_display_name
||
completion
.
suggested_avatar_url
)
}
async
function
finalizeLogin
(
completion
:
PendingOAuthExchangeResponse
,
redirect
:
string
)
{
if
(
!
completion
.
access_token
)
{
throw
new
Error
(
t
(
'
auth.oidc.callbackMissingToken
'
))
}
if
(
completion
.
refresh_token
)
{
localStorage
.
setItem
(
'
refresh_token
'
,
completion
.
refresh_token
)
}
if
(
completion
.
expires_in
)
{
localStorage
.
setItem
(
'
token_expires_at
'
,
String
(
Date
.
now
()
+
completion
.
expires_in
*
1000
))
}
await
authStore
.
setToken
(
completion
.
access_token
)
appStore
.
showSuccess
(
t
(
'
auth.loginSuccess
'
))
await
router
.
replace
(
redirect
)
}
async
function
handleSubmitInvitation
()
{
invitationError
.
value
=
''
if
(
!
invitationCode
.
value
.
trim
())
return
...
...
@@ -136,8 +255,8 @@ async function handleSubmitInvitation() {
isSubmitting
.
value
=
true
try
{
const
tokenData
=
await
completeOIDCOAuthRegistration
(
pendingOAuthToken
.
value
,
invitationCode
.
value
.
trim
()
invitationCode
.
value
.
trim
()
,
currentAdoptionDecision
()
)
if
(
tokenData
.
refresh_token
)
{
localStorage
.
setItem
(
'
refresh_token
'
,
tokenData
.
refresh_token
)
...
...
@@ -157,63 +276,67 @@ async function handleSubmitInvitation() {
}
}
async
function
handleContinueLogin
()
{
isSubmitting
.
value
=
true
try
{
const
completion
=
await
exchangePendingOAuthCompletion
(
currentAdoptionDecision
())
await
finalizeLogin
(
completion
,
redirectTo
.
value
)
}
catch
(
e
:
unknown
)
{
const
err
=
e
as
{
message
?:
string
;
response
?:
{
data
?:
{
detail
?:
string
;
message
?:
string
}
}
}
errorMessage
.
value
=
err
.
response
?.
data
?.
detail
||
err
.
response
?.
data
?.
message
||
err
.
message
||
t
(
'
auth.loginFailed
'
)
appStore
.
showError
(
errorMessage
.
value
)
needsAdoptionConfirmation
.
value
=
false
}
finally
{
isSubmitting
.
value
=
false
}
}
onMounted
(
async
()
=>
{
void
loadProviderName
()
const
params
=
parseFragmentParams
()
const
token
=
params
.
get
(
'
access_token
'
)
||
''
const
refreshToken
=
params
.
get
(
'
refresh_token
'
)
||
''
const
expiresInStr
=
params
.
get
(
'
expires_in
'
)
||
''
const
redirect
=
sanitizeRedirectPath
(
params
.
get
(
'
redirect
'
)
||
(
route
.
query
.
redirect
as
string
|
undefined
)
||
'
/dashboard
'
)
const
error
=
params
.
get
(
'
error
'
)
const
errorDesc
=
params
.
get
(
'
error_description
'
)
||
params
.
get
(
'
error_message
'
)
||
''
if
(
error
)
{
if
(
error
===
'
invitation_required
'
)
{
pendingOAuthToken
.
value
=
params
.
get
(
'
pending_oauth_token
'
)
||
''
redirectTo
.
value
=
sanitizeRedirectPath
(
params
.
get
(
'
redirect
'
))
if
(
!
pendingOAuthToken
.
value
)
{
errorMessage
.
value
=
t
(
'
auth.oidc.invalidPendingToken
'
)
errorMessage
.
value
=
errorDesc
||
error
appStore
.
showError
(
errorMessage
.
value
)
isProcessing
.
value
=
false
return
}
try
{
const
completion
=
await
exchangePendingOAuthCompletion
()
const
redirect
=
sanitizeRedirectPath
(
completion
.
redirect
||
(
route
.
query
.
redirect
as
string
|
undefined
)
||
'
/dashboard
'
)
applyAdoptionSuggestionState
(
completion
)
redirectTo
.
value
=
redirect
if
(
completion
.
error
===
'
invitation_required
'
)
{
needsInvitation
.
value
=
true
isProcessing
.
value
=
false
return
}
errorMessage
.
value
=
errorDesc
||
error
appStore
.
showError
(
errorMessage
.
value
)
isProcessing
.
value
=
false
return
}
if
(
!
token
)
{
errorMessage
.
value
=
t
(
'
auth.oidc.callbackMissingToken
'
)
appStore
.
showError
(
errorMessage
.
value
)
if
(
adoptionRequired
.
value
&&
hasSuggestedProfile
(
completion
))
{
needsAdoptionConfirmation
.
value
=
true
isProcessing
.
value
=
false
return
}
try
{
if
(
refreshToken
)
{
localStorage
.
setItem
(
'
refresh_token
'
,
refreshToken
)
}
if
(
expiresInStr
)
{
const
expiresIn
=
parseInt
(
expiresInStr
,
10
)
if
(
!
isNaN
(
expiresIn
))
{
localStorage
.
setItem
(
'
token_expires_at
'
,
String
(
Date
.
now
()
+
expiresIn
*
1000
))
}
}
await
authStore
.
setToken
(
token
)
appStore
.
showSuccess
(
t
(
'
auth.loginSuccess
'
))
await
router
.
replace
(
redirect
)
await
finalizeLogin
(
completion
,
redirect
)
}
catch
(
e
:
unknown
)
{
const
err
=
e
as
{
message
?:
string
;
response
?:
{
data
?:
{
detail
?:
string
}
}
}
errorMessage
.
value
=
err
.
response
?.
data
?.
detail
||
err
.
message
||
t
(
'
auth.loginFailed
'
)
const
err
=
e
as
{
message
?:
string
;
response
?:
{
data
?:
{
detail
?:
string
;
message
?:
string
}
}
}
errorMessage
.
value
=
err
.
response
?.
data
?.
detail
||
err
.
response
?.
data
?.
message
||
err
.
message
||
t
(
'
auth.loginFailed
'
)
appStore
.
showError
(
errorMessage
.
value
)
isProcessing
.
value
=
false
}
...
...
frontend/src/views/auth/RegisterView.vue
View file @
e9de839d
...
...
@@ -11,12 +11,17 @@
<
/p
>
<
/div
>
<
div
v
-
if
=
"
linuxdoOAuthEnabled || oidcOAuthEnabled
"
class
=
"
space-y-4
"
>
<
div
v
-
if
=
"
linuxdoOAuthEnabled ||
wechatOAuthEnabled ||
oidcOAuthEnabled
"
class
=
"
space-y-4
"
>
<
LinuxDoOAuthSection
v
-
if
=
"
linuxdoOAuthEnabled
"
:
disabled
=
"
isLoading
"
:
show
-
divider
=
"
false
"
/>
<
WechatOAuthSection
v
-
if
=
"
wechatOAuthEnabled
"
:
disabled
=
"
isLoading
"
:
show
-
divider
=
"
false
"
/>
<
OidcOAuthSection
v
-
if
=
"
oidcOAuthEnabled
"
:
disabled
=
"
isLoading
"
...
...
@@ -308,6 +313,7 @@ import { useI18n } from 'vue-i18n'
import
{
AuthLayout
}
from
'
@/components/layout
'
import
LinuxDoOAuthSection
from
'
@/components/auth/LinuxDoOAuthSection.vue
'
import
OidcOAuthSection
from
'
@/components/auth/OidcOAuthSection.vue
'
import
WechatOAuthSection
from
'
@/components/auth/WechatOAuthSection.vue
'
import
Icon
from
'
@/components/icons/Icon.vue
'
import
TurnstileWidget
from
'
@/components/TurnstileWidget.vue
'
import
{
useAuthStore
,
useAppStore
}
from
'
@/stores
'
...
...
@@ -343,6 +349,7 @@ const turnstileEnabled = ref<boolean>(false)
const
turnstileSiteKey
=
ref
<
string
>
(
''
)
const
siteName
=
ref
<
string
>
(
'
Sub2API
'
)
const
linuxdoOAuthEnabled
=
ref
<
boolean
>
(
false
)
const
wechatOAuthEnabled
=
ref
<
boolean
>
(
false
)
const
oidcOAuthEnabled
=
ref
<
boolean
>
(
false
)
const
oidcOAuthProviderName
=
ref
<
string
>
(
'
OIDC
'
)
const
registrationEmailSuffixWhitelist
=
ref
<
string
[]
>
([])
...
...
@@ -397,6 +404,7 @@ onMounted(async () => {
turnstileSiteKey
.
value
=
settings
.
turnstile_site_key
||
''
siteName
.
value
=
settings
.
site_name
||
'
Sub2API
'
linuxdoOAuthEnabled
.
value
=
settings
.
linuxdo_oauth_enabled
wechatOAuthEnabled
.
value
=
settings
.
wechat_oauth_enabled
oidcOAuthEnabled
.
value
=
settings
.
oidc_oauth_enabled
oidcOAuthProviderName
.
value
=
settings
.
oidc_oauth_provider_name
||
'
OIDC
'
registrationEmailSuffixWhitelist
.
value
=
normalizeRegistrationEmailSuffixWhitelist
(
...
...
frontend/src/views/auth/WechatCallbackView.vue
0 → 100644
View file @
e9de839d
<
template
>
<AuthLayout>
<div
class=
"space-y-6"
>
<div
class=
"text-center"
>
<h2
class=
"text-2xl font-bold text-gray-900 dark:text-white"
>
{{
t
(
'
auth.oidc.callbackTitle
'
,
{
providerName
}
)
}}
<
/h2
>
<
p
class
=
"
mt-2 text-sm text-gray-500 dark:text-dark-400
"
>
{{
isProcessing
?
t
(
'
auth.oidc.callbackProcessing
'
,
{
providerName
}
)
:
t
(
'
auth.oidc.callbackHint
'
)
}}
<
/p
>
<
/div
>
<
transition
name
=
"
fade
"
>
<
div
v
-
if
=
"
needsInvitation || needsAdoptionConfirmation
"
class
=
"
space-y-4
"
>
<
div
v
-
if
=
"
adoptionRequired && (suggestedDisplayName || suggestedAvatarUrl)
"
class
=
"
rounded-xl border border-gray-200 bg-gray-50 p-4 dark:border-dark-600 dark:bg-dark-800/60
"
>
<
div
class
=
"
space-y-3
"
>
<
div
class
=
"
space-y-1
"
>
<
p
class
=
"
text-sm font-medium text-gray-900 dark:text-white
"
>
Use
{{
providerName
}}
profile
details
<
/p
>
<
p
class
=
"
text-xs text-gray-500 dark:text-dark-400
"
>
Choose
whether
to
apply
the
nickname
or
avatar
from
{{
providerName
}}
to
this
account
.
<
/p
>
<
/div
>
<
label
v
-
if
=
"
suggestedDisplayName
"
class
=
"
flex items-start gap-3 rounded-lg border border-gray-200 bg-white p-3 text-sm dark:border-dark-600 dark:bg-dark-900/50
"
>
<
input
v
-
model
=
"
adoptDisplayName
"
type
=
"
checkbox
"
class
=
"
mt-1 h-4 w-4
"
/>
<
span
class
=
"
space-y-1
"
>
<
span
class
=
"
block font-medium text-gray-900 dark:text-white
"
>
Use
display
name
<
/span
>
<
span
class
=
"
block text-gray-500 dark:text-dark-400
"
>
{{
suggestedDisplayName
}}
<
/span
>
<
/span
>
<
/label
>
<
label
v
-
if
=
"
suggestedAvatarUrl
"
class
=
"
flex items-start gap-3 rounded-lg border border-gray-200 bg-white p-3 text-sm dark:border-dark-600 dark:bg-dark-900/50
"
>
<
input
v
-
model
=
"
adoptAvatar
"
type
=
"
checkbox
"
class
=
"
mt-1 h-4 w-4
"
/>
<
img
:
src
=
"
suggestedAvatarUrl
"
:
alt
=
"
`${providerName
}
avatar`
"
class
=
"
h-10 w-10 rounded-full border border-gray-200 object-cover dark:border-dark-600
"
/>
<
span
class
=
"
space-y-1
"
>
<
span
class
=
"
block font-medium text-gray-900 dark:text-white
"
>
Use
avatar
<
/span
>
<
span
class
=
"
block break-all text-gray-500 dark:text-dark-400
"
>
{{
suggestedAvatarUrl
}}
<
/span
>
<
/span
>
<
/label
>
<
/div
>
<
/div
>
<
template
v
-
if
=
"
needsInvitation
"
>
<
p
class
=
"
text-sm text-gray-700 dark:text-gray-300
"
>
{{
t
(
'
auth.oidc.invitationRequired
'
,
{
providerName
}
)
}}
<
/p
>
<
div
>
<
input
v
-
model
=
"
invitationCode
"
type
=
"
text
"
class
=
"
input w-full
"
:
placeholder
=
"
t('auth.invitationCodePlaceholder')
"
:
disabled
=
"
isSubmitting
"
@
keyup
.
enter
=
"
handleSubmitInvitation
"
/>
<
/div
>
<
transition
name
=
"
fade
"
>
<
p
v
-
if
=
"
invitationError
"
class
=
"
text-sm text-red-600 dark:text-red-400
"
>
{{
invitationError
}}
<
/p
>
<
/transition
>
<
button
class
=
"
btn btn-primary w-full
"
:
disabled
=
"
isSubmitting || !invitationCode.trim()
"
@
click
=
"
handleSubmitInvitation
"
>
{{
isSubmitting
?
t
(
'
auth.oidc.completing
'
)
:
t
(
'
auth.oidc.completeRegistration
'
)
}}
<
/button
>
<
/template
>
<
template
v
-
else
-
if
=
"
needsAdoptionConfirmation
"
>
<
p
class
=
"
text-sm text-gray-700 dark:text-gray-300
"
>
Review
the
{{
providerName
}}
profile
details
before
continuing
.
<
/p
>
<
button
class
=
"
btn btn-primary w-full
"
:
disabled
=
"
isSubmitting
"
@
click
=
"
handleContinueLogin
"
>
{{
isSubmitting
?
t
(
'
common.processing
'
)
:
'
Continue
'
}}
<
/button
>
<
/template
>
<
/div
>
<
/transition
>
<
transition
name
=
"
fade
"
>
<
div
v
-
if
=
"
errorMessage
"
class
=
"
rounded-xl border border-red-200 bg-red-50 p-4 dark:border-red-800/50 dark:bg-red-900/20
"
>
<
div
class
=
"
flex items-start gap-3
"
>
<
div
class
=
"
flex-shrink-0
"
>
<
Icon
name
=
"
exclamationCircle
"
size
=
"
md
"
class
=
"
text-red-500
"
/>
<
/div
>
<
div
class
=
"
space-y-2
"
>
<
p
class
=
"
text-sm text-red-700 dark:text-red-400
"
>
{{
errorMessage
}}
<
/p
>
<
router
-
link
to
=
"
/login
"
class
=
"
btn btn-primary
"
>
{{
t
(
'
auth.oidc.backToLogin
'
)
}}
<
/router-link
>
<
/div
>
<
/div
>
<
/div
>
<
/transition
>
<
/div
>
<
/AuthLayout
>
<
/template
>
<
script
setup
lang
=
"
ts
"
>
import
{
onMounted
,
ref
}
from
'
vue
'
import
{
useRoute
,
useRouter
}
from
'
vue-router
'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
AuthLayout
}
from
'
@/components/layout
'
import
Icon
from
'
@/components/icons/Icon.vue
'
import
{
apiClient
}
from
'
@/api/client
'
import
{
useAuthStore
,
useAppStore
}
from
'
@/stores
'
interface
OAuthTokenResponse
{
access_token
:
string
refresh_token
:
string
expires_in
:
number
token_type
:
string
}
interface
PendingOAuthExchangeResponse
{
access_token
?:
string
refresh_token
?:
string
expires_in
?:
number
token_type
?:
string
redirect
?:
string
error
?:
string
adoption_required
?:
boolean
suggested_display_name
?:
string
suggested_avatar_url
?:
string
}
const
route
=
useRoute
()
const
router
=
useRouter
()
const
{
t
}
=
useI18n
()
const
authStore
=
useAuthStore
()
const
appStore
=
useAppStore
()
const
isProcessing
=
ref
(
true
)
const
errorMessage
=
ref
(
''
)
const
needsInvitation
=
ref
(
false
)
const
invitationCode
=
ref
(
''
)
const
isSubmitting
=
ref
(
false
)
const
invitationError
=
ref
(
''
)
const
redirectTo
=
ref
(
'
/dashboard
'
)
const
adoptionRequired
=
ref
(
false
)
const
suggestedDisplayName
=
ref
(
''
)
const
suggestedAvatarUrl
=
ref
(
''
)
const
adoptDisplayName
=
ref
(
true
)
const
adoptAvatar
=
ref
(
true
)
const
needsAdoptionConfirmation
=
ref
(
false
)
const
providerName
=
'
WeChat
'
function
parseFragmentParams
():
URLSearchParams
{
const
raw
=
typeof
window
!==
'
undefined
'
?
window
.
location
.
hash
:
''
const
hash
=
raw
.
startsWith
(
'
#
'
)
?
raw
.
slice
(
1
)
:
raw
return
new
URLSearchParams
(
hash
)
}
function
sanitizeRedirectPath
(
path
:
string
|
null
|
undefined
):
string
{
if
(
!
path
)
return
'
/dashboard
'
if
(
!
path
.
startsWith
(
'
/
'
))
return
'
/dashboard
'
if
(
path
.
startsWith
(
'
//
'
))
return
'
/dashboard
'
if
(
path
.
includes
(
'
://
'
))
return
'
/dashboard
'
if
(
path
.
includes
(
'
\n
'
)
||
path
.
includes
(
'
\r
'
))
return
'
/dashboard
'
return
path
}
function
currentAdoptionDecision
():
Record
<
string
,
boolean
>
{
return
{
adopt_display_name
:
adoptDisplayName
.
value
,
adopt_avatar
:
adoptAvatar
.
value
,
}
}
function
applyAdoptionSuggestionState
(
completion
:
PendingOAuthExchangeResponse
)
{
adoptionRequired
.
value
=
completion
.
adoption_required
===
true
suggestedDisplayName
.
value
=
completion
.
suggested_display_name
||
''
suggestedAvatarUrl
.
value
=
completion
.
suggested_avatar_url
||
''
if
(
!
suggestedDisplayName
.
value
)
{
adoptDisplayName
.
value
=
false
}
if
(
!
suggestedAvatarUrl
.
value
)
{
adoptAvatar
.
value
=
false
}
}
function
hasSuggestedProfile
(
completion
:
PendingOAuthExchangeResponse
):
boolean
{
return
Boolean
(
completion
.
suggested_display_name
||
completion
.
suggested_avatar_url
)
}
async
function
exchangePendingOAuthCompletion
():
Promise
<
PendingOAuthExchangeResponse
>
{
const
{
data
}
=
await
apiClient
.
post
<
PendingOAuthExchangeResponse
>
(
'
/auth/oauth/pending/exchange
'
,
{
}
)
return
data
}
async
function
finalizeLogin
(
completion
:
PendingOAuthExchangeResponse
,
redirect
:
string
)
{
if
(
!
completion
.
access_token
)
{
throw
new
Error
(
t
(
'
auth.oidc.callbackMissingToken
'
))
}
if
(
completion
.
refresh_token
)
{
localStorage
.
setItem
(
'
refresh_token
'
,
completion
.
refresh_token
)
}
if
(
completion
.
expires_in
)
{
localStorage
.
setItem
(
'
token_expires_at
'
,
String
(
Date
.
now
()
+
completion
.
expires_in
*
1000
))
}
await
authStore
.
setToken
(
completion
.
access_token
)
appStore
.
showSuccess
(
t
(
'
auth.loginSuccess
'
))
await
router
.
replace
(
redirect
)
}
async
function
completeWeChatOAuthRegistration
(
invitation
:
string
):
Promise
<
OAuthTokenResponse
>
{
const
{
data
}
=
await
apiClient
.
post
<
OAuthTokenResponse
>
(
'
/auth/oauth/wechat/complete-registration
'
,
{
invitation_code
:
invitation
,
...
currentAdoptionDecision
(),
}
)
return
data
}
async
function
handleSubmitInvitation
()
{
invitationError
.
value
=
''
if
(
!
invitationCode
.
value
.
trim
())
return
isSubmitting
.
value
=
true
try
{
const
tokenData
=
await
completeWeChatOAuthRegistration
(
invitationCode
.
value
.
trim
())
if
(
tokenData
.
refresh_token
)
{
localStorage
.
setItem
(
'
refresh_token
'
,
tokenData
.
refresh_token
)
}
if
(
tokenData
.
expires_in
)
{
localStorage
.
setItem
(
'
token_expires_at
'
,
String
(
Date
.
now
()
+
tokenData
.
expires_in
*
1000
))
}
await
authStore
.
setToken
(
tokenData
.
access_token
)
appStore
.
showSuccess
(
t
(
'
auth.loginSuccess
'
))
await
router
.
replace
(
redirectTo
.
value
)
}
catch
(
e
:
unknown
)
{
const
err
=
e
as
{
message
?:
string
;
response
?:
{
data
?:
{
message
?:
string
}
}
}
invitationError
.
value
=
err
.
response
?.
data
?.
message
||
err
.
message
||
t
(
'
auth.oidc.completeRegistrationFailed
'
)
}
finally
{
isSubmitting
.
value
=
false
}
}
async
function
handleContinueLogin
()
{
isSubmitting
.
value
=
true
try
{
const
{
data
}
=
await
apiClient
.
post
<
PendingOAuthExchangeResponse
>
(
'
/auth/oauth/pending/exchange
'
,
currentAdoptionDecision
()
)
await
finalizeLogin
(
data
,
redirectTo
.
value
)
}
catch
(
e
:
unknown
)
{
const
err
=
e
as
{
message
?:
string
;
response
?:
{
data
?:
{
detail
?:
string
;
message
?:
string
}
}
}
errorMessage
.
value
=
err
.
response
?.
data
?.
detail
||
err
.
response
?.
data
?.
message
||
err
.
message
||
t
(
'
auth.loginFailed
'
)
appStore
.
showError
(
errorMessage
.
value
)
needsAdoptionConfirmation
.
value
=
false
}
finally
{
isSubmitting
.
value
=
false
}
}
onMounted
(
async
()
=>
{
const
params
=
parseFragmentParams
()
const
error
=
params
.
get
(
'
error
'
)
const
errorDesc
=
params
.
get
(
'
error_description
'
)
||
params
.
get
(
'
error_message
'
)
||
''
if
(
error
)
{
errorMessage
.
value
=
errorDesc
||
error
appStore
.
showError
(
errorMessage
.
value
)
isProcessing
.
value
=
false
return
}
try
{
const
completion
=
await
exchangePendingOAuthCompletion
()
const
redirect
=
sanitizeRedirectPath
(
completion
.
redirect
||
(
route
.
query
.
redirect
as
string
|
undefined
)
||
'
/dashboard
'
)
applyAdoptionSuggestionState
(
completion
)
redirectTo
.
value
=
redirect
if
(
completion
.
error
===
'
invitation_required
'
)
{
needsInvitation
.
value
=
true
isProcessing
.
value
=
false
return
}
if
(
adoptionRequired
.
value
&&
hasSuggestedProfile
(
completion
))
{
needsAdoptionConfirmation
.
value
=
true
isProcessing
.
value
=
false
return
}
await
finalizeLogin
(
completion
,
redirect
)
}
catch
(
e
:
unknown
)
{
const
err
=
e
as
{
message
?:
string
;
response
?:
{
data
?:
{
detail
?:
string
;
message
?:
string
}
}
}
errorMessage
.
value
=
err
.
response
?.
data
?.
detail
||
err
.
response
?.
data
?.
message
||
err
.
message
||
t
(
'
auth.loginFailed
'
)
appStore
.
showError
(
errorMessage
.
value
)
isProcessing
.
value
=
false
}
}
)
<
/script
>
<
style
scoped
>
.
fade
-
enter
-
active
,
.
fade
-
leave
-
active
{
transition
:
all
0.3
s
ease
;
}
.
fade
-
enter
-
from
,
.
fade
-
leave
-
to
{
opacity
:
0
;
transform
:
translateY
(
-
8
px
);
}
<
/style
>
frontend/src/views/auth/__tests__/LinuxDoCallbackView.spec.ts
0 → 100644
View file @
e9de839d
import
{
beforeEach
,
describe
,
expect
,
it
,
vi
}
from
'
vitest
'
import
{
flushPromises
,
mount
}
from
'
@vue/test-utils
'
import
LinuxDoCallbackView
from
'
../LinuxDoCallbackView.vue
'
const
replace
=
vi
.
fn
()
const
showSuccess
=
vi
.
fn
()
const
showError
=
vi
.
fn
()
const
setToken
=
vi
.
fn
()
const
exchangePendingOAuthCompletion
=
vi
.
fn
()
const
completeLinuxDoOAuthRegistration
=
vi
.
fn
()
vi
.
mock
(
'
vue-router
'
,
()
=>
({
useRoute
:
()
=>
({
query
:
{}
}),
useRouter
:
()
=>
({
replace
})
}))
vi
.
mock
(
'
vue-i18n
'
,
async
()
=>
{
const
actual
=
await
vi
.
importActual
<
typeof
import
(
'
vue-i18n
'
)
>
(
'
vue-i18n
'
)
return
{
...
actual
,
useI18n
:
()
=>
({
t
:
(
key
:
string
)
=>
key
})
}
})
vi
.
mock
(
'
@/stores
'
,
()
=>
({
useAuthStore
:
()
=>
({
setToken
}),
useAppStore
:
()
=>
({
showSuccess
,
showError
})
}))
vi
.
mock
(
'
@/api/auth
'
,
()
=>
({
exchangePendingOAuthCompletion
:
(...
args
:
any
[])
=>
exchangePendingOAuthCompletion
(...
args
),
completeLinuxDoOAuthRegistration
:
(...
args
:
any
[])
=>
completeLinuxDoOAuthRegistration
(...
args
)
}))
describe
(
'
LinuxDoCallbackView
'
,
()
=>
{
beforeEach
(()
=>
{
replace
.
mockReset
()
showSuccess
.
mockReset
()
showError
.
mockReset
()
setToken
.
mockReset
()
exchangePendingOAuthCompletion
.
mockReset
()
completeLinuxDoOAuthRegistration
.
mockReset
()
})
it
(
'
does not send adoption decisions during the initial exchange
'
,
async
()
=>
{
exchangePendingOAuthCompletion
.
mockResolvedValue
({
access_token
:
'
access-token
'
,
refresh_token
:
'
refresh-token
'
,
expires_in
:
3600
,
redirect
:
'
/dashboard
'
,
adoption_required
:
true
})
setToken
.
mockResolvedValue
({})
mount
(
LinuxDoCallbackView
,
{
global
:
{
stubs
:
{
AuthLayout
:
{
template
:
'
<div><slot /></div>
'
},
Icon
:
true
,
RouterLink
:
{
template
:
'
<a><slot /></a>
'
},
transition
:
false
}
}
})
await
flushPromises
()
expect
(
exchangePendingOAuthCompletion
).
toHaveBeenCalledTimes
(
1
)
expect
(
exchangePendingOAuthCompletion
).
toHaveBeenCalledWith
()
})
it
(
'
waits for explicit adoption confirmation before finishing a non-invitation login
'
,
async
()
=>
{
exchangePendingOAuthCompletion
.
mockResolvedValueOnce
({
redirect
:
'
/dashboard
'
,
adoption_required
:
true
,
suggested_display_name
:
'
LinuxDo Nick
'
,
suggested_avatar_url
:
'
https://cdn.example/linuxdo.png
'
})
.
mockResolvedValueOnce
({
access_token
:
'
access-token
'
,
refresh_token
:
'
refresh-token
'
,
expires_in
:
3600
,
redirect
:
'
/dashboard
'
})
setToken
.
mockResolvedValue
({})
const
wrapper
=
mount
(
LinuxDoCallbackView
,
{
global
:
{
stubs
:
{
AuthLayout
:
{
template
:
'
<div><slot /></div>
'
},
Icon
:
true
,
RouterLink
:
{
template
:
'
<a><slot /></a>
'
},
transition
:
false
}
}
})
await
flushPromises
()
expect
(
wrapper
.
text
()).
toContain
(
'
LinuxDo Nick
'
)
expect
(
setToken
).
not
.
toHaveBeenCalled
()
expect
(
replace
).
not
.
toHaveBeenCalled
()
const
checkboxes
=
wrapper
.
findAll
(
'
input[type="checkbox"]
'
)
await
checkboxes
[
1
].
setValue
(
false
)
const
buttons
=
wrapper
.
findAll
(
'
button
'
)
expect
(
buttons
).
toHaveLength
(
1
)
await
buttons
[
0
].
trigger
(
'
click
'
)
await
flushPromises
()
expect
(
exchangePendingOAuthCompletion
).
toHaveBeenCalledTimes
(
2
)
expect
(
exchangePendingOAuthCompletion
).
toHaveBeenNthCalledWith
(
1
)
expect
(
exchangePendingOAuthCompletion
).
toHaveBeenNthCalledWith
(
2
,
{
adoptDisplayName
:
true
,
adoptAvatar
:
false
})
expect
(
setToken
).
toHaveBeenCalledWith
(
'
access-token
'
)
expect
(
replace
).
toHaveBeenCalledWith
(
'
/dashboard
'
)
})
it
(
'
renders adoption choices for invitation flow and submits the selected values
'
,
async
()
=>
{
exchangePendingOAuthCompletion
.
mockResolvedValue
({
error
:
'
invitation_required
'
,
redirect
:
'
/dashboard
'
,
adoption_required
:
true
,
suggested_display_name
:
'
LinuxDo Nick
'
,
suggested_avatar_url
:
'
https://cdn.example/linuxdo.png
'
})
completeLinuxDoOAuthRegistration
.
mockResolvedValue
({
access_token
:
'
access-token
'
,
refresh_token
:
'
refresh-token
'
,
expires_in
:
3600
,
token_type
:
'
Bearer
'
})
setToken
.
mockResolvedValue
({})
const
wrapper
=
mount
(
LinuxDoCallbackView
,
{
global
:
{
stubs
:
{
AuthLayout
:
{
template
:
'
<div><slot /></div>
'
},
Icon
:
true
,
RouterLink
:
{
template
:
'
<a><slot /></a>
'
},
transition
:
false
}
}
})
await
flushPromises
()
expect
(
wrapper
.
text
()).
toContain
(
'
LinuxDo Nick
'
)
expect
(
exchangePendingOAuthCompletion
).
toHaveBeenCalledTimes
(
1
)
expect
(
exchangePendingOAuthCompletion
).
toHaveBeenCalledWith
()
const
checkboxes
=
wrapper
.
findAll
(
'
input[type="checkbox"]
'
)
expect
(
checkboxes
).
toHaveLength
(
2
)
await
checkboxes
[
0
].
setValue
(
false
)
await
wrapper
.
find
(
'
input[type="text"]
'
).
setValue
(
'
invite-code
'
)
await
wrapper
.
find
(
'
button
'
).
trigger
(
'
click
'
)
expect
(
completeLinuxDoOAuthRegistration
).
toHaveBeenCalledWith
(
'
invite-code
'
,
{
adoptDisplayName
:
false
,
adoptAvatar
:
true
})
})
})
Prev
1
2
3
4
5
6
7
Next
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