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
6b97a8be
Commit
6b97a8be
authored
Jan 09, 2026
by
Edric Li
Browse files
Merge branch 'main' into feat/api-key-ip-restriction
parents
90798f14
62dc0b95
Changes
70
Hide whitespace changes
Inline
Side-by-side
backend/internal/service/gemini_oauth_service_test.go
View file @
6b97a8be
...
...
@@ -40,7 +40,7 @@ func TestGeminiOAuthService_GenerateAuthURL_RedirectURIStrategy(t *testing.T) {
wantProjectID
:
""
,
},
{
name
:
"google_one
uses custom
client when c
onfigured and redirects to localhost
"
,
name
:
"google_one
always forces built-in
client
even
when c
ustom client configured
"
,
cfg
:
&
config
.
Config
{
Gemini
:
config
.
GeminiConfig
{
OAuth
:
config
.
GeminiOAuthConfig
{
...
...
@@ -50,9 +50,9 @@ func TestGeminiOAuthService_GenerateAuthURL_RedirectURIStrategy(t *testing.T) {
},
},
oauthType
:
"google_one"
,
wantClientID
:
"custom-client-id"
,
wantRedirect
:
geminicli
.
AIStudioOAuth
RedirectURI
,
wantScope
:
geminicli
.
Default
GoogleOne
Scopes
,
wantClientID
:
geminicli
.
GeminiCLIOAuthClientID
,
wantRedirect
:
geminicli
.
GeminiCLI
RedirectURI
,
wantScope
:
geminicli
.
Default
CodeAssist
Scopes
,
wantProjectID
:
""
,
},
{
...
...
backend/internal/service/group_service.go
View file @
6b97a8be
...
...
@@ -21,7 +21,7 @@ type GroupRepository interface {
DeleteCascade
(
ctx
context
.
Context
,
id
int64
)
([]
int64
,
error
)
List
(
ctx
context
.
Context
,
params
pagination
.
PaginationParams
)
([]
Group
,
*
pagination
.
PaginationResult
,
error
)
ListWithFilters
(
ctx
context
.
Context
,
params
pagination
.
PaginationParams
,
platform
,
status
string
,
isExclusive
*
bool
)
([]
Group
,
*
pagination
.
PaginationResult
,
error
)
ListWithFilters
(
ctx
context
.
Context
,
params
pagination
.
PaginationParams
,
platform
,
status
,
search
string
,
isExclusive
*
bool
)
([]
Group
,
*
pagination
.
PaginationResult
,
error
)
ListActive
(
ctx
context
.
Context
)
([]
Group
,
error
)
ListActiveByPlatform
(
ctx
context
.
Context
,
platform
string
)
([]
Group
,
error
)
...
...
backend/internal/service/openai_gateway_service.go
View file @
6b97a8be
...
...
@@ -540,10 +540,19 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
bodyModified
=
true
}
// For OAuth accounts using ChatGPT internal API, add store: false
// For OAuth accounts using ChatGPT internal API:
// 1. Add store: false
// 2. Normalize input format for Codex API compatibility
if
account
.
Type
==
AccountTypeOAuth
{
reqBody
[
"store"
]
=
false
bodyModified
=
true
// Normalize input format: convert AI SDK multi-part content format to simplified format
// AI SDK sends: {"content": [{"type": "input_text", "text": "..."}]}
// Codex API expects: {"content": "..."}
if
normalizeInputForCodexAPI
(
reqBody
)
{
bodyModified
=
true
}
}
// Re-serialize body only if modified
...
...
@@ -1085,6 +1094,101 @@ func (s *OpenAIGatewayService) replaceModelInResponseBody(body []byte, fromModel
return
newBody
}
// normalizeInputForCodexAPI converts AI SDK multi-part content format to simplified format
// that the ChatGPT internal Codex API expects.
//
// AI SDK sends content as an array of typed objects:
//
// {"content": [{"type": "input_text", "text": "hello"}]}
//
// ChatGPT Codex API expects content as a simple string:
//
// {"content": "hello"}
//
// This function modifies reqBody in-place and returns true if any modification was made.
func
normalizeInputForCodexAPI
(
reqBody
map
[
string
]
any
)
bool
{
input
,
ok
:=
reqBody
[
"input"
]
if
!
ok
{
return
false
}
// Handle case where input is a simple string (already compatible)
if
_
,
isString
:=
input
.
(
string
);
isString
{
return
false
}
// Handle case where input is an array of messages
inputArray
,
ok
:=
input
.
([]
any
)
if
!
ok
{
return
false
}
modified
:=
false
for
_
,
item
:=
range
inputArray
{
message
,
ok
:=
item
.
(
map
[
string
]
any
)
if
!
ok
{
continue
}
content
,
ok
:=
message
[
"content"
]
if
!
ok
{
continue
}
// If content is already a string, no conversion needed
if
_
,
isString
:=
content
.
(
string
);
isString
{
continue
}
// If content is an array (AI SDK format), convert to string
contentArray
,
ok
:=
content
.
([]
any
)
if
!
ok
{
continue
}
// Extract text from content array
var
textParts
[]
string
for
_
,
part
:=
range
contentArray
{
partMap
,
ok
:=
part
.
(
map
[
string
]
any
)
if
!
ok
{
continue
}
// Handle different content types
partType
,
_
:=
partMap
[
"type"
]
.
(
string
)
switch
partType
{
case
"input_text"
,
"text"
:
// Extract text from input_text or text type
if
text
,
ok
:=
partMap
[
"text"
]
.
(
string
);
ok
{
textParts
=
append
(
textParts
,
text
)
}
case
"input_image"
,
"image"
:
// For images, we need to preserve the original format
// as ChatGPT Codex API may support images in a different way
// For now, skip image parts (they will be lost in conversion)
// TODO: Consider preserving image data or handling it separately
continue
case
"input_file"
,
"file"
:
// Similar to images, file inputs may need special handling
continue
default
:
// For unknown types, try to extract text if available
if
text
,
ok
:=
partMap
[
"text"
]
.
(
string
);
ok
{
textParts
=
append
(
textParts
,
text
)
}
}
}
// Convert content array to string
if
len
(
textParts
)
>
0
{
message
[
"content"
]
=
strings
.
Join
(
textParts
,
"
\n
"
)
modified
=
true
}
}
return
modified
}
// OpenAIRecordUsageInput input for recording usage
type
OpenAIRecordUsageInput
struct
{
Result
*
OpenAIForwardResult
...
...
backend/internal/service/setting_service.go
View file @
6b97a8be
...
...
@@ -7,6 +7,7 @@ import (
"errors"
"fmt"
"strconv"
"strings"
"github.com/Wei-Shaw/sub2api/internal/config"
infraerrors
"github.com/Wei-Shaw/sub2api/internal/pkg/errors"
...
...
@@ -64,6 +65,7 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings
SettingKeyAPIBaseURL
,
SettingKeyContactInfo
,
SettingKeyDocURL
,
SettingKeyLinuxDoConnectEnabled
,
}
settings
,
err
:=
s
.
settingRepo
.
GetMultiple
(
ctx
,
keys
)
...
...
@@ -71,6 +73,13 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings
return
nil
,
fmt
.
Errorf
(
"get public settings: %w"
,
err
)
}
linuxDoEnabled
:=
false
if
raw
,
ok
:=
settings
[
SettingKeyLinuxDoConnectEnabled
];
ok
{
linuxDoEnabled
=
raw
==
"true"
}
else
{
linuxDoEnabled
=
s
.
cfg
!=
nil
&&
s
.
cfg
.
LinuxDo
.
Enabled
}
return
&
PublicSettings
{
RegistrationEnabled
:
settings
[
SettingKeyRegistrationEnabled
]
==
"true"
,
EmailVerifyEnabled
:
settings
[
SettingKeyEmailVerifyEnabled
]
==
"true"
,
...
...
@@ -82,6 +91,7 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings
APIBaseURL
:
settings
[
SettingKeyAPIBaseURL
],
ContactInfo
:
settings
[
SettingKeyContactInfo
],
DocURL
:
settings
[
SettingKeyDocURL
],
LinuxDoOAuthEnabled
:
linuxDoEnabled
,
},
nil
}
...
...
@@ -111,6 +121,14 @@ func (s *SettingService) UpdateSettings(ctx context.Context, settings *SystemSet
updates
[
SettingKeyTurnstileSecretKey
]
=
settings
.
TurnstileSecretKey
}
// LinuxDo Connect OAuth 登录(终端用户 SSO)
updates
[
SettingKeyLinuxDoConnectEnabled
]
=
strconv
.
FormatBool
(
settings
.
LinuxDoConnectEnabled
)
updates
[
SettingKeyLinuxDoConnectClientID
]
=
settings
.
LinuxDoConnectClientID
updates
[
SettingKeyLinuxDoConnectRedirectURL
]
=
settings
.
LinuxDoConnectRedirectURL
if
settings
.
LinuxDoConnectClientSecret
!=
""
{
updates
[
SettingKeyLinuxDoConnectClientSecret
]
=
settings
.
LinuxDoConnectClientSecret
}
// OEM设置
updates
[
SettingKeySiteName
]
=
settings
.
SiteName
updates
[
SettingKeySiteLogo
]
=
settings
.
SiteLogo
...
...
@@ -141,8 +159,8 @@ func (s *SettingService) UpdateSettings(ctx context.Context, settings *SystemSet
func
(
s
*
SettingService
)
IsRegistrationEnabled
(
ctx
context
.
Context
)
bool
{
value
,
err
:=
s
.
settingRepo
.
GetValue
(
ctx
,
SettingKeyRegistrationEnabled
)
if
err
!=
nil
{
//
默认开放
注册
return
tru
e
//
安全默认:如果设置不存在或查询出错,默认关闭
注册
return
fals
e
}
return
value
==
"true"
}
...
...
@@ -271,6 +289,38 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin
result
.
SMTPPassword
=
settings
[
SettingKeySMTPPassword
]
result
.
TurnstileSecretKey
=
settings
[
SettingKeyTurnstileSecretKey
]
// LinuxDo Connect 设置:
// - 兼容 config.yaml/env(避免老部署因为未迁移到数据库设置而被意外关闭)
// - 支持在后台“系统设置”中覆盖并持久化(存储于 DB)
linuxDoBase
:=
config
.
LinuxDoConnectConfig
{}
if
s
.
cfg
!=
nil
{
linuxDoBase
=
s
.
cfg
.
LinuxDo
}
if
raw
,
ok
:=
settings
[
SettingKeyLinuxDoConnectEnabled
];
ok
{
result
.
LinuxDoConnectEnabled
=
raw
==
"true"
}
else
{
result
.
LinuxDoConnectEnabled
=
linuxDoBase
.
Enabled
}
if
v
,
ok
:=
settings
[
SettingKeyLinuxDoConnectClientID
];
ok
&&
strings
.
TrimSpace
(
v
)
!=
""
{
result
.
LinuxDoConnectClientID
=
strings
.
TrimSpace
(
v
)
}
else
{
result
.
LinuxDoConnectClientID
=
linuxDoBase
.
ClientID
}
if
v
,
ok
:=
settings
[
SettingKeyLinuxDoConnectRedirectURL
];
ok
&&
strings
.
TrimSpace
(
v
)
!=
""
{
result
.
LinuxDoConnectRedirectURL
=
strings
.
TrimSpace
(
v
)
}
else
{
result
.
LinuxDoConnectRedirectURL
=
linuxDoBase
.
RedirectURL
}
result
.
LinuxDoConnectClientSecret
=
strings
.
TrimSpace
(
settings
[
SettingKeyLinuxDoConnectClientSecret
])
if
result
.
LinuxDoConnectClientSecret
==
""
{
result
.
LinuxDoConnectClientSecret
=
strings
.
TrimSpace
(
linuxDoBase
.
ClientSecret
)
}
result
.
LinuxDoConnectClientSecretConfigured
=
result
.
LinuxDoConnectClientSecret
!=
""
// Model fallback settings
result
.
EnableModelFallback
=
settings
[
SettingKeyEnableModelFallback
]
==
"true"
result
.
FallbackModelAnthropic
=
s
.
getStringOrDefault
(
settings
,
SettingKeyFallbackModelAnthropic
,
"claude-3-5-sonnet-20241022"
)
...
...
@@ -289,6 +339,99 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin
return
result
}
// GetLinuxDoConnectOAuthConfig 返回用于登录的“最终生效” LinuxDo Connect 配置。
//
// 优先级:
// - 若对应系统设置键存在,则覆盖 config.yaml/env 的值
// - 否则回退到 config.yaml/env 的值
func
(
s
*
SettingService
)
GetLinuxDoConnectOAuthConfig
(
ctx
context
.
Context
)
(
config
.
LinuxDoConnectConfig
,
error
)
{
if
s
==
nil
||
s
.
cfg
==
nil
{
return
config
.
LinuxDoConnectConfig
{},
infraerrors
.
ServiceUnavailable
(
"CONFIG_NOT_READY"
,
"config not loaded"
)
}
effective
:=
s
.
cfg
.
LinuxDo
keys
:=
[]
string
{
SettingKeyLinuxDoConnectEnabled
,
SettingKeyLinuxDoConnectClientID
,
SettingKeyLinuxDoConnectClientSecret
,
SettingKeyLinuxDoConnectRedirectURL
,
}
settings
,
err
:=
s
.
settingRepo
.
GetMultiple
(
ctx
,
keys
)
if
err
!=
nil
{
return
config
.
LinuxDoConnectConfig
{},
fmt
.
Errorf
(
"get linuxdo connect settings: %w"
,
err
)
}
if
raw
,
ok
:=
settings
[
SettingKeyLinuxDoConnectEnabled
];
ok
{
effective
.
Enabled
=
raw
==
"true"
}
if
v
,
ok
:=
settings
[
SettingKeyLinuxDoConnectClientID
];
ok
&&
strings
.
TrimSpace
(
v
)
!=
""
{
effective
.
ClientID
=
strings
.
TrimSpace
(
v
)
}
if
v
,
ok
:=
settings
[
SettingKeyLinuxDoConnectClientSecret
];
ok
&&
strings
.
TrimSpace
(
v
)
!=
""
{
effective
.
ClientSecret
=
strings
.
TrimSpace
(
v
)
}
if
v
,
ok
:=
settings
[
SettingKeyLinuxDoConnectRedirectURL
];
ok
&&
strings
.
TrimSpace
(
v
)
!=
""
{
effective
.
RedirectURL
=
strings
.
TrimSpace
(
v
)
}
if
!
effective
.
Enabled
{
return
config
.
LinuxDoConnectConfig
{},
infraerrors
.
NotFound
(
"OAUTH_DISABLED"
,
"oauth login is disabled"
)
}
// 基础健壮性校验(避免把用户重定向到一个必然失败或不安全的 OAuth 流程里)。
if
strings
.
TrimSpace
(
effective
.
ClientID
)
==
""
{
return
config
.
LinuxDoConnectConfig
{},
infraerrors
.
InternalServer
(
"OAUTH_CONFIG_INVALID"
,
"oauth client id not configured"
)
}
if
strings
.
TrimSpace
(
effective
.
AuthorizeURL
)
==
""
{
return
config
.
LinuxDoConnectConfig
{},
infraerrors
.
InternalServer
(
"OAUTH_CONFIG_INVALID"
,
"oauth authorize url not configured"
)
}
if
strings
.
TrimSpace
(
effective
.
TokenURL
)
==
""
{
return
config
.
LinuxDoConnectConfig
{},
infraerrors
.
InternalServer
(
"OAUTH_CONFIG_INVALID"
,
"oauth token url not configured"
)
}
if
strings
.
TrimSpace
(
effective
.
UserInfoURL
)
==
""
{
return
config
.
LinuxDoConnectConfig
{},
infraerrors
.
InternalServer
(
"OAUTH_CONFIG_INVALID"
,
"oauth userinfo url not configured"
)
}
if
strings
.
TrimSpace
(
effective
.
RedirectURL
)
==
""
{
return
config
.
LinuxDoConnectConfig
{},
infraerrors
.
InternalServer
(
"OAUTH_CONFIG_INVALID"
,
"oauth redirect url not configured"
)
}
if
strings
.
TrimSpace
(
effective
.
FrontendRedirectURL
)
==
""
{
return
config
.
LinuxDoConnectConfig
{},
infraerrors
.
InternalServer
(
"OAUTH_CONFIG_INVALID"
,
"oauth frontend redirect url not configured"
)
}
if
err
:=
config
.
ValidateAbsoluteHTTPURL
(
effective
.
AuthorizeURL
);
err
!=
nil
{
return
config
.
LinuxDoConnectConfig
{},
infraerrors
.
InternalServer
(
"OAUTH_CONFIG_INVALID"
,
"oauth authorize url invalid"
)
}
if
err
:=
config
.
ValidateAbsoluteHTTPURL
(
effective
.
TokenURL
);
err
!=
nil
{
return
config
.
LinuxDoConnectConfig
{},
infraerrors
.
InternalServer
(
"OAUTH_CONFIG_INVALID"
,
"oauth token url invalid"
)
}
if
err
:=
config
.
ValidateAbsoluteHTTPURL
(
effective
.
UserInfoURL
);
err
!=
nil
{
return
config
.
LinuxDoConnectConfig
{},
infraerrors
.
InternalServer
(
"OAUTH_CONFIG_INVALID"
,
"oauth userinfo url invalid"
)
}
if
err
:=
config
.
ValidateAbsoluteHTTPURL
(
effective
.
RedirectURL
);
err
!=
nil
{
return
config
.
LinuxDoConnectConfig
{},
infraerrors
.
InternalServer
(
"OAUTH_CONFIG_INVALID"
,
"oauth redirect url invalid"
)
}
if
err
:=
config
.
ValidateFrontendRedirectURL
(
effective
.
FrontendRedirectURL
);
err
!=
nil
{
return
config
.
LinuxDoConnectConfig
{},
infraerrors
.
InternalServer
(
"OAUTH_CONFIG_INVALID"
,
"oauth frontend redirect url invalid"
)
}
method
:=
strings
.
ToLower
(
strings
.
TrimSpace
(
effective
.
TokenAuthMethod
))
switch
method
{
case
""
,
"client_secret_post"
,
"client_secret_basic"
:
if
strings
.
TrimSpace
(
effective
.
ClientSecret
)
==
""
{
return
config
.
LinuxDoConnectConfig
{},
infraerrors
.
InternalServer
(
"OAUTH_CONFIG_INVALID"
,
"oauth client secret not configured"
)
}
case
"none"
:
if
!
effective
.
UsePKCE
{
return
config
.
LinuxDoConnectConfig
{},
infraerrors
.
InternalServer
(
"OAUTH_CONFIG_INVALID"
,
"oauth pkce must be enabled when token_auth_method=none"
)
}
default
:
return
config
.
LinuxDoConnectConfig
{},
infraerrors
.
InternalServer
(
"OAUTH_CONFIG_INVALID"
,
"oauth token_auth_method invalid"
)
}
return
effective
,
nil
}
// getStringOrDefault 获取字符串值或默认值
func
(
s
*
SettingService
)
getStringOrDefault
(
settings
map
[
string
]
string
,
key
,
defaultValue
string
)
string
{
if
value
,
ok
:=
settings
[
key
];
ok
&&
value
!=
""
{
...
...
backend/internal/service/settings_view.go
View file @
6b97a8be
...
...
@@ -18,6 +18,13 @@ type SystemSettings struct {
TurnstileSecretKey
string
TurnstileSecretKeyConfigured
bool
// LinuxDo Connect OAuth 登录(终端用户 SSO)
LinuxDoConnectEnabled
bool
LinuxDoConnectClientID
string
LinuxDoConnectClientSecret
string
LinuxDoConnectClientSecretConfigured
bool
LinuxDoConnectRedirectURL
string
SiteName
string
SiteLogo
string
SiteSubtitle
string
...
...
@@ -51,5 +58,6 @@ type PublicSettings struct {
APIBaseURL
string
ContactInfo
string
DocURL
string
LinuxDoOAuthEnabled
bool
Version
string
}
deploy/config.example.yaml
View file @
6b97a8be
...
...
@@ -234,6 +234,31 @@ jwt:
# 令牌过期时间(小时,最大 24)
expire_hour
:
24
# =============================================================================
# LinuxDo Connect OAuth Login (SSO)
# LinuxDo Connect OAuth 登录(用于 Sub2API 用户登录)
# =============================================================================
linuxdo_connect
:
enabled
:
false
client_id
:
"
"
client_secret
:
"
"
authorize_url
:
"
https://connect.linux.do/oauth2/authorize"
token_url
:
"
https://connect.linux.do/oauth2/token"
userinfo_url
:
"
https://connect.linux.do/api/user"
scopes
:
"
user"
# 示例: "https://your-domain.com/api/v1/auth/oauth/linuxdo/callback"
redirect_url
:
"
"
# 安全提示:
# - 建议使用同源相对路径(以 / 开头),避免把 token 重定向到意外的第三方域名
# - 该地址不应包含 #fragment(本实现使用 URL fragment 传递 access_token)
frontend_redirect_url
:
"
/auth/linuxdo/callback"
token_auth_method
:
"
client_secret_post"
# client_secret_post | client_secret_basic | none
# 注意:当 token_auth_method=none(public client)时,必须启用 PKCE
use_pkce
:
false
userinfo_email_path
:
"
"
userinfo_id_path
:
"
"
userinfo_username_path
:
"
"
# =============================================================================
# Default Settings
# 默认设置
...
...
deploy/docker-compose.yml
View file @
6b97a8be
...
...
@@ -173,11 +173,12 @@ services:
volumes
:
-
redis_data:/data
command
:
>
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)
...
...
frontend/src/api/admin/groups.ts
View file @
6b97a8be
...
...
@@ -16,7 +16,7 @@ import type {
* List all groups with pagination
* @param page - Page number (default: 1)
* @param pageSize - Items per page (default: 20)
* @param filters - Optional filters (platform, status, is_exclusive)
* @param filters - Optional filters (platform, status, is_exclusive
, search
)
* @returns Paginated list of groups
*/
export
async
function
list
(
...
...
@@ -26,6 +26,7 @@ export async function list(
platform
?:
GroupPlatform
status
?:
'
active
'
|
'
inactive
'
is_exclusive
?:
boolean
search
?:
string
},
options
?:
{
signal
?:
AbortSignal
...
...
frontend/src/api/admin/settings.ts
View file @
6b97a8be
...
...
@@ -34,6 +34,11 @@ export interface SystemSettings {
turnstile_enabled
:
boolean
turnstile_site_key
:
string
turnstile_secret_key_configured
:
boolean
// LinuxDo Connect OAuth 登录(终端用户 SSO)
linuxdo_connect_enabled
:
boolean
linuxdo_connect_client_id
:
string
linuxdo_connect_client_secret_configured
:
boolean
linuxdo_connect_redirect_url
:
string
// Identity patch configuration (Claude -> Gemini)
enable_identity_patch
:
boolean
identity_patch_prompt
:
string
...
...
@@ -60,6 +65,10 @@ export interface UpdateSettingsRequest {
turnstile_enabled
?:
boolean
turnstile_site_key
?:
string
turnstile_secret_key
?:
string
linuxdo_connect_enabled
?:
boolean
linuxdo_connect_client_id
?:
string
linuxdo_connect_client_secret
?:
string
linuxdo_connect_redirect_url
?:
string
enable_identity_patch
?:
boolean
identity_patch_prompt
?:
string
}
...
...
frontend/src/components/account/CreateAccountModal.vue
View file @
6b97a8be
...
...
@@ -166,7 +166,7 @@
>
<div
:class=
"[
'flex h-8 w-8 items-center justify-center rounded-lg',
'flex h-8 w-8
shrink-0
items-center justify-center rounded-lg',
accountCategory === 'oauth-based'
? 'bg-orange-500 text-white'
: 'bg-gray-100 text-gray-500 dark:bg-dark-600 dark:text-gray-400'
...
...
@@ -196,7 +196,7 @@
>
<div
:class=
"[
'flex h-8 w-8 items-center justify-center rounded-lg',
'flex h-8 w-8
shrink-0
items-center justify-center rounded-lg',
accountCategory === 'apikey'
? 'bg-purple-500 text-white'
: 'bg-gray-100 text-gray-500 dark:bg-dark-600 dark:text-gray-400'
...
...
@@ -232,7 +232,7 @@
>
<div
:class=
"[
'flex h-8 w-8 items-center justify-center rounded-lg',
'flex h-8 w-8
shrink-0
items-center justify-center rounded-lg',
accountCategory === 'oauth-based'
? 'bg-green-500 text-white'
: 'bg-gray-100 text-gray-500 dark:bg-dark-600 dark:text-gray-400'
...
...
@@ -258,7 +258,7 @@
>
<div
:class=
"[
'flex h-8 w-8 items-center justify-center rounded-lg',
'flex h-8 w-8
shrink-0
items-center justify-center rounded-lg',
accountCategory === 'apikey'
? 'bg-purple-500 text-white'
: 'bg-gray-100 text-gray-500 dark:bg-dark-600 dark:text-gray-400'
...
...
@@ -302,7 +302,7 @@
>
<div
:class=
"[
'flex h-8 w-8 items-center justify-center rounded-lg',
'flex h-8 w-8
shrink-0
items-center justify-center rounded-lg',
accountCategory === 'oauth-based'
? 'bg-blue-500 text-white'
: 'bg-gray-100 text-gray-500 dark:bg-dark-600 dark:text-gray-400'
...
...
@@ -332,7 +332,7 @@
>
<div
:class=
"[
'flex h-8 w-8 items-center justify-center rounded-lg',
'flex h-8 w-8
shrink-0
items-center justify-center rounded-lg',
accountCategory === 'apikey'
? 'bg-purple-500 text-white'
: 'bg-gray-100 text-gray-500 dark:bg-dark-600 dark:text-gray-400'
...
...
@@ -397,7 +397,7 @@
>
<div
:class=
"[
'flex h-8 w-8 items-center justify-center rounded-lg',
'flex h-8 w-8
shrink-0
items-center justify-center rounded-lg',
geminiOAuthType === 'google_one'
? 'bg-purple-500 text-white'
: 'bg-gray-100 text-gray-500 dark:bg-dark-600 dark:text-gray-400'
...
...
@@ -440,7 +440,7 @@
>
<div
:class=
"[
'flex h-8 w-8 items-center justify-center rounded-lg',
'flex h-8 w-8
shrink-0
items-center justify-center rounded-lg',
geminiOAuthType === 'code_assist'
? 'bg-blue-500 text-white'
: 'bg-gray-100 text-gray-500 dark:bg-dark-600 dark:text-gray-400'
...
...
@@ -518,7 +518,7 @@
>
<div
:class=
"[
'flex h-8 w-8 items-center justify-center rounded-lg',
'flex h-8 w-8
shrink-0
items-center justify-center rounded-lg',
geminiOAuthType === 'ai_studio'
? 'bg-amber-500 text-white'
: 'bg-gray-100 text-gray-500 dark:bg-dark-600 dark:text-gray-400'
...
...
@@ -621,7 +621,7 @@
<div
class=
"flex items-center gap-3 rounded-lg border-2 border-purple-500 bg-purple-50 p-3 dark:bg-purple-900/20"
>
<div
class=
"flex h-8 w-8 items-center justify-center rounded-lg bg-purple-500 text-white"
>
<div
class=
"flex h-8 w-8
shrink-0
items-center justify-center rounded-lg bg-purple-500 text-white"
>
<Icon
name=
"key"
size=
"sm"
/>
</div>
<div>
...
...
frontend/src/components/account/ReAuthAccountModal.vue
View file @
6b97a8be
...
...
@@ -73,113 +73,48 @@
</div>
</fieldset>
<!-- Gemini OAuth Type
Selection
-->
<
fieldset
v-if=
"isGemini"
class=
"
border-0 p-
0"
>
<
legend
class=
"
input-label"
>
{{
t
(
'
admin.accounts.oauth.gemini.oauthTypeLabel
'
)
}}
</legend
>
<div
class=
"mt-2 grid grid-cols-3 gap-3"
>
<button
type=
"button"
@
click=
"handleSelectGeminiOAuthType('google_one')"
<!-- Gemini OAuth Type
Display (read-only)
-->
<
div
v-if=
"isGemini"
class=
"
rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-dark-600 dark:bg-dark-70
0"
>
<
div
class=
"
mb-2 text-sm font-medium text-gray-700 dark:text-gray-300"
>
{{
t
(
'
admin.accounts.oauth.gemini.oauthTypeLabel
'
)
}}
</div>
<div
class=
"flex items-center gap-3"
>
<div
:class=
"[
'flex
items-center gap-3 rounded-lg border-2 p-3 text-left transition-all
',
'flex
h-8 w-8 shrink-0 items-center justify-center rounded-lg
',
geminiOAuthType === 'google_one'
? 'border-purple-500 bg-purple-50 dark:bg-purple-900/20'
: 'border-gray-200 hover:border-purple-300 dark:border-dark-600 dark:hover:border-purple-700'
]"
>
<div
:class=
"[
'flex h-8 w-8 items-center justify-center rounded-lg',
geminiOAuthType === 'google_one'
? 'bg-purple-500 text-white'
: 'bg-gray-100 text-gray-500 dark:bg-dark-600 dark:text-gray-400'
]"
>
<svg
class=
"h-4 w-4"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z"
/>
</svg>
</div>
<div
class=
"min-w-0"
>
<span
class=
"block text-sm font-medium text-gray-900 dark:text-white"
>
Google One
</span>
<span
class=
"text-xs text-gray-500 dark:text-gray-400"
>
个人账号
</span>
</div>
</button>
<button
type=
"button"
@
click=
"handleSelectGeminiOAuthType('code_assist')"
:class=
"[
'flex items-center gap-3 rounded-lg border-2 p-3 text-left transition-all',
geminiOAuthType === 'code_assist'
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
: 'border-gray-200 hover:border-blue-300 dark:border-dark-600 dark:hover:border-blue-700'
]"
>
<div
:class=
"[
'flex h-8 w-8 items-center justify-center rounded-lg',
geminiOAuthType === 'code_assist'
? 'bg-purple-500 text-white'
: geminiOAuthType === 'code_assist'
? 'bg-blue-500 text-white'
: 'bg-gray-100 text-gray-500 dark:bg-dark-600 dark:text-gray-400'
]"
>
<Icon
name=
"cloud"
size=
"sm"
/>
</div>
<div
class=
"min-w-0"
>
<span
class=
"block text-sm font-medium text-gray-900 dark:text-white"
>
{{
t
(
'
admin.accounts.gemini.oauthType.builtInTitle
'
)
}}
</span>
<span
class=
"text-xs text-gray-500 dark:text-gray-400"
>
{{
t
(
'
admin.accounts.gemini.oauthType.builtInDesc
'
)
}}
</span>
</div>
</button>
<button
type=
"button"
:disabled=
"!geminiAIStudioOAuthEnabled"
@
click=
"handleSelectGeminiOAuthType('ai_studio')"
:class=
"[
'flex items-center gap-3 rounded-lg border-2 p-3 text-left transition-all',
!geminiAIStudioOAuthEnabled ? 'cursor-not-allowed opacity-60' : '',
geminiOAuthType === 'ai_studio'
? 'border-purple-500 bg-purple-50 dark:bg-purple-900/20'
: 'border-gray-200 hover:border-purple-300 dark:border-dark-600 dark:hover:border-purple-700'
: 'bg-amber-500 text-white'
]"
>
<div
:class=
"[
'flex h-8 w-8 items-center justify-center rounded-lg',
geminiOAuthType === 'ai_studio'
? 'bg-purple-500 text-white'
: 'bg-gray-100 text-gray-500 dark:bg-dark-600 dark:text-gray-400'
]"
>
<Icon
name=
"sparkles"
size=
"sm"
/>
</div>
<div
class=
"min-w-0"
>
<span
class=
"block text-sm font-medium text-gray-900 dark:text-white"
>
{{
t
(
'
admin.accounts.gemini.oauthType.customTitle
'
)
}}
</span>
<span
class=
"text-xs text-gray-500 dark:text-gray-400"
>
{{
t
(
'
admin.accounts.gemini.oauthType.customDesc
'
)
}}
</span>
<div
v-if=
"!geminiAIStudioOAuthEnabled"
class=
"group relative mt-1 inline-block"
>
<span
class=
"rounded bg-amber-100 px-2 py-0.5 text-xs text-amber-700 dark:bg-amber-900/30 dark:text-amber-300"
>
{{
t
(
'
admin.accounts.oauth.gemini.aiStudioNotConfiguredShort
'
)
}}
</span>
<div
class=
"pointer-events-none absolute left-0 top-full z-10 mt-2 w-[28rem] rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-800 opacity-0 shadow-sm transition-opacity group-hover:opacity-100 dark:border-amber-700 dark:bg-amber-900/40 dark:text-amber-200"
>
{{
t
(
'
admin.accounts.oauth.gemini.aiStudioNotConfiguredTip
'
)
}}
</div>
</div>
</div>
</button>
<Icon
v-if=
"geminiOAuthType === 'google_one'"
name=
"user"
size=
"sm"
/>
<Icon
v-else-if=
"geminiOAuthType === 'code_assist'"
name=
"cloud"
size=
"sm"
/>
<Icon
v-else
name=
"sparkles"
size=
"sm"
/>
</div>
<div>
<span
class=
"block text-sm font-medium text-gray-900 dark:text-white"
>
{{
geminiOAuthType
===
'
google_one
'
?
'
Google One
'
:
geminiOAuthType
===
'
code_assist
'
?
t
(
'
admin.accounts.gemini.oauthType.builtInTitle
'
)
:
t
(
'
admin.accounts.gemini.oauthType.customTitle
'
)
}}
</span>
<span
class=
"text-xs text-gray-500 dark:text-gray-400"
>
{{
geminiOAuthType
===
'
google_one
'
?
'
个人账号
'
:
geminiOAuthType
===
'
code_assist
'
?
t
(
'
admin.accounts.gemini.oauthType.builtInDesc
'
)
:
t
(
'
admin.accounts.gemini.oauthType.customDesc
'
)
}}
</span>
</div>
</div>
</
fieldset
>
</
div
>
<OAuthAuthorizationFlow
ref=
"oauthFlowRef"
...
...
@@ -299,7 +234,6 @@ const oauthFlowRef = ref<OAuthFlowExposed | null>(null)
// State
const
addMethod
=
ref
<
AddMethod
>
(
'
oauth
'
)
const
geminiOAuthType
=
ref
<
'
code_assist
'
|
'
google_one
'
|
'
ai_studio
'
>
(
'
code_assist
'
)
const
geminiAIStudioOAuthEnabled
=
ref
(
false
)
// Computed - check platform
const
isOpenAI
=
computed
(()
=>
props
.
account
?.
platform
===
'
openai
'
)
...
...
@@ -367,14 +301,6 @@ watch(
?
'
ai_studio
'
:
'
code_assist
'
}
if
(
isGemini
.
value
)
{
geminiOAuth
.
getCapabilities
().
then
((
caps
)
=>
{
geminiAIStudioOAuthEnabled
.
value
=
!!
caps
?.
ai_studio_oauth_enabled
if
(
!
geminiAIStudioOAuthEnabled
.
value
&&
geminiOAuthType
.
value
===
'
ai_studio
'
)
{
geminiOAuthType
.
value
=
'
code_assist
'
}
})
}
}
else
{
resetState
()
}
...
...
@@ -385,7 +311,6 @@ watch(
const
resetState
=
()
=>
{
addMethod
.
value
=
'
oauth
'
geminiOAuthType
.
value
=
'
code_assist
'
geminiAIStudioOAuthEnabled
.
value
=
false
claudeOAuth
.
resetState
()
openaiOAuth
.
resetState
()
geminiOAuth
.
resetState
()
...
...
@@ -393,14 +318,6 @@ const resetState = () => {
oauthFlowRef
.
value
?.
reset
()
}
const
handleSelectGeminiOAuthType
=
(
oauthType
:
'
code_assist
'
|
'
google_one
'
|
'
ai_studio
'
)
=>
{
if
(
oauthType
===
'
ai_studio
'
&&
!
geminiAIStudioOAuthEnabled
.
value
)
{
appStore
.
showError
(
t
(
'
admin.accounts.oauth.gemini.aiStudioNotConfigured
'
))
return
}
geminiOAuthType
.
value
=
oauthType
}
const
handleClose
=
()
=>
{
emit
(
'
close
'
)
}
...
...
frontend/src/components/admin/account/AccountBulkActionsBar.vue
View file @
6b97a8be
<
template
>
<div
v-if=
"selectedIds.length > 0"
class=
"mb-4 flex items-center justify-between p-3 bg-primary-50 rounded-lg"
>
<span
class=
"text-sm font-medium"
>
{{
t
(
'
admin.accounts.bulkActions.selected
'
,
{
count
:
selectedIds
.
length
}
)
}}
<
/span
>
<div
v-if=
"selectedIds.length > 0"
class=
"mb-4 flex items-center justify-between p-3 bg-primary-50 rounded-lg dark:bg-primary-900/20"
>
<div
class=
"flex flex-wrap items-center gap-2"
>
<span
class=
"text-sm font-medium text-primary-900 dark:text-primary-100"
>
{{
t
(
'
admin.accounts.bulkActions.selected
'
,
{
count
:
selectedIds
.
length
}
)
}}
<
/span
>
<
button
@
click
=
"
$emit('select-page')
"
class
=
"
text-xs font-medium text-primary-700 hover:text-primary-800 dark:text-primary-300 dark:hover:text-primary-200
"
>
{{
t
(
'
admin.accounts.bulkActions.selectCurrentPage
'
)
}}
<
/button
>
<
span
class
=
"
text-gray-300 dark:text-primary-800
"
>
•
<
/span
>
<
button
@
click
=
"
$emit('clear')
"
class
=
"
text-xs font-medium text-primary-700 hover:text-primary-800 dark:text-primary-300 dark:hover:text-primary-200
"
>
{{
t
(
'
admin.accounts.bulkActions.clear
'
)
}}
<
/button
>
<
/div
>
<
div
class
=
"
flex gap-2
"
>
<
button
@
click
=
"
$emit('delete')
"
class
=
"
btn btn-danger btn-sm
"
>
{{
t
(
'
admin.accounts.bulkActions.delete
'
)
}}
<
/button
>
<
button
@
click
=
"
$emit('toggle-schedulable', true)
"
class
=
"
btn btn-success btn-sm
"
>
{{
t
(
'
admin.accounts.bulkActions.enableScheduling
'
)
}}
<
/button
>
<
button
@
click
=
"
$emit('toggle-schedulable', false)
"
class
=
"
btn btn-warning btn-sm
"
>
{{
t
(
'
admin.accounts.bulkActions.disableScheduling
'
)
}}
<
/button
>
<
button
@
click
=
"
$emit('edit')
"
class
=
"
btn btn-primary btn-sm
"
>
{{
t
(
'
admin.accounts.bulkActions.edit
'
)
}}
<
/button
>
<
/div
>
<
/div
>
...
...
@@ -10,5 +29,5 @@
<
script
setup
lang
=
"
ts
"
>
import
{
useI18n
}
from
'
vue-i18n
'
defineProps
([
'
selectedIds
'
]);
defineEmits
([
'
delete
'
,
'
edit
'
]);
const
{
t
}
=
useI18n
()
defineProps
([
'
selectedIds
'
]);
defineEmits
([
'
delete
'
,
'
edit
'
,
'
clear
'
,
'
select-page
'
,
'
toggle-schedulable
'
]);
const
{
t
}
=
useI18n
()
<
/script>
\ No newline at end of file
frontend/src/components/admin/account/ReAuthAccountModal.vue
View file @
6b97a8be
...
...
@@ -73,111 +73,48 @@
</div>
</fieldset>
<!-- Gemini OAuth Type
Selection
-->
<
fieldset
v-if=
"isGemini"
class=
"
border-0 p-
0"
>
<
legend
class=
"
input-label"
>
{{
t
(
'
admin.accounts.oauth.gemini.oauthTypeLabel
'
)
}}
</legend
>
<div
class=
"mt-2 grid grid-cols-3 gap-3"
>
<button
type=
"button"
@
click=
"handleSelectGeminiOAuthType('google_one')"
<!-- Gemini OAuth Type
Display (read-only)
-->
<
div
v-if=
"isGemini"
class=
"
rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-dark-600 dark:bg-dark-70
0"
>
<
div
class=
"
mb-2 text-sm font-medium text-gray-700 dark:text-gray-300"
>
{{
t
(
'
admin.accounts.oauth.gemini.oauthTypeLabel
'
)
}}
</div>
<div
class=
"flex items-center gap-3"
>
<div
:class=
"[
'flex
items-center gap-3 rounded-lg border-2 p-3 text-left transition-all
',
'flex
h-8 w-8 shrink-0 items-center justify-center rounded-lg
',
geminiOAuthType === 'google_one'
? 'border-purple-500 bg-purple-50 dark:bg-purple-900/20'
: 'border-gray-200 hover:border-purple-300 dark:border-dark-600 dark:hover:border-purple-700'
]"
>
<div
:class=
"[
'flex h-8 w-8 items-center justify-center rounded-lg',
geminiOAuthType === 'google_one'
? 'bg-purple-500 text-white'
: 'bg-gray-100 text-gray-500 dark:bg-dark-600 dark:text-gray-400'
]"
>
<Icon
name=
"user"
size=
"sm"
/>
</div>
<div
class=
"min-w-0"
>
<span
class=
"block text-sm font-medium text-gray-900 dark:text-white"
>
Google One
</span>
<span
class=
"text-xs text-gray-500 dark:text-gray-400"
>
个人账号
</span>
</div>
</button>
<button
type=
"button"
@
click=
"handleSelectGeminiOAuthType('code_assist')"
:class=
"[
'flex items-center gap-3 rounded-lg border-2 p-3 text-left transition-all',
geminiOAuthType === 'code_assist'
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
: 'border-gray-200 hover:border-blue-300 dark:border-dark-600 dark:hover:border-blue-700'
]"
>
<div
:class=
"[
'flex h-8 w-8 items-center justify-center rounded-lg',
geminiOAuthType === 'code_assist'
? 'bg-purple-500 text-white'
: geminiOAuthType === 'code_assist'
? 'bg-blue-500 text-white'
: 'bg-gray-100 text-gray-500 dark:bg-dark-600 dark:text-gray-400'
]"
>
<Icon
name=
"cloud"
size=
"sm"
/>
</div>
<div
class=
"min-w-0"
>
<span
class=
"block text-sm font-medium text-gray-900 dark:text-white"
>
{{
t
(
'
admin.accounts.gemini.oauthType.builtInTitle
'
)
}}
</span>
<span
class=
"text-xs text-gray-500 dark:text-gray-400"
>
{{
t
(
'
admin.accounts.gemini.oauthType.builtInDesc
'
)
}}
</span>
</div>
</button>
<button
type=
"button"
:disabled=
"!geminiAIStudioOAuthEnabled"
@
click=
"handleSelectGeminiOAuthType('ai_studio')"
:class=
"[
'flex items-center gap-3 rounded-lg border-2 p-3 text-left transition-all',
!geminiAIStudioOAuthEnabled ? 'cursor-not-allowed opacity-60' : '',
geminiOAuthType === 'ai_studio'
? 'border-purple-500 bg-purple-50 dark:bg-purple-900/20'
: 'border-gray-200 hover:border-purple-300 dark:border-dark-600 dark:hover:border-purple-700'
: 'bg-amber-500 text-white'
]"
>
<div
:class=
"[
'flex h-8 w-8 items-center justify-center rounded-lg',
geminiOAuthType === 'ai_studio'
? 'bg-purple-500 text-white'
: 'bg-gray-100 text-gray-500 dark:bg-dark-600 dark:text-gray-400'
]"
>
<Icon
name=
"sparkles"
size=
"sm"
/>
</div>
<div
class=
"min-w-0"
>
<span
class=
"block text-sm font-medium text-gray-900 dark:text-white"
>
{{
t
(
'
admin.accounts.gemini.oauthType.customTitle
'
)
}}
</span>
<span
class=
"text-xs text-gray-500 dark:text-gray-400"
>
{{
t
(
'
admin.accounts.gemini.oauthType.customDesc
'
)
}}
</span>
<div
v-if=
"!geminiAIStudioOAuthEnabled"
class=
"group relative mt-1 inline-block"
>
<span
class=
"rounded bg-amber-100 px-2 py-0.5 text-xs text-amber-700 dark:bg-amber-900/30 dark:text-amber-300"
>
{{
t
(
'
admin.accounts.oauth.gemini.aiStudioNotConfiguredShort
'
)
}}
</span>
<div
class=
"pointer-events-none absolute left-0 top-full z-10 mt-2 w-[28rem] rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-800 opacity-0 shadow-sm transition-opacity group-hover:opacity-100 dark:border-amber-700 dark:bg-amber-900/40 dark:text-amber-200"
>
{{
t
(
'
admin.accounts.oauth.gemini.aiStudioNotConfiguredTip
'
)
}}
</div>
</div>
</div>
</button>
<Icon
v-if=
"geminiOAuthType === 'google_one'"
name=
"user"
size=
"sm"
/>
<Icon
v-else-if=
"geminiOAuthType === 'code_assist'"
name=
"cloud"
size=
"sm"
/>
<Icon
v-else
name=
"sparkles"
size=
"sm"
/>
</div>
<div>
<span
class=
"block text-sm font-medium text-gray-900 dark:text-white"
>
{{
geminiOAuthType
===
'
google_one
'
?
'
Google One
'
:
geminiOAuthType
===
'
code_assist
'
?
t
(
'
admin.accounts.gemini.oauthType.builtInTitle
'
)
:
t
(
'
admin.accounts.gemini.oauthType.customTitle
'
)
}}
</span>
<span
class=
"text-xs text-gray-500 dark:text-gray-400"
>
{{
geminiOAuthType
===
'
google_one
'
?
'
个人账号
'
:
geminiOAuthType
===
'
code_assist
'
?
t
(
'
admin.accounts.gemini.oauthType.builtInDesc
'
)
:
t
(
'
admin.accounts.gemini.oauthType.customDesc
'
)
}}
</span>
</div>
</div>
</
fieldset
>
</
div
>
<OAuthAuthorizationFlow
ref=
"oauthFlowRef"
...
...
@@ -297,7 +234,6 @@ const oauthFlowRef = ref<OAuthFlowExposed | null>(null)
// State
const
addMethod
=
ref
<
AddMethod
>
(
'
oauth
'
)
const
geminiOAuthType
=
ref
<
'
code_assist
'
|
'
google_one
'
|
'
ai_studio
'
>
(
'
code_assist
'
)
const
geminiAIStudioOAuthEnabled
=
ref
(
false
)
// Computed - check platform
const
isOpenAI
=
computed
(()
=>
props
.
account
?.
platform
===
'
openai
'
)
...
...
@@ -365,14 +301,6 @@ watch(
?
'
ai_studio
'
:
'
code_assist
'
}
if
(
isGemini
.
value
)
{
geminiOAuth
.
getCapabilities
().
then
((
caps
)
=>
{
geminiAIStudioOAuthEnabled
.
value
=
!!
caps
?.
ai_studio_oauth_enabled
if
(
!
geminiAIStudioOAuthEnabled
.
value
&&
geminiOAuthType
.
value
===
'
ai_studio
'
)
{
geminiOAuthType
.
value
=
'
code_assist
'
}
})
}
}
else
{
resetState
()
}
...
...
@@ -383,7 +311,6 @@ watch(
const
resetState
=
()
=>
{
addMethod
.
value
=
'
oauth
'
geminiOAuthType
.
value
=
'
code_assist
'
geminiAIStudioOAuthEnabled
.
value
=
false
claudeOAuth
.
resetState
()
openaiOAuth
.
resetState
()
geminiOAuth
.
resetState
()
...
...
@@ -391,14 +318,6 @@ const resetState = () => {
oauthFlowRef
.
value
?.
reset
()
}
const
handleSelectGeminiOAuthType
=
(
oauthType
:
'
code_assist
'
|
'
google_one
'
|
'
ai_studio
'
)
=>
{
if
(
oauthType
===
'
ai_studio
'
&&
!
geminiAIStudioOAuthEnabled
.
value
)
{
appStore
.
showError
(
t
(
'
admin.accounts.oauth.gemini.aiStudioNotConfigured
'
))
return
}
geminiOAuthType
.
value
=
oauthType
}
const
handleClose
=
()
=>
{
emit
(
'
close
'
)
}
...
...
frontend/src/components/auth/LinuxDoOAuthSection.vue
0 → 100644
View file @
6b97a8be
<
template
>
<div
class=
"space-y-4"
>
<button
type=
"button"
:disabled=
"disabled"
class=
"btn btn-secondary w-full"
@
click=
"startLogin"
>
<svg
class=
"icon mr-2"
viewBox=
"0 0 16 16"
version=
"1.1"
xmlns=
"http://www.w3.org/2000/svg"
width=
"1em"
height=
"1em"
style=
"color: rgb(233, 84, 32); width: 20px; height: 20px"
aria-hidden=
"true"
>
<g
id=
"linuxdo_icon"
data-name=
"linuxdo_icon"
>
<path
d=
"m7.44,0s.09,0,.13,0c.09,0,.19,0,.28,0,.14,0,.29,0,.43,0,.09,0,.18,0,.27,0q.12,0,.25,0t.26.08c.15.03.29.06.44.08,1.97.38,3.78,1.47,4.95,3.11.04.06.09.12.13.18.67.96,1.15,2.11,1.3,3.28q0,.19.09.26c0,.15,0,.29,0,.44,0,.04,0,.09,0,.13,0,.09,0,.19,0,.28,0,.14,0,.29,0,.43,0,.09,0,.18,0,.27,0,.08,0,.17,0,.25q0,.19-.08.26c-.03.15-.06.29-.08.44-.38,1.97-1.47,3.78-3.11,4.95-.06.04-.12.09-.18.13-.96.67-2.11,1.15-3.28,1.3q-.19,0-.26.09c-.15,0-.29,0-.44,0-.04,0-.09,0-.13,0-.09,0-.19,0-.28,0-.14,0-.29,0-.43,0-.09,0-.18,0-.27,0-.08,0-.17,0-.25,0q-.19,0-.26-.08c-.15-.03-.29-.06-.44-.08-1.97-.38-3.78-1.47-4.95-3.11q-.07-.09-.13-.18c-.67-.96-1.15-2.11-1.3-3.28q0-.19-.09-.26c0-.15,0-.29,0-.44,0-.04,0-.09,0-.13,0-.09,0-.19,0-.28,0-.14,0-.29,0-.43,0-.09,0-.18,0-.27,0-.08,0-.17,0-.25q0-.19.08-.26c.03-.15.06-.29.08-.44.38-1.97,1.47-3.78,3.11-4.95.06-.04.12-.09.18-.13C4.42.73,5.57.26,6.74.1,7,.07,7.15,0,7.44,0Z"
fill=
"#EFEFEF"
></path>
<path
d=
"m1.27,11.33h13.45c-.94,1.89-2.51,3.21-4.51,3.88-1.99.59-3.96.37-5.8-.57-1.25-.7-2.67-1.9-3.14-3.3Z"
fill=
"#FEB005"
></path>
<path
d=
"m12.54,1.99c.87.7,1.82,1.59,2.18,2.68H1.27c.87-1.74,2.33-3.13,4.2-3.78,2.44-.79,5-.47,7.07,1.1Z"
fill=
"#1D1D1F"
></path>
</g>
</svg>
{{
t
(
'
auth.linuxdo.signIn
'
)
}}
</button>
<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.linuxdo.orContinue
'
)
}}
</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
'
defineProps
<
{
disabled
?:
boolean
}
>
()
const
route
=
useRoute
()
const
{
t
}
=
useI18n
()
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/linuxdo/start?redirect=
${
encodeURIComponent
(
redirectTo
)}
`
window
.
location
.
href
=
startURL
}
</
script
>
frontend/src/composables/useTableLoader.ts
View file @
6b97a8be
...
...
@@ -43,7 +43,8 @@ export function useTableLoader<T, P extends Record<string, any>>(options: TableL
if
(
abortController
)
{
abortController
.
abort
()
}
abortController
=
new
AbortController
()
const
currentController
=
new
AbortController
()
abortController
=
currentController
loading
.
value
=
true
try
{
...
...
@@ -51,9 +52,9 @@ export function useTableLoader<T, P extends Record<string, any>>(options: TableL
pagination
.
page
,
pagination
.
page_size
,
toRaw
(
params
)
as
P
,
{
signal
:
abor
tController
.
signal
}
{
signal
:
curren
tController
.
signal
}
)
items
.
value
=
response
.
items
||
[]
pagination
.
total
=
response
.
total
||
0
pagination
.
pages
=
response
.
pages
||
0
...
...
@@ -63,7 +64,7 @@ export function useTableLoader<T, P extends Record<string, any>>(options: TableL
throw
error
}
}
finally
{
if
(
abortController
&&
!
abor
tController
.
signal
.
aborted
)
{
if
(
abortController
===
curren
tController
)
{
loading
.
value
=
false
}
}
...
...
@@ -77,7 +78,9 @@ export function useTableLoader<T, P extends Record<string, any>>(options: TableL
const
debouncedReload
=
useDebounceFn
(
reload
,
debounceMs
)
const
handlePageChange
=
(
page
:
number
)
=>
{
pagination
.
page
=
page
// 确保页码在有效范围内
const
validPage
=
Math
.
max
(
1
,
Math
.
min
(
page
,
pagination
.
pages
||
1
))
pagination
.
page
=
validPage
load
()
}
...
...
frontend/src/i18n/locales/en.ts
View file @
6b97a8be
...
...
@@ -229,6 +229,15 @@ export default {
sendingCode
:
'
Sending...
'
,
clickToResend
:
'
Click to resend code
'
,
resendCode
:
'
Resend verification code
'
,
linuxdo
:
{
signIn
:
'
Continue with Linux.do
'
,
orContinue
:
'
or continue with email
'
,
callbackTitle
:
'
Signing you in
'
,
callbackProcessing
:
'
Completing login, 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
'
},
oauth
:
{
code
:
'
Code
'
,
state
:
'
State
'
,
...
...
@@ -1081,12 +1090,16 @@ export default {
tokenRefreshed
:
'
Token refreshed successfully
'
,
accountDeleted
:
'
Account deleted successfully
'
,
rateLimitCleared
:
'
Rate limit cleared successfully
'
,
bulkSchedulableEnabled
:
'
Successfully enabled scheduling for {count} account(s)
'
,
bulkSchedulableDisabled
:
'
Successfully disabled scheduling for {count} account(s)
'
,
bulkActions
:
{
selected
:
'
{count} account(s) selected
'
,
selectCurrentPage
:
'
Select this page
'
,
clear
:
'
Clear selection
'
,
edit
:
'
Bulk Edit
'
,
delete
:
'
Bulk Delete
'
delete
:
'
Bulk Delete
'
,
enableScheduling
:
'
Enable Scheduling
'
,
disableScheduling
:
'
Disable Scheduling
'
},
bulkEdit
:
{
title
:
'
Bulk Edit Accounts
'
,
...
...
@@ -1491,6 +1504,7 @@ export default {
testing
:
'
Testing...
'
,
retry
:
'
Retry
'
,
copyOutput
:
'
Copy output
'
,
outputCopied
:
'
Output copied
'
,
startingTestForAccount
:
'
Starting test for account: {name}
'
,
testAccountTypeLabel
:
'
Account type: {type}
'
,
selectTestModel
:
'
Select Test Model
'
,
...
...
@@ -1761,6 +1775,26 @@ export default {
cloudflareDashboard
:
'
Cloudflare Dashboard
'
,
secretKeyHint
:
'
Server-side verification key (keep this secret)
'
,
secretKeyConfiguredHint
:
'
Secret key configured. Leave empty to keep the current value.
'
},
linuxdo
:
{
title
:
'
LinuxDo Connect Login
'
,
description
:
'
Configure LinuxDo Connect OAuth for Sub2API end-user login
'
,
enable
:
'
Enable LinuxDo Login
'
,
enableHint
:
'
Show LinuxDo login on the login/register pages
'
,
clientId
:
'
Client ID
'
,
clientIdPlaceholder
:
'
e.g., hprJ5pC3...
'
,
clientIdHint
:
'
Get this from Connect.Linux.Do
'
,
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.
'
,
redirectUrl
:
'
Redirect URL
'
,
redirectUrlPlaceholder
:
'
https://your-domain.com/api/v1/auth/oauth/linuxdo/callback
'
,
redirectUrlHint
:
'
Must match the redirect URL configured in Connect.Linux.Do (must be an absolute http(s) URL)
'
,
quickSetCopy
:
'
Generate & Copy (current site)
'
,
redirectUrlSetAndCopied
:
'
Redirect URL generated and copied to clipboard
'
},
defaults
:
{
title
:
'
Default User Settings
'
,
description
:
'
Default values for new users
'
,
...
...
frontend/src/i18n/locales/zh.ts
View file @
6b97a8be
...
...
@@ -227,6 +227,15 @@ export default {
sendingCode
:
'
发送中...
'
,
clickToResend
:
'
点击重新发送验证码
'
,
resendCode
:
'
重新发送验证码
'
,
linuxdo
:
{
signIn
:
'
使用 Linux.do 登录
'
,
orContinue
:
'
或使用邮箱密码继续
'
,
callbackTitle
:
'
正在完成登录
'
,
callbackProcessing
:
'
正在验证登录信息,请稍候...
'
,
callbackHint
:
'
如果页面未自动跳转,请返回登录页重试。
'
,
callbackMissingToken
:
'
登录信息缺失,请返回重试。
'
,
backToLogin
:
'
返回登录
'
},
oauth
:
{
code
:
'
授权码
'
,
state
:
'
状态
'
,
...
...
@@ -1217,12 +1226,16 @@ export default {
accountCreatedSuccess
:
'
账号添加成功
'
,
accountUpdatedSuccess
:
'
账号更新成功
'
,
accountDeletedSuccess
:
'
账号删除成功
'
,
bulkSchedulableEnabled
:
'
成功启用 {count} 个账号的调度
'
,
bulkSchedulableDisabled
:
'
成功停止 {count} 个账号的调度
'
,
bulkActions
:
{
selected
:
'
已选择 {count} 个账号
'
,
selectCurrentPage
:
'
本页全选
'
,
clear
:
'
清除选择
'
,
edit
:
'
批量编辑账号
'
,
delete
:
'
批量删除
'
delete
:
'
批量删除
'
,
enableScheduling
:
'
批量启用调度
'
,
disableScheduling
:
'
批量停止调度
'
},
bulkEdit
:
{
title
:
'
批量编辑账号
'
,
...
...
@@ -1606,6 +1619,7 @@ export default {
startTest
:
'
开始测试
'
,
retry
:
'
重试
'
,
copyOutput
:
'
复制输出
'
,
outputCopied
:
'
输出已复制
'
,
startingTestForAccount
:
'
开始测试账号:{name}
'
,
testAccountTypeLabel
:
'
账号类型:{type}
'
,
selectTestModel
:
'
选择测试模型
'
,
...
...
@@ -1906,6 +1920,25 @@ export default {
cloudflareDashboard
:
'
Cloudflare Dashboard
'
,
secretKeyHint
:
'
服务端验证密钥(请保密)
'
,
secretKeyConfiguredHint
:
'
密钥已配置,留空以保留当前值。
'
},
linuxdo
:
{
title
:
'
LinuxDo Connect 登录
'
,
description
:
'
配置 LinuxDo Connect OAuth,用于 Sub2API 用户登录
'
,
enable
:
'
启用 LinuxDo 登录
'
,
enableHint
:
'
在登录/注册页面显示 LinuxDo 登录入口
'
,
clientId
:
'
Client ID
'
,
clientIdPlaceholder
:
'
例如:hprJ5pC3...
'
,
clientIdHint
:
'
从 Connect.Linux.Do 后台获取
'
,
clientSecret
:
'
Client Secret
'
,
clientSecretPlaceholder
:
'
********
'
,
clientSecretHint
:
'
用于后端交换 token(请保密)
'
,
clientSecretConfiguredPlaceholder
:
'
********
'
,
clientSecretConfiguredHint
:
'
密钥已配置,留空以保留当前值。
'
,
redirectUrl
:
'
回调地址(Redirect URL)
'
,
redirectUrlPlaceholder
:
'
https://your-domain.com/api/v1/auth/oauth/linuxdo/callback
'
,
redirectUrlHint
:
'
需与 Connect.Linux.Do 中配置的回调地址一致(必须是 http(s) 完整 URL)
'
,
quickSetCopy
:
'
使用当前站点生成并复制
'
,
redirectUrlSetAndCopied
:
'
已使用当前站点生成回调地址并复制到剪贴板
'
},
defaults
:
{
title
:
'
用户默认设置
'
,
description
:
'
新用户的默认值
'
,
...
...
frontend/src/router/index.ts
View file @
6b97a8be
...
...
@@ -67,6 +67,15 @@ const routes: RouteRecordRaw[] = [
title
:
'
OAuth Callback
'
}
},
{
path
:
'
/auth/linuxdo/callback
'
,
name
:
'
LinuxDoOAuthCallback
'
,
component
:
()
=>
import
(
'
@/views/auth/LinuxDoCallbackView.vue
'
),
meta
:
{
requiresAuth
:
false
,
title
:
'
LinuxDo OAuth Callback
'
}
},
// ==================== User Routes ====================
{
...
...
frontend/src/stores/app.ts
View file @
6b97a8be
...
...
@@ -30,6 +30,7 @@ export const useAppStore = defineStore('app', () => {
const
contactInfo
=
ref
<
string
>
(
''
)
const
apiBaseUrl
=
ref
<
string
>
(
''
)
const
docUrl
=
ref
<
string
>
(
''
)
const
cachedPublicSettings
=
ref
<
PublicSettings
|
null
>
(
null
)
// Version cache state
const
versionLoaded
=
ref
<
boolean
>
(
false
)
...
...
@@ -285,6 +286,9 @@ export const useAppStore = defineStore('app', () => {
async
function
fetchPublicSettings
(
force
=
false
):
Promise
<
PublicSettings
|
null
>
{
// Return cached data if available and not forcing refresh
if
(
publicSettingsLoaded
.
value
&&
!
force
)
{
if
(
cachedPublicSettings
.
value
)
{
return
{
...
cachedPublicSettings
.
value
}
}
return
{
registration_enabled
:
false
,
email_verify_enabled
:
false
,
...
...
@@ -296,6 +300,7 @@ export const useAppStore = defineStore('app', () => {
api_base_url
:
apiBaseUrl
.
value
,
contact_info
:
contactInfo
.
value
,
doc_url
:
docUrl
.
value
,
linuxdo_oauth_enabled
:
false
,
version
:
siteVersion
.
value
}
}
...
...
@@ -308,6 +313,7 @@ export const useAppStore = defineStore('app', () => {
publicSettingsLoading
.
value
=
true
try
{
const
data
=
await
fetchPublicSettingsAPI
()
cachedPublicSettings
.
value
=
data
siteName
.
value
=
data
.
site_name
||
'
Sub2API
'
siteLogo
.
value
=
data
.
site_logo
||
''
siteVersion
.
value
=
data
.
version
||
''
...
...
@@ -329,6 +335,7 @@ export const useAppStore = defineStore('app', () => {
*/
function
clearPublicSettingsCache
():
void
{
publicSettingsLoaded
.
value
=
false
cachedPublicSettings
.
value
=
null
}
// ==================== Return Store API ====================
...
...
frontend/src/stores/auth.ts
View file @
6b97a8be
...
...
@@ -159,6 +159,27 @@ export const useAuthStore = defineStore('auth', () => {
}
}
/**
* 直接设置 token(用于 OAuth/SSO 回调),并加载当前用户信息。
* @param newToken - 后端签发的 JWT access token
*/
async
function
setToken
(
newToken
:
string
):
Promise
<
User
>
{
// Clear any previous state first (avoid mixing sessions)
clearAuth
()
token
.
value
=
newToken
localStorage
.
setItem
(
AUTH_TOKEN_KEY
,
newToken
)
try
{
const
userData
=
await
refreshUser
()
startAutoRefresh
()
return
userData
}
catch
(
error
)
{
clearAuth
()
throw
error
}
}
/**
* User logout
* Clears all authentication state and persisted data
...
...
@@ -233,6 +254,7 @@ export const useAuthStore = defineStore('auth', () => {
// Actions
login
,
register
,
setToken
,
logout
,
checkAuth
,
refreshUser
...
...
Prev
1
2
3
4
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