Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 69 additions & 23 deletions src/components/shared/OrganizationSwitcher.tsx
Original file line number Diff line number Diff line change
@@ -1,57 +1,103 @@
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<string | null>(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) {
Comment thread
dustinhealy marked this conversation as resolved.
return (
<div
role="status"
className="fixed inset-0 z-50 flex items-center justify-center gap-2 bg-(--cui-color-background-default) text-(--cui-color-text-muted)"
>
<Icon name="loading-animated" size="sm" />
<span className="text-sm">
{localize('com_admin_org_switcher_switching_to', { org: switchingTo })}
</span>
</div>
);
Comment thread
dustinhealy marked this conversation as resolved.
}

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 && (
<p role="alert" className="text-sm text-(--cui-color-text-danger)">
{localize('com_admin_org_switcher_error')}
</p>
);

if (organizations.length === 1) {
const organization = organizations[0];
return (
<div className="flex w-full max-w-sm flex-col gap-2">
<Button
type="primary"
label={localize('com_admin_org_switcher_continue', { org: organization.name })}
onClick={() => startSwitch(organization)}
disabled={switchMutation.isPending}
/>
Comment thread
dustinhealy marked this conversation as resolved.
{error}
</div>
);
}

const selected = organizations.find((organization) => organization.id === selectedOrgId);
return (
<div className="flex w-full max-w-sm flex-col gap-2">
<Select
label={localize('com_admin_org_switcher_label')}
placeholder={localize('com_admin_org_switcher_placeholder')}
value={selectedOrgId}
onSelect={(value) => {
setSelectedOrgId(value);
switchMutation.mutate(value);
}}
disabled={switchMutation.isPending || organizationsQuery.isLoading}
value={selectedOrgId || undefined}
onSelect={(value) => setSelectedOrgId(value)}
>
{organizations.map((organization) => (
<Select.Item key={organization.id} value={organization.id}>
{organization.name}
</Select.Item>
))}
</Select>
{switchMutation.isPending && (
<p className="text-sm text-(--cui-color-text-muted)">
{localize('com_admin_org_switcher_switching')}
</p>
)}
{switchMutation.isError && (
<p role="alert" className="text-sm text-(--cui-color-text-danger)">
{localize('com_admin_org_switcher_error')}
</p>
)}
<Button
type="primary"
label={
selected
? localize('com_admin_org_switcher_switch_to', { org: selected.name })
: localize('com_admin_org_switcher_switch')
}
onClick={() => selected && startSwitch(selected)}
disabled={!selected || switchMutation.isPending}
/>
{error}
</div>
);
}
32 changes: 20 additions & 12 deletions src/hooks/useCapabilities.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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<CapabilitiesData> => {
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;
}
Expand All @@ -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 ?? [];

Expand Down
4 changes: 4 additions & 0 deletions src/locales/en/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
}
5 changes: 3 additions & 2 deletions src/routes/__root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -24,7 +25,7 @@ const themeScript = `(function(){
} catch(e) {}
})();`;

export const Route = createRootRoute({
export const Route = createRootRouteWithContext<{ queryClient: QueryClient }>()({
ssr: false,
head: () => ({
meta: [
Expand Down
39 changes: 31 additions & 8 deletions src/routes/_app.tsx
Original file line number Diff line number Diff line change
@@ -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<string, string> = {
Expand All @@ -19,7 +20,6 @@ const ROUTE_TITLE_KEYS: Record<string, string> = {
'/help': 'com_help_title',
};


export const Route = createFileRoute('/_app')({
beforeLoad: async ({ location }) => {
const result = await verifyAdminTokenFn();
Expand All @@ -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(() => {});
Comment thread
dustinhealy marked this conversation as resolved.
}
Comment thread
dustinhealy marked this conversation as resolved.
},
pendingComponent: AppPending,
component: AppLayout,
errorComponent: AppError,
notFoundComponent: AppNotFound,
});

function AppPending() {
return (
<div className="flex h-screen items-center justify-center">
<LoadingState />
</div>
);
}

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();
Expand Down Expand Up @@ -68,8 +87,12 @@ function AppLayout() {
return () => document.removeEventListener('keydown', handleKeyDown);
}, []);

if (!isLoading && !isError && !hasCapability(SystemCapabilities.ACCESS_ADMIN)) {
return <AccessDenied />;
if (!available || !hasImpliedCapability(capabilities, SystemCapabilities.ACCESS_ADMIN)) {
return (
<div className="flex h-screen flex-col">
<AccessDenied />
</div>
);
}

const matchedKey = Object.keys(ROUTE_TITLE_KEYS).find((route) =>
Expand Down
16 changes: 8 additions & 8 deletions src/server/auth.oauth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 }),
);
});
});
Expand Down