Skip to content
Toggle navigation
P
Projects
G
Groups
S
Snippets
Help
phsl
/
new-api
This project
Loading...
Sign in
Toggle navigation
Go to a project
Project
Repository
Issues
0
Merge Requests
0
Pipelines
Wiki
Snippets
Members
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Unverified
Commit
6f8668e4
authored
May 16, 2026
by
yyhhyyyyyy
Committed by
GitHub
May 16, 2026
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
fix: enforce header nav access control for public modules (#4889)
parent
8a10dedb
Show whitespace changes
Inline
Side-by-side
Showing
17 changed files
with
686 additions
and
148 deletions
+686
-148
controller/rankings.go
+0
-40
middleware/header_nav.go
+135
-0
middleware/header_nav_test.go
+167
-0
router/api-router.go
+3
-3
web/default/src/components/layout/components/public-header.tsx
+156
-14
web/default/src/components/layout/types.ts
+1
-0
web/default/src/hooks/use-top-nav-links.ts
+10
-69
web/default/src/i18n/locales/en.json
+4
-0
web/default/src/i18n/locales/fr.json
+4
-0
web/default/src/i18n/locales/ja.json
+4
-0
web/default/src/i18n/locales/ru.json
+4
-0
web/default/src/i18n/locales/vi.json
+4
-0
web/default/src/i18n/locales/zh.json
+4
-0
web/default/src/lib/nav-modules.ts
+146
-15
web/default/src/routes/pricing/$modelId/index.tsx
+18
-1
web/default/src/routes/pricing/index.tsx
+18
-1
web/default/src/routes/rankings/index.tsx
+8
-5
No files found.
controller/rankings.go
View file @
6f8668e4
...
...
@@ -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
{
...
...
middleware/header_nav.go
0 → 100644
View file @
6f8668e4
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
)
}
}
middleware/header_nav_test.go
0 → 100644
View file @
6f8668e4
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
)
}
router/api-router.go
View file @
6f8668e4
...
...
@@ -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
)
...
...
web/default/src/components/layout/components/public-header.tsx
View file @
6f8668e4
...
...
@@ -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
{
use
State
,
useEffect
}
from
'react'
import
{
Link
,
useRouterState
}
from
'@tanstack/react-router'
import
{
use
Callback
,
useEffect
,
useState
}
from
'react'
import
{
Link
,
use
Navigate
,
use
RouterState
}
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
return
(
<
Link
key=
{
i
}
to=
{
link
.
href
}
onClick=
{
()
=>
setMobileOpen
(
false
)
}
className=
{
cn
(
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'
)
}
style=
{
{
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
}
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
...
...
web/default/src/components/layout/types.ts
View file @
6f8668e4
...
...
@@ -97,5 +97,6 @@ export type TopNavLink = {
href
:
string
isActive
?:
boolean
disabled
?:
boolean
requiresAuth
?:
boolean
external
?:
boolean
}
web/default/src/hooks/use-top-nav-links.ts
View file @
6f8668e4
...
...
@@ -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)
...
...
web/default/src/i18n/locales/en.json
View file @
6f8668e4
...
...
@@ -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"
,
...
...
web/default/src/i18n/locales/fr.json
View file @
6f8668e4
...
...
@@ -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"
,
...
...
web/default/src/i18n/locales/ja.json
View file @
6f8668e4
...
...
@@ -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"
:
"サインアップ"
,
...
...
web/default/src/i18n/locales/ru.json
View file @
6f8668e4
...
...
@@ -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"
:
"Регистрация"
,
...
...
web/default/src/i18n/locales/vi.json
View file @
6f8668e4
...
...
@@ -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ý"
,
...
...
web/default/src/i18n/locales/zh.json
View file @
6f8668e4
...
...
@@ -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"
:
"注册"
,
...
...
web/default/src/lib/nav-modules.ts
View file @
6f8668e4
...
...
@@ -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
]
function
cacheStatus
(
status
:
Record
<
string
,
unknown
>
|
null
):
void
{
try
{
if
(
typeof
window
!==
'undefined'
&&
status
)
{
window
.
localStorage
.
setItem
(
'status'
,
JSON
.
stringify
(
status
))
}
}
catch
{
/* empty */
}
}
const
rawNav
=
status
.
HeaderNavModules
if
(
!
rawNav
||
String
(
rawNav
).
trim
()
===
''
)
return
DEFAULTS
[
module
]
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
parsed
=
JSON
.
parse
(
String
(
rawNav
))
as
Record
<
string
,
unknown
>
return
parseAccess
(
parsed
[
module
],
DEFAULTS
[
module
])
const
status
=
(
await
getStatus
())
as
Record
<
string
,
unknown
>
|
null
cacheStatus
(
status
)
return
getModuleAccessFromStatus
(
status
,
module
)
}
catch
{
return
DEFAULTS
[
module
]
return
getModuleAccess
(
module
)
}
}
export
function
isSidebarModuleEnabled
(
section
:
string
,
module
:
string
):
boolean
{
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
...
...
web/default/src/routes/pricing/$modelId/index.tsx
View file @
6f8668e4
...
...
@@ -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
,
})
web/default/src/routes/pricing/index.tsx
View file @
6f8668e4
...
...
@@ -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
,
})
web/default/src/routes/rankings/index.tsx
View file @
6f8668e4
...
...
@@ -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
=
get
ModuleAccess
(
'rankings'
)
beforeLoad
:
async
({
location
}
)
=>
{
const
access
=
await
getFresh
ModuleAccess
(
'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
},
})
}
}
},
...
...
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment