diff --git a/src/hooks/useCapabilities.ts b/src/hooks/useCapabilities.ts index c359f46e..de83df0e 100644 --- a/src/hooks/useCapabilities.ts +++ b/src/hooks/useCapabilities.ts @@ -1,6 +1,7 @@ import { useCallback } from 'react'; -import { useQuery } from '@tanstack/react-query'; import { getRouteApi } from '@tanstack/react-router'; +import { queryOptions, useQuery } from '@tanstack/react-query'; +import type * as t from '@/types'; import { getEffectiveCapabilitiesFn } from '@/server'; import { hasImpliedCapability } from '@/constants'; @@ -11,26 +12,20 @@ const GRANTS_UNAVAILABLE_PATTERN = /\b(404|503)\b|endpoint not found|fetch faile const AUTH_DENIED_PATTERN = /\b(401|403)\b|forbidden|unauthorized|authentication required|no admin session token/i; -export function useCapabilities(): { - capabilities: string[]; - hasCapability: (cap: string) => boolean; - isLoading: boolean; - isError: boolean; -} { - const { user } = Route.useRouteContext(); - const query = useQuery({ - queryKey: ['effectiveCapabilities', user?.id ?? ''], - queryFn: async () => { +export const capabilitiesQueryOptions = (userId: string) => + queryOptions({ + queryKey: ['effectiveCapabilities', userId], + queryFn: async (): Promise => { try { const res = await getEffectiveCapabilitiesFn(); return { available: true, capabilities: res.capabilities }; } catch (err) { const message = err instanceof Error ? err.message : String(err); if (GRANTS_UNAVAILABLE_PATTERN.test(message)) { - return { available: false, capabilities: [] as string[] }; + return { available: false, capabilities: [] }; } if (AUTH_DENIED_PATTERN.test(message)) { - return { available: true, capabilities: [] as string[] }; + return { available: true, capabilities: [] }; } throw err; } @@ -39,6 +34,15 @@ export function useCapabilities(): { retry: false, }); +export function useCapabilities(): { + capabilities: string[]; + hasCapability: (cap: string) => boolean; + isLoading: boolean; + isError: boolean; +} { + const { user } = Route.useRouteContext(); + const query = useQuery(capabilitiesQueryOptions(user?.id ?? '')); + const grantsAvailable = query.data?.available ?? false; const capabilities = query.data?.capabilities ?? []; diff --git a/src/routes/__root.tsx b/src/routes/__root.tsx index 18d99af2..b8a3de07 100644 --- a/src/routes/__root.tsx +++ b/src/routes/__root.tsx @@ -9,9 +9,10 @@ import { Outlet, ScriptOnce, Scripts, - createRootRoute, + createRootRouteWithContext, } from '@tanstack/react-router'; import type { ErrorComponentProps } from '@tanstack/react-router'; +import type { QueryClient } from '@tanstack/react-query'; import { ThemeProvider, useTheme } from '../contexts/ThemeContext'; import appCss from '../styles.css?url'; import { useLocalize } from '@/hooks'; @@ -24,7 +25,7 @@ const themeScript = `(function(){ } catch(e) {} })();`; -export const Route = createRootRoute({ +export const Route = createRootRouteWithContext<{ queryClient: QueryClient }>()({ ssr: false, head: () => ({ meta: [ diff --git a/src/routes/_app.tsx b/src/routes/_app.tsx index 4eeb0421..30c7ff7b 100644 --- a/src/routes/_app.tsx +++ b/src/routes/_app.tsx @@ -1,13 +1,14 @@ import { useState, useEffect } from 'react'; import { Icon } from '@clickhouse/click-ui'; +import { useSuspenseQuery } from '@tanstack/react-query'; import { createFileRoute, Outlet, useRouter, Link, redirect } from '@tanstack/react-router'; import type { ErrorComponentProps } from '@tanstack/react-router'; -import { useCapabilities, useCommandMenu, useLocalize } from '@/hooks'; +import { capabilitiesQueryOptions, useCommandMenu, useLocalize } from '@/hooks'; +import { verifyAdminTokenFn } from '@/server'; +import { SystemCapabilities, hasImpliedCapability } from '@/constants'; +import { AccessDenied, LoadingState } from '@/components/shared'; import { CommandMenu } from '@/components/CommandMenu'; -import { AccessDenied } from '@/components/shared'; -import { SystemCapabilities } from '@/constants'; import { Sidebar } from '@/components/Sidebar'; -import { verifyAdminTokenFn } from '@/server'; import { Header } from '@/components/Header'; const ROUTE_TITLE_KEYS: Record = { @@ -19,7 +20,6 @@ const ROUTE_TITLE_KEYS: Record = { '/help': 'com_help_title', }; - export const Route = createFileRoute('/_app')({ beforeLoad: async ({ location }) => { const result = await verifyAdminTokenFn(); @@ -33,14 +33,26 @@ export const Route = createFileRoute('/_app')({ return { user: result.user }; }, + loader: async ({ context }) => { + await context.queryClient.ensureQueryData(capabilitiesQueryOptions(context.user.id)); + }, + pendingComponent: AppPending, component: AppLayout, errorComponent: AppError, notFoundComponent: AppNotFound, }); +function AppPending() { + return ( +
+ +
+ ); +} + function AppLayout() { const { user } = Route.useRouteContext(); - const { hasCapability, isLoading, isError } = useCapabilities(); + const { available, capabilities } = useSuspenseQuery(capabilitiesQueryOptions(user.id)).data; const router = useRouter(); const localize = useLocalize(); const { open, setOpen } = useCommandMenu(); @@ -68,7 +80,7 @@ function AppLayout() { return () => document.removeEventListener('keydown', handleKeyDown); }, []); - if (!isLoading && !isError && !hasCapability(SystemCapabilities.ACCESS_ADMIN)) { + if (!available || !hasImpliedCapability(capabilities, SystemCapabilities.ACCESS_ADMIN)) { return ; } diff --git a/src/server/capabilities.ts b/src/server/capabilities.ts index 8ae8a0a5..2b288d50 100644 --- a/src/server/capabilities.ts +++ b/src/server/capabilities.ts @@ -163,13 +163,6 @@ export async function requireAllSectionCapabilities(sections: string[]): Promise } } -export const effectiveCapabilitiesOptions = (userId: string) => - queryOptions({ - queryKey: ['effectiveCapabilities', userId], - queryFn: () => getEffectiveCapabilitiesFn().then((r) => r.capabilities), - staleTime: 30_000, - }); - // ── Mutations ──────────────────────────────────────────────────────── export const grantCapabilityFn = createServerFn({ method: 'POST' }) diff --git a/src/types/hooks.ts b/src/types/hooks.ts index e70c52a4..3791a9a3 100644 --- a/src/types/hooks.ts +++ b/src/types/hooks.ts @@ -13,6 +13,11 @@ export type LocalizeFn = (key: string, options?: Record export type TranslationKeys = string; +export interface CapabilitiesData { + available: boolean; + capabilities: string[]; +} + export interface UseProfileMutationsOptions { fieldPath: string; onProfileChange?: () => void;