refactor(auth): 将 Linux DO OAuth 配置迁移到系统设置
- 将 LinuxDo Connect 配置从环境变量迁移到数据库持久化 - 在管理后台系统设置中添加 LinuxDo OAuth 配置项 - 简化部署流程,无需修改 docker-compose.override.yml 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -53,7 +53,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
|||||||
emailQueueService := service.ProvideEmailQueueService(emailService)
|
emailQueueService := service.ProvideEmailQueueService(emailService)
|
||||||
authService := service.NewAuthService(userRepository, configConfig, settingService, emailService, turnstileService, emailQueueService)
|
authService := service.NewAuthService(userRepository, configConfig, settingService, emailService, turnstileService, emailQueueService)
|
||||||
userService := service.NewUserService(userRepository)
|
userService := service.NewUserService(userRepository)
|
||||||
authHandler := handler.NewAuthHandler(configConfig, authService, userService)
|
authHandler := handler.NewAuthHandler(configConfig, authService, userService, settingService)
|
||||||
userHandler := handler.NewUserHandler(userService)
|
userHandler := handler.NewUserHandler(userService)
|
||||||
apiKeyRepository := repository.NewAPIKeyRepository(client)
|
apiKeyRepository := repository.NewAPIKeyRepository(client)
|
||||||
groupRepository := repository.NewGroupRepository(client, db)
|
groupRepository := repository.NewGroupRepository(client, db)
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ package admin
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"log"
|
"log"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/Wei-Shaw/sub2api/internal/handler/dto"
|
"github.com/Wei-Shaw/sub2api/internal/handler/dto"
|
||||||
@@ -38,33 +40,37 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
response.Success(c, dto.SystemSettings{
|
response.Success(c, dto.SystemSettings{
|
||||||
RegistrationEnabled: settings.RegistrationEnabled,
|
RegistrationEnabled: settings.RegistrationEnabled,
|
||||||
EmailVerifyEnabled: settings.EmailVerifyEnabled,
|
EmailVerifyEnabled: settings.EmailVerifyEnabled,
|
||||||
SMTPHost: settings.SMTPHost,
|
SMTPHost: settings.SMTPHost,
|
||||||
SMTPPort: settings.SMTPPort,
|
SMTPPort: settings.SMTPPort,
|
||||||
SMTPUsername: settings.SMTPUsername,
|
SMTPUsername: settings.SMTPUsername,
|
||||||
SMTPPasswordConfigured: settings.SMTPPasswordConfigured,
|
SMTPPasswordConfigured: settings.SMTPPasswordConfigured,
|
||||||
SMTPFrom: settings.SMTPFrom,
|
SMTPFrom: settings.SMTPFrom,
|
||||||
SMTPFromName: settings.SMTPFromName,
|
SMTPFromName: settings.SMTPFromName,
|
||||||
SMTPUseTLS: settings.SMTPUseTLS,
|
SMTPUseTLS: settings.SMTPUseTLS,
|
||||||
TurnstileEnabled: settings.TurnstileEnabled,
|
TurnstileEnabled: settings.TurnstileEnabled,
|
||||||
TurnstileSiteKey: settings.TurnstileSiteKey,
|
TurnstileSiteKey: settings.TurnstileSiteKey,
|
||||||
TurnstileSecretKeyConfigured: settings.TurnstileSecretKeyConfigured,
|
TurnstileSecretKeyConfigured: settings.TurnstileSecretKeyConfigured,
|
||||||
SiteName: settings.SiteName,
|
LinuxDoConnectEnabled: settings.LinuxDoConnectEnabled,
|
||||||
SiteLogo: settings.SiteLogo,
|
LinuxDoConnectClientID: settings.LinuxDoConnectClientID,
|
||||||
SiteSubtitle: settings.SiteSubtitle,
|
LinuxDoConnectClientSecretConfigured: settings.LinuxDoConnectClientSecretConfigured,
|
||||||
APIBaseURL: settings.APIBaseURL,
|
LinuxDoConnectRedirectURL: settings.LinuxDoConnectRedirectURL,
|
||||||
ContactInfo: settings.ContactInfo,
|
SiteName: settings.SiteName,
|
||||||
DocURL: settings.DocURL,
|
SiteLogo: settings.SiteLogo,
|
||||||
DefaultConcurrency: settings.DefaultConcurrency,
|
SiteSubtitle: settings.SiteSubtitle,
|
||||||
DefaultBalance: settings.DefaultBalance,
|
APIBaseURL: settings.APIBaseURL,
|
||||||
EnableModelFallback: settings.EnableModelFallback,
|
ContactInfo: settings.ContactInfo,
|
||||||
FallbackModelAnthropic: settings.FallbackModelAnthropic,
|
DocURL: settings.DocURL,
|
||||||
FallbackModelOpenAI: settings.FallbackModelOpenAI,
|
DefaultConcurrency: settings.DefaultConcurrency,
|
||||||
FallbackModelGemini: settings.FallbackModelGemini,
|
DefaultBalance: settings.DefaultBalance,
|
||||||
FallbackModelAntigravity: settings.FallbackModelAntigravity,
|
EnableModelFallback: settings.EnableModelFallback,
|
||||||
EnableIdentityPatch: settings.EnableIdentityPatch,
|
FallbackModelAnthropic: settings.FallbackModelAnthropic,
|
||||||
IdentityPatchPrompt: settings.IdentityPatchPrompt,
|
FallbackModelOpenAI: settings.FallbackModelOpenAI,
|
||||||
|
FallbackModelGemini: settings.FallbackModelGemini,
|
||||||
|
FallbackModelAntigravity: settings.FallbackModelAntigravity,
|
||||||
|
EnableIdentityPatch: settings.EnableIdentityPatch,
|
||||||
|
IdentityPatchPrompt: settings.IdentityPatchPrompt,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,6 +94,12 @@ type UpdateSettingsRequest struct {
|
|||||||
TurnstileSiteKey string `json:"turnstile_site_key"`
|
TurnstileSiteKey string `json:"turnstile_site_key"`
|
||||||
TurnstileSecretKey string `json:"turnstile_secret_key"`
|
TurnstileSecretKey string `json:"turnstile_secret_key"`
|
||||||
|
|
||||||
|
// LinuxDo Connect OAuth login (end-user SSO)
|
||||||
|
LinuxDoConnectEnabled bool `json:"linuxdo_connect_enabled"`
|
||||||
|
LinuxDoConnectClientID string `json:"linuxdo_connect_client_id"`
|
||||||
|
LinuxDoConnectClientSecret string `json:"linuxdo_connect_client_secret"`
|
||||||
|
LinuxDoConnectRedirectURL string `json:"linuxdo_connect_redirect_url"`
|
||||||
|
|
||||||
// OEM设置
|
// OEM设置
|
||||||
SiteName string `json:"site_name"`
|
SiteName string `json:"site_name"`
|
||||||
SiteLogo string `json:"site_logo"`
|
SiteLogo string `json:"site_logo"`
|
||||||
@@ -165,34 +177,67 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// LinuxDo Connect 参数验证
|
||||||
|
if req.LinuxDoConnectEnabled {
|
||||||
|
req.LinuxDoConnectClientID = strings.TrimSpace(req.LinuxDoConnectClientID)
|
||||||
|
req.LinuxDoConnectClientSecret = strings.TrimSpace(req.LinuxDoConnectClientSecret)
|
||||||
|
req.LinuxDoConnectRedirectURL = strings.TrimSpace(req.LinuxDoConnectRedirectURL)
|
||||||
|
|
||||||
|
if req.LinuxDoConnectClientID == "" {
|
||||||
|
response.BadRequest(c, "LinuxDo Client ID is required when enabled")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.LinuxDoConnectRedirectURL == "" {
|
||||||
|
response.BadRequest(c, "LinuxDo Redirect URL is required when enabled")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !isAbsoluteHTTPURL(req.LinuxDoConnectRedirectURL) {
|
||||||
|
response.BadRequest(c, "LinuxDo Redirect URL must be an absolute http(s) URL")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// If client_secret not provided, keep existing value (if any).
|
||||||
|
if req.LinuxDoConnectClientSecret == "" {
|
||||||
|
if previousSettings.LinuxDoConnectClientSecret == "" {
|
||||||
|
response.BadRequest(c, "LinuxDo Client Secret is required when enabled")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req.LinuxDoConnectClientSecret = previousSettings.LinuxDoConnectClientSecret
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
settings := &service.SystemSettings{
|
settings := &service.SystemSettings{
|
||||||
RegistrationEnabled: req.RegistrationEnabled,
|
RegistrationEnabled: req.RegistrationEnabled,
|
||||||
EmailVerifyEnabled: req.EmailVerifyEnabled,
|
EmailVerifyEnabled: req.EmailVerifyEnabled,
|
||||||
SMTPHost: req.SMTPHost,
|
SMTPHost: req.SMTPHost,
|
||||||
SMTPPort: req.SMTPPort,
|
SMTPPort: req.SMTPPort,
|
||||||
SMTPUsername: req.SMTPUsername,
|
SMTPUsername: req.SMTPUsername,
|
||||||
SMTPPassword: req.SMTPPassword,
|
SMTPPassword: req.SMTPPassword,
|
||||||
SMTPFrom: req.SMTPFrom,
|
SMTPFrom: req.SMTPFrom,
|
||||||
SMTPFromName: req.SMTPFromName,
|
SMTPFromName: req.SMTPFromName,
|
||||||
SMTPUseTLS: req.SMTPUseTLS,
|
SMTPUseTLS: req.SMTPUseTLS,
|
||||||
TurnstileEnabled: req.TurnstileEnabled,
|
TurnstileEnabled: req.TurnstileEnabled,
|
||||||
TurnstileSiteKey: req.TurnstileSiteKey,
|
TurnstileSiteKey: req.TurnstileSiteKey,
|
||||||
TurnstileSecretKey: req.TurnstileSecretKey,
|
TurnstileSecretKey: req.TurnstileSecretKey,
|
||||||
SiteName: req.SiteName,
|
LinuxDoConnectEnabled: req.LinuxDoConnectEnabled,
|
||||||
SiteLogo: req.SiteLogo,
|
LinuxDoConnectClientID: req.LinuxDoConnectClientID,
|
||||||
SiteSubtitle: req.SiteSubtitle,
|
LinuxDoConnectClientSecret: req.LinuxDoConnectClientSecret,
|
||||||
APIBaseURL: req.APIBaseURL,
|
LinuxDoConnectRedirectURL: req.LinuxDoConnectRedirectURL,
|
||||||
ContactInfo: req.ContactInfo,
|
SiteName: req.SiteName,
|
||||||
DocURL: req.DocURL,
|
SiteLogo: req.SiteLogo,
|
||||||
DefaultConcurrency: req.DefaultConcurrency,
|
SiteSubtitle: req.SiteSubtitle,
|
||||||
DefaultBalance: req.DefaultBalance,
|
APIBaseURL: req.APIBaseURL,
|
||||||
EnableModelFallback: req.EnableModelFallback,
|
ContactInfo: req.ContactInfo,
|
||||||
FallbackModelAnthropic: req.FallbackModelAnthropic,
|
DocURL: req.DocURL,
|
||||||
FallbackModelOpenAI: req.FallbackModelOpenAI,
|
DefaultConcurrency: req.DefaultConcurrency,
|
||||||
FallbackModelGemini: req.FallbackModelGemini,
|
DefaultBalance: req.DefaultBalance,
|
||||||
FallbackModelAntigravity: req.FallbackModelAntigravity,
|
EnableModelFallback: req.EnableModelFallback,
|
||||||
EnableIdentityPatch: req.EnableIdentityPatch,
|
FallbackModelAnthropic: req.FallbackModelAnthropic,
|
||||||
IdentityPatchPrompt: req.IdentityPatchPrompt,
|
FallbackModelOpenAI: req.FallbackModelOpenAI,
|
||||||
|
FallbackModelGemini: req.FallbackModelGemini,
|
||||||
|
FallbackModelAntigravity: req.FallbackModelAntigravity,
|
||||||
|
EnableIdentityPatch: req.EnableIdentityPatch,
|
||||||
|
IdentityPatchPrompt: req.IdentityPatchPrompt,
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := h.settingService.UpdateSettings(c.Request.Context(), settings); err != nil {
|
if err := h.settingService.UpdateSettings(c.Request.Context(), settings); err != nil {
|
||||||
@@ -210,33 +255,37 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
response.Success(c, dto.SystemSettings{
|
response.Success(c, dto.SystemSettings{
|
||||||
RegistrationEnabled: updatedSettings.RegistrationEnabled,
|
RegistrationEnabled: updatedSettings.RegistrationEnabled,
|
||||||
EmailVerifyEnabled: updatedSettings.EmailVerifyEnabled,
|
EmailVerifyEnabled: updatedSettings.EmailVerifyEnabled,
|
||||||
SMTPHost: updatedSettings.SMTPHost,
|
SMTPHost: updatedSettings.SMTPHost,
|
||||||
SMTPPort: updatedSettings.SMTPPort,
|
SMTPPort: updatedSettings.SMTPPort,
|
||||||
SMTPUsername: updatedSettings.SMTPUsername,
|
SMTPUsername: updatedSettings.SMTPUsername,
|
||||||
SMTPPasswordConfigured: updatedSettings.SMTPPasswordConfigured,
|
SMTPPasswordConfigured: updatedSettings.SMTPPasswordConfigured,
|
||||||
SMTPFrom: updatedSettings.SMTPFrom,
|
SMTPFrom: updatedSettings.SMTPFrom,
|
||||||
SMTPFromName: updatedSettings.SMTPFromName,
|
SMTPFromName: updatedSettings.SMTPFromName,
|
||||||
SMTPUseTLS: updatedSettings.SMTPUseTLS,
|
SMTPUseTLS: updatedSettings.SMTPUseTLS,
|
||||||
TurnstileEnabled: updatedSettings.TurnstileEnabled,
|
TurnstileEnabled: updatedSettings.TurnstileEnabled,
|
||||||
TurnstileSiteKey: updatedSettings.TurnstileSiteKey,
|
TurnstileSiteKey: updatedSettings.TurnstileSiteKey,
|
||||||
TurnstileSecretKeyConfigured: updatedSettings.TurnstileSecretKeyConfigured,
|
TurnstileSecretKeyConfigured: updatedSettings.TurnstileSecretKeyConfigured,
|
||||||
SiteName: updatedSettings.SiteName,
|
LinuxDoConnectEnabled: updatedSettings.LinuxDoConnectEnabled,
|
||||||
SiteLogo: updatedSettings.SiteLogo,
|
LinuxDoConnectClientID: updatedSettings.LinuxDoConnectClientID,
|
||||||
SiteSubtitle: updatedSettings.SiteSubtitle,
|
LinuxDoConnectClientSecretConfigured: updatedSettings.LinuxDoConnectClientSecretConfigured,
|
||||||
APIBaseURL: updatedSettings.APIBaseURL,
|
LinuxDoConnectRedirectURL: updatedSettings.LinuxDoConnectRedirectURL,
|
||||||
ContactInfo: updatedSettings.ContactInfo,
|
SiteName: updatedSettings.SiteName,
|
||||||
DocURL: updatedSettings.DocURL,
|
SiteLogo: updatedSettings.SiteLogo,
|
||||||
DefaultConcurrency: updatedSettings.DefaultConcurrency,
|
SiteSubtitle: updatedSettings.SiteSubtitle,
|
||||||
DefaultBalance: updatedSettings.DefaultBalance,
|
APIBaseURL: updatedSettings.APIBaseURL,
|
||||||
EnableModelFallback: updatedSettings.EnableModelFallback,
|
ContactInfo: updatedSettings.ContactInfo,
|
||||||
FallbackModelAnthropic: updatedSettings.FallbackModelAnthropic,
|
DocURL: updatedSettings.DocURL,
|
||||||
FallbackModelOpenAI: updatedSettings.FallbackModelOpenAI,
|
DefaultConcurrency: updatedSettings.DefaultConcurrency,
|
||||||
FallbackModelGemini: updatedSettings.FallbackModelGemini,
|
DefaultBalance: updatedSettings.DefaultBalance,
|
||||||
FallbackModelAntigravity: updatedSettings.FallbackModelAntigravity,
|
EnableModelFallback: updatedSettings.EnableModelFallback,
|
||||||
EnableIdentityPatch: updatedSettings.EnableIdentityPatch,
|
FallbackModelAnthropic: updatedSettings.FallbackModelAnthropic,
|
||||||
IdentityPatchPrompt: updatedSettings.IdentityPatchPrompt,
|
FallbackModelOpenAI: updatedSettings.FallbackModelOpenAI,
|
||||||
|
FallbackModelGemini: updatedSettings.FallbackModelGemini,
|
||||||
|
FallbackModelAntigravity: updatedSettings.FallbackModelAntigravity,
|
||||||
|
EnableIdentityPatch: updatedSettings.EnableIdentityPatch,
|
||||||
|
IdentityPatchPrompt: updatedSettings.IdentityPatchPrompt,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -298,6 +347,18 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings,
|
|||||||
if req.TurnstileSecretKey != "" {
|
if req.TurnstileSecretKey != "" {
|
||||||
changed = append(changed, "turnstile_secret_key")
|
changed = append(changed, "turnstile_secret_key")
|
||||||
}
|
}
|
||||||
|
if before.LinuxDoConnectEnabled != after.LinuxDoConnectEnabled {
|
||||||
|
changed = append(changed, "linuxdo_connect_enabled")
|
||||||
|
}
|
||||||
|
if before.LinuxDoConnectClientID != after.LinuxDoConnectClientID {
|
||||||
|
changed = append(changed, "linuxdo_connect_client_id")
|
||||||
|
}
|
||||||
|
if req.LinuxDoConnectClientSecret != "" {
|
||||||
|
changed = append(changed, "linuxdo_connect_client_secret")
|
||||||
|
}
|
||||||
|
if before.LinuxDoConnectRedirectURL != after.LinuxDoConnectRedirectURL {
|
||||||
|
changed = append(changed, "linuxdo_connect_redirect_url")
|
||||||
|
}
|
||||||
if before.SiteName != after.SiteName {
|
if before.SiteName != after.SiteName {
|
||||||
changed = append(changed, "site_name")
|
changed = append(changed, "site_name")
|
||||||
}
|
}
|
||||||
@@ -337,9 +398,42 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings,
|
|||||||
if before.FallbackModelAntigravity != after.FallbackModelAntigravity {
|
if before.FallbackModelAntigravity != after.FallbackModelAntigravity {
|
||||||
changed = append(changed, "fallback_model_antigravity")
|
changed = append(changed, "fallback_model_antigravity")
|
||||||
}
|
}
|
||||||
|
if before.EnableIdentityPatch != after.EnableIdentityPatch {
|
||||||
|
changed = append(changed, "enable_identity_patch")
|
||||||
|
}
|
||||||
|
if before.IdentityPatchPrompt != after.IdentityPatchPrompt {
|
||||||
|
changed = append(changed, "identity_patch_prompt")
|
||||||
|
}
|
||||||
return changed
|
return changed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func isAbsoluteHTTPURL(raw string) bool {
|
||||||
|
raw = strings.TrimSpace(raw)
|
||||||
|
if raw == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(raw, "//") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
u, err := url.Parse(raw)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if !u.IsAbs() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if !strings.EqualFold(u.Scheme, "http") && !strings.EqualFold(u.Scheme, "https") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(u.Host) == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if u.Fragment != "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
// TestSMTPRequest 测试SMTP连接请求
|
// TestSMTPRequest 测试SMTP连接请求
|
||||||
type TestSMTPRequest struct {
|
type TestSMTPRequest struct {
|
||||||
SMTPHost string `json:"smtp_host" binding:"required"`
|
SMTPHost string `json:"smtp_host" binding:"required"`
|
||||||
|
|||||||
@@ -15,14 +15,16 @@ type AuthHandler struct {
|
|||||||
cfg *config.Config
|
cfg *config.Config
|
||||||
authService *service.AuthService
|
authService *service.AuthService
|
||||||
userService *service.UserService
|
userService *service.UserService
|
||||||
|
settingSvc *service.SettingService
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewAuthHandler creates a new AuthHandler
|
// NewAuthHandler creates a new AuthHandler
|
||||||
func NewAuthHandler(cfg *config.Config, authService *service.AuthService, userService *service.UserService) *AuthHandler {
|
func NewAuthHandler(cfg *config.Config, authService *service.AuthService, userService *service.UserService, settingService *service.SettingService) *AuthHandler {
|
||||||
return &AuthHandler{
|
return &AuthHandler{
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
authService: authService,
|
authService: authService,
|
||||||
userService: userService,
|
userService: userService,
|
||||||
|
settingSvc: settingService,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
@@ -44,10 +45,31 @@ type linuxDoTokenResponse struct {
|
|||||||
Scope string `json:"scope,omitempty"`
|
Scope string `json:"scope,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type linuxDoTokenExchangeError struct {
|
||||||
|
StatusCode int
|
||||||
|
ProviderError string
|
||||||
|
ProviderDescription string
|
||||||
|
Body string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *linuxDoTokenExchangeError) Error() string {
|
||||||
|
if e == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
parts := []string{fmt.Sprintf("token exchange status=%d", e.StatusCode)}
|
||||||
|
if strings.TrimSpace(e.ProviderError) != "" {
|
||||||
|
parts = append(parts, "error="+strings.TrimSpace(e.ProviderError))
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(e.ProviderDescription) != "" {
|
||||||
|
parts = append(parts, "error_description="+strings.TrimSpace(e.ProviderDescription))
|
||||||
|
}
|
||||||
|
return strings.Join(parts, " ")
|
||||||
|
}
|
||||||
|
|
||||||
// LinuxDoOAuthStart starts the LinuxDo Connect OAuth login flow.
|
// LinuxDoOAuthStart starts the LinuxDo Connect OAuth login flow.
|
||||||
// GET /api/v1/auth/oauth/linuxdo/start?redirect=/dashboard
|
// GET /api/v1/auth/oauth/linuxdo/start?redirect=/dashboard
|
||||||
func (h *AuthHandler) LinuxDoOAuthStart(c *gin.Context) {
|
func (h *AuthHandler) LinuxDoOAuthStart(c *gin.Context) {
|
||||||
cfg, err := linuxDoOAuthConfig(h.cfg)
|
cfg, err := h.getLinuxDoOAuthConfig(c.Request.Context())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.ErrorFrom(c, err)
|
response.ErrorFrom(c, err)
|
||||||
return
|
return
|
||||||
@@ -97,7 +119,7 @@ func (h *AuthHandler) LinuxDoOAuthStart(c *gin.Context) {
|
|||||||
// LinuxDoOAuthCallback handles the OAuth callback, creates/logins the user, then redirects to frontend.
|
// LinuxDoOAuthCallback handles the OAuth callback, creates/logins the user, then redirects to frontend.
|
||||||
// GET /api/v1/auth/oauth/linuxdo/callback?code=...&state=...
|
// GET /api/v1/auth/oauth/linuxdo/callback?code=...&state=...
|
||||||
func (h *AuthHandler) LinuxDoOAuthCallback(c *gin.Context) {
|
func (h *AuthHandler) LinuxDoOAuthCallback(c *gin.Context) {
|
||||||
cfg, cfgErr := linuxDoOAuthConfig(h.cfg)
|
cfg, cfgErr := h.getLinuxDoOAuthConfig(c.Request.Context())
|
||||||
if cfgErr != nil {
|
if cfgErr != nil {
|
||||||
response.ErrorFrom(c, cfgErr)
|
response.ErrorFrom(c, cfgErr)
|
||||||
return
|
return
|
||||||
@@ -156,8 +178,22 @@ func (h *AuthHandler) LinuxDoOAuthCallback(c *gin.Context) {
|
|||||||
|
|
||||||
tokenResp, err := linuxDoExchangeCode(c.Request.Context(), cfg, code, redirectURI, codeVerifier)
|
tokenResp, err := linuxDoExchangeCode(c.Request.Context(), cfg, code, redirectURI, codeVerifier)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("[LinuxDo OAuth] token exchange failed: %v", err)
|
description := ""
|
||||||
redirectOAuthError(c, frontendCallback, "token_exchange_failed", "failed to exchange oauth code", "")
|
var exchangeErr *linuxDoTokenExchangeError
|
||||||
|
if errors.As(err, &exchangeErr) && exchangeErr != nil {
|
||||||
|
log.Printf(
|
||||||
|
"[LinuxDo OAuth] token exchange failed: status=%d provider_error=%q provider_description=%q body=%s",
|
||||||
|
exchangeErr.StatusCode,
|
||||||
|
exchangeErr.ProviderError,
|
||||||
|
exchangeErr.ProviderDescription,
|
||||||
|
truncateLogValue(exchangeErr.Body, 2048),
|
||||||
|
)
|
||||||
|
description = exchangeErr.Error()
|
||||||
|
} else {
|
||||||
|
log.Printf("[LinuxDo OAuth] token exchange failed: %v", err)
|
||||||
|
description = err.Error()
|
||||||
|
}
|
||||||
|
redirectOAuthError(c, frontendCallback, "token_exchange_failed", "failed to exchange oauth code", singleLine(description))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -182,14 +218,17 @@ func (h *AuthHandler) LinuxDoOAuthCallback(c *gin.Context) {
|
|||||||
redirectWithFragment(c, frontendCallback, fragment)
|
redirectWithFragment(c, frontendCallback, fragment)
|
||||||
}
|
}
|
||||||
|
|
||||||
func linuxDoOAuthConfig(cfg *config.Config) (config.LinuxDoConnectConfig, error) {
|
func (h *AuthHandler) getLinuxDoOAuthConfig(ctx context.Context) (config.LinuxDoConnectConfig, error) {
|
||||||
if cfg == nil {
|
if h != nil && h.settingSvc != nil {
|
||||||
|
return h.settingSvc.GetLinuxDoConnectOAuthConfig(ctx)
|
||||||
|
}
|
||||||
|
if h == nil || h.cfg == nil {
|
||||||
return config.LinuxDoConnectConfig{}, infraerrors.ServiceUnavailable("CONFIG_NOT_READY", "config not loaded")
|
return config.LinuxDoConnectConfig{}, infraerrors.ServiceUnavailable("CONFIG_NOT_READY", "config not loaded")
|
||||||
}
|
}
|
||||||
if !cfg.LinuxDo.Enabled {
|
if !h.cfg.LinuxDo.Enabled {
|
||||||
return config.LinuxDoConnectConfig{}, infraerrors.NotFound("OAUTH_DISABLED", "oauth login is disabled")
|
return config.LinuxDoConnectConfig{}, infraerrors.NotFound("OAUTH_DISABLED", "oauth login is disabled")
|
||||||
}
|
}
|
||||||
return cfg.LinuxDo, nil
|
return h.cfg.LinuxDo, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func linuxDoExchangeCode(
|
func linuxDoExchangeCode(
|
||||||
@@ -224,21 +263,32 @@ func linuxDoExchangeCode(
|
|||||||
return nil, fmt.Errorf("unsupported token_auth_method: %s", cfg.TokenAuthMethod)
|
return nil, fmt.Errorf("unsupported token_auth_method: %s", cfg.TokenAuthMethod)
|
||||||
}
|
}
|
||||||
|
|
||||||
var tokenResp linuxDoTokenResponse
|
resp, err := r.SetFormDataFromValues(form).Post(cfg.TokenURL)
|
||||||
resp, err := r.SetFormDataFromValues(form).SetSuccessResult(&tokenResp).Post(cfg.TokenURL)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("request token: %w", err)
|
return nil, fmt.Errorf("request token: %w", err)
|
||||||
}
|
}
|
||||||
|
body := strings.TrimSpace(resp.String())
|
||||||
if !resp.IsSuccessState() {
|
if !resp.IsSuccessState() {
|
||||||
return nil, fmt.Errorf("token exchange status=%d", resp.StatusCode)
|
providerErr, providerDesc := parseOAuthProviderError(body)
|
||||||
|
return nil, &linuxDoTokenExchangeError{
|
||||||
|
StatusCode: resp.StatusCode,
|
||||||
|
ProviderError: providerErr,
|
||||||
|
ProviderDescription: providerDesc,
|
||||||
|
Body: body,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(tokenResp.AccessToken) == "" {
|
|
||||||
return nil, errors.New("token response missing access_token")
|
tokenResp, ok := parseLinuxDoTokenResponse(body)
|
||||||
|
if !ok || strings.TrimSpace(tokenResp.AccessToken) == "" {
|
||||||
|
return nil, &linuxDoTokenExchangeError{
|
||||||
|
StatusCode: resp.StatusCode,
|
||||||
|
Body: body,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(tokenResp.TokenType) == "" {
|
if strings.TrimSpace(tokenResp.TokenType) == "" {
|
||||||
tokenResp.TokenType = "Bearer"
|
tokenResp.TokenType = "Bearer"
|
||||||
}
|
}
|
||||||
return &tokenResp, nil
|
return tokenResp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func linuxDoFetchUserInfo(
|
func linuxDoFetchUserInfo(
|
||||||
@@ -377,6 +427,81 @@ func firstNonEmpty(values ...string) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func parseOAuthProviderError(body string) (providerErr string, providerDesc string) {
|
||||||
|
body = strings.TrimSpace(body)
|
||||||
|
if body == "" {
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
|
||||||
|
providerErr = firstNonEmpty(
|
||||||
|
getGJSON(body, "error"),
|
||||||
|
getGJSON(body, "code"),
|
||||||
|
getGJSON(body, "error.code"),
|
||||||
|
)
|
||||||
|
providerDesc = firstNonEmpty(
|
||||||
|
getGJSON(body, "error_description"),
|
||||||
|
getGJSON(body, "error.message"),
|
||||||
|
getGJSON(body, "message"),
|
||||||
|
getGJSON(body, "detail"),
|
||||||
|
)
|
||||||
|
|
||||||
|
if providerErr != "" || providerDesc != "" {
|
||||||
|
return providerErr, providerDesc
|
||||||
|
}
|
||||||
|
|
||||||
|
values, err := url.ParseQuery(body)
|
||||||
|
if err != nil {
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
providerErr = firstNonEmpty(values.Get("error"), values.Get("code"))
|
||||||
|
providerDesc = firstNonEmpty(values.Get("error_description"), values.Get("error_message"), values.Get("message"))
|
||||||
|
return providerErr, providerDesc
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseLinuxDoTokenResponse(body string) (*linuxDoTokenResponse, bool) {
|
||||||
|
body = strings.TrimSpace(body)
|
||||||
|
if body == "" {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
accessToken := strings.TrimSpace(getGJSON(body, "access_token"))
|
||||||
|
if accessToken != "" {
|
||||||
|
tokenType := strings.TrimSpace(getGJSON(body, "token_type"))
|
||||||
|
refreshToken := strings.TrimSpace(getGJSON(body, "refresh_token"))
|
||||||
|
scope := strings.TrimSpace(getGJSON(body, "scope"))
|
||||||
|
expiresIn := gjson.Get(body, "expires_in").Int()
|
||||||
|
return &linuxDoTokenResponse{
|
||||||
|
AccessToken: accessToken,
|
||||||
|
TokenType: tokenType,
|
||||||
|
ExpiresIn: expiresIn,
|
||||||
|
RefreshToken: refreshToken,
|
||||||
|
Scope: scope,
|
||||||
|
}, true
|
||||||
|
}
|
||||||
|
|
||||||
|
values, err := url.ParseQuery(body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
accessToken = strings.TrimSpace(values.Get("access_token"))
|
||||||
|
if accessToken == "" {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
expiresIn := int64(0)
|
||||||
|
if raw := strings.TrimSpace(values.Get("expires_in")); raw != "" {
|
||||||
|
if v, err := strconv.ParseInt(raw, 10, 64); err == nil {
|
||||||
|
expiresIn = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &linuxDoTokenResponse{
|
||||||
|
AccessToken: accessToken,
|
||||||
|
TokenType: strings.TrimSpace(values.Get("token_type")),
|
||||||
|
ExpiresIn: expiresIn,
|
||||||
|
RefreshToken: strings.TrimSpace(values.Get("refresh_token")),
|
||||||
|
Scope: strings.TrimSpace(values.Get("scope")),
|
||||||
|
}, true
|
||||||
|
}
|
||||||
|
|
||||||
func getGJSON(body string, path string) string {
|
func getGJSON(body string, path string) string {
|
||||||
path = strings.TrimSpace(path)
|
path = strings.TrimSpace(path)
|
||||||
if path == "" {
|
if path == "" {
|
||||||
@@ -389,6 +514,29 @@ func getGJSON(body string, path string) string {
|
|||||||
return res.String()
|
return res.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func truncateLogValue(value string, maxLen int) string {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
if value == "" || maxLen <= 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if len(value) <= maxLen {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
value = value[:maxLen]
|
||||||
|
for !utf8.ValidString(value) {
|
||||||
|
value = value[:len(value)-1]
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func singleLine(value string) string {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
if value == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.Join(strings.Fields(value), " ")
|
||||||
|
}
|
||||||
|
|
||||||
func sanitizeFrontendRedirectPath(path string) string {
|
func sanitizeFrontendRedirectPath(path string) string {
|
||||||
path = strings.TrimSpace(path)
|
path = strings.TrimSpace(path)
|
||||||
if path == "" {
|
if path == "" {
|
||||||
|
|||||||
@@ -72,3 +72,37 @@ func TestLinuxDoParseUserInfoRejectsUnsafeSubject(t *testing.T) {
|
|||||||
_, _, _, err = linuxDoParseUserInfo(`{"id":"`+tooLong+`"}`, cfg)
|
_, _, _, err = linuxDoParseUserInfo(`{"id":"`+tooLong+`"}`, cfg)
|
||||||
require.Error(t, err)
|
require.Error(t, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestParseOAuthProviderErrorJSON(t *testing.T) {
|
||||||
|
code, desc := parseOAuthProviderError(`{"error":"invalid_client","error_description":"bad secret"}`)
|
||||||
|
require.Equal(t, "invalid_client", code)
|
||||||
|
require.Equal(t, "bad secret", desc)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseOAuthProviderErrorForm(t *testing.T) {
|
||||||
|
code, desc := parseOAuthProviderError("error=invalid_request&error_description=Missing+code_verifier")
|
||||||
|
require.Equal(t, "invalid_request", code)
|
||||||
|
require.Equal(t, "Missing code_verifier", desc)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseLinuxDoTokenResponseJSON(t *testing.T) {
|
||||||
|
token, ok := parseLinuxDoTokenResponse(`{"access_token":"t1","token_type":"Bearer","expires_in":3600,"scope":"user"}`)
|
||||||
|
require.True(t, ok)
|
||||||
|
require.Equal(t, "t1", token.AccessToken)
|
||||||
|
require.Equal(t, "Bearer", token.TokenType)
|
||||||
|
require.Equal(t, int64(3600), token.ExpiresIn)
|
||||||
|
require.Equal(t, "user", token.Scope)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseLinuxDoTokenResponseForm(t *testing.T) {
|
||||||
|
token, ok := parseLinuxDoTokenResponse("access_token=t2&token_type=bearer&expires_in=60")
|
||||||
|
require.True(t, ok)
|
||||||
|
require.Equal(t, "t2", token.AccessToken)
|
||||||
|
require.Equal(t, "bearer", token.TokenType)
|
||||||
|
require.Equal(t, int64(60), token.ExpiresIn)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSingleLineStripsWhitespace(t *testing.T) {
|
||||||
|
require.Equal(t, "hello world", singleLine("hello\r\nworld"))
|
||||||
|
require.Equal(t, "", singleLine("\n\t\r"))
|
||||||
|
}
|
||||||
|
|||||||
@@ -17,6 +17,11 @@ type SystemSettings struct {
|
|||||||
TurnstileSiteKey string `json:"turnstile_site_key"`
|
TurnstileSiteKey string `json:"turnstile_site_key"`
|
||||||
TurnstileSecretKeyConfigured bool `json:"turnstile_secret_key_configured"`
|
TurnstileSecretKeyConfigured bool `json:"turnstile_secret_key_configured"`
|
||||||
|
|
||||||
|
LinuxDoConnectEnabled bool `json:"linuxdo_connect_enabled"`
|
||||||
|
LinuxDoConnectClientID string `json:"linuxdo_connect_client_id"`
|
||||||
|
LinuxDoConnectClientSecretConfigured bool `json:"linuxdo_connect_client_secret_configured"`
|
||||||
|
LinuxDoConnectRedirectURL string `json:"linuxdo_connect_redirect_url"`
|
||||||
|
|
||||||
SiteName string `json:"site_name"`
|
SiteName string `json:"site_name"`
|
||||||
SiteLogo string `json:"site_logo"`
|
SiteLogo string `json:"site_logo"`
|
||||||
SiteSubtitle string `json:"site_subtitle"`
|
SiteSubtitle string `json:"site_subtitle"`
|
||||||
|
|||||||
@@ -304,6 +304,10 @@ func TestAPIContracts(t *testing.T) {
|
|||||||
"turnstile_enabled": true,
|
"turnstile_enabled": true,
|
||||||
"turnstile_site_key": "site-key",
|
"turnstile_site_key": "site-key",
|
||||||
"turnstile_secret_key_configured": true,
|
"turnstile_secret_key_configured": true,
|
||||||
|
"linuxdo_connect_enabled": false,
|
||||||
|
"linuxdo_connect_client_id": "",
|
||||||
|
"linuxdo_connect_client_secret_configured": false,
|
||||||
|
"linuxdo_connect_redirect_url": "",
|
||||||
"site_name": "Sub2API",
|
"site_name": "Sub2API",
|
||||||
"site_logo": "",
|
"site_logo": "",
|
||||||
"site_subtitle": "Subtitle",
|
"site_subtitle": "Subtitle",
|
||||||
@@ -390,7 +394,7 @@ func newContractDeps(t *testing.T) *contractDeps {
|
|||||||
settingRepo := newStubSettingRepo()
|
settingRepo := newStubSettingRepo()
|
||||||
settingService := service.NewSettingService(settingRepo, cfg)
|
settingService := service.NewSettingService(settingRepo, cfg)
|
||||||
|
|
||||||
authHandler := handler.NewAuthHandler(cfg, nil, userService)
|
authHandler := handler.NewAuthHandler(cfg, nil, userService, settingService)
|
||||||
apiKeyHandler := handler.NewAPIKeyHandler(apiKeyService)
|
apiKeyHandler := handler.NewAPIKeyHandler(apiKeyService)
|
||||||
usageHandler := handler.NewUsageHandler(usageService, apiKeyService)
|
usageHandler := handler.NewUsageHandler(usageService, apiKeyService)
|
||||||
adminSettingHandler := adminhandler.NewSettingHandler(settingService, nil, nil)
|
adminSettingHandler := adminhandler.NewSettingHandler(settingService, nil, nil)
|
||||||
|
|||||||
@@ -105,6 +105,12 @@ const (
|
|||||||
// Request identity patch (Claude -> Gemini systemInstruction injection)
|
// Request identity patch (Claude -> Gemini systemInstruction injection)
|
||||||
SettingKeyEnableIdentityPatch = "enable_identity_patch"
|
SettingKeyEnableIdentityPatch = "enable_identity_patch"
|
||||||
SettingKeyIdentityPatchPrompt = "identity_patch_prompt"
|
SettingKeyIdentityPatchPrompt = "identity_patch_prompt"
|
||||||
|
|
||||||
|
// LinuxDo Connect OAuth login (end-user SSO)
|
||||||
|
SettingKeyLinuxDoConnectEnabled = "linuxdo_connect_enabled"
|
||||||
|
SettingKeyLinuxDoConnectClientID = "linuxdo_connect_client_id"
|
||||||
|
SettingKeyLinuxDoConnectClientSecret = "linuxdo_connect_client_secret"
|
||||||
|
SettingKeyLinuxDoConnectRedirectURL = "linuxdo_connect_redirect_url"
|
||||||
)
|
)
|
||||||
|
|
||||||
// AdminAPIKeyPrefix is the prefix for admin API keys (distinct from user "sk-" keys).
|
// AdminAPIKeyPrefix is the prefix for admin API keys (distinct from user "sk-" keys).
|
||||||
|
|||||||
@@ -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
|
|||||||
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
|
|||||||
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,7 +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: s.cfg != nil && s.cfg.LinuxDo.Enabled,
|
LinuxDoOAuthEnabled: linuxDoEnabled,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,6 +121,14 @@ func (s *SettingService) UpdateSettings(ctx context.Context, settings *SystemSet
|
|||||||
updates[SettingKeyTurnstileSecretKey] = settings.TurnstileSecretKey
|
updates[SettingKeyTurnstileSecretKey] = settings.TurnstileSecretKey
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// LinuxDo Connect OAuth login (end-user 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
|
||||||
@@ -272,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 settings:
|
||||||
|
// - Backward compatible with config.yaml/env (so existing deployments don't get disabled by accident)
|
||||||
|
// - Can be overridden and persisted via admin "system settings" (stored in 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")
|
||||||
@@ -290,6 +339,83 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetLinuxDoConnectOAuthConfig returns the effective LinuxDo Connect config for login.
|
||||||
|
//
|
||||||
|
// Precedence:
|
||||||
|
// - If a corresponding system setting key exists, it overrides config.yaml/env values.
|
||||||
|
// - Otherwise, it falls back to config.yaml/env values.
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Best-effort sanity check (avoid redirecting users into a broken OAuth flow).
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
|||||||
TurnstileSecretKey string
|
TurnstileSecretKey string
|
||||||
TurnstileSecretKeyConfigured bool
|
TurnstileSecretKeyConfigured bool
|
||||||
|
|
||||||
|
// LinuxDo Connect OAuth login (end-user SSO)
|
||||||
|
LinuxDoConnectEnabled bool
|
||||||
|
LinuxDoConnectClientID string
|
||||||
|
LinuxDoConnectClientSecret string
|
||||||
|
LinuxDoConnectClientSecretConfigured bool
|
||||||
|
LinuxDoConnectRedirectURL string
|
||||||
|
|
||||||
SiteName string
|
SiteName string
|
||||||
SiteLogo string
|
SiteLogo string
|
||||||
SiteSubtitle string
|
SiteSubtitle string
|
||||||
|
|||||||
@@ -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 login (end-user 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 {
|
|||||||
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1766,6 +1766,24 @@ 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)'
|
||||||
|
},
|
||||||
defaults: {
|
defaults: {
|
||||||
title: 'Default User Settings',
|
title: 'Default User Settings',
|
||||||
description: 'Default values for new users',
|
description: 'Default values for new users',
|
||||||
|
|||||||
@@ -1911,6 +1911,23 @@ export default {
|
|||||||
cloudflareDashboard: 'Cloudflare Dashboard',
|
cloudflareDashboard: 'Cloudflare Dashboard',
|
||||||
secretKeyHint: '服务端验证密钥(请保密)',
|
secretKeyHint: '服务端验证密钥(请保密)',
|
||||||
secretKeyConfiguredHint: '密钥已配置,留空以保留当前值。' },
|
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)'
|
||||||
|
},
|
||||||
defaults: {
|
defaults: {
|
||||||
title: '用户默认设置',
|
title: '用户默认设置',
|
||||||
description: '新用户的默认值',
|
description: '新用户的默认值',
|
||||||
|
|||||||
@@ -261,6 +261,91 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- LinuxDo Connect OAuth Login -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="border-b border-gray-100 px-6 py-4 dark:border-dark-700">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||||
|
{{ t('admin.settings.linuxdo.title') }}
|
||||||
|
</h2>
|
||||||
|
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{{ t('admin.settings.linuxdo.description') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-5 p-6">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<label class="font-medium text-gray-900 dark:text-white">{{
|
||||||
|
t('admin.settings.linuxdo.enable')
|
||||||
|
}}</label>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{{ t('admin.settings.linuxdo.enableHint') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Toggle v-model="form.linuxdo_connect_enabled" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="form.linuxdo_connect_enabled"
|
||||||
|
class="border-t border-gray-100 pt-4 dark:border-dark-700"
|
||||||
|
>
|
||||||
|
<div class="grid grid-cols-1 gap-6">
|
||||||
|
<div>
|
||||||
|
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
{{ t('admin.settings.linuxdo.clientId') }}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-model="form.linuxdo_connect_client_id"
|
||||||
|
type="text"
|
||||||
|
class="input font-mono text-sm"
|
||||||
|
:placeholder="t('admin.settings.linuxdo.clientIdPlaceholder')"
|
||||||
|
/>
|
||||||
|
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{{ t('admin.settings.linuxdo.clientIdHint') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
{{ t('admin.settings.linuxdo.clientSecret') }}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-model="form.linuxdo_connect_client_secret"
|
||||||
|
type="password"
|
||||||
|
class="input font-mono text-sm"
|
||||||
|
:placeholder="
|
||||||
|
form.linuxdo_connect_client_secret_configured
|
||||||
|
? t('admin.settings.linuxdo.clientSecretConfiguredPlaceholder')
|
||||||
|
: t('admin.settings.linuxdo.clientSecretPlaceholder')
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{{
|
||||||
|
form.linuxdo_connect_client_secret_configured
|
||||||
|
? t('admin.settings.linuxdo.clientSecretConfiguredHint')
|
||||||
|
: t('admin.settings.linuxdo.clientSecretHint')
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
{{ t('admin.settings.linuxdo.redirectUrl') }}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-model="form.linuxdo_connect_redirect_url"
|
||||||
|
type="url"
|
||||||
|
class="input font-mono text-sm"
|
||||||
|
:placeholder="t('admin.settings.linuxdo.redirectUrlPlaceholder')"
|
||||||
|
/>
|
||||||
|
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{{ t('admin.settings.linuxdo.redirectUrlHint') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Default Settings -->
|
<!-- Default Settings -->
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="border-b border-gray-100 px-6 py-4 dark:border-dark-700">
|
<div class="border-b border-gray-100 px-6 py-4 dark:border-dark-700">
|
||||||
@@ -721,6 +806,7 @@ const newAdminApiKey = ref('')
|
|||||||
type SettingsForm = SystemSettings & {
|
type SettingsForm = SystemSettings & {
|
||||||
smtp_password: string
|
smtp_password: string
|
||||||
turnstile_secret_key: string
|
turnstile_secret_key: string
|
||||||
|
linuxdo_connect_client_secret: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const form = reactive<SettingsForm>({
|
const form = reactive<SettingsForm>({
|
||||||
@@ -747,6 +833,12 @@ const form = reactive<SettingsForm>({
|
|||||||
turnstile_site_key: '',
|
turnstile_site_key: '',
|
||||||
turnstile_secret_key: '',
|
turnstile_secret_key: '',
|
||||||
turnstile_secret_key_configured: false,
|
turnstile_secret_key_configured: false,
|
||||||
|
// LinuxDo Connect OAuth
|
||||||
|
linuxdo_connect_enabled: false,
|
||||||
|
linuxdo_connect_client_id: '',
|
||||||
|
linuxdo_connect_client_secret: '',
|
||||||
|
linuxdo_connect_client_secret_configured: false,
|
||||||
|
linuxdo_connect_redirect_url: '',
|
||||||
// Identity patch (Claude -> Gemini)
|
// Identity patch (Claude -> Gemini)
|
||||||
enable_identity_patch: true,
|
enable_identity_patch: true,
|
||||||
identity_patch_prompt: ''
|
identity_patch_prompt: ''
|
||||||
@@ -797,6 +889,7 @@ async function loadSettings() {
|
|||||||
Object.assign(form, settings)
|
Object.assign(form, settings)
|
||||||
form.smtp_password = ''
|
form.smtp_password = ''
|
||||||
form.turnstile_secret_key = ''
|
form.turnstile_secret_key = ''
|
||||||
|
form.linuxdo_connect_client_secret = ''
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
appStore.showError(
|
appStore.showError(
|
||||||
t('admin.settings.failedToLoad') + ': ' + (error.message || t('common.unknownError'))
|
t('admin.settings.failedToLoad') + ': ' + (error.message || t('common.unknownError'))
|
||||||
@@ -829,12 +922,17 @@ async function saveSettings() {
|
|||||||
smtp_use_tls: form.smtp_use_tls,
|
smtp_use_tls: form.smtp_use_tls,
|
||||||
turnstile_enabled: form.turnstile_enabled,
|
turnstile_enabled: form.turnstile_enabled,
|
||||||
turnstile_site_key: form.turnstile_site_key,
|
turnstile_site_key: form.turnstile_site_key,
|
||||||
turnstile_secret_key: form.turnstile_secret_key || undefined
|
turnstile_secret_key: form.turnstile_secret_key || undefined,
|
||||||
|
linuxdo_connect_enabled: form.linuxdo_connect_enabled,
|
||||||
|
linuxdo_connect_client_id: form.linuxdo_connect_client_id,
|
||||||
|
linuxdo_connect_client_secret: form.linuxdo_connect_client_secret || undefined,
|
||||||
|
linuxdo_connect_redirect_url: form.linuxdo_connect_redirect_url
|
||||||
}
|
}
|
||||||
const updated = await adminAPI.settings.updateSettings(payload)
|
const updated = await adminAPI.settings.updateSettings(payload)
|
||||||
Object.assign(form, updated)
|
Object.assign(form, updated)
|
||||||
form.smtp_password = ''
|
form.smtp_password = ''
|
||||||
form.turnstile_secret_key = ''
|
form.turnstile_secret_key = ''
|
||||||
|
form.linuxdo_connect_client_secret = ''
|
||||||
// Refresh cached public settings so sidebar/header update immediately
|
// Refresh cached public settings so sidebar/header update immediately
|
||||||
await appStore.fetchPublicSettings(true)
|
await appStore.fetchPublicSettings(true)
|
||||||
appStore.showSuccess(t('admin.settings.settingsSaved'))
|
appStore.showSuccess(t('admin.settings.settingsSaved'))
|
||||||
|
|||||||
Reference in New Issue
Block a user