🔗 fix: Surface Share Permissions Load Error as Alert Button With Tooltip (#13833)
Some checks are pending
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
GitNexus Index / index (push) Waiting to run
GitNexus Index / post-index (push) Blocked by required conditions

This commit is contained in:
Danny Avila 2026-06-18 13:37:49 -04:00 committed by GitHub
parent a468becf8c
commit 2fcba914f7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 172 additions and 3 deletions

View file

@ -1,6 +1,6 @@
import React, { useState, useEffect } from 'react';
import { AccessRoleIds, ResourceType } from 'librechat-data-provider';
import { Share2Icon, Users, Link, CopyCheck, UserX, UserCheck } from 'lucide-react';
import { Share2Icon, Users, Link, CopyCheck, UserX, UserCheck, AlertCircle } from 'lucide-react';
import {
Label,
Button,
@ -11,6 +11,7 @@ import {
OGDialogClose,
OGDialogContent,
OGDialogTrigger,
TooltipAnchor,
useToastContext,
} from '@librechat/client';
import type { TPrincipal } from 'librechat-data-provider';
@ -60,7 +61,9 @@ export default function GenericGrantAccessDialog({
config,
permissionsData,
isLoadingPermissions,
isFetchingPermissions,
permissionsError,
refetchPermissions,
updatePermissionsMutation,
currentShares,
currentIsPublic,
@ -237,9 +240,35 @@ export default function GenericGrantAccessDialog({
const hasPublicChanges = isPublic !== currentIsPublic || publicRole !== currentPublicRole;
const submitButtonActive = hasChanges || hasPublicChanges;
// Error handling
// On permissions load failure, keep a compact trigger-sized button so the layout holds,
// surfacing the error (and a retry on click) through a tooltip.
if (permissionsError) {
return <div className="text-sm text-red-600">{localize('com_ui_permissions_failed_load')}</div>;
return (
<TooltipAnchor
description={localize('com_ui_permissions_failed_load')}
render={
<Button
size="sm"
variant="outline"
type="button"
disabled={disabled}
onClick={() => refetchPermissions()}
aria-label={localize('com_ui_permissions_failed_load')}
className={cn('h-9', buttonClassName)}
>
<div className="flex min-w-[32px] items-center justify-center text-red-500">
<span className="flex h-6 w-6 items-center justify-center">
{isFetchingPermissions ? (
<Spinner className="h-4 w-4" />
) : (
<AlertCircle className="icon-md h-4 w-4" aria-hidden="true" />
)}
</span>
</div>
</Button>
}
/>
);
}
const TriggerComponent = children ? (

View file

@ -0,0 +1,136 @@
import React from 'react';
import { ResourceType } from 'librechat-data-provider';
import { render, screen, fireEvent } from '@testing-library/react';
import '@testing-library/jest-dom/extend-expect';
import GenericGrantAccessDialog from '../GenericGrantAccessDialog';
const mockRefetchPermissions = jest.fn();
const mockUseResourcePermissionState = jest.fn();
const config = {
defaultViewerRoleId: 'viewer',
defaultOwnerRoleId: 'owner',
getShareMessage: () => 'Share Agent',
getResourceUrl: () => 'http://localhost/agent/1',
getCopyUrlMessage: () => 'Copied',
};
const baseState = (overrides: Record<string, unknown> = {}) => ({
config,
permissionsData: { principals: [], public: false },
isLoadingPermissions: false,
isFetchingPermissions: false,
permissionsError: null,
refetchPermissions: mockRefetchPermissions,
updatePermissionsMutation: { isLoading: false, mutateAsync: jest.fn() },
currentShares: [],
currentIsPublic: false,
currentPublicRole: 'viewer',
isPublic: false,
setIsPublic: jest.fn(),
publicRole: 'viewer',
setPublicRole: jest.fn(),
...overrides,
});
jest.mock('~/hooks', () => ({
useLocalize: () => (key: string) => key,
useResourcePermissionState: () => mockUseResourcePermissionState(),
usePeoplePickerPermissions: () => ({ hasPeoplePickerAccess: true, peoplePickerTypeFilter: '' }),
useCanSharePublic: () => true,
useCopyToClipboard: () => jest.fn(),
}));
jest.mock('@librechat/client', () => ({
...jest.requireActual('@librechat/client'),
useToastContext: () => ({ showToast: jest.fn() }),
}));
jest.mock('../PeoplePicker/UnifiedPeopleSearch', () => ({
__esModule: true,
default: () => <div data-testid="unified-people-search" />,
}));
jest.mock('../PeoplePickerAdminSettings', () => ({
__esModule: true,
default: () => <div data-testid="admin-settings" />,
}));
jest.mock('../PublicSharingToggle', () => ({
__esModule: true,
default: () => <div data-testid="public-toggle" />,
}));
jest.mock('../PeoplePicker', () => ({
__esModule: true,
SelectedPrincipalsList: () => <div data-testid="principals-list" />,
}));
const renderDialog = () =>
render(
<GenericGrantAccessDialog
resourceDbId="agent-db-1"
resourceId="agent-1"
resourceName="Test Agent"
resourceType={ResourceType.AGENT}
/>,
);
describe('GenericGrantAccessDialog - permissions load failure', () => {
beforeEach(() => {
mockUseResourcePermissionState.mockReset();
mockRefetchPermissions.mockReset();
});
it('renders a compact alert button (not the share trigger, not raw text) when permissions fail to load', () => {
mockUseResourcePermissionState.mockReturnValue(
baseState({ permissionsError: new Error('boom'), permissionsData: undefined }),
);
const { container } = renderDialog();
expect(
screen.getByRole('button', { name: 'com_ui_permissions_failed_load' }),
).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'com_ui_share_var' })).not.toBeInTheDocument();
expect(container.querySelector('.spinner')).not.toBeInTheDocument();
});
it('retries the permissions fetch when the alert button is clicked', () => {
mockUseResourcePermissionState.mockReturnValue(
baseState({ permissionsError: new Error('boom'), permissionsData: undefined }),
);
renderDialog();
fireEvent.click(screen.getByRole('button', { name: 'com_ui_permissions_failed_load' }));
expect(mockRefetchPermissions).toHaveBeenCalledTimes(1);
});
it('shows a spinner instead of the alert icon while refetching', () => {
mockUseResourcePermissionState.mockReturnValue(
baseState({
permissionsError: new Error('boom'),
permissionsData: undefined,
isFetchingPermissions: true,
}),
);
const { container } = renderDialog();
expect(
screen.getByRole('button', { name: 'com_ui_permissions_failed_load' }),
).toBeInTheDocument();
expect(container.querySelector('.spinner')).toBeInTheDocument();
});
it('renders the normal share trigger and dialog body when permissions load successfully', () => {
mockUseResourcePermissionState.mockReturnValue(baseState());
renderDialog();
expect(
screen.queryByRole('button', { name: 'com_ui_permissions_failed_load' }),
).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'com_ui_share_var' }));
expect(screen.getByTestId('unified-people-search')).toBeInTheDocument();
});
});

View file

@ -26,7 +26,9 @@ export const useResourcePermissionState = (
const {
data: permissionsData,
isLoading: isLoadingPermissions,
isFetching: isFetchingPermissions,
error: permissionsError,
refetch: refetchPermissions,
} = useGetResourcePermissionsQuery(resourceType, resourceDbId || '', {
enabled: isValidResourceId,
});
@ -68,7 +70,9 @@ export const useResourcePermissionState = (
config,
permissionsData,
isLoadingPermissions,
isFetchingPermissions,
permissionsError,
refetchPermissions,
updatePermissionsMutation,
currentShares,
currentIsPublic,