From 469d3747af202db88a4f69cdb88b67b42df078bc Mon Sep 17 00:00:00 2001 From: Calcium-Ion Date: Tue, 12 May 2026 16:47:02 +0800 Subject: [PATCH] fix: defaut ui triage (#4802) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: theme-aware payment paths, auto-group validation, route guards, perf group filtering - Add common.ThemeAwarePath to generate correct redirect URLs based on active theme (default vs classic), replacing hardcoded /console/* paths in 7 controllers and service/quota.go (#4765) - Validate auto-group availability against getUserGroups before defaulting form values; playground falls back to 'default' group when 'auto' is unavailable (#4796, #4799) - Enforce HeaderNavModules settings in rankings route (frontend + backend API) and SidebarModulesAdmin in playground route to block direct URL access when features are disabled (#4704, #4512) - Filter perf_metrics API response to only include currently configured groups, hiding stale data from deleted groups (#4790) - Preserve query params (pay=success/fail) in /console/topup → /wallet frontend redirect * fix: update hero section text and localization strings for clarity --- common/constants.go | 21 +++++ controller/perf_metrics.go | 14 ++++ controller/rankings.go | 40 ++++++++++ controller/return_path.go | 13 +++ controller/subscription_payment_epay.go | 15 ++-- controller/subscription_payment_stripe.go | 5 +- controller/telegram.go | 2 +- controller/topup.go | 3 +- controller/topup_stripe.go | 5 +- controller/topup_waffo.go | 3 +- controller/topup_waffo_pancake.go | 3 +- service/quota.go | 5 +- service/return_path.go | 13 +++ .../home/components/sections/hero.tsx | 7 +- .../components/api-keys-mutate-drawer.tsx | 29 ++++--- .../src/features/playground/constants.ts | 5 +- web/default/src/features/playground/index.tsx | 25 +++--- web/default/src/i18n/locales/en.json | 2 +- web/default/src/i18n/locales/fr.json | 2 +- web/default/src/i18n/locales/ja.json | 2 +- web/default/src/i18n/locales/ru.json | 2 +- web/default/src/i18n/locales/vi.json | 2 +- web/default/src/i18n/locales/zh.json | 2 +- web/default/src/lib/nav-modules.ts | 80 +++++++++++++++++++ .../_authenticated/playground/index.tsx | 8 +- web/default/src/routes/console/topup.tsx | 8 +- web/default/src/routes/rankings/index.tsx | 16 +++- 27 files changed, 261 insertions(+), 71 deletions(-) create mode 100644 controller/return_path.go create mode 100644 service/return_path.go create mode 100644 web/default/src/lib/nav-modules.ts diff --git a/common/constants.go b/common/constants.go index c4d2511e..1912e460 100644 --- a/common/constants.go +++ b/common/constants.go @@ -4,6 +4,7 @@ import ( "crypto/tls" //"os" //"strconv" + "strings" "sync" "sync/atomic" "time" @@ -36,6 +37,26 @@ func SetTheme(t string) { } } +// ThemeAwarePath rewrites legacy /console/* paths to the default-theme +// equivalents when the active theme is "default". For "classic" (or any +// other theme) the path is returned unchanged. The function only touches +// known prefixes so it is safe to call with arbitrary suffixes and query +// strings. +func ThemeAwarePath(suffix string) string { + if GetTheme() != "default" { + return suffix + } + switch { + case strings.HasPrefix(suffix, "/console/topup"): + return strings.Replace(suffix, "/console/topup", "/wallet", 1) + case strings.HasPrefix(suffix, "/console/log"): + return strings.Replace(suffix, "/console/log", "/usage-logs", 1) + case strings.HasPrefix(suffix, "/console/personal"): + return strings.Replace(suffix, "/console/personal", "/profile", 1) + } + return suffix +} + // var ChatLink = "" // var ChatLink2 = "" var QuotaPerUnit = 500 * 1000.0 // $0.002 / 1K tokens diff --git a/controller/perf_metrics.go b/controller/perf_metrics.go index 2ffc2b91..51e8d9ec 100644 --- a/controller/perf_metrics.go +++ b/controller/perf_metrics.go @@ -5,6 +5,7 @@ import ( "strconv" perfmetrics "github.com/QuantumNous/new-api/pkg/perf_metrics" + "github.com/QuantumNous/new-api/setting/ratio_setting" "github.com/gin-gonic/gin" ) @@ -62,8 +63,21 @@ func GetPerfMetrics(c *gin.Context) { return } + result.Groups = filterActiveGroups(result.Groups) + c.JSON(http.StatusOK, gin.H{ "success": true, "data": result, }) } + +func filterActiveGroups(groups []perfmetrics.GroupResult) []perfmetrics.GroupResult { + activeGroups := ratio_setting.GetGroupRatioCopy() + filtered := make([]perfmetrics.GroupResult, 0, len(groups)) + for _, g := range groups { + if _, ok := activeGroups[g.Group]; ok || g.Group == "auto" { + filtered = append(filtered, g) + } + } + return filtered +} diff --git a/controller/rankings.go b/controller/rankings.go index 5a7fdaae..a3fdf2b5 100644 --- a/controller/rankings.go +++ b/controller/rankings.go @@ -3,11 +3,51 @@ package controller import ( "net/http" + "github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/service" "github.com/gin-gonic/gin" ) +func isRankingsEnabled() bool { + common.OptionMapRWMutex.RLock() + raw := common.OptionMap["HeaderNavModules"] + common.OptionMapRWMutex.RUnlock() + + if raw == "" { + return true + } + + var parsed map[string]interface{} + if err := common.Unmarshal([]byte(raw), &parsed); err != nil { + return true + } + rankings, ok := parsed["rankings"] + if !ok { + return true + } + switch v := rankings.(type) { + case bool: + return v + case map[string]interface{}: + if enabled, ok := v["enabled"]; ok { + if b, ok := enabled.(bool); ok { + return b + } + } + return true + } + return true +} + func GetRankings(c *gin.Context) { + if !isRankingsEnabled() { + c.JSON(http.StatusForbidden, gin.H{ + "success": false, + "message": "rankings is disabled", + }) + return + } + result, err := service.GetRankingsSnapshot(c.DefaultQuery("period", "week")) if err != nil { c.JSON(http.StatusBadRequest, gin.H{ diff --git a/controller/return_path.go b/controller/return_path.go new file mode 100644 index 00000000..28378b7e --- /dev/null +++ b/controller/return_path.go @@ -0,0 +1,13 @@ +package controller + +import ( + "strings" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/setting/system_setting" +) + +func paymentReturnPath(suffix string) string { + base := strings.TrimRight(system_setting.ServerAddress, "/") + return base + common.ThemeAwarePath(suffix) +} diff --git a/controller/subscription_payment_epay.go b/controller/subscription_payment_epay.go index 2567654f..ecd38472 100644 --- a/controller/subscription_payment_epay.go +++ b/controller/subscription_payment_epay.go @@ -12,7 +12,6 @@ import ( "github.com/QuantumNous/new-api/model" "github.com/QuantumNous/new-api/service" "github.com/QuantumNous/new-api/setting/operation_setting" - "github.com/QuantumNous/new-api/setting/system_setting" "github.com/gin-gonic/gin" "github.com/samber/lo" ) @@ -173,7 +172,7 @@ func SubscriptionEpayReturn(c *gin.Context) { if c.Request.Method == "POST" { // POST 请求:从 POST body 解析参数 if err := c.Request.ParseForm(); err != nil { - c.Redirect(http.StatusFound, system_setting.ServerAddress+"/console/topup?pay=fail") + c.Redirect(http.StatusFound, paymentReturnPath("/console/topup?pay=fail")) return } params = lo.Reduce(lo.Keys(c.Request.PostForm), func(r map[string]string, t string, i int) map[string]string { @@ -189,29 +188,29 @@ func SubscriptionEpayReturn(c *gin.Context) { } if len(params) == 0 { - c.Redirect(http.StatusFound, system_setting.ServerAddress+"/console/topup?pay=fail") + c.Redirect(http.StatusFound, paymentReturnPath("/console/topup?pay=fail")) return } client := GetEpayClient() if client == nil { - c.Redirect(http.StatusFound, system_setting.ServerAddress+"/console/topup?pay=fail") + c.Redirect(http.StatusFound, paymentReturnPath("/console/topup?pay=fail")) return } verifyInfo, err := client.Verify(params) if err != nil || !verifyInfo.VerifyStatus { - c.Redirect(http.StatusFound, system_setting.ServerAddress+"/console/topup?pay=fail") + c.Redirect(http.StatusFound, paymentReturnPath("/console/topup?pay=fail")) return } if verifyInfo.TradeStatus == epay.StatusTradeSuccess { LockOrder(verifyInfo.ServiceTradeNo) defer UnlockOrder(verifyInfo.ServiceTradeNo) if err := model.CompleteSubscriptionOrder(verifyInfo.ServiceTradeNo, common.GetJsonString(verifyInfo), model.PaymentProviderEpay, verifyInfo.Type); err != nil { - c.Redirect(http.StatusFound, system_setting.ServerAddress+"/console/topup?pay=fail") + c.Redirect(http.StatusFound, paymentReturnPath("/console/topup?pay=fail")) return } - c.Redirect(http.StatusFound, system_setting.ServerAddress+"/console/topup?pay=success") + c.Redirect(http.StatusFound, paymentReturnPath("/console/topup?pay=success")) return } - c.Redirect(http.StatusFound, system_setting.ServerAddress+"/console/topup?pay=pending") + c.Redirect(http.StatusFound, paymentReturnPath("/console/topup?pay=pending")) } diff --git a/controller/subscription_payment_stripe.go b/controller/subscription_payment_stripe.go index a5ce4685..3efe7b38 100644 --- a/controller/subscription_payment_stripe.go +++ b/controller/subscription_payment_stripe.go @@ -10,7 +10,6 @@ import ( "github.com/QuantumNous/new-api/logger" "github.com/QuantumNous/new-api/model" "github.com/QuantumNous/new-api/setting" - "github.com/QuantumNous/new-api/setting/system_setting" "github.com/gin-gonic/gin" "github.com/stripe/stripe-go/v81" "github.com/stripe/stripe-go/v81/checkout/session" @@ -111,8 +110,8 @@ func genStripeSubscriptionLink(referenceId string, customerId string, email stri params := &stripe.CheckoutSessionParams{ ClientReferenceID: stripe.String(referenceId), - SuccessURL: stripe.String(system_setting.ServerAddress + "/console/topup"), - CancelURL: stripe.String(system_setting.ServerAddress + "/console/topup"), + SuccessURL: stripe.String(paymentReturnPath("/console/topup")), + CancelURL: stripe.String(paymentReturnPath("/console/topup")), LineItems: []*stripe.CheckoutSessionLineItemParams{ { Price: stripe.String(priceId), diff --git a/controller/telegram.go b/controller/telegram.go index f16cdd66..b5918d8e 100644 --- a/controller/telegram.go +++ b/controller/telegram.go @@ -66,7 +66,7 @@ func TelegramBind(c *gin.Context) { return } - c.Redirect(302, "/console/personal") + c.Redirect(302, common.ThemeAwarePath("/console/personal")) } func TelegramLogin(c *gin.Context) { diff --git a/controller/topup.go b/controller/topup.go index f2848671..208de2c7 100644 --- a/controller/topup.go +++ b/controller/topup.go @@ -14,7 +14,6 @@ import ( "github.com/QuantumNous/new-api/service" "github.com/QuantumNous/new-api/setting" "github.com/QuantumNous/new-api/setting/operation_setting" - "github.com/QuantumNous/new-api/setting/system_setting" "github.com/Calcium-Ion/go-epay/epay" "github.com/gin-gonic/gin" @@ -208,7 +207,7 @@ func RequestEpay(c *gin.Context) { } callBackAddress := service.GetCallbackAddress() - returnUrl, _ := url.Parse(system_setting.ServerAddress + "/console/log") + returnUrl, _ := url.Parse(paymentReturnPath("/console/log")) notifyUrl, _ := url.Parse(callBackAddress + "/api/user/epay/notify") tradeNo := fmt.Sprintf("%s%d", common.GetRandomString(6), time.Now().Unix()) tradeNo = fmt.Sprintf("USR%dNO%s", id, tradeNo) diff --git a/controller/topup_stripe.go b/controller/topup_stripe.go index ceee8ecd..bcae201e 100644 --- a/controller/topup_stripe.go +++ b/controller/topup_stripe.go @@ -15,7 +15,6 @@ import ( "github.com/QuantumNous/new-api/model" "github.com/QuantumNous/new-api/setting" "github.com/QuantumNous/new-api/setting/operation_setting" - "github.com/QuantumNous/new-api/setting/system_setting" "github.com/gin-gonic/gin" "github.com/stripe/stripe-go/v81" @@ -348,10 +347,10 @@ func genStripeLink(referenceId string, customerId string, email string, amount i // Use custom URLs if provided, otherwise use defaults if successURL == "" { - successURL = system_setting.ServerAddress + "/console/log" + successURL = paymentReturnPath("/console/log") } if cancelURL == "" { - cancelURL = system_setting.ServerAddress + "/console/topup" + cancelURL = paymentReturnPath("/console/topup") } params := &stripe.CheckoutSessionParams{ diff --git a/controller/topup_waffo.go b/controller/topup_waffo.go index 1885c1de..344630f7 100644 --- a/controller/topup_waffo.go +++ b/controller/topup_waffo.go @@ -14,7 +14,6 @@ import ( "github.com/QuantumNous/new-api/service" "github.com/QuantumNous/new-api/setting" "github.com/QuantumNous/new-api/setting/operation_setting" - "github.com/QuantumNous/new-api/setting/system_setting" "github.com/gin-gonic/gin" "github.com/thanhpk/randstr" waffo "github.com/waffo-com/waffo-go" @@ -237,7 +236,7 @@ func RequestWaffoPay(c *gin.Context) { if setting.WaffoNotifyUrl != "" { notifyUrl = setting.WaffoNotifyUrl } - returnUrl := system_setting.ServerAddress + "/console/topup?show_history=true" + returnUrl := paymentReturnPath("/console/topup?show_history=true") if setting.WaffoReturnUrl != "" { returnUrl = setting.WaffoReturnUrl } diff --git a/controller/topup_waffo_pancake.go b/controller/topup_waffo_pancake.go index 09f15163..11c581fa 100644 --- a/controller/topup_waffo_pancake.go +++ b/controller/topup_waffo_pancake.go @@ -13,7 +13,6 @@ import ( "github.com/QuantumNous/new-api/service" "github.com/QuantumNous/new-api/setting" "github.com/QuantumNous/new-api/setting/operation_setting" - "github.com/QuantumNous/new-api/setting/system_setting" "github.com/gin-gonic/gin" "github.com/shopspring/decimal" "github.com/thanhpk/randstr" @@ -107,7 +106,7 @@ func getWaffoPancakeReturnURL() string { if strings.TrimSpace(setting.WaffoPancakeReturnURL) != "" { return setting.WaffoPancakeReturnURL } - return strings.TrimRight(system_setting.ServerAddress, "/") + "/console/topup?show_history=true" + return paymentReturnPath("/console/topup?show_history=true") } func RequestWaffoPancakePay(c *gin.Context) { diff --git a/service/quota.go b/service/quota.go index 7364598a..e2ab25cf 100644 --- a/service/quota.go +++ b/service/quota.go @@ -17,7 +17,6 @@ import ( perfmetrics "github.com/QuantumNous/new-api/pkg/perf_metrics" relaycommon "github.com/QuantumNous/new-api/relay/common" "github.com/QuantumNous/new-api/setting/ratio_setting" - "github.com/QuantumNous/new-api/setting/system_setting" "github.com/QuantumNous/new-api/types" "github.com/bytedance/gopkg/util/gopool" @@ -467,7 +466,7 @@ func checkAndSendQuotaNotify(relayInfo *relaycommon.RelayInfo, quota int, preCon } if quotaTooLow { prompt := "您的额度即将用尽" - topUpLink := fmt.Sprintf("%s/console/topup", system_setting.ServerAddress) + topUpLink := PaymentReturnURL("/console/topup") // 根据通知方式生成不同的内容格式 var content string @@ -521,7 +520,7 @@ func checkAndSendSubscriptionQuotaNotify(relayInfo *relaycommon.RelayInfo) { } prompt := "您的订阅额度即将用尽" - topUpLink := fmt.Sprintf("%s/console/topup", system_setting.ServerAddress) + topUpLink := PaymentReturnURL("/console/topup") var content string var values []interface{} diff --git a/service/return_path.go b/service/return_path.go new file mode 100644 index 00000000..c99e1fd3 --- /dev/null +++ b/service/return_path.go @@ -0,0 +1,13 @@ +package service + +import ( + "strings" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/setting/system_setting" +) + +func PaymentReturnURL(suffix string) string { + base := strings.TrimRight(system_setting.ServerAddress, "/") + return base + common.ThemeAwarePath(suffix) +} diff --git a/web/default/src/features/home/components/sections/hero.tsx b/web/default/src/features/home/components/sections/hero.tsx index f5955972..34f6d915 100644 --- a/web/default/src/features/home/components/sections/hero.tsx +++ b/web/default/src/features/home/components/sections/hero.tsx @@ -19,7 +19,6 @@ For commercial licensing, please contact support@quantumnous.com import { Link } from '@tanstack/react-router' import { ArrowRight } from 'lucide-react' import { useTranslation } from 'react-i18next' -import { useSystemConfig } from '@/hooks/use-system-config' import { Button } from '@/components/ui/button' import { HeroTerminalDemo } from '../hero-terminal-demo' @@ -30,7 +29,6 @@ interface HeroProps { export function Hero(props: HeroProps) { const { t } = useTranslation() - const { systemName } = useSystemConfig() return (
@@ -67,10 +65,7 @@ export function Hero(props: HeroProps) { className='landing-animate-fade-up text-muted-foreground/80 mt-5 max-w-lg text-base leading-relaxed opacity-0 md:text-lg' style={{ animationDelay: '80ms' }} > - {systemName}{' '} - {t( - 'is an open-source AI API gateway for self-hosted deployments. Connect multiple upstream services, manage models, keys, quotas, logs, and routing policies in one place.' - )} + {t('Power AI applications, manage digital assets, connect the Future')}

g.value === 'auto')) { - groups.unshift({ - value: 'auto', - label: 'auto', - desc: t('Auto (Circuit Breaker)'), - }) - } + const backendHasAuto = groups.some((g) => g.value === 'auto') const form = useForm({ resolver: zodResolver(apiKeyFormSchema), @@ -169,17 +161,28 @@ export function ApiKeysMutateDrawer({ // Load existing data when updating useEffect(() => { if (open && isUpdate && currentRow) { - // For update, fetch fresh data getApiKey(currentRow.id).then((result) => { if (result.success && result.data) { form.reset(transformApiKeyToFormDefaults(result.data)) } }) } else if (open && !isUpdate) { - // For create, reset to defaults - form.reset(getApiKeyFormDefaultValues(defaultUseAutoGroup)) + form.reset(getApiKeyFormDefaultValues(defaultUseAutoGroup && backendHasAuto)) } - }, [open, isUpdate, currentRow, form, defaultUseAutoGroup]) + }, [open, isUpdate, currentRow, form, defaultUseAutoGroup, backendHasAuto]) + + // Correct group after groups load: if the form value is not in available groups, fall back + useEffect(() => { + if (groups.length === 0) return + const currentGroup = form.getValues('group') + if (currentGroup && !groups.some((g) => g.value === currentGroup)) { + const fallback = groups.find((g) => g.value === 'default')?.value ?? groups[0]?.value ?? '' + form.setValue('group', fallback) + if (currentGroup === 'auto') { + form.setValue('cross_group_retry', false) + } + } + }, [groups, form]) const onSubmit = async (data: ApiKeyFormValues) => { setIsSubmitting(true) diff --git a/web/default/src/features/playground/constants.ts b/web/default/src/features/playground/constants.ts index 8748a704..cd2ef913 100644 --- a/web/default/src/features/playground/constants.ts +++ b/web/default/src/features/playground/constants.ts @@ -39,8 +39,9 @@ export const API_ENDPOINTS = { USER_GROUPS: '/api/user/self/groups', } as const -// Default group -export const DEFAULT_GROUP = 'auto' as const +// Default group — uses 'default' as the safe fallback; auto-group is +// only selected when the backend confirms it is available for the user. +export const DEFAULT_GROUP = 'default' as const // Default configuration export const DEFAULT_CONFIG: PlaygroundConfig = { diff --git a/web/default/src/features/playground/index.tsx b/web/default/src/features/playground/index.tsx index 49d4a37c..a9a86b76 100644 --- a/web/default/src/features/playground/index.tsx +++ b/web/default/src/features/playground/index.tsx @@ -21,7 +21,6 @@ import { useQuery } from '@tanstack/react-query' import { getUserModels, getUserGroups } from './api' import { PlaygroundChat } from './components/playground-chat' import { PlaygroundInput } from './components/playground-input' -import { DEFAULT_GROUP } from './constants' import { usePlaygroundState, useChatHandler } from './hooks' import { createUserMessage, createLoadingAssistantMessage } from './lib' import type { Message as MessageType } from './types' @@ -79,22 +78,16 @@ export function Playground() { useEffect(() => { if (!groupsData) return - // Add auto group if not present - const hasAutoGroup = groupsData.some((g) => g.value === DEFAULT_GROUP) - const processedGroups = hasAutoGroup - ? groupsData - : [ - { - value: DEFAULT_GROUP, - label: 'Auto', - ratio: 1, - desc: 'Circuit Breaker', - }, - ...groupsData, - ] + setGroups(groupsData) - setGroups(processedGroups) - }, [groupsData, setGroups]) + const hasCurrentGroup = groupsData.some((g) => g.value === config.group) + if (!hasCurrentGroup && groupsData.length > 0) { + const fallback = + groupsData.find((g) => g.value === 'default')?.value ?? + groupsData[0].value + updateConfig('group', fallback) + } + }, [groupsData, setGroups, config.group, updateConfig]) const handleSendMessage = (text: string) => { const userMessage = createUserMessage(text) diff --git a/web/default/src/i18n/locales/en.json b/web/default/src/i18n/locales/en.json index bb8496d1..29897900 100644 --- a/web/default/src/i18n/locales/en.json +++ b/web/default/src/i18n/locales/en.json @@ -2059,7 +2059,7 @@ "IP Filter Mode": "IP Filter Mode", "IP Restriction": "IP Restriction", "IP Whitelist (supports CIDR)": "IP Whitelist (supports CIDR)", - "is an open-source AI API gateway for self-hosted deployments. Connect multiple upstream services, manage models, keys, quotas, logs, and routing policies in one place.": "is an open-source AI API gateway for self-hosted deployments. Connect multiple upstream services, manage models, keys, quotas, logs, and routing policies in one place.", + "Power AI applications, manage digital assets, connect the Future": "Power AI applications, manage digital assets, connect the Future", "is less than the configured maximum cache size": "is less than the configured maximum cache size", "is the default price; ": "is the default price; ", "It seems like the page you're looking for": "It seems like the page you're looking for", diff --git a/web/default/src/i18n/locales/fr.json b/web/default/src/i18n/locales/fr.json index 5944badb..109b229b 100644 --- a/web/default/src/i18n/locales/fr.json +++ b/web/default/src/i18n/locales/fr.json @@ -2059,7 +2059,7 @@ "IP Filter Mode": "Mode de filtre IP", "IP Restriction": "Restriction IP", "IP Whitelist (supports CIDR)": "Liste blanche IP (supporte CIDR)", - "is an open-source AI API gateway for self-hosted deployments. Connect multiple upstream services, manage models, keys, quotas, logs, and routing policies in one place.": "est une passerelle API IA open source pour les déploiements auto-hébergés. Connectez plusieurs services en amont et gérez au même endroit les modèles, les clés, les quotas, les journaux et les politiques de routage.", + "Power AI applications, manage digital assets, connect the Future": "Propulser les applications IA, gérer les actifs numériques, connecter l'Avenir", "is less than the configured maximum cache size": "est inférieur à la taille maximale du cache configurée", "is the default price; ": "est le prix par défaut ; ", "It seems like the page you're looking for": "Il semble que la page que vous recherchez", diff --git a/web/default/src/i18n/locales/ja.json b/web/default/src/i18n/locales/ja.json index 9ee353aa..403981a2 100644 --- a/web/default/src/i18n/locales/ja.json +++ b/web/default/src/i18n/locales/ja.json @@ -2059,7 +2059,7 @@ "IP Filter Mode": "IP フィルターモード", "IP Restriction": "IP制限", "IP Whitelist (supports CIDR)": "IP ホワイトリスト(CIDR対応)", - "is an open-source AI API gateway for self-hosted deployments. Connect multiple upstream services, manage models, keys, quotas, logs, and routing policies in one place.": "はセルフホスト運用向けのオープンソースAI APIゲートウェイです。複数のアップストリームサービスを接続し、モデル、キー、クォータ、ログ、ルーティングポリシーを一元管理できます。", + "Power AI applications, manage digital assets, connect the Future": "AIアプリケーションを支え、デジタル資産を管理し、未来をつなぐ", "is less than the configured maximum cache size": "設定された最大キャッシュサイズより小さい", "is the default price; ": "はデフォルト価格です; ", "It seems like the page you're looking for": "お探しのページは", diff --git a/web/default/src/i18n/locales/ru.json b/web/default/src/i18n/locales/ru.json index f6b24615..0c1fbc77 100644 --- a/web/default/src/i18n/locales/ru.json +++ b/web/default/src/i18n/locales/ru.json @@ -2059,7 +2059,7 @@ "IP Filter Mode": "Режим фильтрации IP", "IP Restriction": "Ограничение IP", "IP Whitelist (supports CIDR)": "Белый список IP (поддерживает CIDR)", - "is an open-source AI API gateway for self-hosted deployments. Connect multiple upstream services, manage models, keys, quotas, logs, and routing policies in one place.": "— это open-source API-шлюз для ИИ, предназначенный для самостоятельного размещения. Подключайте несколько вышестоящих сервисов и управляйте моделями, ключами, квотами, журналами и политиками маршрутизации в одном месте.", + "Power AI applications, manage digital assets, connect the Future": "Обеспечивайте AI-приложения, управляйте цифровыми активами, соединяйте Будущее", "is less than the configured maximum cache size": "меньше настроенного максимального размера кэша", "is the default price; ": "— цена по умолчанию; ", "It seems like the page you're looking for": "Похоже, страница, которую вы ищете", diff --git a/web/default/src/i18n/locales/vi.json b/web/default/src/i18n/locales/vi.json index aafdc61c..77647eb5 100644 --- a/web/default/src/i18n/locales/vi.json +++ b/web/default/src/i18n/locales/vi.json @@ -2059,7 +2059,7 @@ "IP Filter Mode": "Lọc IP", "IP Restriction": "Giới hạn IP", "IP Whitelist (supports CIDR)": "Danh sách trắng IP (hỗ trợ CIDR)", - "is an open-source AI API gateway for self-hosted deployments. Connect multiple upstream services, manage models, keys, quotas, logs, and routing policies in one place.": "là cổng API AI mã nguồn mở dành cho triển khai tự lưu trữ. Kết nối nhiều dịch vụ thượng nguồn, quản lý mô hình, khóa, hạn mức, nhật ký và chính sách định tuyến tại một nơi.", + "Power AI applications, manage digital assets, connect the Future": "Vận hành ứng dụng AI, quản lý tài sản số, kết nối Tương lai", "is less than the configured maximum cache size": "nhỏ hơn kích thước bộ nhớ đệm tối đa đã cấu hình", "is the default price; ": "là giá mặc định; ", "It seems like the page you're looking for": "Có vẻ như trang bạn đang tìm kiếm", diff --git a/web/default/src/i18n/locales/zh.json b/web/default/src/i18n/locales/zh.json index 4d6bab3d..53e912c2 100644 --- a/web/default/src/i18n/locales/zh.json +++ b/web/default/src/i18n/locales/zh.json @@ -2059,7 +2059,7 @@ "IP Filter Mode": "IP 过滤模式", "IP Restriction": "IP 限制", "IP Whitelist (supports CIDR)": "IP 白名单(支持 CIDR 表达式)", - "is an open-source AI API gateway for self-hosted deployments. Connect multiple upstream services, manage models, keys, quotas, logs, and routing policies in one place.": "是一个用于自托管部署的开源 AI API 网关。接入多家上游服务,并集中管理模型、密钥、额度、日志与路由策略。", + "Power AI applications, manage digital assets, connect the Future": "承载 AI 应用,管理数字资产,连接未来", "is less than the configured maximum cache size": "小于配置的最大缓存大小", "is the default price; ": "为默认价格;", "It seems like the page you're looking for": "您要查找的页面似乎", diff --git a/web/default/src/lib/nav-modules.ts b/web/default/src/lib/nav-modules.ts new file mode 100644 index 00000000..93ab5388 --- /dev/null +++ b/web/default/src/lib/nav-modules.ts @@ -0,0 +1,80 @@ +/* +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 +*/ + +type ModuleAccess = { enabled: boolean; requireAuth: boolean } + +const DEFAULTS: Record = { + pricing: { enabled: true, requireAuth: false }, + rankings: { enabled: true, requireAuth: false }, +} + +function parseAccess(raw: unknown, fallback: ModuleAccess): ModuleAccess { + if (typeof raw === 'boolean') return { enabled: raw, requireAuth: fallback.requireAuth } + if (raw && typeof raw === 'object') { + const r = raw as Record + return { + enabled: typeof r.enabled === 'boolean' ? r.enabled : fallback.enabled, + requireAuth: typeof r.requireAuth === 'boolean' ? r.requireAuth : fallback.requireAuth, + } + } + return { ...fallback } +} + +function getCachedStatus(): Record | null { + try { + const raw = window.localStorage.getItem('status') + return raw ? (JSON.parse(raw) as Record) : null + } catch { + return null + } +} + +export function getModuleAccess(module: 'rankings' | 'pricing'): ModuleAccess { + const status = getCachedStatus() + if (!status) return DEFAULTS[module] + + const rawNav = status.HeaderNavModules + if (!rawNav || String(rawNav).trim() === '') return DEFAULTS[module] + + try { + const parsed = JSON.parse(String(rawNav)) as Record + return parseAccess(parsed[module], DEFAULTS[module]) + } catch { + return DEFAULTS[module] + } +} + +export function isSidebarModuleEnabled(section: string, module: string): boolean { + const status = getCachedStatus() + if (!status) return true + + const raw = status.SidebarModulesAdmin + if (!raw || String(raw).trim() === '') return true + + try { + const parsed = JSON.parse(String(raw)) as Record> + const sectionConfig = parsed[section] + if (!sectionConfig) return true + if (sectionConfig.enabled === false) return false + if (sectionConfig[module] === false) return false + return true + } catch { + return true + } +} diff --git a/web/default/src/routes/_authenticated/playground/index.tsx b/web/default/src/routes/_authenticated/playground/index.tsx index 41b5778f..a755accc 100644 --- a/web/default/src/routes/_authenticated/playground/index.tsx +++ b/web/default/src/routes/_authenticated/playground/index.tsx @@ -16,11 +16,17 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ -import { createFileRoute } from '@tanstack/react-router' +import { createFileRoute, redirect } from '@tanstack/react-router' import { Main } from '@/components/layout' import { Playground } from '@/features/playground' +import { isSidebarModuleEnabled } from '@/lib/nav-modules' export const Route = createFileRoute('/_authenticated/playground/')({ + beforeLoad: () => { + if (!isSidebarModuleEnabled('chat', 'playground')) { + throw redirect({ to: '/dashboard' }) + } + }, component: PlaygroundPage, }) diff --git a/web/default/src/routes/console/topup.tsx b/web/default/src/routes/console/topup.tsx index fce5e772..8e728a2c 100644 --- a/web/default/src/routes/console/topup.tsx +++ b/web/default/src/routes/console/topup.tsx @@ -16,13 +16,17 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ +import z from 'zod' import { createFileRoute, redirect } from '@tanstack/react-router' +const topupSearchSchema = z.record(z.string(), z.unknown()).catch({}) + export const Route = createFileRoute('/console/topup')({ - beforeLoad: () => { + validateSearch: topupSearchSchema, + beforeLoad: ({ search }) => { throw redirect({ to: '/wallet', - search: { show_history: true }, + search: { show_history: true, ...search }, }) }, }) diff --git a/web/default/src/routes/rankings/index.tsx b/web/default/src/routes/rankings/index.tsx index 05ab20b7..24de1050 100644 --- a/web/default/src/routes/rankings/index.tsx +++ b/web/default/src/routes/rankings/index.tsx @@ -17,8 +17,10 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ import z from 'zod' -import { createFileRoute } from '@tanstack/react-router' +import { createFileRoute, redirect } from '@tanstack/react-router' import { Rankings } from '@/features/rankings' +import { getModuleAccess } from '@/lib/nav-modules' +import { useAuthStore } from '@/stores/auth-store' const rankingsSearchSchema = z.object({ period: z @@ -29,5 +31,17 @@ const rankingsSearchSchema = z.object({ export const Route = createFileRoute('/rankings/')({ validateSearch: rankingsSearchSchema, + beforeLoad: () => { + const access = getModuleAccess('rankings') + if (!access.enabled) { + throw redirect({ to: '/' }) + } + if (access.requireAuth) { + const { auth } = useAuthStore.getState() + if (!auth.user) { + throw redirect({ to: '/sign-in', search: { redirect: '/rankings' } }) + } + } + }, component: Rankings, })