diff --git a/src/components/shared/OrganizationSwitcher.tsx b/src/components/shared/OrganizationSwitcher.tsx index 6027dc82..d10989a4 100644 --- a/src/components/shared/OrganizationSwitcher.tsx +++ b/src/components/shared/OrganizationSwitcher.tsx @@ -1,40 +1,85 @@ import { useState } from 'react'; -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useRouter } from '@tanstack/react-router'; -import { Select } from '@clickhouse/click-ui'; -import { useLocalize } from '@/hooks'; +import { Button, Icon, Select } from '@clickhouse/click-ui'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { adminOrganizationsQueryOptions, switchAdminOrganizationFn } from '@/server'; +import { useLocalize } from '@/hooks'; export function OrganizationSwitcher() { const localize = useLocalize(); const router = useRouter(); const queryClient = useQueryClient(); const [selectedOrgId, setSelectedOrgId] = useState(''); + const [switchingTo, setSwitchingTo] = useState(null); const organizationsQuery = useQuery(adminOrganizationsQueryOptions); const switchMutation = useMutation({ mutationFn: (targetOrgId: string) => switchAdminOrganizationFn({ data: { targetOrgId } }), onSuccess: async () => { - await queryClient.invalidateQueries({ queryKey: ['adminOrganizations'] }); - await router.invalidate(); - await router.navigate({ to: '/' }); + try { + await queryClient.invalidateQueries(); + await router.invalidate(); + await router.navigate({ to: '/' }); + } catch { + setSwitchingTo(null); + } + }, + onError: () => { + setSwitchingTo(null); + setSelectedOrgId(''); }, - onError: () => setSelectedOrgId(''), }); + if (switchingTo) { + return ( +
+ + + {localize('com_admin_org_switcher_switching_to', { org: switchingTo })} + +
+ ); + } + const organizations = organizationsQuery.data ?? []; if (organizations.length === 0) return null; + const startSwitch = (organization: { id: string; name: string }) => { + setSwitchingTo(organization.name); + switchMutation.mutate(organization.id); + }; + + const error = switchMutation.isError && ( +

+ {localize('com_admin_org_switcher_error')} +

+ ); + + if (organizations.length === 1) { + const organization = organizations[0]; + return ( +
+
+ ); + } + + const selected = organizations.find((organization) => organization.id === selectedOrgId); return (
- {switchMutation.isPending && ( -

- {localize('com_admin_org_switcher_switching')} -

- )} - {switchMutation.isError && ( -

- {localize('com_admin_org_switcher_error')} -

- )} +
); } 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/locales/en/translation.json b/src/locales/en/translation.json index 89e07770..10885be8 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -1232,6 +1232,10 @@ "com_admin_return_to_librechat": "Return to LibreChat", "com_admin_org_switcher_label": "Choose an organization where you have admin access", "com_admin_org_switcher_placeholder": "Select an organization", + "com_admin_org_switcher_continue": "Continue to {{org}}", + "com_admin_org_switcher_switch": "Switch organization", + "com_admin_org_switcher_switch_to": "Switch to {{org}}", + "com_admin_org_switcher_switching_to": "Switching to {{org}}...", "com_admin_org_switcher_switching": "Switching organization...", "com_admin_org_switcher_error": "We couldn't switch organizations. Please try again." } 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..9175aeaf 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 { adminOrganizationsQueryOptions, 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,33 @@ export const Route = createFileRoute('/_app')({ return { user: result.user }; }, + loader: async ({ context }) => { + const caps = await context.queryClient.ensureQueryData( + capabilitiesQueryOptions(context.user.id), + ); + const hasAdmin = + caps.available && hasImpliedCapability(caps.capabilities, SystemCapabilities.ACCESS_ADMIN); + if (!hasAdmin) { + await context.queryClient.ensureQueryData(adminOrganizationsQueryOptions).catch(() => {}); + } + }, + 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,8 +87,12 @@ function AppLayout() { return () => document.removeEventListener('keydown', handleKeyDown); }, []); - if (!isLoading && !isError && !hasCapability(SystemCapabilities.ACCESS_ADMIN)) { - return ; + if (!available || !hasImpliedCapability(capabilities, SystemCapabilities.ACCESS_ADMIN)) { + return ( +
+ +
+ ); } const matchedKey = Object.keys(ROUTE_TITLE_KEYS).find((route) => diff --git a/src/server/auth.oauth.test.ts b/src/server/auth.oauth.test.ts index 569f4803..d8f13b5c 100644 --- a/src/server/auth.oauth.test.ts +++ b/src/server/auth.oauth.test.ts @@ -170,7 +170,7 @@ describe('verifyAdminTokenFn', () => { expect(updateSession).toHaveBeenCalledWith({ lastActivity: expect.any(Number) }); }); - it('clears a delegated admin session when backend capability revalidation is denied', async () => { + it('keeps the authenticated session and flags access denied when the current org fails admin revalidation', async () => { const user = { id: 'user-4', role: 'department-admin', email: 'delegate4@example.com' }; sessionState.data = { user, @@ -182,16 +182,16 @@ describe('verifyAdminTokenFn', () => { const result = await verifyAdminTokenFn(); - expect(result).toEqual({ valid: false, error: 'Admin privileges have been revoked' }); + expect(result).toEqual({ valid: true, user, accessDenied: true }); expect(fetchMock).toHaveBeenCalledWith('http://librechat.test/api/admin/verify', { headers: { Authorization: 'Bearer jwt-token-4' }, }); - expect(updateSession).toHaveBeenCalledWith( - expect.objectContaining({ - token: undefined, - user: undefined, - refreshToken: undefined, - }), + expect(updateSession).toHaveBeenCalledWith({ + lastVerified: expect.any(Number), + lastActivity: expect.any(Number), + }); + expect(updateSession).not.toHaveBeenCalledWith( + expect.objectContaining({ token: undefined, user: undefined }), ); }); });