refactor: remove GenericManagePermissionsDialog and GrantAccessDialog components

- Deleted GenericManagePermissionsDialog and GrantAccessDialog components to streamline sharing functionality.
- Updated ManagePermissionsDialog to utilize AccessRolesPicker directly.
- Introduced UnifiedPeopleSearch for improved people selection experience.
- Enhanced PublicSharingToggle with InfoHoverCard for better user guidance.
- Adjusted AgentPanel to change error status to warning for duplicate agent versions.
- Updated translations to include new keys for search and access management.
This commit is contained in:
Marco Beretta 2025-08-05 23:01:24 +02:00
parent a9b19fa956
commit f70a62793b
No known key found for this signature in database
GPG key ID: D918033D8E74CC11
24 changed files with 385 additions and 874 deletions

View file

@ -1,7 +1,6 @@
import React from 'react';
import { useRecoilState } from 'recoil';
import { Switch, Label } from '@librechat/client';
import HoverCardSettings from '../HoverCardSettings';
import { Switch, Label, InfoHoverCard, ESide } from '@librechat/client';
import { useLocalize } from '~/hooks';
import store from '~/store';
@ -17,7 +16,7 @@ export default function DisplayUsernameMessages() {
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<Label className="font-light">{localize('com_nav_user_name_display')}</Label>
<HoverCardSettings side="bottom" text="com_nav_info_user_name_display" />
<InfoHoverCard side={ESide.Bottom} text={localize('com_nav_info_user_name_display')} />
</div>
<Switch
id="UsernameDisplay"

View file

@ -1,6 +1,5 @@
import React from 'react';
import { Label } from '@librechat/client';
import HoverCardSettings from '~/components/Nav/SettingsTabs/HoverCardSettings';
import { Label, InfoHoverCard, ESide } from '@librechat/client';
import { TranslationKeys, useLocalize } from '~/hooks';
interface AutoRefillSettingsProps {
@ -114,7 +113,7 @@ const AutoRefillSettings: React.FC<AutoRefillSettingsProps> = ({
{/* Left Section: Label */}
<div className="flex items-center space-x-2">
<Label className="font-light">{localize('com_nav_balance_next_refill')}</Label>
<HoverCardSettings side="bottom" text="com_nav_balance_next_refill_info" />
<InfoHoverCard side={ESide.Bottom} text={localize('com_nav_balance_next_refill_info')} />
</div>
{/* Right Section: tokenCredits Value */}

View file

@ -1,6 +1,5 @@
import React from 'react';
import { Label } from '@librechat/client';
import HoverCardSettings from '~/components/Nav/SettingsTabs/HoverCardSettings';
import { Label, InfoHoverCard, ESide } from '@librechat/client';
import { useLocalize } from '~/hooks';
interface TokenCreditsItemProps {
@ -15,7 +14,7 @@ const TokenCreditsItem: React.FC<TokenCreditsItemProps> = ({ tokenCredits }) =>
{/* Left Section: Label */}
<div className="flex items-center space-x-2">
<Label className="font-light">{localize('com_nav_balance')}</Label>
<HoverCardSettings side="bottom" text="com_nav_info_balance" />
<InfoHoverCard side={ESide.Bottom} text={localize('com_nav_info_balance')} />
</div>
{/* Right Section: tokenCredits Value */}

View file

@ -1,7 +1,6 @@
import { useRecoilState } from 'recoil';
import { Dropdown, Switch } from '@librechat/client';
import { ForkOptions } from 'librechat-data-provider';
import HoverCardSettings from '../HoverCardSettings';
import { Dropdown, Switch, InfoHoverCard, ESide } from '@librechat/client';
import { useLocalize } from '~/hooks';
import store from '~/store';
@ -36,7 +35,10 @@ export const ForkSettings = () => {
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<div>{localize('com_ui_fork_change_default')}</div>
<HoverCardSettings side="bottom" text="com_nav_info_fork_change_default" />
<InfoHoverCard
side={ESide.Bottom}
text={localize('com_nav_info_fork_change_default')}
/>
</div>
<Dropdown
value={forkSetting}
@ -53,7 +55,10 @@ export const ForkSettings = () => {
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<div>{localize('com_ui_fork_split_target_setting')}</div>
<HoverCardSettings side="bottom" text="com_nav_info_fork_split_target_setting" />
<InfoHoverCard
side={ESide.Bottom}
text={localize('com_nav_info_fork_split_target_setting')}
/>
</div>
<Switch
id="splitAtTarget"

View file

@ -1,6 +1,5 @@
import { useRecoilState } from 'recoil';
import { Switch } from '@librechat/client';
import HoverCardSettings from '../HoverCardSettings';
import { Switch, InfoHoverCard, ESide } from '@librechat/client';
import { useLocalize } from '~/hooks';
import store from '~/store';
@ -23,7 +22,7 @@ export default function SaveBadgesState({
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<div>{localize('com_nav_save_badges_state')}</div>
<HoverCardSettings side="bottom" text="com_nav_info_save_badges_state" />
<InfoHoverCard side={ESide.Bottom} text={localize('com_nav_info_save_badges_state')} />
</div>
<Switch
id="saveBadgesState"

View file

@ -1,6 +1,5 @@
import { useRecoilState } from 'recoil';
import { Switch } from '@librechat/client';
import HoverCardSettings from '../HoverCardSettings';
import { Switch, InfoHoverCard, ESide } from '@librechat/client';
import { useLocalize } from '~/hooks';
import store from '~/store';
@ -23,7 +22,7 @@ export default function SaveDraft({
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<div>{localize('com_nav_show_thinking')}</div>
<HoverCardSettings side="bottom" text="com_nav_info_show_thinking" />
<InfoHoverCard side={ESide.Bottom} text={localize('com_nav_info_show_thinking')} />
</div>
<Switch
id="showThinking"

View file

@ -1,8 +1,8 @@
import { memo } from 'react';
import { InfoHoverCard, ESide } from '@librechat/client';
import { PermissionTypes, Permissions } from 'librechat-data-provider';
import HoverCardSettings from '~/components/Nav/SettingsTabs/HoverCardSettings';
import { useLocalize, useHasAccess } from '~/hooks';
import SlashCommandSwitch from './SlashCommandSwitch';
import { useLocalize, useHasAccess } from '~/hooks';
import PlusCommandSwitch from './PlusCommandSwitch';
import AtCommandSwitch from './AtCommandSwitch';
@ -25,7 +25,7 @@ function Commands() {
<h3 className="text-lg font-medium text-text-primary">
{localize('com_nav_chat_commands')}
</h3>
<HoverCardSettings side="bottom" text="com_nav_chat_commands_info" />
<InfoHoverCard side={ESide.Bottom} text={localize('com_nav_chat_commands_info')} />
</div>
<div className="flex flex-col gap-3 text-sm text-text-primary">
<div className="pb-3">

View file

@ -1,9 +1,8 @@
import { forwardRef } from 'react';
import type { ForwardedRef } from 'react';
import { CheckIcon } from 'lucide-react';
import { Spinner, DialogButton } from '@librechat/client';
import HoverCardSettings from './HoverCardSettings';
import { Spinner, DialogButton, InfoHoverCard, ESide } from '@librechat/client';
import type { TDangerButtonProps } from '~/common';
import type { ForwardedRef } from 'react';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
@ -37,7 +36,7 @@ const DangerButton = (props: TDangerButtonProps, ref: ForwardedRef<HTMLButtonEle
{showText && (
<div className={`flex items-center ${infoDescriptionCode ? 'space-x-2' : ''}`}>
<div>{localize(infoTextCode)}</div>
{infoDescriptionCode && <HoverCardSettings side="bottom" text={infoDescriptionCode} />}
{infoDescriptionCode && <InfoHoverCard side={ESide.Bottom} text={infoDescriptionCode} />}
</div>
)}
<DialogButton

View file

@ -1,30 +0,0 @@
import React from 'react';
import {
CircleHelpIcon,
HoverCard,
HoverCardTrigger,
HoverCardPortal,
HoverCardContent,
} from '@librechat/client';
import { useLocalize } from '~/hooks';
const HoverCardSettings = ({ side, text }) => {
const localize = useLocalize();
return (
<HoverCard openDelay={500}>
<HoverCardTrigger>
<CircleHelpIcon className="h-5 w-5 text-text-tertiary" />{' '}
</HoverCardTrigger>
<HoverCardPortal>
<HoverCardContent side={side} className="z-[999] w-80">
<div className="space-y-2">
<p className="text-sm text-text-secondary">{localize(text)}</p>
</div>
</HoverCardContent>
</HoverCardPortal>
</HoverCard>
);
};
export default HoverCardSettings;

View file

@ -1,12 +1,14 @@
import { Switch } from '@librechat/client';
import { RecoilState, useRecoilState } from 'recoil';
import HoverCardSettings from './HoverCardSettings';
import { Switch, InfoHoverCard, ESide } from '@librechat/client';
import { useLocalize } from '~/hooks';
type LocalizeFn = ReturnType<typeof useLocalize>;
type LocalizeKey = Parameters<LocalizeFn>[0];
interface ToggleSwitchProps {
stateAtom: RecoilState<boolean>;
localizationKey: string;
hoverCardText?: string;
localizationKey: LocalizeKey;
hoverCardText?: LocalizeKey;
switchId: string;
onCheckedChange?: (value: boolean) => void;
}
@ -18,21 +20,19 @@ const ToggleSwitch: React.FC<ToggleSwitchProps> = ({
switchId,
onCheckedChange,
}) => {
const [switchState, setSwitchState] = useRecoilState<boolean>(stateAtom);
const [switchState, setSwitchState] = useRecoilState(stateAtom);
const localize = useLocalize();
const handleCheckedChange = (value: boolean) => {
setSwitchState(value);
if (onCheckedChange) {
onCheckedChange(value);
}
onCheckedChange?.(value);
};
return (
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<div>{localize(localizationKey as any)}</div>
{hoverCardText && <HoverCardSettings side="bottom" text={hoverCardText} />}
<div>{localize(localizationKey)}</div>
{hoverCardText && <InfoHoverCard side={ESide.Bottom} text={localize(hoverCardText)} />}
</div>
<Switch
id={switchId}

View file

@ -1,7 +1,7 @@
import React from 'react';
import * as Ariakit from '@ariakit/react';
import { ChevronDown } from 'lucide-react';
import { DropdownPopup } from '@librechat/client';
import { DropdownPopup, Skeleton } from '@librechat/client';
import { AccessRoleIds, ResourceType } from 'librechat-data-provider';
import { useGetAccessRolesQuery } from 'librechat-data-provider/react-query';
import type { AccessRole } from 'librechat-data-provider';
@ -39,14 +39,7 @@ export default function AccessRolesPicker({
const selectedRoleInfo = selectedRole ? getLocalizedRoleInfo(selectedRole.accessRoleId) : null;
if (rolesLoading || !accessRoles) {
return (
<div className={className}>
<div className="flex items-center justify-center py-2">
<div className="h-4 w-4 animate-spin rounded-full border-2 border-border-light border-t-blue-600"></div>
<span className="ml-2 text-sm text-text-secondary">{localize('com_ui_loading')}</span>
</div>
</div>
);
return <Skeleton className="h-10 w-24 rounded-lg" />;
}
const dropdownItems: t.MenuItemProps[] = accessRoles.map((role: AccessRole) => {

View file

@ -1,10 +1,11 @@
import React, { useState } from 'react';
import React, { useState, useEffect } from 'react';
import { AccessRoleIds, ResourceType } from 'librechat-data-provider';
import { Share2Icon, Users, Shield, Link, CopyCheck } from 'lucide-react';
import { Share2Icon, Users, Link, CopyCheck, UserX, UserCheck } from 'lucide-react';
import {
Label,
Button,
Spinner,
Skeleton,
OGDialog,
OGDialogTitle,
OGDialogClose,
@ -19,10 +20,9 @@ import {
useCopyToClipboard,
useLocalize,
} from '~/hooks';
import GenericManagePermissionsDialog from './GenericManagePermissionsDialog';
import UnifiedPeopleSearch from './PeoplePicker/UnifiedPeopleSearch';
import PublicSharingToggle from './PublicSharingToggle';
import AccessRolesPicker from './AccessRolesPicker';
import { PeoplePicker } from './PeoplePicker';
import { SelectedPrincipalsList } from './PeoplePicker';
import { cn } from '~/utils';
export default function GenericGrantAccessDialog({
@ -51,6 +51,9 @@ export default function GenericGrantAccessDialog({
const { hasPeoplePickerAccess, peoplePickerTypeFilter } = usePeoplePickerPermissions();
const {
config,
permissionsData,
isLoadingPermissions,
permissionsError,
updatePermissionsMutation,
currentShares,
currentIsPublic,
@ -61,11 +64,22 @@ export default function GenericGrantAccessDialog({
setPublicRole,
} = useResourcePermissionState(resourceType, resourceDbId, isModalOpen);
const [newShares, setNewShares] = useState<TPrincipal[]>([]);
// State for unified list of all shares (existing + newly added)
const [allShares, setAllShares] = useState<TPrincipal[]>([]);
const [hasChanges, setHasChanges] = useState(false);
const [defaultPermissionId, setDefaultPermissionId] = useState<AccessRoleIds | undefined>(
config?.defaultViewerRoleId,
);
// Sync all shares with current shares when modal opens, marking existing vs new
useEffect(() => {
if (permissionsData && isModalOpen) {
const shares = permissionsData.principals || [];
setAllShares(shares.map((share) => ({ ...share, isExisting: true })));
setHasChanges(false);
}
}, [permissionsData, isModalOpen]);
const resourceUrl = config?.getResourceUrl ? config?.getResourceUrl(resourceId || '') : '';
const copyResourceUrl = useCopyToClipboard({ text: resourceUrl });
@ -78,21 +92,95 @@ export default function GenericGrantAccessDialog({
return null;
}
const handleGrantAccess = async () => {
try {
const sharesToAdd = newShares.map((share) => ({
...share,
accessRoleId: defaultPermissionId,
}));
// Handler for adding users from search (immediate add to unified list)
const handleAddFromSearch = (newShares: TPrincipal[]) => {
const sharesToAdd = newShares.filter(
(newShare) =>
!allShares.some((existing) => existing.idOnTheSource === newShare.idOnTheSource),
);
const allShares = [...currentShares, ...sharesToAdd];
const sharesWithDefaults = sharesToAdd.map((share) => ({
...share,
accessRoleId: defaultPermissionId || config?.defaultViewerRoleId,
isExisting: false, // Mark as newly added
}));
setAllShares((prev) => [...prev, ...sharesWithDefaults]);
setHasChanges(true);
};
// Handler for removing individual shares
const handleRemoveShare = (idOnTheSource: string) => {
setAllShares(allShares.filter((s) => s.idOnTheSource !== idOnTheSource));
setHasChanges(true);
};
// Handler for changing individual share permissions
const handleRoleChange = (idOnTheSource: string, newRole: string) => {
setAllShares(
allShares.map((s) =>
s.idOnTheSource === idOnTheSource ? { ...s, accessRoleId: newRole as AccessRoleIds } : s,
),
);
setHasChanges(true);
};
// Handler for revoking all sharing
const handleRevokeAll = () => {
setAllShares([]);
setIsPublic(false);
setHasChanges(true);
};
// Handler for public access toggle
const handlePublicToggle = (isPublicValue: boolean) => {
setIsPublic(isPublicValue);
setHasChanges(true);
if (!isPublicValue) {
setPublicRole(config?.defaultViewerRoleId);
}
};
// Handler for public role change
const handlePublicRoleChange = (role: string) => {
setPublicRole(role as AccessRoleIds);
setHasChanges(true);
};
// Save all changes (unified save handler)
const handleSave = async () => {
if (!allShares.length && !isPublic && !hasChanges) {
return;
}
try {
// Calculate changes for unified list
const originalSharesMap = new Map(
currentShares.map((share) => [`${share.type}-${share.idOnTheSource}`, share]),
);
const allSharesMap = new Map(
allShares.map((share) => [`${share.type}-${share.idOnTheSource}`, share]),
);
// Find newly added and updated shares
const updated = allShares.filter((share) => {
const key = `${share.type}-${share.idOnTheSource}`;
const original = originalSharesMap.get(key);
return !original || original.accessRoleId !== share.accessRoleId;
});
// Find removed shares
const removed = currentShares.filter((share) => {
const key = `${share.type}-${share.idOnTheSource}`;
return !allSharesMap.has(key);
});
await updatePermissionsMutation.mutateAsync({
resourceType,
resourceId: resourceDbId,
data: {
updated: sharesToAdd,
removed: [],
updated,
removed,
public: isPublic,
publicAccessRoleId: isPublic ? publicRole : undefined,
},
@ -103,34 +191,48 @@ export default function GenericGrantAccessDialog({
}
showToast({
message: `Access granted successfully to ${newShares.length} ${newShares.length === 1 ? 'person' : 'people'}${isPublic ? ' and made public' : ''}`,
message: localize('com_ui_permissions_updated_success'),
status: 'success',
});
setNewShares([]);
setDefaultPermissionId(config?.defaultViewerRoleId);
setIsPublic(false);
setPublicRole(config?.defaultViewerRoleId);
setHasChanges(false);
setIsModalOpen(false);
} catch (error) {
console.error('Error granting access:', error);
console.error('Error updating permissions:', error);
showToast({
message: 'Failed to grant access. Please try again.',
message: localize('com_ui_permissions_failed_update'),
status: 'error',
});
}
};
const handleCancel = () => {
setNewShares([]);
// Reset to original state
const shares = permissionsData?.principals || [];
setAllShares(shares.map((share) => ({ ...share, isExisting: true })));
setDefaultPermissionId(config?.defaultViewerRoleId);
setIsPublic(false);
setPublicRole(config?.defaultViewerRoleId);
setIsPublic(currentIsPublic);
setPublicRole(currentPublicRole || config?.defaultViewerRoleId || '');
setHasChanges(false);
setIsModalOpen(false);
};
// Validation and calculated values
const totalCurrentShares = currentShares.length + (currentIsPublic ? 1 : 0);
const submitButtonActive =
newShares.length > 0 || isPublic !== currentIsPublic || publicRole !== currentPublicRole;
// Check if there's at least one owner (user, group, or public with owner role)
const hasAtLeastOneOwner =
allShares.some((share) => share.accessRoleId === config?.defaultOwnerRoleId) ||
(isPublic && publicRole === config?.defaultOwnerRoleId);
// Check if there are any changes to save
const hasPublicChanges = isPublic !== currentIsPublic || publicRole !== currentPublicRole;
const submitButtonActive = hasChanges || hasPublicChanges;
// Error handling
if (permissionsError) {
return <div className="text-sm text-red-600">{localize('com_ui_permissions_failed_load')}</div>;
}
const TriggerComponent = children ? (
children
@ -158,7 +260,6 @@ export default function GenericGrantAccessDialog({
return (
<OGDialog open={isModalOpen} onOpenChange={setIsModalOpen} modal>
<OGDialogTrigger asChild>{TriggerComponent}</OGDialogTrigger>
<OGDialogContent className="max-h-[90vh] w-11/12 overflow-y-auto md:max-w-3xl">
<OGDialogTitle>
<div className="flex items-center gap-2">
@ -170,47 +271,83 @@ export default function GenericGrantAccessDialog({
</OGDialogTitle>
<div className="space-y-6 p-2">
{hasPeoplePickerAccess && (
<>
<PeoplePicker
onSelectionChange={setNewShares}
placeholder={localize('com_ui_search_people_placeholder')}
typeFilter={peoplePickerTypeFilter}
/>
{/* Unified Search and Management Section */}
<div className="space-y-4">
{/* Search Bar with Default Permission Setting */}
{hasPeoplePickerAccess && (
<div className="space-y-2">
<h4 className="flex items-center gap-2 text-sm font-medium text-text-primary">
<UserCheck className="h-4 w-4" />
{localize('com_ui_user_group_permissions')} ( {allShares.length} )
</h4>
<div className="space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Shield className="h-4 w-4 text-text-secondary" />
<label className="text-sm font-medium text-text-primary">
{localize('com_ui_permission_level')}
</label>
</div>
</div>
<AccessRolesPicker
resourceType={resourceType}
selectedRoleId={defaultPermissionId}
onRoleChange={setDefaultPermissionId}
<UnifiedPeopleSearch
onAddPeople={handleAddFromSearch}
placeholder={localize('com_ui_search_people_placeholder')}
typeFilter={peoplePickerTypeFilter}
excludeIds={allShares.map((s) => s.idOnTheSource)}
/>
{/* Unified User/Group List */}
{(() => {
if (isLoadingPermissions) {
return (
<div className="flex flex-col items-center gap-2">
<Skeleton className="h-[62px] w-full rounded-lg" />
<Skeleton className="h-[62px] w-full rounded-lg" />
</div>
);
}
if (allShares.length === 0 && !hasChanges) {
return (
<div className="rounded-lg border-2 border-dashed border-border-light p-8 text-center">
<Users className="mx-auto h-8 w-8 text-text-primary" />
<p className="mt-2 text-sm text-text-primary">
{localize('com_ui_no_individual_access')}
</p>
<p className="mt-1 text-xs text-text-primary">
{localize('com_ui_search_above_to_add_people')}
</p>
</div>
);
}
return (
<div className="space-y-2">
{!hasAtLeastOneOwner && hasChanges && (
<div className="rounded-lg border border-destructive/30 bg-destructive/10 p-3 text-center">
<div className="flex items-center justify-center gap-2 text-sm text-red-600 dark:text-red-400">
<UserX className="h-4 w-4" />
{localize('com_ui_at_least_one_owner_required')}
</div>
</div>
)}
<SelectedPrincipalsList
principles={allShares}
onRemoveHandler={handleRemoveShare}
resourceType={resourceType}
onRoleChange={(id, newRole) => handleRoleChange(id, newRole)}
/>
</div>
);
})()}
</div>
</>
)}
)}
</div>
{/* Public Access Section */}
<PublicSharingToggle
isPublic={isPublic}
publicRole={publicRole}
onPublicToggle={setIsPublic}
onPublicRoleChange={setPublicRole}
onPublicToggle={handlePublicToggle}
onPublicRoleChange={handlePublicRoleChange}
resourceType={resourceType}
/>
{/* Footer Actions */}
<div className="flex justify-between pt-4">
<div className="flex gap-2">
{hasPeoplePickerAccess && (
<GenericManagePermissionsDialog
resourceDbId={resourceDbId}
resourceName={resourceName}
resourceType={resourceType}
/>
)}
{resourceId && resourceUrl && (
<Button
variant="outline"
@ -242,17 +379,21 @@ export default function GenericGrantAccessDialog({
</Button>
</OGDialogClose>
<Button
onClick={handleGrantAccess}
disabled={updatePermissionsMutation.isLoading || !submitButtonActive}
onClick={handleSave}
disabled={
updatePermissionsMutation.isLoading ||
!submitButtonActive ||
(hasChanges && !hasAtLeastOneOwner)
}
className="min-w-[120px]"
>
{updatePermissionsMutation.isLoading ? (
<div className="flex items-center gap-2">
<Spinner className="h-4 w-4" />
{localize('com_ui_granting')}
{localize('com_ui_saving')}
</div>
) : (
localize('com_ui_grant_access')
localize('com_ui_save_changes')
)}
</Button>
</div>

View file

@ -1,339 +0,0 @@
import React, { useState, useEffect } from 'react';
import { useGetAccessRolesQuery } from 'librechat-data-provider/react-query';
import { Settings, Users, Loader, UserCheck, Trash2, Shield } from 'lucide-react';
import {
Button,
OGDialog,
OGDialogTitle,
OGDialogClose,
OGDialogContent,
OGDialogTrigger,
useToastContext,
} from '@librechat/client';
import type { TPrincipal, ResourceType, AccessRoleIds } from 'librechat-data-provider';
import { useResourcePermissionState } from '~/hooks/Sharing';
import PublicSharingToggle from './PublicSharingToggle';
import { SelectedPrincipalsList } from './PeoplePicker';
import { useLocalize } from '~/hooks';
export default function GenericManagePermissionsDialog({
resourceDbId,
resourceName,
resourceType,
onUpdatePermissions,
children,
}: {
resourceDbId: string;
resourceName?: string;
resourceType: ResourceType;
onUpdatePermissions?: (
shares: TPrincipal[],
isPublic: boolean,
publicRole: AccessRoleIds,
) => void;
children?: React.ReactNode;
}) {
const localize = useLocalize();
const { showToast } = useToastContext();
const [isModalOpen, setIsModalOpen] = useState(false);
const [hasChanges, setHasChanges] = useState(false);
const {
config,
permissionsData,
isLoadingPermissions,
permissionsError,
updatePermissionsMutation,
currentShares,
currentIsPublic,
currentPublicRole,
isPublic: managedIsPublic,
setIsPublic: setManagedIsPublic,
publicRole: managedPublicRole,
setPublicRole: setManagedPublicRole,
} = useResourcePermissionState(resourceType, resourceDbId, isModalOpen);
const { data: accessRoles } = useGetAccessRolesQuery(resourceType);
const [managedShares, setManagedShares] = useState<TPrincipal[]>([]);
useEffect(() => {
if (permissionsData && isModalOpen) {
const shares = permissionsData.principals || [];
setManagedShares(shares);
setHasChanges(false);
}
}, [permissionsData, isModalOpen]);
if (!resourceDbId) {
return null;
}
if (!config) {
console.error(`Unsupported resource type: ${resourceType}`);
return null;
}
if (permissionsError) {
return <div className="text-sm text-red-600">{localize('com_ui_permissions_failed_load')}</div>;
}
const handleRemoveShare = (idOnTheSource: string) => {
setManagedShares(managedShares.filter((s) => s.idOnTheSource !== idOnTheSource));
setHasChanges(true);
};
const handleRoleChange = (idOnTheSource: string, newRole: string) => {
setManagedShares(
managedShares.map((s) =>
s.idOnTheSource === idOnTheSource ? { ...s, accessRoleId: newRole } : s,
),
);
setHasChanges(true);
};
const handleSaveChanges = async () => {
try {
const originalSharesMap = new Map(
currentShares.map((share) => [`${share.type}-${share.idOnTheSource}`, share]),
);
const managedSharesMap = new Map(
managedShares.map((share) => [`${share.type}-${share.idOnTheSource}`, share]),
);
const updated = managedShares.filter((share) => {
const key = `${share.type}-${share.idOnTheSource}`;
const original = originalSharesMap.get(key);
return !original || original.accessRoleId !== share.accessRoleId;
});
const removed = currentShares.filter((share) => {
const key = `${share.type}-${share.idOnTheSource}`;
return !managedSharesMap.has(key);
});
await updatePermissionsMutation.mutateAsync({
resourceType,
resourceId: resourceDbId,
data: {
updated,
removed,
public: managedIsPublic,
publicAccessRoleId: managedIsPublic ? managedPublicRole : undefined,
},
});
if (onUpdatePermissions) {
onUpdatePermissions(managedShares, managedIsPublic, managedPublicRole);
}
showToast({
message: localize('com_ui_permissions_updated_success'),
status: 'success',
});
setIsModalOpen(false);
} catch (error) {
console.error('Error updating permissions:', error);
showToast({
message: localize('com_ui_permissions_failed_update'),
status: 'error',
});
}
};
const handleCancel = () => {
setManagedShares(currentShares);
setManagedIsPublic(currentIsPublic);
setManagedPublicRole(currentPublicRole || config?.defaultViewerRoleId || '');
setIsModalOpen(false);
};
const handleRevokeAll = () => {
setManagedShares([]);
setManagedIsPublic(false);
setHasChanges(true);
};
const handlePublicToggle = (isPublic: boolean) => {
setManagedIsPublic(isPublic);
setHasChanges(true);
if (!isPublic) {
setManagedPublicRole(config?.defaultViewerRoleId);
}
};
const handlePublicRoleChange = (role: string) => {
setManagedPublicRole(role);
setHasChanges(true);
};
const totalShares = managedShares.length + (managedIsPublic ? 1 : 0);
const originalTotalShares = currentShares.length + (currentIsPublic ? 1 : 0);
/** Check if there's at least one owner (user, group, or public with owner role) */
const hasAtLeastOneOwner =
managedShares.some((share) => share.accessRoleId === config?.defaultOwnerRoleId) ||
(managedIsPublic && managedPublicRole === config?.defaultOwnerRoleId);
let peopleLabel = localize('com_ui_people');
if (managedShares.length === 1) {
peopleLabel = localize('com_ui_person');
}
const buttonAriaLabel = config?.getManageMessage(resourceName);
const dialogTitle = config?.getManageMessage(resourceName);
let publicSuffix = '';
if (managedIsPublic) {
publicSuffix = localize('com_ui_and_public');
}
const TriggerComponent = children ? (
children
) : (
<Button variant="outline" aria-label={buttonAriaLabel} type="button">
<Settings className="icon-md h-4 w-4" />
<span className="hidden sm:inline">{localize('com_ui_manage')}</span>
{originalTotalShares > 0 && `( ${originalTotalShares} )`}
</Button>
);
return (
<OGDialog open={isModalOpen} onOpenChange={setIsModalOpen}>
<OGDialogTrigger asChild>{TriggerComponent}</OGDialogTrigger>
<OGDialogContent className="max-h-[90vh] w-11/12 overflow-y-auto md:max-w-3xl">
<OGDialogTitle>
<div className="flex items-center gap-2">
<Shield className="h-5 w-5 text-blue-500" />
{dialogTitle}
</div>
</OGDialogTitle>
<div className="space-y-6 p-2">
<div className="rounded-lg bg-surface-tertiary p-4">
<div className="flex items-center justify-between">
<div>
<h3 className="text-sm font-medium text-text-primary">
{localize('com_ui_current_access')}
</h3>
<p className="text-xs text-text-secondary">
{(() => {
if (totalShares === 0) {
return localize('com_ui_no_users_groups_access');
}
return localize('com_ui_shared_with_count', {
0: managedShares.length,
1: peopleLabel,
2: publicSuffix,
});
})()}
</p>
</div>
{(managedShares.length > 0 || managedIsPublic) && (
<Button
variant="outline"
size="sm"
onClick={handleRevokeAll}
className="text-red-600 hover:text-red-700"
>
<Trash2 className="mr-2 h-4 w-4" />
{localize('com_ui_revoke_all')}
</Button>
)}
</div>
</div>
{(() => {
if (isLoadingPermissions) {
return (
<div className="flex items-center justify-center p-8">
<Loader className="h-6 w-6 animate-spin" />
<span className="ml-2 text-sm text-text-secondary">
{localize('com_ui_loading_permissions')}
</span>
</div>
);
}
if (managedShares.length > 0) {
return (
<div>
<h3 className="mb-3 flex items-center gap-2 text-sm font-medium text-text-primary">
<UserCheck className="h-4 w-4" />
{localize('com_ui_user_group_permissions')} ({managedShares.length})
</h3>
<SelectedPrincipalsList
principles={managedShares}
onRemoveHandler={handleRemoveShare}
availableRoles={accessRoles || []}
onRoleChange={(id, newRole) => handleRoleChange(id, newRole)}
/>
</div>
);
}
return (
<div className="rounded-lg border-2 border-dashed border-border-light p-8 text-center">
<Users className="mx-auto h-8 w-8 text-text-secondary" />
<p className="mt-2 text-sm text-text-secondary">
{localize('com_ui_no_individual_access')}
</p>
</div>
);
})()}
<div>
<h3 className="mb-3 text-sm font-medium text-text-primary">
{localize('com_ui_public_access')}
</h3>
<PublicSharingToggle
isPublic={managedIsPublic}
publicRole={managedPublicRole}
onPublicToggle={handlePublicToggle}
onPublicRoleChange={handlePublicRoleChange}
resourceType={resourceType}
/>
</div>
<div className="flex justify-end gap-3 border-t pt-4">
<OGDialogClose asChild>
<Button variant="outline" onClick={handleCancel}>
{localize('com_ui_cancel')}
</Button>
</OGDialogClose>
<Button
onClick={handleSaveChanges}
disabled={
updatePermissionsMutation.isLoading ||
!hasChanges ||
isLoadingPermissions ||
!hasAtLeastOneOwner
}
className="min-w-[120px]"
>
{updatePermissionsMutation.isLoading ? (
<div className="flex items-center gap-2">
<Loader className="h-4 w-4 animate-spin" />
{localize('com_ui_saving')}
</div>
) : (
localize('com_ui_save_changes')
)}
</Button>
</div>
{hasChanges && (
<div className="text-xs text-orange-600 dark:text-orange-400">
* {localize('com_ui_unsaved_changes')}
</div>
)}
{!hasAtLeastOneOwner && hasChanges && (
<div className="text-xs text-red-600 dark:text-red-400">
* {localize('com_ui_at_least_one_owner_required')}
</div>
)}
</div>
</OGDialogContent>
</OGDialog>
);
}

View file

@ -1,295 +0,0 @@
import React, { useState, useEffect, useMemo } from 'react';
import { Share2Icon, Users, Loader, Shield, Link, CopyCheck } from 'lucide-react';
import { Permissions, ResourceType, PermissionTypes, AccessRoleIds } from 'librechat-data-provider';
import {
useGetResourcePermissionsQuery,
useUpdateResourcePermissionsMutation,
} from 'librechat-data-provider/react-query';
import {
Button,
OGDialog,
OGDialogTitle,
OGDialogClose,
OGDialogContent,
OGDialogTrigger,
useToastContext,
} from '@librechat/client';
import type { TPrincipal } from 'librechat-data-provider';
import { useLocalize, useCopyToClipboard, useHasAccess } from '~/hooks';
import ManagePermissionsDialog from './ManagePermissionsDialog';
import PublicSharingToggle from './PublicSharingToggle';
import AccessRolesPicker from './AccessRolesPicker';
import { cn, removeFocusOutlines } from '~/utils';
import { PeoplePicker } from './PeoplePicker';
export default function GrantAccessDialog({
agentName,
onGrantAccess,
resourceType = ResourceType.AGENT,
agentDbId,
agentId,
}: {
agentDbId?: string | null;
agentId?: string | null;
agentName?: string;
onGrantAccess?: (shares: TPrincipal[], isPublic: boolean, publicRole: AccessRoleIds) => void;
resourceType?: ResourceType;
}) {
const localize = useLocalize();
const { showToast } = useToastContext();
// Check if user has permission to access people picker
const canViewUsers = useHasAccess({
permissionType: PermissionTypes.PEOPLE_PICKER,
permission: Permissions.VIEW_USERS,
});
const canViewGroups = useHasAccess({
permissionType: PermissionTypes.PEOPLE_PICKER,
permission: Permissions.VIEW_GROUPS,
});
const hasPeoplePickerAccess = canViewUsers || canViewGroups;
/** Type filter based on permissions */
const peoplePickerTypeFilter = useMemo(() => {
if (canViewUsers && canViewGroups) {
return null; // Both types allowed
} else if (canViewUsers) {
return 'user' as const;
} else if (canViewGroups) {
return 'group' as const;
}
return null;
}, [canViewUsers, canViewGroups]);
const {
data: permissionsData,
// isLoading: isLoadingPermissions,
// error: permissionsError,
} = useGetResourcePermissionsQuery(resourceType, agentDbId!, {
enabled: !!agentDbId,
});
const updatePermissionsMutation = useUpdateResourcePermissionsMutation();
const [newShares, setNewShares] = useState<TPrincipal[]>([]);
const [defaultPermissionId, setDefaultPermissionId] = useState<string>(
AccessRoleIds.AGENT_VIEWER,
);
const [isModalOpen, setIsModalOpen] = useState(false);
const [isCopying, setIsCopying] = useState(false);
const agentUrl = `${window.location.origin}/c/new?agent_id=${agentId}`;
const copyAgentUrl = useCopyToClipboard({ text: agentUrl });
const currentShares: TPrincipal[] =
permissionsData?.principals?.map((principal) => ({
type: principal.type,
id: principal.id,
name: principal.name,
email: principal.email,
source: principal.source,
avatar: principal.avatar,
description: principal.description,
accessRoleId: principal.accessRoleId,
})) || [];
const currentIsPublic = permissionsData?.public ?? false;
const currentPublicRole = permissionsData?.publicAccessRoleId || AccessRoleIds.AGENT_VIEWER;
const [isPublic, setIsPublic] = useState(false);
const [publicRole, setPublicRole] = useState<AccessRoleIds>(AccessRoleIds.AGENT_VIEWER);
useEffect(() => {
if (permissionsData && isModalOpen) {
setIsPublic(currentIsPublic ?? false);
setPublicRole(currentPublicRole);
}
}, [permissionsData, isModalOpen, currentIsPublic, currentPublicRole]);
if (!agentDbId) {
return null;
}
const handleGrantAccess = async () => {
try {
const sharesToAdd = newShares.map((share) => ({
...share,
accessRoleId: defaultPermissionId,
}));
const allShares = [...currentShares, ...sharesToAdd];
await updatePermissionsMutation.mutateAsync({
resourceType,
resourceId: agentDbId,
data: {
updated: sharesToAdd,
removed: [],
public: isPublic,
publicAccessRoleId: isPublic ? publicRole : undefined,
},
});
if (onGrantAccess) {
onGrantAccess(allShares, isPublic, publicRole);
}
showToast({
message: `Access granted successfully to ${newShares.length} ${newShares.length === 1 ? 'person' : 'people'}${isPublic ? ' and made public' : ''}`,
status: 'success',
});
setNewShares([]);
setDefaultPermissionId(AccessRoleIds.AGENT_VIEWER);
setIsPublic(false);
setPublicRole(AccessRoleIds.AGENT_VIEWER);
setIsModalOpen(false);
} catch (error) {
console.error('Error granting access:', error);
showToast({
message: 'Failed to grant access. Please try again.',
status: 'error',
});
}
};
const handleCancel = () => {
setNewShares([]);
setDefaultPermissionId(AccessRoleIds.AGENT_VIEWER);
setIsPublic(false);
setPublicRole(AccessRoleIds.AGENT_VIEWER);
setIsModalOpen(false);
};
const totalCurrentShares = currentShares.length + (currentIsPublic ? 1 : 0);
const submitButtonActive =
newShares.length > 0 || isPublic !== currentIsPublic || publicRole !== currentPublicRole;
return (
<OGDialog open={isModalOpen} onOpenChange={setIsModalOpen} modal>
<OGDialogTrigger asChild>
<button
className={cn(
'btn btn-neutral border-token-border-light relative h-9 rounded-lg font-medium',
removeFocusOutlines,
)}
aria-label={localize('com_ui_share_var', {
0: agentName != null && agentName !== '' ? `"${agentName}"` : localize('com_ui_agent'),
})}
type="button"
>
<div className="flex items-center justify-center gap-2 text-blue-500">
<Share2Icon className="icon-md h-4 w-4" />
{totalCurrentShares > 0 && (
<span className="rounded-full bg-blue-100 px-1.5 py-0.5 text-xs font-medium text-blue-800 dark:bg-blue-900 dark:text-blue-300">
{totalCurrentShares}
</span>
)}
</div>
</button>
</OGDialogTrigger>
<OGDialogContent className="max-h-[90vh] w-11/12 overflow-y-auto md:max-w-3xl">
<OGDialogTitle>
<div className="flex items-center gap-2">
<Users className="h-5 w-5" />
{localize('com_ui_share_var', {
0:
agentName != null && agentName !== '' ? `"${agentName}"` : localize('com_ui_agent'),
})}
</div>
</OGDialogTitle>
<div className="space-y-6 p-2">
{hasPeoplePickerAccess && (
<>
<PeoplePicker
onSelectionChange={setNewShares}
placeholder={localize('com_ui_search_people_placeholder')}
typeFilter={peoplePickerTypeFilter}
/>
<div className="space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Shield className="h-4 w-4 text-text-secondary" />
<label className="text-sm font-medium text-text-primary">
{localize('com_ui_permission_level')}
</label>
</div>
</div>
<AccessRolesPicker
resourceType={resourceType}
selectedRoleId={defaultPermissionId}
onRoleChange={setDefaultPermissionId}
/>
</div>
</>
)}
<PublicSharingToggle
isPublic={isPublic}
publicRole={publicRole}
onPublicToggle={setIsPublic}
onPublicRoleChange={setPublicRole}
resourceType={resourceType}
/>
<div className="flex justify-between pt-4">
<div className="flex gap-2">
{hasPeoplePickerAccess && (
<ManagePermissionsDialog
agentDbId={agentDbId}
agentName={agentName}
resourceType={resourceType}
/>
)}
{agentId && (
<Button
variant="outline"
size="sm"
onClick={() => {
if (isCopying) return;
copyAgentUrl(setIsCopying);
showToast({
message: localize('com_ui_agent_url_copied'),
status: 'success',
});
}}
disabled={isCopying}
className={cn('shrink-0', isCopying ? 'cursor-default' : '')}
aria-label={localize('com_ui_copy_url_to_clipboard')}
title={
isCopying
? localize('com_ui_agent_url_copied')
: localize('com_ui_copy_url_to_clipboard')
}
>
{isCopying ? <CopyCheck className="h-4 w-4" /> : <Link className="h-4 w-4" />}
</Button>
)}
</div>
<div className="flex gap-3">
<OGDialogClose asChild>
<Button variant="outline" onClick={handleCancel}>
{localize('com_ui_cancel')}
</Button>
</OGDialogClose>
<Button
onClick={handleGrantAccess}
disabled={updatePermissionsMutation.isLoading || !submitButtonActive}
className="min-w-[120px]"
>
{updatePermissionsMutation.isLoading ? (
<div className="flex items-center gap-2">
<Loader className="h-4 w-4 animate-spin" />
{localize('com_ui_granting')}
</div>
) : (
localize('com_ui_grant_access')
)}
</Button>
</div>
</div>
</div>
</OGDialogContent>
</OGDialog>
);
}

View file

@ -1,14 +1,14 @@
import React, { useState, useEffect } from 'react';
import { AccessRoleIds, ResourceType } from 'librechat-data-provider';
import { Settings, Users, Loader, UserCheck, Trash2, Shield } from 'lucide-react';
import { Settings, Users, UserCheck, Trash2, Shield } from 'lucide-react';
import {
useGetAccessRolesQuery,
useGetResourcePermissionsQuery,
useUpdateResourcePermissionsMutation,
} from 'librechat-data-provider/react-query';
import type { TPrincipal } from 'librechat-data-provider';
import {
Button,
Spinner,
OGDialog,
OGDialogTitle,
OGDialogClose,
@ -46,10 +46,6 @@ export default function ManagePermissionsDialog({
} = useGetResourcePermissionsQuery(resourceType, agentDbId, {
enabled: !!agentDbId,
});
const {
data: accessRoles,
// isLoading,
} = useGetAccessRolesQuery(resourceType);
const updatePermissionsMutation = useUpdateResourcePermissionsMutation();
@ -267,7 +263,7 @@ export default function ManagePermissionsDialog({
if (isLoadingPermissions) {
return (
<div className="flex items-center justify-center p-8">
<Loader className="h-6 w-6 animate-spin" />
<Spinner className="h-6 w-6" />
<span className="ml-2 text-sm text-text-secondary">
{localize('com_ui_loading_permissions')}
</span>
@ -285,7 +281,7 @@ export default function ManagePermissionsDialog({
<SelectedPrincipalsList
principles={managedShares}
onRemoveHandler={handleRemoveShare}
availableRoles={accessRoles || []}
resourceType={resourceType}
onRoleChange={(id, newRole) => handleRoleChange(id, newRole)}
/>
</div>
@ -302,17 +298,12 @@ export default function ManagePermissionsDialog({
);
})()}
<div>
<h3 className="mb-3 text-sm font-medium text-text-primary">
{localize('com_ui_public_access')}
</h3>
<PublicSharingToggle
isPublic={managedIsPublic}
publicRole={managedPublicRole}
onPublicToggle={handlePublicToggle}
onPublicRoleChange={handlePublicRoleChange}
/>
</div>
<PublicSharingToggle
isPublic={managedIsPublic}
publicRole={managedPublicRole}
onPublicToggle={handlePublicToggle}
onPublicRoleChange={handlePublicRoleChange}
/>
<div className="flex justify-end gap-3 border-t pt-4">
<OGDialogClose asChild>
@ -332,7 +323,7 @@ export default function ManagePermissionsDialog({
>
{updatePermissionsMutation.isLoading ? (
<div className="flex items-center gap-2">
<Loader className="h-4 w-4 animate-spin" />
<Spinner className="h-4 w-4" />
{localize('com_ui_saving')}
</div>
) : (

View file

@ -1,6 +1,6 @@
import * as React from 'react';
import { Search } from 'lucide-react';
import debounce from 'lodash/debounce';
import { Search, X } from 'lucide-react';
import * as Ariakit from '@ariakit/react';
import { Spinner, Skeleton } from '@librechat/client';
import { useLocalize } from '~/hooks';
@ -78,7 +78,7 @@ export function SearchPicker<TOption extends { key: string; value: string }>({
<div className="py-1.5">
<div
className={cn(
'group relative mt-1 flex h-10 cursor-pointer items-center gap-3 rounded-lg border-border-medium px-3 py-2 text-text-primary transition-colors duration-200 focus-within:bg-surface-hover hover:bg-surface-hover',
'group relative flex h-10 cursor-pointer items-center gap-2 rounded-lg border-border-medium text-text-primary transition-colors duration-200 focus-within:bg-surface-hover hover:bg-surface-hover',
isSmallScreen === true ? 'mb-2 h-14 rounded-2xl' : '',
)}
>
@ -110,7 +110,7 @@ export function SearchPicker<TOption extends { key: string; value: string }>({
value={localQuery}
// autoSelect
placeholder={placeholder || localize('com_ui_select_options')}
className="m-0 mr-0 w-full rounded-md border-none bg-transparent p-0 py-2 pl-9 pr-3 text-sm leading-tight text-text-primary placeholder-text-secondary placeholder-opacity-100 focus:outline-none focus-visible:outline-none group-focus-within:placeholder-text-primary group-hover:placeholder-text-primary"
className="h-10 w-full rounded-lg bg-transparent pl-10 text-sm leading-tight text-text-primary placeholder-text-secondary placeholder-opacity-100 focus:outline-none focus-visible:outline-none group-focus-within:placeholder-text-primary group-hover:placeholder-text-primary"
/>
</div>
</div>

View file

@ -1,17 +1,17 @@
import React, { useState, useId } from 'react';
import * as Ariakit from '@ariakit/react/menu';
import { Button, DropdownPopup } from '@librechat/client';
import { Users, X, ExternalLink, ChevronDown } from 'lucide-react';
import type { TPrincipal, TAccessRole, AccessRoleIds } from 'librechat-data-provider';
import { getRoleLocalizationKeys } from '~/utils';
import React from 'react';
import { Button } from '@librechat/client';
import { Users, X, ExternalLink } from 'lucide-react';
import type { TPrincipal, AccessRoleIds } from 'librechat-data-provider';
import { ResourceType } from 'librechat-data-provider';
import PrincipalAvatar from '../PrincipalAvatar';
import AccessRolesPicker from '../AccessRolesPicker';
import { useLocalize } from '~/hooks';
interface SelectedPrincipalsListProps {
principles: TPrincipal[];
onRemoveHandler: (idOnTheSource: string) => void;
onRoleChange?: (idOnTheSource: string, newRoleId: AccessRoleIds) => void;
availableRoles?: Omit<TAccessRole, 'resourceType'>[];
resourceType?: ResourceType;
className?: string;
}
@ -20,7 +20,7 @@ export default function SelectedPrincipalsList({
onRemoveHandler,
className = '',
onRoleChange,
availableRoles,
resourceType = ResourceType.AGENT,
}: SelectedPrincipalsListProps) {
const localize = useLocalize();
@ -71,12 +71,13 @@ export default function SelectedPrincipalsList({
<div className="flex flex-shrink-0 items-center gap-2">
{!!share.accessRoleId && !!onRoleChange && (
<RoleSelector
currentRole={share.accessRoleId}
<AccessRolesPicker
resourceType={resourceType}
selectedRoleId={share.accessRoleId}
onRoleChange={(newRole) => {
onRoleChange?.(share.idOnTheSource!, newRole);
}}
availableRoles={availableRoles ?? []}
className="min-w-0"
/>
)}
<Button
@ -96,44 +97,3 @@ export default function SelectedPrincipalsList({
</div>
);
}
interface RoleSelectorProps {
currentRole: AccessRoleIds;
onRoleChange: (newRole: AccessRoleIds) => void;
availableRoles: Omit<TAccessRole, 'resourceType'>[];
}
function RoleSelector({ currentRole, onRoleChange, availableRoles }: RoleSelectorProps) {
const menuId = useId();
const [isMenuOpen, setIsMenuOpen] = useState(false);
const localize = useLocalize();
const getLocalizedRoleName = (roleId: AccessRoleIds) => {
const keys = getRoleLocalizationKeys(roleId);
return localize(keys.name);
};
return (
<DropdownPopup
portal={true}
mountByState={true}
unmountOnHide={true}
preserveTabOrder={true}
isOpen={isMenuOpen}
setIsOpen={setIsMenuOpen}
trigger={
<Ariakit.MenuButton className="flex items-center justify-between gap-2 rounded-xl border border-border-light bg-transparent px-3 py-2 text-sm transition-colors hover:bg-surface-tertiary">
<span className="hidden sm:inline">{getLocalizedRoleName(currentRole)}</span>
<ChevronDown className="h-4 w-4 text-text-secondary" />
</Ariakit.MenuButton>
}
items={availableRoles?.map((role) => ({
id: role.accessRoleId,
label: getLocalizedRoleName(role.accessRoleId),
onClick: () => onRoleChange(role.accessRoleId),
}))}
menuId={menuId}
className="z-50 [pointer-events:auto]"
/>
);
}

View file

@ -0,0 +1,83 @@
import React, { useState, useMemo } from 'react';
import type { TPrincipal, PrincipalSearchParams } from 'librechat-data-provider';
import { useSearchPrincipalsQuery } from 'librechat-data-provider/react-query';
import PeoplePickerSearchItem from './PeoplePickerSearchItem';
import { SearchPicker } from './SearchPicker';
import { useLocalize } from '~/hooks';
interface UnifiedPeopleSearchProps {
onAddPeople: (principals: TPrincipal[]) => void;
placeholder?: string;
className?: string;
typeFilter?: 'user' | 'group' | null;
excludeIds?: string[];
}
export default function UnifiedPeopleSearch({
onAddPeople,
placeholder,
className = '',
typeFilter = null,
excludeIds = [],
}: UnifiedPeopleSearchProps) {
const localize = useLocalize();
const [searchQuery, setSearchQuery] = useState('');
const searchParams: PrincipalSearchParams = useMemo(
() => ({
q: searchQuery,
limit: 30,
...(typeFilter && { type: typeFilter }),
}),
[searchQuery, typeFilter],
);
const {
data: searchResponse,
isLoading: queryIsLoading,
error,
} = useSearchPrincipalsQuery(searchParams, {
enabled: searchQuery.length >= 2,
});
const isLoading = searchQuery.length >= 2 && queryIsLoading;
const selectableResults = useMemo(() => {
const results = searchResponse?.results || [];
return results.filter(
(result) => result.idOnTheSource && !excludeIds.includes(result.idOnTheSource),
);
}, [searchResponse?.results, excludeIds]);
if (error) {
console.error('Principal search error:', error);
}
const handlePick = (principal: TPrincipal) => {
// Immediately add the selected person to the unified list
onAddPeople([principal]);
};
return (
<div className={`${className}`}>
<SearchPicker<TPrincipal & { key: string; value: string }>
options={selectableResults.map((s) => ({
...s,
id: s.id ?? undefined,
key: s.idOnTheSource || 'unknown' + 'picker_key',
value: s.idOnTheSource || 'Unknown',
}))}
renderOptions={(o) => <PeoplePickerSearchItem principal={o} />}
placeholder={placeholder || localize('com_ui_search_default_placeholder')}
query={searchQuery}
onQueryChange={(query: string) => {
setSearchQuery(query);
}}
onPick={handlePick}
isLoading={isLoading}
label=""
/>
</div>
);
}

View file

@ -1,3 +1,4 @@
export { default as PeoplePicker } from './PeoplePicker';
export { default as PeoplePickerSearchItem } from './PeoplePickerSearchItem';
export { default as SelectedPrincipalsList } from './SelectedPrincipalsList';
export { default as UnifiedPeopleSearch } from './UnifiedPeopleSearch';

View file

@ -1,8 +1,8 @@
import React from 'react';
import { Switch } from '@librechat/client';
import { Globe, Shield } from 'lucide-react';
import type { AccessRoleIds } from 'librechat-data-provider';
import { ResourceType } from 'librechat-data-provider';
import { Switch, InfoHoverCard, ESide, Label } from '@librechat/client';
import type { AccessRoleIds } from 'librechat-data-provider';
import AccessRolesPicker from './AccessRolesPicker';
import { useLocalize } from '~/hooks';
@ -22,17 +22,16 @@ export default function PublicSharingToggle({
const localize = useLocalize();
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-start gap-3">
<Globe className="mt-0.5 h-5 w-5 text-blue-500" />
<div>
<h4 className="text-sm font-medium text-text-primary">
<div className="space-y-2">
{/* Main toggle section */}
<div className="flex items-center justify-between py-3">
<div className="flex items-center gap-3">
<Globe className="size-5 text-text-secondary" />
<div className="flex items-center gap-2">
<Label className="text-sm font-medium text-text-primary">
{localize('com_ui_public_access')}
</h4>
<p className="text-xs text-text-secondary">
{localize('com_ui_public_access_description')}
</p>
</Label>
<InfoHoverCard side={ESide.Top} text={localize('com_ui_public_access_description')} />
</div>
</div>
<Switch
@ -42,19 +41,22 @@ export default function PublicSharingToggle({
/>
</div>
{/* Permission level section */}
{isPublic && (
<div className="ml-8 space-y-3">
<div className="flex items-center gap-2">
<Shield className="h-4 w-4 text-text-secondary" />
<label className="text-sm font-medium text-text-primary">
{localize('com_ui_public_permission_level')}
</label>
<div className="pt-2 duration-200 animate-in slide-in-from-top-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Shield className="size-5 text-text-secondary" />
<Label className="text-sm font-medium text-text-primary">
{localize('com_ui_public_permission_level')}
</Label>
</div>
<AccessRolesPicker
resourceType={resourceType}
selectedRoleId={publicRole}
onRoleChange={onPublicRoleChange}
/>
</div>
<AccessRolesPicker
resourceType={resourceType}
selectedRoleId={publicRole}
onRoleChange={onPublicRoleChange}
/>
</div>
)}
</div>

View file

@ -1,6 +1,5 @@
export { default as AccessRolesPicker } from './AccessRolesPicker';
export { default as GenericGrantAccessDialog } from './GenericGrantAccessDialog';
export { default as GenericManagePermissionsDialog } from './GenericManagePermissionsDialog';
export { default as ManagePermissionsDialog } from './ManagePermissionsDialog';
export { default as PrincipalAvatar } from './PrincipalAvatar';
export { default as PublicSharingToggle } from './PublicSharingToggle';

View file

@ -135,7 +135,7 @@ export default function AgentPanel() {
} else {
showToast({
message: localize('com_ui_agent_version_duplicate', { versionIndex: versionIndex + 1 }),
status: 'error',
status: 'warning',
duration: 10000,
});
}

View file

@ -697,6 +697,10 @@
"com_ui_user": "User",
"com_ui_group": "Group",
"com_ui_search_above_to_add": "Search above to add users or groups",
"com_ui_search_add_people": "Search & Add People",
"com_ui_default_permission_level": "Default Permission Level",
"com_ui_manage_access": "Manage Access",
"com_ui_search_above_to_add_people": "Search above to add people",
"com_ui_azure_ad": "Entra ID",
"com_ui_remove_user": "Remove {{0}}",
"com_ui_create": "Create",

View file

@ -34,6 +34,8 @@
"../e2e/**/*",
"test/setupTests.js",
"env.d.ts",
"../config/translations/**/*.ts"
, "../packages/client/src/hooks/useDelayedRender.tsx" ]
"../config/translations/**/*.ts",
"../packages/client/src/hooks/useDelayedRender.tsx",
"../packages/client/src/components/InfoHoverCard.tsx"
]
}