Commit 6b97a8be authored by Edric Li's avatar Edric Li
Browse files

Merge branch 'main' into feat/api-key-ip-restriction

parents 90798f14 62dc0b95
...@@ -40,7 +40,7 @@ func TestGeminiOAuthService_GenerateAuthURL_RedirectURIStrategy(t *testing.T) { ...@@ -40,7 +40,7 @@ func TestGeminiOAuthService_GenerateAuthURL_RedirectURIStrategy(t *testing.T) {
wantProjectID: "", wantProjectID: "",
}, },
{ {
name: "google_one uses custom client when configured and redirects to localhost", name: "google_one always forces built-in client even when custom client configured",
cfg: &config.Config{ cfg: &config.Config{
Gemini: config.GeminiConfig{ Gemini: config.GeminiConfig{
OAuth: config.GeminiOAuthConfig{ OAuth: config.GeminiOAuthConfig{
...@@ -50,9 +50,9 @@ func TestGeminiOAuthService_GenerateAuthURL_RedirectURIStrategy(t *testing.T) { ...@@ -50,9 +50,9 @@ func TestGeminiOAuthService_GenerateAuthURL_RedirectURIStrategy(t *testing.T) {
}, },
}, },
oauthType: "google_one", oauthType: "google_one",
wantClientID: "custom-client-id", wantClientID: geminicli.GeminiCLIOAuthClientID,
wantRedirect: geminicli.AIStudioOAuthRedirectURI, wantRedirect: geminicli.GeminiCLIRedirectURI,
wantScope: geminicli.DefaultGoogleOneScopes, wantScope: geminicli.DefaultCodeAssistScopes,
wantProjectID: "", wantProjectID: "",
}, },
{ {
......
...@@ -21,7 +21,7 @@ type GroupRepository interface { ...@@ -21,7 +21,7 @@ type GroupRepository interface {
DeleteCascade(ctx context.Context, id int64) ([]int64, error) DeleteCascade(ctx context.Context, id int64) ([]int64, error)
List(ctx context.Context, params pagination.PaginationParams) ([]Group, *pagination.PaginationResult, 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) ListActive(ctx context.Context) ([]Group, error)
ListActiveByPlatform(ctx context.Context, platform string) ([]Group, error) ListActiveByPlatform(ctx context.Context, platform string) ([]Group, error)
......
...@@ -540,10 +540,19 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco ...@@ -540,10 +540,19 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
bodyModified = true 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 { if account.Type == AccountTypeOAuth {
reqBody["store"] = false reqBody["store"] = false
bodyModified = true 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 // Re-serialize body only if modified
...@@ -1085,6 +1094,101 @@ func (s *OpenAIGatewayService) replaceModelInResponseBody(body []byte, fromModel ...@@ -1085,6 +1094,101 @@ func (s *OpenAIGatewayService) replaceModelInResponseBody(body []byte, fromModel
return newBody 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 // OpenAIRecordUsageInput input for recording usage
type OpenAIRecordUsageInput struct { type OpenAIRecordUsageInput struct {
Result *OpenAIForwardResult Result *OpenAIForwardResult
......
...@@ -7,6 +7,7 @@ import ( ...@@ -7,6 +7,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"strconv" "strconv"
"strings"
"github.com/Wei-Shaw/sub2api/internal/config" "github.com/Wei-Shaw/sub2api/internal/config"
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
...@@ -64,6 +65,7 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings ...@@ -64,6 +65,7 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings
SettingKeyAPIBaseURL, SettingKeyAPIBaseURL,
SettingKeyContactInfo, SettingKeyContactInfo,
SettingKeyDocURL, SettingKeyDocURL,
SettingKeyLinuxDoConnectEnabled,
} }
settings, err := s.settingRepo.GetMultiple(ctx, keys) settings, err := s.settingRepo.GetMultiple(ctx, keys)
...@@ -71,6 +73,13 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings ...@@ -71,6 +73,13 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings
return nil, fmt.Errorf("get public settings: %w", err) 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{ return &PublicSettings{
RegistrationEnabled: settings[SettingKeyRegistrationEnabled] == "true", RegistrationEnabled: settings[SettingKeyRegistrationEnabled] == "true",
EmailVerifyEnabled: settings[SettingKeyEmailVerifyEnabled] == "true", EmailVerifyEnabled: settings[SettingKeyEmailVerifyEnabled] == "true",
...@@ -82,6 +91,7 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings ...@@ -82,6 +91,7 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings
APIBaseURL: settings[SettingKeyAPIBaseURL], APIBaseURL: settings[SettingKeyAPIBaseURL],
ContactInfo: settings[SettingKeyContactInfo], ContactInfo: settings[SettingKeyContactInfo],
DocURL: settings[SettingKeyDocURL], DocURL: settings[SettingKeyDocURL],
LinuxDoOAuthEnabled: linuxDoEnabled,
}, nil }, nil
} }
...@@ -111,6 +121,14 @@ func (s *SettingService) UpdateSettings(ctx context.Context, settings *SystemSet ...@@ -111,6 +121,14 @@ func (s *SettingService) UpdateSettings(ctx context.Context, settings *SystemSet
updates[SettingKeyTurnstileSecretKey] = settings.TurnstileSecretKey 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设置 // OEM设置
updates[SettingKeySiteName] = settings.SiteName updates[SettingKeySiteName] = settings.SiteName
updates[SettingKeySiteLogo] = settings.SiteLogo updates[SettingKeySiteLogo] = settings.SiteLogo
...@@ -141,8 +159,8 @@ func (s *SettingService) UpdateSettings(ctx context.Context, settings *SystemSet ...@@ -141,8 +159,8 @@ func (s *SettingService) UpdateSettings(ctx context.Context, settings *SystemSet
func (s *SettingService) IsRegistrationEnabled(ctx context.Context) bool { func (s *SettingService) IsRegistrationEnabled(ctx context.Context) bool {
value, err := s.settingRepo.GetValue(ctx, SettingKeyRegistrationEnabled) value, err := s.settingRepo.GetValue(ctx, SettingKeyRegistrationEnabled)
if err != nil { if err != nil {
// 默认开放注册 // 安全默认:如果设置不存在或查询出错,默认关闭注册
return true return false
} }
return value == "true" return value == "true"
} }
...@@ -271,6 +289,38 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin ...@@ -271,6 +289,38 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin
result.SMTPPassword = settings[SettingKeySMTPPassword] result.SMTPPassword = settings[SettingKeySMTPPassword]
result.TurnstileSecretKey = settings[SettingKeyTurnstileSecretKey] 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 // Model fallback settings
result.EnableModelFallback = settings[SettingKeyEnableModelFallback] == "true" result.EnableModelFallback = settings[SettingKeyEnableModelFallback] == "true"
result.FallbackModelAnthropic = s.getStringOrDefault(settings, SettingKeyFallbackModelAnthropic, "claude-3-5-sonnet-20241022") result.FallbackModelAnthropic = s.getStringOrDefault(settings, SettingKeyFallbackModelAnthropic, "claude-3-5-sonnet-20241022")
...@@ -289,6 +339,99 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin ...@@ -289,6 +339,99 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin
return result 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 获取字符串值或默认值 // getStringOrDefault 获取字符串值或默认值
func (s *SettingService) getStringOrDefault(settings map[string]string, key, defaultValue string) string { func (s *SettingService) getStringOrDefault(settings map[string]string, key, defaultValue string) string {
if value, ok := settings[key]; ok && value != "" { if value, ok := settings[key]; ok && value != "" {
......
...@@ -18,6 +18,13 @@ type SystemSettings struct { ...@@ -18,6 +18,13 @@ type SystemSettings struct {
TurnstileSecretKey string TurnstileSecretKey string
TurnstileSecretKeyConfigured bool TurnstileSecretKeyConfigured bool
// LinuxDo Connect OAuth 登录(终端用户 SSO)
LinuxDoConnectEnabled bool
LinuxDoConnectClientID string
LinuxDoConnectClientSecret string
LinuxDoConnectClientSecretConfigured bool
LinuxDoConnectRedirectURL string
SiteName string SiteName string
SiteLogo string SiteLogo string
SiteSubtitle string SiteSubtitle string
...@@ -51,5 +58,6 @@ type PublicSettings struct { ...@@ -51,5 +58,6 @@ type PublicSettings struct {
APIBaseURL string APIBaseURL string
ContactInfo string ContactInfo string
DocURL string DocURL string
LinuxDoOAuthEnabled bool
Version string Version string
} }
...@@ -234,6 +234,31 @@ jwt: ...@@ -234,6 +234,31 @@ jwt:
# 令牌过期时间(小时,最大 24) # 令牌过期时间(小时,最大 24)
expire_hour: 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 # Default Settings
# 默认设置 # 默认设置
......
...@@ -173,11 +173,12 @@ services: ...@@ -173,11 +173,12 @@ services:
volumes: volumes:
- redis_data:/data - redis_data:/data
command: > command: >
redis-server sh -c '
--save 60 1 redis-server
--appendonly yes --save 60 1
--appendfsync everysec --appendonly yes
${REDIS_PASSWORD:+--requirepass ${REDIS_PASSWORD}} --appendfsync everysec
${REDIS_PASSWORD:+--requirepass "$REDIS_PASSWORD"}'
environment: environment:
- TZ=${TZ:-Asia/Shanghai} - TZ=${TZ:-Asia/Shanghai}
# REDISCLI_AUTH is used by redis-cli for authentication (safer than -a flag) # REDISCLI_AUTH is used by redis-cli for authentication (safer than -a flag)
......
...@@ -16,7 +16,7 @@ import type { ...@@ -16,7 +16,7 @@ import type {
* List all groups with pagination * List all groups with pagination
* @param page - Page number (default: 1) * @param page - Page number (default: 1)
* @param pageSize - Items per page (default: 20) * @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 * @returns Paginated list of groups
*/ */
export async function list( export async function list(
...@@ -26,6 +26,7 @@ export async function list( ...@@ -26,6 +26,7 @@ export async function list(
platform?: GroupPlatform platform?: GroupPlatform
status?: 'active' | 'inactive' status?: 'active' | 'inactive'
is_exclusive?: boolean is_exclusive?: boolean
search?: string
}, },
options?: { options?: {
signal?: AbortSignal signal?: AbortSignal
......
...@@ -34,6 +34,11 @@ export interface SystemSettings { ...@@ -34,6 +34,11 @@ export interface SystemSettings {
turnstile_enabled: boolean turnstile_enabled: boolean
turnstile_site_key: string turnstile_site_key: string
turnstile_secret_key_configured: boolean 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) // Identity patch configuration (Claude -> Gemini)
enable_identity_patch: boolean enable_identity_patch: boolean
identity_patch_prompt: string identity_patch_prompt: string
...@@ -60,6 +65,10 @@ export interface UpdateSettingsRequest { ...@@ -60,6 +65,10 @@ export interface UpdateSettingsRequest {
turnstile_enabled?: boolean turnstile_enabled?: boolean
turnstile_site_key?: string turnstile_site_key?: string
turnstile_secret_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 enable_identity_patch?: boolean
identity_patch_prompt?: string identity_patch_prompt?: string
} }
......
...@@ -166,7 +166,7 @@ ...@@ -166,7 +166,7 @@
> >
<div <div
:class="[ :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' accountCategory === 'oauth-based'
? 'bg-orange-500 text-white' ? 'bg-orange-500 text-white'
: 'bg-gray-100 text-gray-500 dark:bg-dark-600 dark:text-gray-400' : 'bg-gray-100 text-gray-500 dark:bg-dark-600 dark:text-gray-400'
...@@ -196,7 +196,7 @@ ...@@ -196,7 +196,7 @@
> >
<div <div
:class="[ :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' accountCategory === 'apikey'
? 'bg-purple-500 text-white' ? 'bg-purple-500 text-white'
: 'bg-gray-100 text-gray-500 dark:bg-dark-600 dark:text-gray-400' : 'bg-gray-100 text-gray-500 dark:bg-dark-600 dark:text-gray-400'
...@@ -232,7 +232,7 @@ ...@@ -232,7 +232,7 @@
> >
<div <div
:class="[ :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' accountCategory === 'oauth-based'
? 'bg-green-500 text-white' ? 'bg-green-500 text-white'
: 'bg-gray-100 text-gray-500 dark:bg-dark-600 dark:text-gray-400' : 'bg-gray-100 text-gray-500 dark:bg-dark-600 dark:text-gray-400'
...@@ -258,7 +258,7 @@ ...@@ -258,7 +258,7 @@
> >
<div <div
:class="[ :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' accountCategory === 'apikey'
? 'bg-purple-500 text-white' ? 'bg-purple-500 text-white'
: 'bg-gray-100 text-gray-500 dark:bg-dark-600 dark:text-gray-400' : 'bg-gray-100 text-gray-500 dark:bg-dark-600 dark:text-gray-400'
...@@ -302,7 +302,7 @@ ...@@ -302,7 +302,7 @@
> >
<div <div
:class="[ :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' accountCategory === 'oauth-based'
? 'bg-blue-500 text-white' ? 'bg-blue-500 text-white'
: 'bg-gray-100 text-gray-500 dark:bg-dark-600 dark:text-gray-400' : 'bg-gray-100 text-gray-500 dark:bg-dark-600 dark:text-gray-400'
...@@ -332,7 +332,7 @@ ...@@ -332,7 +332,7 @@
> >
<div <div
:class="[ :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' accountCategory === 'apikey'
? 'bg-purple-500 text-white' ? 'bg-purple-500 text-white'
: 'bg-gray-100 text-gray-500 dark:bg-dark-600 dark:text-gray-400' : 'bg-gray-100 text-gray-500 dark:bg-dark-600 dark:text-gray-400'
...@@ -397,7 +397,7 @@ ...@@ -397,7 +397,7 @@
> >
<div <div
:class="[ :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' geminiOAuthType === 'google_one'
? 'bg-purple-500 text-white' ? 'bg-purple-500 text-white'
: 'bg-gray-100 text-gray-500 dark:bg-dark-600 dark:text-gray-400' : 'bg-gray-100 text-gray-500 dark:bg-dark-600 dark:text-gray-400'
...@@ -440,7 +440,7 @@ ...@@ -440,7 +440,7 @@
> >
<div <div
:class="[ :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' geminiOAuthType === 'code_assist'
? 'bg-blue-500 text-white' ? 'bg-blue-500 text-white'
: 'bg-gray-100 text-gray-500 dark:bg-dark-600 dark:text-gray-400' : 'bg-gray-100 text-gray-500 dark:bg-dark-600 dark:text-gray-400'
...@@ -518,7 +518,7 @@ ...@@ -518,7 +518,7 @@
> >
<div <div
:class="[ :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' geminiOAuthType === 'ai_studio'
? 'bg-amber-500 text-white' ? 'bg-amber-500 text-white'
: 'bg-gray-100 text-gray-500 dark:bg-dark-600 dark:text-gray-400' : 'bg-gray-100 text-gray-500 dark:bg-dark-600 dark:text-gray-400'
...@@ -621,7 +621,7 @@ ...@@ -621,7 +621,7 @@
<div <div
class="flex items-center gap-3 rounded-lg border-2 border-purple-500 bg-purple-50 p-3 dark:bg-purple-900/20" 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" /> <Icon name="key" size="sm" />
</div> </div>
<div> <div>
......
...@@ -73,113 +73,48 @@ ...@@ -73,113 +73,48 @@
</div> </div>
</fieldset> </fieldset>
<!-- Gemini OAuth Type Selection --> <!-- Gemini OAuth Type Display (read-only) -->
<fieldset v-if="isGemini" class="border-0 p-0"> <div v-if="isGemini" class="rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-dark-600 dark:bg-dark-700">
<legend class="input-label">{{ t('admin.accounts.oauth.gemini.oauthTypeLabel') }}</legend> <div class="mb-2 text-sm font-medium text-gray-700 dark:text-gray-300">
<div class="mt-2 grid grid-cols-3 gap-3"> {{ t('admin.accounts.oauth.gemini.oauthTypeLabel') }}
<button </div>
type="button" <div class="flex items-center gap-3">
@click="handleSelectGeminiOAuthType('google_one')" <div
:class="[ :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' geminiOAuthType === 'google_one'
? 'border-purple-500 bg-purple-50 dark:bg-purple-900/20' ? 'bg-purple-500 text-white'
: 'border-gray-200 hover:border-purple-300 dark:border-dark-600 dark:hover:border-purple-700' : geminiOAuthType === 'code_assist'
]"
>
<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-blue-500 text-white' ? 'bg-blue-500 text-white'
: 'bg-gray-100 text-gray-500 dark:bg-dark-600 dark:text-gray-400' : 'bg-amber-500 text-white'
]"
>
<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'
]" ]"
> >
<div <Icon v-if="geminiOAuthType === 'google_one'" name="user" size="sm" />
:class="[ <Icon v-else-if="geminiOAuthType === 'code_assist'" name="cloud" size="sm" />
'flex h-8 w-8 items-center justify-center rounded-lg', <Icon v-else name="sparkles" size="sm" />
geminiOAuthType === 'ai_studio' </div>
? 'bg-purple-500 text-white' <div>
: 'bg-gray-100 text-gray-500 dark:bg-dark-600 dark:text-gray-400' <span class="block text-sm font-medium text-gray-900 dark:text-white">
]" {{
> geminiOAuthType === 'google_one'
<Icon name="sparkles" size="sm" /> ? 'Google One'
</div> : geminiOAuthType === 'code_assist'
<div class="min-w-0"> ? t('admin.accounts.gemini.oauthType.builtInTitle')
<span class="block text-sm font-medium text-gray-900 dark:text-white"> : t('admin.accounts.gemini.oauthType.customTitle')
{{ t('admin.accounts.gemini.oauthType.customTitle') }} }}
</span> </span>
<span class="text-xs text-gray-500 dark:text-gray-400"> <span class="text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.gemini.oauthType.customDesc') }} {{
</span> geminiOAuthType === 'google_one'
<div v-if="!geminiAIStudioOAuthEnabled" class="group relative mt-1 inline-block"> ? '个人账号'
<span : geminiOAuthType === 'code_assist'
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.gemini.oauthType.builtInDesc')
> : t('admin.accounts.gemini.oauthType.customDesc')
{{ t('admin.accounts.oauth.gemini.aiStudioNotConfiguredShort') }} }}
</span> </span>
<div </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>
</div> </div>
</fieldset> </div>
<OAuthAuthorizationFlow <OAuthAuthorizationFlow
ref="oauthFlowRef" ref="oauthFlowRef"
...@@ -299,7 +234,6 @@ const oauthFlowRef = ref<OAuthFlowExposed | null>(null) ...@@ -299,7 +234,6 @@ const oauthFlowRef = ref<OAuthFlowExposed | null>(null)
// State // State
const addMethod = ref<AddMethod>('oauth') const addMethod = ref<AddMethod>('oauth')
const geminiOAuthType = ref<'code_assist' | 'google_one' | 'ai_studio'>('code_assist') const geminiOAuthType = ref<'code_assist' | 'google_one' | 'ai_studio'>('code_assist')
const geminiAIStudioOAuthEnabled = ref(false)
// Computed - check platform // Computed - check platform
const isOpenAI = computed(() => props.account?.platform === 'openai') const isOpenAI = computed(() => props.account?.platform === 'openai')
...@@ -367,14 +301,6 @@ watch( ...@@ -367,14 +301,6 @@ watch(
? 'ai_studio' ? 'ai_studio'
: 'code_assist' : '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 { } else {
resetState() resetState()
} }
...@@ -385,7 +311,6 @@ watch( ...@@ -385,7 +311,6 @@ watch(
const resetState = () => { const resetState = () => {
addMethod.value = 'oauth' addMethod.value = 'oauth'
geminiOAuthType.value = 'code_assist' geminiOAuthType.value = 'code_assist'
geminiAIStudioOAuthEnabled.value = false
claudeOAuth.resetState() claudeOAuth.resetState()
openaiOAuth.resetState() openaiOAuth.resetState()
geminiOAuth.resetState() geminiOAuth.resetState()
...@@ -393,14 +318,6 @@ const resetState = () => { ...@@ -393,14 +318,6 @@ const resetState = () => {
oauthFlowRef.value?.reset() 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 = () => { const handleClose = () => {
emit('close') emit('close')
} }
......
<template> <template>
<div v-if="selectedIds.length > 0" class="mb-4 flex items-center justify-between p-3 bg-primary-50 rounded-lg"> <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">
<span class="text-sm font-medium">{{ t('admin.accounts.bulkActions.selected', { count: selectedIds.length }) }}</span> <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"> <div class="flex gap-2">
<button @click="$emit('delete')" class="btn btn-danger btn-sm">{{ t('admin.accounts.bulkActions.delete') }}</button> <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> <button @click="$emit('edit')" class="btn btn-primary btn-sm">{{ t('admin.accounts.bulkActions.edit') }}</button>
</div> </div>
</div> </div>
...@@ -10,5 +29,5 @@ ...@@ -10,5 +29,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { useI18n } from 'vue-i18n' 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> </script>
\ No newline at end of file
...@@ -73,111 +73,48 @@ ...@@ -73,111 +73,48 @@
</div> </div>
</fieldset> </fieldset>
<!-- Gemini OAuth Type Selection --> <!-- Gemini OAuth Type Display (read-only) -->
<fieldset v-if="isGemini" class="border-0 p-0"> <div v-if="isGemini" class="rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-dark-600 dark:bg-dark-700">
<legend class="input-label">{{ t('admin.accounts.oauth.gemini.oauthTypeLabel') }}</legend> <div class="mb-2 text-sm font-medium text-gray-700 dark:text-gray-300">
<div class="mt-2 grid grid-cols-3 gap-3"> {{ t('admin.accounts.oauth.gemini.oauthTypeLabel') }}
<button </div>
type="button" <div class="flex items-center gap-3">
@click="handleSelectGeminiOAuthType('google_one')" <div
:class="[ :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' geminiOAuthType === 'google_one'
? 'border-purple-500 bg-purple-50 dark:bg-purple-900/20' ? 'bg-purple-500 text-white'
: 'border-gray-200 hover:border-purple-300 dark:border-dark-600 dark:hover:border-purple-700' : geminiOAuthType === 'code_assist'
]"
>
<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-blue-500 text-white' ? 'bg-blue-500 text-white'
: 'bg-gray-100 text-gray-500 dark:bg-dark-600 dark:text-gray-400' : 'bg-amber-500 text-white'
]"
>
<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'
]" ]"
> >
<div <Icon v-if="geminiOAuthType === 'google_one'" name="user" size="sm" />
:class="[ <Icon v-else-if="geminiOAuthType === 'code_assist'" name="cloud" size="sm" />
'flex h-8 w-8 items-center justify-center rounded-lg', <Icon v-else name="sparkles" size="sm" />
geminiOAuthType === 'ai_studio' </div>
? 'bg-purple-500 text-white' <div>
: 'bg-gray-100 text-gray-500 dark:bg-dark-600 dark:text-gray-400' <span class="block text-sm font-medium text-gray-900 dark:text-white">
]" {{
> geminiOAuthType === 'google_one'
<Icon name="sparkles" size="sm" /> ? 'Google One'
</div> : geminiOAuthType === 'code_assist'
<div class="min-w-0"> ? t('admin.accounts.gemini.oauthType.builtInTitle')
<span class="block text-sm font-medium text-gray-900 dark:text-white"> : t('admin.accounts.gemini.oauthType.customTitle')
{{ t('admin.accounts.gemini.oauthType.customTitle') }} }}
</span> </span>
<span class="text-xs text-gray-500 dark:text-gray-400"> <span class="text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.gemini.oauthType.customDesc') }} {{
</span> geminiOAuthType === 'google_one'
<div v-if="!geminiAIStudioOAuthEnabled" class="group relative mt-1 inline-block"> ? '个人账号'
<span : geminiOAuthType === 'code_assist'
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.gemini.oauthType.builtInDesc')
> : t('admin.accounts.gemini.oauthType.customDesc')
{{ t('admin.accounts.oauth.gemini.aiStudioNotConfiguredShort') }} }}
</span> </span>
<div </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>
</div> </div>
</fieldset> </div>
<OAuthAuthorizationFlow <OAuthAuthorizationFlow
ref="oauthFlowRef" ref="oauthFlowRef"
...@@ -297,7 +234,6 @@ const oauthFlowRef = ref<OAuthFlowExposed | null>(null) ...@@ -297,7 +234,6 @@ const oauthFlowRef = ref<OAuthFlowExposed | null>(null)
// State // State
const addMethod = ref<AddMethod>('oauth') const addMethod = ref<AddMethod>('oauth')
const geminiOAuthType = ref<'code_assist' | 'google_one' | 'ai_studio'>('code_assist') const geminiOAuthType = ref<'code_assist' | 'google_one' | 'ai_studio'>('code_assist')
const geminiAIStudioOAuthEnabled = ref(false)
// Computed - check platform // Computed - check platform
const isOpenAI = computed(() => props.account?.platform === 'openai') const isOpenAI = computed(() => props.account?.platform === 'openai')
...@@ -365,14 +301,6 @@ watch( ...@@ -365,14 +301,6 @@ watch(
? 'ai_studio' ? 'ai_studio'
: 'code_assist' : '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 { } else {
resetState() resetState()
} }
...@@ -383,7 +311,6 @@ watch( ...@@ -383,7 +311,6 @@ watch(
const resetState = () => { const resetState = () => {
addMethod.value = 'oauth' addMethod.value = 'oauth'
geminiOAuthType.value = 'code_assist' geminiOAuthType.value = 'code_assist'
geminiAIStudioOAuthEnabled.value = false
claudeOAuth.resetState() claudeOAuth.resetState()
openaiOAuth.resetState() openaiOAuth.resetState()
geminiOAuth.resetState() geminiOAuth.resetState()
...@@ -391,14 +318,6 @@ const resetState = () => { ...@@ -391,14 +318,6 @@ const resetState = () => {
oauthFlowRef.value?.reset() 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 = () => { const handleClose = () => {
emit('close') emit('close')
} }
......
<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>
...@@ -43,7 +43,8 @@ export function useTableLoader<T, P extends Record<string, any>>(options: TableL ...@@ -43,7 +43,8 @@ export function useTableLoader<T, P extends Record<string, any>>(options: TableL
if (abortController) { if (abortController) {
abortController.abort() abortController.abort()
} }
abortController = new AbortController() const currentController = new AbortController()
abortController = currentController
loading.value = true loading.value = true
try { try {
...@@ -51,9 +52,9 @@ export function useTableLoader<T, P extends Record<string, any>>(options: TableL ...@@ -51,9 +52,9 @@ export function useTableLoader<T, P extends Record<string, any>>(options: TableL
pagination.page, pagination.page,
pagination.page_size, pagination.page_size,
toRaw(params) as P, toRaw(params) as P,
{ signal: abortController.signal } { signal: currentController.signal }
) )
items.value = response.items || [] items.value = response.items || []
pagination.total = response.total || 0 pagination.total = response.total || 0
pagination.pages = response.pages || 0 pagination.pages = response.pages || 0
...@@ -63,7 +64,7 @@ export function useTableLoader<T, P extends Record<string, any>>(options: TableL ...@@ -63,7 +64,7 @@ export function useTableLoader<T, P extends Record<string, any>>(options: TableL
throw error throw error
} }
} finally { } finally {
if (abortController && !abortController.signal.aborted) { if (abortController === currentController) {
loading.value = false loading.value = false
} }
} }
...@@ -77,7 +78,9 @@ export function useTableLoader<T, P extends Record<string, any>>(options: TableL ...@@ -77,7 +78,9 @@ export function useTableLoader<T, P extends Record<string, any>>(options: TableL
const debouncedReload = useDebounceFn(reload, debounceMs) const debouncedReload = useDebounceFn(reload, debounceMs)
const handlePageChange = (page: number) => { const handlePageChange = (page: number) => {
pagination.page = page // 确保页码在有效范围内
const validPage = Math.max(1, Math.min(page, pagination.pages || 1))
pagination.page = validPage
load() load()
} }
......
...@@ -229,6 +229,15 @@ export default { ...@@ -229,6 +229,15 @@ export default {
sendingCode: 'Sending...', sendingCode: 'Sending...',
clickToResend: 'Click to resend code', clickToResend: 'Click to resend code',
resendCode: 'Resend verification 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: { oauth: {
code: 'Code', code: 'Code',
state: 'State', state: 'State',
...@@ -1081,12 +1090,16 @@ export default { ...@@ -1081,12 +1090,16 @@ export default {
tokenRefreshed: 'Token refreshed successfully', tokenRefreshed: 'Token refreshed successfully',
accountDeleted: 'Account deleted successfully', accountDeleted: 'Account deleted successfully',
rateLimitCleared: 'Rate limit cleared successfully', rateLimitCleared: 'Rate limit cleared successfully',
bulkSchedulableEnabled: 'Successfully enabled scheduling for {count} account(s)',
bulkSchedulableDisabled: 'Successfully disabled scheduling for {count} account(s)',
bulkActions: { bulkActions: {
selected: '{count} account(s) selected', selected: '{count} account(s) selected',
selectCurrentPage: 'Select this page', selectCurrentPage: 'Select this page',
clear: 'Clear selection', clear: 'Clear selection',
edit: 'Bulk Edit', edit: 'Bulk Edit',
delete: 'Bulk Delete' delete: 'Bulk Delete',
enableScheduling: 'Enable Scheduling',
disableScheduling: 'Disable Scheduling'
}, },
bulkEdit: { bulkEdit: {
title: 'Bulk Edit Accounts', title: 'Bulk Edit Accounts',
...@@ -1491,6 +1504,7 @@ export default { ...@@ -1491,6 +1504,7 @@ export default {
testing: 'Testing...', testing: 'Testing...',
retry: 'Retry', retry: 'Retry',
copyOutput: 'Copy output', copyOutput: 'Copy output',
outputCopied: 'Output copied',
startingTestForAccount: 'Starting test for account: {name}', startingTestForAccount: 'Starting test for account: {name}',
testAccountTypeLabel: 'Account type: {type}', testAccountTypeLabel: 'Account type: {type}',
selectTestModel: 'Select Test Model', selectTestModel: 'Select Test Model',
...@@ -1761,6 +1775,26 @@ export default { ...@@ -1761,6 +1775,26 @@ export default {
cloudflareDashboard: 'Cloudflare Dashboard', cloudflareDashboard: 'Cloudflare Dashboard',
secretKeyHint: 'Server-side verification key (keep this secret)', secretKeyHint: 'Server-side verification key (keep this secret)',
secretKeyConfiguredHint: 'Secret key configured. Leave empty to keep the current value.' }, 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: { defaults: {
title: 'Default User Settings', title: 'Default User Settings',
description: 'Default values for new users', description: 'Default values for new users',
......
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment