fix: enforce header nav access control for public modules (#4889)

This commit is contained in:
yyhhyyyyyy 2026-05-16 14:54:47 +08:00 committed by GitHub
parent 8a10dedb7d
commit 6f8668e4c3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 689 additions and 151 deletions

View File

@ -3,51 +3,11 @@ 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{

135
middleware/header_nav.go Normal file
View File

@ -0,0 +1,135 @@
package middleware
import (
"fmt"
"net/http"
"strings"
"github.com/QuantumNous/new-api/common"
"github.com/gin-gonic/gin"
)
type headerNavAccess struct {
Enabled bool
RequireAuth bool
}
func getHeaderNavAccess(module string) headerNavAccess {
fallback := headerNavAccess{
Enabled: true,
RequireAuth: false,
}
common.OptionMapRWMutex.RLock()
raw := common.OptionMap["HeaderNavModules"]
common.OptionMapRWMutex.RUnlock()
if strings.TrimSpace(raw) == "" {
return fallback
}
var parsed map[string]any
if err := common.Unmarshal([]byte(raw), &parsed); err != nil {
return fallback
}
return parseHeaderNavAccess(parsed[module], fallback)
}
func parseHeaderNavAccess(raw any, fallback headerNavAccess) headerNavAccess {
switch value := raw.(type) {
case bool:
return headerNavAccess{
Enabled: value,
RequireAuth: fallback.RequireAuth,
}
case string:
return headerNavAccess{
Enabled: parseHeaderNavBool(value, fallback.Enabled),
RequireAuth: fallback.RequireAuth,
}
case float64:
return headerNavAccess{
Enabled: parseHeaderNavBool(value, fallback.Enabled),
RequireAuth: fallback.RequireAuth,
}
case map[string]any:
access := fallback
if enabled, ok := value["enabled"]; ok {
access.Enabled = parseHeaderNavBool(enabled, fallback.Enabled)
}
if requireAuth, ok := value["requireAuth"]; ok {
access.RequireAuth = parseHeaderNavBool(requireAuth, fallback.RequireAuth)
}
return access
default:
return fallback
}
}
func parseHeaderNavBool(value any, fallback bool) bool {
switch v := value.(type) {
case bool:
return v
case string:
switch strings.ToLower(strings.TrimSpace(v)) {
case "true", "1":
return true
case "false", "0":
return false
default:
return fallback
}
case float64:
if v == 1 {
return true
}
if v == 0 {
return false
}
return fallback
case int:
if v == 1 {
return true
}
if v == 0 {
return false
}
return fallback
default:
return fallback
}
}
func HeaderNavModuleAuth(module string) gin.HandlerFunc {
return func(c *gin.Context) {
access := getHeaderNavAccess(module)
if !access.Enabled {
c.JSON(http.StatusForbidden, gin.H{
"success": false,
"message": fmt.Sprintf("%s is disabled", module),
})
c.Abort()
return
}
if access.RequireAuth {
UserAuth()(c)
return
}
TryUserAuth()(c)
}
}
func HeaderNavModulePublicOrUserAuth(module string) gin.HandlerFunc {
return func(c *gin.Context) {
access := getHeaderNavAccess(module)
if !access.Enabled || access.RequireAuth {
UserAuth()(c)
return
}
TryUserAuth()(c)
}
}

View File

@ -0,0 +1,167 @@
package middleware
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/QuantumNous/new-api/common"
"github.com/gin-contrib/sessions"
"github.com/gin-contrib/sessions/cookie"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
func withHeaderNavModules(t *testing.T, raw string) {
t.Helper()
common.OptionMapRWMutex.Lock()
if common.OptionMap == nil {
common.OptionMap = map[string]string{}
}
previous, hadPrevious := common.OptionMap["HeaderNavModules"]
common.OptionMap["HeaderNavModules"] = raw
common.OptionMapRWMutex.Unlock()
t.Cleanup(func() {
common.OptionMapRWMutex.Lock()
defer common.OptionMapRWMutex.Unlock()
if hadPrevious {
common.OptionMap["HeaderNavModules"] = previous
return
}
delete(common.OptionMap, "HeaderNavModules")
})
}
func performHeaderNavRequest(t *testing.T, handler gin.HandlerFunc, authenticated bool) *httptest.ResponseRecorder {
t.Helper()
gin.SetMode(gin.TestMode)
router := gin.New()
router.Use(sessions.Sessions("session", cookie.NewStore([]byte("header-nav-test"))))
router.GET("/login", func(c *gin.Context) {
session := sessions.Default(c)
session.Set("username", "tester")
session.Set("role", common.RoleCommonUser)
session.Set("id", 1)
session.Set("status", common.UserStatusEnabled)
session.Set("group", "default")
if err := session.Save(); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false})
return
}
c.Status(http.StatusNoContent)
})
router.GET("/api/test", handler, func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true})
})
var cookies []*http.Cookie
if authenticated {
loginRecorder := httptest.NewRecorder()
loginRequest := httptest.NewRequest(http.MethodGet, "/login", nil)
router.ServeHTTP(loginRecorder, loginRequest)
require.Equal(t, http.StatusNoContent, loginRecorder.Code)
cookies = loginRecorder.Result().Cookies()
}
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodGet, "/api/test", nil)
if authenticated {
request.Header.Set("New-Api-User", "1")
for _, cookie := range cookies {
request.AddCookie(cookie)
}
}
router.ServeHTTP(recorder, request)
return recorder
}
func TestHeaderNavModuleAuthAllowsDefaultPublicAccess(t *testing.T) {
withHeaderNavModules(t, "")
recorder := performHeaderNavRequest(t, HeaderNavModuleAuth("pricing"), false)
require.Equal(t, http.StatusOK, recorder.Code)
}
func TestHeaderNavModuleAuthRejectsDisabledPricing(t *testing.T) {
raw := `{"pricing":{"enabled":false,"requireAuth":false}}`
withHeaderNavModules(t, raw)
recorder := performHeaderNavRequest(t, HeaderNavModuleAuth("pricing"), false)
require.Equal(t, http.StatusForbidden, recorder.Code)
}
func TestHeaderNavModuleAuthRequiresLoginForPricing(t *testing.T) {
raw := `{"pricing":{"enabled":true,"requireAuth":true}}`
withHeaderNavModules(t, raw)
recorder := performHeaderNavRequest(t, HeaderNavModuleAuth("pricing"), false)
require.Equal(t, http.StatusUnauthorized, recorder.Code)
}
func TestHeaderNavModuleAuthRequiresLoginForRankings(t *testing.T) {
raw := `{"rankings":{"enabled":true,"requireAuth":true}}`
withHeaderNavModules(t, raw)
recorder := performHeaderNavRequest(t, HeaderNavModuleAuth("rankings"), false)
require.Equal(t, http.StatusUnauthorized, recorder.Code)
}
func TestHeaderNavModuleAuthRejectsLegacyDisabledModule(t *testing.T) {
raw := `{"rankings":false}`
withHeaderNavModules(t, raw)
recorder := performHeaderNavRequest(t, HeaderNavModuleAuth("rankings"), false)
require.Equal(t, http.StatusForbidden, recorder.Code)
}
func TestHeaderNavModulePublicOrUserAuthAllowsDefaultPublicAccess(t *testing.T) {
withHeaderNavModules(t, "")
recorder := performHeaderNavRequest(t, HeaderNavModulePublicOrUserAuth("pricing"), false)
require.Equal(t, http.StatusOK, recorder.Code)
}
func TestHeaderNavModulePublicOrUserAuthRequiresLoginWhenDisabled(t *testing.T) {
raw := `{"pricing":{"enabled":false,"requireAuth":false}}`
withHeaderNavModules(t, raw)
recorder := performHeaderNavRequest(t, HeaderNavModulePublicOrUserAuth("pricing"), false)
require.Equal(t, http.StatusUnauthorized, recorder.Code)
}
func TestHeaderNavModulePublicOrUserAuthAllowsLoggedInWhenDisabled(t *testing.T) {
raw := `{"pricing":{"enabled":false,"requireAuth":false}}`
withHeaderNavModules(t, raw)
recorder := performHeaderNavRequest(t, HeaderNavModulePublicOrUserAuth("pricing"), true)
require.Equal(t, http.StatusOK, recorder.Code)
}
func TestHeaderNavModulePublicOrUserAuthRequiresLoginWhenRequireAuth(t *testing.T) {
raw := `{"pricing":{"enabled":true,"requireAuth":true}}`
withHeaderNavModules(t, raw)
recorder := performHeaderNavRequest(t, HeaderNavModulePublicOrUserAuth("pricing"), false)
require.Equal(t, http.StatusUnauthorized, recorder.Code)
}
func TestHeaderNavModulePublicOrUserAuthRequiresLoginForLegacyDisabledModule(t *testing.T) {
raw := `{"pricing":false}`
withHeaderNavModules(t, raw)
recorder := performHeaderNavRequest(t, HeaderNavModulePublicOrUserAuth("pricing"), false)
require.Equal(t, http.StatusUnauthorized, recorder.Code)
}

