fix: enforce header nav access control for public modules (#4889)
This commit is contained in:
parent
8a10dedb7d
commit
6f8668e4c3
@ -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
135
middleware/header_nav.go
Normal 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)
|
||||
}
|
||||
}
|
||||
167
middleware/header_nav_test.go
Normal file
167
middleware/header_nav_test.go
Normal 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)
|
||||
}
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
1
web/default/src/components/layout/types.ts
vendored
1
web/default/src/components/layout/types.ts
vendored
@ -97,5 +97,6 @@ export type TopNavLink = {
|
||||
href: string
|
||||
isActive?: boolean
|
||||
disabled?: boolean
|
||||
requiresAuth?: boolean
|
||||
external?: boolean
|
||||
}
|
||||
|
||||
79
web/default/src/hooks/use-top-nav-links.ts
vendored
79
web/default/src/hooks/use-top-nav-links.ts
vendored
@ -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)
|
||||
|
||||
4
web/default/src/i18n/locales/en.json
vendored
4
web/default/src/i18n/locales/en.json
vendored
@ -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",
|
||||
|
||||
4
web/default/src/i18n/locales/fr.json
vendored
4
web/default/src/i18n/locales/fr.json
vendored
@ -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",
|
||||
|
||||
4
web/default/src/i18n/locales/ja.json
vendored
4
web/default/src/i18n/locales/ja.json
vendored
@ -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": "サインアップ",
|
||||
|
||||
4
web/default/src/i18n/locales/ru.json
vendored
4
web/default/src/i18n/locales/ru.json
vendored
@ -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": "Регистрация",
|
||||
|
||||
4
web/default/src/i18n/locales/vi.json
vendored
4
web/default/src/i18n/locales/vi.json
vendored
@ -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ý",
|
||||
|
||||
4
web/default/src/i18n/locales/zh.json
vendored
4
web/default/src/i18n/locales/zh.json
vendored
@ -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": "注册",
|
||||
|
||||
165
web/default/src/lib/nav-modules.ts
vendored
165
web/default/src/lib/nav-modules.ts
vendored
@ -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
|
||||
|
||||
@ -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,
|
||||
})
|
||||
|
||||
19
web/default/src/routes/pricing/index.tsx
vendored
19
web/default/src/routes/pricing/index.tsx
vendored
@ -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,
|
||||
})
|
||||
|
||||
13
web/default/src/routes/rankings/index.tsx
vendored
13
web/default/src/routes/rankings/index.tsx
vendored
@ -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 },
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user