diff --git a/controller/rankings.go b/controller/rankings.go index a3fdf2b5..5a7fdaae 100644 --- a/controller/rankings.go +++ b/controller/rankings.go @@ -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{ diff --git a/middleware/header_nav.go b/middleware/header_nav.go new file mode 100644 index 00000000..70aa1869 --- /dev/null +++ b/middleware/header_nav.go @@ -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) + } +} diff --git a/middleware/header_nav_test.go b/middleware/header_nav_test.go new file mode 100644 index 00000000..d4c9c221 --- /dev/null +++ b/middleware/header_nav_test.go @@ -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) +} diff --git a/router/api-router.go b/router/api-router.go index 64ccbe15..da026ed9 100644 --- a/router/api-router.go +++ b/router/api-router.go @@ -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) diff --git a/web/default/src/components/layout/components/public-header.tsx b/web/default/src/components/layout/components/public-header.tsx index 1854ed05..4aca8365 100644 --- a/web/default/src/components/layout/components/public-header.tsx +++ b/web/default/src/components/layout/components/public-header.tsx @@ -16,8 +16,8 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ -import { useState, useEffect } from 'react' -import { Link, useRouterState } from '@tanstack/react-router' +import { useCallback, useEffect, useState } from 'react' +import { Link, useNavigate, useRouterState } from '@tanstack/react-router' import { useTranslation } from 'react-i18next' import { useAuthStore } from '@/stores/auth-store' import { cn } from '@/lib/utils' @@ -25,6 +25,14 @@ import { useNotifications } from '@/hooks/use-notifications' import { useSystemConfig } from '@/hooks/use-system-config' import { useTopNavLinks } from '@/hooks/use-top-nav-links' import { Button } from '@/components/ui/button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' import { Skeleton } from '@/components/ui/skeleton' import { LanguageSwitcher } from '@/components/language-switcher' import { NotificationButton } from '@/components/notification-button' @@ -35,6 +43,13 @@ import { defaultTopNavLinks } from '../config/top-nav.config' import type { TopNavLink } from '../types' import { HeaderLogo } from './header-logo' +const AUTH_PROMPT_SECONDS = 5 + +type AuthPromptTarget = { + title: string + href: string +} + export interface PublicHeaderProps { navLinks?: TopNavLink[] mobileLinks?: TopNavLink[] @@ -65,8 +80,13 @@ export function PublicHeader(props: PublicHeaderProps) { } = props const { t } = useTranslation() + const navigate = useNavigate() const [scrolled, setScrolled] = useState(false) const [mobileOpen, setMobileOpen] = useState(false) + const [authPromptTarget, setAuthPromptTarget] = + useState(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, + 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 ( <> @@ -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)} @@ -160,11 +247,14 @@ export function PublicHeader(props: PublicHeaderProps) { 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) { {links.map((link, i) => { const isActive = pathname === link.href + const linkClassName = cn( + 'flex items-center gap-3 py-3 text-base font-medium tracking-tight transition-all duration-500 ease-[cubic-bezier(0.16,1,0.3,1)]', + mobileOpen + ? 'translate-y-0 opacity-100' + : 'translate-y-4 opacity-0', + isActive ? 'text-foreground' : 'text-muted-foreground', + link.disabled && 'pointer-events-none opacity-50' + ) + const transitionStyle = { + transitionDelay: mobileOpen ? `${100 + i * 50}ms` : '0ms', + } + if (link.external) { + return ( + handleNavLinkClick(event, link, true)} + className={linkClassName} + style={transitionStyle} + > + {t(link.title)} + + ) + } return ( setMobileOpen(false)} - className={cn( - 'flex items-center gap-3 py-3 text-base font-medium tracking-tight transition-all duration-500 ease-[cubic-bezier(0.16,1,0.3,1)]', - mobileOpen - ? 'translate-y-0 opacity-100' - : 'translate-y-4 opacity-0', - isActive ? 'text-foreground' : 'text-muted-foreground' - )} - style={{ - transitionDelay: mobileOpen ? `${100 + i * 50}ms` : '0ms', - }} + disabled={link.disabled} + onClick={(event) => handleNavLinkClick(event, link, true)} + className={linkClassName} + style={transitionStyle} > {t(link.title)} @@ -304,6 +415,37 @@ export function PublicHeader(props: PublicHeaderProps) { + { + if (!open) { + closeAuthPrompt() + } + }} + > + + + {t('Sign in required')} + + {t('Please sign in to view {{module}}.', { + module: authPromptTarget?.title || '', + })} + + + + {t('Redirecting to sign in in {{seconds}} seconds.', { + seconds: authPromptSecondsLeft, + })} + + + + {t('Cancel')} + + {t('Sign in now')} + + + + {/* Notification Dialog */} {showNotifications && ( - 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 - 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 | 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) diff --git a/web/default/src/i18n/locales/en.json b/web/default/src/i18n/locales/en.json index a2a8118a..dbdf60e9 100644 --- a/web/default/src/i18n/locales/en.json +++ b/web/default/src/i18n/locales/en.json @@ -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", diff --git a/web/default/src/i18n/locales/fr.json b/web/default/src/i18n/locales/fr.json index 7af7e215..6e89b602 100644 --- a/web/default/src/i18n/locales/fr.json +++ b/web/default/src/i18n/locales/fr.json @@ -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", diff --git a/web/default/src/i18n/locales/ja.json b/web/default/src/i18n/locales/ja.json index 9fe97f0d..46e19f0a 100644 --- a/web/default/src/i18n/locales/ja.json +++ b/web/default/src/i18n/locales/ja.json @@ -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": "サインアップ", diff --git a/web/default/src/i18n/locales/ru.json b/web/default/src/i18n/locales/ru.json index 26141442..f9dc8f83 100644 --- a/web/default/src/i18n/locales/ru.json +++ b/web/default/src/i18n/locales/ru.json @@ -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": "Регистрация", diff --git a/web/default/src/i18n/locales/vi.json b/web/default/src/i18n/locales/vi.json index c610bc9e..8002aaee 100644 --- a/web/default/src/i18n/locales/vi.json +++ b/web/default/src/i18n/locales/vi.json @@ -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ý", diff --git a/web/default/src/i18n/locales/zh.json b/web/default/src/i18n/locales/zh.json index a1361446..c2c0d9d3 100644 --- a/web/default/src/i18n/locales/zh.json +++ b/web/default/src/i18n/locales/zh.json @@ -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": "注册", diff --git a/web/default/src/lib/nav-modules.ts b/web/default/src/lib/nav-modules.ts index 93ab5388..e17a2f27 100644 --- a/web/default/src/lib/nav-modules.ts +++ b/web/default/src/lib/nav-modules.ts @@ -16,28 +16,135 @@ along with this program. If not, see . 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 = { +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 = { + 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 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 | null { + if (!raw || String(raw).trim() === '') return null + if (raw && typeof raw === 'object') return raw as Record + + try { + return JSON.parse(String(raw)) as Record + } 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 | null +): HeaderNavModules { + return parseHeaderNavModules(status?.HeaderNavModules) +} + function getCachedStatus(): Record | null { try { + if (typeof window === 'undefined') return null const raw = window.localStorage.getItem('status') return raw ? (JSON.parse(raw) as Record) : null } catch { @@ -45,22 +152,43 @@ function getCachedStatus(): Record | null { } } -export function getModuleAccess(module: 'rankings' | 'pricing'): ModuleAccess { - const status = getCachedStatus() - if (!status) return DEFAULTS[module] - - const rawNav = status.HeaderNavModules - if (!rawNav || String(rawNav).trim() === '') return DEFAULTS[module] - +function cacheStatus(status: Record | null): void { try { - const parsed = JSON.parse(String(rawNav)) as Record - return parseAccess(parsed[module], DEFAULTS[module]) + if (typeof window !== 'undefined' && status) { + window.localStorage.setItem('status', JSON.stringify(status)) + } } catch { - return DEFAULTS[module] + /* empty */ } } -export function isSidebarModuleEnabled(section: string, module: string): boolean { +export function getModuleAccessFromStatus( + status: Record | 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 { + try { + const status = (await getStatus()) as Record | null + cacheStatus(status) + return getModuleAccessFromStatus(status, module) + } catch { + return getModuleAccess(module) + } +} + +export function isSidebarModuleEnabled( + section: string, + module: string +): boolean { const status = getCachedStatus() if (!status) return true @@ -68,7 +196,10 @@ export function isSidebarModuleEnabled(section: string, module: string): boolean if (!raw || String(raw).trim() === '') return true try { - const parsed = JSON.parse(String(raw)) as Record> + const parsed = JSON.parse(String(raw)) as Record< + string, + Record + > const sectionConfig = parsed[section] if (!sectionConfig) return true if (sectionConfig.enabled === false) return false diff --git a/web/default/src/routes/pricing/$modelId/index.tsx b/web/default/src/routes/pricing/$modelId/index.tsx index ec666979..e09ccaf4 100644 --- a/web/default/src/routes/pricing/$modelId/index.tsx +++ b/web/default/src/routes/pricing/$modelId/index.tsx @@ -17,7 +17,9 @@ along with this program. If not, see . 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, }) diff --git a/web/default/src/routes/pricing/index.tsx b/web/default/src/routes/pricing/index.tsx index 3b7222e7..66afcb86 100644 --- a/web/default/src/routes/pricing/index.tsx +++ b/web/default/src/routes/pricing/index.tsx @@ -17,7 +17,9 @@ along with this program. If not, see . 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, }) diff --git a/web/default/src/routes/rankings/index.tsx b/web/default/src/routes/rankings/index.tsx index 24de1050..f41e22db 100644 --- a/web/default/src/routes/rankings/index.tsx +++ b/web/default/src/routes/rankings/index.tsx @@ -18,9 +18,9 @@ For commercial licensing, please contact support@quantumnous.com */ import z from 'zod' import { createFileRoute, redirect } from '@tanstack/react-router' -import { Rankings } from '@/features/rankings' -import { getModuleAccess } from '@/lib/nav-modules' import { useAuthStore } from '@/stores/auth-store' +import { getFreshModuleAccess } from '@/lib/nav-modules' +import { Rankings } from '@/features/rankings' const rankingsSearchSchema = z.object({ period: z @@ -31,15 +31,18 @@ const rankingsSearchSchema = z.object({ export const Route = createFileRoute('/rankings/')({ validateSearch: rankingsSearchSchema, - beforeLoad: () => { - const access = getModuleAccess('rankings') + beforeLoad: async ({ location }) => { + const access = await getFreshModuleAccess('rankings') if (!access.enabled) { throw redirect({ to: '/' }) } if (access.requireAuth) { const { auth } = useAuthStore.getState() if (!auth.user) { - throw redirect({ to: '/sign-in', search: { redirect: '/rankings' } }) + throw redirect({ + to: '/sign-in', + search: { redirect: location.href }, + }) } } },