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

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