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 (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/QuantumNous/new-api/common"
|
|
||||||
"github.com/QuantumNous/new-api/service"
|
"github.com/QuantumNous/new-api/service"
|
||||||
"github.com/gin-gonic/gin"
|
"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) {
|
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"))
|
result, err := service.GetRankingsSnapshot(c.DefaultQuery("period", "week"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{
|
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("/about", controller.GetAbout)
|
||||||
//apiRouter.GET("/midjourney", controller.GetMidjourney)
|
//apiRouter.GET("/midjourney", controller.GetMidjourney)
|
||||||
apiRouter.GET("/home_page_content", controller.GetHomePageContent)
|
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 := apiRouter.Group("/perf-metrics")
|
||||||
perfMetricsRoute.Use(middleware.TryUserAuth())
|
perfMetricsRoute.Use(middleware.HeaderNavModulePublicOrUserAuth("pricing"))
|
||||||
{
|
{
|
||||||
perfMetricsRoute.GET("/summary", controller.GetPerfMetricsSummary)
|
perfMetricsRoute.GET("/summary", controller.GetPerfMetricsSummary)
|
||||||
perfMetricsRoute.GET("", controller.GetPerfMetrics)
|
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("/verification", middleware.EmailVerificationRateLimit(), middleware.TurnstileCheck(), controller.SendEmailVerification)
|
||||||
apiRouter.GET("/reset_password", middleware.CriticalRateLimit(), middleware.TurnstileCheck(), controller.SendPasswordResetEmail)
|
apiRouter.GET("/reset_password", middleware.CriticalRateLimit(), middleware.TurnstileCheck(), controller.SendPasswordResetEmail)
|
||||||
apiRouter.POST("/user/reset", middleware.CriticalRateLimit(), controller.ResetPassword)
|
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
|
For commercial licensing, please contact support@quantumnous.com
|
||||||
*/
|
*/
|
||||||
import { useState, useEffect } from 'react'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
import { Link, useRouterState } from '@tanstack/react-router'
|
import { Link, useNavigate, useRouterState } from '@tanstack/react-router'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { useAuthStore } from '@/stores/auth-store'
|
import { useAuthStore } from '@/stores/auth-store'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
@ -25,6 +25,14 @@ import { useNotifications } from '@/hooks/use-notifications'
|
|||||||
import { useSystemConfig } from '@/hooks/use-system-config'
|
import { useSystemConfig } from '@/hooks/use-system-config'
|
||||||
import { useTopNavLinks } from '@/hooks/use-top-nav-links'
|
import { useTopNavLinks } from '@/hooks/use-top-nav-links'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog'
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
import { LanguageSwitcher } from '@/components/language-switcher'
|
import { LanguageSwitcher } from '@/components/language-switcher'
|
||||||
import { NotificationButton } from '@/components/notification-button'
|
import { NotificationButton } from '@/components/notification-button'
|
||||||
@ -35,6 +43,13 @@ import { defaultTopNavLinks } from '../config/top-nav.config'
|
|||||||
import type { TopNavLink } from '../types'
|
import type { TopNavLink } from '../types'
|
||||||
import { HeaderLogo } from './header-logo'
|
import { HeaderLogo } from './header-logo'
|
||||||
|
|
||||||
|
const AUTH_PROMPT_SECONDS = 5
|
||||||
|
|
||||||
|
type AuthPromptTarget = {
|
||||||
|
title: string
|
||||||
|
href: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface PublicHeaderProps {
|
export interface PublicHeaderProps {
|
||||||
navLinks?: TopNavLink[]
|
navLinks?: TopNavLink[]
|
||||||
mobileLinks?: TopNavLink[]
|
mobileLinks?: TopNavLink[]
|
||||||
@ -65,8 +80,13 @@ export function PublicHeader(props: PublicHeaderProps) {
|
|||||||
} = props
|
} = props
|
||||||
|
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
const navigate = useNavigate()
|
||||||
const [scrolled, setScrolled] = useState(false)
|
const [scrolled, setScrolled] = useState(false)
|
||||||
const [mobileOpen, setMobileOpen] = 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 { auth } = useAuthStore()
|
||||||
const {
|
const {
|
||||||
systemName,
|
systemName,
|
||||||
@ -98,6 +118,67 @@ export function PublicHeader(props: PublicHeaderProps) {
|
|||||||
}
|
}
|
||||||
}, [mobileOpen])
|
}, [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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<header className='pointer-events-none fixed inset-x-0 top-0 z-50'>
|
<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}
|
href={link.href}
|
||||||
target='_blank'
|
target='_blank'
|
||||||
rel='noopener noreferrer'
|
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)}
|
{t(link.title)}
|
||||||
</a>
|
</a>
|
||||||
@ -160,11 +247,14 @@ export function PublicHeader(props: PublicHeaderProps) {
|
|||||||
<Link
|
<Link
|
||||||
key={i}
|
key={i}
|
||||||
to={link.href}
|
to={link.href}
|
||||||
|
disabled={link.disabled}
|
||||||
|
onClick={(event) => handleNavLinkClick(event, link)}
|
||||||
className={cn(
|
className={cn(
|
||||||
'rounded-lg px-3 py-1.5 text-[13px] font-medium transition-colors duration-200',
|
'rounded-lg px-3 py-1.5 text-[13px] font-medium transition-colors duration-200',
|
||||||
isActive
|
isActive
|
||||||
? 'text-foreground'
|
? 'text-foreground'
|
||||||
: 'text-muted-foreground hover:text-foreground'
|
: 'text-muted-foreground hover:text-foreground',
|
||||||
|
link.disabled && 'pointer-events-none opacity-50'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{t(link.title)}
|
{t(link.title)}
|
||||||
@ -260,21 +350,42 @@ export function PublicHeader(props: PublicHeaderProps) {
|
|||||||
<nav className='flex flex-col gap-1'>
|
<nav className='flex flex-col gap-1'>
|
||||||
{links.map((link, i) => {
|
{links.map((link, i) => {
|
||||||
const isActive = pathname === link.href
|
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 (
|
return (
|
||||||
<Link
|
<Link
|
||||||
key={i}
|
key={i}
|
||||||
to={link.href}
|
to={link.href}
|
||||||
onClick={() => setMobileOpen(false)}
|
disabled={link.disabled}
|
||||||
className={cn(
|
onClick={(event) => handleNavLinkClick(event, link, true)}
|
||||||
'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)]',
|
className={linkClassName}
|
||||||
mobileOpen
|
style={transitionStyle}
|
||||||
? 'translate-y-0 opacity-100'
|
|
||||||
: 'translate-y-4 opacity-0',
|
|
||||||
isActive ? 'text-foreground' : 'text-muted-foreground'
|
|
||||||
)}
|
|
||||||
style={{
|
|
||||||
transitionDelay: mobileOpen ? `${100 + i * 50}ms` : '0ms',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{t(link.title)}
|
{t(link.title)}
|
||||||
</Link>
|
</Link>
|
||||||
@ -304,6 +415,37 @@ export function PublicHeader(props: PublicHeaderProps) {
|
|||||||
</div>
|
</div>
|
||||||
</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 */}
|
{/* Notification Dialog */}
|
||||||
{showNotifications && (
|
{showNotifications && (
|
||||||
<NotificationDialog
|
<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
|
href: string
|
||||||
isActive?: boolean
|
isActive?: boolean
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
|
requiresAuth?: boolean
|
||||||
external?: 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 { useTranslation } from 'react-i18next'
|
||||||
import { useAuthStore } from '@/stores/auth-store'
|
import { useAuthStore } from '@/stores/auth-store'
|
||||||
import { useStatus } from '@/hooks/use-status'
|
import { useStatus } from '@/hooks/use-status'
|
||||||
|
import { parseHeaderNavModulesFromStatus } from '@/lib/nav-modules'
|
||||||
|
|
||||||
export type TopNavLink = {
|
export type TopNavLink = {
|
||||||
title: string
|
title: string
|
||||||
href: string
|
href: string
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
|
requiresAuth?: boolean
|
||||||
external?: 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
|
* Generate top navigation links based on HeaderNavModules configuration from backend /api/status
|
||||||
* Backend format example (stringified JSON):
|
* Backend format example (stringified JSON):
|
||||||
@ -110,8 +49,10 @@ export function useTopNavLinks(): TopNavLink[] {
|
|||||||
|
|
||||||
// Parse HeaderNavModules
|
// Parse HeaderNavModules
|
||||||
const modules = useMemo(() => {
|
const modules = useMemo(() => {
|
||||||
return parseHeaderNavModules(status?.HeaderNavModules)
|
return parseHeaderNavModulesFromStatus(
|
||||||
}, [status?.HeaderNavModules])
|
status as Record<string, unknown> | null
|
||||||
|
)
|
||||||
|
}, [status])
|
||||||
|
|
||||||
// Documentation link (may be external)
|
// Documentation link (may be external)
|
||||||
const docsLink: string | undefined = status?.docs_link as string | undefined
|
const docsLink: string | undefined = status?.docs_link as string | undefined
|
||||||
@ -133,15 +74,15 @@ export function useTopNavLinks(): TopNavLink[] {
|
|||||||
// Pricing
|
// Pricing
|
||||||
const pricing = modules?.pricing
|
const pricing = modules?.pricing
|
||||||
if (pricing && typeof pricing === 'object' && pricing.enabled) {
|
if (pricing && typeof pricing === 'object' && pricing.enabled) {
|
||||||
const disabled = pricing.requireAuth && !isAuthed
|
const requiresAuth = pricing.requireAuth && !isAuthed
|
||||||
links.push({ title: t('Model Square'), href: '/pricing', disabled })
|
links.push({ title: t('Model Square'), href: '/pricing', requiresAuth })
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rankings
|
// Rankings
|
||||||
const rankings = modules?.rankings
|
const rankings = modules?.rankings
|
||||||
if (rankings && typeof rankings === 'object' && rankings.enabled) {
|
if (rankings && typeof rankings === 'object' && rankings.enabled) {
|
||||||
const disabled = rankings.requireAuth && !isAuthed
|
const requiresAuth = rankings.requireAuth && !isAuthed
|
||||||
links.push({ title: t('Rankings'), href: '/rankings', disabled })
|
links.push({ title: t('Rankings'), href: '/rankings', requiresAuth })
|
||||||
}
|
}
|
||||||
|
|
||||||
// Docs (supports external links)
|
// 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 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 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 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 try again later.": "Please try again later.",
|
||||||
"Please upload key file(s)": "Please upload key file(s)",
|
"Please upload key file(s)": "Please upload key file(s)",
|
||||||
"Please wait a moment before trying again.": "Please wait a moment before trying again.",
|
"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 Creem checkout...": "Redirecting to Creem checkout...",
|
||||||
"Redirecting to GitHub...": "Redirecting to GitHub...",
|
"Redirecting to GitHub...": "Redirecting to GitHub...",
|
||||||
"Redirecting to payment page...": "Redirecting to payment page...",
|
"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",
|
"Reference Video": "Reference Video",
|
||||||
"Referral link:": "Referral link:",
|
"Referral link:": "Referral link:",
|
||||||
"Referral Program": "Referral Program",
|
"Referral Program": "Referral Program",
|
||||||
@ -3600,6 +3602,8 @@
|
|||||||
"Sidebar Personal Settings": "Sidebar Personal Settings",
|
"Sidebar Personal Settings": "Sidebar Personal Settings",
|
||||||
"Sign in": "Sign in",
|
"Sign in": "Sign in",
|
||||||
"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 in with Passkey": "Sign in with Passkey",
|
||||||
"Sign out": "Sign out",
|
"Sign out": "Sign out",
|
||||||
"Sign up": "Sign up",
|
"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 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 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 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 try again later.": "Veuillez réessayer plus tard.",
|
||||||
"Please upload key file(s)": "Veuillez télécharger le (s) fichier(s) clé (s)",
|
"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.",
|
"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 Creem checkout...": "Redirection vers la caisse Creem...",
|
||||||
"Redirecting to GitHub...": "Redirection vers GitHub...",
|
"Redirecting to GitHub...": "Redirection vers GitHub...",
|
||||||
"Redirecting to payment page...": "Redirection vers la page de paiement...",
|
"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",
|
"Reference Video": "Vidéo de référence",
|
||||||
"Referral link:": "Lien de parrainage :",
|
"Referral link:": "Lien de parrainage :",
|
||||||
"Referral Program": "Programme de parrainage",
|
"Referral Program": "Programme de parrainage",
|
||||||
@ -3600,6 +3602,8 @@
|
|||||||
"Sidebar Personal Settings": "Paramètres personnels de la barre latérale",
|
"Sidebar Personal Settings": "Paramètres personnels de la barre latérale",
|
||||||
"Sign in": "Se connecter",
|
"Sign in": "Se connecter",
|
||||||
"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 in with Passkey": "Se connecter avec Passkey",
|
||||||
"Sign out": "Se déconnecter",
|
"Sign out": "Se déconnecter",
|
||||||
"Sign up": "S'inscrire",
|
"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 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": "最初にOllama APIベースURLを設定してください",
|
"Please set Ollama API Base URL first": "最初にOllama APIベースURLを設定してください",
|
||||||
|
"Please sign in to view {{module}}.": "{{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...": "Creemチェックアウトにリダイレクト中...",
|
"Redirecting to Creem checkout...": "Creemチェックアウトにリダイレクト中...",
|
||||||
"Redirecting to GitHub...": "GitHub にリダイレクトしています...",
|
"Redirecting to GitHub...": "GitHub にリダイレクトしています...",
|
||||||
"Redirecting to payment page...": "支払いページにリダイレクト中...",
|
"Redirecting to payment page...": "支払いページにリダイレクト中...",
|
||||||
|
"Redirecting to sign in in {{seconds}} 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 required": "ログインが必要です",
|
||||||
"Sign in with Passkey": "Passkeyでログイン",
|
"Sign in with Passkey": "Passkeyでログイン",
|
||||||
"Sign out": "ログアウト",
|
"Sign out": "ログアウト",
|
||||||
"Sign up": "サインアップ",
|
"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 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": "Сначала установите базовый URL-адрес API Ollama",
|
"Please set Ollama API Base URL first": "Сначала установите базовый URL-адрес API Ollama",
|
||||||
|
"Please sign in to view {{module}}.": "Войдите, чтобы просмотреть {{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...": "Перенаправление на кассу Creem...",
|
"Redirecting to Creem checkout...": "Перенаправление на кассу Creem...",
|
||||||
"Redirecting to GitHub...": "Перенаправление на GitHub...",
|
"Redirecting to GitHub...": "Перенаправление на GitHub...",
|
||||||
"Redirecting to payment page...": "Перенаправление на страницу оплаты...",
|
"Redirecting to payment page...": "Перенаправление на страницу оплаты...",
|
||||||
|
"Redirecting to sign in in {{seconds}} 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 required": "Требуется вход",
|
||||||
"Sign in with Passkey": "Войти с Passkey",
|
"Sign in with Passkey": "Войти с Passkey",
|
||||||
"Sign out": "Выйти",
|
"Sign out": "Выйти",
|
||||||
"Sign up": "Регистрация",
|
"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 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 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 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 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 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.",
|
"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 Creem checkout...": "Đang chuyển hướng đến thanh toán Creem...",
|
||||||
"Redirecting to GitHub...": "Đang chuyển hướng đến GitHub...",
|
"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 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",
|
"Reference Video": "Video tham chiếu",
|
||||||
"Referral link:": "Liên kết giới thiệu:",
|
"Referral link:": "Liên kết giới thiệu:",
|
||||||
"Referral Program": "Chương trình 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",
|
"Sidebar Personal Settings": "Cài đặt cá nhân thanh bên",
|
||||||
"Sign in": "Đăng nhập",
|
"Sign in": "Đăng nhập",
|
||||||
"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 in with Passkey": "Đăng nhập bằng Passkey",
|
||||||
"Sign out": "Đăng xuất",
|
"Sign out": "Đăng xuất",
|
||||||
"Sign up": "Đăng ký",
|
"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 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": "请先设置 Ollama API Base URL",
|
"Please set Ollama API Base URL first": "请先设置 Ollama API Base URL",
|
||||||
|
"Please sign in to view {{module}}.": "请登录后查看 {{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...": "正在重定向到 Creem 结账...",
|
"Redirecting to Creem checkout...": "正在重定向到 Creem 结账...",
|
||||||
"Redirecting to GitHub...": "正在跳转 GitHub...",
|
"Redirecting to GitHub...": "正在跳转 GitHub...",
|
||||||
"Redirecting to payment page...": "正在重定向到支付页面...",
|
"Redirecting to payment page...": "正在重定向到支付页面...",
|
||||||
|
"Redirecting to sign in in {{seconds}} 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 required": "需要登录",
|
||||||
"Sign in with Passkey": "使用 Passkey 登录",
|
"Sign in with Passkey": "使用 Passkey 登录",
|
||||||
"Sign out": "登出",
|
"Sign out": "登出",
|
||||||
"Sign up": "注册",
|
"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
|
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 },
|
pricing: { enabled: true, requireAuth: false },
|
||||||
rankings: { 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 {
|
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') {
|
if (raw && typeof raw === 'object') {
|
||||||
const r = raw as Record<string, unknown>
|
const r = raw as Record<string, unknown>
|
||||||
return {
|
return {
|
||||||
enabled: typeof r.enabled === 'boolean' ? r.enabled : fallback.enabled,
|
enabled: parseHeaderNavBoolean(r.enabled, fallback.enabled),
|
||||||
requireAuth: typeof r.requireAuth === 'boolean' ? r.requireAuth : fallback.requireAuth,
|
requireAuth: parseHeaderNavBoolean(r.requireAuth, fallback.requireAuth),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return { ...fallback }
|
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 {
|
function getCachedStatus(): Record<string, unknown> | null {
|
||||||
try {
|
try {
|
||||||
|
if (typeof window === 'undefined') return null
|
||||||
const raw = window.localStorage.getItem('status')
|
const raw = window.localStorage.getItem('status')
|
||||||
return raw ? (JSON.parse(raw) as Record<string, unknown>) : null
|
return raw ? (JSON.parse(raw) as Record<string, unknown>) : null
|
||||||
} catch {
|
} catch {
|
||||||
@ -45,22 +152,43 @@ function getCachedStatus(): Record<string, unknown> | null {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getModuleAccess(module: 'rankings' | 'pricing'): ModuleAccess {
|
function cacheStatus(status: Record<string, unknown> | null): void {
|
||||||
const status = getCachedStatus()
|
|
||||||
if (!status) return DEFAULTS[module]
|
|
||||||
|
|
||||||
const rawNav = status.HeaderNavModules
|
|
||||||
if (!rawNav || String(rawNav).trim() === '') return DEFAULTS[module]
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(String(rawNav)) as Record<string, unknown>
|
if (typeof window !== 'undefined' && status) {
|
||||||
return parseAccess(parsed[module], DEFAULTS[module])
|
window.localStorage.setItem('status', JSON.stringify(status))
|
||||||
|
}
|
||||||
} catch {
|
} 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()
|
const status = getCachedStatus()
|
||||||
if (!status) return true
|
if (!status) return true
|
||||||
|
|
||||||
@ -68,7 +196,10 @@ export function isSidebarModuleEnabled(section: string, module: string): boolean
|
|||||||
if (!raw || String(raw).trim() === '') return true
|
if (!raw || String(raw).trim() === '') return true
|
||||||
|
|
||||||
try {
|
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]
|
const sectionConfig = parsed[section]
|
||||||
if (!sectionConfig) return true
|
if (!sectionConfig) return true
|
||||||
if (sectionConfig.enabled === false) return false
|
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
|
For commercial licensing, please contact support@quantumnous.com
|
||||||
*/
|
*/
|
||||||
import z from 'zod'
|
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'
|
import { ModelDetails } from '@/features/pricing/components/model-details'
|
||||||
|
|
||||||
const modelDetailsSearchSchema = z.object({
|
const modelDetailsSearchSchema = z.object({
|
||||||
@ -35,5 +37,20 @@ const modelDetailsSearchSchema = z.object({
|
|||||||
|
|
||||||
export const Route = createFileRoute('/pricing/$modelId/')({
|
export const Route = createFileRoute('/pricing/$modelId/')({
|
||||||
validateSearch: modelDetailsSearchSchema,
|
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,
|
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
|
For commercial licensing, please contact support@quantumnous.com
|
||||||
*/
|
*/
|
||||||
import z from 'zod'
|
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'
|
import { Pricing } from '@/features/pricing'
|
||||||
|
|
||||||
const pricingSearchSchema = z.object({
|
const pricingSearchSchema = z.object({
|
||||||
@ -35,5 +37,20 @@ const pricingSearchSchema = z.object({
|
|||||||
|
|
||||||
export const Route = createFileRoute('/pricing/')({
|
export const Route = createFileRoute('/pricing/')({
|
||||||
validateSearch: pricingSearchSchema,
|
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,
|
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 z from 'zod'
|
||||||
import { createFileRoute, redirect } 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'
|
import { useAuthStore } from '@/stores/auth-store'
|
||||||
|
import { getFreshModuleAccess } from '@/lib/nav-modules'
|
||||||
|
import { Rankings } from '@/features/rankings'
|
||||||
|
|
||||||
const rankingsSearchSchema = z.object({
|
const rankingsSearchSchema = z.object({
|
||||||
period: z
|
period: z
|
||||||
@ -31,15 +31,18 @@ const rankingsSearchSchema = z.object({
|
|||||||
|
|
||||||
export const Route = createFileRoute('/rankings/')({
|
export const Route = createFileRoute('/rankings/')({
|
||||||
validateSearch: rankingsSearchSchema,
|
validateSearch: rankingsSearchSchema,
|
||||||
beforeLoad: () => {
|
beforeLoad: async ({ location }) => {
|
||||||
const access = getModuleAccess('rankings')
|
const access = await getFreshModuleAccess('rankings')
|
||||||
if (!access.enabled) {
|
if (!access.enabled) {
|
||||||
throw redirect({ to: '/' })
|
throw redirect({ to: '/' })
|
||||||
}
|
}
|
||||||
if (access.requireAuth) {
|
if (access.requireAuth) {
|
||||||
const { auth } = useAuthStore.getState()
|
const { auth } = useAuthStore.getState()
|
||||||
if (!auth.user) {
|
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