View File

@ -30,14 +30,14 @@ func SetApiRouter(router *gin.Engine) {
apiRouter.GET("/about", controller.GetAbout)
//apiRouter.GET("/midjourney", controller.GetMidjourney)
apiRouter.GET("/home_page_content", controller.GetHomePageContent)
apiRouter.GET("/pricing", middleware.TryUserAuth(), controller.GetPricing)
apiRouter.GET("/pricing", middleware.HeaderNavModuleAuth("pricing"), controller.GetPricing)
perfMetricsRoute := apiRouter.Group("/perf-metrics")
perfMetricsRoute.Use(middleware.TryUserAuth())
perfMetricsRoute.Use(middleware.HeaderNavModulePublicOrUserAuth("pricing"))
{
perfMetricsRoute.GET("/summary", controller.GetPerfMetricsSummary)
perfMetricsRoute.GET("", controller.GetPerfMetrics)
}
apiRouter.GET("/rankings", controller.GetRankings)
apiRouter.GET("/rankings", middleware.HeaderNavModuleAuth("rankings"), controller.GetRankings)
apiRouter.GET("/verification", middleware.EmailVerificationRateLimit(), middleware.TurnstileCheck(), controller.SendEmailVerification)
apiRouter.GET("/reset_password", middleware.CriticalRateLimit(), middleware.TurnstileCheck(), controller.SendPasswordResetEmail)
apiRouter.POST("/user/reset", middleware.CriticalRateLimit(), controller.ResetPassword)

View File

@ -16,8 +16,8 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { useState, useEffect } from 'react'
import { Link, useRouterState } from '@tanstack/react-router'
import { useCallback, useEffect, useState } from 'react'
import { Link, useNavigate, useRouterState } from '@tanstack/react-router'
import { useTranslation } from 'react-i18next'
import { useAuthStore } from '@/stores/auth-store'
import { cn } from '@/lib/utils'
@ -25,6 +25,14 @@ import { useNotifications } from '@/hooks/use-notifications'
import { useSystemConfig } from '@/hooks/use-system-config'
import { useTopNavLinks } from '@/hooks/use-top-nav-links'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Skeleton } from '@/components/ui/skeleton'
import { LanguageSwitcher } from '@/components/language-switcher'
import { NotificationButton } from '@/components/notification-button'
@ -35,6 +43,13 @@ import { defaultTopNavLinks } from '../config/top-nav.config'
import type { TopNavLink } from '../types'
import { HeaderLogo } from './header-logo'
const AUTH_PROMPT_SECONDS = 5
type AuthPromptTarget = {
title: string
href: string
}
export interface PublicHeaderProps {
navLinks?: TopNavLink[]
mobileLinks?: TopNavLink[]
@ -65,8 +80,13 @@ export function PublicHeader(props: PublicHeaderProps) {
} = props
const { t } = useTranslation()
const navigate = useNavigate()
const [scrolled, setScrolled] = useState(false)
const [mobileOpen, setMobileOpen] = useState(false)
const [authPromptTarget, setAuthPromptTarget] =
useState<AuthPromptTarget | null>(null)
const [authPromptSecondsLeft, setAuthPromptSecondsLeft] =
useState(AUTH_PROMPT_SECONDS)
const { auth } = useAuthStore()
const {
systemName,
@ -98,6 +118,67 @@ export function PublicHeader(props: PublicHeaderProps) {
}
}, [mobileOpen])
useEffect(() => {
if (!authPromptTarget) return
const intervalId = window.setInterval(() => {
setAuthPromptSecondsLeft((seconds) => Math.max(seconds - 1, 0))
}, 1000)
const timeoutId = window.setTimeout(() => {
const redirect = authPromptTarget.href
setAuthPromptTarget(null)
navigate({ to: '/sign-in', search: { redirect } })
}, AUTH_PROMPT_SECONDS * 1000)
return () => {
window.clearInterval(intervalId)
window.clearTimeout(timeoutId)
}
}, [authPromptTarget, navigate])
const closeAuthPrompt = useCallback(() => {
setAuthPromptTarget(null)
setAuthPromptSecondsLeft(AUTH_PROMPT_SECONDS)
}, [])
const navigateToSignIn = useCallback(() => {
const redirect = authPromptTarget?.href || '/'
setAuthPromptTarget(null)
navigate({ to: '/sign-in', search: { redirect } })
}, [authPromptTarget?.href, navigate])
const handleNavLinkClick = useCallback(
(
event: React.MouseEvent<HTMLAnchorElement>,
link: TopNavLink,
closeMobile = false
) => {
if (link.disabled) {
event.preventDefault()
return
}
if (link.requiresAuth) {
event.preventDefault()
if (closeMobile) {
setMobileOpen(false)
}
setAuthPromptSecondsLeft(AUTH_PROMPT_SECONDS)
setAuthPromptTarget({
title: t(link.title),
href: link.href,
})
return
}
if (closeMobile) {
setMobileOpen(false)
}
},
[t]
)
return (
<>
<header className='pointer-events-none fixed inset-x-0 top-0 z-50'>
@ -150,7 +231,13 @@ export function PublicHeader(props: PublicHeaderProps) {
href={link.href}
target='_blank'
rel='noopener noreferrer'
className='text-muted-foreground hover:text-foreground rounded-lg px-3 py-1.5 text-[13px] font-medium transition-colors duration-200'
aria-disabled={link.disabled}
tabIndex={link.disabled ? -1 : undefined}
onClick={(event) => handleNavLinkClick(event, link)}
className={cn(
'text-muted-foreground hover:text-foreground rounded-lg px-3 py-1.5 text-[13px] font-medium transition-colors duration-200',
link.disabled && 'pointer-events-none opacity-50'
)}
>
{t(link.title)}
</a>
@ -160,11 +247,14 @@ export function PublicHeader(props: PublicHeaderProps) {
<Link
key={i}
to={link.href}
disabled={link.disabled}
onClick={(event) => handleNavLinkClick(event, link)}
className={cn(
'rounded-lg px-3 py-1.5 text-[13px] font-medium transition-colors duration-200',
isActive
? 'text-foreground'
: 'text-muted-foreground hover:text-foreground'
: 'text-muted-foreground hover:text-foreground',
link.disabled && 'pointer-events-none opacity-50'
)}
>
{t(link.title)}
@ -260,21 +350,42 @@ export function PublicHeader(props: PublicHeaderProps) {
<nav className='flex flex-col gap-1'>
{links.map((link, i) => {
const isActive = pathname === link.href
const linkClassName = cn(
'flex items-center gap-3 py-3 text-base font-medium tracking-tight transition-all duration-500 ease-[cubic-bezier(0.16,1,0.3,1)]',
mobileOpen
? 'translate-y-0 opacity-100'
: 'translate-y-4 opacity-0',
isActive ? 'text-foreground' : 'text-muted-foreground',
link.disabled && 'pointer-events-none opacity-50'
)
const transitionStyle = {
transitionDelay: mobileOpen ? `${100 + i * 50}ms` : '0ms',
}
if (link.external) {
return (
<a
key={i}
href={link.href}
target='_blank'
rel='noopener noreferrer'
aria-disabled={link.disabled}
tabIndex={link.disabled ? -1 : undefined}
onClick={(event) => handleNavLinkClick(event, link, true)}
className={linkClassName}
style={transitionStyle}
>
{t(link.title)}
</a>
)
}
return (
<Link
key={i}
to={link.href}
onClick={() => setMobileOpen(false)}
className={cn(
'flex items-center gap-3 py-3 text-base font-medium tracking-tight transition-all duration-500 ease-[cubic-bezier(0.16,1,0.3,1)]',
mobileOpen
? 'translate-y-0 opacity-100'
: 'translate-y-4 opacity-0',
isActive ? 'text-foreground' : 'text-muted-foreground'
)}
style={{
transitionDelay: mobileOpen ? `${100 + i * 50}ms` : '0ms',
}}
disabled={link.disabled}
onClick={(event) => handleNavLinkClick(event, link, true)}
className={linkClassName}
style={transitionStyle}
>
{t(link.title)}
</Link>
@ -304,6 +415,37 @@ export function PublicHeader(props: PublicHeaderProps) {
</div>
</div>
<Dialog
open={!!authPromptTarget}
onOpenChange={(open) => {
if (!open) {
closeAuthPrompt()
}
}}
>
<DialogContent className='sm:max-w-md'>
<DialogHeader>
<DialogTitle>{t('Sign in required')}</DialogTitle>
<DialogDescription>
{t('Please sign in to view {{module}}.', {
module: authPromptTarget?.title || '',
})}
</DialogDescription>
</DialogHeader>
<div className='bg-muted/40 text-muted-foreground rounded-lg px-3 py-2 text-sm'>
{t('Redirecting to sign in in {{seconds}} seconds.', {
seconds: authPromptSecondsLeft,
})}
</div>
<DialogFooter>
<Button variant='outline' onClick={closeAuthPrompt}>
{t('Cancel')}
</Button>
<Button onClick={navigateToSignIn}>{t('Sign in now')}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Notification Dialog */}
{showNotifications && (
<NotificationDialog

View File

@ -97,5 +97,6 @@ export type TopNavLink = {
href: string
isActive?: boolean
disabled?: boolean
requiresAuth?: boolean
external?: boolean
}

View File

@ -20,77 +20,16 @@ import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { useAuthStore } from '@/stores/auth-store'
import { useStatus } from '@/hooks/use-status'
import { parseHeaderNavModulesFromStatus } from '@/lib/nav-modules'
export type TopNavLink = {
title: string
href: string
disabled?: boolean
requiresAuth?: boolean
external?: boolean
}
// Default navigation configuration
const DEFAULT_HEADER_NAV_MODULES = {
home: true,
console: true,
pricing: { enabled: true, requireAuth: false },
rankings: { enabled: true, requireAuth: false },
docs: true,
about: true,
}
function parseAccessModule(
raw: unknown,
fallback: { enabled: boolean; requireAuth: boolean }
) {
if (
typeof raw === 'boolean' ||
typeof raw === 'string' ||
typeof raw === 'number'
) {
return {
enabled: raw === true || raw === 'true' || raw === '1' || raw === 1,
requireAuth: fallback.requireAuth,
}
}
if (raw && typeof raw === 'object') {
const record = raw as Record<string, unknown>
return {
enabled:
typeof record.enabled === 'boolean' ? record.enabled : fallback.enabled,
requireAuth:
typeof record.requireAuth === 'boolean'
? record.requireAuth
: fallback.requireAuth,
}
}
return { ...fallback }
}
function parseHeaderNavModules(
raw: unknown
): typeof DEFAULT_HEADER_NAV_MODULES {
if (!raw || String(raw).trim() === '') {
return DEFAULT_HEADER_NAV_MODULES
}
try {
const parsed = JSON.parse(String(raw)) as Record<string, unknown>
return {
...DEFAULT_HEADER_NAV_MODULES,
...parsed,
pricing: parseAccessModule(
parsed.pricing,
DEFAULT_HEADER_NAV_MODULES.pricing
),
rankings: parseAccessModule(
parsed.rankings,
DEFAULT_HEADER_NAV_MODULES.rankings
),
}
} catch {
return DEFAULT_HEADER_NAV_MODULES
}
}
/**
* Generate top navigation links based on HeaderNavModules configuration from backend /api/status
* Backend format example (stringified JSON):
@ -110,8 +49,10 @@ export function useTopNavLinks(): TopNavLink[] {
// Parse HeaderNavModules
const modules = useMemo(() => {
return parseHeaderNavModules(status?.HeaderNavModules)
}, [status?.HeaderNavModules])
return parseHeaderNavModulesFromStatus(
status as Record<string, unknown> | null
)
}, [status])
// Documentation link (may be external)
const docsLink: string | undefined = status?.docs_link as string | undefined
@ -133,15 +74,15 @@ export function useTopNavLinks(): TopNavLink[] {
// Pricing
const pricing = modules?.pricing
if (pricing && typeof pricing === 'object' && pricing.enabled) {
const disabled = pricing.requireAuth && !isAuthed
links.push({ title: t('Model Square'), href: '/pricing', disabled })
const requiresAuth = pricing.requireAuth && !isAuthed
links.push({ title: t('Model Square'), href: '/pricing', requiresAuth })
}
// Rankings
const rankings = modules?.rankings
if (rankings && typeof rankings === 'object' && rankings.enabled) {
const disabled = rankings.requireAuth && !isAuthed
links.push({ title: t('Rankings'), href: '/rankings', disabled })
const requiresAuth = rankings.requireAuth && !isAuthed
links.push({ title: t('Rankings'), href: '/rankings', requiresAuth })
}
// Docs (supports external links)

View File

@ -2933,6 +2933,7 @@
"Please select items to delete": "Please select items to delete",
"Please Select user groups that can access this channel.": "Please Select user groups that can access this channel.",
"Please set Ollama API Base URL first": "Please set Ollama API Base URL first",
"Please sign in to view {{module}}.": "Please sign in to view {{module}}.",
"Please try again later.": "Please try again later.",
"Please upload key file(s)": "Please upload key file(s)",
"Please wait a moment before trying again.": "Please wait a moment before trying again.",
@ -3169,6 +3170,7 @@
"Redirecting to Creem checkout...": "Redirecting to Creem checkout...",
"Redirecting to GitHub...": "Redirecting to GitHub...",
"Redirecting to payment page...": "Redirecting to payment page...",
"Redirecting to sign in in {{seconds}} seconds.": "Redirecting to sign in in {{seconds}} seconds.",
"Reference Video": "Reference Video",
"Referral link:": "Referral link:",
"Referral Program": "Referral Program",
@ -3600,6 +3602,8 @@
"Sidebar Personal Settings": "Sidebar Personal Settings",
"Sign in": "Sign in",
"Sign In": "Sign In",
"Sign in now": "Sign in now",
"Sign in required": "Sign in required",
"Sign in with Passkey": "Sign in with Passkey",
"Sign out": "Sign out",
"Sign up": "Sign up",

View File

@ -2933,6 +2933,7 @@
"Please select items to delete": "Veuillez sélectionner des éléments à supprimer",
"Please Select user groups that can access this channel.": "Veuillez sélectionner les groupes d'utilisateurs qui peuvent accéder à ce canal.",
"Please set Ollama API Base URL first": "Veuillez d'abord définir l'URL de base de l'API Ollama",
"Please sign in to view {{module}}.": "Veuillez vous connecter pour voir {{module}}.",
"Please try again later.": "Veuillez réessayer plus tard.",
"Please upload key file(s)": "Veuillez télécharger le (s) fichier(s) clé (s)",
"Please wait a moment before trying again.": "Veuillez patienter un instant avant de réessayer.",
@ -3169,6 +3170,7 @@
"Redirecting to Creem checkout...": "Redirection vers la caisse Creem...",
"Redirecting to GitHub...": "Redirection vers GitHub...",
"Redirecting to payment page...": "Redirection vers la page de paiement...",
"Redirecting to sign in in {{seconds}} seconds.": "Redirection vers la connexion dans {{seconds}} secondes.",
"Reference Video": "Vidéo de référence",
"Referral link:": "Lien de parrainage :",
"Referral Program": "Programme de parrainage",
@ -3600,6 +3602,8 @@
"Sidebar Personal Settings": "Paramètres personnels de la barre latérale",
"Sign in": "Se connecter",
"Sign In": "Se connecter",
"Sign in now": "Se connecter maintenant",
"Sign in required": "Connexion requise",
"Sign in with Passkey": "Se connecter avec Passkey",
"Sign out": "Se déconnecter",
"Sign up": "S'inscrire",

View File

@ -2933,6 +2933,7 @@
"Please select items to delete": "削除する項目を選択してください",
"Please Select user groups that can access this channel.": "このチャネルにアクセスできるユーザーグループを選択してください。",
"Please set Ollama API Base URL first": "最初にOllama APIベースURLを設定してください",
"Please sign in to view {{module}}.": "{{module}} を表示するにはログインしてください。",
"Please try again later.": "後でもう一度お試しください。",
"Please upload key file(s)": "キーファイルをアップロードしてください",
"Please wait a moment before trying again.": "しばらく待ってからもう一度お試しください。",
@ -3169,6 +3170,7 @@
"Redirecting to Creem checkout...": "Creemチェックアウトにリダイレクト中...",
"Redirecting to GitHub...": "GitHub にリダイレクトしています...",
"Redirecting to payment page...": "支払いページにリダイレクト中...",
"Redirecting to sign in in {{seconds}} seconds.": "{{seconds}} 秒後にログインページへ移動します。",
"Reference Video": "参照動画",
"Referral link:": "紹介リンク:",
"Referral Program": "紹介プログラム",
@ -3600,6 +3602,8 @@
"Sidebar Personal Settings": "サイドバー個人設定",
"Sign in": "ログイン",
"Sign In": "ログイン",
"Sign in now": "今すぐログイン",
"Sign in required": "ログインが必要です",
"Sign in with Passkey": "Passkeyでログイン",
"Sign out": "ログアウト",
"Sign up": "サインアップ",

View File

@ -2933,6 +2933,7 @@
"Please select items to delete": "Пожалуйста, выберите элементы для удаления",
"Please Select user groups that can access this channel.": "Пожалуйста, выберите группы пользователей, которые могут получить доступ к этому каналу.",
"Please set Ollama API Base URL first": "Сначала установите базовый URL-адрес API Ollama",
"Please sign in to view {{module}}.": "Войдите, чтобы просмотреть {{module}}.",
"Please try again later.": "Пожалуйста, попробуйте еще раз позже.",
"Please upload key file(s)": "Загрузите ключевой файл(ы)",
"Please wait a moment before trying again.": "Пожалуйста, подождите немного и попробуйте снова.",
@ -3169,6 +3170,7 @@
"Redirecting to Creem checkout...": "Перенаправление на кассу Creem...",
"Redirecting to GitHub...": "Перенаправление на GitHub...",
"Redirecting to payment page...": "Перенаправление на страницу оплаты...",
"Redirecting to sign in in {{seconds}} seconds.": "Переход на страницу входа через {{seconds}} сек.",
"Reference Video": "Эталонное видео",
"Referral link:": "Реферальная ссылка:",
"Referral Program": "Реферальная программа",
@ -3600,6 +3602,8 @@
"Sidebar Personal Settings": "Личные настройки боковой панели",
"Sign in": "Войти",
"Sign In": "Войти",
"Sign in now": "Войти сейчас",
"Sign in required": "Требуется вход",
"Sign in with Passkey": "Войти с Passkey",
"Sign out": "Выйти",
"Sign up": "Регистрация",

View File

@ -2933,6 +2933,7 @@
"Please select items to delete": "Vui lòng chọn các mục để xóa",
"Please Select user groups that can access this channel.": "Vui lòng chọn nhóm người dùng có thể truy cập kênh này.",
"Please set Ollama API Base URL first": "Vui lòng đặt URL cơ sở API Ollama trước",
"Please sign in to view {{module}}.": "Vui lòng đăng nhập để xem {{module}}.",
"Please try again later.": "Vui lòng thử lại sau.",
"Please upload key file(s)": "Vui lòng tải lên (các) tệp khóa",
"Please wait a moment before trying again.": "Vui lòng chờ một lát rồi thử lại.",
@ -3169,6 +3170,7 @@
"Redirecting to Creem checkout...": "Đang chuyển hướng đến thanh toán Creem...",
"Redirecting to GitHub...": "Đang chuyển hướng đến GitHub...",
"Redirecting to payment page...": "Đang chuyển hướng đến trang thanh toán...",
"Redirecting to sign in in {{seconds}} seconds.": "Đang chuyển đến trang đăng nhập sau {{seconds}} giây.",
"Reference Video": "Video tham chiếu",
"Referral link:": "Liên kết giới thiệu:",
"Referral Program": "Chương trình Giới thiệu",
@ -3600,6 +3602,8 @@
"Sidebar Personal Settings": "Cài đặt cá nhân thanh bên",
"Sign in": "Đăng nhập",
"Sign In": "Đăng nhập",
"Sign in now": "Đăng nhập ngay",
"Sign in required": "Cần đăng nhập",
"Sign in with Passkey": "Đăng nhập bằng Passkey",
"Sign out": "Đăng xuất",
"Sign up": "Đăng ký",

View File

@ -2933,6 +2933,7 @@
"Please select items to delete": "请选择要删除的项目",
"Please Select user groups that can access this channel.": "请选择可以访问此渠道的用户组。",
"Please set Ollama API Base URL first": "请先设置 Ollama API Base URL",
"Please sign in to view {{module}}.": "请登录后查看 {{module}}。",
"Please try again later.": "请稍后再试。",
"Please upload key file(s)": "请上传密钥文件",
"Please wait a moment before trying again.": "请稍候再试。",
@ -3169,6 +3170,7 @@
"Redirecting to Creem checkout...": "正在重定向到 Creem 结账...",
"Redirecting to GitHub...": "正在跳转 GitHub...",
"Redirecting to payment page...": "正在重定向到支付页面...",
"Redirecting to sign in in {{seconds}} seconds.": "将在 {{seconds}} 秒后跳转到登录页。",
"Reference Video": "参照生视频",
"Referral link:": "推荐链接:",
"Referral Program": "推荐计划",
@ -3600,6 +3602,8 @@
"Sidebar Personal Settings": "左侧边栏个人设置",
"Sign in": "登录",
"Sign In": "登录",
"Sign in now": "立即登录",
"Sign in required": "需要登录",
"Sign in with Passkey": "使用 Passkey 登录",
"Sign out": "登出",
"Sign up": "注册",

View File

@ -16,28 +16,135 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { getStatus } from '@/lib/api'
type ModuleAccess = { enabled: boolean; requireAuth: boolean }
export type ModuleAccess = { enabled: boolean; requireAuth: boolean }
const DEFAULTS: Record<string, ModuleAccess> = {
export type HeaderNavModule = 'rankings' | 'pricing'
export type HeaderNavModules = {
home: boolean
console: boolean
pricing: ModuleAccess
rankings: ModuleAccess
docs: boolean
about: boolean
[key: string]: boolean | ModuleAccess
}
const DEFAULT_HEADER_NAV_MODULES: HeaderNavModules = {
home: true,
console: true,
pricing: { enabled: true, requireAuth: false },
rankings: { enabled: true, requireAuth: false },
docs: true,
about: true,
}
const DEFAULTS: Record<HeaderNavModule, ModuleAccess> = {
pricing: DEFAULT_HEADER_NAV_MODULES.pricing,
rankings: DEFAULT_HEADER_NAV_MODULES.rankings,
}
function cloneHeaderNavDefaults(): HeaderNavModules {
return {
...DEFAULT_HEADER_NAV_MODULES,
pricing: { ...DEFAULT_HEADER_NAV_MODULES.pricing },
rankings: { ...DEFAULT_HEADER_NAV_MODULES.rankings },
}
}
export function parseHeaderNavBoolean(
raw: unknown,
fallback: boolean
): boolean {
if (typeof raw === 'boolean') return raw
if (typeof raw === 'number') {
if (raw === 1) return true
if (raw === 0) return false
return fallback
}
if (typeof raw === 'string') {
const normalized = raw.trim().toLowerCase()
if (normalized === 'true' || normalized === '1') return true
if (normalized === 'false' || normalized === '0') return false
}
return fallback
}
function parseAccess(raw: unknown, fallback: ModuleAccess): ModuleAccess {
if (typeof raw === 'boolean') return { enabled: raw, requireAuth: fallback.requireAuth }
if (
typeof raw === 'boolean' ||
typeof raw === 'number' ||
typeof raw === 'string'
) {
return {
enabled: parseHeaderNavBoolean(raw, fallback.enabled),
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,
enabled: parseHeaderNavBoolean(r.enabled, fallback.enabled),
requireAuth: parseHeaderNavBoolean(r.requireAuth, fallback.requireAuth),
}
}
return { ...fallback }
}
function parseHeaderNavRecord(raw: unknown): Record<string, unknown> | null {
if (!raw || String(raw).trim() === '') return null
if (raw && typeof raw === 'object') return raw as Record<string, unknown>
try {
return JSON.parse(String(raw)) as Record<string, unknown>
} catch {
return null
}
}
export function parseHeaderNavModules(raw: unknown): HeaderNavModules {
const result = cloneHeaderNavDefaults()
const parsed = parseHeaderNavRecord(raw)
if (!parsed) return result
Object.entries(parsed).forEach(([key, value]) => {
if (key === 'pricing') {
result.pricing = parseAccess(value, result.pricing)
return
}
if (key === 'rankings') {
result.rankings = parseAccess(value, result.rankings)
return
}
const fallback = result[key]
if (
typeof fallback === 'boolean' ||
typeof value === 'boolean' ||
typeof value === 'number' ||
typeof value === 'string'
) {
result[key] = parseHeaderNavBoolean(
value,
typeof fallback === 'boolean' ? fallback : true
)
}
})
return result
}
export function parseHeaderNavModulesFromStatus(
status: Record<string, unknown> | null
): HeaderNavModules {
return parseHeaderNavModules(status?.HeaderNavModules)
}
function getCachedStatus(): Record<string, unknown> | null {
try {
if (typeof window === 'undefined') return null
const raw = window.localStorage.getItem('status')
return raw ? (JSON.parse(raw) as Record<string, unknown>) : null
} catch {
@ -45,22 +152,43 @@ function getCachedStatus(): Record<string, unknown> | 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]
function cacheStatus(status: Record<string, unknown> | null): void {
try {
const parsed = JSON.parse(String(rawNav)) as Record<string, unknown>
return parseAccess(parsed[module], DEFAULTS[module])
if (typeof window !== 'undefined' && status) {
window.localStorage.setItem('status', JSON.stringify(status))
}
} catch {
return DEFAULTS[module]
/* empty */
}
}
export function isSidebarModuleEnabled(section: string, module: string): boolean {
export function getModuleAccessFromStatus(
status: Record<string, unknown> | null,
module: HeaderNavModule
): ModuleAccess {
return parseHeaderNavModulesFromStatus(status)[module] ?? DEFAULTS[module]
}
export function getModuleAccess(module: HeaderNavModule): ModuleAccess {
return getModuleAccessFromStatus(getCachedStatus(), module)
}
export async function getFreshModuleAccess(
module: HeaderNavModule
): Promise<ModuleAccess> {
try {
const status = (await getStatus()) as Record<string, unknown> | null
cacheStatus(status)
return getModuleAccessFromStatus(status, module)
} catch {
return getModuleAccess(module)
}
}
export function isSidebarModuleEnabled(
section: string,
module: string
): boolean {
const status = getCachedStatus()
if (!status) return true
@ -68,7 +196,10 @@ export function isSidebarModuleEnabled(section: string, module: string): boolean
if (!raw || String(raw).trim() === '') return true
try {
const parsed = JSON.parse(String(raw)) as Record<string, Record<string, boolean>>
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

View File

@ -17,7 +17,9 @@ 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 { useAuthStore } from '@/stores/auth-store'
import { getFreshModuleAccess } from '@/lib/nav-modules'
import { ModelDetails } from '@/features/pricing/components/model-details'
const modelDetailsSearchSchema = z.object({
@ -35,5 +37,20 @@ const modelDetailsSearchSchema = z.object({
export const Route = createFileRoute('/pricing/$modelId/')({
validateSearch: modelDetailsSearchSchema,
beforeLoad: async ({ location }) => {
const access = await getFreshModuleAccess('pricing')
if (!access.enabled) {
throw redirect({ to: '/' })
}
if (access.requireAuth) {
const { auth } = useAuthStore.getState()
if (!auth.user) {
throw redirect({
to: '/sign-in',
search: { redirect: location.href },
})
}
}
},
component: ModelDetails,
})

View File

@ -17,7 +17,9 @@ 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 { useAuthStore } from '@/stores/auth-store'
import { getFreshModuleAccess } from '@/lib/nav-modules'
import { Pricing } from '@/features/pricing'
const pricingSearchSchema = z.object({
@ -35,5 +37,20 @@ const pricingSearchSchema = z.object({
export const Route = createFileRoute('/pricing/')({
validateSearch: pricingSearchSchema,
beforeLoad: async ({ location }) => {
const access = await getFreshModuleAccess('pricing')
if (!access.enabled) {
throw redirect({ to: '/' })
}
if (access.requireAuth) {
const { auth } = useAuthStore.getState()
if (!auth.user) {
throw redirect({
to: '/sign-in',
search: { redirect: location.href },
})
}
}
},
component: Pricing,
})

View File

@ -18,9 +18,9 @@ For commercial licensing, please contact support@quantumnous.com
*/
import z from 'zod'
import { createFileRoute, redirect } from '@tanstack/react-router'
import { Rankings } from '@/features/rankings'
import { getModuleAccess } from '@/lib/nav-modules'
import { useAuthStore } from '@/stores/auth-store'
import { getFreshModuleAccess } from '@/lib/nav-modules'
import { Rankings } from '@/features/rankings'
const rankingsSearchSchema = z.object({
period: z
@ -31,15 +31,18 @@ const rankingsSearchSchema = z.object({
export const Route = createFileRoute('/rankings/')({
validateSearch: rankingsSearchSchema,
beforeLoad: () => {
const access = getModuleAccess('rankings')
beforeLoad: async ({ location }) => {
const access = await getFreshModuleAccess('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' } })
throw redirect({
to: '/sign-in',
search: { redirect: location.href },
})
}
}
},