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
02a66a01
Commit
02a66a01
authored
Mar 13, 2026
by
ruiqurm
Committed by
Glorhop
Apr 09, 2026
Browse files
feat: support OIDC login.
parent
155d3474
Changes
28
Hide whitespace changes
Inline
Side-by-side
frontend/src/i18n/locales/zh.ts
View file @
02a66a01
...
...
@@ -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
:
'
状态
'
,
...
...
@@ -4393,6 +4407,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 @
02a66a01
...
...
@@ -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 @
02a66a01
...
...
@@ -332,6 +332,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 @
02a66a01
...
...
@@ -109,6 +109,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
}
...
...
frontend/src/views/admin/SettingsView.vue
View file @
02a66a01
...
...
@@ -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
"
>
...
...
@@ -2193,6 +2513,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
>
({
...
...
@@ -2240,6 +2561,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
'
,
...
...
@@ -2360,6 +2705,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
({
...
...
@@ -2425,6 +2785,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
(
...
...
@@ -2559,6 +2920,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
,
...
...
@@ -2583,6 +2966,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/auth/LoginView.vue
View file @
02a66a01
...
...
@@ -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 @
02a66a01
<
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
"
class
=
"
space-y-4
"
>
<
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
>
<
/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
{
useAuthStore
,
useAppStore
}
from
'
@/stores
'
import
{
completeOIDCOAuthRegistration
,
getPublicSettings
}
from
'
@/api/auth
'
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
pendingOAuthToken
=
ref
(
''
)
const
invitationCode
=
ref
(
''
)
const
isSubmitting
=
ref
(
false
)
const
invitationError
=
ref
(
''
)
const
redirectTo
=
ref
(
'
/dashboard
'
)
const
providerName
=
ref
(
'
OIDC
'
)
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
}
async
function
loadProviderName
()
{
try
{
const
settings
=
await
getPublicSettings
()
const
name
=
settings
.
oidc_oauth_provider_name
?.
trim
()
if
(
name
)
{
providerName
.
value
=
name
}
}
catch
{
// Ignore; fallback remains OIDC
}
}
async
function
handleSubmitInvitation
()
{
invitationError
.
value
=
''
if
(
!
invitationCode
.
value
.
trim
())
return
isSubmitting
.
value
=
true
try
{
const
tokenData
=
await
completeOIDCOAuthRegistration
(
pendingOAuthToken
.
value
,
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
}
}
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
'
)
appStore
.
showError
(
errorMessage
.
value
)
isProcessing
.
value
=
false
return
}
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
)
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
)
}
catch
(
e
:
unknown
)
{
const
err
=
e
as
{
message
?:
string
;
response
?:
{
data
?:
{
detail
?:
string
}
}
}
errorMessage
.
value
=
err
.
response
?.
data
?.
detail
||
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/RegisterView.vue
View file @
02a66a01
...
...
@@ -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
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