mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-03-30 02:25:00 +00:00
imporve oauth provider UI/UX (#2983)
* feat: imporve UI/UX * fix: stabilize provider enabled toggle and polish custom OAuth settings UX * fix: add access policy/message templates and persist advanced fields reliably * fix: move template fill actions below fields and keep advanced form flow cleaner
This commit is contained in:
@@ -1,8 +1,13 @@
|
|||||||
package controller
|
package controller
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/QuantumNous/new-api/common"
|
"github.com/QuantumNous/new-api/common"
|
||||||
"github.com/QuantumNous/new-api/model"
|
"github.com/QuantumNous/new-api/model"
|
||||||
@@ -16,6 +21,7 @@ type CustomOAuthProviderResponse struct {
|
|||||||
Id int `json:"id"`
|
Id int `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Slug string `json:"slug"`
|
Slug string `json:"slug"`
|
||||||
|
Icon string `json:"icon"`
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
ClientId string `json:"client_id"`
|
ClientId string `json:"client_id"`
|
||||||
AuthorizationEndpoint string `json:"authorization_endpoint"`
|
AuthorizationEndpoint string `json:"authorization_endpoint"`
|
||||||
@@ -28,6 +34,8 @@ type CustomOAuthProviderResponse struct {
|
|||||||
EmailField string `json:"email_field"`
|
EmailField string `json:"email_field"`
|
||||||
WellKnown string `json:"well_known"`
|
WellKnown string `json:"well_known"`
|
||||||
AuthStyle int `json:"auth_style"`
|
AuthStyle int `json:"auth_style"`
|
||||||
|
AccessPolicy string `json:"access_policy"`
|
||||||
|
AccessDeniedMessage string `json:"access_denied_message"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func toCustomOAuthProviderResponse(p *model.CustomOAuthProvider) *CustomOAuthProviderResponse {
|
func toCustomOAuthProviderResponse(p *model.CustomOAuthProvider) *CustomOAuthProviderResponse {
|
||||||
@@ -35,6 +43,7 @@ func toCustomOAuthProviderResponse(p *model.CustomOAuthProvider) *CustomOAuthPro
|
|||||||
Id: p.Id,
|
Id: p.Id,
|
||||||
Name: p.Name,
|
Name: p.Name,
|
||||||
Slug: p.Slug,
|
Slug: p.Slug,
|
||||||
|
Icon: p.Icon,
|
||||||
Enabled: p.Enabled,
|
Enabled: p.Enabled,
|
||||||
ClientId: p.ClientId,
|
ClientId: p.ClientId,
|
||||||
AuthorizationEndpoint: p.AuthorizationEndpoint,
|
AuthorizationEndpoint: p.AuthorizationEndpoint,
|
||||||
@@ -47,6 +56,8 @@ func toCustomOAuthProviderResponse(p *model.CustomOAuthProvider) *CustomOAuthPro
|
|||||||
EmailField: p.EmailField,
|
EmailField: p.EmailField,
|
||||||
WellKnown: p.WellKnown,
|
WellKnown: p.WellKnown,
|
||||||
AuthStyle: p.AuthStyle,
|
AuthStyle: p.AuthStyle,
|
||||||
|
AccessPolicy: p.AccessPolicy,
|
||||||
|
AccessDeniedMessage: p.AccessDeniedMessage,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,6 +107,7 @@ func GetCustomOAuthProvider(c *gin.Context) {
|
|||||||
type CreateCustomOAuthProviderRequest struct {
|
type CreateCustomOAuthProviderRequest struct {
|
||||||
Name string `json:"name" binding:"required"`
|
Name string `json:"name" binding:"required"`
|
||||||
Slug string `json:"slug" binding:"required"`
|
Slug string `json:"slug" binding:"required"`
|
||||||
|
Icon string `json:"icon"`
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
ClientId string `json:"client_id" binding:"required"`
|
ClientId string `json:"client_id" binding:"required"`
|
||||||
ClientSecret string `json:"client_secret" binding:"required"`
|
ClientSecret string `json:"client_secret" binding:"required"`
|
||||||
@@ -109,6 +121,85 @@ type CreateCustomOAuthProviderRequest struct {
|
|||||||
EmailField string `json:"email_field"`
|
EmailField string `json:"email_field"`
|
||||||
WellKnown string `json:"well_known"`
|
WellKnown string `json:"well_known"`
|
||||||
AuthStyle int `json:"auth_style"`
|
AuthStyle int `json:"auth_style"`
|
||||||
|
AccessPolicy string `json:"access_policy"`
|
||||||
|
AccessDeniedMessage string `json:"access_denied_message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type FetchCustomOAuthDiscoveryRequest struct {
|
||||||
|
WellKnownURL string `json:"well_known_url"`
|
||||||
|
IssuerURL string `json:"issuer_url"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// FetchCustomOAuthDiscovery fetches OIDC discovery document via backend (root-only route)
|
||||||
|
func FetchCustomOAuthDiscovery(c *gin.Context) {
|
||||||
|
var req FetchCustomOAuthDiscoveryRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
common.ApiErrorMsg(c, "无效的请求参数: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
wellKnownURL := strings.TrimSpace(req.WellKnownURL)
|
||||||
|
issuerURL := strings.TrimSpace(req.IssuerURL)
|
||||||
|
|
||||||
|
if wellKnownURL == "" && issuerURL == "" {
|
||||||
|
common.ApiErrorMsg(c, "请先填写 Discovery URL 或 Issuer URL")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
targetURL := wellKnownURL
|
||||||
|
if targetURL == "" {
|
||||||
|
targetURL = strings.TrimRight(issuerURL, "/") + "/.well-known/openid-configuration"
|
||||||
|
}
|
||||||
|
targetURL = strings.TrimSpace(targetURL)
|
||||||
|
|
||||||
|
parsedURL, err := url.Parse(targetURL)
|
||||||
|
if err != nil || parsedURL.Host == "" || (parsedURL.Scheme != "http" && parsedURL.Scheme != "https") {
|
||||||
|
common.ApiErrorMsg(c, "Discovery URL 无效,仅支持 http/https")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(c.Request.Context(), 20*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, targetURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
common.ApiErrorMsg(c, "创建 Discovery 请求失败: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
httpReq.Header.Set("Accept", "application/json")
|
||||||
|
|
||||||
|
client := &http.Client{Timeout: 20 * time.Second}
|
||||||
|
resp, err := client.Do(httpReq)
|
||||||
|
if err != nil {
|
||||||
|
common.ApiErrorMsg(c, "获取 Discovery 配置失败: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
|
||||||
|
message := strings.TrimSpace(string(body))
|
||||||
|
if message == "" {
|
||||||
|
message = resp.Status
|
||||||
|
}
|
||||||
|
common.ApiErrorMsg(c, "获取 Discovery 配置失败: "+message)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var discovery map[string]any
|
||||||
|
if err = common.DecodeJson(resp.Body, &discovery); err != nil {
|
||||||
|
common.ApiErrorMsg(c, "解析 Discovery 配置失败: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": true,
|
||||||
|
"message": "",
|
||||||
|
"data": gin.H{
|
||||||
|
"well_known_url": targetURL,
|
||||||
|
"discovery": discovery,
|
||||||
|
},
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateCustomOAuthProvider creates a new custom OAuth provider
|
// CreateCustomOAuthProvider creates a new custom OAuth provider
|
||||||
@@ -134,6 +225,7 @@ func CreateCustomOAuthProvider(c *gin.Context) {
|
|||||||
provider := &model.CustomOAuthProvider{
|
provider := &model.CustomOAuthProvider{
|
||||||
Name: req.Name,
|
Name: req.Name,
|
||||||
Slug: req.Slug,
|
Slug: req.Slug,
|
||||||
|
Icon: req.Icon,
|
||||||
Enabled: req.Enabled,
|
Enabled: req.Enabled,
|
||||||
ClientId: req.ClientId,
|
ClientId: req.ClientId,
|
||||||
ClientSecret: req.ClientSecret,
|
ClientSecret: req.ClientSecret,
|
||||||
@@ -147,6 +239,8 @@ func CreateCustomOAuthProvider(c *gin.Context) {
|
|||||||
EmailField: req.EmailField,
|
EmailField: req.EmailField,
|
||||||
WellKnown: req.WellKnown,
|
WellKnown: req.WellKnown,
|
||||||
AuthStyle: req.AuthStyle,
|
AuthStyle: req.AuthStyle,
|
||||||
|
AccessPolicy: req.AccessPolicy,
|
||||||
|
AccessDeniedMessage: req.AccessDeniedMessage,
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := model.CreateCustomOAuthProvider(provider); err != nil {
|
if err := model.CreateCustomOAuthProvider(provider); err != nil {
|
||||||
@@ -168,9 +262,10 @@ func CreateCustomOAuthProvider(c *gin.Context) {
|
|||||||
type UpdateCustomOAuthProviderRequest struct {
|
type UpdateCustomOAuthProviderRequest struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Slug string `json:"slug"`
|
Slug string `json:"slug"`
|
||||||
Enabled *bool `json:"enabled"` // Optional: if nil, keep existing
|
Icon *string `json:"icon"` // Optional: if nil, keep existing
|
||||||
|
Enabled *bool `json:"enabled"` // Optional: if nil, keep existing
|
||||||
ClientId string `json:"client_id"`
|
ClientId string `json:"client_id"`
|
||||||
ClientSecret string `json:"client_secret"` // Optional: if empty, keep existing
|
ClientSecret string `json:"client_secret"` // Optional: if empty, keep existing
|
||||||
AuthorizationEndpoint string `json:"authorization_endpoint"`
|
AuthorizationEndpoint string `json:"authorization_endpoint"`
|
||||||
TokenEndpoint string `json:"token_endpoint"`
|
TokenEndpoint string `json:"token_endpoint"`
|
||||||
UserInfoEndpoint string `json:"user_info_endpoint"`
|
UserInfoEndpoint string `json:"user_info_endpoint"`
|
||||||
@@ -181,6 +276,8 @@ type UpdateCustomOAuthProviderRequest struct {
|
|||||||
EmailField string `json:"email_field"`
|
EmailField string `json:"email_field"`
|
||||||
WellKnown *string `json:"well_known"` // Optional: if nil, keep existing
|
WellKnown *string `json:"well_known"` // Optional: if nil, keep existing
|
||||||
AuthStyle *int `json:"auth_style"` // Optional: if nil, keep existing
|
AuthStyle *int `json:"auth_style"` // Optional: if nil, keep existing
|
||||||
|
AccessPolicy *string `json:"access_policy"` // Optional: if nil, keep existing
|
||||||
|
AccessDeniedMessage *string `json:"access_denied_message"` // Optional: if nil, keep existing
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateCustomOAuthProvider updates an existing custom OAuth provider
|
// UpdateCustomOAuthProvider updates an existing custom OAuth provider
|
||||||
@@ -227,6 +324,9 @@ func UpdateCustomOAuthProvider(c *gin.Context) {
|
|||||||
if req.Slug != "" {
|
if req.Slug != "" {
|
||||||
provider.Slug = req.Slug
|
provider.Slug = req.Slug
|
||||||
}
|
}
|
||||||
|
if req.Icon != nil {
|
||||||
|
provider.Icon = *req.Icon
|
||||||
|
}
|
||||||
if req.Enabled != nil {
|
if req.Enabled != nil {
|
||||||
provider.Enabled = *req.Enabled
|
provider.Enabled = *req.Enabled
|
||||||
}
|
}
|
||||||
@@ -266,6 +366,12 @@ func UpdateCustomOAuthProvider(c *gin.Context) {
|
|||||||
if req.AuthStyle != nil {
|
if req.AuthStyle != nil {
|
||||||
provider.AuthStyle = *req.AuthStyle
|
provider.AuthStyle = *req.AuthStyle
|
||||||
}
|
}
|
||||||
|
if req.AccessPolicy != nil {
|
||||||
|
provider.AccessPolicy = *req.AccessPolicy
|
||||||
|
}
|
||||||
|
if req.AccessDeniedMessage != nil {
|
||||||
|
provider.AccessDeniedMessage = *req.AccessDeniedMessage
|
||||||
|
}
|
||||||
|
|
||||||
if err := model.UpdateCustomOAuthProvider(provider); err != nil {
|
if err := model.UpdateCustomOAuthProvider(provider); err != nil {
|
||||||
common.ApiError(c, err)
|
common.ApiError(c, err)
|
||||||
@@ -346,6 +452,7 @@ func GetUserOAuthBindings(c *gin.Context) {
|
|||||||
ProviderId int `json:"provider_id"`
|
ProviderId int `json:"provider_id"`
|
||||||
ProviderName string `json:"provider_name"`
|
ProviderName string `json:"provider_name"`
|
||||||
ProviderSlug string `json:"provider_slug"`
|
ProviderSlug string `json:"provider_slug"`
|
||||||
|
ProviderIcon string `json:"provider_icon"`
|
||||||
ProviderUserId string `json:"provider_user_id"`
|
ProviderUserId string `json:"provider_user_id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -359,6 +466,7 @@ func GetUserOAuthBindings(c *gin.Context) {
|
|||||||
ProviderId: binding.ProviderId,
|
ProviderId: binding.ProviderId,
|
||||||
ProviderName: provider.Name,
|
ProviderName: provider.Name,
|
||||||
ProviderSlug: provider.Slug,
|
ProviderSlug: provider.Slug,
|
||||||
|
ProviderIcon: provider.Icon,
|
||||||
ProviderUserId: binding.ProviderUserId,
|
ProviderUserId: binding.ProviderUserId,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -134,8 +134,10 @@ func GetStatus(c *gin.Context) {
|
|||||||
customProviders := oauth.GetEnabledCustomProviders()
|
customProviders := oauth.GetEnabledCustomProviders()
|
||||||
if len(customProviders) > 0 {
|
if len(customProviders) > 0 {
|
||||||
type CustomOAuthInfo struct {
|
type CustomOAuthInfo struct {
|
||||||
|
Id int `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Slug string `json:"slug"`
|
Slug string `json:"slug"`
|
||||||
|
Icon string `json:"icon"`
|
||||||
ClientId string `json:"client_id"`
|
ClientId string `json:"client_id"`
|
||||||
AuthorizationEndpoint string `json:"authorization_endpoint"`
|
AuthorizationEndpoint string `json:"authorization_endpoint"`
|
||||||
Scopes string `json:"scopes"`
|
Scopes string `json:"scopes"`
|
||||||
@@ -144,8 +146,10 @@ func GetStatus(c *gin.Context) {
|
|||||||
for _, p := range customProviders {
|
for _, p := range customProviders {
|
||||||
config := p.GetConfig()
|
config := p.GetConfig()
|
||||||
providersInfo = append(providersInfo, CustomOAuthInfo{
|
providersInfo = append(providersInfo, CustomOAuthInfo{
|
||||||
|
Id: config.Id,
|
||||||
Name: config.Name,
|
Name: config.Name,
|
||||||
Slug: config.Slug,
|
Slug: config.Slug,
|
||||||
|
Icon: config.Icon,
|
||||||
ClientId: config.ClientId,
|
ClientId: config.ClientId,
|
||||||
AuthorizationEndpoint: config.AuthorizationEndpoint,
|
AuthorizationEndpoint: config.AuthorizationEndpoint,
|
||||||
Scopes: config.Scopes,
|
Scopes: config.Scopes,
|
||||||
|
|||||||
@@ -295,12 +295,12 @@ func findOrCreateOAuthUser(c *gin.Context, provider oauth.Provider, oauthUser *o
|
|||||||
// Set the provider user ID on the user model and update
|
// Set the provider user ID on the user model and update
|
||||||
provider.SetProviderUserID(user, oauthUser.ProviderUserID)
|
provider.SetProviderUserID(user, oauthUser.ProviderUserID)
|
||||||
if err := tx.Model(user).Updates(map[string]interface{}{
|
if err := tx.Model(user).Updates(map[string]interface{}{
|
||||||
"github_id": user.GitHubId,
|
"github_id": user.GitHubId,
|
||||||
"discord_id": user.DiscordId,
|
"discord_id": user.DiscordId,
|
||||||
"oidc_id": user.OidcId,
|
"oidc_id": user.OidcId,
|
||||||
"linux_do_id": user.LinuxDOId,
|
"linux_do_id": user.LinuxDOId,
|
||||||
"wechat_id": user.WeChatId,
|
"wechat_id": user.WeChatId,
|
||||||
"telegram_id": user.TelegramId,
|
"telegram_id": user.TelegramId,
|
||||||
}).Error; err != nil {
|
}).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -340,6 +340,8 @@ func handleOAuthError(c *gin.Context, err error) {
|
|||||||
} else {
|
} else {
|
||||||
common.ApiErrorI18n(c, e.MsgKey)
|
common.ApiErrorI18n(c, e.MsgKey)
|
||||||
}
|
}
|
||||||
|
case *oauth.AccessDeniedError:
|
||||||
|
common.ApiErrorMsg(c, e.Message)
|
||||||
case *oauth.TrustLevelError:
|
case *oauth.TrustLevelError:
|
||||||
common.ApiErrorI18n(c, i18n.MsgOAuthTrustLevelLow)
|
common.ApiErrorI18n(c, i18n.MsgOAuthTrustLevelLow)
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -2,32 +2,65 @@ package model
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/QuantumNous/new-api/common"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type accessPolicyPayload struct {
|
||||||
|
Logic string `json:"logic"`
|
||||||
|
Conditions []accessConditionItem `json:"conditions"`
|
||||||
|
Groups []accessPolicyPayload `json:"groups"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type accessConditionItem struct {
|
||||||
|
Field string `json:"field"`
|
||||||
|
Op string `json:"op"`
|
||||||
|
Value any `json:"value"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var supportedAccessPolicyOps = map[string]struct{}{
|
||||||
|
"eq": {},
|
||||||
|
"ne": {},
|
||||||
|
"gt": {},
|
||||||
|
"gte": {},
|
||||||
|
"lt": {},
|
||||||
|
"lte": {},
|
||||||
|
"in": {},
|
||||||
|
"not_in": {},
|
||||||
|
"contains": {},
|
||||||
|
"not_contains": {},
|
||||||
|
"exists": {},
|
||||||
|
"not_exists": {},
|
||||||
|
}
|
||||||
|
|
||||||
// CustomOAuthProvider stores configuration for custom OAuth providers
|
// CustomOAuthProvider stores configuration for custom OAuth providers
|
||||||
type CustomOAuthProvider struct {
|
type CustomOAuthProvider struct {
|
||||||
Id int `json:"id" gorm:"primaryKey"`
|
Id int `json:"id" gorm:"primaryKey"`
|
||||||
Name string `json:"name" gorm:"type:varchar(64);not null"` // Display name, e.g., "GitHub Enterprise"
|
Name string `json:"name" gorm:"type:varchar(64);not null"` // Display name, e.g., "GitHub Enterprise"
|
||||||
Slug string `json:"slug" gorm:"type:varchar(64);uniqueIndex;not null"` // URL identifier, e.g., "github-enterprise"
|
Slug string `json:"slug" gorm:"type:varchar(64);uniqueIndex;not null"` // URL identifier, e.g., "github-enterprise"
|
||||||
Enabled bool `json:"enabled" gorm:"default:false"` // Whether this provider is enabled
|
Icon string `json:"icon" gorm:"type:varchar(128);default:''"` // Icon name from @lobehub/icons
|
||||||
ClientId string `json:"client_id" gorm:"type:varchar(256)"` // OAuth client ID
|
Enabled bool `json:"enabled" gorm:"default:false"` // Whether this provider is enabled
|
||||||
ClientSecret string `json:"-" gorm:"type:varchar(512)"` // OAuth client secret (not returned to frontend)
|
ClientId string `json:"client_id" gorm:"type:varchar(256)"` // OAuth client ID
|
||||||
AuthorizationEndpoint string `json:"authorization_endpoint" gorm:"type:varchar(512)"` // Authorization URL
|
ClientSecret string `json:"-" gorm:"type:varchar(512)"` // OAuth client secret (not returned to frontend)
|
||||||
TokenEndpoint string `json:"token_endpoint" gorm:"type:varchar(512)"` // Token exchange URL
|
AuthorizationEndpoint string `json:"authorization_endpoint" gorm:"type:varchar(512)"` // Authorization URL
|
||||||
UserInfoEndpoint string `json:"user_info_endpoint" gorm:"type:varchar(512)"` // User info URL
|
TokenEndpoint string `json:"token_endpoint" gorm:"type:varchar(512)"` // Token exchange URL
|
||||||
Scopes string `json:"scopes" gorm:"type:varchar(256);default:'openid profile email'"` // OAuth scopes
|
UserInfoEndpoint string `json:"user_info_endpoint" gorm:"type:varchar(512)"` // User info URL
|
||||||
|
Scopes string `json:"scopes" gorm:"type:varchar(256);default:'openid profile email'"` // OAuth scopes
|
||||||
|
|
||||||
// Field mapping configuration (supports JSONPath via gjson)
|
// Field mapping configuration (supports JSONPath via gjson)
|
||||||
UserIdField string `json:"user_id_field" gorm:"type:varchar(128);default:'sub'"` // User ID field path, e.g., "sub", "id", "data.user.id"
|
UserIdField string `json:"user_id_field" gorm:"type:varchar(128);default:'sub'"` // User ID field path, e.g., "sub", "id", "data.user.id"
|
||||||
UsernameField string `json:"username_field" gorm:"type:varchar(128);default:'preferred_username'"` // Username field path
|
UsernameField string `json:"username_field" gorm:"type:varchar(128);default:'preferred_username'"` // Username field path
|
||||||
DisplayNameField string `json:"display_name_field" gorm:"type:varchar(128);default:'name'"` // Display name field path
|
DisplayNameField string `json:"display_name_field" gorm:"type:varchar(128);default:'name'"` // Display name field path
|
||||||
EmailField string `json:"email_field" gorm:"type:varchar(128);default:'email'"` // Email field path
|
EmailField string `json:"email_field" gorm:"type:varchar(128);default:'email'"` // Email field path
|
||||||
|
|
||||||
// Advanced options
|
// Advanced options
|
||||||
WellKnown string `json:"well_known" gorm:"type:varchar(512)"` // OIDC discovery endpoint (optional)
|
WellKnown string `json:"well_known" gorm:"type:varchar(512)"` // OIDC discovery endpoint (optional)
|
||||||
AuthStyle int `json:"auth_style" gorm:"default:0"` // 0=auto, 1=params, 2=header (Basic Auth)
|
AuthStyle int `json:"auth_style" gorm:"default:0"` // 0=auto, 1=params, 2=header (Basic Auth)
|
||||||
|
AccessPolicy string `json:"access_policy" gorm:"type:text"` // JSON policy for access control based on user info
|
||||||
|
AccessDeniedMessage string `json:"access_denied_message" gorm:"type:varchar(512)"` // Custom error message template when access is denied
|
||||||
|
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
@@ -158,6 +191,57 @@ func validateCustomOAuthProvider(provider *CustomOAuthProvider) error {
|
|||||||
if provider.Scopes == "" {
|
if provider.Scopes == "" {
|
||||||
provider.Scopes = "openid profile email"
|
provider.Scopes = "openid profile email"
|
||||||
}
|
}
|
||||||
|
if strings.TrimSpace(provider.AccessPolicy) != "" {
|
||||||
|
var policy accessPolicyPayload
|
||||||
|
if err := common.UnmarshalJsonStr(provider.AccessPolicy, &policy); err != nil {
|
||||||
|
return errors.New("access_policy must be valid JSON")
|
||||||
|
}
|
||||||
|
if err := validateAccessPolicyPayload(&policy); err != nil {
|
||||||
|
return fmt.Errorf("access_policy is invalid: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateAccessPolicyPayload(policy *accessPolicyPayload) error {
|
||||||
|
if policy == nil {
|
||||||
|
return errors.New("policy is nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
logic := strings.ToLower(strings.TrimSpace(policy.Logic))
|
||||||
|
if logic == "" {
|
||||||
|
logic = "and"
|
||||||
|
}
|
||||||
|
if logic != "and" && logic != "or" {
|
||||||
|
return fmt.Errorf("unsupported logic: %s", logic)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(policy.Conditions) == 0 && len(policy.Groups) == 0 {
|
||||||
|
return errors.New("policy requires at least one condition or group")
|
||||||
|
}
|
||||||
|
|
||||||
|
for index, condition := range policy.Conditions {
|
||||||
|
field := strings.TrimSpace(condition.Field)
|
||||||
|
if field == "" {
|
||||||
|
return fmt.Errorf("condition[%d].field is required", index)
|
||||||
|
}
|
||||||
|
op := strings.ToLower(strings.TrimSpace(condition.Op))
|
||||||
|
if _, ok := supportedAccessPolicyOps[op]; !ok {
|
||||||
|
return fmt.Errorf("condition[%d].op is unsupported: %s", index, op)
|
||||||
|
}
|
||||||
|
if op == "in" || op == "not_in" {
|
||||||
|
if _, ok := condition.Value.([]any); !ok {
|
||||||
|
return fmt.Errorf("condition[%d].value must be an array for op %s", index, op)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for index := range policy.Groups {
|
||||||
|
if err := validateAccessPolicyPayload(&policy.Groups[index]); err != nil {
|
||||||
|
return fmt.Errorf("group[%d]: %w", index, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
404
oauth/generic.go
404
oauth/generic.go
@@ -3,19 +3,24 @@ package oauth
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
stdjson "encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/QuantumNous/new-api/common"
|
||||||
"github.com/QuantumNous/new-api/i18n"
|
"github.com/QuantumNous/new-api/i18n"
|
||||||
"github.com/QuantumNous/new-api/logger"
|
"github.com/QuantumNous/new-api/logger"
|
||||||
"github.com/QuantumNous/new-api/model"
|
"github.com/QuantumNous/new-api/model"
|
||||||
"github.com/QuantumNous/new-api/setting/system_setting"
|
"github.com/QuantumNous/new-api/setting/system_setting"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/samber/lo"
|
||||||
"github.com/tidwall/gjson"
|
"github.com/tidwall/gjson"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -31,6 +36,40 @@ type GenericOAuthProvider struct {
|
|||||||
config *model.CustomOAuthProvider
|
config *model.CustomOAuthProvider
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type accessPolicy struct {
|
||||||
|
Logic string `json:"logic"`
|
||||||
|
Conditions []accessCondition `json:"conditions"`
|
||||||
|
Groups []accessPolicy `json:"groups"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type accessCondition struct {
|
||||||
|
Field string `json:"field"`
|
||||||
|
Op string `json:"op"`
|
||||||
|
Value any `json:"value"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type accessPolicyFailure struct {
|
||||||
|
Field string
|
||||||
|
Op string
|
||||||
|
Expected any
|
||||||
|
Current any
|
||||||
|
}
|
||||||
|
|
||||||
|
var supportedAccessPolicyOps = []string{
|
||||||
|
"eq",
|
||||||
|
"ne",
|
||||||
|
"gt",
|
||||||
|
"gte",
|
||||||
|
"lt",
|
||||||
|
"lte",
|
||||||
|
"in",
|
||||||
|
"not_in",
|
||||||
|
"contains",
|
||||||
|
"not_contains",
|
||||||
|
"exists",
|
||||||
|
"not_exists",
|
||||||
|
}
|
||||||
|
|
||||||
// NewGenericOAuthProvider creates a new generic OAuth provider from config
|
// NewGenericOAuthProvider creates a new generic OAuth provider from config
|
||||||
func NewGenericOAuthProvider(config *model.CustomOAuthProvider) *GenericOAuthProvider {
|
func NewGenericOAuthProvider(config *model.CustomOAuthProvider) *GenericOAuthProvider {
|
||||||
return &GenericOAuthProvider{config: config}
|
return &GenericOAuthProvider{config: config}
|
||||||
@@ -125,7 +164,7 @@ func (p *GenericOAuthProvider) ExchangeToken(ctx context.Context, code string, c
|
|||||||
ErrorDesc string `json:"error_description"`
|
ErrorDesc string `json:"error_description"`
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := json.Unmarshal(body, &tokenResponse); err != nil {
|
if err := common.Unmarshal(body, &tokenResponse); err != nil {
|
||||||
// Try to parse as URL-encoded (some OAuth servers like GitHub return this format)
|
// Try to parse as URL-encoded (some OAuth servers like GitHub return this format)
|
||||||
parsedValues, parseErr := url.ParseQuery(bodyStr)
|
parsedValues, parseErr := url.ParseQuery(bodyStr)
|
||||||
if parseErr != nil {
|
if parseErr != nil {
|
||||||
@@ -227,11 +266,30 @@ func (p *GenericOAuthProvider) GetUserInfo(ctx context.Context, token *OAuthToke
|
|||||||
logger.LogDebug(ctx, "[OAuth-Generic-%s] GetUserInfo success: id=%s, username=%s, name=%s, email=%s",
|
logger.LogDebug(ctx, "[OAuth-Generic-%s] GetUserInfo success: id=%s, username=%s, name=%s, email=%s",
|
||||||
p.config.Slug, userId, username, displayName, email)
|
p.config.Slug, userId, username, displayName, email)
|
||||||
|
|
||||||
|
policyRaw := strings.TrimSpace(p.config.AccessPolicy)
|
||||||
|
if policyRaw != "" {
|
||||||
|
policy, err := parseAccessPolicy(policyRaw)
|
||||||
|
if err != nil {
|
||||||
|
logger.LogError(ctx, fmt.Sprintf("[OAuth-Generic-%s] invalid access policy: %s", p.config.Slug, err.Error()))
|
||||||
|
return nil, NewOAuthErrorWithRaw(i18n.MsgOAuthGetUserErr, nil, "invalid access policy configuration")
|
||||||
|
}
|
||||||
|
allowed, failure := evaluateAccessPolicy(bodyStr, policy)
|
||||||
|
if !allowed {
|
||||||
|
message := renderAccessDeniedMessage(p.config.AccessDeniedMessage, p.config.Name, bodyStr, failure)
|
||||||
|
logger.LogWarn(ctx, fmt.Sprintf("[OAuth-Generic-%s] access denied by policy: field=%s op=%s expected=%v current=%v",
|
||||||
|
p.config.Slug, failure.Field, failure.Op, failure.Expected, failure.Current))
|
||||||
|
return nil, &AccessDeniedError{Message: message}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return &OAuthUser{
|
return &OAuthUser{
|
||||||
ProviderUserID: userId,
|
ProviderUserID: userId,
|
||||||
Username: username,
|
Username: username,
|
||||||
DisplayName: displayName,
|
DisplayName: displayName,
|
||||||
Email: email,
|
Email: email,
|
||||||
|
Extra: map[string]any{
|
||||||
|
"provider": p.config.Slug,
|
||||||
|
},
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -266,3 +324,345 @@ func (p *GenericOAuthProvider) GetProviderId() int {
|
|||||||
func (p *GenericOAuthProvider) IsGenericProvider() bool {
|
func (p *GenericOAuthProvider) IsGenericProvider() bool {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func parseAccessPolicy(raw string) (*accessPolicy, error) {
|
||||||
|
var policy accessPolicy
|
||||||
|
if err := common.UnmarshalJsonStr(raw, &policy); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := validateAccessPolicy(&policy); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &policy, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateAccessPolicy(policy *accessPolicy) error {
|
||||||
|
if policy == nil {
|
||||||
|
return errors.New("policy is nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
logic := strings.ToLower(strings.TrimSpace(policy.Logic))
|
||||||
|
if logic == "" {
|
||||||
|
logic = "and"
|
||||||
|
}
|
||||||
|
if !lo.Contains([]string{"and", "or"}, logic) {
|
||||||
|
return fmt.Errorf("unsupported policy logic: %s", logic)
|
||||||
|
}
|
||||||
|
policy.Logic = logic
|
||||||
|
|
||||||
|
if len(policy.Conditions) == 0 && len(policy.Groups) == 0 {
|
||||||
|
return errors.New("policy requires at least one condition or group")
|
||||||
|
}
|
||||||
|
|
||||||
|
for index := range policy.Conditions {
|
||||||
|
if err := validateAccessCondition(&policy.Conditions[index], index); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for index := range policy.Groups {
|
||||||
|
if err := validateAccessPolicy(&policy.Groups[index]); err != nil {
|
||||||
|
return fmt.Errorf("invalid policy group[%d]: %w", index, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateAccessCondition(condition *accessCondition, index int) error {
|
||||||
|
if condition == nil {
|
||||||
|
return fmt.Errorf("condition[%d] is nil", index)
|
||||||
|
}
|
||||||
|
|
||||||
|
condition.Field = strings.TrimSpace(condition.Field)
|
||||||
|
if condition.Field == "" {
|
||||||
|
return fmt.Errorf("condition[%d].field is required", index)
|
||||||
|
}
|
||||||
|
|
||||||
|
condition.Op = normalizePolicyOp(condition.Op)
|
||||||
|
if !lo.Contains(supportedAccessPolicyOps, condition.Op) {
|
||||||
|
return fmt.Errorf("condition[%d].op is unsupported: %s", index, condition.Op)
|
||||||
|
}
|
||||||
|
|
||||||
|
if lo.Contains([]string{"in", "not_in"}, condition.Op) {
|
||||||
|
if _, ok := condition.Value.([]any); !ok {
|
||||||
|
return fmt.Errorf("condition[%d].value must be an array for op %s", index, condition.Op)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func evaluateAccessPolicy(body string, policy *accessPolicy) (bool, *accessPolicyFailure) {
|
||||||
|
if policy == nil {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
logic := strings.ToLower(strings.TrimSpace(policy.Logic))
|
||||||
|
if logic == "" {
|
||||||
|
logic = "and"
|
||||||
|
}
|
||||||
|
|
||||||
|
hasAny := len(policy.Conditions) > 0 || len(policy.Groups) > 0
|
||||||
|
if !hasAny {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if logic == "or" {
|
||||||
|
var firstFailure *accessPolicyFailure
|
||||||
|
for _, cond := range policy.Conditions {
|
||||||
|
ok, failure := evaluateAccessCondition(body, cond)
|
||||||
|
if ok {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
if firstFailure == nil {
|
||||||
|
firstFailure = failure
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, group := range policy.Groups {
|
||||||
|
ok, failure := evaluateAccessPolicy(body, &group)
|
||||||
|
if ok {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
if firstFailure == nil {
|
||||||
|
firstFailure = failure
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false, firstFailure
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, cond := range policy.Conditions {
|
||||||
|
ok, failure := evaluateAccessCondition(body, cond)
|
||||||
|
if !ok {
|
||||||
|
return false, failure
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, group := range policy.Groups {
|
||||||
|
ok, failure := evaluateAccessPolicy(body, &group)
|
||||||
|
if !ok {
|
||||||
|
return false, failure
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func evaluateAccessCondition(body string, cond accessCondition) (bool, *accessPolicyFailure) {
|
||||||
|
path := cond.Field
|
||||||
|
op := cond.Op
|
||||||
|
result := gjson.Get(body, path)
|
||||||
|
current := gjsonResultToValue(result)
|
||||||
|
failure := &accessPolicyFailure{
|
||||||
|
Field: path,
|
||||||
|
Op: op,
|
||||||
|
Expected: cond.Value,
|
||||||
|
Current: current,
|
||||||
|
}
|
||||||
|
|
||||||
|
switch op {
|
||||||
|
case "exists":
|
||||||
|
return result.Exists(), failure
|
||||||
|
case "not_exists":
|
||||||
|
return !result.Exists(), failure
|
||||||
|
case "eq":
|
||||||
|
return compareAny(current, cond.Value) == 0, failure
|
||||||
|
case "ne":
|
||||||
|
return compareAny(current, cond.Value) != 0, failure
|
||||||
|
case "gt":
|
||||||
|
return compareAny(current, cond.Value) > 0, failure
|
||||||
|
case "gte":
|
||||||
|
return compareAny(current, cond.Value) >= 0, failure
|
||||||
|
case "lt":
|
||||||
|
return compareAny(current, cond.Value) < 0, failure
|
||||||
|
case "lte":
|
||||||
|
return compareAny(current, cond.Value) <= 0, failure
|
||||||
|
case "in":
|
||||||
|
return valueInSlice(current, cond.Value), failure
|
||||||
|
case "not_in":
|
||||||
|
return !valueInSlice(current, cond.Value), failure
|
||||||
|
case "contains":
|
||||||
|
return containsValue(current, cond.Value), failure
|
||||||
|
case "not_contains":
|
||||||
|
return !containsValue(current, cond.Value), failure
|
||||||
|
default:
|
||||||
|
return false, failure
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizePolicyOp(op string) string {
|
||||||
|
return strings.ToLower(strings.TrimSpace(op))
|
||||||
|
}
|
||||||
|
|
||||||
|
func gjsonResultToValue(result gjson.Result) any {
|
||||||
|
if !result.Exists() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if result.IsArray() {
|
||||||
|
arr := result.Array()
|
||||||
|
values := make([]any, 0, len(arr))
|
||||||
|
for _, item := range arr {
|
||||||
|
values = append(values, gjsonResultToValue(item))
|
||||||
|
}
|
||||||
|
return values
|
||||||
|
}
|
||||||
|
switch result.Type {
|
||||||
|
case gjson.Null:
|
||||||
|
return nil
|
||||||
|
case gjson.True:
|
||||||
|
return true
|
||||||
|
case gjson.False:
|
||||||
|
return false
|
||||||
|
case gjson.Number:
|
||||||
|
return result.Num
|
||||||
|
case gjson.String:
|
||||||
|
return result.String()
|
||||||
|
case gjson.JSON:
|
||||||
|
var data any
|
||||||
|
if err := common.UnmarshalJsonStr(result.Raw, &data); err == nil {
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
return result.Raw
|
||||||
|
default:
|
||||||
|
return result.Value()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func compareAny(left any, right any) int {
|
||||||
|
if lf, ok := toFloat(left); ok {
|
||||||
|
if rf, ok2 := toFloat(right); ok2 {
|
||||||
|
switch {
|
||||||
|
case lf < rf:
|
||||||
|
return -1
|
||||||
|
case lf > rf:
|
||||||
|
return 1
|
||||||
|
default:
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ls := strings.TrimSpace(fmt.Sprint(left))
|
||||||
|
rs := strings.TrimSpace(fmt.Sprint(right))
|
||||||
|
switch {
|
||||||
|
case ls < rs:
|
||||||
|
return -1
|
||||||
|
case ls > rs:
|
||||||
|
return 1
|
||||||
|
default:
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func toFloat(v any) (float64, bool) {
|
||||||
|
switch value := v.(type) {
|
||||||
|
case float64:
|
||||||
|
return value, true
|
||||||
|
case float32:
|
||||||
|
return float64(value), true
|
||||||
|
case int:
|
||||||
|
return float64(value), true
|
||||||
|
case int8:
|
||||||
|
return float64(value), true
|
||||||
|
case int16:
|
||||||
|
return float64(value), true
|
||||||
|
case int32:
|
||||||
|
return float64(value), true
|
||||||
|
case int64:
|
||||||
|
return float64(value), true
|
||||||
|
case uint:
|
||||||
|
return float64(value), true
|
||||||
|
case uint8:
|
||||||
|
return float64(value), true
|
||||||
|
case uint16:
|
||||||
|
return float64(value), true
|
||||||
|
case uint32:
|
||||||
|
return float64(value), true
|
||||||
|
case uint64:
|
||||||
|
return float64(value), true
|
||||||
|
case stdjson.Number:
|
||||||
|
n, err := value.Float64()
|
||||||
|
if err == nil {
|
||||||
|
return n, true
|
||||||
|
}
|
||||||
|
case string:
|
||||||
|
n, err := strconv.ParseFloat(strings.TrimSpace(value), 64)
|
||||||
|
if err == nil {
|
||||||
|
return n, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func valueInSlice(current any, expected any) bool {
|
||||||
|
list, ok := expected.([]any)
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return lo.ContainsBy(list, func(item any) bool {
|
||||||
|
return compareAny(current, item) == 0
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsValue(current any, expected any) bool {
|
||||||
|
switch value := current.(type) {
|
||||||
|
case string:
|
||||||
|
target := strings.TrimSpace(fmt.Sprint(expected))
|
||||||
|
return strings.Contains(value, target)
|
||||||
|
case []any:
|
||||||
|
return lo.ContainsBy(value, func(item any) bool {
|
||||||
|
return compareAny(item, expected) == 0
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderAccessDeniedMessage(template string, providerName string, body string, failure *accessPolicyFailure) string {
|
||||||
|
defaultMessage := "Access denied: your account does not meet this provider's access requirements."
|
||||||
|
message := strings.TrimSpace(template)
|
||||||
|
if message == "" {
|
||||||
|
return defaultMessage
|
||||||
|
}
|
||||||
|
|
||||||
|
if failure == nil {
|
||||||
|
failure = &accessPolicyFailure{}
|
||||||
|
}
|
||||||
|
|
||||||
|
replacements := map[string]string{
|
||||||
|
"{{provider}}": providerName,
|
||||||
|
"{{field}}": failure.Field,
|
||||||
|
"{{op}}": failure.Op,
|
||||||
|
"{{required}}": fmt.Sprint(failure.Expected),
|
||||||
|
"{{current}}": fmt.Sprint(failure.Current),
|
||||||
|
}
|
||||||
|
|
||||||
|
for key, value := range replacements {
|
||||||
|
message = strings.ReplaceAll(message, key, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
currentPattern := regexp.MustCompile(`\{\{current\.([^}]+)\}\}`)
|
||||||
|
message = currentPattern.ReplaceAllStringFunc(message, func(token string) string {
|
||||||
|
match := currentPattern.FindStringSubmatch(token)
|
||||||
|
if len(match) != 2 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
path := strings.TrimSpace(match[1])
|
||||||
|
if path == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(gjson.Get(body, path).String())
|
||||||
|
})
|
||||||
|
|
||||||
|
requiredPattern := regexp.MustCompile(`\{\{required\.([^}]+)\}\}`)
|
||||||
|
message = requiredPattern.ReplaceAllStringFunc(message, func(token string) string {
|
||||||
|
match := requiredPattern.FindStringSubmatch(token)
|
||||||
|
if len(match) != 2 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
path := strings.TrimSpace(match[1])
|
||||||
|
if failure.Field == path {
|
||||||
|
return fmt.Sprint(failure.Expected)
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
})
|
||||||
|
|
||||||
|
return strings.TrimSpace(message)
|
||||||
|
}
|
||||||
|
|||||||
@@ -57,3 +57,12 @@ func NewOAuthErrorWithRaw(msgKey string, params map[string]any, rawError string)
|
|||||||
RawError: rawError,
|
RawError: rawError,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AccessDeniedError is a direct user-facing access denial message.
|
||||||
|
type AccessDeniedError struct {
|
||||||
|
Message string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *AccessDeniedError) Error() string {
|
||||||
|
return e.Message
|
||||||
|
}
|
||||||
|
|||||||
@@ -170,10 +170,11 @@ func SetApiRouter(router *gin.Engine) {
|
|||||||
optionRoute.POST("/migrate_console_setting", controller.MigrateConsoleSetting) // 用于迁移检测的旧键,下个版本会删除
|
optionRoute.POST("/migrate_console_setting", controller.MigrateConsoleSetting) // 用于迁移检测的旧键,下个版本会删除
|
||||||
}
|
}
|
||||||
|
|
||||||
// Custom OAuth provider management (admin only)
|
// Custom OAuth provider management (root only)
|
||||||
customOAuthRoute := apiRouter.Group("/custom-oauth-provider")
|
customOAuthRoute := apiRouter.Group("/custom-oauth-provider")
|
||||||
customOAuthRoute.Use(middleware.RootAuth())
|
customOAuthRoute.Use(middleware.RootAuth())
|
||||||
{
|
{
|
||||||
|
customOAuthRoute.POST("/discovery", controller.FetchCustomOAuthDiscovery)
|
||||||
customOAuthRoute.GET("/", controller.GetCustomOAuthProviders)
|
customOAuthRoute.GET("/", controller.GetCustomOAuthProviders)
|
||||||
customOAuthRoute.GET("/:id", controller.GetCustomOAuthProvider)
|
customOAuthRoute.GET("/:id", controller.GetCustomOAuthProvider)
|
||||||
customOAuthRoute.POST("/", controller.CreateCustomOAuthProvider)
|
customOAuthRoute.POST("/", controller.CreateCustomOAuthProvider)
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import {
|
|||||||
showSuccess,
|
showSuccess,
|
||||||
updateAPI,
|
updateAPI,
|
||||||
getSystemName,
|
getSystemName,
|
||||||
|
getOAuthProviderIcon,
|
||||||
setUserData,
|
setUserData,
|
||||||
onGitHubOAuthClicked,
|
onGitHubOAuthClicked,
|
||||||
onDiscordOAuthClicked,
|
onDiscordOAuthClicked,
|
||||||
@@ -130,6 +131,17 @@ const LoginForm = () => {
|
|||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
}, [statusState?.status]);
|
}, [statusState?.status]);
|
||||||
|
const hasCustomOAuthProviders =
|
||||||
|
(status.custom_oauth_providers || []).length > 0;
|
||||||
|
const hasOAuthLoginOptions = Boolean(
|
||||||
|
status.github_oauth ||
|
||||||
|
status.discord_oauth ||
|
||||||
|
status.oidc_enabled ||
|
||||||
|
status.wechat_login ||
|
||||||
|
status.linuxdo_oauth ||
|
||||||
|
status.telegram_oauth ||
|
||||||
|
hasCustomOAuthProviders,
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (status?.turnstile_check) {
|
if (status?.turnstile_check) {
|
||||||
@@ -598,7 +610,7 @@ const LoginForm = () => {
|
|||||||
theme='outline'
|
theme='outline'
|
||||||
className='w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors'
|
className='w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors'
|
||||||
type='tertiary'
|
type='tertiary'
|
||||||
icon={<IconLock size='large' />}
|
icon={getOAuthProviderIcon(provider.icon || '', 20)}
|
||||||
onClick={() => handleCustomOAuthClick(provider)}
|
onClick={() => handleCustomOAuthClick(provider)}
|
||||||
loading={customOAuthLoading[provider.slug]}
|
loading={customOAuthLoading[provider.slug]}
|
||||||
>
|
>
|
||||||
@@ -817,12 +829,7 @@ const LoginForm = () => {
|
|||||||
</div>
|
</div>
|
||||||
</Form>
|
</Form>
|
||||||
|
|
||||||
{(status.github_oauth ||
|
{hasOAuthLoginOptions && (
|
||||||
status.discord_oauth ||
|
|
||||||
status.oidc_enabled ||
|
|
||||||
status.wechat_login ||
|
|
||||||
status.linuxdo_oauth ||
|
|
||||||
status.telegram_oauth) && (
|
|
||||||
<>
|
<>
|
||||||
<Divider margin='12px' align='center'>
|
<Divider margin='12px' align='center'>
|
||||||
{t('或')}
|
{t('或')}
|
||||||
@@ -952,14 +959,7 @@ const LoginForm = () => {
|
|||||||
/>
|
/>
|
||||||
<div className='w-full max-w-sm mt-[60px]'>
|
<div className='w-full max-w-sm mt-[60px]'>
|
||||||
{showEmailLogin ||
|
{showEmailLogin ||
|
||||||
!(
|
!hasOAuthLoginOptions
|
||||||
status.github_oauth ||
|
|
||||||
status.discord_oauth ||
|
|
||||||
status.oidc_enabled ||
|
|
||||||
status.wechat_login ||
|
|
||||||
status.linuxdo_oauth ||
|
|
||||||
status.telegram_oauth
|
|
||||||
)
|
|
||||||
? renderEmailLoginForm()
|
? renderEmailLoginForm()
|
||||||
: renderOAuthOptions()}
|
: renderOAuthOptions()}
|
||||||
{renderWeChatLoginModal()}
|
{renderWeChatLoginModal()}
|
||||||
|
|||||||
@@ -27,8 +27,10 @@ import {
|
|||||||
showSuccess,
|
showSuccess,
|
||||||
updateAPI,
|
updateAPI,
|
||||||
getSystemName,
|
getSystemName,
|
||||||
|
getOAuthProviderIcon,
|
||||||
setUserData,
|
setUserData,
|
||||||
onDiscordOAuthClicked,
|
onDiscordOAuthClicked,
|
||||||
|
onCustomOAuthClicked,
|
||||||
} from '../../helpers';
|
} from '../../helpers';
|
||||||
import Turnstile from 'react-turnstile';
|
import Turnstile from 'react-turnstile';
|
||||||
import {
|
import {
|
||||||
@@ -98,6 +100,7 @@ const RegisterForm = () => {
|
|||||||
const [otherRegisterOptionsLoading, setOtherRegisterOptionsLoading] =
|
const [otherRegisterOptionsLoading, setOtherRegisterOptionsLoading] =
|
||||||
useState(false);
|
useState(false);
|
||||||
const [wechatCodeSubmitLoading, setWechatCodeSubmitLoading] = useState(false);
|
const [wechatCodeSubmitLoading, setWechatCodeSubmitLoading] = useState(false);
|
||||||
|
const [customOAuthLoading, setCustomOAuthLoading] = useState({});
|
||||||
const [disableButton, setDisableButton] = useState(false);
|
const [disableButton, setDisableButton] = useState(false);
|
||||||
const [countdown, setCountdown] = useState(30);
|
const [countdown, setCountdown] = useState(30);
|
||||||
const [agreedToTerms, setAgreedToTerms] = useState(false);
|
const [agreedToTerms, setAgreedToTerms] = useState(false);
|
||||||
@@ -126,6 +129,17 @@ const RegisterForm = () => {
|
|||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
}, [statusState?.status]);
|
}, [statusState?.status]);
|
||||||
|
const hasCustomOAuthProviders =
|
||||||
|
(status.custom_oauth_providers || []).length > 0;
|
||||||
|
const hasOAuthRegisterOptions = Boolean(
|
||||||
|
status.github_oauth ||
|
||||||
|
status.discord_oauth ||
|
||||||
|
status.oidc_enabled ||
|
||||||
|
status.wechat_login ||
|
||||||
|
status.linuxdo_oauth ||
|
||||||
|
status.telegram_oauth ||
|
||||||
|
hasCustomOAuthProviders,
|
||||||
|
);
|
||||||
|
|
||||||
const [showEmailVerification, setShowEmailVerification] = useState(false);
|
const [showEmailVerification, setShowEmailVerification] = useState(false);
|
||||||
|
|
||||||
@@ -319,6 +333,17 @@ const RegisterForm = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleCustomOAuthClick = (provider) => {
|
||||||
|
setCustomOAuthLoading((prev) => ({ ...prev, [provider.slug]: true }));
|
||||||
|
try {
|
||||||
|
onCustomOAuthClicked(provider, { shouldLogout: true });
|
||||||
|
} finally {
|
||||||
|
setTimeout(() => {
|
||||||
|
setCustomOAuthLoading((prev) => ({ ...prev, [provider.slug]: false }));
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleEmailRegisterClick = () => {
|
const handleEmailRegisterClick = () => {
|
||||||
setEmailRegisterLoading(true);
|
setEmailRegisterLoading(true);
|
||||||
setShowEmailRegister(true);
|
setShowEmailRegister(true);
|
||||||
@@ -469,6 +494,23 @@ const RegisterForm = () => {
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{status.custom_oauth_providers &&
|
||||||
|
status.custom_oauth_providers.map((provider) => (
|
||||||
|
<Button
|
||||||
|
key={provider.slug}
|
||||||
|
theme='outline'
|
||||||
|
className='w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors'
|
||||||
|
type='tertiary'
|
||||||
|
icon={getOAuthProviderIcon(provider.icon || '', 20)}
|
||||||
|
onClick={() => handleCustomOAuthClick(provider)}
|
||||||
|
loading={customOAuthLoading[provider.slug]}
|
||||||
|
>
|
||||||
|
<span className='ml-3'>
|
||||||
|
{t('使用 {{name}} 继续', { name: provider.name })}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
|
||||||
{status.telegram_oauth && (
|
{status.telegram_oauth && (
|
||||||
<div className='flex justify-center my-2'>
|
<div className='flex justify-center my-2'>
|
||||||
<TelegramLoginButton
|
<TelegramLoginButton
|
||||||
@@ -650,12 +692,7 @@ const RegisterForm = () => {
|
|||||||
</div>
|
</div>
|
||||||
</Form>
|
</Form>
|
||||||
|
|
||||||
{(status.github_oauth ||
|
{hasOAuthRegisterOptions && (
|
||||||
status.discord_oauth ||
|
|
||||||
status.oidc_enabled ||
|
|
||||||
status.wechat_login ||
|
|
||||||
status.linuxdo_oauth ||
|
|
||||||
status.telegram_oauth) && (
|
|
||||||
<>
|
<>
|
||||||
<Divider margin='12px' align='center'>
|
<Divider margin='12px' align='center'>
|
||||||
{t('或')}
|
{t('或')}
|
||||||
@@ -745,14 +782,7 @@ const RegisterForm = () => {
|
|||||||
/>
|
/>
|
||||||
<div className='w-full max-w-sm mt-[60px]'>
|
<div className='w-full max-w-sm mt-[60px]'>
|
||||||
{showEmailRegister ||
|
{showEmailRegister ||
|
||||||
!(
|
!hasOAuthRegisterOptions
|
||||||
status.github_oauth ||
|
|
||||||
status.discord_oauth ||
|
|
||||||
status.oidc_enabled ||
|
|
||||||
status.wechat_login ||
|
|
||||||
status.linuxdo_oauth ||
|
|
||||||
status.telegram_oauth
|
|
||||||
)
|
|
||||||
? renderEmailRegisterForm()
|
? renderEmailRegisterForm()
|
||||||
: renderOAuthOptions()}
|
: renderOAuthOptions()}
|
||||||
{renderWeChatLoginModal()}
|
{renderWeChatLoginModal()}
|
||||||
|
|||||||
@@ -27,14 +27,20 @@ import {
|
|||||||
Modal,
|
Modal,
|
||||||
Banner,
|
Banner,
|
||||||
Card,
|
Card,
|
||||||
|
Collapse,
|
||||||
|
Switch,
|
||||||
Table,
|
Table,
|
||||||
Tag,
|
Tag,
|
||||||
Popconfirm,
|
Popconfirm,
|
||||||
Space,
|
Space,
|
||||||
Select,
|
|
||||||
} from '@douyinfe/semi-ui';
|
} from '@douyinfe/semi-ui';
|
||||||
import { IconPlus, IconEdit, IconDelete } from '@douyinfe/semi-icons';
|
import {
|
||||||
import { API, showError, showSuccess } from '../../helpers';
|
IconPlus,
|
||||||
|
IconEdit,
|
||||||
|
IconDelete,
|
||||||
|
IconRefresh,
|
||||||
|
} from '@douyinfe/semi-icons';
|
||||||
|
import { API, showError, showSuccess, getOAuthProviderIcon } from '../../helpers';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
const { Text } = Typography;
|
const { Text } = Typography;
|
||||||
@@ -120,6 +126,69 @@ const OAUTH_PRESETS = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const OAUTH_PRESET_ICONS = {
|
||||||
|
'github-enterprise': 'github',
|
||||||
|
gitlab: 'gitlab',
|
||||||
|
gitea: 'gitea',
|
||||||
|
nextcloud: 'nextcloud',
|
||||||
|
keycloak: 'keycloak',
|
||||||
|
authentik: 'authentik',
|
||||||
|
ory: 'openid',
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPresetIcon = (preset) => OAUTH_PRESET_ICONS[preset] || '';
|
||||||
|
|
||||||
|
const PRESET_RESET_VALUES = {
|
||||||
|
name: '',
|
||||||
|
slug: '',
|
||||||
|
icon: '',
|
||||||
|
authorization_endpoint: '',
|
||||||
|
token_endpoint: '',
|
||||||
|
user_info_endpoint: '',
|
||||||
|
scopes: '',
|
||||||
|
user_id_field: '',
|
||||||
|
username_field: '',
|
||||||
|
display_name_field: '',
|
||||||
|
email_field: '',
|
||||||
|
well_known: '',
|
||||||
|
auth_style: 0,
|
||||||
|
access_policy: '',
|
||||||
|
access_denied_message: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
const DISCOVERY_FIELD_LABELS = {
|
||||||
|
authorization_endpoint: 'Authorization Endpoint',
|
||||||
|
token_endpoint: 'Token Endpoint',
|
||||||
|
user_info_endpoint: 'User Info Endpoint',
|
||||||
|
scopes: 'Scopes',
|
||||||
|
user_id_field: 'User ID Field',
|
||||||
|
username_field: 'Username Field',
|
||||||
|
display_name_field: 'Display Name Field',
|
||||||
|
email_field: 'Email Field',
|
||||||
|
};
|
||||||
|
|
||||||
|
const ACCESS_POLICY_TEMPLATES = {
|
||||||
|
level_active: `{
|
||||||
|
"logic": "and",
|
||||||
|
"conditions": [
|
||||||
|
{"field": "trust_level", "op": "gte", "value": 2},
|
||||||
|
{"field": "active", "op": "eq", "value": true}
|
||||||
|
]
|
||||||
|
}`,
|
||||||
|
org_or_role: `{
|
||||||
|
"logic": "or",
|
||||||
|
"conditions": [
|
||||||
|
{"field": "org", "op": "eq", "value": "core"},
|
||||||
|
{"field": "roles", "op": "contains", "value": "admin"}
|
||||||
|
]
|
||||||
|
}`,
|
||||||
|
};
|
||||||
|
|
||||||
|
const ACCESS_DENIED_TEMPLATES = {
|
||||||
|
level_hint: '需要等级 {{required}},你当前等级 {{current}}(字段:{{field}})',
|
||||||
|
org_hint: '仅限指定组织或角色访问。组织={{current.org}},角色={{current.roles}}',
|
||||||
|
};
|
||||||
|
|
||||||
const CustomOAuthSetting = ({ serverAddress }) => {
|
const CustomOAuthSetting = ({ serverAddress }) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [providers, setProviders] = useState([]);
|
const [providers, setProviders] = useState([]);
|
||||||
@@ -129,8 +198,47 @@ const CustomOAuthSetting = ({ serverAddress }) => {
|
|||||||
const [formValues, setFormValues] = useState({});
|
const [formValues, setFormValues] = useState({});
|
||||||
const [selectedPreset, setSelectedPreset] = useState('');
|
const [selectedPreset, setSelectedPreset] = useState('');
|
||||||
const [baseUrl, setBaseUrl] = useState('');
|
const [baseUrl, setBaseUrl] = useState('');
|
||||||
|
const [discoveryLoading, setDiscoveryLoading] = useState(false);
|
||||||
|
const [discoveryInfo, setDiscoveryInfo] = useState(null);
|
||||||
|
const [advancedActiveKeys, setAdvancedActiveKeys] = useState([]);
|
||||||
const formApiRef = React.useRef(null);
|
const formApiRef = React.useRef(null);
|
||||||
|
|
||||||
|
const mergeFormValues = (newValues) => {
|
||||||
|
setFormValues((prev) => ({ ...prev, ...newValues }));
|
||||||
|
if (!formApiRef.current) return;
|
||||||
|
Object.entries(newValues).forEach(([key, value]) => {
|
||||||
|
formApiRef.current.setValue(key, value);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const getLatestFormValues = () => {
|
||||||
|
const values = formApiRef.current?.getValues?.();
|
||||||
|
return values && typeof values === 'object' ? values : formValues;
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeBaseUrl = (url) => (url || '').trim().replace(/\/+$/, '');
|
||||||
|
|
||||||
|
const inferBaseUrlFromProvider = (provider) => {
|
||||||
|
const endpoint = provider?.authorization_endpoint || provider?.token_endpoint;
|
||||||
|
if (!endpoint) return '';
|
||||||
|
try {
|
||||||
|
const url = new URL(endpoint);
|
||||||
|
return `${url.protocol}//${url.host}`;
|
||||||
|
} catch (error) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetDiscoveryState = () => {
|
||||||
|
setDiscoveryInfo(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeModal = () => {
|
||||||
|
setModalVisible(false);
|
||||||
|
resetDiscoveryState();
|
||||||
|
setAdvancedActiveKeys([]);
|
||||||
|
};
|
||||||
|
|
||||||
const fetchProviders = async () => {
|
const fetchProviders = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
@@ -154,23 +262,30 @@ const CustomOAuthSetting = ({ serverAddress }) => {
|
|||||||
setEditingProvider(null);
|
setEditingProvider(null);
|
||||||
setFormValues({
|
setFormValues({
|
||||||
enabled: false,
|
enabled: false,
|
||||||
|
icon: '',
|
||||||
scopes: 'openid profile email',
|
scopes: 'openid profile email',
|
||||||
user_id_field: 'sub',
|
user_id_field: 'sub',
|
||||||
username_field: 'preferred_username',
|
username_field: 'preferred_username',
|
||||||
display_name_field: 'name',
|
display_name_field: 'name',
|
||||||
email_field: 'email',
|
email_field: 'email',
|
||||||
auth_style: 0,
|
auth_style: 0,
|
||||||
|
access_policy: '',
|
||||||
|
access_denied_message: '',
|
||||||
});
|
});
|
||||||
setSelectedPreset('');
|
setSelectedPreset('');
|
||||||
setBaseUrl('');
|
setBaseUrl('');
|
||||||
|
resetDiscoveryState();
|
||||||
|
setAdvancedActiveKeys([]);
|
||||||
setModalVisible(true);
|
setModalVisible(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleEdit = (provider) => {
|
const handleEdit = (provider) => {
|
||||||
setEditingProvider(provider);
|
setEditingProvider(provider);
|
||||||
setFormValues({ ...provider });
|
setFormValues({ ...provider });
|
||||||
setSelectedPreset('');
|
setSelectedPreset(OAUTH_PRESETS[provider.slug] ? provider.slug : '');
|
||||||
setBaseUrl('');
|
setBaseUrl(inferBaseUrlFromProvider(provider));
|
||||||
|
resetDiscoveryState();
|
||||||
|
setAdvancedActiveKeys([]);
|
||||||
setModalVisible(true);
|
setModalVisible(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -189,6 +304,8 @@ const CustomOAuthSetting = ({ serverAddress }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
|
const currentValues = getLatestFormValues();
|
||||||
|
|
||||||
// Validate required fields
|
// Validate required fields
|
||||||
const requiredFields = [
|
const requiredFields = [
|
||||||
'name',
|
'name',
|
||||||
@@ -204,7 +321,7 @@ const CustomOAuthSetting = ({ serverAddress }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const field of requiredFields) {
|
for (const field of requiredFields) {
|
||||||
if (!formValues[field]) {
|
if (!currentValues[field]) {
|
||||||
showError(t(`请填写 ${field}`));
|
showError(t(`请填写 ${field}`));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -213,11 +330,11 @@ const CustomOAuthSetting = ({ serverAddress }) => {
|
|||||||
// Validate endpoint URLs must be full URLs
|
// Validate endpoint URLs must be full URLs
|
||||||
const endpointFields = ['authorization_endpoint', 'token_endpoint', 'user_info_endpoint'];
|
const endpointFields = ['authorization_endpoint', 'token_endpoint', 'user_info_endpoint'];
|
||||||
for (const field of endpointFields) {
|
for (const field of endpointFields) {
|
||||||
const value = formValues[field];
|
const value = currentValues[field];
|
||||||
if (value && !value.startsWith('http://') && !value.startsWith('https://')) {
|
if (value && !value.startsWith('http://') && !value.startsWith('https://')) {
|
||||||
// Check if user selected a preset but forgot to fill server address
|
// Check if user selected a preset but forgot to fill issuer URL
|
||||||
if (selectedPreset && !baseUrl) {
|
if (selectedPreset && !baseUrl) {
|
||||||
showError(t('请先填写服务器地址,以自动生成完整的端点 URL'));
|
showError(t('请先填写 Issuer URL,以自动生成完整的端点 URL'));
|
||||||
} else {
|
} else {
|
||||||
showError(t('端点 URL 必须是完整地址(以 http:// 或 https:// 开头)'));
|
showError(t('端点 URL 必须是完整地址(以 http:// 或 https:// 开头)'));
|
||||||
}
|
}
|
||||||
@@ -226,80 +343,199 @@ const CustomOAuthSetting = ({ serverAddress }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const payload = { ...currentValues, enabled: !!currentValues.enabled };
|
||||||
|
delete payload.preset;
|
||||||
|
delete payload.base_url;
|
||||||
|
|
||||||
let res;
|
let res;
|
||||||
if (editingProvider) {
|
if (editingProvider) {
|
||||||
res = await API.put(
|
res = await API.put(
|
||||||
`/api/custom-oauth-provider/${editingProvider.id}`,
|
`/api/custom-oauth-provider/${editingProvider.id}`,
|
||||||
formValues
|
payload
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
res = await API.post('/api/custom-oauth-provider/', formValues);
|
res = await API.post('/api/custom-oauth-provider/', payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (res.data.success) {
|
if (res.data.success) {
|
||||||
showSuccess(editingProvider ? t('更新成功') : t('创建成功'));
|
showSuccess(editingProvider ? t('更新成功') : t('创建成功'));
|
||||||
setModalVisible(false);
|
closeModal();
|
||||||
fetchProviders();
|
fetchProviders();
|
||||||
} else {
|
} else {
|
||||||
showError(res.data.message);
|
showError(res.data.message);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showError(editingProvider ? t('更新失败') : t('创建失败'));
|
showError(
|
||||||
|
error?.response?.data?.message ||
|
||||||
|
(editingProvider ? t('更新失败') : t('创建失败')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFetchFromDiscovery = async () => {
|
||||||
|
const cleanBaseUrl = normalizeBaseUrl(baseUrl);
|
||||||
|
const configuredWellKnown = (formValues.well_known || '').trim();
|
||||||
|
const wellKnownUrl =
|
||||||
|
configuredWellKnown ||
|
||||||
|
(cleanBaseUrl ? `${cleanBaseUrl}/.well-known/openid-configuration` : '');
|
||||||
|
|
||||||
|
if (!wellKnownUrl) {
|
||||||
|
showError(t('请先填写 Discovery URL 或 Issuer URL'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setDiscoveryLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await API.post('/api/custom-oauth-provider/discovery', {
|
||||||
|
well_known_url: configuredWellKnown || '',
|
||||||
|
issuer_url: cleanBaseUrl || '',
|
||||||
|
});
|
||||||
|
if (!res.data.success) {
|
||||||
|
throw new Error(res.data.message || t('未知错误'));
|
||||||
|
}
|
||||||
|
const data = res.data.data?.discovery || {};
|
||||||
|
const resolvedWellKnown = res.data.data?.well_known_url || wellKnownUrl;
|
||||||
|
|
||||||
|
const discoveredValues = {
|
||||||
|
well_known: resolvedWellKnown,
|
||||||
|
};
|
||||||
|
const autoFilledFields = [];
|
||||||
|
if (data.authorization_endpoint) {
|
||||||
|
discoveredValues.authorization_endpoint = data.authorization_endpoint;
|
||||||
|
autoFilledFields.push('authorization_endpoint');
|
||||||
|
}
|
||||||
|
if (data.token_endpoint) {
|
||||||
|
discoveredValues.token_endpoint = data.token_endpoint;
|
||||||
|
autoFilledFields.push('token_endpoint');
|
||||||
|
}
|
||||||
|
if (data.userinfo_endpoint) {
|
||||||
|
discoveredValues.user_info_endpoint = data.userinfo_endpoint;
|
||||||
|
autoFilledFields.push('user_info_endpoint');
|
||||||
|
}
|
||||||
|
|
||||||
|
const scopesSupported = Array.isArray(data.scopes_supported)
|
||||||
|
? data.scopes_supported
|
||||||
|
: [];
|
||||||
|
if (scopesSupported.length > 0 && !formValues.scopes) {
|
||||||
|
const preferredScopes = ['openid', 'profile', 'email'].filter((scope) =>
|
||||||
|
scopesSupported.includes(scope),
|
||||||
|
);
|
||||||
|
discoveredValues.scopes =
|
||||||
|
preferredScopes.length > 0
|
||||||
|
? preferredScopes.join(' ')
|
||||||
|
: scopesSupported.slice(0, 5).join(' ');
|
||||||
|
autoFilledFields.push('scopes');
|
||||||
|
}
|
||||||
|
|
||||||
|
const claimsSupported = Array.isArray(data.claims_supported)
|
||||||
|
? data.claims_supported
|
||||||
|
: [];
|
||||||
|
const claimMap = {
|
||||||
|
user_id_field: 'sub',
|
||||||
|
username_field: 'preferred_username',
|
||||||
|
display_name_field: 'name',
|
||||||
|
email_field: 'email',
|
||||||
|
};
|
||||||
|
Object.entries(claimMap).forEach(([field, claim]) => {
|
||||||
|
if (!formValues[field] && claimsSupported.includes(claim)) {
|
||||||
|
discoveredValues[field] = claim;
|
||||||
|
autoFilledFields.push(field);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const hasCoreEndpoint =
|
||||||
|
discoveredValues.authorization_endpoint ||
|
||||||
|
discoveredValues.token_endpoint ||
|
||||||
|
discoveredValues.user_info_endpoint;
|
||||||
|
if (!hasCoreEndpoint) {
|
||||||
|
showError(t('未在 Discovery 响应中找到可用的 OAuth 端点'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
mergeFormValues(discoveredValues);
|
||||||
|
setDiscoveryInfo({
|
||||||
|
wellKnown: wellKnownUrl,
|
||||||
|
autoFilledFields,
|
||||||
|
scopesSupported: scopesSupported.slice(0, 12),
|
||||||
|
claimsSupported: claimsSupported.slice(0, 12),
|
||||||
|
});
|
||||||
|
showSuccess(t('已从 Discovery 自动填充配置'));
|
||||||
|
} catch (error) {
|
||||||
|
showError(
|
||||||
|
t('获取 Discovery 配置失败:') + (error?.message || t('未知错误')),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setDiscoveryLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePresetChange = (preset) => {
|
const handlePresetChange = (preset) => {
|
||||||
setSelectedPreset(preset);
|
setSelectedPreset(preset);
|
||||||
if (preset && OAUTH_PRESETS[preset]) {
|
resetDiscoveryState();
|
||||||
const presetConfig = OAUTH_PRESETS[preset];
|
const cleanUrl = normalizeBaseUrl(baseUrl);
|
||||||
const cleanUrl = baseUrl ? baseUrl.replace(/\/+$/, '') : '';
|
if (!preset || !OAUTH_PRESETS[preset]) {
|
||||||
const newValues = {
|
mergeFormValues(PRESET_RESET_VALUES);
|
||||||
name: presetConfig.name,
|
return;
|
||||||
slug: preset,
|
|
||||||
scopes: presetConfig.scopes,
|
|
||||||
user_id_field: presetConfig.user_id_field,
|
|
||||||
username_field: presetConfig.username_field,
|
|
||||||
display_name_field: presetConfig.display_name_field,
|
|
||||||
email_field: presetConfig.email_field,
|
|
||||||
auth_style: presetConfig.auth_style ?? 0,
|
|
||||||
};
|
|
||||||
// Only fill endpoints if server address is provided
|
|
||||||
if (cleanUrl) {
|
|
||||||
newValues.authorization_endpoint = cleanUrl + presetConfig.authorization_endpoint;
|
|
||||||
newValues.token_endpoint = cleanUrl + presetConfig.token_endpoint;
|
|
||||||
newValues.user_info_endpoint = cleanUrl + presetConfig.user_info_endpoint;
|
|
||||||
}
|
|
||||||
setFormValues((prev) => ({ ...prev, ...newValues }));
|
|
||||||
// Update form fields directly via formApi
|
|
||||||
if (formApiRef.current) {
|
|
||||||
Object.entries(newValues).forEach(([key, value]) => {
|
|
||||||
formApiRef.current.setValue(key, value);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const presetConfig = OAUTH_PRESETS[preset];
|
||||||
|
const newValues = {
|
||||||
|
...PRESET_RESET_VALUES,
|
||||||
|
name: presetConfig.name,
|
||||||
|
slug: preset,
|
||||||
|
icon: getPresetIcon(preset),
|
||||||
|
scopes: presetConfig.scopes,
|
||||||
|
user_id_field: presetConfig.user_id_field,
|
||||||
|
username_field: presetConfig.username_field,
|
||||||
|
display_name_field: presetConfig.display_name_field,
|
||||||
|
email_field: presetConfig.email_field,
|
||||||
|
auth_style: presetConfig.auth_style ?? 0,
|
||||||
|
};
|
||||||
|
if (cleanUrl) {
|
||||||
|
newValues.authorization_endpoint =
|
||||||
|
cleanUrl + presetConfig.authorization_endpoint;
|
||||||
|
newValues.token_endpoint = cleanUrl + presetConfig.token_endpoint;
|
||||||
|
newValues.user_info_endpoint = cleanUrl + presetConfig.user_info_endpoint;
|
||||||
|
}
|
||||||
|
mergeFormValues(newValues);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleBaseUrlChange = (url) => {
|
const handleBaseUrlChange = (url) => {
|
||||||
setBaseUrl(url);
|
setBaseUrl(url);
|
||||||
if (url && selectedPreset && OAUTH_PRESETS[selectedPreset]) {
|
if (url && selectedPreset && OAUTH_PRESETS[selectedPreset]) {
|
||||||
const presetConfig = OAUTH_PRESETS[selectedPreset];
|
const presetConfig = OAUTH_PRESETS[selectedPreset];
|
||||||
const cleanUrl = url.replace(/\/+$/, ''); // Remove trailing slashes
|
const cleanUrl = normalizeBaseUrl(url);
|
||||||
const newValues = {
|
const newValues = {
|
||||||
authorization_endpoint: cleanUrl + presetConfig.authorization_endpoint,
|
authorization_endpoint: cleanUrl + presetConfig.authorization_endpoint,
|
||||||
token_endpoint: cleanUrl + presetConfig.token_endpoint,
|
token_endpoint: cleanUrl + presetConfig.token_endpoint,
|
||||||
user_info_endpoint: cleanUrl + presetConfig.user_info_endpoint,
|
user_info_endpoint: cleanUrl + presetConfig.user_info_endpoint,
|
||||||
};
|
};
|
||||||
setFormValues((prev) => ({ ...prev, ...newValues }));
|
mergeFormValues(newValues);
|
||||||
// Update form fields directly via formApi (use merge mode to preserve other fields)
|
|
||||||
if (formApiRef.current) {
|
|
||||||
Object.entries(newValues).forEach(([key, value]) => {
|
|
||||||
formApiRef.current.setValue(key, value);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const applyAccessPolicyTemplate = (templateKey) => {
|
||||||
|
const template = ACCESS_POLICY_TEMPLATES[templateKey];
|
||||||
|
if (!template) return;
|
||||||
|
mergeFormValues({ access_policy: template });
|
||||||
|
showSuccess(t('已填充策略模板'));
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyDeniedTemplate = (templateKey) => {
|
||||||
|
const template = ACCESS_DENIED_TEMPLATES[templateKey];
|
||||||
|
if (!template) return;
|
||||||
|
mergeFormValues({ access_denied_message: template });
|
||||||
|
showSuccess(t('已填充提示模板'));
|
||||||
|
};
|
||||||
|
|
||||||
const columns = [
|
const columns = [
|
||||||
|
{
|
||||||
|
title: t('图标'),
|
||||||
|
dataIndex: 'icon',
|
||||||
|
key: 'icon',
|
||||||
|
width: 80,
|
||||||
|
render: (icon) => getOAuthProviderIcon(icon || '', 18),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: t('名称'),
|
title: t('名称'),
|
||||||
dataIndex: 'name',
|
dataIndex: 'name',
|
||||||
@@ -325,7 +561,10 @@ const CustomOAuthSetting = ({ serverAddress }) => {
|
|||||||
title: t('Client ID'),
|
title: t('Client ID'),
|
||||||
dataIndex: 'client_id',
|
dataIndex: 'client_id',
|
||||||
key: 'client_id',
|
key: 'client_id',
|
||||||
render: (id) => (id ? id.substring(0, 20) + '...' : '-'),
|
render: (id) => {
|
||||||
|
if (!id) return '-';
|
||||||
|
return id.length > 20 ? `${id.substring(0, 20)}...` : id;
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t('操作'),
|
title: t('操作'),
|
||||||
@@ -352,6 +591,10 @@ const CustomOAuthSetting = ({ serverAddress }) => {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const discoveryAutoFilledLabels = (discoveryInfo?.autoFilledFields || [])
|
||||||
|
.map((field) => DISCOVERY_FIELD_LABELS[field] || field)
|
||||||
|
.join(', ');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<Form.Section text={t('自定义 OAuth 提供商')}>
|
<Form.Section text={t('自定义 OAuth 提供商')}>
|
||||||
@@ -391,56 +634,142 @@ const CustomOAuthSetting = ({ serverAddress }) => {
|
|||||||
<Modal
|
<Modal
|
||||||
title={editingProvider ? t('编辑 OAuth 提供商') : t('添加 OAuth 提供商')}
|
title={editingProvider ? t('编辑 OAuth 提供商') : t('添加 OAuth 提供商')}
|
||||||
visible={modalVisible}
|
visible={modalVisible}
|
||||||
onOk={handleSubmit}
|
onCancel={closeModal}
|
||||||
onCancel={() => setModalVisible(false)}
|
width={860}
|
||||||
okText={t('保存')}
|
centered
|
||||||
cancelText={t('取消')}
|
bodyStyle={{ maxHeight: '72vh', overflowY: 'auto', paddingRight: 6 }}
|
||||||
width={800}
|
footer={
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'flex-end',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 12,
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Space spacing={8} align='center'>
|
||||||
|
<Text type='secondary'>{t('启用供应商')}</Text>
|
||||||
|
<Switch
|
||||||
|
checked={!!formValues.enabled}
|
||||||
|
size='large'
|
||||||
|
onChange={(checked) => mergeFormValues({ enabled: !!checked })}
|
||||||
|
/>
|
||||||
|
<Tag color={formValues.enabled ? 'green' : 'grey'}>
|
||||||
|
{formValues.enabled ? t('已启用') : t('已禁用')}
|
||||||
|
</Tag>
|
||||||
|
</Space>
|
||||||
|
<Button onClick={closeModal}>{t('取消')}</Button>
|
||||||
|
<Button type='primary' onClick={handleSubmit}>
|
||||||
|
{t('保存')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<Form
|
<Form
|
||||||
initValues={formValues}
|
initValues={formValues}
|
||||||
onValueChange={(values) => setFormValues(values)}
|
onValueChange={() => {
|
||||||
|
setFormValues((prev) => ({ ...prev, ...getLatestFormValues() }));
|
||||||
|
}}
|
||||||
getFormApi={(api) => (formApiRef.current = api)}
|
getFormApi={(api) => (formApiRef.current = api)}
|
||||||
>
|
>
|
||||||
{!editingProvider && (
|
<Text strong style={{ display: 'block', marginBottom: 8 }}>
|
||||||
<Row gutter={16} style={{ marginBottom: 16 }}>
|
{t('Configuration')}
|
||||||
<Col span={12}>
|
</Text>
|
||||||
<Form.Select
|
<Text type="secondary" style={{ display: 'block', marginBottom: 8 }}>
|
||||||
field="preset"
|
{t('先填写配置,再自动填充 OAuth 端点,能显著减少手工输入')}
|
||||||
label={t('预设模板')}
|
</Text>
|
||||||
placeholder={t('选择预设模板(可选)')}
|
{discoveryInfo && (
|
||||||
value={selectedPreset}
|
<Banner
|
||||||
onChange={handlePresetChange}
|
type='success'
|
||||||
optionList={[
|
closeIcon={null}
|
||||||
{ value: '', label: t('自定义') },
|
style={{ marginBottom: 12 }}
|
||||||
...Object.entries(OAUTH_PRESETS).map(([key, config]) => ({
|
description={
|
||||||
value: key,
|
<div>
|
||||||
label: config.name,
|
<div>
|
||||||
})),
|
{t('已从 Discovery 获取配置,可继续手动修改所有字段。')}
|
||||||
]}
|
</div>
|
||||||
/>
|
{discoveryAutoFilledLabels ? (
|
||||||
</Col>
|
<div>
|
||||||
<Col span={12}>
|
{t('自动填充字段')}:
|
||||||
<Form.Input
|
{' '}
|
||||||
field="base_url"
|
{discoveryAutoFilledLabels}
|
||||||
label={
|
</div>
|
||||||
selectedPreset
|
) : null}
|
||||||
? t('服务器地址') + ' *'
|
{discoveryInfo.scopesSupported?.length ? (
|
||||||
: t('服务器地址')
|
<div>
|
||||||
}
|
{t('Discovery scopes')}:
|
||||||
placeholder={t('例如:https://gitea.example.com')}
|
{' '}
|
||||||
value={baseUrl}
|
{discoveryInfo.scopesSupported.join(', ')}
|
||||||
onChange={handleBaseUrlChange}
|
</div>
|
||||||
extraText={
|
) : null}
|
||||||
selectedPreset
|
{discoveryInfo.claimsSupported?.length ? (
|
||||||
? t('必填:请输入服务器地址以自动生成完整端点 URL')
|
<div>
|
||||||
: t('选择预设模板后填写服务器地址可自动填充端点')
|
{t('Discovery claims')}:
|
||||||
}
|
{' '}
|
||||||
/>
|
{discoveryInfo.claimsSupported.join(', ')}
|
||||||
</Col>
|
</div>
|
||||||
</Row>
|
) : null}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<Row gutter={16}>
|
||||||
|
<Col span={8}>
|
||||||
|
<Form.Select
|
||||||
|
field="preset"
|
||||||
|
label={t('预设模板')}
|
||||||
|
placeholder={t('选择预设模板(可选)')}
|
||||||
|
value={selectedPreset}
|
||||||
|
onChange={handlePresetChange}
|
||||||
|
optionList={[
|
||||||
|
{ value: '', label: t('自定义') },
|
||||||
|
...Object.entries(OAUTH_PRESETS).map(([key, config]) => ({
|
||||||
|
value: key,
|
||||||
|
label: config.name,
|
||||||
|
})),
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
<Col span={10}>
|
||||||
|
<Form.Input
|
||||||
|
field="base_url"
|
||||||
|
label={t('发行者 URL(Issuer URL)')}
|
||||||
|
placeholder={t('例如:https://gitea.example.com')}
|
||||||
|
value={baseUrl}
|
||||||
|
onChange={handleBaseUrlChange}
|
||||||
|
extraText={
|
||||||
|
selectedPreset
|
||||||
|
? t('填写后会自动拼接预设端点')
|
||||||
|
: t('可选:用于自动生成端点或 Discovery URL')
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
<Col span={6}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'flex-end', height: '100%' }}>
|
||||||
|
<Button
|
||||||
|
icon={<IconRefresh />}
|
||||||
|
onClick={handleFetchFromDiscovery}
|
||||||
|
loading={discoveryLoading}
|
||||||
|
block
|
||||||
|
>
|
||||||
|
{t('获取 Discovery 配置')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
<Row gutter={16}>
|
||||||
|
<Col span={24}>
|
||||||
|
<Form.Input
|
||||||
|
field="well_known"
|
||||||
|
label={t('发现文档地址(Discovery URL,可选)')}
|
||||||
|
placeholder={t('例如:https://example.com/.well-known/openid-configuration')}
|
||||||
|
extraText={t('可留空;留空时会尝试使用 Issuer URL + /.well-known/openid-configuration')}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
<Row gutter={16}>
|
<Row gutter={16}>
|
||||||
<Col span={12}>
|
<Col span={12}>
|
||||||
<Form.Input
|
<Form.Input
|
||||||
@@ -461,6 +790,41 @@ const CustomOAuthSetting = ({ serverAddress }) => {
|
|||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
|
|
||||||
|
<Row gutter={16}>
|
||||||
|
<Col span={18}>
|
||||||
|
<Form.Input
|
||||||
|
field='icon'
|
||||||
|
label={t('图标')}
|
||||||
|
placeholder={t('例如:github / si:google / https://example.com/logo.png / 🐱')}
|
||||||
|
extraText={
|
||||||
|
<span>
|
||||||
|
{t(
|
||||||
|
'图标使用 react-icons(Simple Icons)或 URL/emoji,例如:github、gitlab、si:google',
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
showClear
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
<Col span={6} style={{ display: 'flex', alignItems: 'flex-end' }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
minHeight: 74,
|
||||||
|
border: '1px solid var(--semi-color-border)',
|
||||||
|
borderRadius: 8,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
marginBottom: 24,
|
||||||
|
background: 'var(--semi-color-fill-0)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{getOAuthProviderIcon(formValues.icon || '', 24)}
|
||||||
|
</div>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
<Row gutter={16}>
|
<Row gutter={16}>
|
||||||
<Col span={12}>
|
<Col span={12}>
|
||||||
<Form.Input
|
<Form.Input
|
||||||
@@ -500,7 +864,7 @@ const CustomOAuthSetting = ({ serverAddress }) => {
|
|||||||
label={t('Authorization Endpoint')}
|
label={t('Authorization Endpoint')}
|
||||||
placeholder={
|
placeholder={
|
||||||
selectedPreset && OAUTH_PRESETS[selectedPreset]
|
selectedPreset && OAUTH_PRESETS[selectedPreset]
|
||||||
? t('填写服务器地址后自动生成:') +
|
? t('填写 Issuer URL 后自动生成:') +
|
||||||
OAUTH_PRESETS[selectedPreset].authorization_endpoint
|
OAUTH_PRESETS[selectedPreset].authorization_endpoint
|
||||||
: 'https://example.com/oauth/authorize'
|
: 'https://example.com/oauth/authorize'
|
||||||
}
|
}
|
||||||
@@ -544,15 +908,14 @@ const CustomOAuthSetting = ({ serverAddress }) => {
|
|||||||
<Col span={12}>
|
<Col span={12}>
|
||||||
<Form.Input
|
<Form.Input
|
||||||
field="scopes"
|
field="scopes"
|
||||||
label={t('Scopes')}
|
label={t('Scopes(可选)')}
|
||||||
placeholder="openid profile email"
|
placeholder="openid profile email"
|
||||||
/>
|
extraText={
|
||||||
</Col>
|
discoveryInfo?.scopesSupported?.length
|
||||||
<Col span={12}>
|
? t('Discovery 建议 scopes:') +
|
||||||
<Form.Input
|
discoveryInfo.scopesSupported.join(', ')
|
||||||
field="well_known"
|
: t('可手动填写,多个 scope 用空格分隔')
|
||||||
label={t('Well-Known URL')}
|
}
|
||||||
placeholder={t('OIDC Discovery 端点(可选)')}
|
|
||||||
/>
|
/>
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
@@ -568,7 +931,7 @@ const CustomOAuthSetting = ({ serverAddress }) => {
|
|||||||
<Col span={12}>
|
<Col span={12}>
|
||||||
<Form.Input
|
<Form.Input
|
||||||
field="user_id_field"
|
field="user_id_field"
|
||||||
label={t('用户 ID 字段')}
|
label={t('用户 ID 字段(可选)')}
|
||||||
placeholder={t('例如:sub、id、data.user.id')}
|
placeholder={t('例如:sub、id、data.user.id')}
|
||||||
extraText={t('用于唯一标识用户的字段路径')}
|
extraText={t('用于唯一标识用户的字段路径')}
|
||||||
/>
|
/>
|
||||||
@@ -576,7 +939,7 @@ const CustomOAuthSetting = ({ serverAddress }) => {
|
|||||||
<Col span={12}>
|
<Col span={12}>
|
||||||
<Form.Input
|
<Form.Input
|
||||||
field="username_field"
|
field="username_field"
|
||||||
label={t('用户名字段')}
|
label={t('用户名字段(可选)')}
|
||||||
placeholder={t('例如:preferred_username、login')}
|
placeholder={t('例如:preferred_username、login')}
|
||||||
/>
|
/>
|
||||||
</Col>
|
</Col>
|
||||||
@@ -586,41 +949,100 @@ const CustomOAuthSetting = ({ serverAddress }) => {
|
|||||||
<Col span={12}>
|
<Col span={12}>
|
||||||
<Form.Input
|
<Form.Input
|
||||||
field="display_name_field"
|
field="display_name_field"
|
||||||
label={t('显示名称字段')}
|
label={t('显示名称字段(可选)')}
|
||||||
placeholder={t('例如:name、full_name')}
|
placeholder={t('例如:name、full_name')}
|
||||||
/>
|
/>
|
||||||
</Col>
|
</Col>
|
||||||
<Col span={12}>
|
<Col span={12}>
|
||||||
<Form.Input
|
<Form.Input
|
||||||
field="email_field"
|
field="email_field"
|
||||||
label={t('邮箱字段')}
|
label={t('邮箱字段(可选)')}
|
||||||
placeholder={t('例如:email')}
|
placeholder={t('例如:email')}
|
||||||
/>
|
/>
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
|
|
||||||
<Text strong style={{ display: 'block', margin: '16px 0 8px' }}>
|
<Collapse
|
||||||
{t('高级选项')}
|
keepDOM
|
||||||
</Text>
|
activeKey={advancedActiveKeys}
|
||||||
|
style={{ marginTop: 16 }}
|
||||||
|
onChange={(activeKey) => {
|
||||||
|
const keys = Array.isArray(activeKey) ? activeKey : [activeKey];
|
||||||
|
setAdvancedActiveKeys(keys.filter(Boolean));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Collapse.Panel header={t('高级选项')} itemKey='advanced'>
|
||||||
|
<Row gutter={16}>
|
||||||
|
<Col span={12}>
|
||||||
|
<Form.Select
|
||||||
|
field="auth_style"
|
||||||
|
label={t('认证方式')}
|
||||||
|
optionList={[
|
||||||
|
{ value: 0, label: t('自动检测') },
|
||||||
|
{ value: 1, label: t('POST 参数') },
|
||||||
|
{ value: 2, label: t('Basic Auth 头') },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
<Row gutter={16}>
|
<Text strong style={{ display: 'block', margin: '16px 0 8px' }}>
|
||||||
<Col span={12}>
|
{t('准入策略')}
|
||||||
<Form.Select
|
</Text>
|
||||||
field="auth_style"
|
<Text type="secondary" style={{ display: 'block', marginBottom: 8 }}>
|
||||||
label={t('认证方式')}
|
{t('可选:基于用户信息 JSON 做组合条件准入,条件不满足时返回自定义提示')}
|
||||||
optionList={[
|
</Text>
|
||||||
{ value: 0, label: t('自动检测') },
|
<Row gutter={16}>
|
||||||
{ value: 1, label: t('POST 参数') },
|
<Col span={24}>
|
||||||
{ value: 2, label: t('Basic Auth 头') },
|
<Form.TextArea
|
||||||
]}
|
field='access_policy'
|
||||||
/>
|
value={formValues.access_policy || ''}
|
||||||
</Col>
|
onChange={(value) => mergeFormValues({ access_policy: value })}
|
||||||
<Col span={12}>
|
label={t('准入策略 JSON(可选)')}
|
||||||
<Form.Checkbox field="enabled" noLabel>
|
rows={6}
|
||||||
{t('启用此 OAuth 提供商')}
|
placeholder={`{
|
||||||
</Form.Checkbox>
|
"logic": "and",
|
||||||
</Col>
|
"conditions": [
|
||||||
</Row>
|
{"field": "trust_level", "op": "gte", "value": 2},
|
||||||
|
{"field": "active", "op": "eq", "value": true}
|
||||||
|
]
|
||||||
|
}`}
|
||||||
|
extraText={t('支持逻辑 and/or 与嵌套 groups;操作符支持 eq/ne/gt/gte/lt/lte/in/not_in/contains/exists')}
|
||||||
|
showClear
|
||||||
|
/>
|
||||||
|
<Space spacing={8} style={{ marginTop: 8 }}>
|
||||||
|
<Button size='small' theme='light' onClick={() => applyAccessPolicyTemplate('level_active')}>
|
||||||
|
{t('填充模板:等级+激活')}
|
||||||
|
</Button>
|
||||||
|
<Button size='small' theme='light' onClick={() => applyAccessPolicyTemplate('org_or_role')}>
|
||||||
|
{t('填充模板:组织或角色')}
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
<Row gutter={16}>
|
||||||
|
<Col span={24}>
|
||||||
|
<Form.Input
|
||||||
|
field='access_denied_message'
|
||||||
|
value={formValues.access_denied_message || ''}
|
||||||
|
onChange={(value) => mergeFormValues({ access_denied_message: value })}
|
||||||
|
label={t('拒绝提示模板(可选)')}
|
||||||
|
placeholder={t('例如:需要等级 {{required}},你当前等级 {{current}}')}
|
||||||
|
extraText={t('可用变量:{{provider}} {{field}} {{op}} {{required}} {{current}} 以及 {{current.path}}')}
|
||||||
|
showClear
|
||||||
|
/>
|
||||||
|
<Space spacing={8} style={{ marginTop: 8 }}>
|
||||||
|
<Button size='small' theme='light' onClick={() => applyDeniedTemplate('level_hint')}>
|
||||||
|
{t('填充模板:等级提示')}
|
||||||
|
</Button>
|
||||||
|
<Button size='small' theme='light' onClick={() => applyDeniedTemplate('org_hint')}>
|
||||||
|
{t('填充模板:组织提示')}
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</Collapse.Panel>
|
||||||
|
</Collapse>
|
||||||
</Form>
|
</Form>
|
||||||
</Modal>
|
</Modal>
|
||||||
</Form.Section>
|
</Form.Section>
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ import {
|
|||||||
onLinuxDOOAuthClicked,
|
onLinuxDOOAuthClicked,
|
||||||
onDiscordOAuthClicked,
|
onDiscordOAuthClicked,
|
||||||
onCustomOAuthClicked,
|
onCustomOAuthClicked,
|
||||||
|
getOAuthProviderIcon,
|
||||||
} from '../../../../helpers';
|
} from '../../../../helpers';
|
||||||
import TwoFASetting from '../components/TwoFASetting';
|
import TwoFASetting from '../components/TwoFASetting';
|
||||||
|
|
||||||
@@ -148,12 +149,14 @@ const AccountManagement = ({
|
|||||||
|
|
||||||
// Check if custom OAuth provider is bound
|
// Check if custom OAuth provider is bound
|
||||||
const isCustomOAuthBound = (providerId) => {
|
const isCustomOAuthBound = (providerId) => {
|
||||||
return customOAuthBindings.some((b) => b.provider_id === providerId);
|
const normalizedId = Number(providerId);
|
||||||
|
return customOAuthBindings.some((b) => Number(b.provider_id) === normalizedId);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Get binding info for a provider
|
// Get binding info for a provider
|
||||||
const getCustomOAuthBinding = (providerId) => {
|
const getCustomOAuthBinding = (providerId) => {
|
||||||
return customOAuthBindings.find((b) => b.provider_id === providerId);
|
const normalizedId = Number(providerId);
|
||||||
|
return customOAuthBindings.find((b) => Number(b.provider_id) === normalizedId);
|
||||||
};
|
};
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
@@ -524,10 +527,10 @@ const AccountManagement = ({
|
|||||||
<div className='flex items-center justify-between gap-3'>
|
<div className='flex items-center justify-between gap-3'>
|
||||||
<div className='flex items-center flex-1 min-w-0'>
|
<div className='flex items-center flex-1 min-w-0'>
|
||||||
<div className='w-10 h-10 rounded-full bg-slate-100 dark:bg-slate-700 flex items-center justify-center mr-3 flex-shrink-0'>
|
<div className='w-10 h-10 rounded-full bg-slate-100 dark:bg-slate-700 flex items-center justify-center mr-3 flex-shrink-0'>
|
||||||
<IconLock
|
{getOAuthProviderIcon(
|
||||||
size='default'
|
provider.icon || binding?.provider_icon || '',
|
||||||
className='text-slate-600 dark:text-slate-300'
|
20,
|
||||||
/>
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className='flex-1 min-w-0'>
|
<div className='flex-1 min-w-0'>
|
||||||
<div className='font-medium text-gray-900'>
|
<div className='font-medium text-gray-900'>
|
||||||
|
|||||||
@@ -76,6 +76,31 @@ import {
|
|||||||
Server,
|
Server,
|
||||||
CalendarClock,
|
CalendarClock,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
import {
|
||||||
|
SiAtlassian,
|
||||||
|
SiAuth0,
|
||||||
|
SiAuthentik,
|
||||||
|
SiBitbucket,
|
||||||
|
SiDiscord,
|
||||||
|
SiDropbox,
|
||||||
|
SiFacebook,
|
||||||
|
SiGitea,
|
||||||
|
SiGithub,
|
||||||
|
SiGitlab,
|
||||||
|
SiGoogle,
|
||||||
|
SiKeycloak,
|
||||||
|
SiLinkedin,
|
||||||
|
SiNextcloud,
|
||||||
|
SiNotion,
|
||||||
|
SiOkta,
|
||||||
|
SiOpenid,
|
||||||
|
SiReddit,
|
||||||
|
SiSlack,
|
||||||
|
SiTelegram,
|
||||||
|
SiTwitch,
|
||||||
|
SiWechat,
|
||||||
|
SiX,
|
||||||
|
} from 'react-icons/si';
|
||||||
|
|
||||||
// 获取侧边栏Lucide图标组件
|
// 获取侧边栏Lucide图标组件
|
||||||
export function getLucideIcon(key, selected = false) {
|
export function getLucideIcon(key, selected = false) {
|
||||||
@@ -472,6 +497,106 @@ export function getLobeHubIcon(iconName, size = 14) {
|
|||||||
return <IconComponent {...props} />;
|
return <IconComponent {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const oauthProviderIconMap = {
|
||||||
|
github: SiGithub,
|
||||||
|
gitlab: SiGitlab,
|
||||||
|
gitea: SiGitea,
|
||||||
|
google: SiGoogle,
|
||||||
|
discord: SiDiscord,
|
||||||
|
facebook: SiFacebook,
|
||||||
|
linkedin: SiLinkedin,
|
||||||
|
x: SiX,
|
||||||
|
twitter: SiX,
|
||||||
|
slack: SiSlack,
|
||||||
|
telegram: SiTelegram,
|
||||||
|
wechat: SiWechat,
|
||||||
|
keycloak: SiKeycloak,
|
||||||
|
nextcloud: SiNextcloud,
|
||||||
|
authentik: SiAuthentik,
|
||||||
|
openid: SiOpenid,
|
||||||
|
okta: SiOkta,
|
||||||
|
auth0: SiAuth0,
|
||||||
|
atlassian: SiAtlassian,
|
||||||
|
bitbucket: SiBitbucket,
|
||||||
|
notion: SiNotion,
|
||||||
|
twitch: SiTwitch,
|
||||||
|
reddit: SiReddit,
|
||||||
|
dropbox: SiDropbox,
|
||||||
|
};
|
||||||
|
|
||||||
|
function isHttpUrl(value) {
|
||||||
|
return /^https?:\/\//i.test(value || '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSimpleEmoji(value) {
|
||||||
|
if (!value) return false;
|
||||||
|
const trimmed = String(value).trim();
|
||||||
|
return trimmed.length > 0 && trimmed.length <= 4 && !isHttpUrl(trimmed);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeOAuthIconKey(raw) {
|
||||||
|
return raw
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/^ri:/, '')
|
||||||
|
.replace(/^react-icons:/, '')
|
||||||
|
.replace(/^si:/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render custom OAuth provider icon with react-icons or URL/emoji fallback.
|
||||||
|
* Supported formats:
|
||||||
|
* - react-icons simple key: github / gitlab / google / keycloak
|
||||||
|
* - prefixed key: ri:github / si:github
|
||||||
|
* - full URL image: https://example.com/logo.png
|
||||||
|
* - emoji: 🐱
|
||||||
|
*/
|
||||||
|
export function getOAuthProviderIcon(iconName, size = 20) {
|
||||||
|
const raw = String(iconName || '').trim();
|
||||||
|
const iconSize = Number(size) > 0 ? Number(size) : 20;
|
||||||
|
|
||||||
|
if (!raw) {
|
||||||
|
return <Layers size={iconSize} color='var(--semi-color-text-2)' />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isHttpUrl(raw)) {
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
src={raw}
|
||||||
|
alt='provider icon'
|
||||||
|
width={iconSize}
|
||||||
|
height={iconSize}
|
||||||
|
style={{ borderRadius: 4, objectFit: 'cover' }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isSimpleEmoji(raw)) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
width: iconSize,
|
||||||
|
height: iconSize,
|
||||||
|
lineHeight: `${iconSize}px`,
|
||||||
|
textAlign: 'center',
|
||||||
|
display: 'inline-block',
|
||||||
|
fontSize: Math.max(Math.floor(iconSize * 0.8), 14),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{raw}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = normalizeOAuthIconKey(raw);
|
||||||
|
const IconComp = oauthProviderIconMap[key];
|
||||||
|
if (IconComp) {
|
||||||
|
return <IconComp size={iconSize} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Avatar size='extra-extra-small'>{raw.charAt(0).toUpperCase()}</Avatar>;
|
||||||
|
}
|
||||||
|
|
||||||
// 颜色列表
|
// 颜色列表
|
||||||
const colors = [
|
const colors = [
|
||||||
'amber',
|
'amber',
|
||||||
|
|||||||
Reference in New Issue
Block a user