Commit 3e84ec0a by CaIon

feat(auth): migrate Telegram to unified OAuth

Use authorization code flow with PKCE and verified ID tokens for Telegram login, binding, and security verification. Preserve existing bindings and require administrator OAuth configuration.

Keep the restricted WeChat first-enrollment session proof, fix missing-target authentication errors, and preserve callback requests after OAuth popups close.
parent 45c3fbe8
......@@ -3,6 +3,7 @@ package controller
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"strconv"
......@@ -421,20 +422,19 @@ func GetChannel(c *gin.Context) {
// 此函数依赖 SecureVerificationRequired 中间件,确保用户已通过安全验证
func GetChannelKey(c *gin.Context) {
channelId, err := strconv.Atoi(c.Param("id"))
if err != nil {
if err != nil || channelId <= 0 {
common.ApiErrorMsg(c, "渠道ID格式错误")
return
}
// 获取渠道信息(包含密钥)
channel, err := model.GetChannelById(channelId, true)
if err != nil {
writeSecurityOperationError(c, err)
if errors.Is(err, gorm.ErrRecordNotFound) {
common.ApiErrorI18n(c, i18n.MsgChannelNotExists)
return
}
if channel == nil {
common.ApiErrorMsg(c, "渠道不存在")
if err != nil {
writeSecurityOperationError(c, err)
return
}
......
......@@ -62,6 +62,7 @@ func GetStatus(c *gin.Context) {
"linuxdo_client_id": common.LinuxDOClientId,
"linuxdo_minimum_trust_level": common.LinuxDOMinimumTrustLevel,
"telegram_oauth": common.TelegramOAuthEnabled,
"telegram_oauth_configured": oauth.TelegramConfigurationError() == nil,
"telegram_bot_name": common.TelegramBotName,
"theme": "default",
"system_name": common.SystemName,
......
......@@ -32,6 +32,8 @@ type oauthStateRequest struct {
type oauthFlowPayload struct {
AffiliateCode string `json:"affiliate_code,omitempty"`
Verification *service.OAuthVerificationFlow `json:"verification,omitempty"`
Telegram *oauth.TelegramOAuthFlow `json:"telegram,omitempty"`
SessionIdentity *service.AuthIdentity `json:"session_identity,omitempty"`
}
// providerParams returns map with Provider key for i18n templates
......@@ -60,6 +62,14 @@ func GenerateOAuthCode(c *gin.Context) {
userID := 0
sessionID := ""
flowPayload := oauthFlowPayload{AffiliateCode: request.Aff}
if request.Provider == "telegram" {
telegramFlow, err := oauth.NewTelegramOAuthFlow()
if err != nil {
writeSecurityOperationError(c, err)
return
}
flowPayload.Telegram = telegramFlow
}
if request.Intent == model.AuthFlowIntentBind || request.Intent == model.AuthFlowIntentVerify {
identity, ok := middleware.GetSessionAuthIdentity(c)
if !ok {
......@@ -68,6 +78,13 @@ func GenerateOAuthCode(c *gin.Context) {
}
userID = identity.UserID
sessionID = identity.SessionID
if flowPayload.Telegram != nil {
if _, _, err := service.ValidateLoginSession(identity); err != nil {
writeSecurityOperationError(c, err)
return
}
flowPayload.SessionIdentity = &identity
}
if request.Intent == model.AuthFlowIntentVerify {
verification, err := service.StartOAuthVerification(identity, service.VerificationOperation{Scope: request.Scope, Context: request.Context}, request.Provider)
if err != nil {
......@@ -96,13 +113,14 @@ func GenerateOAuthCode(c *gin.Context) {
writeSecurityOperationError(c, err)
return
}
data := gin.H{"flow_token": state, "expires_at": expiresAt.Unix()}
if flowPayload.Telegram != nil {
data["authorization_url"] = flowPayload.Telegram.AuthorizationURL(state)
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": gin.H{
"flow_token": state,
"expires_at": expiresAt.Unix(),
},
"data": data,
})
}
......@@ -155,6 +173,29 @@ func HandleOAuth(c *gin.Context) {
}
// 3. Check if provider is enabled
var telegramPayload oauthFlowPayload
if providerName == "telegram" {
if err := oauth.TelegramConfigurationError(); err != nil {
writeSecurityOperationError(c, err)
return
}
if err := common.UnmarshalJsonStr(pendingFlow.Payload, &telegramPayload); err != nil || telegramPayload.Telegram == nil {
writeSecurityOperationError(c, model.ErrAuthFlowInvalid)
return
}
if pendingFlow.Intent != model.AuthFlowIntentLogin {
identity, _ := middleware.GetSessionAuthIdentity(c)
if telegramPayload.SessionIdentity == nil || *telegramPayload.SessionIdentity != identity {
writeSecurityOperationError(c, model.ErrAuthFlowInvalid)
return
}
if _, _, err := service.ValidateLoginSession(identity); err != nil {
writeSecurityOperationError(c, err)
return
}
}
c.Set(oauth.TelegramOAuthFlowContextKey, telegramPayload.Telegram)
}
if !provider.IsEnabled() {
common.ApiErrorI18n(c, i18n.MsgOAuthNotEnabled, providerParams(provider.GetName()))
return
......@@ -181,6 +222,10 @@ func HandleOAuth(c *gin.Context) {
code := c.Query("code")
token, err := provider.ExchangeToken(c.Request.Context(), code, c)
if err != nil {
if providerName == "telegram" {
writeSecurityOperationError(c, err)
return
}
handleOAuthError(c, err)
return
}
......@@ -188,9 +233,24 @@ func HandleOAuth(c *gin.Context) {
// 6. Get user info
oauthUser, err := provider.GetUserInfo(c.Request.Context(), token)
if err != nil {
if providerName == "telegram" {
writeSecurityOperationError(c, err)
return
}
handleOAuthError(c, err)
return
}
if providerName == "telegram" && pendingFlow.Intent == model.AuthFlowIntentBind {
_, err := model.ConsumeAuthFlowWithAction(state, consumeMatch, func(tx *gorm.DB, _ *model.AuthFlow) error {
return model.BindTelegramForSessionWithTx(tx, *telegramPayload.SessionIdentity, oauthUser.ProviderUserID)
})
if err != nil {
writeSecurityOperationError(c, err)
return
}
common.ApiSuccessI18n(c, i18n.MsgOAuthBindSuccess, gin.H{"action": "bind"})
return
}
flow, err := model.ConsumeAuthFlow(state, consumeMatch)
if err != nil {
c.JSON(http.StatusForbidden, gin.H{"success": false, "message": i18n.T(c, i18n.MsgOAuthStateInvalid)})
......@@ -303,6 +363,13 @@ func handleOAuthBind(c *gin.Context, provider oauth.Provider, oauthUser *oauth.O
// findOrCreateOAuthUser finds existing user or creates new user
func findOrCreateOAuthUser(c *gin.Context, provider oauth.Provider, oauthUser *oauth.OAuthUser, affiliateCode string) (*model.User, error) {
user := &model.User{}
if provider.ProviderUserIDColumn() == "telegram_id" {
err := provider.FillUserByProviderID(user, oauthUser.ProviderUserID)
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, oauth.ErrTelegramAccountNotBound
}
return user, err
}
// Check if user already exists with new ID
if provider.IsUserIDTaken(oauthUser.ProviderUserID) {
......
......@@ -233,10 +233,11 @@ func UpdateOption(c *gin.Context) {
return
}
case "TelegramOAuthEnabled":
if option.Value == "true" && common.TelegramBotToken == "" {
if option.Value == "true" && !system_setting.GetTelegramSettings().IsConfigured() {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "无法启用 Telegram OAuth,请先填入 Telegram Bot Token!",
"code": "TELEGRAM_OAUTH_NOT_CONFIGURED",
"message": "Telegram OAuth is not configured or enabled. Please contact your administrator.",
})
return
}
......
......@@ -7,6 +7,7 @@ import (
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/middleware"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/oauth"
"github.com/QuantumNous/new-api/service"
"github.com/gin-gonic/gin"
"github.com/go-webauthn/webauthn/protocol"
......@@ -33,6 +34,16 @@ func writeSecurityOperationError(c *gin.Context, err error) {
var code, message string
var protocolError *protocol.Error
switch {
case errors.Is(err, oauth.ErrTelegramOAuthNotConfigured):
code, message = "TELEGRAM_OAUTH_NOT_CONFIGURED", oauth.ErrTelegramOAuthNotConfigured.Error()
case errors.Is(err, oauth.ErrTelegramOAuthConflict):
code, message = "TELEGRAM_OAUTH_CONFLICT", oauth.ErrTelegramOAuthConflict.Error()
case errors.Is(err, oauth.ErrTelegramOAuthFailed):
code, message = "TELEGRAM_OAUTH_FAILED", oauth.ErrTelegramOAuthFailed.Error()
case errors.Is(err, oauth.ErrTelegramAccountNotBound):
code, message = "TELEGRAM_ACCOUNT_NOT_BOUND", oauth.ErrTelegramAccountNotBound.Error()
case errors.Is(err, model.ErrExternalIdentityAlreadyClaimed):
code, message = "TELEGRAM_BIND_ALREADY_BOUND", "This Telegram account is already bound."
case errors.Is(err, service.ErrVerificationContextInvalid):
status = http.StatusBadRequest
code, message = "SECURITY_CONTEXT_INVALID", service.ErrVerificationContextInvalid.Error()
......
......@@ -6,11 +6,13 @@ import (
"strconv"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/i18n"
"github.com/QuantumNous/new-api/middleware"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/service"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
type Verify2FARequest struct {
......@@ -333,7 +335,7 @@ func Admin2FAStats(c *gin.Context) {
func AdminDisable2FA(c *gin.Context) {
userIdStr := c.Param("id")
userId, err := strconv.Atoi(userIdStr)
if err != nil {
if err != nil || userId <= 0 {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "用户ID格式错误",
......@@ -343,6 +345,10 @@ func AdminDisable2FA(c *gin.Context) {
// 检查目标用户权限
targetUser, err := model.GetUserById(userId, false)
if errors.Is(err, gorm.ErrRecordNotFound) {
common.ApiErrorI18n(c, i18n.MsgUserNotExists)
return
}
if err != nil {
writeSecurityOperationError(c, err)
return
......
......@@ -14,6 +14,7 @@ require (
github.com/aws/smithy-go v1.24.2
github.com/bytedance/gopkg v0.1.3
github.com/casbin/casbin/v2 v2.135.0
github.com/coreos/go-oidc/v3 v3.21.0
github.com/gin-contrib/cors v1.7.2
github.com/gin-contrib/gzip v0.0.6
github.com/gin-contrib/static v0.0.1
......@@ -52,6 +53,7 @@ require (
golang.org/x/crypto v0.52.0
golang.org/x/image v0.41.0
golang.org/x/net v0.55.0
golang.org/x/oauth2 v0.36.0
golang.org/x/sync v0.20.0
golang.org/x/sys v0.45.0
golang.org/x/text v0.37.0
......@@ -73,6 +75,7 @@ require (
github.com/dlclark/regexp2/v2 v2.2.2 // indirect
github.com/go-faster/city v1.0.1 // indirect
github.com/go-faster/errors v0.7.1 // indirect
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e // indirect
github.com/hashicorp/go-version v1.8.0 // indirect
......
......@@ -964,6 +964,8 @@ github.com/coreos/go-iptables v0.4.5/go.mod h1:/mVI274lEDI2ns62jHCDnCyBF9Iwsmeka
github.com/coreos/go-iptables v0.5.0/go.mod h1:/mVI274lEDI2ns62jHCDnCyBF9Iwsmekav8Dbxlm1MU=
github.com/coreos/go-iptables v0.6.0/go.mod h1:Qe8Bv2Xik5FyTXwgIbLAnv2sWSBmvWdFETJConOQ//Q=
github.com/coreos/go-oidc v2.1.0+incompatible/go.mod h1:CgnwVTmzoESiwO9qyAFEMiHoZ1nMCKZlZ9V6mm3/LKc=
github.com/coreos/go-oidc/v3 v3.21.0 h1:wZo4Q9Pum8dYEj0eMUPrqR+kvuGkeUplbLpNCkBqoWM=
github.com/coreos/go-oidc/v3 v3.21.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4=
github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
github.com/coreos/go-systemd v0.0.0-20161114122254-48702e0da86b/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
......@@ -1144,6 +1146,8 @@ github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-ini/ini v1.25.4/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8=
github.com/go-ini/ini v1.66.6/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8=
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY=
......@@ -2389,6 +2393,8 @@ golang.org/x/oauth2 v0.4.0/go.mod h1:RznEsdpjGAINPTOF0UH/t+xJ75L18YO3Ho6Pyn+uRec
golang.org/x/oauth2 v0.5.0/go.mod h1:9/XBHVqLaWO3/BRHs5jbpYCnOZVjj5V0ndyaAM7KB4I=
golang.org/x/oauth2 v0.6.0/go.mod h1:ycmewcwgD4Rpr3eZJLSB4Kyyljb3qDh40vJ8STE5HKw=
golang.org/x/oauth2 v0.7.0/go.mod h1:hPLQkd9LyjfXTiRohC/41GhcFqxisoUQ99sCUOHO9x4=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
......
......@@ -161,6 +161,9 @@ func validateCustomOAuthProvider(provider *CustomOAuthProvider) error {
}
}
provider.Slug = slug
if slug == "telegram" {
return errors.New("the telegram slug is reserved for built-in Telegram OAuth; rename this custom provider")
}
if provider.ClientId == "" {
return errors.New("client ID is required")
......
......@@ -76,6 +76,38 @@ func ReleaseExternalIdentityWithTx(tx *gorm.DB, provider string, userId int) err
Delete(&ExternalIdentityClaim{}).Error
}
func GetUserByTelegramID(telegramID string) (*User, error) {
var user User
err := DB.Where("telegram_id = ?", telegramID).First(&user).Error
return &user, err
}
// BindTelegramForSessionWithTx preserves single ownership and the session that
// started the binding. The caller consumes its OAuth flow in this transaction.
func BindTelegramForSessionWithTx(tx *gorm.DB, identity AuthSessionIdentity, telegramID string) error {
if err := ValidateAuthSessionWithTx(tx, identity); err != nil {
return err
}
var user User
if err := tx.Select("id", "telegram_id").First(&user, identity.UserID).Error; err != nil {
return err
}
if user.TelegramId != "" {
return ErrExternalIdentityAlreadyClaimed
}
if err := ClaimExternalIdentityWithTx(tx, ExternalIdentityProviderTelegram, telegramID, user.Id); err != nil {
return err
}
result := tx.Model(&User{}).Where("id = ? AND (telegram_id = ? OR telegram_id IS NULL)", user.Id, "").Update("telegram_id", telegramID)
if result.Error != nil {
return result.Error
}
if result.RowsAffected != 1 {
return ErrExternalIdentityAlreadyClaimed
}
return nil
}
func releaseAllExternalIdentitiesWithTx(tx *gorm.DB, userId int) error {
if tx == nil || userId == 0 {
return errors.New("external identity release is invalid")
......
......@@ -13,6 +13,7 @@ var (
mu sync.RWMutex
// customProviderSlugs tracks which providers are custom (can be unregistered)
customProviderSlugs = make(map[string]bool)
customProviderConflicts = make(map[string]bool)
)
// Register registers an OAuth provider with the given name
......@@ -23,11 +24,22 @@ func Register(name string, provider Provider) {
}
// RegisterCustom registers a custom OAuth provider (can be unregistered later)
func RegisterCustom(name string, provider Provider) {
func RegisterCustom(name string, provider Provider) error {
mu.Lock()
defer mu.Unlock()
if providers[name] != nil && !customProviderSlugs[name] {
customProviderConflicts[name] = true
return fmt.Errorf("custom OAuth provider %q conflicts with a built-in provider; rename the custom provider", name)
}
providers[name] = provider
customProviderSlugs[name] = true
return nil
}
func HasCustomProviderConflict(name string) bool {
mu.RLock()
defer mu.RUnlock()
return customProviderConflicts[name]
}
// Unregister removes a provider from the registry
......@@ -94,6 +106,7 @@ func LoadCustomProviders() error {
delete(providers, name)
}
customProviderSlugs = make(map[string]bool)
customProviderConflicts = make(map[string]bool)
mu.Unlock()
// Load all custom providers from database
......@@ -104,14 +117,19 @@ func LoadCustomProviders() error {
}
// Register each custom provider
var conflict error
for _, config := range customProviders {
provider := NewGenericOAuthProvider(config)
RegisterCustom(config.Slug, provider)
if err := RegisterCustom(config.Slug, provider); err != nil {
common.SysError(err.Error())
conflict = err
continue
}
common.SysLog("Loaded custom OAuth provider: " + config.Name + " (" + config.Slug + ")")
}
common.SysLog(fmt.Sprintf("Loaded %d custom OAuth providers", len(customProviders)))
return nil
return conflict
}
// ReloadCustomProviders reloads all custom OAuth providers from the database
......@@ -122,13 +140,18 @@ func ReloadCustomProviders() error {
// RegisterOrUpdateCustomProvider registers or updates a single custom provider
func RegisterOrUpdateCustomProvider(config *model.CustomOAuthProvider) {
provider := NewGenericOAuthProvider(config)
mu.Lock()
defer mu.Unlock()
providers[config.Slug] = provider
customProviderSlugs[config.Slug] = true
if err := RegisterCustom(config.Slug, provider); err != nil {
common.SysError(err.Error())
}
}
// UnregisterCustomProvider unregisters a custom provider by slug
func UnregisterCustomProvider(slug string) {
Unregister(slug)
mu.Lock()
defer mu.Unlock()
if customProviderSlugs[slug] {
delete(providers, slug)
delete(customProviderSlugs, slug)
}
delete(customProviderConflicts, slug)
}
package oauth
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/setting/system_setting"
"github.com/coreos/go-oidc/v3/oidc"
"github.com/gin-gonic/gin"
"golang.org/x/oauth2"
)
const (
TelegramIssuer = "https://oauth.telegram.org"
TelegramOAuthFlowContextKey = "telegram_oauth_flow"
)
var (
ErrTelegramOAuthNotConfigured = errors.New("Telegram OAuth is not configured or enabled. Please contact your administrator.")
ErrTelegramOAuthConflict = errors.New("The telegram OAuth provider name is reserved. Ask your administrator to rename the conflicting custom provider.")
ErrTelegramOAuthFailed = errors.New("Telegram authorization failed. Please try again.")
ErrTelegramAccountNotBound = errors.New("This Telegram account is not linked. Sign in using another method and link it first.")
)
// TelegramOAuthFlow keeps the PKCE secret and original client configuration on
// the server. Only AuthorizationURL is sent to the browser.
type TelegramOAuthFlow struct {
CodeVerifier string `json:"code_verifier"`
ClientID string `json:"client_id"`
RedirectURI string `json:"redirect_uri"`
}
func TelegramConfigurationError() error {
if HasCustomProviderConflict("telegram") {
return ErrTelegramOAuthConflict
}
if !common.TelegramOAuthEnabled || !system_setting.GetTelegramSettings().IsConfigured() {
return ErrTelegramOAuthNotConfigured
}
return nil
}
func NewTelegramOAuthFlow() (*TelegramOAuthFlow, error) {
if err := TelegramConfigurationError(); err != nil {
return nil, err
}
redirectURI := strings.TrimRight(system_setting.ServerAddress, "/") + "/oauth/telegram"
parsed, err := url.Parse(redirectURI)
if err != nil || parsed.Host == "" || (parsed.Scheme != "https" && parsed.Scheme != "http") {
return nil, ErrTelegramOAuthNotConfigured
}
return &TelegramOAuthFlow{
CodeVerifier: oauth2.GenerateVerifier(),
ClientID: strings.TrimSpace(system_setting.GetTelegramSettings().ClientID),
RedirectURI: redirectURI,
}, nil
}
func (flow *TelegramOAuthFlow) AuthorizationURL(state string) string {
values := url.Values{
"client_id": {flow.ClientID},
"redirect_uri": {flow.RedirectURI},
"response_type": {"code"},
"scope": {"openid profile"},
"state": {state},
"code_challenge": {oauth2.S256ChallengeFromVerifier(flow.CodeVerifier)},
"code_challenge_method": {"S256"},
}
return TelegramIssuer + "/auth?" + values.Encode()
}
type TelegramProvider struct {
client *http.Client
keys oidc.KeySet
}
func init() {
Register("telegram", NewTelegramProvider(&http.Client{Timeout: 20 * time.Second}))
}
// NewTelegramProvider shares the HTTP client with a long-lived, cached JWKS
// verifier. Tests can exercise the real protocol using a local HTTP transport.
func NewTelegramProvider(client *http.Client) *TelegramProvider {
return &TelegramProvider{
client: client,
keys: oidc.NewRemoteKeySet(
oidc.ClientContext(context.Background(), client),
TelegramIssuer+"/.well-known/jwks.json",
),
}
}
func (p *TelegramProvider) GetName() string { return "Telegram" }
func (p *TelegramProvider) IsEnabled() bool { return TelegramConfigurationError() == nil }
func (p *TelegramProvider) ExchangeToken(ctx context.Context, code string, c *gin.Context) (*OAuthToken, error) {
if err := TelegramConfigurationError(); err != nil {
return nil, err
}
value, _ := c.Get(TelegramOAuthFlowContextKey)
flow, ok := value.(*TelegramOAuthFlow)
settings := system_setting.GetTelegramSettings()
if !ok || flow == nil || flow.CodeVerifier == "" || code == "" ||
flow.ClientID != strings.TrimSpace(settings.ClientID) ||
flow.RedirectURI != strings.TrimRight(system_setting.ServerAddress, "/")+"/oauth/telegram" {
return nil, ErrTelegramOAuthFailed
}
values := url.Values{
"grant_type": {"authorization_code"},
"code": {code},
"client_id": {flow.ClientID},
"redirect_uri": {flow.RedirectURI},
"code_verifier": {flow.CodeVerifier},
}
request, err := http.NewRequestWithContext(ctx, http.MethodPost, TelegramIssuer+"/token", strings.NewReader(values.Encode()))
if err != nil {
return nil, fmt.Errorf("%w: %v", ErrTelegramOAuthFailed, err)
}
request.SetBasicAuth(flow.ClientID, strings.TrimSpace(settings.ClientSecret))
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
response, err := p.client.Do(request)
if err != nil {
return nil, fmt.Errorf("%w: %v", ErrTelegramOAuthFailed, err)
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
return nil, fmt.Errorf("%w: token endpoint status %d", ErrTelegramOAuthFailed, response.StatusCode)
}
var token OAuthToken
if err := common.DecodeJson(io.LimitReader(response.Body, 1<<20), &token); err != nil {
return nil, fmt.Errorf("%w: %v", ErrTelegramOAuthFailed, err)
}
if token.IDToken == "" {
return nil, ErrTelegramOAuthFailed
}
token.ClientID = flow.ClientID
return &token, nil
}
func (p *TelegramProvider) GetUserInfo(ctx context.Context, token *OAuthToken) (*OAuthUser, error) {
if err := TelegramConfigurationError(); err != nil {
return nil, err
}
if token == nil || token.ClientID != strings.TrimSpace(system_setting.GetTelegramSettings().ClientID) {
return nil, ErrTelegramOAuthFailed
}
verifier := oidc.NewVerifier(TelegramIssuer, p.keys, &oidc.Config{
ClientID: token.ClientID, SupportedSigningAlgs: []string{oidc.RS256, oidc.ES256},
})
verified, err := verifier.Verify(ctx, token.IDToken)
if err != nil {
return nil, fmt.Errorf("%w: %v", ErrTelegramOAuthFailed, err)
}
var rawClaims json.RawMessage
if err := verified.Claims(&rawClaims); err != nil {
return nil, fmt.Errorf("%w: %v", ErrTelegramOAuthFailed, err)
}
var claims struct {
ID json.Number `json:"id"`
Name string `json:"name"`
Username string `json:"preferred_username"`
}
if err := common.Unmarshal(rawClaims, &claims); err != nil {
return nil, fmt.Errorf("%w: %v", ErrTelegramOAuthFailed, err)
}
id, err := strconv.ParseUint(claims.ID.String(), 10, 64)
if err != nil || id == 0 || verified.Subject == "" {
return nil, ErrTelegramOAuthFailed
}
return &OAuthUser{
ProviderUserID: strconv.FormatUint(id, 10),
Username: claims.Username, DisplayName: claims.Name,
}, nil
}
func (p *TelegramProvider) IsUserIDTaken(id string) bool { return model.IsTelegramIdAlreadyTaken(id) }
func (p *TelegramProvider) FillUserByProviderID(user *model.User, id string) error {
stored, err := model.GetUserByTelegramID(id)
if err != nil {
return err
}
*user = *stored
return nil
}
func (p *TelegramProvider) SetProviderUserID(user *model.User, id string) { user.TelegramId = id }
func (p *TelegramProvider) GetProviderPrefix() string { return "telegram_" }
func (p *TelegramProvider) ProviderUserIDColumn() string { return "telegram_id" }
......@@ -8,6 +8,7 @@ type OAuthToken struct {
ExpiresIn int `json:"expires_in,omitempty"`
Scope string `json:"scope,omitempty"`
IDToken string `json:"id_token,omitempty"`
ClientID string `json:"-"` // Expected OIDC audience from the server-owned flow.
}
// OAuthUser represents the user info from OAuth provider
......
......@@ -47,13 +47,13 @@ func SetApiRouter(router *gin.Engine) {
// OAuth routes - specific routes must come before :provider wildcard
apiRouter.POST("/oauth/state", middleware.CriticalRateLimit(), middleware.DisableCache(), middleware.TryUserAuth(), anonymousRequestBodyLimit, controller.GenerateOAuthCode)
apiRouter.POST("/oauth/email/bind", middleware.UserAuth(), middleware.CriticalRateLimit(), controller.EmailBind)
// Non-standard OAuth (WeChat, Telegram) - keep original routes
// WeChat uses its existing authorization-code service.
apiRouter.GET("/oauth/wechat", middleware.CriticalRateLimit(), middleware.DisableCache(), controller.WeChatAuth)
apiRouter.POST("/oauth/wechat/bind", middleware.UserAuth(), middleware.CriticalRateLimit(), controller.WeChatBind)
apiRouter.GET("/oauth/telegram/login", middleware.CriticalRateLimit(), middleware.DisableCache(), controller.TelegramLogin)
apiRouter.POST("/oauth/telegram/bind/start", middleware.UserAuth(), middleware.CriticalRateLimit(), middleware.DisableCache(), controller.TelegramBindStart)
apiRouter.GET("/oauth/telegram/bind/:flow_token", middleware.CriticalRateLimit(), middleware.DisableCache(), controller.TelegramBind)
// Standard OAuth providers (GitHub, Discord, OIDC, LinuxDO) - unified route
apiRouter.GET("/oauth/telegram/login", middleware.CriticalRateLimit(), middleware.DisableCache(), controller.TelegramLegacyAuth)
apiRouter.POST("/oauth/telegram/bind/start", middleware.UserAuth(), middleware.CriticalRateLimit(), middleware.DisableCache(), controller.TelegramLegacyAuth)
apiRouter.GET("/oauth/telegram/bind/:flow_token", middleware.CriticalRateLimit(), middleware.DisableCache(), controller.TelegramLegacyAuth)
// Standard OAuth providers (GitHub, Discord, OIDC, LinuxDO, Telegram) - unified route
apiRouter.GET("/oauth/:provider", middleware.CriticalRateLimit(), middleware.DisableCache(), middleware.TryUserAuth(), controller.HandleOAuth)
apiRouter.GET("/ratio_config", middleware.CriticalRateLimit(), controller.GetRatioConfig)
......
......@@ -19,6 +19,7 @@ const (
VerificationMethodPasskey = "passkey"
VerificationMethodPassword = "password"
VerificationMethodOAuth = "oauth"
VerificationMethodSession = "session"
VerificationScopeChannelKeyRead = "channel.key.read"
VerificationScopePasskeyRegister = "passkey.register"
VerificationScopePasskeyDelete = "passkey.delete"
......@@ -120,6 +121,7 @@ type verificationAccountState struct {
TwoFALocked bool
HasPasskey bool
PasskeyEnabled bool
WeChatEnrollment bool
}
// securityVerificationPolicy is the only operation-to-method policy. Device
......@@ -151,6 +153,8 @@ func securityVerificationPolicy(scope string, state verificationAccountState) ([
methods = []string{VerificationMethodPasskey}
case state.HasPassword:
methods = []string{VerificationMethodPassword}
case state.WeChatEnrollment:
methods = []string{VerificationMethodSession}
default:
methods = []string{VerificationMethodOAuth}
}
......@@ -195,6 +199,16 @@ func GetVerificationRequirements(identity AuthIdentity, scope string) (*Verifica
TwoFALocked: twoFA != nil && twoFA.IsLocked(), HasPasskey: err == nil,
PasskeyEnabled: system_setting.GetPasskeySettings().Enabled,
}
if (scope == VerificationScopeTwoFASetup || scope == VerificationScopePasskeyRegister) &&
!state.HasPassword && !state.HasTwoFA && !state.HasPasskey &&
user.WeChatId != "" && user.TelegramId == "" && user.GitHubId == "" &&
user.DiscordId == "" && user.OidcId == "" && user.LinuxDOId == "" {
bindings, err := model.GetUserOAuthBindingsByUserId(user.Id)
if err != nil {
return nil, err
}
state.WeChatEnrollment = len(bindings) == 0
}
methods, err := securityVerificationPolicy(scope, state)
if err != nil {
return nil, err
......@@ -210,6 +224,11 @@ func GetVerificationRequirements(identity AuthIdentity, scope string) (*Verifica
}
if len(requirements.OAuthProviders) == 0 {
methods[i].Available, methods[i].Reason = false, "No linked OAuth provider is available."
if user.TelegramId != "" {
if err := oauth.TelegramConfigurationError(); err != nil {
methods[i].Reason = err.Error()
}
}
}
}
return requirements, nil
......@@ -244,6 +263,8 @@ func verificationOAuthProviders(user *model.User) ([]VerificationOAuthProvider,
userID = user.OidcId
case "linux_do_id":
userID = user.LinuxDOId
case "telegram_id":
userID = user.TelegramId
}
}
if userID != "" {
......@@ -364,6 +385,9 @@ func VerifySecurityInput(identity AuthIdentity, input VerificationInput) (*Secur
return nil, err
}
switch input.Method {
case VerificationMethodSession:
// The policy above permits only first enrollment for a WeChat-only
// account. CompleteSecurityVerification rechecks its live session.
case VerificationMethodPassword:
password := input.Password
if common.PasswordLoginEncryptionEnabled {
......
package system_setting
import (
"strings"
"github.com/QuantumNous/new-api/setting/config"
)
type TelegramSettings struct {
ClientID string `json:"client_id"`
ClientSecret string `json:"client_secret"`
}
var telegramSettings TelegramSettings
func init() {
config.GlobalConfig.Register("telegram", &telegramSettings)
}
func GetTelegramSettings() *TelegramSettings {
return &telegramSettings
}
func (s *TelegramSettings) IsConfigured() bool {
return strings.TrimSpace(s.ClientID) != "" && strings.TrimSpace(s.ClientSecret) != ""
}
......@@ -16,12 +16,75 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { describe, expect, test } from 'vitest'
import { act, renderHook } from '@testing-library/react'
import { toast } from 'sonner'
import { afterEach, describe, expect, test, vi } from 'vitest'
import type { RefreshOutcome } from '@/lib/api'
import { api, type RefreshOutcome } from '@/lib/api'
import type { AuthBundle } from '@/stores/auth-store'
import { executeLogout } from './api'
import { useOAuthLogin } from './hooks/use-oauth-login'
import { consumeOAuthLoginRedirect } from './lib/oauth-callback-mode'
afterEach(() => vi.restoreAllMocks())
test.each([true, false])(
'starts Telegram OAuth only when configuration is ready: %s',
async (configured) => {
vi.spyOn(window, 'localStorage', 'get').mockReturnValue(
window.sessionStorage
)
const post = vi.spyOn(api, 'post').mockImplementation(async (url) => {
if (url === '/api/oauth/state') {
return {
data: {
success: true,
data: {
flow_token: 'telegram-state',
authorization_url: 'https://oauth.telegram.org/auth?server=pkce',
},
},
}
}
if (url === '/api/user/auth/logout') return { data: { success: true } }
throw new Error(`Unexpected POST ${url}`)
})
const open = vi.spyOn(window, 'open').mockReturnValue(null)
const error = vi.spyOn(toast, 'error')
const { result } = renderHook(() =>
useOAuthLogin(
{ telegram_oauth: true, telegram_oauth_configured: configured },
'/console/personal'
)
)
await act(() => result.current.handleTelegramLogin())
if (configured) {
expect(post.mock.calls.map(([url]) => url)).toEqual([
'/api/oauth/state',
'/api/user/auth/logout',
])
expect(post).toHaveBeenCalledWith(
'/api/oauth/state',
expect.objectContaining({ provider: 'telegram', intent: 'login' }),
expect.anything()
)
expect(open).toHaveBeenCalledWith(
'https://oauth.telegram.org/auth?server=pkce',
'_self'
)
expect(consumeOAuthLoginRedirect('telegram-state')).toBe(
'/console/personal'
)
} else {
expect(post).not.toHaveBeenCalled()
expect(open).not.toHaveBeenCalled()
expect(error).toHaveBeenCalledWith(
'Telegram OAuth is not configured or enabled. Please contact your administrator.'
)
}
}
)
const bundle: AuthBundle = {
access_token: 'access-token',
......
......@@ -20,6 +20,7 @@ import axios from 'axios'
import { api, refreshAuthentication, type RefreshOutcome } from '@/lib/api'
import { AuthOperationError } from '@/lib/secure-verification'
import { getServerErrorMessageKey } from '@/lib/server-error-message'
import { useAuthStore } from '@/stores/auth-store'
import {
......@@ -166,12 +167,12 @@ export async function githubOAuthStart(clientId: string, state: string) {
}
// Get OAuth state for CSRF protection
export async function createOAuthFlow(
export async function createOAuthAuthorization(
provider: string,
intent: 'login' | 'bind' | 'verify',
operation?: VerificationOperation,
signal?: AbortSignal
): Promise<string> {
): Promise<{ state: string; authorizationUrl?: string }> {
const aff = intent === 'login' ? getAffiliateCode() : ''
const res = await api.post(
'/api/oauth/state',
......@@ -190,17 +191,32 @@ export async function createOAuthFlow(
}
)
if (res.data?.success) {
if (typeof res.data.data === 'string') return res.data.data
if (typeof res.data.data === 'string') return { state: res.data.data }
if (typeof res.data.data?.flow_token === 'string') {
return res.data.data.flow_token
return {
state: res.data.data.flow_token,
authorizationUrl: res.data.data.authorization_url,
}
}
}
throw new AuthOperationError(
res.data?.message || 'Failed to initialize OAuth',
getServerErrorMessageKey(res.data) ||
res.data?.message ||
'Failed to initialize OAuth',
res.data?.code
)
}
export async function createOAuthFlow(
provider: string,
intent: 'login' | 'bind' | 'verify',
operation?: VerificationOperation,
signal?: AbortSignal
): Promise<string> {
return (await createOAuthAuthorization(provider, intent, operation, signal))
.state
}
// WeChat login by authorization code
export async function wechatLoginByCode(code: string): Promise<ApiResponse> {
const res = await api.get('/api/oauth/wechat', { params: { code } })
......
......@@ -31,7 +31,6 @@ import { cn } from '@/lib/utils'
import { useOAuthLogin } from '../hooks/use-oauth-login'
import type { SystemStatus } from '../types'
import { TelegramLoginDialog } from './telegram-login-dialog'
type OAuthProvidersProps = {
status: SystemStatus | null
......@@ -69,10 +68,6 @@ export function OAuthProviders({
handleLinuxDOLogin,
handleTelegramLogin,
handleCustomOAuthLogin,
isTelegramDialogOpen,
isTelegramPending,
handleTelegramAuthorization,
setIsTelegramDialogOpen,
} = useOAuthLogin(status, redirectTo)
const providerButtons: ProviderButton[] = []
......@@ -150,7 +145,6 @@ export function OAuthProviders({
if (providerButtons.length === 0) return null
return (
<>
<div className={cn('space-y-3', className)}>
<div className='relative'>
<div className='absolute inset-0 flex items-center'>
......@@ -181,14 +175,5 @@ export function OAuthProviders({
)}
</div>
</div>
<TelegramLoginDialog
open={isTelegramDialogOpen}
botName={status?.telegram_bot_name ?? ''}
pending={isTelegramPending}
onOpenChange={setIsTelegramDialogOpen}
onAuthorization={handleTelegramAuthorization}
/>
</>
)
}
......@@ -20,18 +20,18 @@ import { useState, useRef, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { clearAuthentication, isAuthBundle } from '@/lib/api'
import { clearAuthentication } from '@/lib/api'
import { AuthOperationError } from '@/lib/secure-verification'
import { createOAuthFlow, logout, telegramLogin } from '../api'
import { createOAuthAuthorization, createOAuthFlow, logout } from '../api'
import {
buildGitHubOAuthUrl,
buildDiscordOAuthUrl,
buildOIDCOAuthUrl,
buildLinuxDOOAuthUrl,
} from '../lib/oauth'
import { pickTelegramAuthorization } from '../lib/telegram-login'
import { rememberOAuthLoginRedirect } from '../lib/oauth-callback-mode'
import type { SystemStatus, CustomOAuthProviderInfo } from '../types'
import { useAuthRedirect } from './use-auth-redirect'
/**
* Hook for managing OAuth login
......@@ -41,10 +41,7 @@ export function useOAuthLogin(
redirectTo?: string
) {
const { t } = useTranslation()
const { handleLoginSuccess } = useAuthRedirect()
const [isLoading, setIsLoading] = useState(false)
const [isTelegramDialogOpen, setIsTelegramDialogOpen] = useState(false)
const [isTelegramPending, setIsTelegramPending] = useState(false)
const [githubButtonText, setGithubButtonText] = useState('')
const [githubButtonDisabled, setGithubButtonDisabled] = useState(false)
const githubTimeoutRef = useRef<NodeJS.Timeout | null>(null)
......@@ -160,46 +157,27 @@ export function useOAuthLogin(
}
const handleTelegramLogin = async () => {
if (!status?.telegram_bot_name?.trim()) {
toast.error(t('Login failed'))
return
}
setIsLoading(true)
try {
await resetSession()
setIsTelegramDialogOpen(true)
} catch {
if (!status?.telegram_oauth_configured) {
toast.error(
t('Failed to start {{provider}} login', { provider: 'Telegram' })
t(
'Telegram OAuth is not configured or enabled. Please contact your administrator.'
)
)
} finally {
setIsLoading(false)
}
}
const handleTelegramAuthorization = async (value: unknown) => {
const authorization = pickTelegramAuthorization(value)
if (!authorization) {
toast.error(t('Login failed'))
return
}
setIsTelegramPending(true)
setIsLoading(true)
try {
const response = await telegramLogin(authorization)
if (!response.success || !isAuthBundle(response.data)) {
toast.error(t('Login failed'))
return
const authorization = await createOAuthAuthorization('telegram', 'login')
if (!authorization.authorizationUrl) {
throw new AuthOperationError('Failed to initialize OAuth')
}
setIsTelegramDialogOpen(false)
await handleLoginSuccess(response.data, redirectTo)
toast.success(t('Welcome back!'))
} catch {
toast.error(t('Login failed'))
await resetSession()
rememberOAuthLoginRedirect(authorization.state, redirectTo)
window.open(authorization.authorizationUrl, '_self')
} catch (error) {
toast.error(t(AuthOperationError.from(error).message))
} finally {
setIsTelegramPending(false)
setIsLoading(false)
}
}
......@@ -235,15 +213,11 @@ export function useOAuthLogin(
isLoading,
githubButtonText,
githubButtonDisabled,
isTelegramDialogOpen,
isTelegramPending,
handleGitHubLogin,
handleDiscordLogin,
handleOIDCLogin,
handleLinuxDOLogin,
handleTelegramLogin,
handleTelegramAuthorization,
setIsTelegramDialogOpen,
handleCustomOAuthLogin,
}
}
......@@ -131,7 +131,9 @@ it('closes an aborted popup and ignores a late authorization response', async ()
expect(popup.location.replace).not.toHaveBeenCalled()
})
it('aborts the callback request if the user closes the popup before it finishes', async () => {
it.each(['bind', 'verify'] as const)(
'keeps the %s callback request alive after the popup closes',
async (intent) => {
vi.useFakeTimers()
const popup = popupWindow()
const prepared = Promise.resolve({
......@@ -140,17 +142,60 @@ it('aborts the callback request if the user closes the popup before it finishes'
})
const result = openOAuthPopup({
provider: 'github',
intent: 'verify',
intent,
signal: new AbortController().signal,
prepare: () => prepared,
})
await prepared
callbackMessage(popup, { intent })
const exchange = await result
popup.closed = true
await vi.advanceTimersByTimeAsync(11 * 60_000)
expect(exchange.signal.aborted).toBe(false)
expect(vi.getTimerCount()).toBe(0)
exchange.finish({ success: true })
}
)
it('still cancels when the caller aborts after receiving a callback', async () => {
const popup = popupWindow()
const controller = new AbortController()
const prepared = Promise.resolve({
state: 'state',
url: 'https://example.com/authorize',
})
const result = openOAuthPopup({
provider: 'github',
intent: 'verify',
signal: controller.signal,
prepare: () => prepared,
})
await prepared
callbackMessage(popup)
const exchange = await result
controller.abort(new AuthOperationError('Cancelled', 'AUTH_CANCELLED'))
expect(exchange.signal.aborted).toBe(true)
expect(popup.closed).toBe(true)
})
it('cancels when the popup closes before receiving a callback', async () => {
vi.useFakeTimers()
const popup = popupWindow()
const result = openOAuthPopup({
provider: 'github',
intent: 'verify',
signal: new AbortController().signal,
prepare: async () => ({
state: 'state',
url: 'https://example.com/authorize',
}),
})
const rejected = expect(result).rejects.toMatchObject({
code: 'AUTH_CANCELLED',
})
popup.closed = true
await vi.advanceTimersByTimeAsync(500)
expect(exchange.signal.aborted).toBe(true)
expect(exchange.signal.reason).toMatchObject({ code: 'AUTH_CANCELLED' })
await rejected
expect(vi.getTimerCount()).toBe(0)
})
......
......@@ -19,6 +19,29 @@ For commercial licensing, please contact support@quantumnous.com
const OAUTH_POPUP_FLOW_KEY_PREFIX = 'oauth_popup_flow:'
export function rememberOAuthLoginRedirect(
state: string,
redirect?: string
): void {
if (!redirect) return
try {
window.sessionStorage.setItem(`oauth_login_redirect:${state}`, redirect)
} catch {
// Login can still complete using the default destination.
}
}
export function consumeOAuthLoginRedirect(state: string): string | null {
try {
const key = `oauth_login_redirect:${state}`
const redirect = window.sessionStorage.getItem(key)
window.sessionStorage.removeItem(key)
return redirect
} catch {
return null
}
}
/** Minimal shape of `sessionStorage`, kept structural so tests can fake it. */
export interface OAuthModeStorage {
getItem: (key: string) => string | null
......
......@@ -126,6 +126,9 @@ export function openOAuthPopup(
return
}
received = true
stopCloseWatcher()
clearTimeout(deadline)
window.removeEventListener('message', onMessage)
resolve({
callback: {
provider: options.provider,
......
......@@ -16,6 +16,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { waitFor } from '@testing-library/react'
import { AxiosError, type InternalAxiosRequestConfig } from 'axios'
import { afterEach, expect, it, vi } from 'vitest'
......@@ -24,10 +25,100 @@ import { AuthOperationError, authResult } from '@/lib/secure-verification'
import { useAuthStore, type AuthBundle } from '@/stores/auth-store'
import { createOAuthFlow } from '../../api'
import { OAUTH_POPUP_CALLBACK_MESSAGE } from '../../constants'
import { checkVerificationMethods, verify } from '../api'
import type { SecurityProof } from '../types'
const originalAdapter = api.defaults.adapter
const originalLocation = window.location.href
it.each([false, true])(
'retains the Telegram verification request after popup close and honors caller cancellation: %s',
async (cancel) => {
const popup = {
closed: false,
location: { replace: vi.fn() },
sessionStorage: window.sessionStorage,
close: vi.fn(),
postMessage: vi.fn(),
}
vi.spyOn(window, 'open').mockReturnValue(popup as unknown as Window)
vi.spyOn(api, 'post').mockResolvedValue({
data: {
success: true,
data: {
flow_token: 'verification-state',
authorization_url: 'https://oauth.telegram.org/auth?server=pkce',
},
},
})
const proof: SecurityProof = {
proof_token: 'telegram-proof',
method: 'oauth',
scope: '2fa.setup',
expires_at: Math.floor(Date.now() / 1000) + 300,
}
let resolve!: (response: {
data: { success: boolean; data: SecurityProof }
}) => void
const response = new Promise<{
data: { success: boolean; data: SecurityProof }
}>((done) => {
resolve = done
})
const get = vi
.spyOn(api, 'get')
.mockImplementation((url) =>
url === '/api/status'
? Promise.resolve({ data: { success: true, data: {} } })
: response
)
const controller = new AbortController()
const result = verify(
{ method: 'oauth', provider: 'telegram' },
{ scope: '2fa.setup' },
false,
controller.signal
)
const outcome = cancel
? expect(result).rejects.toMatchObject({ code: 'AUTH_CANCELLED' })
: expect(result).resolves.toEqual(proof)
await waitFor(() =>
expect(popup.location.replace).toHaveBeenCalledWith(
'https://oauth.telegram.org/auth?server=pkce'
)
)
const event = new MessageEvent('message', {
origin: window.location.origin,
data: {
type: OAUTH_POPUP_CALLBACK_MESSAGE,
intent: 'verify',
provider: 'telegram',
state: 'verification-state',
code: 'code',
},
})
Object.defineProperty(event, 'source', { value: popup })
window.dispatchEvent(event)
popup.closed = true
await waitFor(() =>
expect(get).toHaveBeenCalledWith(
'/api/oauth/telegram',
expect.objectContaining({ singleUseAuthorization: true })
)
)
const signal = get.mock.calls.find(
([url]) => url === '/api/oauth/telegram'
)?.[1]?.signal
expect(signal?.aborted).toBe(false)
if (cancel) {
controller.abort(new AuthOperationError('Cancelled', 'AUTH_CANCELLED'))
}
expect(signal?.aborted).toBe(cancel)
resolve({ data: { success: true, data: proof } })
await outcome
}
)
const sessionBundle = {
access_token: 'access-token',
token_type: 'Bearer' as const,
......
......@@ -76,6 +76,67 @@ afterEach(() => {
vi.unstubAllGlobals()
})
it.each(['success', 'cancel', 'retry'] as const)(
'automatically obtains the first-enrollment session proof and handles %s',
async (outcome) => {
vi.spyOn(api, 'get').mockResolvedValue({
data: {
success: true,
data: {
...passwordRequirements,
methods: [{ method: 'session', available: true }],
},
},
})
const proof: SecurityProof = {
proof_token: 'session-proof',
method: 'session',
scope: 'passkey.register',
expires_at: Math.floor(Date.now() / 1000) + 300,
}
const reply = pendingResponse<{
data: { success: boolean; data: SecurityProof }
}>()
const post = vi.spyOn(api, 'post').mockReturnValue(reply.promise)
if (outcome === 'retry') {
post.mockRejectedValueOnce(new Error('Session check failed'))
}
const result = vi.fn()
const user = userEvent.setup()
render(<Harness onResult={result} />)
await user.click(screen.getByText('Protected action'))
if (outcome === 'retry') {
expect(await screen.findByRole('alert')).toHaveTextContent(
'Session check failed'
)
await user.click(screen.getByRole('button', { name: 'Retry' }))
}
await waitFor(() =>
expect(post).toHaveBeenCalledTimes(outcome === 'retry' ? 2 : 1)
)
expect(post).toHaveBeenLastCalledWith(
'/api/verify',
{ method: 'session', scope: 'passkey.register' },
expect.objectContaining({ signal: expect.any(AbortSignal) })
)
expect(screen.getByRole('button', { name: 'Verify' })).toBeDisabled()
if (outcome === 'cancel') {
await user.click(screen.getByRole('button', { name: 'Cancel' }))
expect(post.mock.calls[0][2]?.signal?.aborted).toBe(true)
}
await act(async () => {
reply.resolve({ data: { success: true, data: proof } })
await reply.promise
})
await waitFor(() =>
expect(result).toHaveBeenCalledExactlyOnceWith(
outcome === 'cancel' ? null : proof
)
)
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
}
)
it('keeps the requested channel context fixed while verification is open', async () => {
vi.spyOn(api, 'get').mockResolvedValue({
data: {
......
......@@ -29,7 +29,7 @@ import {
authResult,
} from '@/lib/secure-verification'
import { createOAuthFlow } from '../api'
import { createOAuthAuthorization } from '../api'
import { openOAuthPopup } from '../lib/oauth-popup'
import { encryptPassword } from '../lib/password-encryption'
import {
......@@ -92,6 +92,18 @@ export async function verify(
}
let proof: SecurityProof
switch (input.method) {
case 'session':
proof = await authResult<SecurityProof>(
api.post(
'/api/verify',
{ method: 'session', ...operationFields },
{
...authRequestOptions,
signal,
}
)
)
break
case '2fa':
proof = await authResult<SecurityProof>(
api.post(
......@@ -188,8 +200,8 @@ async function verifyOAuth(
intent: 'verify',
signal,
prepare: async (popupSignal) => {
const [state, status] = await Promise.all([
createOAuthFlow(provider, 'verify', operation, popupSignal),
const [authorization, status] = await Promise.all([
createOAuthAuthorization(provider, 'verify', operation, popupSignal),
authResult<SystemStatus>(
api.get('/api/status', {
...authRequestOptions,
......@@ -198,7 +210,12 @@ async function verifyOAuth(
})
),
])
return { state, url: buildOAuthAuthorizationUrl(provider, state, status) }
return {
state: authorization.state,
url:
authorization.authorizationUrl ??
buildOAuthAuthorizationUrl(provider, authorization.state, status),
}
},
})
try {
......
......@@ -45,6 +45,7 @@ const methodLabels: Record<VerificationMethod, string> = {
passkey: 'Passkey',
password: 'Password',
oauth: 'Linked account',
session: 'Login session',
}
export function SecureVerificationDialog(props: SecureVerificationDialogProps) {
......
......@@ -134,6 +134,24 @@ export function useSecureVerification() {
current.request.scope,
current.controller.signal
)
if (pending.current !== current) return
if (
requirements.methods.length === 1 &&
requirements.methods[0].method === 'session' &&
requirements.methods[0].available
) {
const proof = await verify(
{ method: 'session' },
current.request,
requirements.password_encryption_enabled,
current.controller.signal
)
if (pending.current !== current) return
pending.current = null
dispatch({ type: 'reset' })
current.resolve(proof)
return
}
if (pending.current === current) {
dispatch({ type: 'loaded', requirements })
}
......
......@@ -16,7 +16,12 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
export type VerificationMethod = '2fa' | 'passkey' | 'password' | 'oauth'
export type VerificationMethod =
| '2fa'
| 'passkey'
| 'password'
| 'oauth'
| 'session'
export type SecurityProofScope =
| 'channel.key.read'
| 'passkey.register'
......@@ -49,6 +54,7 @@ export type VerificationInput =
| { method: 'password'; password: string }
| { method: 'passkey' }
| { method: 'oauth'; provider: string }
| { method: 'session' }
export type RequestVerificationOptions = VerificationOperation & {
title?: string
......
......@@ -108,6 +108,7 @@ export interface SystemStatus {
linuxdo_oauth?: boolean
linuxdo_client_id?: string
telegram_oauth?: boolean
telegram_oauth_configured?: boolean
telegram_bot_name?: string
passkey_login?: boolean
wechat_login?: boolean
......@@ -154,6 +155,7 @@ export interface SystemStatus {
linuxdo_oauth?: boolean
linuxdo_client_id?: string
telegram_oauth?: boolean
telegram_oauth_configured?: boolean
telegram_bot_name?: string
passkey_login?: boolean
wechat_login?: boolean
......
......@@ -16,13 +16,17 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { render, screen, waitFor } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { act, render, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { toast } from 'sonner'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { OAUTH_POPUP_CALLBACK_MESSAGE } from '@/features/auth/constants'
import type { UserProfile } from '@/features/profile/types'
import { api } from '@/lib/api'
import { AccountBindings } from '../components/account-bindings'
import { PasskeyCard } from '../components/passkey-card'
import { TwoFACard } from '../components/two-fa-card'
......@@ -71,6 +75,140 @@ afterEach(() => {
} else Reflect.deleteProperty(navigator, 'credentials')
})
it.each(['2fa', 'passkey'] as const)(
'blocks %s enrollment and explains missing Telegram configuration',
async (factor) => {
const reason =
'Telegram OAuth is not configured or enabled. Please contact your administrator.'
vi.spyOn(api, 'get').mockImplementation(async (url) => ({
data: {
success: true,
data:
url === '/api/verify/methods'
? {
scope: factor === '2fa' ? '2fa.setup' : 'passkey.register',
methods: [{ method: 'oauth', available: false, reason }],
oauth_providers: [],
password_encryption_enabled: false,
}
: { enabled: false, locked: false },
},
}))
const post = vi.spyOn(api, 'post')
const user = userEvent.setup()
render(
factor === '2fa' ? (
<TwoFACard loading={false} />
) : (
<PasskeyCard loading={false} />
)
)
const enable = await screen.findByRole('button', {
name: factor === '2fa' ? 'Enable' : 'Enable Passkey',
})
await waitFor(() => expect(enable).toBeEnabled())
await user.click(enable)
expect(await screen.findByText(reason)).toBeVisible()
expect(post).not.toHaveBeenCalled()
expect(navigator.credentials.create).not.toHaveBeenCalled()
}
)
it('refreshes Telegram bindings from the server result after the callback popup has closed', async () => {
const popup = {
closed: false,
location: { replace: vi.fn() },
sessionStorage: window.sessionStorage,
close: vi.fn(),
postMessage: vi.fn(),
}
vi.spyOn(window, 'open').mockReturnValue(popup as unknown as Window)
vi.spyOn(api, 'post').mockResolvedValue({
data: {
success: true,
data: {
flow_token: 'binding-state',
authorization_url: 'https://oauth.telegram.org/auth?server=pkce',
},
},
})
let resolve!: (response: {
data: { success: boolean; data: { action: string } }
}) => void
const response = new Promise<{
data: { success: boolean; data: { action: string } }
}>((done) => {
resolve = done
})
const get = vi.spyOn(api, 'get').mockImplementation((url) =>
url === '/api/status'
? Promise.resolve({
data: {
success: true,
data: { telegram_oauth: true, telegram_oauth_configured: true },
},
})
: response
)
const onUpdate = vi.fn()
const user = userEvent.setup()
const client = new QueryClient({
defaultOptions: { queries: { retry: false } },
})
render(
<QueryClientProvider client={client}>
<AccountBindings
profile={{ email: 'bound@example.com' } as UserProfile}
onUpdate={onUpdate}
/>
</QueryClientProvider>
)
const telegram = (await screen.findByText('Telegram')).closest('li')
if (!telegram) throw new Error('Telegram binding entry is missing')
await user.click(within(telegram).getByRole('button', { name: 'Bind' }))
await waitFor(() =>
expect(popup.location.replace).toHaveBeenCalledWith(
'https://oauth.telegram.org/auth?server=pkce'
)
)
const message = new MessageEvent('message', {
origin: window.location.origin,
data: {
type: OAUTH_POPUP_CALLBACK_MESSAGE,
provider: 'telegram',
intent: 'bind',
state: 'binding-state',
code: 'code',
},
})
Object.defineProperty(message, 'source', { value: popup })
act(() => {
window.dispatchEvent(message)
popup.closed = true
})
await waitFor(() =>
expect(get).toHaveBeenCalledWith(
'/api/oauth/telegram',
expect.objectContaining({
params: expect.objectContaining({
state: 'binding-state',
code: 'code',
}),
})
)
)
expect(onUpdate).not.toHaveBeenCalled()
expect(
get.mock.calls.find(([url]) => url === '/api/oauth/telegram')?.[1]?.signal
?.aborted
).toBe(false)
await act(async () => {
resolve({ data: { success: true, data: { action: 'bind' } } })
await response
})
await waitFor(() => expect(onUpdate).toHaveBeenCalledTimes(1))
})
it('shows a retry when the 2FA status query fails instead of offering enrollment', async () => {
const get = vi
.spyOn(api, 'get')
......
......@@ -26,7 +26,7 @@ import { IconDiscord } from '@/assets/brand-icons'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { StatusBadge } from '@/components/status-badge'
import { Button } from '@/components/ui/button'
import { createOAuthFlow } from '@/features/auth/api'
import { createOAuthAuthorization } from '@/features/auth/api'
import {
openOAuthPopup,
type OAuthPopupExchange,
......@@ -49,7 +49,6 @@ import {
} from '@/lib/secure-verification'
import { EmailBindDialog } from './dialogs/email-bind-dialog'
import { TelegramBindDialog } from './dialogs/telegram-bind-dialog'
import { WeChatBindDialog } from './dialogs/wechat-bind-dialog'
// ============================================================================
......@@ -61,7 +60,7 @@ interface AccountBindingsProps {
onUpdate: () => void
}
type DialogKey = 'email' | 'wechat' | 'telegram'
type DialogKey = 'email' | 'wechat'
export function AccountBindings({ profile, onUpdate }: AccountBindingsProps) {
const { t } = useTranslation()
......@@ -134,15 +133,21 @@ export function AccountBindings({ profile, onUpdate }: AccountBindingsProps) {
intent: 'bind',
signal: controller.signal,
prepare: async (signal) => {
const state = await createOAuthFlow(
const authorization = await createOAuthAuthorization(
provider,
'bind',
undefined,
signal
)
return {
state,
url: buildOAuthAuthorizationUrl(provider, state, status ?? {}),
state: authorization.state,
url:
authorization.authorizationUrl ??
buildOAuthAuthorizationUrl(
provider,
authorization.state,
status ?? {}
),
}
},
})
......@@ -267,7 +272,7 @@ export function AccountBindings({ profile, onUpdate }: AccountBindingsProps) {
(profile as unknown as Record<string, unknown>).telegram_id
),
isEnabled: status?.telegram_oauth || false,
onBind: () => dialogs.open('telegram'),
onBind: () => void startOAuthBinding('telegram'),
},
{
id: 'linuxdo',
......@@ -441,18 +446,6 @@ export function AccountBindings({ profile, onUpdate }: AccountBindingsProps) {
}
onSuccess={onUpdate}
/>
{/* Telegram Bind Dialog */}
{status?.telegram_bot_name && (
<TelegramBindDialog
open={dialogs.isOpen('telegram')}
onOpenChange={(open) =>
open ? dialogs.open('telegram') : dialogs.close('telegram')
}
botName={status.telegram_bot_name as string}
onSuccess={onUpdate}
/>
)}
</>
)
}
......@@ -48,6 +48,8 @@ const defaultAuthSettings: AuthSettings = {
'oidc.token_endpoint': '',
'oidc.user_info_endpoint': '',
TelegramOAuthEnabled: false,
'telegram.client_id': '',
'telegram.client_secret': '',
TelegramBotToken: '',
TelegramBotName: '',
LinuxDOOAuthEnabled: false,
......
......@@ -81,8 +81,7 @@ const oauthSchema = z.object({
user_info_endpoint: z.string(),
}),
TelegramOAuthEnabled: z.boolean(),
TelegramBotToken: z.string(),
TelegramBotName: z.string(),
telegram: z.object({ client_id: z.string(), client_secret: z.string() }),
LinuxDOOAuthEnabled: z.boolean(),
LinuxDOClientId: z.string(),
LinuxDOClientSecret: z.string(),
......@@ -111,8 +110,8 @@ type FlatOAuthDefaults = {
'oidc.token_endpoint': string
'oidc.user_info_endpoint': string
TelegramOAuthEnabled: boolean
TelegramBotToken: string
TelegramBotName: string
'telegram.client_id': string
'telegram.client_secret': string
LinuxDOOAuthEnabled: boolean
LinuxDOClientId: string
LinuxDOClientSecret: string
......@@ -195,8 +194,10 @@ const buildFormDefaults = (defaults: FlatOAuthDefaults): OAuthFormValues => ({
user_info_endpoint: defaults['oidc.user_info_endpoint'] ?? '',
},
TelegramOAuthEnabled: defaults.TelegramOAuthEnabled,
TelegramBotToken: defaults.TelegramBotToken ?? '',
TelegramBotName: defaults.TelegramBotName ?? '',
telegram: {
client_id: defaults['telegram.client_id'] ?? '',
client_secret: defaults['telegram.client_secret'] ?? '',
},
LinuxDOOAuthEnabled: defaults.LinuxDOOAuthEnabled,
LinuxDOClientId: defaults.LinuxDOClientId ?? '',
LinuxDOClientSecret: defaults.LinuxDOClientSecret ?? '',
......@@ -222,9 +223,9 @@ const normalizeFormValues = (values: OAuthFormValues): FlatOAuthDefaults => ({
'oidc.authorization_endpoint': values.oidc.authorization_endpoint,
'oidc.token_endpoint': values.oidc.token_endpoint,
'oidc.user_info_endpoint': values.oidc.user_info_endpoint,
'telegram.client_id': values.telegram.client_id,
'telegram.client_secret': values.telegram.client_secret,
TelegramOAuthEnabled: values.TelegramOAuthEnabled,
TelegramBotToken: values.TelegramBotToken,
TelegramBotName: values.TelegramBotName,
LinuxDOOAuthEnabled: values.LinuxDOOAuthEnabled,
LinuxDOClientId: values.LinuxDOClientId,
LinuxDOClientSecret: values.LinuxDOClientSecret,
......@@ -260,6 +261,11 @@ export function OAuthSection(props: OAuthSectionProps) {
'oidc',
t('Site URL')
)
const telegramCallbackUrl = buildOAuthCallbackUrl(
props.serverAddress,
'telegram',
t('Site URL')
)
const linuxDOCallbackUrl = buildOAuthCallbackUrl(
props.serverAddress,
'linuxdo',
......@@ -807,6 +813,19 @@ export function OAuthSection(props: OAuthSectionProps) {
value='telegram'
className={oauthTabContentClassName}
>
<OAuthSetupGuide
title={t('Setup guide')}
description={t(
'In BotFather, open Login Widget, register this callback URL, and copy the Client ID and Client Secret. Existing Telegram bindings will continue to work after configuration.'
)}
rows={[
{
label: t('Authorization callback URL'),
value: telegramCallbackUrl,
copyLabel: t('Copy callback URL'),
},
]}
/>
<FormField
control={form.control}
name='TelegramOAuthEnabled'
......@@ -830,14 +849,16 @@ export function OAuthSection(props: OAuthSectionProps) {
<FormField
control={form.control}
name='TelegramBotToken'
name='telegram.client_secret'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Bot Token')}</FormLabel>
<FormLabel>{t('Client Secret')}</FormLabel>
<FormControl>
<Input
type='password'
placeholder={t('Your Telegram Bot Token')}
placeholder={t(
'Telegram OAuth Client Secret from BotFather'
)}
autoComplete='new-password'
value={field.value ?? ''}
onChange={(event) =>
......@@ -855,13 +876,15 @@ export function OAuthSection(props: OAuthSectionProps) {
<FormField
control={form.control}
name='TelegramBotName'
name='telegram.client_id'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Bot Name')}</FormLabel>
<FormLabel>{t('Client ID')}</FormLabel>
<FormControl>
<Input
placeholder={t('Your Bot Name')}
placeholder={t(
'Telegram OAuth Client ID from BotFather'
)}
autoComplete='off'
value={field.value ?? ''}
onChange={(event) =>
......
......@@ -65,8 +65,8 @@ const AUTH_SECTIONS = [
'oidc.token_endpoint': settings['oidc.token_endpoint'],
'oidc.user_info_endpoint': settings['oidc.user_info_endpoint'],
TelegramOAuthEnabled: settings.TelegramOAuthEnabled,
TelegramBotToken: settings.TelegramBotToken,
TelegramBotName: settings.TelegramBotName,
'telegram.client_id': settings['telegram.client_id'],
'telegram.client_secret': settings['telegram.client_secret'],
LinuxDOOAuthEnabled: settings.LinuxDOOAuthEnabled,
LinuxDOClientId: settings.LinuxDOClientId,
LinuxDOClientSecret: settings.LinuxDOClientSecret,
......
......@@ -145,6 +145,8 @@ export type AuthSettings = {
'oidc.token_endpoint': string
'oidc.user_info_endpoint': string
TelegramOAuthEnabled: boolean
'telegram.client_id': string
'telegram.client_secret': string
TelegramBotToken: string
TelegramBotName: string
LinuxDOOAuthEnabled: boolean
......
......@@ -2453,6 +2453,7 @@
"Import from URL": "Import from URL",
"Import to CC Switch": "Import to CC Switch",
"Important": "Important",
"In BotFather, open Login Widget, register this callback URL, and copy the Client ID and Client Secret. Existing Telegram bindings will continue to work after configuration.": "In BotFather, open Login Widget, register this callback URL, and copy the Client ID and Client Secret. Existing Telegram bindings will continue to work after configuration.",
"In JSON, the user group is the outer key and the billing group is the inner key. The example below means: vip users pay 0.8 when billed as standard, and 0.3 when billed as premium.": "In JSON, the user group is the outer key and the billing group is the inner key. The example below means: vip users pay 0.8 when billed as standard, and 0.3 when billed as premium.",
"In Progress": "In Progress",
"In the visual editor these appear as Extra visible and Hidden. In JSON, +: (or no prefix) adds a group and -: removes one.": "In the visual editor these appear as Extra visible and Hidden. In JSON, +: (or no prefix) adds a group and -: removes one.",
......@@ -2730,6 +2731,7 @@
"Login flow expired. Please sign in again.": "Login flow expired. Please sign in again.",
"Login Info": "Login Info",
"Login Method": "Login Method",
"Login session": "Login session",
"Login sessions": "Login sessions",
"Login, security and access records": "Login, security and access records",
"Logo": "Logo",
......@@ -4772,9 +4774,14 @@
"Team Collaboration": "Team Collaboration",
"Technical Support": "Technical Support",
"Telegram": "Telegram",
"Telegram authorization failed. Please try again.": "Telegram authorization failed. Please try again.",
"Telegram binding failed. Please try again.": "Telegram binding failed. Please try again.",
"Telegram binding is disabled.": "Telegram binding is disabled.",
"Telegram login has changed. Reload the page and start Telegram OAuth again.": "Telegram login has changed. Reload the page and start Telegram OAuth again.",
"Telegram Login Widget": "Telegram Login Widget",
"Telegram OAuth Client ID from BotFather": "Telegram OAuth Client ID from BotFather",
"Telegram OAuth Client Secret from BotFather": "Telegram OAuth Client Secret from BotFather",
"Telegram OAuth is not configured or enabled. Please contact your administrator.": "Telegram OAuth is not configured or enabled. Please contact your administrator.",
"Temperature": "Temperature",
"Template": "Template",
"Template variables:": "Template variables:",
......@@ -4846,6 +4853,7 @@
"The slug is appended to the URL:": "The slug is appended to the URL:",
"The sync will fetch missing models and vendors from the selected source. Existing records are updated only when you approve conflicts.": "The sync will fetch missing models and vendors from the selected source. Existing records are updated only when you approve conflicts.",
"The Telegram authorization request is invalid or expired.": "The Telegram authorization request is invalid or expired.",
"The telegram OAuth provider name is reserved. Ask your administrator to rename the conflicting custom provider.": "The telegram OAuth provider name is reserved. Ask your administrator to rename the conflicting custom provider.",
"The token group that will have a custom ratio": "The token group that will have a custom ratio",
"The token has no group, so it is billed as the user group vip, using the base ratio of vip.": "The token has no group, so it is billed as the user group vip, using the base ratio of vip.",
"The two roles of a group": "The two roles of a group",
......@@ -4925,6 +4933,7 @@
"This source does not publish a sha256 for this version, so the downloaded source cannot be pinned to what the source intended.": "This source does not publish a sha256 for this version, so the downloaded source cannot be pinned to what the source intended.",
"This source lists no installable task plugins.": "This source lists no installable task plugins.",
"This Telegram account is already bound.": "This Telegram account is already bound.",
"This Telegram account is not linked. Sign in using another method and link it first.": "This Telegram account is not linked. Sign in using another method and link it first.",
"This Telegram binding request has expired or has already been used.": "This Telegram binding request has expired or has already been used.",
"This tier catches any request that did not match earlier tiers.": "This tier catches any request that did not match earlier tiers.",
"this token group": "this token group",
......
......@@ -2453,6 +2453,7 @@
"Import from URL": "Importer depuis une URL",
"Import to CC Switch": "Importer vers CC Switch",
"Important": "Important",
"In BotFather, open Login Widget, register this callback URL, and copy the Client ID and Client Secret. Existing Telegram bindings will continue to work after configuration.": "Dans BotFather, ouvrez Login Widget, enregistrez cette URL de rappel et copiez le Client ID et le Client Secret. Les comptes Telegram déjà liés fonctionneront après la configuration.",
"In JSON, the user group is the outer key and the billing group is the inner key. The example below means: vip users pay 0.8 when billed as standard, and 0.3 when billed as premium.": "En JSON, le groupe d’utilisateurs est la clé externe et le groupe de facturation la clé interne. L’exemple ci-dessous signifie : les utilisateurs vip paient 0,8 sous standard et 0,3 sous premium.",
"In Progress": "En cours",
"In the visual editor these appear as Extra visible and Hidden. In JSON, +: (or no prefix) adds a group and -: removes one.": "Dans l’éditeur visuel, ces règles apparaissent comme « Visible en plus » et « Masqué ». En JSON, +: (ou aucun préfixe) ajoute un groupe et -: en retire un.",
......@@ -2730,6 +2731,7 @@
"Login flow expired. Please sign in again.": "Le processus de connexion a expiré. Veuillez vous reconnecter.",
"Login Info": "Informations de connexion",
"Login Method": "Méthode de connexion",
"Login session": "Session de connexion",
"Login sessions": "Sessions de connexion",
"Login, security and access records": "Historique des connexions, de la sécurité et des accès",
"Logo": "Logo",
......@@ -4772,9 +4774,14 @@
"Team Collaboration": "Collaboration d'équipe",
"Technical Support": "Support technique",
"Telegram": "Telegram",
"Telegram authorization failed. Please try again.": "L’autorisation Telegram a échoué. Réessayez.",
"Telegram binding failed. Please try again.": "Échec de la liaison Telegram. Veuillez réessayer.",
"Telegram binding is disabled.": "La liaison Telegram est désactivée.",
"Telegram login has changed. Reload the page and start Telegram OAuth again.": "La connexion Telegram a changé. Rechargez la page et relancez Telegram OAuth.",
"Telegram Login Widget": "Widget de connexion Telegram",
"Telegram OAuth Client ID from BotFather": "Client ID Telegram OAuth fourni par BotFather",
"Telegram OAuth Client Secret from BotFather": "Client Secret Telegram OAuth fourni par BotFather",
"Telegram OAuth is not configured or enabled. Please contact your administrator.": "Telegram OAuth n’est pas configuré ou activé. Contactez votre administrateur.",
"Temperature": "Température",
"Template": "Modèle",
"Template variables:": "Variables de modèle :",
......@@ -4846,6 +4853,7 @@
"The slug is appended to the URL:": "Le slug est ajouté à l'URL :",
"The sync will fetch missing models and vendors from the selected source. Existing records are updated only when you approve conflicts.": "La synchronisation récupérera les modèles et fournisseurs manquants à partir de la source sélectionnée. Les enregistrements existants ne sont mis à jour que lorsque vous approuvez les conflits.",
"The Telegram authorization request is invalid or expired.": "La demande d’autorisation Telegram est invalide ou a expiré.",
"The telegram OAuth provider name is reserved. Ask your administrator to rename the conflicting custom provider.": "Le nom de fournisseur OAuth telegram est réservé. Demandez à votre administrateur de renommer le fournisseur personnalisé en conflit.",
"The token group that will have a custom ratio": "Le groupe de jetons qui aura un ratio personnalisé",
"The token has no group, so it is billed as the user group vip, using the base ratio of vip.": "Le jeton n’a pas de groupe, il est donc facturé sous le groupe d’utilisateurs vip, avec le taux de base de vip.",
"The two roles of a group": "Les deux rôles d’un groupe",
......@@ -4925,6 +4933,7 @@
"This source does not publish a sha256 for this version, so the downloaded source cannot be pinned to what the source intended.": "Cette source ne publie pas de sha256 pour cette version : impossible de garantir que le code téléchargé est bien celui qu’elle a publié.",
"This source lists no installable task plugins.": "Cette source ne liste aucun plugin de tâches installable.",
"This Telegram account is already bound.": "Ce compte Telegram est déjà lié.",
"This Telegram account is not linked. Sign in using another method and link it first.": "Ce compte Telegram n’est pas lié. Connectez-vous autrement pour le lier.",
"This Telegram binding request has expired or has already been used.": "Cette demande de liaison Telegram a expiré ou a déjà été utilisée.",
"This tier catches any request that did not match earlier tiers.": "Ce palier récupère toute requête qui ne correspond à aucun palier précédent.",
"this token group": "ce groupe de jetons",
......
......@@ -2453,6 +2453,7 @@
"Import from URL": "URL からインポート",
"Import to CC Switch": "CC Switch にインポート",
"Important": "重要",
"In BotFather, open Login Widget, register this callback URL, and copy the Client ID and Client Secret. Existing Telegram bindings will continue to work after configuration.": "BotFather で Login Widget を開き、このコールバック URL を登録して Client ID と Client Secret をコピーしてください。設定後は既存の Telegram 連携を引き続き利用できます。",
"In JSON, the user group is the outer key and the billing group is the inner key. The example below means: vip users pay 0.8 when billed as standard, and 0.3 when billed as premium.": "JSONでは外側のキーがユーザーグループ、内側のキーが課金グループです。以下の例は、vip ユーザーが standard として課金されると 0.8、premium として課金されると 0.3 を意味します。",
"In Progress": "処理中",
"In the visual editor these appear as Extra visible and Hidden. In JSON, +: (or no prefix) adds a group and -: removes one.": "ビジュアルエディタでは「追加表示」と「非表示」として表示されます。JSONでは +:(または接頭辞なし)でグループを追加し、-: で削除します。",
......@@ -2730,6 +2731,7 @@
"Login flow expired. Please sign in again.": "ログイン手続きの有効期限が切れました。もう一度サインインしてください。",
"Login Info": "ログイン情報",
"Login Method": "ログイン方法",
"Login session": "ログインセッション",
"Login sessions": "ログインセッション",
"Login, security and access records": "ログイン、セキュリティ、アクセスの記録",
"Logo": "ロゴ",
......@@ -4772,9 +4774,14 @@
"Team Collaboration": "チームコラボレーション",
"Technical Support": "テクニカルサポート",
"Telegram": "Telegram",
"Telegram authorization failed. Please try again.": "Telegram の認証に失敗しました。もう一度お試しください。",
"Telegram binding failed. Please try again.": "Telegram の連携に失敗しました。もう一度お試しください。",
"Telegram binding is disabled.": "Telegram 連携は無効です。",
"Telegram login has changed. Reload the page and start Telegram OAuth again.": "Telegram のログイン方法が更新されました。ページを再読み込みし、Telegram OAuth をやり直してください。",
"Telegram Login Widget": "Telegramログインウィジェット",
"Telegram OAuth Client ID from BotFather": "BotFather が発行した Telegram OAuth Client ID",
"Telegram OAuth Client Secret from BotFather": "BotFather が発行した Telegram OAuth Client Secret",
"Telegram OAuth is not configured or enabled. Please contact your administrator.": "Telegram OAuth が設定されていないか、無効になっています。管理者にお問い合わせください。",
"Temperature": "温度",
"Template": "テンプレート",
"Template variables:": "テンプレート変数:",
......@@ -4846,6 +4853,7 @@
"The slug is appended to the URL:": "スラッグがURLに追加されます:",
"The sync will fetch missing models and vendors from the selected source. Existing records are updated only when you approve conflicts.": "同期により、選択されたソースから不足しているモデルとベンダーが取得されます。既存のレコードは、競合を承認した場合にのみ更新されます。",
"The Telegram authorization request is invalid or expired.": "Telegram の認証リクエストが無効か、有効期限が切れています。",
"The telegram OAuth provider name is reserved. Ask your administrator to rename the conflicting custom provider.": "OAuth プロバイダー名 telegram は予約されています。管理者に競合するカスタムプロバイダーの名前変更を依頼してください。",
"The token group that will have a custom ratio": "カスタム比率を持つトークングループ",
"The token has no group, so it is billed as the user group vip, using the base ratio of vip.": "トークンにグループがないため、ユーザーグループ vip として課金され、vip の基本倍率が使われます。",
"The two roles of a group": "グループの2つの役割",
......@@ -4925,6 +4933,7 @@
"This source does not publish a sha256 for this version, so the downloaded source cannot be pinned to what the source intended.": "このソースはこのバージョンの sha256 を公開していないため、ダウンロードしたソースがソース側の意図した内容と一致するか確認できません。",
"This source lists no installable task plugins.": "このソースにはインストール可能なタスクプラグインがありません。",
"This Telegram account is already bound.": "この Telegram アカウントはすでに連携されています。",
"This Telegram account is not linked. Sign in using another method and link it first.": "この Telegram アカウントは連携されていません。別の方法でログインしてから連携してください。",
"This Telegram binding request has expired or has already been used.": "この Telegram 連携リクエストは期限切れか、すでに使用されています。",
"This tier catches any request that did not match earlier tiers.": "この段階は、前の段階に一致しなかったすべてのリクエストを受け取ります。",
"this token group": "このトークングループ",
......
......@@ -2453,6 +2453,7 @@
"Import from URL": "Импорт по URL",
"Import to CC Switch": "Импорт в CC Switch",
"Important": "Важно",
"In BotFather, open Login Widget, register this callback URL, and copy the Client ID and Client Secret. Existing Telegram bindings will continue to work after configuration.": "Откройте Login Widget в BotFather, зарегистрируйте этот URL обратного вызова и скопируйте Client ID и Client Secret. После настройки существующие привязки Telegram продолжат работать.",
"In JSON, the user group is the outer key and the billing group is the inner key. The example below means: vip users pay 0.8 when billed as standard, and 0.3 when billed as premium.": "В JSON внешний ключ — группа пользователя, внутренний — тарифная группа. Пример ниже означает: пользователи vip платят 0,8 по standard и 0,3 по premium.",
"In Progress": "Выполняется",
"In the visual editor these appear as Extra visible and Hidden. In JSON, +: (or no prefix) adds a group and -: removes one.": "В визуальном редакторе это отображается как «Дополнительно видимая» и «Скрыта». В JSON префикс +: (или его отсутствие) добавляет группу, а -: удаляет её.",
......@@ -2730,6 +2731,7 @@
"Login flow expired. Please sign in again.": "Процесс входа истёк. Войдите снова.",
"Login Info": "Информация о входе",
"Login Method": "Способ входа",
"Login session": "Сеанс входа",
"Login sessions": "Сеансы входа",
"Login, security and access records": "Записи о входах, безопасности и доступе",
"Logo": "Логотип",
......@@ -4772,9 +4774,14 @@
"Team Collaboration": "Совместная работа в команде",
"Technical Support": "Техническая поддержка",
"Telegram": "Telegram",
"Telegram authorization failed. Please try again.": "Не удалось выполнить авторизацию Telegram. Повторите попытку.",
"Telegram binding failed. Please try again.": "Не удалось привязать Telegram. Повторите попытку.",
"Telegram binding is disabled.": "Привязка Telegram отключена.",
"Telegram login has changed. Reload the page and start Telegram OAuth again.": "Способ входа через Telegram изменился. Обновите страницу и снова начните вход через Telegram OAuth.",
"Telegram Login Widget": "Виджет входа Telegram",
"Telegram OAuth Client ID from BotFather": "Client ID Telegram OAuth из BotFather",
"Telegram OAuth Client Secret from BotFather": "Client Secret Telegram OAuth из BotFather",
"Telegram OAuth is not configured or enabled. Please contact your administrator.": "Telegram OAuth не настроен или отключён. Обратитесь к администратору.",
"Temperature": "Температура",
"Template": "Шаблон",
"Template variables:": "Переменные шаблона:",
......@@ -4846,6 +4853,7 @@
"The slug is appended to the URL:": "Слаг добавляется к URL:",
"The sync will fetch missing models and vendors from the selected source. Existing records are updated only when you approve conflicts.": "Синхронизация получит отсутствующие модели и поставщиков из выбранного источника. Существующие записи обновляются только после подтверждения конфликтов.",
"The Telegram authorization request is invalid or expired.": "Запрос авторизации Telegram недействителен или истёк.",
"The telegram OAuth provider name is reserved. Ask your administrator to rename the conflicting custom provider.": "Имя OAuth-провайдера telegram зарезервировано. Попросите администратора переименовать конфликтующего пользовательского провайдера.",
"The token group that will have a custom ratio": "Группа токенов, которая будет иметь пользовательское соотношение",
"The token has no group, so it is billed as the user group vip, using the base ratio of vip.": "У токена нет группы, поэтому вызов тарифицируется по группе пользователя vip с базовым коэффициентом vip.",
"The two roles of a group": "Две роли группы",
......@@ -4925,6 +4933,7 @@
"This source does not publish a sha256 for this version, so the downloaded source cannot be pinned to what the source intended.": "Источник не публикует sha256 для этой версии, поэтому нельзя убедиться, что загруженный код совпадает с опубликованным.",
"This source lists no installable task plugins.": "В этом источнике нет плагинов задач, доступных для установки.",
"This Telegram account is already bound.": "Эта учётная запись Telegram уже привязана.",
"This Telegram account is not linked. Sign in using another method and link it first.": "Этот аккаунт Telegram не привязан. Войдите другим способом и привяжите его.",
"This Telegram binding request has expired or has already been used.": "Этот запрос на привязку Telegram истёк или уже был использован.",
"This tier catches any request that did not match earlier tiers.": "Этот уровень обрабатывает все запросы, которые не совпали с предыдущими уровнями.",
"this token group": "эта группа токенов",
......
......@@ -2453,6 +2453,7 @@
"Import from URL": "Nhập từ URL",
"Import to CC Switch": "Nhập vào CC Switch",
"Important": "Quan trọng",
"In BotFather, open Login Widget, register this callback URL, and copy the Client ID and Client Secret. Existing Telegram bindings will continue to work after configuration.": "Trong BotFather, mở Login Widget, đăng ký URL gọi lại này và sao chép Client ID cùng Client Secret. Các liên kết Telegram hiện có sẽ tiếp tục hoạt động sau khi cấu hình.",
"In JSON, the user group is the outer key and the billing group is the inner key. The example below means: vip users pay 0.8 when billed as standard, and 0.3 when billed as premium.": "Trong JSON, khóa ngoài là nhóm người dùng, khóa trong là nhóm tính phí. Ví dụ dưới đây nghĩa là: người dùng vip trả 0.8 khi tính phí theo standard và 0.3 khi theo premium.",
"In Progress": "Đang xử lý",
"In the visual editor these appear as Extra visible and Hidden. In JSON, +: (or no prefix) adds a group and -: removes one.": "Trong trình chỉnh sửa trực quan, các quy tắc này hiển thị là «Hiển thị thêm» và «Ẩn». Trong JSON, +: (hoặc không có tiền tố) thêm nhóm và -: xóa nhóm.",
......@@ -2730,6 +2731,7 @@
"Login flow expired. Please sign in again.": "Quy trình đăng nhập đã hết hạn. Vui lòng đăng nhập lại.",
"Login Info": "Thông tin đăng nhập",
"Login Method": "Phương thức đăng nhập",
"Login session": "Phiên đăng nhập",
"Login sessions": "Phiên đăng nhập",
"Login, security and access records": "Nhật ký đăng nhập, bảo mật và truy cập",
"Logo": "Logo",
......@@ -4772,9 +4774,14 @@
"Team Collaboration": "Teamwork",
"Technical Support": "Hỗ trợ kỹ thuật",
"Telegram": "Telegram",
"Telegram authorization failed. Please try again.": "Ủy quyền Telegram thất bại. Vui lòng thử lại.",
"Telegram binding failed. Please try again.": "Liên kết Telegram không thành công. Vui lòng thử lại.",
"Telegram binding is disabled.": "Tính năng liên kết Telegram đã bị tắt.",
"Telegram login has changed. Reload the page and start Telegram OAuth again.": "Cách đăng nhập Telegram đã thay đổi. Hãy tải lại trang và bắt đầu lại Telegram OAuth.",
"Telegram Login Widget": "Tiện ích đăng nhập Telegram",
"Telegram OAuth Client ID from BotFather": "Client ID Telegram OAuth từ BotFather",
"Telegram OAuth Client Secret from BotFather": "Client Secret Telegram OAuth từ BotFather",
"Telegram OAuth is not configured or enabled. Please contact your administrator.": "Telegram OAuth chưa được cấu hình hoặc bật. Vui lòng liên hệ quản trị viên.",
"Temperature": "Nhiệt độ",
"Template": "Mẫu",
"Template variables:": "Biến mẫu:",
......@@ -4846,6 +4853,7 @@
"The slug is appended to the URL:": "Slug được gắn vào URL:",
"The sync will fetch missing models and vendors from the selected source. Existing records are updated only when you approve conflicts.": "Đồng bộ hóa sẽ tìm nạp các mẫu và nhà cung cấp còn thiếu từ nguồn đã chọn. Các bản ghi hiện có chỉ được cập nhật khi bạn chấp thuận các xung đột.",
"The Telegram authorization request is invalid or expired.": "Yêu cầu ủy quyền Telegram không hợp lệ hoặc đã hết hạn.",
"The telegram OAuth provider name is reserved. Ask your administrator to rename the conflicting custom provider.": "Tên nhà cung cấp OAuth telegram đã được dành riêng. Hãy nhờ quản trị viên đổi tên nhà cung cấp tùy chỉnh bị trùng.",
"The token group that will have a custom ratio": "The token group will have a custom ratio.",
"The token has no group, so it is billed as the user group vip, using the base ratio of vip.": "Token không có nhóm, nên được tính phí theo nhóm người dùng vip, dùng hệ số cơ bản của vip.",
"The two roles of a group": "Hai vai trò của một nhóm",
......@@ -4925,6 +4933,7 @@
"This source does not publish a sha256 for this version, so the downloaded source cannot be pinned to what the source intended.": "Nguồn này không công bố sha256 cho phiên bản đó, nên không thể xác nhận mã nguồn đã tải đúng với nội dung nguồn phát hành.",
"This source lists no installable task plugins.": "Nguồn này không liệt kê plugin tác vụ nào có thể cài đặt.",
"This Telegram account is already bound.": "Tài khoản Telegram này đã được liên kết.",
"This Telegram account is not linked. Sign in using another method and link it first.": "Tài khoản Telegram này chưa được liên kết. Hãy đăng nhập bằng cách khác rồi liên kết tài khoản.",
"This Telegram binding request has expired or has already been used.": "Yêu cầu liên kết Telegram này đã hết hạn hoặc đã được sử dụng.",
"This tier catches any request that did not match earlier tiers.": "Tầng này bắt mọi yêu cầu không khớp với các tầng trước.",
"this token group": "nhóm token này",
......
......@@ -2453,6 +2453,7 @@
"Import from URL": "從 URL 匯入",
"Import to CC Switch": "填入 CC Switch",
"Important": "重要",
"In BotFather, open Login Widget, register this callback URL, and copy the Client ID and Client Secret. Existing Telegram bindings will continue to work after configuration.": "在 BotFather 中開啟 Login Widget,登記此回呼網址並複製 Client ID 和 Client Secret。完成設定後,現有 Telegram 綁定可繼續使用。",
"In JSON, the user group is the outer key and the billing group is the inner key. The example below means: vip users pay 0.8 when billed as standard, and 0.3 when billed as premium.": "在 JSON 中,外層鍵是用戶分組,內層鍵是收費分組。下面的示例表示:vip 用戶按 standard 收費時用 0.8,按 premium 收費時用 0.3。",
"In Progress": "進行中",
"In the visual editor these appear as Extra visible and Hidden. In JSON, +: (or no prefix) adds a group and -: removes one.": "在可視化編輯器中顯示為「額外可見」和「屏蔽」。在 JSON 中,+:(或無前綴)表示新增分組,-: 表示移除分組。",
......@@ -2730,6 +2731,7 @@
"Login flow expired. Please sign in again.": "登入流程已過期,請重新登入。",
"Login Info": "登入資訊",
"Login Method": "登入方式",
"Login session": "登入工作階段",
"Login sessions": "登入工作階段",
"Login, security and access records": "登入、安全與存取記錄",
"Logo": "徽標",
......@@ -4772,9 +4774,14 @@
"Team Collaboration": "團隊協作",
"Technical Support": "技術支援",
"Telegram": "Telegram",
"Telegram authorization failed. Please try again.": "Telegram 授權失敗,請重試。",
"Telegram binding failed. Please try again.": "Telegram 綁定失敗,請再試一次。",
"Telegram binding is disabled.": "Telegram 綁定已停用。",
"Telegram login has changed. Reload the page and start Telegram OAuth again.": "Telegram 登入方式已更新,請重新載入頁面並重新發起 Telegram OAuth 登入。",
"Telegram Login Widget": "Telegram 登入小部件",
"Telegram OAuth Client ID from BotFather": "BotFather 提供的 Telegram OAuth Client ID",
"Telegram OAuth Client Secret from BotFather": "BotFather 提供的 Telegram OAuth Client Secret",
"Telegram OAuth is not configured or enabled. Please contact your administrator.": "Telegram OAuth 尚未設定或啟用,請聯絡管理員。",
"Temperature": "溫度",
"Template": "模板",
"Template variables:": "模板變數:",
......@@ -4846,6 +4853,7 @@
"The slug is appended to the URL:": "別名將附加到 URL:",
"The sync will fetch missing models and vendors from the selected source. Existing records are updated only when you approve conflicts.": "同步將從選定的源獲取缺失的模型和供應商。僅在您批准衝突時才會更新現有記錄。",
"The Telegram authorization request is invalid or expired.": "Telegram 授權要求無效或已過期。",
"The telegram OAuth provider name is reserved. Ask your administrator to rename the conflicting custom provider.": "telegram 是保留的 OAuth 提供方名稱,請聯絡管理員重新命名衝突的自訂提供方。",
"The token group that will have a custom ratio": "將具有自訂比例的令牌分組",
"The token has no group, so it is billed as the user group vip, using the base ratio of vip.": "令牌未設定分組,因此按用戶分組 vip 收費,使用 vip 的基礎倍率。",
"The two roles of a group": "分組的兩種角色",
......@@ -4925,6 +4933,7 @@
"This source does not publish a sha256 for this version, so the downloaded source cannot be pinned to what the source intended.": "該來源未為此版本提供 sha256,因此無法確認下載到的原始碼與該來源所發布的內容一致。",
"This source lists no installable task plugins.": "該來源未列出任何可安裝的任務外掛。",
"This Telegram account is already bound.": "此 Telegram 帳號已綁定。",
"This Telegram account is not linked. Sign in using another method and link it first.": "此 Telegram 帳號尚未綁定,請先透過其他方式登入並綁定。",
"This Telegram binding request has expired or has already been used.": "此 Telegram 綁定要求已過期或已使用。",
"This tier catches any request that did not match earlier tiers.": "此階梯會兜底處理未匹配前面階梯的請求。",
"this token group": "此令牌分組",
......
......@@ -2453,6 +2453,7 @@
"Import from URL": "从 URL 导入",
"Import to CC Switch": "填入 CC Switch",
"Important": "重要",
"In BotFather, open Login Widget, register this callback URL, and copy the Client ID and Client Secret. Existing Telegram bindings will continue to work after configuration.": "在 BotFather 中打开 Login Widget,登记此回调地址并复制 Client ID 和 Client Secret。完成配置后,现有 Telegram 绑定可继续使用。",
"In JSON, the user group is the outer key and the billing group is the inner key. The example below means: vip users pay 0.8 when billed as standard, and 0.3 when billed as premium.": "在 JSON 中,外层键是用户分组,内层键是计费分组。下面的示例表示:vip 用户按 standard 计费时用 0.8,按 premium 计费时用 0.3。",
"In Progress": "进行中",
"In the visual editor these appear as Extra visible and Hidden. In JSON, +: (or no prefix) adds a group and -: removes one.": "在可视化编辑器中显示为「额外可见」和「屏蔽」。在 JSON 中,+:(或无前缀)表示添加分组,-: 表示移除分组。",
......@@ -2730,6 +2731,7 @@
"Login flow expired. Please sign in again.": "登录流程已过期,请重新登录。",
"Login Info": "登录信息",
"Login Method": "登录方式",
"Login session": "登录会话",
"Login sessions": "登录会话",
"Login, security and access records": "登录、安全与访问记录",
"Logo": "徽标",
......@@ -4772,9 +4774,14 @@
"Team Collaboration": "团队协作",
"Technical Support": "技术支持",
"Telegram": "Telegram",
"Telegram authorization failed. Please try again.": "Telegram 授权失败,请重试。",
"Telegram binding failed. Please try again.": "Telegram 绑定失败,请重试。",
"Telegram binding is disabled.": "Telegram 绑定已禁用。",
"Telegram login has changed. Reload the page and start Telegram OAuth again.": "Telegram 登录方式已更新,请刷新页面并重新发起 Telegram OAuth 登录。",
"Telegram Login Widget": "Telegram 登录小部件",
"Telegram OAuth Client ID from BotFather": "BotFather 提供的 Telegram OAuth Client ID",
"Telegram OAuth Client Secret from BotFather": "BotFather 提供的 Telegram OAuth Client Secret",
"Telegram OAuth is not configured or enabled. Please contact your administrator.": "Telegram OAuth 尚未配置或启用,请联系管理员。",
"Temperature": "温度",
"Template": "模板",
"Template variables:": "模板变量:",
......@@ -4846,6 +4853,7 @@
"The slug is appended to the URL:": "别名将附加到 URL:",
"The sync will fetch missing models and vendors from the selected source. Existing records are updated only when you approve conflicts.": "同步将从选定的源获取缺失的模型和供应商。仅在您批准冲突时才会更新现有记录。",
"The Telegram authorization request is invalid or expired.": "Telegram 授权请求无效或已过期。",
"The telegram OAuth provider name is reserved. Ask your administrator to rename the conflicting custom provider.": "telegram 是保留的 OAuth 提供方名称,请联系管理员重命名冲突的自定义提供方。",
"The token group that will have a custom ratio": "将具有自定义比例的令牌分组",
"The token has no group, so it is billed as the user group vip, using the base ratio of vip.": "令牌未设置分组,因此按用户分组 vip 计费,使用 vip 的基础倍率。",
"The two roles of a group": "分组的两种角色",
......@@ -4925,6 +4933,7 @@
"This source does not publish a sha256 for this version, so the downloaded source cannot be pinned to what the source intended.": "该源未为此版本提供 sha256,因此无法确认下载到的源码与该源所发布的内容一致。",
"This source lists no installable task plugins.": "该源未列出任何可安装的任务插件。",
"This Telegram account is already bound.": "此 Telegram 账户已被绑定。",
"This Telegram account is not linked. Sign in using another method and link it first.": "此 Telegram 账号尚未绑定,请先通过其他方式登录并绑定。",
"This Telegram binding request has expired or has already been used.": "此 Telegram 绑定请求已过期或已使用。",
"This tier catches any request that did not match earlier tiers.": "此阶梯会兜底处理未匹配前面阶梯的请求。",
"this token group": "此令牌分组",
......
......@@ -578,6 +578,11 @@ export const STATIC_I18N_KEYS = [
'This user account no longer exists.',
'This user account is disabled.',
'Telegram binding failed. Please try again.',
'Telegram authorization failed. Please try again.',
'This Telegram account is not linked. Sign in using another method and link it first.',
'The telegram OAuth provider name is reserved. Ask your administrator to rename the conflicting custom provider.',
'Telegram login has changed. Reload the page and start Telegram OAuth again.',
'Login session',
'Verification scope is missing',
'This verification has already been used. Please verify again.',
"Verification does not match this action's details. Please verify again.",
......
......@@ -17,6 +17,15 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
const serverErrorMessageKeys = {
TELEGRAM_OAUTH_NOT_CONFIGURED:
'Telegram OAuth is not configured or enabled. Please contact your administrator.',
TELEGRAM_OAUTH_CONFLICT:
'The telegram OAuth provider name is reserved. Ask your administrator to rename the conflicting custom provider.',
TELEGRAM_OAUTH_FAILED: 'Telegram authorization failed. Please try again.',
TELEGRAM_ACCOUNT_NOT_BOUND:
'This Telegram account is not linked. Sign in using another method and link it first.',
TELEGRAM_LEGACY_AUTH_REMOVED:
'Telegram login has changed. Reload the page and start Telegram OAuth again.',
AUTH_INTERNAL_ERROR: 'Please try again later.',
SECURITY_VERIFICATION_FAILED: 'Verification failed. Please try again.',
SECURITY_VERIFICATION_FLOW_REQUIRED:
......
......@@ -40,6 +40,7 @@ import {
} from '@/features/auth/lib/oauth-bind-window'
import {
getOAuthSessionStorage,
consumeOAuthLoginRedirect,
resolveOAuthCallbackMode,
} from '@/features/auth/lib/oauth-callback-mode'
import { api, applyAuthBundle, isAuthBundle } from '@/lib/api'
......@@ -205,7 +206,7 @@ function OAuthCallback() {
const response = await api.get(`/api/oauth/${provider}`, config)
if (response.data?.success && isAuthBundle(response.data?.data)) {
applyAuthBundle(response.data.data)
safeNavigate(search.redirect)
safeNavigate(search.redirect ?? consumeOAuthLoginRedirect(state))
toast.success(i18next.t('Signed in successfully!'))
return
}
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment