diff --git a/controller/custom_oauth.go b/controller/custom_oauth.go index 8172e29718f3..6da3e646b92b 100644 --- a/controller/custom_oauth.go +++ b/controller/custom_oauth.go @@ -34,6 +34,7 @@ type CustomOAuthProviderResponse struct { EmailField string `json:"email_field"` WellKnown string `json:"well_known"` AuthStyle int `json:"auth_style"` + AutoLinkPolicy string `json:"auto_link_policy"` AccessPolicy string `json:"access_policy"` AccessDeniedMessage string `json:"access_denied_message"` } @@ -64,6 +65,7 @@ func toCustomOAuthProviderResponse(p *model.CustomOAuthProvider) *CustomOAuthPro EmailField: p.EmailField, WellKnown: p.WellKnown, AuthStyle: p.AuthStyle, + AutoLinkPolicy: p.AutoLinkPolicy, AccessPolicy: p.AccessPolicy, AccessDeniedMessage: p.AccessDeniedMessage, } @@ -129,6 +131,7 @@ type CreateCustomOAuthProviderRequest struct { EmailField string `json:"email_field"` WellKnown string `json:"well_known"` AuthStyle int `json:"auth_style"` + AutoLinkPolicy string `json:"auto_link_policy"` AccessPolicy string `json:"access_policy"` AccessDeniedMessage string `json:"access_denied_message"` } @@ -247,6 +250,7 @@ func CreateCustomOAuthProvider(c *gin.Context) { EmailField: req.EmailField, WellKnown: req.WellKnown, AuthStyle: req.AuthStyle, + AutoLinkPolicy: req.AutoLinkPolicy, AccessPolicy: req.AccessPolicy, AccessDeniedMessage: req.AccessDeniedMessage, } @@ -284,6 +288,7 @@ type UpdateCustomOAuthProviderRequest struct { EmailField string `json:"email_field"` WellKnown *string `json:"well_known"` // Optional: if nil, keep existing AuthStyle *int `json:"auth_style"` // Optional: if nil, keep existing + AutoLinkPolicy *string `json:"auto_link_policy"` // 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 } @@ -374,6 +379,9 @@ func UpdateCustomOAuthProvider(c *gin.Context) { if req.AuthStyle != nil { provider.AuthStyle = *req.AuthStyle } + if req.AutoLinkPolicy != nil { + provider.AutoLinkPolicy = *req.AutoLinkPolicy + } if req.AccessPolicy != nil { provider.AccessPolicy = *req.AccessPolicy } diff --git a/controller/oauth.go b/controller/oauth.go index 4d9725c1cc7a..340af12d926f 100644 --- a/controller/oauth.go +++ b/controller/oauth.go @@ -325,6 +325,17 @@ func findOrCreateOAuthUser(c *gin.Context, provider oauth.Provider, oauthUser *o } } + // Custom providers may explicitly link to an existing local user before creating a new one. + if genericProvider, ok := provider.(*oauth.GenericOAuthProvider); ok { + linkedUser, linked, err := tryAutoLinkCustomOAuthUser(genericProvider, oauthUser) + if err != nil { + return nil, err + } + if linked { + return linkedUser, nil + } + } + // User doesn't exist, create new user if registration is enabled if !common.RegisterEnabled { return nil, &OAuthRegistrationDisabledError{} @@ -428,6 +439,49 @@ func findOrCreateOAuthUser(c *gin.Context, provider oauth.Provider, oauthUser *o return user, nil } +func tryAutoLinkCustomOAuthUser(provider *oauth.GenericOAuthProvider, oauthUser *oauth.OAuthUser) (*model.User, bool, error) { + policy := provider.GetAutoLinkPolicy() + if policy == "none" { + return nil, false, nil + } + + user := &model.User{} + var err error + switch policy { + case "email_verified": + if oauthUser.Email == "" { + return nil, false, nil + } + if verified, ok := oauthUser.Extra["email_verified"].(bool); !ok || !verified { + return nil, false, nil + } + err = model.DB.Where("email = ?", oauthUser.Email).First(user).Error + case "username": + if oauthUser.Username == "" { + return nil, false, nil + } + err = model.DB.Where("username = ?", oauthUser.Username).First(user).Error + default: + return nil, false, nil + } + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, false, nil + } + if err != nil { + return nil, false, err + } + if user.Id == 0 { + return nil, false, nil + } + if user.Status != common.UserStatusEnabled { + return user, true, nil + } + if err := model.UpdateUserOAuthBinding(user.Id, provider.GetProviderId(), oauthUser.ProviderUserID); err != nil { + return nil, false, err + } + return user, true, nil +} + // Error types for OAuth type OAuthUserDeletedError struct{} diff --git a/model/custom_oauth_provider.go b/model/custom_oauth_provider.go index 12b4d11113d0..7dfa2a7bd276 100644 --- a/model/custom_oauth_provider.go +++ b/model/custom_oauth_provider.go @@ -59,6 +59,7 @@ type CustomOAuthProvider struct { // Advanced options 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) + AutoLinkPolicy string `json:"auto_link_policy" gorm:"type:varchar(32);default:'none'"` 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 @@ -191,6 +192,14 @@ func validateCustomOAuthProvider(provider *CustomOAuthProvider) error { if provider.Scopes == "" { provider.Scopes = "openid profile email" } + if provider.AutoLinkPolicy == "" { + provider.AutoLinkPolicy = "none" + } + switch provider.AutoLinkPolicy { + case "none", "email_verified", "username": + default: + return errors.New("invalid auto link policy") + } if strings.TrimSpace(provider.AccessPolicy) != "" { var policy accessPolicyPayload if err := common.UnmarshalJsonStr(provider.AccessPolicy, &policy); err != nil { diff --git a/model/user.go b/model/user.go index 7bc060ad1bf8..97bb4adfc1f9 100644 --- a/model/user.go +++ b/model/user.go @@ -517,6 +517,9 @@ func DeleteUserById(id int) (err error) { if id == 0 { return errors.New("id 为空!") } + if err = deleteUserOAuthBindingsByUserId(DB, id); err != nil { + return err + } user := User{Id: id} return user.Delete() } diff --git a/model/user_oauth_binding_delete_test.go b/model/user_oauth_binding_delete_test.go new file mode 100644 index 000000000000..0c1edd623bac --- /dev/null +++ b/model/user_oauth_binding_delete_test.go @@ -0,0 +1,51 @@ +package model + +import ( + "testing" + + "github.com/QuantumNous/new-api/common" + "github.com/stretchr/testify/require" +) + +func insertUserWithCustomOAuthBinding(t *testing.T, userId int, providerId int) { + t.Helper() + require.NoError(t, DB.Create(&User{ + Id: userId, + Username: "custom_oauth_deleted_user", + Status: common.UserStatusEnabled, + }).Error) + require.NoError(t, CreateUserOAuthBinding(&UserOAuthBinding{ + UserId: userId, + ProviderId: providerId, + ProviderUserId: "provider-user-id", + })) +} + +func countCustomOAuthBindingsForUser(t *testing.T, userId int) int64 { + t.Helper() + var count int64 + require.NoError(t, DB.Model(&UserOAuthBinding{}).Where("user_id = ?", userId).Count(&count).Error) + return count +} + +func TestDeleteUserById_RemovesCustomOAuthBindings(t *testing.T) { + truncateTables(t) + + insertUserWithCustomOAuthBinding(t, 201, 301) + require.Equal(t, int64(1), countCustomOAuthBindingsForUser(t, 201)) + + require.NoError(t, DeleteUserById(201)) + + require.Equal(t, int64(0), countCustomOAuthBindingsForUser(t, 201)) +} + +func TestHardDeleteUserById_RemovesCustomOAuthBindings(t *testing.T) { + truncateTables(t) + + insertUserWithCustomOAuthBinding(t, 202, 302) + require.Equal(t, int64(1), countCustomOAuthBindingsForUser(t, 202)) + + require.NoError(t, HardDeleteUserById(202)) + + require.Equal(t, int64(0), countCustomOAuthBindingsForUser(t, 202)) +} diff --git a/oauth/generic.go b/oauth/generic.go index 23ae2273fa0b..0774c881e935 100644 --- a/oauth/generic.go +++ b/oauth/generic.go @@ -18,7 +18,6 @@ import ( "github.com/QuantumNous/new-api/i18n" "github.com/QuantumNous/new-api/logger" "github.com/QuantumNous/new-api/model" - "github.com/QuantumNous/new-api/setting/system_setting" "github.com/gin-gonic/gin" "github.com/samber/lo" "github.com/tidwall/gjson" @@ -94,7 +93,7 @@ func (p *GenericOAuthProvider) ExchangeToken(ctx context.Context, code string, c logger.LogDebug(ctx, "[OAuth-Generic-%s] ExchangeToken: code=%s...", p.config.Slug, code[:min(len(code), 10)]) - redirectUri := fmt.Sprintf("%s/oauth/%s", system_setting.ServerAddress, p.config.Slug) + redirectUri := BuildOAuthRedirectURI(c, p.config.Slug) values := url.Values{} values.Set("grant_type", "authorization_code") values.Set("code", code) @@ -243,6 +242,10 @@ func (p *GenericOAuthProvider) GetUserInfo(ctx context.Context, token *OAuthToke username := gjson.Get(bodyStr, p.config.UsernameField).String() displayName := gjson.Get(bodyStr, p.config.DisplayNameField).String() email := gjson.Get(bodyStr, p.config.EmailField).String() + emailVerified := false + if emailVerifiedValue := gjson.Get(bodyStr, "email_verified"); emailVerifiedValue.Exists() { + emailVerified = emailVerifiedValue.Bool() + } // If user ID field returns a number, convert it if userId == "" { @@ -285,7 +288,8 @@ func (p *GenericOAuthProvider) GetUserInfo(ctx context.Context, token *OAuthToke DisplayName: displayName, Email: email, Extra: map[string]any{ - "provider": p.config.Slug, + "provider": p.config.Slug, + "email_verified": emailVerified, }, }, nil } @@ -322,6 +326,13 @@ func (p *GenericOAuthProvider) GetProviderId() int { return p.config.Id } +func (p *GenericOAuthProvider) GetAutoLinkPolicy() string { + if p.config.AutoLinkPolicy == "" { + return "none" + } + return p.config.AutoLinkPolicy +} + func normalizeAuthorizationTokenType(tokenType string) string { tokenType = strings.TrimSpace(tokenType) if tokenType == "" || strings.EqualFold(tokenType, "Bearer") { diff --git a/oauth/oidc.go b/oauth/oidc.go index 79582a4edb68..4d0097de9318 100644 --- a/oauth/oidc.go +++ b/oauth/oidc.go @@ -2,13 +2,14 @@ package oauth import ( "context" - "encoding/json" "fmt" + "io" "net/http" "net/url" "strings" "time" + "github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/i18n" "github.com/QuantumNous/new-api/logger" "github.com/QuantumNous/new-api/model" @@ -30,6 +31,8 @@ type oidcOAuthResponse struct { TokenType string `json:"token_type"` ExpiresIn int `json:"expires_in"` Scope string `json:"scope"` + Error string `json:"error"` + ErrorDesc string `json:"error_description"` } type oidcUser struct { @@ -56,7 +59,7 @@ func (p *OIDCProvider) ExchangeToken(ctx context.Context, code string, c *gin.Co logger.LogDebug(ctx, "[OAuth-OIDC] ExchangeToken: code=%s...", code[:min(len(code), 10)]) settings := system_setting.GetOIDCSettings() - redirectUri := fmt.Sprintf("%s/oauth/oidc", system_setting.ServerAddress) + redirectUri := BuildOAuthRedirectURI(c, "oidc") values := url.Values{} values.Set("client_id", settings.ClientId) values.Set("client_secret", settings.ClientSecret) @@ -85,16 +88,29 @@ func (p *OIDCProvider) ExchangeToken(ctx context.Context, code string, c *gin.Co logger.LogDebug(ctx, "[OAuth-OIDC] ExchangeToken response status: %d", res.StatusCode) + body, err := io.ReadAll(res.Body) + if err != nil { + logger.LogError(ctx, fmt.Sprintf("[OAuth-OIDC] ExchangeToken read body error: %s", err.Error())) + return nil, err + } + bodyStr := string(body) + logger.LogDebug(ctx, "[OAuth-OIDC] ExchangeToken response body: %s", bodyStr[:min(len(bodyStr), 500)]) + var oidcResponse oidcOAuthResponse - err = json.NewDecoder(res.Body).Decode(&oidcResponse) + err = common.Unmarshal(body, &oidcResponse) if err != nil { logger.LogError(ctx, fmt.Sprintf("[OAuth-OIDC] ExchangeToken decode error: %s", err.Error())) return nil, err } + if oidcResponse.Error != "" { + logger.LogError(ctx, fmt.Sprintf("[OAuth-OIDC] ExchangeToken OAuth error: %s - %s", oidcResponse.Error, oidcResponse.ErrorDesc)) + return nil, NewOAuthErrorWithRaw(i18n.MsgOAuthTokenFailed, map[string]any{"Provider": "OIDC"}, oidcResponse.ErrorDesc) + } + if oidcResponse.AccessToken == "" { logger.LogError(ctx, "[OAuth-OIDC] ExchangeToken failed: empty access token") - return nil, NewOAuthError(i18n.MsgOAuthTokenFailed, map[string]any{"Provider": "OIDC"}) + return nil, NewOAuthErrorWithRaw(i18n.MsgOAuthTokenFailed, map[string]any{"Provider": "OIDC"}, bodyStr) } logger.LogDebug(ctx, "[OAuth-OIDC] ExchangeToken success: scope=%s", oidcResponse.Scope) @@ -138,7 +154,7 @@ func (p *OIDCProvider) GetUserInfo(ctx context.Context, token *OAuthToken) (*OAu } var oidcUser oidcUser - err = json.NewDecoder(res.Body).Decode(&oidcUser) + err = common.DecodeJson(res.Body, &oidcUser) if err != nil { logger.LogError(ctx, fmt.Sprintf("[OAuth-OIDC] GetUserInfo decode error: %s", err.Error())) return nil, err diff --git a/oauth/redirect_uri.go b/oauth/redirect_uri.go new file mode 100644 index 000000000000..5f86dc9fbbff --- /dev/null +++ b/oauth/redirect_uri.go @@ -0,0 +1,35 @@ +package oauth + +import ( + "fmt" + "strings" + + "github.com/QuantumNous/new-api/setting/system_setting" + "github.com/gin-gonic/gin" +) + +func BuildOAuthRedirectURI(c *gin.Context, provider string) string { + base := "" + if c != nil && c.Request != nil { + proto := strings.TrimSpace(c.GetHeader("X-Forwarded-Proto")) + host := strings.TrimSpace(c.GetHeader("X-Forwarded-Host")) + if host == "" { + host = strings.TrimSpace(c.Request.Host) + } + if proto == "" { + if c.Request.TLS != nil { + proto = "https" + } else { + proto = "http" + } + } + if host != "" { + base = proto + "://" + host + } + } + if base == "" { + base = system_setting.ServerAddress + } + base = strings.TrimRight(base, "/") + return fmt.Sprintf("%s/oauth/%s", base, provider) +} diff --git a/web/src/features/profile/api.ts b/web/src/features/profile/api.ts index 1d112ab4a98f..84e8f6f8d934 100644 --- a/web/src/features/profile/api.ts +++ b/web/src/features/profile/api.ts @@ -173,6 +173,14 @@ export async function revokeOtherLoginSessions(): Promise { // Custom OAuth Binding APIs // ============================================================================ +export interface CustomOAuthBinding { + provider_id: number + provider_name: string + provider_slug: string + provider_icon: string + provider_user_id: string +} + /** * Get current user's custom OAuth bindings */ @@ -187,7 +195,7 @@ export async function getSelfOAuthBindings(): Promise< * Unbind a custom OAuth provider for current user */ export async function unbindCustomOAuth( - providerId: number + providerId: number | string ): Promise { const res = await api.delete(`/api/user/oauth/bindings/${providerId}`) return res.data diff --git a/web/src/features/profile/components/tabs/account-bindings-tab.tsx b/web/src/features/profile/components/tabs/account-bindings-tab.tsx index a8e3cf53cb22..727e1a9cf11b 100644 --- a/web/src/features/profile/components/tabs/account-bindings-tab.tsx +++ b/web/src/features/profile/components/tabs/account-bindings-tab.tsx @@ -49,7 +49,6 @@ import { buildOIDCOAuthUrl, type CustomOAuthBinding, } from '@/lib/oauth' - import { getSelfOAuthBindings, unbindCustomOAuth } from '../../api' import type { UserProfile, BindingItem } from '../../types' import { EmailBindDialog } from '../dialogs/email-bind-dialog' diff --git a/web/src/features/system-settings/auth/custom-oauth/components/provider-form-dialog.tsx b/web/src/features/system-settings/auth/custom-oauth/components/provider-form-dialog.tsx index acafc1b83166..b113f3e7718b 100644 --- a/web/src/features/system-settings/auth/custom-oauth/components/provider-form-dialog.tsx +++ b/web/src/features/system-settings/auth/custom-oauth/components/provider-form-dialog.tsx @@ -60,6 +60,7 @@ import { import { customOAuthFormSchema, AUTH_STYLE_OPTIONS, + AUTO_LINK_POLICY_OPTIONS, type CustomOAuthProvider, type CustomOAuthFormValues, } from '../types' @@ -106,6 +107,7 @@ export function ProviderFormDialog(props: ProviderFormDialogProps) { email_field: '', well_known: '', auth_style: 0, + auto_link_policy: 'none', access_policy: '', access_denied_message: '', }, @@ -137,6 +139,7 @@ export function ProviderFormDialog(props: ProviderFormDialogProps) { email_field: props.provider.email_field || '', well_known: props.provider.well_known || '', auth_style: props.provider.auth_style ?? 0, + auto_link_policy: props.provider.auto_link_policy || 'none', access_policy: props.provider.access_policy || '', access_denied_message: props.provider.access_denied_message || '', }) @@ -158,6 +161,7 @@ export function ProviderFormDialog(props: ProviderFormDialogProps) { email_field: '', well_known: '', auth_style: 0, + auto_link_policy: 'none', access_policy: '', access_denied_message: '', }) @@ -565,6 +569,45 @@ export function ProviderFormDialog(props: ProviderFormDialogProps) { )} /> + ( + + {t('Auto-link existing users')} + + + {t( + 'Default is disabled. Enable only for trusted identity providers.' + )} + + + + )} + /> + + + {t( + 'Built-in OIDC is a single fixed provider. Use Custom OAuth if you need multiple providers, custom names, icons, field mapping, or access policies.' + )} + { // Admin Binding Management APIs // ============================================================================ +export interface OAuthBinding { + provider_id: number + provider_name: string + provider_slug: string + provider_icon: string + provider_user_id: string +} + /** * Get user's custom OAuth bindings (admin) */ diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 86095b29c1b3..b691dd1e8db3 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -1243,6 +1243,12 @@ "Custom multipliers when specific user groups use specific token groups. Example: VIP users get 0.9x rate when using \"edit_this\" group tokens.": "Custom multipliers when specific user groups use specific token groups. Example: VIP users get 0.9x rate when using \"edit_this\" group tokens.", "Custom OAuth": "Custom OAuth", "Custom OAuth Providers": "Custom OAuth Providers", + "Auto-link existing users": "Auto-link existing users", + "Do not auto-link existing users": "Do not auto-link existing users", + "Auto-link by email": "Auto-link by email", + "Auto-link by username": "Auto-link by username", + "Default is disabled. Enable only for trusted identity providers.": "Default is disabled. Enable only for trusted identity providers.", + "Built-in OIDC is a single fixed provider. Use Custom OAuth if you need multiple providers, custom names, icons, field mapping, or access policies.": "Built-in OIDC is a single fixed provider. Use Custom OAuth if you need multiple providers, custom names, icons, field mapping, or access policies.", "Custom Seconds": "Custom Seconds", "Custom sidebar section": "Custom sidebar section", "Custom Time Range": "Custom Time Range", diff --git a/web/src/i18n/locales/fr.json b/web/src/i18n/locales/fr.json index 1d4dc4b9550d..66d905a99b99 100644 --- a/web/src/i18n/locales/fr.json +++ b/web/src/i18n/locales/fr.json @@ -1243,6 +1243,12 @@ "Custom multipliers when specific user groups use specific token groups. Example: VIP users get 0.9x rate when using \"edit_this\" group tokens.": "Multiplicateurs personnalisés lorsque des groupes d'utilisateurs spécifiques utilisent des groupes de jetons spécifiques. Exemple : les utilisateurs VIP obtiennent un taux de 0,9x lorsqu'ils utilisent les jetons du groupe \"edit_this\".", "Custom OAuth": "OAuth personnalisé", "Custom OAuth Providers": "Fournisseurs OAuth personnalisés", + "Auto-link existing users": "Lier automatiquement les utilisateurs existants", + "Do not auto-link existing users": "Ne pas lier automatiquement les utilisateurs existants", + "Auto-link by email": "Lier automatiquement par e-mail", + "Auto-link by username": "Lier automatiquement par nom d'utilisateur", + "Default is disabled. Enable only for trusted identity providers.": "Désactivé par défaut. À activer uniquement pour les fournisseurs d'identité de confiance.", + "Built-in OIDC is a single fixed provider. Use Custom OAuth if you need multiple providers, custom names, icons, field mapping, or access policies.": "L'OIDC intégré est un fournisseur fixe unique. Utilisez OAuth personnalisé si vous avez besoin de plusieurs fournisseurs, de noms personnalisés, d'icônes, de mappages de champs ou de politiques d'accès.", "Custom Seconds": "Secondes personnalisées", "Custom sidebar section": "Section de barre latérale personnalisée", "Custom Time Range": "Plage horaire personnalisée", diff --git a/web/src/i18n/locales/ja.json b/web/src/i18n/locales/ja.json index 7eaed9574c18..0cc54adb7ccb 100644 --- a/web/src/i18n/locales/ja.json +++ b/web/src/i18n/locales/ja.json @@ -1243,6 +1243,12 @@ "Custom multipliers when specific user groups use specific token groups. Example: VIP users get 0.9x rate when using \"edit_this\" group tokens.": "特定のユーザーグループが特定のトークングループを使用する場合のカスタム乗数。例: VIPユーザーが「edit_this」グループトークンを使用する場合、0.9倍のレートが適用されます。", "Custom OAuth": "カスタム OAuth", "Custom OAuth Providers": "カスタムOAuthプロバイダー", + "Auto-link existing users": "既存ユーザーを自動リンク", + "Do not auto-link existing users": "既存ユーザーを自動リンクしない", + "Auto-link by email": "メールアドレスで自動リンク", + "Auto-link by username": "ユーザー名で自動リンク", + "Default is disabled. Enable only for trusted identity providers.": "デフォルトでは無効です。信頼できる ID プロバイダーでのみ有効にしてください。", + "Built-in OIDC is a single fixed provider. Use Custom OAuth if you need multiple providers, custom names, icons, field mapping, or access policies.": "組み込み OIDC は単一の固定プロバイダーです。複数のプロバイダー、カスタム名、アイコン、フィールドマッピング、アクセス ポリシーが必要な場合はカスタム OAuth を使用してください。", "Custom Seconds": "カスタム秒数", "Custom sidebar section": "カスタムサイドバーセクション", "Custom Time Range": "カスタム時間範囲", diff --git a/web/src/i18n/locales/ru.json b/web/src/i18n/locales/ru.json index 9e087d1fa53f..28df897348c5 100644 --- a/web/src/i18n/locales/ru.json +++ b/web/src/i18n/locales/ru.json @@ -1243,6 +1243,12 @@ "Custom multipliers when specific user groups use specific token groups. Example: VIP users get 0.9x rate when using \"edit_this\" group tokens.": "Пользовательские множители, когда определенные группы пользователей используют определенные группы токенов. Пример: VIP-пользователи получают ставку 0.9x при использовании токенов группы \"edit_this\".", "Custom OAuth": "Пользовательский OAuth", "Custom OAuth Providers": "Пользовательские OAuth-провайдеры", + "Auto-link existing users": "Автоматически связывать существующих пользователей", + "Do not auto-link existing users": "Не связывать существующих пользователей автоматически", + "Auto-link by email": "Автоматически связывать по email", + "Auto-link by username": "Автоматически связывать по имени пользователя", + "Default is disabled. Enable only for trusted identity providers.": "По умолчанию отключено. Включайте только для доверенных поставщиков идентификации.", + "Built-in OIDC is a single fixed provider. Use Custom OAuth if you need multiple providers, custom names, icons, field mapping, or access policies.": "Встроенный OIDC — это один фиксированный провайдер. Используйте пользовательский OAuth, если нужны несколько провайдеров, пользовательские имена, иконки, сопоставление полей или политики доступа.", "Custom Seconds": "Пользовательские секунды", "Custom sidebar section": "Пользовательский раздел боковой панели", "Custom Time Range": "Пользовательский диапазон времени", diff --git a/web/src/i18n/locales/vi.json b/web/src/i18n/locales/vi.json index 2ae592388174..145a6d55e8bc 100644 --- a/web/src/i18n/locales/vi.json +++ b/web/src/i18n/locales/vi.json @@ -1243,6 +1243,12 @@ "Custom multipliers when specific user groups use specific token groups. Example: VIP users get 0.9x rate when using \"edit_this\" group tokens.": "Các hệ số nhân tùy chỉnh khi các nhóm người dùng cụ thể sử dụng các nhóm token cụ thể. Ví dụ: Người dùng VIP được hưởng tỷ lệ 0.9x khi sử dụng các token thuộc nhóm \"edit_this\".", "Custom OAuth": "OAuth tùy chỉnh", "Custom OAuth Providers": "Nhà cung cấp OAuth tùy chỉnh", + "Auto-link existing users": "Tự động liên kết người dùng hiện có", + "Do not auto-link existing users": "Không tự động liên kết người dùng hiện có", + "Auto-link by email": "Tự động liên kết theo email", + "Auto-link by username": "Tự động liên kết theo tên người dùng", + "Default is disabled. Enable only for trusted identity providers.": "Mặc định bị tắt. Chỉ bật cho nhà cung cấp danh tính đáng tin cậy.", + "Built-in OIDC is a single fixed provider. Use Custom OAuth if you need multiple providers, custom names, icons, field mapping, or access policies.": "OIDC tích hợp là một nhà cung cấp cố định duy nhất. Sử dụng OAuth tùy chỉnh nếu bạn cần nhiều nhà cung cấp, tên tùy chỉnh, biểu tượng, ánh xạ trường hoặc chính sách truy cập.", "Custom Seconds": "Giây tùy chỉnh", "Custom sidebar section": "Phần thanh bên tùy chỉnh", "Custom Time Range": "Khoảng thời gian tùy chỉnh", diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index 457b1c53f2b0..15a0ec751ef2 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -1243,6 +1243,12 @@ "Custom multipliers when specific user groups use specific token groups. Example: VIP users get 0.9x rate when using \"edit_this\" group tokens.": "当特定用户分组使用特定令牌分组时的自定义乘数。示例:VIP 用户在使用“edit_this”分组令牌时获得 0.9 倍费率。", "Custom OAuth": "自定义 OAuth", "Custom OAuth Providers": "自定义OAuth提供商", + "Auto-link existing users": "自动关联已有用户", + "Do not auto-link existing users": "不自动关联已有用户", + "Auto-link by email": "按邮箱自动关联", + "Auto-link by username": "按用户名自动关联", + "Default is disabled. Enable only for trusted identity providers.": "默认关闭。仅对可信身份提供商启用。", + "Built-in OIDC is a single fixed provider. Use Custom OAuth if you need multiple providers, custom names, icons, field mapping, or access policies.": "内置 OIDC 是单一固定提供商。如果需要多个提供商、自定义名称、图标、字段映射或访问策略,请使用自定义 OAuth。", "Custom Seconds": "自定义秒数", "Custom sidebar section": "自定义侧边栏部分", "Custom Time Range": "自定义时间范围",