From cfe15fcbd66db325a65e46ac8db1a7de43708c8d Mon Sep 17 00:00:00 2001 From: Dustin Healy <54083382+dustinhealy@users.noreply.github.com> Date: Wed, 5 Aug 2026 12:51:59 -0700 Subject: [PATCH 1/2] =?UTF-8?q?=F0=9F=90=9B=20fix:=20Resolve=20Admin=20Cap?= =?UTF-8?q?ability=20Gate=20In=20Route=20Loader?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The layout decided admin access from a component-level query, so while the effective-capabilities request was in flight it fell through and mounted the full panel before replacing it with the access-denied screen. Resolving the check in the route loader and reading it with a suspense query means the layout only ever renders a decided state, with the route pending component covering the wait. Also removes an unreferenced capabilities query factory that declared the same cache key as the hook with an incompatible payload and no error classification, so the two could not safely coexist. --- src/hooks/useCapabilities.ts | 32 ++++++++++++++++++++------------ src/routes/__root.tsx | 5 +++-- src/routes/_app.tsx | 26 +++++++++++++++++++------- src/server/capabilities.ts | 7 ------- 4 files changed, 42 insertions(+), 28 deletions(-) diff --git a/src/hooks/useCapabilities.ts b/src/hooks/useCapabilities.ts index c359f46e..dcdc685e 100644 --- a/src/hooks/useCapabilities.ts +++ b/src/hooks/useCapabilities.ts @@ -1,6 +1,6 @@ import { useCallback } from 'react'; -import { useQuery } from '@tanstack/react-query'; import { getRouteApi } from '@tanstack/react-router'; +import { queryOptions, useQuery } from '@tanstack/react-query'; import { getEffectiveCapabilitiesFn } from '@/server'; import { hasImpliedCapability } from '@/constants'; @@ -11,26 +11,25 @@ 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(): { +interface CapabilitiesData { + available: boolean; 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 +38,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' }) From ae1e92a430bd0045e345d056c19a3e4a69430a40 Mon Sep 17 00:00:00 2001 From: Dustin Healy <54083382+dustinhealy@users.noreply.github.com> Date: Fri, 7 Aug 2026 08:05:33 -0700 Subject: [PATCH 2/2] =?UTF-8?q?=F0=9F=8E=A8=20refactor:=20Move=20Capabilit?= =?UTF-8?q?ies=20Query=20Shape=20Into=20Types=20Barrel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The exported query factory's data contract belongs with the rest of the local type definitions rather than inside the hook, so future consumers reach for the shared type instead of redefining the result shape. --- src/hooks/useCapabilities.ts | 8 ++------ src/types/hooks.ts | 5 +++++ 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/hooks/useCapabilities.ts b/src/hooks/useCapabilities.ts index dcdc685e..de83df0e 100644 --- a/src/hooks/useCapabilities.ts +++ b/src/hooks/useCapabilities.ts @@ -1,6 +1,7 @@ import { useCallback } from 'react'; 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,15 +12,10 @@ 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; -interface CapabilitiesData { - available: boolean; - capabilities: string[]; -} - export const capabilitiesQueryOptions = (userId: string) => queryOptions({ queryKey: ['effectiveCapabilities', userId], - queryFn: async (): Promise => { + queryFn: async (): Promise => { try { const res = await getEffectiveCapabilitiesFn(); return { available: true, capabilities: res.capabilities }; 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;