mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-10-31 03:26:01 +00:00
fix(web): hide pages the user cannot open from console search
The search box in the console header listed every page, so regular users saw the admin pages and only found out on click that they had no access. It now offers the same pages as the sidebar, which already filters them by role and by the sidebar settings. The layout now renders the search dialog instead of the search provider, which removes the import cycle between the two.
This commit is contained in:
@@ -0,0 +1,130 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import {
|
||||
createMemoryHistory,
|
||||
createRootRoute,
|
||||
createRouter,
|
||||
RouterProvider,
|
||||
} from '@tanstack/react-router'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { afterAll, afterEach, beforeAll, expect, test } from 'vitest'
|
||||
|
||||
import { SearchProvider } from '@/context/search-provider'
|
||||
import { ROLE } from '@/lib/roles'
|
||||
import { STATUS_QUERY_KEY } from '@/lib/status-query'
|
||||
import { useAuthStore } from '@/stores/auth-store'
|
||||
|
||||
import { CommandMenu } from '../command-menu'
|
||||
import { Search } from '../search'
|
||||
|
||||
const adminEntries = ['Channels', 'Models', 'Users', 'Redemption Codes']
|
||||
const superAdminEntries = ['System Info', 'Task Plugins', 'System Settings']
|
||||
|
||||
// jsdom lacks the Web Animations API the menu's scroll area waits on.
|
||||
const getAnimationsDescriptor = Object.getOwnPropertyDescriptor(
|
||||
HTMLElement.prototype,
|
||||
'getAnimations'
|
||||
)
|
||||
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(HTMLElement.prototype, 'getAnimations', {
|
||||
configurable: true,
|
||||
value: () => [],
|
||||
})
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
if (getAnimationsDescriptor) {
|
||||
Object.defineProperty(
|
||||
HTMLElement.prototype,
|
||||
'getAnimations',
|
||||
getAnimationsDescriptor
|
||||
)
|
||||
} else {
|
||||
Reflect.deleteProperty(HTMLElement.prototype, 'getAnimations')
|
||||
}
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
useAuthStore.setState(useAuthStore.getInitialState(), true)
|
||||
window.localStorage.clear()
|
||||
})
|
||||
|
||||
async function searchEntriesFor(role: number, sidebarModulesAdmin = '') {
|
||||
useAuthStore.getState().auth.setUser({ id: 1, username: 'alice', role })
|
||||
const client = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
})
|
||||
client.setQueryData(STATUS_QUERY_KEY, {
|
||||
SidebarModulesAdmin: sidebarModulesAdmin,
|
||||
})
|
||||
const router = createRouter({
|
||||
routeTree: createRootRoute({
|
||||
component: () => (
|
||||
<SearchProvider>
|
||||
<Search />
|
||||
<CommandMenu />
|
||||
</SearchProvider>
|
||||
),
|
||||
}),
|
||||
history: createMemoryHistory({ initialEntries: ['/dashboard/overview'] }),
|
||||
})
|
||||
await router.load()
|
||||
render(
|
||||
<QueryClientProvider client={client}>
|
||||
<RouterProvider router={router} />
|
||||
</QueryClientProvider>
|
||||
)
|
||||
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'Search' }))
|
||||
const options = await screen.findAllByRole('option')
|
||||
return options.map((option) => option.textContent)
|
||||
}
|
||||
|
||||
test('regular users only find their own console pages in search', async () => {
|
||||
const entries = await searchEntriesFor(ROLE.USER)
|
||||
|
||||
expect(entries).toEqual(
|
||||
expect.arrayContaining(['API Keys', 'Usage Logs', 'Wallet', 'Profile'])
|
||||
)
|
||||
for (const entry of [...adminEntries, ...superAdminEntries]) {
|
||||
expect(entries).not.toContain(entry)
|
||||
}
|
||||
})
|
||||
|
||||
test('admins find admin pages in search but not super admin pages', async () => {
|
||||
const entries = await searchEntriesFor(ROLE.ADMIN)
|
||||
|
||||
expect(entries).toEqual(expect.arrayContaining(adminEntries))
|
||||
for (const entry of superAdminEntries) {
|
||||
expect(entries).not.toContain(entry)
|
||||
}
|
||||
})
|
||||
|
||||
test('pages hidden in the sidebar settings stay out of search', async () => {
|
||||
const entries = await searchEntriesFor(
|
||||
ROLE.USER,
|
||||
JSON.stringify({ personal: { enabled: true, topup: false } })
|
||||
)
|
||||
|
||||
expect(entries).not.toContain('Wallet')
|
||||
expect(entries).toContain('Profile')
|
||||
})
|
||||
@@ -16,7 +16,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { useLocation, useNavigate } from '@tanstack/react-router'
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import { ArrowRight, ChevronRight, Laptop, Moon, Sun } from 'lucide-react'
|
||||
import React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@@ -33,9 +33,8 @@ import {
|
||||
} from '@/components/ui/command'
|
||||
import { useSearch } from '@/context/search-provider'
|
||||
import { useTheme } from '@/context/theme-provider'
|
||||
import { useSidebarData } from '@/hooks/use-sidebar-data'
|
||||
import { useSidebarView } from '@/hooks/use-sidebar-view'
|
||||
|
||||
import { getNavGroupsForPath } from './layout/lib/sidebar-view-registry'
|
||||
import { ScrollArea } from './ui/scroll-area'
|
||||
|
||||
export function CommandMenu() {
|
||||
@@ -43,12 +42,9 @@ export function CommandMenu() {
|
||||
const navigate = useNavigate()
|
||||
const { setTheme } = useTheme()
|
||||
const { open, setOpen } = useSearch()
|
||||
const { pathname } = useLocation()
|
||||
const sidebarData = useSidebarData()
|
||||
|
||||
// Use the active nested sidebar view's nav groups when one matches
|
||||
// the current URL; otherwise fall back to the root navigation.
|
||||
const navGroups = getNavGroupsForPath(pathname, t) ?? sidebarData.navGroups
|
||||
// Offer the same pages as the sidebar, so search never lists pages the
|
||||
// user's role or the sidebar settings hide.
|
||||
const { navGroups } = useSidebarView()
|
||||
|
||||
const runCommand = React.useCallback(
|
||||
(command: () => unknown) => {
|
||||
@@ -67,11 +63,11 @@ export function CommandMenu() {
|
||||
<CommandEmpty>{t('No results found.')}</CommandEmpty>
|
||||
{navGroups.map((group) => (
|
||||
<CommandGroup key={group.id || group.title} heading={group.title}>
|
||||
{group.items.map((navItem, i) => {
|
||||
if (navItem.url)
|
||||
{group.items.map((navItem) => {
|
||||
if (navItem.url) {
|
||||
return (
|
||||
<CommandItem
|
||||
key={`${navItem.url}-${i}`}
|
||||
key={`${navItem.title}-${navItem.url}`}
|
||||
value={navItem.title}
|
||||
onSelect={() => {
|
||||
runCommand(() => navigate({ to: navItem.url }))
|
||||
@@ -83,10 +79,11 @@ export function CommandMenu() {
|
||||
{navItem.title}
|
||||
</CommandItem>
|
||||
)
|
||||
}
|
||||
|
||||
return navItem.items?.map((subItem, i) => (
|
||||
return navItem.items?.map((subItem) => (
|
||||
<CommandItem
|
||||
key={`${navItem.title}-${subItem.url}-${i}`}
|
||||
key={`${navItem.title}-${subItem.url}`}
|
||||
value={`${navItem.title}-${subItem.url}`}
|
||||
onSelect={() => {
|
||||
runCommand(() => navigate({ to: subItem.url }))
|
||||
|
||||
@@ -16,6 +16,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { CommandMenu } from '@/components/command-menu'
|
||||
import { AnimatedOutlet } from '@/components/page-transition'
|
||||
import { SkipToMain } from '@/components/skip-to-main'
|
||||
import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar'
|
||||
@@ -54,6 +55,7 @@ export function AuthenticatedLayout(props: AuthenticatedLayoutProps) {
|
||||
</SidebarInset>
|
||||
</div>
|
||||
</SidebarProvider>
|
||||
<CommandMenu />
|
||||
</SearchProvider>
|
||||
</LayoutProvider>
|
||||
)
|
||||
|
||||
@@ -47,10 +47,7 @@ export { defaultTopNavLinks } from './config/top-nav.config'
|
||||
export { MOBILE_DRAWER_ANIMATION, MOBILE_DRAWER_CONFIG } from './constants'
|
||||
|
||||
// Sidebar view registry
|
||||
export {
|
||||
getNavGroupsForPath,
|
||||
resolveSidebarView,
|
||||
} from './lib/sidebar-view-registry'
|
||||
export { resolveSidebarView } from './lib/sidebar-view-registry'
|
||||
|
||||
// Type exports (type-only to avoid conflicts with components above)
|
||||
export type {
|
||||
|
||||
@@ -16,10 +16,8 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { type TFunction } from 'i18next'
|
||||
|
||||
import { SYSTEM_SETTINGS_VIEW } from '../config/system-settings.config'
|
||||
import type { NavGroup, SidebarView } from '../types'
|
||||
import type { SidebarView } from '../types'
|
||||
|
||||
/**
|
||||
* Registered nested sidebar views.
|
||||
@@ -41,19 +39,3 @@ const SIDEBAR_VIEWS: readonly SidebarView[] = [SYSTEM_SETTINGS_VIEW]
|
||||
export function resolveSidebarView(pathname: string): SidebarView | null {
|
||||
return SIDEBAR_VIEWS.find((view) => view.pathPattern.test(pathname)) ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* Backwards-compatible helper for consumers (e.g. command palette) that
|
||||
* just need the navigation groups for the current path, without caring
|
||||
* about the view metadata.
|
||||
*
|
||||
* @returns Nav groups for the matched view, or `null` if no nested view
|
||||
* matches (callers should then fall back to root nav groups).
|
||||
*/
|
||||
export function getNavGroupsForPath(
|
||||
pathname: string,
|
||||
t: TFunction
|
||||
): NavGroup[] | null {
|
||||
const view = resolveSidebarView(pathname)
|
||||
return view ? view.getNavGroups(t) : null
|
||||
}
|
||||
|
||||
@@ -18,8 +18,6 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { createContext, useContext, useEffect, useState } from 'react'
|
||||
|
||||
import { CommandMenu } from '@/components/command-menu'
|
||||
|
||||
type SearchContextType = {
|
||||
open: boolean
|
||||
setOpen: React.Dispatch<React.SetStateAction<boolean>>
|
||||
@@ -48,7 +46,6 @@ export function SearchProvider({ children }: SearchProviderProps) {
|
||||
return (
|
||||
<SearchContext.Provider value={{ open, setOpen }}>
|
||||
{children}
|
||||
<CommandMenu />
|
||||
</SearchContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user