fix: defaut ui triage (#4802)
Some checks failed
Publish Docker image (Multi-arch) / Build & push (amd64) (push) Has been cancelled
Publish Docker image (Multi-arch) / Build & push (arm64) (push) Has been cancelled
Publish Docker image (Multi-arch) / Create multi-arch manifests (push) Has been cancelled
Release (Linux, macOS, Windows) / Linux Release (push) Has been cancelled
Release (Linux, macOS, Windows) / macOS Release (push) Has been cancelled
Release (Linux, macOS, Windows) / Windows Release (push) Has been cancelled

* 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
This commit is contained in:
Calcium-Ion 2026-05-12 16:47:02 +08:00 committed by GitHub
parent a720064d91
commit 469d3747af
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
27 changed files with 261 additions and 71 deletions

View File

@ -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

View File

@ -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
}

View File

@ -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{

13
controller/return_path.go Normal file
View File

@ -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)
}

View File

@ -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"))
}

View File

@ -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),

View File

@ -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) {

View File

@ -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)

View File

@ -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{

View File

@ -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
}

View File

@ -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) {

View File

@ -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{}

13
service/return_path.go Normal file
View File

@ -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)
}

View File

@ -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 (
<section className='relative z-10 flex flex-col items-center overflow-hidden px-6 pt-28 pb-16 md:pt-36 md:pb-24'>
@ -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')}
</p>
<div
className='landing-animate-fade-up mt-8 flex items-center gap-3 opacity-0'

View File

@ -151,15 +151,7 @@ export function ApiKeysMutateDrawer({
ratio: info.ratio,
})
)
// Add auto group if configured
if (!groups.some((g) => 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<ApiKeyFormValues>({
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)

View File

@ -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 = {

View File

@ -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)

View File

@ -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",

View File

@ -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",

View File

@ -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": "お探しのページは",

View File

@ -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": "Похоже, страница, которую вы ищете",

View File

@ -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",

View File

@ -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": "您要查找的页面似乎",

80
web/default/src/lib/nav-modules.ts vendored Normal file
View File

@ -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 <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
type ModuleAccess = { enabled: boolean; requireAuth: boolean }
const DEFAULTS: Record<string, ModuleAccess> = {
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<string, unknown>
return {
enabled: typeof r.enabled === 'boolean' ? r.enabled : fallback.enabled,
requireAuth: typeof r.requireAuth === 'boolean' ? r.requireAuth : fallback.requireAuth,
}
}
return { ...fallback }
}
function getCachedStatus(): Record<string, unknown> | null {
try {
const raw = window.localStorage.getItem('status')
return raw ? (JSON.parse(raw) as Record<string, unknown>) : 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<string, unknown>
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<string, Record<string, boolean>>
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
}
}

View File

@ -16,11 +16,17 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
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,
})

View File

@ -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 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 },
})
},
})

View File

@ -17,8 +17,10 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
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,
})