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
2b70d1d3
Commit
2b70d1d3
authored
Apr 09, 2026
by
IanShaw027
Browse files
merge upstream main into fix/bug-cleanup-main
parents
b37afd68
00c08c57
Changes
60
Expand all
Hide whitespace changes
Inline
Side-by-side
backend/migrations/091_add_group_messages_dispatch_model_config.sql
0 → 100644
View file @
2b70d1d3
ALTER
TABLE
groups
ADD
COLUMN
IF
NOT
EXISTS
messages_dispatch_model_config
JSONB
NOT
NULL
DEFAULT
'{}'
::
jsonb
;
deploy/codex-instructions.md.tmpl
0 → 100644
View file @
2b70d1d3
You are Codex, based on GPT-5. You are running as a coding agent in the Codex CLI on a user's computer.
{{ if .ExistingInstructions }}
{{ .ExistingInstructions }}
{{ end }}
deploy/config.example.yaml
View file @
2b70d1d3
...
...
@@ -202,6 +202,32 @@ gateway:
#
# 注意:开启后会影响所有客户端的行为(不仅限于 VS Code / Codex CLI),请谨慎开启。
force_codex_cli
:
false
# Optional: template file used to build the final top-level Codex `instructions`.
# 可选:用于构建最终 Codex 顶层 `instructions` 的模板文件路径。
#
# This is applied on the `/v1/messages -> Responses/Codex` conversion path,
# after Claude `system` has already been normalized into Codex `instructions`.
# 该模板作用于 `/v1/messages -> Responses/Codex` 转换链路,且发生在 Claude `system`
# 已经被归一化为 Codex `instructions` 之后。
#
# The template can reference:
# 模板可引用:
# - {{ .ExistingInstructions }} : converted client instructions/system
# - {{ .OriginalModel }} : original requested model
# - {{ .NormalizedModel }} : normalized routing model
# - {{ .BillingModel }} : billing model
# - {{ .UpstreamModel }} : final upstream model
#
# If you want to preserve client system prompts, keep {{ .ExistingInstructions }}
# somewhere in the template. If omitted, the template output fully replaces it.
# 如需保留客户端 system 提示词,请在模板中显式包含 {{ .ExistingInstructions }}。
# 若省略,则模板输出会完全覆盖它。
#
# Docker users can mount a host file to /app/data/codex-instructions.md.tmpl
# and point this field there.
# Docker 用户可将宿主机文件挂载到 /app/data/codex-instructions.md.tmpl,
# 然后把本字段指向该路径。
forced_codex_instructions_template_file
:
"
"
# OpenAI 透传模式是否放行客户端超时头(如 x-stainless-timeout)
# 默认 false:过滤超时头,降低上游提前断流风险。
openai_passthrough_allow_timeout_headers
:
false
...
...
@@ -347,12 +373,6 @@ gateway:
# Enable batch load calculation for scheduling
# 启用调度批量负载计算
load_batch_enabled
:
true
# Snapshot bucket MGET chunk size
# 调度快照分桶读取时的 MGET 分块大小
snapshot_mget_chunk_size
:
128
# Snapshot bucket write chunk size
# 调度快照重建写入时的分块大小
snapshot_write_chunk_size
:
256
# Slot cleanup interval (duration)
# 并发槽位清理周期(时间段)
slot_cleanup_interval
:
30s
...
...
@@ -826,6 +846,46 @@ linuxdo_connect:
userinfo_id_path
:
"
"
userinfo_username_path
:
"
"
# =============================================================================
# Generic OIDC OAuth Login (SSO)
# 通用 OIDC OAuth 登录(用于 Sub2API 用户登录)
# =============================================================================
oidc_connect
:
enabled
:
false
provider_name
:
"
OIDC"
client_id
:
"
"
client_secret
:
"
"
# 例如: "https://keycloak.example.com/realms/myrealm"
issuer_url
:
"
"
# 可选: OIDC Discovery URL。为空时可手动填写 authorize/token/userinfo/jwks
discovery_url
:
"
"
authorize_url
:
"
"
token_url
:
"
"
# 可选(仅补充 email/username,不用于 sub 可信绑定)
userinfo_url
:
"
"
# validate_id_token=true 时必填
jwks_url
:
"
"
scopes
:
"
openid
email
profile"
# 示例: "https://your-domain.com/api/v1/auth/oauth/oidc/callback"
redirect_url
:
"
"
# 安全提示:
# - 建议使用同源相对路径(以 / 开头),避免把 token 重定向到意外的第三方域名
# - 该地址不应包含 #fragment(本实现使用 URL fragment 传递 access_token)
frontend_redirect_url
:
"
/auth/oidc/callback"
token_auth_method
:
"
client_secret_post"
# client_secret_post | client_secret_basic | none
# 注意:当 token_auth_method=none(public client)时,必须启用 PKCE
use_pkce
:
false
# 开启后强制校验 id_token 的签名和 claims(推荐)
validate_id_token
:
true
allowed_signing_algs
:
"
RS256,ES256,PS256"
# 允许的时钟偏移(秒)
clock_skew_seconds
:
120
# 若 Provider 返回 email_verified=false,是否拒绝登录
require_email_verified
:
false
userinfo_email_path
:
"
"
userinfo_id_path
:
"
"
userinfo_username_path
:
"
"
# =============================================================================
# Default Settings
# 默认设置
...
...
deploy/docker-compose.yml
View file @
2b70d1d3
...
...
@@ -31,6 +31,10 @@ services:
# Optional: Mount custom config.yaml (uncomment and create the file first)
# Copy config.example.yaml to config.yaml, modify it, then uncomment:
# - ./config.yaml:/app/data/config.yaml
# Optional: Mount a custom Codex instructions template file, then point
# gateway.forced_codex_instructions_template_file at /app/data/codex-instructions.md.tmpl
# in config.yaml.
# - ./codex-instructions.md.tmpl:/app/data/codex-instructions.md.tmpl:ro
environment
:
# =======================================================================
# Auto Setup (REQUIRED for Docker deployment)
...
...
@@ -146,7 +150,17 @@ services:
networks
:
-
sub2api-network
healthcheck
:
test
:
[
"
CMD"
,
"
wget"
,
"
-q"
,
"
-T"
,
"
5"
,
"
-O"
,
"
/dev/null"
,
"
http://localhost:8080/health"
]
test
:
[
"
CMD"
,
"
wget"
,
"
-q"
,
"
-T"
,
"
5"
,
"
-O"
,
"
/dev/null"
,
"
http://localhost:8080/health"
,
]
interval
:
30s
timeout
:
10s
retries
:
3
...
...
@@ -177,11 +191,17 @@ services:
networks
:
-
sub2api-network
healthcheck
:
test
:
[
"
CMD-SHELL"
,
"
pg_isready
-U
${POSTGRES_USER:-sub2api}
-d
${POSTGRES_DB:-sub2api}"
]
test
:
[
"
CMD-SHELL"
,
"
pg_isready
-U
${POSTGRES_USER:-sub2api}
-d
${POSTGRES_DB:-sub2api}"
,
]
interval
:
10s
timeout
:
5s
retries
:
5
start_period
:
10s
ports
:
-
5432:5432
# 注意:不暴露端口到宿主机,应用通过内部网络连接
# 如需调试,可临时添加:ports: ["127.0.0.1:5433:5432"]
...
...
@@ -199,12 +219,12 @@ services:
volumes
:
-
redis_data:/data
command
:
>
sh -c '
redis-server
--save 60 1
--appendonly yes
--appendfsync everysec
${REDIS_PASSWORD:+--requirepass "$REDIS_PASSWORD"}'
sh -c '
redis-server
--save 60 1
--appendonly yes
--appendfsync everysec
${REDIS_PASSWORD:+--requirepass "$REDIS_PASSWORD"}'
environment
:
-
TZ=${TZ:-Asia/Shanghai}
# REDISCLI_AUTH is used by redis-cli for authentication (safer than -a flag)
...
...
@@ -217,7 +237,8 @@ services:
timeout
:
5s
retries
:
5
start_period
:
5s
ports
:
-
6379:6379
# =============================================================================
# Volumes
# =============================================================================
...
...
frontend/src/api/admin/settings.ts
View file @
2b70d1d3
...
...
@@ -64,6 +64,30 @@ export interface SystemSettings {
linuxdo_connect_client_secret_configured
:
boolean
linuxdo_connect_redirect_url
:
string
// Generic OIDC OAuth settings
oidc_connect_enabled
:
boolean
oidc_connect_provider_name
:
string
oidc_connect_client_id
:
string
oidc_connect_client_secret_configured
:
boolean
oidc_connect_issuer_url
:
string
oidc_connect_discovery_url
:
string
oidc_connect_authorize_url
:
string
oidc_connect_token_url
:
string
oidc_connect_userinfo_url
:
string
oidc_connect_jwks_url
:
string
oidc_connect_scopes
:
string
oidc_connect_redirect_url
:
string
oidc_connect_frontend_redirect_url
:
string
oidc_connect_token_auth_method
:
string
oidc_connect_use_pkce
:
boolean
oidc_connect_validate_id_token
:
boolean
oidc_connect_allowed_signing_algs
:
string
oidc_connect_clock_skew_seconds
:
number
oidc_connect_require_email_verified
:
boolean
oidc_connect_userinfo_email_path
:
string
oidc_connect_userinfo_id_path
:
string
oidc_connect_userinfo_username_path
:
string
// Model fallback configuration
enable_model_fallback
:
boolean
fallback_model_anthropic
:
string
...
...
@@ -135,6 +159,28 @@ export interface UpdateSettingsRequest {
linuxdo_connect_client_id
?:
string
linuxdo_connect_client_secret
?:
string
linuxdo_connect_redirect_url
?:
string
oidc_connect_enabled
?:
boolean
oidc_connect_provider_name
?:
string
oidc_connect_client_id
?:
string
oidc_connect_client_secret
?:
string
oidc_connect_issuer_url
?:
string
oidc_connect_discovery_url
?:
string
oidc_connect_authorize_url
?:
string
oidc_connect_token_url
?:
string
oidc_connect_userinfo_url
?:
string
oidc_connect_jwks_url
?:
string
oidc_connect_scopes
?:
string
oidc_connect_redirect_url
?:
string
oidc_connect_frontend_redirect_url
?:
string
oidc_connect_token_auth_method
?:
string
oidc_connect_use_pkce
?:
boolean
oidc_connect_validate_id_token
?:
boolean
oidc_connect_allowed_signing_algs
?:
string
oidc_connect_clock_skew_seconds
?:
number
oidc_connect_require_email_verified
?:
boolean
oidc_connect_userinfo_email_path
?:
string
oidc_connect_userinfo_id_path
?:
string
oidc_connect_userinfo_username_path
?:
string
enable_model_fallback
?:
boolean
fallback_model_anthropic
?:
string
fallback_model_openai
?:
string
...
...
frontend/src/api/auth.ts
View file @
2b70d1d3
...
...
@@ -357,6 +357,28 @@ export async function completeLinuxDoOAuthRegistration(
return
data
}
/**
* Complete OIDC OAuth registration by supplying an invitation code
* @param pendingOAuthToken - Short-lived JWT from the OAuth callback
* @param invitationCode - Invitation code entered by the user
* @returns Token pair on success
*/
export
async
function
completeOIDCOAuthRegistration
(
pendingOAuthToken
:
string
,
invitationCode
:
string
):
Promise
<
{
access_token
:
string
;
refresh_token
:
string
;
expires_in
:
number
;
token_type
:
string
}
>
{
const
{
data
}
=
await
apiClient
.
post
<
{
access_token
:
string
refresh_token
:
string
expires_in
:
number
token_type
:
string
}
>
(
'
/auth/oauth/oidc/complete-registration
'
,
{
pending_oauth_token
:
pendingOAuthToken
,
invitation_code
:
invitationCode
})
return
data
}
export
const
authAPI
=
{
login
,
login2FA
,
...
...
@@ -380,7 +402,8 @@ export const authAPI = {
resetPassword
,
refreshToken
,
revokeAllSessions
,
completeLinuxDoOAuthRegistration
completeLinuxDoOAuthRegistration
,
completeOIDCOAuthRegistration
}
export
default
authAPI
frontend/src/components/auth/LinuxDoOAuthSection.vue
View file @
2b70d1d3
...
...
@@ -29,10 +29,10 @@
{{
t
(
'
auth.linuxdo.signIn
'
)
}}
</button>
<div
class=
"flex items-center gap-3"
>
<div
v-if=
"showDivider"
class=
"flex items-center gap-3"
>
<div
class=
"h-px flex-1 bg-gray-200 dark:bg-dark-700"
></div>
<span
class=
"text-xs text-gray-500 dark:text-dark-400"
>
{{
t
(
'
auth.
linuxdo.o
rContinue
'
)
}}
{{
t
(
'
auth.
oauthO
rContinue
'
)
}}
</span>
<div
class=
"h-px flex-1 bg-gray-200 dark:bg-dark-700"
></div>
</div>
...
...
@@ -43,9 +43,12 @@
import
{
useRoute
}
from
'
vue-router
'
import
{
useI18n
}
from
'
vue-i18n
'
defineProps
<
{
withDefaults
(
defineProps
<
{
disabled
?:
boolean
}
>
()
showDivider
?:
boolean
}
>
(),
{
showDivider
:
true
})
const
route
=
useRoute
()
const
{
t
}
=
useI18n
()
...
...
@@ -58,4 +61,3 @@ function startLogin(): void {
window
.
location
.
href
=
startURL
}
</
script
>
frontend/src/components/auth/OidcOAuthSection.vue
0 → 100644
View file @
2b70d1d3
<
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-primary-100 text-xs font-semibold text-primary-700 dark:bg-primary-900/30 dark:text-primary-300"
>
{{
providerInitial
}}
</span>
{{
t
(
'
auth.oidc.signIn
'
,
{
providerName
:
normalizedProviderName
}
)
}}
<
/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
{
computed
}
from
'
vue
'
import
{
useRoute
}
from
'
vue-router
'
import
{
useI18n
}
from
'
vue-i18n
'
const
props
=
withDefaults
(
defineProps
<
{
disabled
?:
boolean
providerName
?:
string
showDivider
?:
boolean
}
>
(),
{
providerName
:
'
OIDC
'
,
showDivider
:
true
}
)
const
route
=
useRoute
()
const
{
t
}
=
useI18n
()
const
normalizedProviderName
=
computed
(()
=>
{
const
name
=
props
.
providerName
?.
trim
()
return
name
||
'
OIDC
'
}
)
const
providerInitial
=
computed
(()
=>
normalizedProviderName
.
value
.
charAt
(
0
).
toUpperCase
()
||
'
O
'
)
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
startURL
=
`${normalized
}
/auth/oauth/oidc/start?redirect=${encodeURIComponent(redirectTo)
}
`
window
.
location
.
href
=
startURL
}
<
/script
>
frontend/src/i18n/locales/en.ts
View file @
2b70d1d3
...
...
@@ -428,6 +428,7 @@ export default {
invitationCodeInvalid
:
'
Invalid or used invitation code
'
,
invitationCodeValidating
:
'
Validating invitation code...
'
,
invitationCodeInvalidCannotRegister
:
'
Invalid invitation code. Please check and try again
'
,
oauthOrContinue
:
'
or continue with email
'
,
linuxdo
:
{
signIn
:
'
Continue with Linux.do
'
,
orContinue
:
'
or continue with email
'
,
...
...
@@ -442,6 +443,20 @@ export default {
completing
:
'
Completing registration…
'
,
completeRegistrationFailed
:
'
Registration failed. Please check your invitation code and try again.
'
},
oidc
:
{
signIn
:
'
Continue with {providerName}
'
,
callbackTitle
:
'
Signing you in with {providerName}
'
,
callbackProcessing
:
'
Completing login with {providerName}, please wait...
'
,
callbackHint
:
'
If you are not redirected automatically, go back to the login page and try again.
'
,
callbackMissingToken
:
'
Missing login token, please try again.
'
,
backToLogin
:
'
Back to Login
'
,
invitationRequired
:
'
This {providerName} account is not yet registered. The site requires an invitation code — please enter one to complete registration.
'
,
invalidPendingToken
:
'
The registration token has expired. Please sign in again.
'
,
completeRegistration
:
'
Complete Registration
'
,
completing
:
'
Completing registration…
'
,
completeRegistrationFailed
:
'
Registration failed. Please check your invitation code and try again.
'
},
oauth
:
{
code
:
'
Code
'
,
state
:
'
State
'
,
...
...
@@ -4228,6 +4243,57 @@ export default {
quickSetCopy
:
'
Generate & Copy (current site)
'
,
redirectUrlSetAndCopied
:
'
Redirect URL generated and copied to clipboard
'
},
oidc
:
{
title
:
'
OIDC Login
'
,
description
:
'
Configure a standard OIDC provider (for example Keycloak)
'
,
enable
:
'
Enable OIDC Login
'
,
enableHint
:
'
Show OIDC login on the login/register pages
'
,
providerName
:
'
Provider Name
'
,
providerNamePlaceholder
:
'
for example Keycloak
'
,
clientId
:
'
Client ID
'
,
clientIdPlaceholder
:
'
OIDC client id
'
,
clientSecret
:
'
Client Secret
'
,
clientSecretPlaceholder
:
'
********
'
,
clientSecretHint
:
'
Used by backend to exchange tokens (keep it secret)
'
,
clientSecretConfiguredPlaceholder
:
'
********
'
,
clientSecretConfiguredHint
:
'
Secret configured. Leave empty to keep the current value.
'
,
issuerUrl
:
'
Issuer URL
'
,
issuerUrlPlaceholder
:
'
https://id.example.com/realms/main
'
,
discoveryUrl
:
'
Discovery URL
'
,
discoveryUrlPlaceholder
:
'
Optional, leave empty to auto-derive from issuer
'
,
authorizeUrl
:
'
Authorize URL
'
,
authorizeUrlPlaceholder
:
'
Optional, can be discovered automatically
'
,
tokenUrl
:
'
Token URL
'
,
tokenUrlPlaceholder
:
'
Optional, can be discovered automatically
'
,
userinfoUrl
:
'
UserInfo URL
'
,
userinfoUrlPlaceholder
:
'
Optional, can be discovered automatically
'
,
jwksUrl
:
'
JWKS URL
'
,
jwksUrlPlaceholder
:
'
Optional, required when strict ID token validation is enabled
'
,
scopes
:
'
Scopes
'
,
scopesPlaceholder
:
'
openid email profile
'
,
scopesHint
:
'
Must include openid
'
,
redirectUrl
:
'
Backend Redirect URL
'
,
redirectUrlPlaceholder
:
'
https://your-domain.com/api/v1/auth/oauth/oidc/callback
'
,
redirectUrlHint
:
'
Must match the callback URL configured in the OIDC provider
'
,
quickSetCopy
:
'
Generate & Copy (current site)
'
,
redirectUrlSetAndCopied
:
'
Redirect URL generated and copied to clipboard
'
,
frontendRedirectUrl
:
'
Frontend Callback Path
'
,
frontendRedirectUrlPlaceholder
:
'
/auth/oidc/callback
'
,
frontendRedirectUrlHint
:
'
Frontend route used after backend callback
'
,
tokenAuthMethod
:
'
Token Auth Method
'
,
clockSkewSeconds
:
'
Clock Skew (seconds)
'
,
allowedSigningAlgs
:
'
Allowed Signing Algs
'
,
allowedSigningAlgsPlaceholder
:
'
RS256,ES256,PS256
'
,
usePkce
:
'
Use PKCE
'
,
validateIdToken
:
'
Validate ID Token
'
,
requireEmailVerified
:
'
Require Email Verified
'
,
userinfoEmailPath
:
'
UserInfo Email Path
'
,
userinfoEmailPathPlaceholder
:
'
for example data.email
'
,
userinfoIdPath
:
'
UserInfo ID Path
'
,
userinfoIdPathPlaceholder
:
'
for example data.id
'
,
userinfoUsernamePath
:
'
UserInfo Username Path
'
,
userinfoUsernamePathPlaceholder
:
'
for example data.username
'
},
defaults
:
{
title
:
'
Default User Settings
'
,
description
:
'
Default values for new users
'
,
...
...
frontend/src/i18n/locales/zh.ts
View file @
2b70d1d3
...
...
@@ -427,6 +427,7 @@ export default {
invitationCodeInvalid
:
'
邀请码无效或已被使用
'
,
invitationCodeValidating
:
'
正在验证邀请码...
'
,
invitationCodeInvalidCannotRegister
:
'
邀请码无效,请检查后重试
'
,
oauthOrContinue
:
'
或使用邮箱密码继续
'
,
linuxdo
:
{
signIn
:
'
使用 Linux.do 登录
'
,
orContinue
:
'
或使用邮箱密码继续
'
,
...
...
@@ -441,6 +442,19 @@ export default {
completing
:
'
正在完成注册...
'
,
completeRegistrationFailed
:
'
注册失败,请检查邀请码后重试。
'
},
oidc
:
{
signIn
:
'
使用 {providerName} 登录
'
,
callbackTitle
:
'
正在完成 {providerName} 登录
'
,
callbackProcessing
:
'
正在验证 {providerName} 登录信息,请稍候...
'
,
callbackHint
:
'
如果页面未自动跳转,请返回登录页重试。
'
,
callbackMissingToken
:
'
登录信息缺失,请返回重试。
'
,
backToLogin
:
'
返回登录
'
,
invitationRequired
:
'
该 {providerName} 账号尚未注册,站点已开启邀请码注册,请输入邀请码以完成注册。
'
,
invalidPendingToken
:
'
注册凭证已失效,请重新登录。
'
,
completeRegistration
:
'
完成注册
'
,
completing
:
'
正在完成注册...
'
,
completeRegistrationFailed
:
'
注册失败,请检查邀请码后重试。
'
},
oauth
:
{
code
:
'
授权码
'
,
state
:
'
状态
'
,
...
...
@@ -4394,6 +4408,57 @@ export default {
quickSetCopy
:
'
使用当前站点生成并复制
'
,
redirectUrlSetAndCopied
:
'
已使用当前站点生成回调地址并复制到剪贴板
'
},
oidc
:
{
title
:
'
OIDC 登录
'
,
description
:
'
配置标准 OIDC Provider(例如 Keycloak)
'
,
enable
:
'
启用 OIDC 登录
'
,
enableHint
:
'
在登录/注册页面显示 OIDC 登录入口
'
,
providerName
:
'
Provider 名称
'
,
providerNamePlaceholder
:
'
例如 Keycloak
'
,
clientId
:
'
Client ID
'
,
clientIdPlaceholder
:
'
OIDC client id
'
,
clientSecret
:
'
Client Secret
'
,
clientSecretPlaceholder
:
'
********
'
,
clientSecretHint
:
'
用于后端交换 token(请保密)
'
,
clientSecretConfiguredPlaceholder
:
'
********
'
,
clientSecretConfiguredHint
:
'
密钥已配置,留空以保留当前值。
'
,
issuerUrl
:
'
Issuer URL
'
,
issuerUrlPlaceholder
:
'
https://id.example.com/realms/main
'
,
discoveryUrl
:
'
Discovery URL
'
,
discoveryUrlPlaceholder
:
'
可选,留空将基于 issuer 自动推导
'
,
authorizeUrl
:
'
Authorize URL
'
,
authorizeUrlPlaceholder
:
'
可选,可通过 discovery 自动获取
'
,
tokenUrl
:
'
Token URL
'
,
tokenUrlPlaceholder
:
'
可选,可通过 discovery 自动获取
'
,
userinfoUrl
:
'
UserInfo URL
'
,
userinfoUrlPlaceholder
:
'
可选,可通过 discovery 自动获取
'
,
jwksUrl
:
'
JWKS URL
'
,
jwksUrlPlaceholder
:
'
可选;启用严格 ID Token 校验时必填
'
,
scopes
:
'
Scopes
'
,
scopesPlaceholder
:
'
openid email profile
'
,
scopesHint
:
'
必须包含 openid
'
,
redirectUrl
:
'
后端回调地址(Redirect URL)
'
,
redirectUrlPlaceholder
:
'
https://your-domain.com/api/v1/auth/oauth/oidc/callback
'
,
redirectUrlHint
:
'
必须与 OIDC Provider 中配置的回调地址一致
'
,
quickSetCopy
:
'
使用当前站点生成并复制
'
,
redirectUrlSetAndCopied
:
'
已使用当前站点生成回调地址并复制到剪贴板
'
,
frontendRedirectUrl
:
'
前端回调路径
'
,
frontendRedirectUrlPlaceholder
:
'
/auth/oidc/callback
'
,
frontendRedirectUrlHint
:
'
后端回调完成后重定向到此前端路径
'
,
tokenAuthMethod
:
'
Token 鉴权方式
'
,
clockSkewSeconds
:
'
时钟偏移(秒)
'
,
allowedSigningAlgs
:
'
允许的签名算法
'
,
allowedSigningAlgsPlaceholder
:
'
RS256,ES256,PS256
'
,
usePkce
:
'
启用 PKCE
'
,
validateIdToken
:
'
校验 ID Token
'
,
requireEmailVerified
:
'
要求邮箱已验证
'
,
userinfoEmailPath
:
'
UserInfo 邮箱字段路径
'
,
userinfoEmailPathPlaceholder
:
'
例如 data.email
'
,
userinfoIdPath
:
'
UserInfo ID 字段路径
'
,
userinfoIdPathPlaceholder
:
'
例如 data.id
'
,
userinfoUsernamePath
:
'
UserInfo 用户名字段路径
'
,
userinfoUsernamePathPlaceholder
:
'
例如 data.username
'
},
defaults
:
{
title
:
'
用户默认设置
'
,
description
:
'
新用户的默认值
'
,
...
...
frontend/src/router/index.ts
View file @
2b70d1d3
...
...
@@ -83,6 +83,15 @@ const routes: RouteRecordRaw[] = [
title
:
'
LinuxDo OAuth Callback
'
}
},
{
path
:
'
/auth/oidc/callback
'
,
name
:
'
OIDCOAuthCallback
'
,
component
:
()
=>
import
(
'
@/views/auth/OidcCallbackView.vue
'
),
meta
:
{
requiresAuth
:
false
,
title
:
'
OIDC OAuth Callback
'
}
},
{
path
:
'
/forgot-password
'
,
name
:
'
ForgotPassword
'
,
...
...
frontend/src/stores/app.ts
View file @
2b70d1d3
...
...
@@ -339,6 +339,9 @@ export const useAppStore = defineStore('app', () => {
custom_menu_items
:
[],
custom_endpoints
:
[],
linuxdo_oauth_enabled
:
false
,
oidc_oauth_enabled
:
false
,
oidc_oauth_provider_name
:
'
OIDC
'
,
sora_client_enabled
:
false
,
backend_mode_enabled
:
false
,
version
:
siteVersion
.
value
}
...
...
frontend/src/types/index.ts
View file @
2b70d1d3
...
...
@@ -111,6 +111,9 @@ export interface PublicSettings {
custom_menu_items
:
CustomMenuItem
[]
custom_endpoints
:
CustomEndpoint
[]
linuxdo_oauth_enabled
:
boolean
oidc_oauth_enabled
:
boolean
oidc_oauth_provider_name
:
string
sora_client_enabled
:
boolean
backend_mode_enabled
:
boolean
version
:
string
}
...
...
@@ -368,6 +371,13 @@ export type GroupPlatform = 'anthropic' | 'openai' | 'gemini' | 'antigravity'
export
type
SubscriptionType
=
'
standard
'
|
'
subscription
'
export
interface
OpenAIMessagesDispatchModelConfig
{
opus_mapped_model
?:
string
sonnet_mapped_model
?:
string
haiku_mapped_model
?:
string
exact_model_mappings
?:
Record
<
string
,
string
>
}
export
interface
Group
{
id
:
number
name
:
string
...
...
@@ -390,6 +400,8 @@ export interface Group {
fallback_group_id_on_invalid_request
:
number
|
null
// OpenAI Messages 调度开关(用户侧需要此字段判断是否展示 Claude Code 教程)
allow_messages_dispatch
?:
boolean
default_mapped_model
?:
string
messages_dispatch_model_config
?:
OpenAIMessagesDispatchModelConfig
require_oauth_only
:
boolean
require_privacy_set
:
boolean
created_at
:
string
...
...
@@ -416,6 +428,7 @@ export interface AdminGroup extends Group {
// OpenAI Messages 调度配置(仅 openai 平台使用)
default_mapped_model
?:
string
messages_dispatch_model_config
?:
OpenAIMessagesDispatchModelConfig
// 分组排序
sort_order
:
number
...
...
frontend/src/views/admin/GroupsView.vue
View file @
2b70d1d3
This diff is collapsed.
Click to expand it.
frontend/src/views/admin/SettingsView.vue
View file @
2b70d1d3
...
...
@@ -1124,7 +1124,327 @@
<
/div
>
<
/div
>
<
/div
>
<
/div><!-- /
Tab
:
Security
—
Registration
,
Turnstile
,
LinuxDo
-->
<!--
Generic
OIDC
OAuth
登录
-->
<
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
"
>
{{
t
(
'
admin.settings.oidc.title
'
)
}}
<
/h2
>
<
p
class
=
"
mt-1 text-sm text-gray-500 dark:text-gray-400
"
>
{{
t
(
'
admin.settings.oidc.description
'
)
}}
<
/p
>
<
/div
>
<
div
class
=
"
space-y-5 p-6
"
>
<
div
class
=
"
flex items-center justify-between
"
>
<
div
>
<
label
class
=
"
font-medium text-gray-900 dark:text-white
"
>
{{
t
(
'
admin.settings.oidc.enable
'
)
}}
<
/label
>
<
p
class
=
"
text-sm text-gray-500 dark:text-gray-400
"
>
{{
t
(
'
admin.settings.oidc.enableHint
'
)
}}
<
/p
>
<
/div
>
<
Toggle
v
-
model
=
"
form.oidc_connect_enabled
"
/>
<
/div
>
<
div
v
-
if
=
"
form.oidc_connect_enabled
"
class
=
"
space-y-6 border-t border-gray-100 pt-4 dark:border-dark-700
"
>
<
div
class
=
"
grid grid-cols-1 gap-6 lg:grid-cols-3
"
>
<
div
>
<
label
class
=
"
mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300
"
>
{{
t
(
'
admin.settings.oidc.providerName
'
)
}}
<
/label
>
<
input
v
-
model
=
"
form.oidc_connect_provider_name
"
type
=
"
text
"
class
=
"
input
"
:
placeholder
=
"
t('admin.settings.oidc.providerNamePlaceholder')
"
/>
<
/div
>
<
div
>
<
label
class
=
"
mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300
"
>
{{
t
(
'
admin.settings.oidc.clientId
'
)
}}
<
/label
>
<
input
v
-
model
=
"
form.oidc_connect_client_id
"
type
=
"
text
"
class
=
"
input font-mono text-sm
"
:
placeholder
=
"
t('admin.settings.oidc.clientIdPlaceholder')
"
/>
<
/div
>
<
div
>
<
label
class
=
"
mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300
"
>
{{
t
(
'
admin.settings.oidc.clientSecret
'
)
}}
<
/label
>
<
input
v
-
model
=
"
form.oidc_connect_client_secret
"
type
=
"
password
"
class
=
"
input font-mono text-sm
"
:
placeholder
=
"
form.oidc_connect_client_secret_configured
? t('admin.settings.oidc.clientSecretConfiguredPlaceholder')
: t('admin.settings.oidc.clientSecretPlaceholder')
"
/>
<
p
class
=
"
mt-1.5 text-xs text-gray-500 dark:text-gray-400
"
>
{{
form
.
oidc_connect_client_secret_configured
?
t
(
'
admin.settings.oidc.clientSecretConfiguredHint
'
)
:
t
(
'
admin.settings.oidc.clientSecretHint
'
)
}}
<
/p
>
<
/div
>
<
/div
>
<
div
class
=
"
grid grid-cols-1 gap-6 lg:grid-cols-2
"
>
<
div
>
<
label
class
=
"
mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300
"
>
{{
t
(
'
admin.settings.oidc.issuerUrl
'
)
}}
<
/label
>
<
input
v
-
model
=
"
form.oidc_connect_issuer_url
"
type
=
"
url
"
class
=
"
input font-mono text-sm
"
:
placeholder
=
"
t('admin.settings.oidc.issuerUrlPlaceholder')
"
/>
<
/div
>
<
div
>
<
label
class
=
"
mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300
"
>
{{
t
(
'
admin.settings.oidc.discoveryUrl
'
)
}}
<
/label
>
<
input
v
-
model
=
"
form.oidc_connect_discovery_url
"
type
=
"
url
"
class
=
"
input font-mono text-sm
"
:
placeholder
=
"
t('admin.settings.oidc.discoveryUrlPlaceholder')
"
/>
<
/div
>
<
div
>
<
label
class
=
"
mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300
"
>
{{
t
(
'
admin.settings.oidc.authorizeUrl
'
)
}}
<
/label
>
<
input
v
-
model
=
"
form.oidc_connect_authorize_url
"
type
=
"
url
"
class
=
"
input font-mono text-sm
"
:
placeholder
=
"
t('admin.settings.oidc.authorizeUrlPlaceholder')
"
/>
<
/div
>
<
div
>
<
label
class
=
"
mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300
"
>
{{
t
(
'
admin.settings.oidc.tokenUrl
'
)
}}
<
/label
>
<
input
v
-
model
=
"
form.oidc_connect_token_url
"
type
=
"
url
"
class
=
"
input font-mono text-sm
"
:
placeholder
=
"
t('admin.settings.oidc.tokenUrlPlaceholder')
"
/>
<
/div
>
<
div
>
<
label
class
=
"
mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300
"
>
{{
t
(
'
admin.settings.oidc.userinfoUrl
'
)
}}
<
/label
>
<
input
v
-
model
=
"
form.oidc_connect_userinfo_url
"
type
=
"
url
"
class
=
"
input font-mono text-sm
"
:
placeholder
=
"
t('admin.settings.oidc.userinfoUrlPlaceholder')
"
/>
<
/div
>
<
div
>
<
label
class
=
"
mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300
"
>
{{
t
(
'
admin.settings.oidc.jwksUrl
'
)
}}
<
/label
>
<
input
v
-
model
=
"
form.oidc_connect_jwks_url
"
type
=
"
url
"
class
=
"
input font-mono text-sm
"
:
placeholder
=
"
t('admin.settings.oidc.jwksUrlPlaceholder')
"
/>
<
/div
>
<
/div
>
<
div
class
=
"
grid grid-cols-1 gap-6 lg:grid-cols-2
"
>
<
div
>
<
label
class
=
"
mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300
"
>
{{
t
(
'
admin.settings.oidc.scopes
'
)
}}
<
/label
>
<
input
v
-
model
=
"
form.oidc_connect_scopes
"
type
=
"
text
"
class
=
"
input font-mono text-sm
"
:
placeholder
=
"
t('admin.settings.oidc.scopesPlaceholder')
"
/>
<
p
class
=
"
mt-1.5 text-xs text-gray-500 dark:text-gray-400
"
>
{{
t
(
'
admin.settings.oidc.scopesHint
'
)
}}
<
/p
>
<
/div
>
<
div
>
<
label
class
=
"
mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300
"
>
{{
t
(
'
admin.settings.oidc.redirectUrl
'
)
}}
<
/label
>
<
input
v
-
model
=
"
form.oidc_connect_redirect_url
"
type
=
"
url
"
class
=
"
input font-mono text-sm
"
:
placeholder
=
"
t('admin.settings.oidc.redirectUrlPlaceholder')
"
/>
<
div
class
=
"
mt-2 flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3
"
>
<
button
type
=
"
button
"
class
=
"
btn btn-secondary btn-sm w-fit
"
@
click
=
"
setAndCopyOIDCRedirectUrl
"
>
{{
t
(
'
admin.settings.oidc.quickSetCopy
'
)
}}
<
/button
>
<
code
v
-
if
=
"
oidcRedirectUrlSuggestion
"
class
=
"
select-all break-all rounded bg-gray-50 px-2 py-1 font-mono text-xs text-gray-600 dark:bg-dark-800 dark:text-gray-300
"
>
{{
oidcRedirectUrlSuggestion
}}
<
/code
>
<
/div
>
<
p
class
=
"
mt-1.5 text-xs text-gray-500 dark:text-gray-400
"
>
{{
t
(
'
admin.settings.oidc.redirectUrlHint
'
)
}}
<
/p
>
<
/div
>
<
div
class
=
"
lg:col-span-2
"
>
<
label
class
=
"
mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300
"
>
{{
t
(
'
admin.settings.oidc.frontendRedirectUrl
'
)
}}
<
/label
>
<
input
v
-
model
=
"
form.oidc_connect_frontend_redirect_url
"
type
=
"
text
"
class
=
"
input font-mono text-sm
"
:
placeholder
=
"
t('admin.settings.oidc.frontendRedirectUrlPlaceholder')
"
/>
<
p
class
=
"
mt-1.5 text-xs text-gray-500 dark:text-gray-400
"
>
{{
t
(
'
admin.settings.oidc.frontendRedirectUrlHint
'
)
}}
<
/p
>
<
/div
>
<
/div
>
<
div
class
=
"
grid grid-cols-1 gap-6 lg:grid-cols-3
"
>
<
div
>
<
label
class
=
"
mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300
"
>
{{
t
(
'
admin.settings.oidc.tokenAuthMethod
'
)
}}
<
/label
>
<
select
v
-
model
=
"
form.oidc_connect_token_auth_method
"
class
=
"
input font-mono text-sm
"
>
<
option
value
=
"
client_secret_post
"
>
client_secret_post
<
/option
>
<
option
value
=
"
client_secret_basic
"
>
client_secret_basic
<
/option
>
<
option
value
=
"
none
"
>
none
<
/option
>
<
/select
>
<
/div
>
<
div
>
<
label
class
=
"
mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300
"
>
{{
t
(
'
admin.settings.oidc.clockSkewSeconds
'
)
}}
<
/label
>
<
input
v
-
model
.
number
=
"
form.oidc_connect_clock_skew_seconds
"
type
=
"
number
"
min
=
"
0
"
max
=
"
600
"
class
=
"
input
"
/>
<
/div
>
<
div
>
<
label
class
=
"
mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300
"
>
{{
t
(
'
admin.settings.oidc.allowedSigningAlgs
'
)
}}
<
/label
>
<
input
v
-
model
=
"
form.oidc_connect_allowed_signing_algs
"
type
=
"
text
"
class
=
"
input font-mono text-sm
"
:
placeholder
=
"
t('admin.settings.oidc.allowedSigningAlgsPlaceholder')
"
/>
<
/div
>
<
/div
>
<
div
class
=
"
grid grid-cols-1 gap-6 lg:grid-cols-3
"
>
<
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
"
>
{{
t
(
'
admin.settings.oidc.usePkce
'
)
}}
<
/label
>
<
/div
>
<
Toggle
v
-
model
=
"
form.oidc_connect_use_pkce
"
/>
<
/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
"
>
{{
t
(
'
admin.settings.oidc.validateIdToken
'
)
}}
<
/label
>
<
/div
>
<
Toggle
v
-
model
=
"
form.oidc_connect_validate_id_token
"
/>
<
/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
"
>
{{
t
(
'
admin.settings.oidc.requireEmailVerified
'
)
}}
<
/label
>
<
/div
>
<
Toggle
v
-
model
=
"
form.oidc_connect_require_email_verified
"
/>
<
/div
>
<
/div
>
<
div
class
=
"
grid grid-cols-1 gap-6 lg:grid-cols-3
"
>
<
div
>
<
label
class
=
"
mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300
"
>
{{
t
(
'
admin.settings.oidc.userinfoEmailPath
'
)
}}
<
/label
>
<
input
v
-
model
=
"
form.oidc_connect_userinfo_email_path
"
type
=
"
text
"
class
=
"
input font-mono text-sm
"
:
placeholder
=
"
t('admin.settings.oidc.userinfoEmailPathPlaceholder')
"
/>
<
/div
>
<
div
>
<
label
class
=
"
mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300
"
>
{{
t
(
'
admin.settings.oidc.userinfoIdPath
'
)
}}
<
/label
>
<
input
v
-
model
=
"
form.oidc_connect_userinfo_id_path
"
type
=
"
text
"
class
=
"
input font-mono text-sm
"
:
placeholder
=
"
t('admin.settings.oidc.userinfoIdPathPlaceholder')
"
/>
<
/div
>
<
div
>
<
label
class
=
"
mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300
"
>
{{
t
(
'
admin.settings.oidc.userinfoUsernamePath
'
)
}}
<
/label
>
<
input
v
-
model
=
"
form.oidc_connect_userinfo_username_path
"
type
=
"
text
"
class
=
"
input font-mono text-sm
"
:
placeholder
=
"
t('admin.settings.oidc.userinfoUsernamePathPlaceholder')
"
/>
<
/div
>
<
/div
>
<
/div
>
<
/div
>
<
/div
>
<
/div><!-- /
Tab
:
Security
—
Registration
,
Turnstile
,
LinuxDo
,
OIDC
-->
<!--
Tab
:
Users
-->
<
div
v
-
show
=
"
activeTab === 'users'
"
class
=
"
space-y-6
"
>
...
...
@@ -2240,6 +2560,7 @@ type SettingsForm = SystemSettings & {
smtp_password
:
string
turnstile_secret_key
:
string
linuxdo_connect_client_secret
:
string
oidc_connect_client_secret
:
string
}
const
form
=
reactive
<
SettingsForm
>
({
...
...
@@ -2289,6 +2610,30 @@ const form = reactive<SettingsForm>({
linuxdo_connect_client_secret
:
''
,
linuxdo_connect_client_secret_configured
:
false
,
linuxdo_connect_redirect_url
:
''
,
// Generic OIDC OAuth 登录
oidc_connect_enabled
:
false
,
oidc_connect_provider_name
:
'
OIDC
'
,
oidc_connect_client_id
:
''
,
oidc_connect_client_secret
:
''
,
oidc_connect_client_secret_configured
:
false
,
oidc_connect_issuer_url
:
''
,
oidc_connect_discovery_url
:
''
,
oidc_connect_authorize_url
:
''
,
oidc_connect_token_url
:
''
,
oidc_connect_userinfo_url
:
''
,
oidc_connect_jwks_url
:
''
,
oidc_connect_scopes
:
'
openid email profile
'
,
oidc_connect_redirect_url
:
''
,
oidc_connect_frontend_redirect_url
:
'
/auth/oidc/callback
'
,
oidc_connect_token_auth_method
:
'
client_secret_post
'
,
oidc_connect_use_pkce
:
false
,
oidc_connect_validate_id_token
:
true
,
oidc_connect_allowed_signing_algs
:
'
RS256,ES256,PS256
'
,
oidc_connect_clock_skew_seconds
:
120
,
oidc_connect_require_email_verified
:
false
,
oidc_connect_userinfo_email_path
:
''
,
oidc_connect_userinfo_id_path
:
''
,
oidc_connect_userinfo_username_path
:
''
,
// Model fallback
enable_model_fallback
:
false
,
fallback_model_anthropic
:
'
claude-3-5-sonnet-20241022
'
,
...
...
@@ -2409,6 +2754,21 @@ async function setAndCopyLinuxdoRedirectUrl() {
await
copyToClipboard
(
url
,
t
(
'
admin.settings.linuxdo.redirectUrlSetAndCopied
'
))
}
const
oidcRedirectUrlSuggestion
=
computed
(()
=>
{
if
(
typeof
window
===
'
undefined
'
)
return
''
const
origin
=
window
.
location
.
origin
||
`${window.location.protocol
}
//${window.location.host
}
`
return
`${origin
}
/api/v1/auth/oauth/oidc/callback`
}
)
async
function
setAndCopyOIDCRedirectUrl
()
{
const
url
=
oidcRedirectUrlSuggestion
.
value
if
(
!
url
)
return
form
.
oidc_connect_redirect_url
=
url
await
copyToClipboard
(
url
,
t
(
'
admin.settings.oidc.redirectUrlSetAndCopied
'
))
}
// Custom menu item management
function
addMenuItem
()
{
form
.
custom_menu_items
.
push
({
...
...
@@ -2506,6 +2866,7 @@ async function loadSettings() {
smtpPasswordManuallyEdited
.
value
=
false
form
.
turnstile_secret_key
=
''
form
.
linuxdo_connect_client_secret
=
''
form
.
oidc_connect_client_secret
=
''
}
catch
(
error
:
any
)
{
loadFailed
.
value
=
true
appStore
.
showError
(
...
...
@@ -2673,6 +3034,28 @@ async function saveSettings() {
linuxdo_connect_client_id
:
form
.
linuxdo_connect_client_id
,
linuxdo_connect_client_secret
:
form
.
linuxdo_connect_client_secret
||
undefined
,
linuxdo_connect_redirect_url
:
form
.
linuxdo_connect_redirect_url
,
oidc_connect_enabled
:
form
.
oidc_connect_enabled
,
oidc_connect_provider_name
:
form
.
oidc_connect_provider_name
,
oidc_connect_client_id
:
form
.
oidc_connect_client_id
,
oidc_connect_client_secret
:
form
.
oidc_connect_client_secret
||
undefined
,
oidc_connect_issuer_url
:
form
.
oidc_connect_issuer_url
,
oidc_connect_discovery_url
:
form
.
oidc_connect_discovery_url
,
oidc_connect_authorize_url
:
form
.
oidc_connect_authorize_url
,
oidc_connect_token_url
:
form
.
oidc_connect_token_url
,
oidc_connect_userinfo_url
:
form
.
oidc_connect_userinfo_url
,
oidc_connect_jwks_url
:
form
.
oidc_connect_jwks_url
,
oidc_connect_scopes
:
form
.
oidc_connect_scopes
,
oidc_connect_redirect_url
:
form
.
oidc_connect_redirect_url
,
oidc_connect_frontend_redirect_url
:
form
.
oidc_connect_frontend_redirect_url
,
oidc_connect_token_auth_method
:
form
.
oidc_connect_token_auth_method
,
oidc_connect_use_pkce
:
form
.
oidc_connect_use_pkce
,
oidc_connect_validate_id_token
:
form
.
oidc_connect_validate_id_token
,
oidc_connect_allowed_signing_algs
:
form
.
oidc_connect_allowed_signing_algs
,
oidc_connect_clock_skew_seconds
:
form
.
oidc_connect_clock_skew_seconds
,
oidc_connect_require_email_verified
:
form
.
oidc_connect_require_email_verified
,
oidc_connect_userinfo_email_path
:
form
.
oidc_connect_userinfo_email_path
,
oidc_connect_userinfo_id_path
:
form
.
oidc_connect_userinfo_id_path
,
oidc_connect_userinfo_username_path
:
form
.
oidc_connect_userinfo_username_path
,
enable_model_fallback
:
form
.
enable_model_fallback
,
fallback_model_anthropic
:
form
.
fallback_model_anthropic
,
fallback_model_openai
:
form
.
fallback_model_openai
,
...
...
@@ -2700,6 +3083,7 @@ async function saveSettings() {
smtpPasswordManuallyEdited
.
value
=
false
form
.
turnstile_secret_key
=
''
form
.
linuxdo_connect_client_secret
=
''
form
.
oidc_connect_client_secret
=
''
// Refresh cached settings so sidebar/header update immediately
await
appStore
.
fetchPublicSettings
(
true
)
await
adminSettingsStore
.
fetch
(
true
)
...
...
frontend/src/views/admin/__tests__/groupsMessagesDispatch.spec.ts
0 → 100644
View file @
2b70d1d3
import
{
describe
,
expect
,
it
}
from
"
vitest
"
;
import
{
createDefaultMessagesDispatchFormState
,
messagesDispatchConfigToFormState
,
messagesDispatchFormStateToConfig
,
resetMessagesDispatchFormState
,
}
from
"
../groupsMessagesDispatch
"
;
describe
(
"
groupsMessagesDispatch
"
,
()
=>
{
it
(
"
returns the expected default form state
"
,
()
=>
{
expect
(
createDefaultMessagesDispatchFormState
()).
toEqual
({
allow_messages_dispatch
:
false
,
opus_mapped_model
:
"
gpt-5.4
"
,
sonnet_mapped_model
:
"
gpt-5.3-codex
"
,
haiku_mapped_model
:
"
gpt-5.4-mini
"
,
exact_model_mappings
:
[],
});
});
it
(
"
sanitizes exact model mapping rows when converting to config
"
,
()
=>
{
const
config
=
messagesDispatchFormStateToConfig
({
allow_messages_dispatch
:
true
,
opus_mapped_model
:
"
gpt-5.4
"
,
sonnet_mapped_model
:
"
gpt-5.3-codex
"
,
haiku_mapped_model
:
"
gpt-5.4-mini
"
,
exact_model_mappings
:
[
{
claude_model
:
"
claude-sonnet-4-5-20250929
"
,
target_model
:
"
gpt-5.2
"
,
},
{
claude_model
:
""
,
target_model
:
"
gpt-5.4
"
},
{
claude_model
:
"
claude-opus-4-6
"
,
target_model
:
"
"
},
],
});
expect
(
config
).
toEqual
({
opus_mapped_model
:
"
gpt-5.4
"
,
sonnet_mapped_model
:
"
gpt-5.3-codex
"
,
haiku_mapped_model
:
"
gpt-5.4-mini
"
,
exact_model_mappings
:
{
"
claude-sonnet-4-5-20250929
"
:
"
gpt-5.2
"
,
},
});
});
it
(
"
hydrates form state from api config
"
,
()
=>
{
expect
(
messagesDispatchConfigToFormState
({
opus_mapped_model
:
"
gpt-5.4
"
,
sonnet_mapped_model
:
"
gpt-5.2
"
,
haiku_mapped_model
:
"
gpt-5.4-mini
"
,
exact_model_mappings
:
{
"
claude-opus-4-6
"
:
"
gpt-5.4
"
,
"
claude-haiku-4-5-20251001
"
:
"
gpt-5.4-mini
"
,
},
}),
).
toEqual
({
allow_messages_dispatch
:
false
,
opus_mapped_model
:
"
gpt-5.4
"
,
sonnet_mapped_model
:
"
gpt-5.2
"
,
haiku_mapped_model
:
"
gpt-5.4-mini
"
,
exact_model_mappings
:
[
{
claude_model
:
"
claude-haiku-4-5-20251001
"
,
target_model
:
"
gpt-5.4-mini
"
,
},
{
claude_model
:
"
claude-opus-4-6
"
,
target_model
:
"
gpt-5.4
"
},
],
});
});
it
(
"
resets mutable form state when platform switches away from openai
"
,
()
=>
{
const
state
=
{
allow_messages_dispatch
:
true
,
opus_mapped_model
:
"
gpt-5.2
"
,
sonnet_mapped_model
:
"
gpt-5.4
"
,
haiku_mapped_model
:
"
gpt-5.1
"
,
exact_model_mappings
:
[
{
claude_model
:
"
claude-opus-4-6
"
,
target_model
:
"
gpt-5.4
"
},
],
};
resetMessagesDispatchFormState
(
state
);
expect
(
state
).
toEqual
({
allow_messages_dispatch
:
false
,
opus_mapped_model
:
"
gpt-5.4
"
,
sonnet_mapped_model
:
"
gpt-5.3-codex
"
,
haiku_mapped_model
:
"
gpt-5.4-mini
"
,
exact_model_mappings
:
[],
});
});
});
frontend/src/views/admin/groupsMessagesDispatch.ts
0 → 100644
View file @
2b70d1d3
import
type
{
OpenAIMessagesDispatchModelConfig
}
from
"
@/types
"
;
export
interface
MessagesDispatchMappingRow
{
claude_model
:
string
;
target_model
:
string
;
}
export
interface
MessagesDispatchFormState
{
allow_messages_dispatch
:
boolean
;
opus_mapped_model
:
string
;
sonnet_mapped_model
:
string
;
haiku_mapped_model
:
string
;
exact_model_mappings
:
MessagesDispatchMappingRow
[];
}
export
function
createDefaultMessagesDispatchFormState
():
MessagesDispatchFormState
{
return
{
allow_messages_dispatch
:
false
,
opus_mapped_model
:
"
gpt-5.4
"
,
sonnet_mapped_model
:
"
gpt-5.3-codex
"
,
haiku_mapped_model
:
"
gpt-5.4-mini
"
,
exact_model_mappings
:
[],
};
}
export
function
messagesDispatchConfigToFormState
(
config
?:
OpenAIMessagesDispatchModelConfig
|
null
,
):
MessagesDispatchFormState
{
const
defaults
=
createDefaultMessagesDispatchFormState
();
const
exactMappings
=
Object
.
entries
(
config
?.
exact_model_mappings
||
{})
.
sort
(([
left
],
[
right
])
=>
left
.
localeCompare
(
right
))
.
map
(([
claude_model
,
target_model
])
=>
({
claude_model
,
target_model
}));
return
{
allow_messages_dispatch
:
false
,
opus_mapped_model
:
config
?.
opus_mapped_model
?.
trim
()
||
defaults
.
opus_mapped_model
,
sonnet_mapped_model
:
config
?.
sonnet_mapped_model
?.
trim
()
||
defaults
.
sonnet_mapped_model
,
haiku_mapped_model
:
config
?.
haiku_mapped_model
?.
trim
()
||
defaults
.
haiku_mapped_model
,
exact_model_mappings
:
exactMappings
,
};
}
export
function
messagesDispatchFormStateToConfig
(
state
:
MessagesDispatchFormState
,
):
OpenAIMessagesDispatchModelConfig
{
const
exactModelMappings
=
Object
.
fromEntries
(
state
.
exact_model_mappings
.
map
((
row
)
=>
[
row
.
claude_model
.
trim
(),
row
.
target_model
.
trim
()]
as
const
)
.
filter
(([
claudeModel
,
targetModel
])
=>
claudeModel
&&
targetModel
),
);
return
{
opus_mapped_model
:
state
.
opus_mapped_model
.
trim
(),
sonnet_mapped_model
:
state
.
sonnet_mapped_model
.
trim
(),
haiku_mapped_model
:
state
.
haiku_mapped_model
.
trim
(),
exact_model_mappings
:
exactModelMappings
,
};
}
export
function
resetMessagesDispatchFormState
(
target
:
MessagesDispatchFormState
,
):
void
{
const
defaults
=
createDefaultMessagesDispatchFormState
();
target
.
allow_messages_dispatch
=
defaults
.
allow_messages_dispatch
;
target
.
opus_mapped_model
=
defaults
.
opus_mapped_model
;
target
.
sonnet_mapped_model
=
defaults
.
sonnet_mapped_model
;
target
.
haiku_mapped_model
=
defaults
.
haiku_mapped_model
;
target
.
exact_model_mappings
=
[];
}
frontend/src/views/auth/LoginView.vue
View file @
2b70d1d3
...
...
@@ -11,8 +11,26 @@
</p>
</div>
<!-- LinuxDo Connect OAuth 登录 -->
<LinuxDoOAuthSection
v-if=
"linuxdoOAuthEnabled && !backendModeEnabled"
:disabled=
"isLoading"
/>
<div
v-if=
"!backendModeEnabled && (linuxdoOAuthEnabled || oidcOAuthEnabled)"
class=
"space-y-4"
>
<LinuxDoOAuthSection
v-if=
"linuxdoOAuthEnabled"
:disabled=
"isLoading"
:show-divider=
"false"
/>
<OidcOAuthSection
v-if=
"oidcOAuthEnabled"
:disabled=
"isLoading"
:provider-name=
"oidcOAuthProviderName"
:show-divider=
"false"
/>
<div
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>
<!-- Login Form -->
<form
@
submit.prevent=
"handleLogin"
class=
"space-y-5"
>
...
...
@@ -181,6 +199,7 @@ import { useRouter } from 'vue-router'
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
TotpLoginModal
from
'
@/components/auth/TotpLoginModal.vue
'
import
Icon
from
'
@/components/icons/Icon.vue
'
import
TurnstileWidget
from
'
@/components/TurnstileWidget.vue
'
...
...
@@ -207,6 +226,8 @@ const turnstileEnabled = ref<boolean>(false)
const
turnstileSiteKey
=
ref
<
string
>
(
''
)
const
linuxdoOAuthEnabled
=
ref
<
boolean
>
(
false
)
const
backendModeEnabled
=
ref
<
boolean
>
(
false
)
const
oidcOAuthEnabled
=
ref
<
boolean
>
(
false
)
const
oidcOAuthProviderName
=
ref
<
string
>
(
'
OIDC
'
)
const
passwordResetEnabled
=
ref
<
boolean
>
(
false
)
// Turnstile
...
...
@@ -247,6 +268,9 @@ onMounted(async () => {
turnstileSiteKey
.
value
=
settings
.
turnstile_site_key
||
''
linuxdoOAuthEnabled
.
value
=
settings
.
linuxdo_oauth_enabled
backendModeEnabled
.
value
=
settings
.
backend_mode_enabled
oidcOAuthEnabled
.
value
=
settings
.
oidc_oauth_enabled
oidcOAuthProviderName
.
value
=
settings
.
oidc_oauth_provider_name
||
'
OIDC
'
backendModeEnabled
.
value
=
settings
.
backend_mode_enabled
passwordResetEnabled
.
value
=
settings
.
password_reset_enabled
}
catch
(
error
)
{
console
.
error
(
'
Failed to load public settings:
'
,
error
)
...
...
frontend/src/views/auth/OidcCallbackView.vue
0 → 100644
View file @
2b70d1d3
This diff is collapsed.
Click to expand it.
frontend/src/views/auth/RegisterView.vue
View file @
2b70d1d3
...
...
@@ -11,8 +11,26 @@
<
/p
>
<
/div
>
<!--
LinuxDo
Connect
OAuth
登录
-->
<
LinuxDoOAuthSection
v
-
if
=
"
linuxdoOAuthEnabled
"
:
disabled
=
"
isLoading
"
/>
<
div
v
-
if
=
"
linuxdoOAuthEnabled || oidcOAuthEnabled
"
class
=
"
space-y-4
"
>
<
LinuxDoOAuthSection
v
-
if
=
"
linuxdoOAuthEnabled
"
:
disabled
=
"
isLoading
"
:
show
-
divider
=
"
false
"
/>
<
OidcOAuthSection
v
-
if
=
"
oidcOAuthEnabled
"
:
disabled
=
"
isLoading
"
:
provider
-
name
=
"
oidcOAuthProviderName
"
:
show
-
divider
=
"
false
"
/>
<
div
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
>
<!--
Registration
Disabled
Message
-->
<
div
...
...
@@ -289,6 +307,7 @@ import { useRouter, useRoute } from 'vue-router'
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
Icon
from
'
@/components/icons/Icon.vue
'
import
TurnstileWidget
from
'
@/components/TurnstileWidget.vue
'
import
{
useAuthStore
,
useAppStore
}
from
'
@/stores
'
...
...
@@ -324,6 +343,8 @@ const turnstileEnabled = ref<boolean>(false)
const
turnstileSiteKey
=
ref
<
string
>
(
''
)
const
siteName
=
ref
<
string
>
(
'
Sub2API
'
)
const
linuxdoOAuthEnabled
=
ref
<
boolean
>
(
false
)
const
oidcOAuthEnabled
=
ref
<
boolean
>
(
false
)
const
oidcOAuthProviderName
=
ref
<
string
>
(
'
OIDC
'
)
const
registrationEmailSuffixWhitelist
=
ref
<
string
[]
>
([])
// Turnstile
...
...
@@ -376,6 +397,8 @@ onMounted(async () => {
turnstileSiteKey
.
value
=
settings
.
turnstile_site_key
||
''
siteName
.
value
=
settings
.
site_name
||
'
Sub2API
'
linuxdoOAuthEnabled
.
value
=
settings
.
linuxdo_oauth_enabled
oidcOAuthEnabled
.
value
=
settings
.
oidc_oauth_enabled
oidcOAuthProviderName
.
value
=
settings
.
oidc_oauth_provider_name
||
'
OIDC
'
registrationEmailSuffixWhitelist
.
value
=
normalizeRegistrationEmailSuffixWhitelist
(
settings
.
registration_email_suffix_whitelist
||
[]
)
...
...
Prev
1
2
3
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