From b6b97a66e39cfe45aab8cfb01ed96bba77cb279e Mon Sep 17 00:00:00 2001 From: Seefs <40468931+seefs001@users.noreply.github.com> Date: Tue, 14 Jul 2026 14:25:54 +0800 Subject: [PATCH 1/3] fix: purge authentication data on hard user deletion (#6168) * fix: purge authentication data on hard user deletion * fix: fail closed when 2FA status lookup fails * fix: reject stale Telegram login callbacks * fix(twofa): prevent concurrent backup code and lockout bypasses * fix(auth): harden user deletion and Telegram verification --- controller/telegram.go | 73 ++++++++++++----- controller/telegram_test.go | 78 ++++++++++++++++++ controller/user.go | 8 +- model/task_cas_test.go | 10 ++- model/token.go | 7 ++ model/twofa.go | 74 ++++++++++++----- model/user.go | 42 ++++++++-- model/user_authentication_test.go | 130 ++++++++++++++++++++++++++++++ 8 files changed, 371 insertions(+), 51 deletions(-) create mode 100644 controller/telegram_test.go create mode 100644 model/user_authentication_test.go diff --git a/controller/telegram.go b/controller/telegram.go index b5918d8e0e..d51bed2bb0 100644 --- a/controller/telegram.go +++ b/controller/telegram.go @@ -4,9 +4,13 @@ import ( "crypto/hmac" "crypto/sha256" "encoding/hex" - "io" + "errors" "net/http" + "net/url" "sort" + "strconv" + "strings" + "time" "github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/model" @@ -15,6 +19,13 @@ import ( "github.com/gin-gonic/gin" ) +const ( + // The legacy Telegram widget has no nonce. Keep its signed assertion short-lived + // so captured callbacks cannot be reused indefinitely. + telegramAuthorizationMaxAge = 5 * time.Minute + telegramAuthorizationFutureSkew = 2 * time.Minute +) + func TelegramBind(c *gin.Context) { if !common.TelegramOAuthEnabled { c.JSON(200, gin.H{ @@ -24,14 +35,15 @@ func TelegramBind(c *gin.Context) { return } params := c.Request.URL.Query() - if !checkTelegramAuthorization(params, common.TelegramBotToken) { + telegramId, err := verifyTelegramAuthorization(params, common.TelegramBotToken, time.Now()) + if err != nil { + common.SysLog("TelegramBind authorization failed: " + err.Error()) c.JSON(200, gin.H{ "message": "无效的请求", "success": false, }) return } - telegramId := params["id"][0] if model.IsTelegramIdAlreadyTaken(telegramId) { c.JSON(200, gin.H{ "message": "该 Telegram 账户已被绑定", @@ -78,7 +90,9 @@ func TelegramLogin(c *gin.Context) { return } params := c.Request.URL.Query() - if !checkTelegramAuthorization(params, common.TelegramBotToken) { + telegramId, err := verifyTelegramAuthorization(params, common.TelegramBotToken, time.Now()) + if err != nil { + common.SysLog("TelegramLogin authorization failed: " + err.Error()) c.JSON(200, gin.H{ "message": "无效的请求", "success": false, @@ -86,7 +100,6 @@ func TelegramLogin(c *gin.Context) { return } - telegramId := params["id"][0] user := model.User{TelegramId: telegramId} if err := user.FillUserByTelegramId(); err != nil { c.JSON(200, gin.H{ @@ -98,28 +111,46 @@ func TelegramLogin(c *gin.Context) { setupLogin(&user, c) } -func checkTelegramAuthorization(params map[string][]string, token string) bool { - strs := []string{} - var hash = "" +func verifyTelegramAuthorization(params url.Values, token string, now time.Time) (string, error) { + if token == "" { + return "", errors.New("telegram bot token is empty") + } + for _, values := range params { + if len(values) != 1 { + return "", errors.New("telegram authorization contains duplicate parameters") + } + } + + telegramID := params.Get("id") + hash := params.Get("hash") + authDateText := params.Get("auth_date") + if telegramID == "" || hash == "" || authDateText == "" { + return "", errors.New("telegram authorization is incomplete") + } + authDate, err := strconv.ParseInt(authDateText, 10, 64) + if err != nil { + return "", errors.New("telegram authorization date is invalid") + } + if authDate < now.Add(-telegramAuthorizationMaxAge).Unix() || + authDate > now.Add(telegramAuthorizationFutureSkew).Unix() { + return "", errors.New("telegram authorization has expired") + } + + strs := make([]string, 0, len(params)-1) for k, v := range params { if k == "hash" { - hash = v[0] continue } strs = append(strs, k+"="+v[0]) } sort.Strings(strs) - var imploded = "" - for _, s := range strs { - if imploded != "" { - imploded += "\n" - } - imploded += s + secret := sha256.Sum256([]byte(token)) + mac := hmac.New(sha256.New, secret[:]) + _, _ = mac.Write([]byte(strings.Join(strs, "\n"))) + providedHash, err := hex.DecodeString(hash) + if err != nil || !hmac.Equal(providedHash, mac.Sum(nil)) { + return "", errors.New("telegram authorization signature is invalid") } - sha256hash := sha256.New() - io.WriteString(sha256hash, token) - hmachash := hmac.New(sha256.New, sha256hash.Sum(nil)) - io.WriteString(hmachash, imploded) - ss := hex.EncodeToString(hmachash.Sum(nil)) - return hash == ss + + return telegramID, nil } diff --git a/controller/telegram_test.go b/controller/telegram_test.go new file mode 100644 index 0000000000..5b683fb7ec --- /dev/null +++ b/controller/telegram_test.go @@ -0,0 +1,78 @@ +package controller + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "net/url" + "sort" + "strconv" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestVerifyTelegramAuthorization(t *testing.T) { + const token = "telegram-test-token" + now := time.Unix(1_700_000_000, 0) + + tests := []struct { + name string + authDate time.Time + mutate func(url.Values) + wantID string + wantErr string + }{ + {name: "valid", authDate: now, wantID: "123456"}, + {name: "small future clock skew", authDate: now.Add(90 * time.Second), wantID: "123456"}, + {name: "expired", authDate: now.Add(-telegramAuthorizationMaxAge - time.Second), wantErr: "expired"}, + {name: "too far in future", authDate: now.Add(telegramAuthorizationFutureSkew + time.Second), wantErr: "expired"}, + {name: "invalid signature", authDate: now, mutate: func(values url.Values) { values.Set("hash", "00") }, wantErr: "signature"}, + {name: "duplicate parameter", authDate: now, mutate: func(values url.Values) { values["id"] = append(values["id"], "654321") }, wantErr: "duplicate"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + params := signedTelegramAuthorization(token, tt.authDate) + if tt.mutate != nil { + tt.mutate(params) + } + + telegramID, err := verifyTelegramAuthorization(params, token, now) + if tt.wantErr != "" { + require.Error(t, err) + assert.ErrorContains(t, err, tt.wantErr) + assert.Empty(t, telegramID) + return + } + + require.NoError(t, err) + assert.Equal(t, tt.wantID, telegramID) + }) + } +} + +func signedTelegramAuthorization(token string, authDate time.Time) url.Values { + params := url.Values{ + "auth_date": {strconv.FormatInt(authDate.Unix(), 10)}, + "first_name": {"Test"}, + "id": {"123456"}, + } + keys := make([]string, 0, len(params)) + for key := range params { + keys = append(keys, key) + } + sort.Strings(keys) + dataCheck := make([]string, 0, len(keys)) + for _, key := range keys { + dataCheck = append(dataCheck, key+"="+params.Get(key)) + } + secret := sha256.Sum256([]byte(token)) + mac := hmac.New(sha256.New, secret[:]) + _, _ = mac.Write([]byte(strings.Join(dataCheck, "\n"))) + params.Set("hash", hex.EncodeToString(mac.Sum(nil))) + return params +} diff --git a/controller/user.go b/controller/user.go index 3e5293be1b..6316fd1312 100644 --- a/controller/user.go +++ b/controller/user.go @@ -73,7 +73,13 @@ func Login(c *gin.Context) { } // 检查是否启用2FA - if model.IsTwoFAEnabled(user.Id) { + twoFAEnabled, err := model.IsTwoFAEnabled(user.Id) + if err != nil { + common.SysLog(fmt.Sprintf("Login failed to load 2FA status for user %d: %v", user.Id, err)) + common.ApiErrorI18n(c, i18n.MsgDatabaseError) + return + } + if twoFAEnabled { // 设置pending session,等待2FA验证 session := sessions.Default(c) session.Set("pending_username", user.Username) diff --git a/model/task_cas_test.go b/model/task_cas_test.go index 479774cd3f..91ca28fce8 100644 --- a/model/task_cas_test.go +++ b/model/task_cas_test.go @@ -38,6 +38,9 @@ func TestMain(m *testing.M) { &Task{}, &User{}, &Token{}, + &PasskeyCredential{}, + &TwoFA{}, + &TwoFABackupCode{}, &Log{}, &Channel{}, &QuotaData{}, @@ -62,8 +65,12 @@ func truncateTables(t *testing.T) { t.Helper() t.Cleanup(func() { DB.Exec("DELETE FROM tasks") - DB.Exec("DELETE FROM users") + DB.Exec("DELETE FROM passkey_credentials") + DB.Exec("DELETE FROM two_fa_backup_codes") + DB.Exec("DELETE FROM two_fas") DB.Exec("DELETE FROM tokens") + DB.Exec("DELETE FROM user_oauth_bindings") + DB.Exec("DELETE FROM users") DB.Exec("DELETE FROM logs") DB.Exec("DELETE FROM channels") DB.Exec("DELETE FROM quota_data") @@ -72,7 +79,6 @@ func truncateTables(t *testing.T) { DB.Exec("DELETE FROM subscription_orders") DB.Exec("DELETE FROM subscription_plans") DB.Exec("DELETE FROM user_subscriptions") - DB.Exec("DELETE FROM user_oauth_bindings") DB.Exec("DELETE FROM perf_metrics") DB.Exec("DELETE FROM system_instances") DB.Exec("DELETE FROM system_task_locks") diff --git a/model/token.go b/model/token.go index cb34b3ced0..5d62258e79 100644 --- a/model/token.go +++ b/model/token.go @@ -505,6 +505,13 @@ func InvalidateUserTokensCache(userId int) error { Find(&tokens).Error; err != nil { return err } + return invalidateTokensCache(tokens) +} + +func invalidateTokensCache(tokens []Token) error { + if !common.RedisEnabled { + return nil + } var firstErr error for _, t := range tokens { if t.Key == "" { diff --git a/model/twofa.go b/model/twofa.go index a2d0c7e1f6..1887bfe568 100644 --- a/model/twofa.go +++ b/model/twofa.go @@ -54,12 +54,12 @@ func GetTwoFAByUserId(userId int) (*TwoFA, error) { } // IsTwoFAEnabled 检查用户是否启用了2FA -func IsTwoFAEnabled(userId int) bool { +func IsTwoFAEnabled(userId int) (bool, error) { twoFA, err := GetTwoFAByUserId(userId) - if err != nil || twoFA == nil { - return false + if err != nil { + return false, err } - return twoFA.IsEnabled + return twoFA != nil && twoFA.IsEnabled, nil } // CreateTwoFA 创建2FA设置 @@ -120,15 +120,50 @@ func (t *TwoFA) ResetFailedAttempts() error { // IncrementFailedAttempts 增加失败尝试次数 func (t *TwoFA) IncrementFailedAttempts() error { - t.FailedAttempts++ - - // 检查是否需要锁定 - if t.FailedAttempts >= common.MaxFailAttempts { - lockUntil := time.Now().Add(time.Duration(common.LockoutDuration) * time.Second) - t.LockedUntil = &lockUntil + if t.Id == 0 { + return errors.New("2FA记录ID不能为空") } - return t.Update() + const maxUpdateRetries = 5 + for range maxUpdateRetries { + var current TwoFA + if err := DB.Select("id", "failed_attempts", "locked_until").First(¤t, t.Id).Error; err != nil { + return err + } + + now := time.Now() + if current.LockedUntil != nil && now.Before(*current.LockedUntil) { + t.FailedAttempts = current.FailedAttempts + t.LockedUntil = current.LockedUntil + return nil + } + + nextFailedAttempts := current.FailedAttempts + 1 + nextLockedUntil := current.LockedUntil + if nextFailedAttempts >= common.MaxFailAttempts { + lockUntil := now.Add(time.Duration(common.LockoutDuration) * time.Second) + nextLockedUntil = &lockUntil + } + + result := DB.Model(&TwoFA{}). + Where("id = ? AND failed_attempts = ? AND (locked_until IS NULL OR locked_until <= ?)", current.Id, current.FailedAttempts, now). + Updates(map[string]interface{}{ + "failed_attempts": nextFailedAttempts, + "locked_until": nextLockedUntil, + }) + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + continue + } + + t.FailedAttempts = nextFailedAttempts + t.LockedUntil = nextLockedUntil + return nil + } + + return errors.New("更新2FA失败次数冲突,请重试") } // IsLocked 检查账户是否被锁定 @@ -186,16 +221,17 @@ func ValidateBackupCode(userId int, code string) (bool, error) { // 验证备用码 for _, bc := range backupCodes { if common.ValidatePasswordAndHash(normalizedCode, bc.CodeHash) { - // 标记为已使用 now := time.Now() - bc.IsUsed = true - bc.UsedAt = &now - - if err := DB.Save(&bc).Error; err != nil { - return false, err + result := DB.Model(&TwoFABackupCode{}). + Where("id = ? AND is_used = ?", bc.Id, false). + Updates(map[string]interface{}{ + "is_used": true, + "used_at": now, + }) + if result.Error != nil { + return false, result.Error } - - return true, nil + return result.RowsAffected == 1, nil } } diff --git a/model/user.go b/model/user.go index 08ffabccd6..03eb589ede 100644 --- a/model/user.go +++ b/model/user.go @@ -423,12 +423,8 @@ func HardDeleteUserById(id int) error { if id == 0 { return errors.New("id 为空!") } - return DB.Transaction(func(tx *gorm.DB) error { - if err := deleteUserOAuthBindingsByUserId(tx, id); err != nil { - return err - } - return tx.Unscoped().Delete(&User{}, "id = ?", id).Error - }) + user := User{Id: id} + return user.HardDelete() } func inviteUser(inviterId int) (err error) { @@ -754,12 +750,42 @@ func (user *User) HardDelete() error { if user.Id == 0 { return errors.New("id 为空!") } - return DB.Transaction(func(tx *gorm.DB) error { - if err := deleteUserOAuthBindingsByUserId(tx, user.Id); err != nil { + var tokens []Token + err := DB.Transaction(func(tx *gorm.DB) error { + if common.RedisEnabled { + if err := tx.Unscoped().Select("id", commonKeyCol).Where("user_id = ?", user.Id).Find(&tokens).Error; err != nil { + return err + } + } + if err := deleteUserAuthenticationData(tx, user.Id); err != nil { return err } return tx.Unscoped().Delete(user).Error }) + if err != nil { + return err + } + if err := invalidateTokensCache(tokens); err != nil { + common.SysError(fmt.Sprintf("failed to invalidate token cache after hard deleting user %d: %v", user.Id, err)) + } + if err := invalidateUserCache(user.Id); err != nil { + common.SysError(fmt.Sprintf("failed to invalidate user cache after hard deleting user %d: %v", user.Id, err)) + } + return nil +} + +func deleteUserAuthenticationData(tx *gorm.DB, userId int) error { + for _, authenticationData := range []any{ + &TwoFABackupCode{}, + &TwoFA{}, + &PasskeyCredential{}, + &Token{}, + } { + if err := tx.Unscoped().Where("user_id = ?", userId).Delete(authenticationData).Error; err != nil { + return err + } + } + return deleteUserOAuthBindingsByUserId(tx, userId) } // ValidateAndFill check password & user status diff --git a/model/user_authentication_test.go b/model/user_authentication_test.go new file mode 100644 index 0000000000..c285e02387 --- /dev/null +++ b/model/user_authentication_test.go @@ -0,0 +1,130 @@ +package model + +import ( + "context" + "errors" + "net" + "sync" + "sync/atomic" + "testing" + + "github.com/QuantumNous/new-api/common" + "github.com/go-redis/redis/v8" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestHardDeleteUserPurgesAuthenticationDataWhenRedisFails(t *testing.T) { + truncateTables(t) + + user := User{Username: "hard-delete-user", Password: "password"} + require.NoError(t, DB.Create(&user).Error) + require.NoError(t, DB.Create(&Token{UserId: user.Id, Key: "hard-delete-token"}).Error) + require.NoError(t, DB.Create(&TwoFA{UserId: user.Id, Secret: "secret", IsEnabled: true}).Error) + require.NoError(t, DB.Create(&TwoFABackupCode{UserId: user.Id, CodeHash: "hash"}).Error) + require.NoError(t, DB.Create(&PasskeyCredential{UserID: user.Id, CredentialID: "credential", PublicKey: "public-key"}).Error) + require.NoError(t, DB.Create(&UserOAuthBinding{UserId: user.Id, ProviderId: 1, ProviderUserId: "provider-user"}).Error) + + oldRedisEnabled, oldRDB := common.RedisEnabled, common.RDB + common.RedisEnabled = true + var cacheInvalidatedAfterCommit atomic.Bool + common.RDB = redis.NewClient(&redis.Options{ + Dialer: func(context.Context, string, string) (net.Conn, error) { + var count int64 + if err := DB.Unscoped().Model(&User{}).Where("id = ?", user.Id).Count(&count).Error; err == nil && count == 0 { + cacheInvalidatedAfterCommit.Store(true) + } + return nil, errors.New("forced redis failure") + }, + MaxRetries: -1, + }) + t.Cleanup(func() { + _ = common.RDB.Close() + common.RedisEnabled, common.RDB = oldRedisEnabled, oldRDB + }) + + require.NoError(t, HardDeleteUserById(user.Id)) + assert.True(t, cacheInvalidatedAfterCommit.Load()) + + var count int64 + require.NoError(t, DB.Unscoped().Model(&User{}).Where("id = ?", user.Id).Count(&count).Error) + assert.Zero(t, count) + for _, record := range []any{ + &Token{}, + &TwoFA{}, + &TwoFABackupCode{}, + &PasskeyCredential{}, + &UserOAuthBinding{}, + } { + require.NoError(t, DB.Unscoped().Model(record).Where("user_id = ?", user.Id).Count(&count).Error) + assert.Zero(t, count) + } +} + +func TestIncrementFailedAttemptsCountsConcurrentFailures(t *testing.T) { + truncateTables(t) + + user := User{Username: "twofa-cas-user", Password: "password"} + require.NoError(t, DB.Create(&user).Error) + twoFA := TwoFA{UserId: user.Id, Secret: "secret", IsEnabled: true} + require.NoError(t, DB.Create(&twoFA).Error) + + const attempts = 4 + errs := make(chan error, attempts) + var wg sync.WaitGroup + for range attempts { + wg.Add(1) + go func() { + defer wg.Done() + errs <- (&TwoFA{Id: twoFA.Id}).IncrementFailedAttempts() + }() + } + wg.Wait() + close(errs) + for err := range errs { + require.NoError(t, err) + } + + var reloaded TwoFA + require.NoError(t, DB.First(&reloaded, twoFA.Id).Error) + assert.Equal(t, attempts, reloaded.FailedAttempts) +} + +func TestValidateBackupCodeCanOnlySucceedOnce(t *testing.T) { + truncateTables(t) + + const code = "ABCD-1234" + require.NoError(t, CreateBackupCodes(123, []string{code})) + + const attempts = 2 + results := make(chan bool, attempts) + errs := make(chan error, attempts) + var wg sync.WaitGroup + for range attempts { + wg.Add(1) + go func() { + defer wg.Done() + valid, err := ValidateBackupCode(123, code) + results <- valid + errs <- err + }() + } + wg.Wait() + close(results) + close(errs) + + for err := range errs { + require.NoError(t, err) + } + wins := 0 + for valid := range results { + if valid { + wins++ + } + } + assert.Equal(t, 1, wins) + + remaining, err := GetUnusedBackupCodeCount(123) + require.NoError(t, err) + assert.Zero(t, remaining) +} From 9a2d66031677d3f5ab8e3f57006f0d7ff6c94b61 Mon Sep 17 00:00:00 2001 From: QuentinHsu Date: Tue, 14 Jul 2026 20:15:27 +0800 Subject: [PATCH 2/3] fix(users): prevent large quota values from overflowing (#6134) - widen the quota column and add consistent spacing between remaining and total values. - extract quota rendering into a dedicated component and truncate oversized text within the cell. - preserve full-value tooltips, progress indicators, and the zero-quota state. --- .../users/components/user-quota-cell.tsx | 98 +++++++++++++++++++ .../users/components/users-columns.tsx | 65 +----------- 2 files changed, 102 insertions(+), 61 deletions(-) create mode 100644 web/default/src/features/users/components/user-quota-cell.tsx diff --git a/web/default/src/features/users/components/user-quota-cell.tsx b/web/default/src/features/users/components/user-quota-cell.tsx new file mode 100644 index 0000000000..0d024cf973 --- /dev/null +++ b/web/default/src/features/users/components/user-quota-cell.tsx @@ -0,0 +1,98 @@ +/* +Copyright (C) 2023-2026 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ +import { useTranslation } from 'react-i18next' + +import { StatusBadge } from '@/components/status-badge' +import { Progress } from '@/components/ui/progress' +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from '@/components/ui/tooltip' +import { formatQuota } from '@/lib/format' +import { cn } from '@/lib/utils' + +type UserQuotaCellProps = { + used: number + remaining: number +} + +function getQuotaProgressColor(percentage: number): string { + if (percentage <= 10) return '[&_[data-slot=progress-indicator]]:bg-rose-500' + if (percentage <= 30) return '[&_[data-slot=progress-indicator]]:bg-amber-500' + return '[&_[data-slot=progress-indicator]]:bg-emerald-500' +} + +export function UserQuotaCell(props: UserQuotaCellProps) { + const { t } = useTranslation() + const total = props.used + props.remaining + const percentage = total > 0 ? (props.remaining / total) * 100 : 0 + const formattedRemaining = formatQuota(props.remaining) + const formattedTotal = formatQuota(total) + + if (total === 0) { + return ( + + ) + } + + return ( + + + } + > +
+ + {formattedRemaining} + + + {formattedTotal} + +
+ +
+ +
+
+ {t('Used:')} {formatQuota(props.used)} +
+
+ {t('Remaining:')} {formattedRemaining} +
+
+ {t('Total:')} {formattedTotal} +
+
+ {t('Percentage:')} {percentage.toFixed(1)}% +
+
+
+
+ ) +} diff --git a/web/default/src/features/users/components/users-columns.tsx b/web/default/src/features/users/components/users-columns.tsx index 964132e5f4..2fadc772af 100644 --- a/web/default/src/features/users/components/users-columns.tsx +++ b/web/default/src/features/users/components/users-columns.tsx @@ -25,14 +25,12 @@ import { LongText } from '@/components/long-text' import { StatusBadge } from '@/components/status-badge' import { TableId } from '@/components/table-id' import { Checkbox } from '@/components/ui/checkbox' -import { Progress } from '@/components/ui/progress' import { Tooltip, TooltipContent, TooltipTrigger, } from '@/components/ui/tooltip' import { formatQuota, formatTimestamp } from '@/lib/format' -import { cn } from '@/lib/utils' import { USER_STATUS, @@ -42,12 +40,7 @@ import { } from '../constants' import type { User } from '../types' import { DataTableRowActions } from './data-table-row-actions' - -function getQuotaProgressColor(percentage: number): string { - if (percentage <= 10) return '[&_[data-slot=progress-indicator]]:bg-rose-500' - if (percentage <= 30) return '[&_[data-slot=progress-indicator]]:bg-amber-500' - return '[&_[data-slot=progress-indicator]]:bg-emerald-500' -} +import { UserQuotaCell } from './user-quota-cell' export function useUsersColumns(): ColumnDef[] { const { t } = useTranslation() @@ -173,60 +166,10 @@ export function useUsersColumns(): ColumnDef[] { header: t('Quota'), cell: ({ row }) => { const user = row.original - const used = user.used_quota - const remaining = user.quota - const total = used + remaining - const percentage = total > 0 ? (remaining / total) * 100 : 0 - - if (total === 0) { - return ( - - ) - } - - return ( - - } - > -
- - {formatQuota(remaining)} - - - {formatQuota(total)} - -
- -
- -
-
- {t('Used:')} {formatQuota(used)} -
-
- {t('Remaining:')} {formatQuota(remaining)} -
-
- {t('Total:')} {formatQuota(total)} -
-
- {t('Percentage:')} {percentage.toFixed(1)}% -
-
-
-
- ) + return }, - size: 170, + size: 300, + minSize: 260, meta: { mobileOrder: 40 }, }, { From a63364d156cf2a64f1c3d1ee4923d73d5f3222a1 Mon Sep 17 00:00:00 2001 From: fuxdev <15846422264@163.com> Date: Tue, 14 Jul 2026 20:32:48 +0800 Subject: [PATCH 3/3] fix: infer MiniMax vendor for MiniMax models (#6164) * fix: infer MiniMax vendor for MiniMax models * Delete model/pricing_default_test.go --------- Co-authored-by: duanxufu --- model/pricing_default.go | 1 + 1 file changed, 1 insertion(+) diff --git a/model/pricing_default.go b/model/pricing_default.go index db64cafbb1..f73a17fc42 100644 --- a/model/pricing_default.go +++ b/model/pricing_default.go @@ -20,6 +20,7 @@ var defaultVendorRules = map[string]string{ "qwen": "阿里巴巴", "deepseek": "DeepSeek", "abab": "MiniMax", + "minimax": "MiniMax", "ernie": "百度", "spark": "讯飞", "hunyuan": "腾讯",