mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-01-19 00:36:12 +01:00
🖼️ style: Improve Marketplace & Sharing Dialog UI
feat: Enhance CategoryTabs and Marketplace components for better responsiveness and navigation feat: Refactor AgentCard and AgentGrid components for improved layout and accessibility feat: Implement animated category transitions in AgentMarketplace and update NewChat component layout feat: Refactor UI components for improved styling and accessibility in sharing dialogs 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. feat: Add responsive design for SelectedPrincipalsList and improve layout in GenericGrantAccessDialog feat: Enhance styling in SelectedPrincipalsList and SearchPicker components for improved UI consistency feat: Improve PublicSharingToggle component with enhanced styling and accessibility features feat: Introduce InfoHoverCard component and refactor enums for better organization feat: Implement infinite scroll for agent grids and enhance performance - Added `useInfiniteScroll` hook to manage infinite scrolling behavior in agent grids. - Integrated infinite scroll functionality into `AgentGrid` and `VirtualizedAgentGrid` components. - Updated `AgentMarketplace` to pass the scroll container to the agent grid components. - Refactored loading indicators to show a spinner instead of a "Load More" button. - Created `VirtualizedAgentGrid` component for optimized rendering of agent cards using virtualization. - Added performance tests for `VirtualizedAgentGrid` to ensure efficient handling of large datasets. - Updated translations to include new messages for end-of-results scenarios. chore: Remove unused permission-related UI localization keys ci: Update Agent model tests to handle duplicate support_contact updates - Modified tests to ensure that updating an agent with the same support_contact does not create a new version and returns successfully. - Enhanced verification for partial changes in support_contact, confirming no new version is created when content remains the same. chore: Address ESLint, clean up unused imports and improve prop definitions in various components ci: fix tests ci: update tests chore: remove unused search localization keys
This commit is contained in:
parent
9585db14ba
commit
d82a63642d
51 changed files with 2074 additions and 1311 deletions
|
|
@ -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';
|
||||
|
|
@ -10,6 +10,7 @@ import { cn, getRoleLocalizationKeys } from '~/utils';
|
|||
import { useLocalize } from '~/hooks';
|
||||
|
||||
interface AccessRolesPickerProps {
|
||||
id?: string;
|
||||
resourceType?: ResourceType;
|
||||
selectedRoleId?: AccessRoleIds;
|
||||
onRoleChange: (roleId: AccessRoleIds) => void;
|
||||
|
|
@ -17,6 +18,7 @@ interface AccessRolesPickerProps {
|
|||
}
|
||||
|
||||
export default function AccessRolesPicker({
|
||||
id,
|
||||
resourceType = ResourceType.AGENT,
|
||||
selectedRoleId = AccessRoleIds.AGENT_VIEWER,
|
||||
onRoleChange,
|
||||
|
|
@ -39,14 +41,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) => {
|
||||
|
|
@ -70,7 +65,7 @@ export default function AccessRolesPicker({
|
|||
});
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className={className} id={id}>
|
||||
<DropdownPopup
|
||||
menuId="access-roles-menu"
|
||||
isOpen={isOpen}
|
||||
|
|
@ -79,8 +74,7 @@ export default function AccessRolesPicker({
|
|||
<Ariakit.MenuButton
|
||||
aria-label={selectedRoleInfo?.description || 'Select role'}
|
||||
className={cn(
|
||||
'flex items-center justify-between gap-2 rounded-lg border border-border-light bg-surface-primary px-3 py-2 text-sm transition-colors hover:bg-surface-hover focus:outline-none focus:ring-2 focus:ring-ring-primary',
|
||||
'min-w-[200px]',
|
||||
'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="font-medium">
|
||||
|
|
|
|||
|
|
@ -1,8 +1,11 @@
|
|||
import React, { useState } from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { AccessRoleIds, ResourceType } from 'librechat-data-provider';
|
||||
import { Share2Icon, Users, Loader, Shield, Link, CopyCheck } from 'lucide-react';
|
||||
import { Share2Icon, Users, Link, CopyCheck, UserX, UserCheck } from 'lucide-react';
|
||||
import {
|
||||
Label,
|
||||
Button,
|
||||
Spinner,
|
||||
Skeleton,
|
||||
OGDialog,
|
||||
OGDialogTitle,
|
||||
OGDialogClose,
|
||||
|
|
@ -17,11 +20,10 @@ import {
|
|||
useCopyToClipboard,
|
||||
useLocalize,
|
||||
} from '~/hooks';
|
||||
import GenericManagePermissionsDialog from './GenericManagePermissionsDialog';
|
||||
import UnifiedPeopleSearch from './PeoplePicker/UnifiedPeopleSearch';
|
||||
import PublicSharingToggle from './PublicSharingToggle';
|
||||
import AccessRolesPicker from './AccessRolesPicker';
|
||||
import { cn, removeFocusOutlines } from '~/utils';
|
||||
import { PeoplePicker } from './PeoplePicker';
|
||||
import { SelectedPrincipalsList } from './PeoplePicker';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
export default function GenericGrantAccessDialog({
|
||||
resourceName,
|
||||
|
|
@ -49,6 +51,9 @@ export default function GenericGrantAccessDialog({
|
|||
const { hasPeoplePickerAccess, peoplePickerTypeFilter } = usePeoplePickerPermissions();
|
||||
const {
|
||||
config,
|
||||
permissionsData,
|
||||
isLoadingPermissions,
|
||||
permissionsError,
|
||||
updatePermissionsMutation,
|
||||
currentShares,
|
||||
currentIsPublic,
|
||||
|
|
@ -59,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 });
|
||||
|
||||
|
|
@ -76,21 +92,88 @@ 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 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,
|
||||
},
|
||||
|
|
@ -101,65 +184,74 @@ 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);
|
||||
setIsModalOpen(false);
|
||||
setHasChanges(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
|
||||
) : (
|
||||
<button
|
||||
className={cn(
|
||||
'btn btn-neutral border-token-border-light relative h-9 rounded-lg font-medium',
|
||||
removeFocusOutlines,
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
aria-label={localize('com_ui_share_var', {
|
||||
0: config?.getShareMessage(resourceName),
|
||||
})}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
>
|
||||
<div className="flex items-center justify-center gap-2 text-blue-500">
|
||||
<Share2Icon className="icon-md h-4 w-4" />
|
||||
<div className="flex min-w-[32px] items-center justify-center gap-2 text-blue-500">
|
||||
<span className="flex h-6 w-6 items-center justify-center">
|
||||
<Share2Icon className="icon-md h-4 w-4" />
|
||||
</span>
|
||||
{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>
|
||||
<Label className="text-sm font-medium text-text-secondary">{totalCurrentShares}</Label>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
</Button>
|
||||
);
|
||||
|
||||
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">
|
||||
|
|
@ -171,51 +263,88 @@ 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="mb-2 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>
|
||||
|
||||
<div className="flex border-t border-border-light" />
|
||||
|
||||
{/* Public Access Section */}
|
||||
<PublicSharingToggle
|
||||
isPublic={isPublic}
|
||||
publicRole={publicRole}
|
||||
onPublicToggle={setIsPublic}
|
||||
onPublicRoleChange={setPublicRole}
|
||||
onPublicToggle={handlePublicToggle}
|
||||
onPublicRoleChange={handlePublicRoleChange}
|
||||
resourceType={resourceType}
|
||||
/>
|
||||
<div className="flex justify-between border-t pt-4">
|
||||
|
||||
{/* 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"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (isCopying) return;
|
||||
copyResourceUrl(setIsCopying);
|
||||
|
|
@ -244,17 +373,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">
|
||||
<Loader className="h-4 w-4 animate-spin" />
|
||||
{localize('com_ui_granting')}
|
||||
<Spinner className="h-4 w-4" />
|
||||
{localize('com_ui_saving')}
|
||||
</div>
|
||||
) : (
|
||||
localize('com_ui_grant_access')
|
||||
localize('com_ui_save_changes')
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,349 +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 { cn, removeFocusOutlines } from '~/utils';
|
||||
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: AccessRoleIds) => {
|
||||
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: AccessRoleIds) => {
|
||||
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
|
||||
className={cn(
|
||||
'btn btn-neutral border-token-border-light relative h-9 rounded-lg font-medium',
|
||||
removeFocusOutlines,
|
||||
)}
|
||||
aria-label={buttonAriaLabel}
|
||||
type="button"
|
||||
>
|
||||
<div className="flex items-center justify-center gap-2 text-blue-500">
|
||||
<Settings className="icon-md h-4 w-4" />
|
||||
<span className="hidden sm:inline">{localize('com_ui_manage')}</span>
|
||||
{originalTotalShares > 0 && `(${originalTotalShares})`}
|
||||
</div>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,273 +0,0 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import { ResourceType, AccessRoleIds } from 'librechat-data-provider';
|
||||
import { Share2Icon, Users, Loader, Shield, Link, CopyCheck } from 'lucide-react';
|
||||
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, usePeoplePickerPermissions } 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();
|
||||
const { hasPeoplePickerAccess, peoplePickerTypeFilter } = usePeoplePickerPermissions();
|
||||
|
||||
const {
|
||||
data: permissionsData,
|
||||
// isLoading: isLoadingPermissions,
|
||||
// error: permissionsError,
|
||||
} = useGetResourcePermissionsQuery(resourceType, agentDbId!, {
|
||||
enabled: !!agentDbId,
|
||||
});
|
||||
|
||||
const updatePermissionsMutation = useUpdateResourcePermissionsMutation();
|
||||
|
||||
const [newShares, setNewShares] = useState<TPrincipal[]>([]);
|
||||
const [defaultPermissionId, setDefaultPermissionId] = useState<AccessRoleIds>(
|
||||
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 border-t 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -2,10 +2,10 @@ import React, { useState, useMemo } from 'react';
|
|||
import { PrincipalType } from 'librechat-data-provider';
|
||||
import type { TPrincipal, PrincipalSearchParams } from 'librechat-data-provider';
|
||||
import { useSearchPrincipalsQuery } from 'librechat-data-provider/react-query';
|
||||
import { useLocalize, usePeoplePickerPermissions } from '~/hooks';
|
||||
import PeoplePickerSearchItem from './PeoplePickerSearchItem';
|
||||
import SelectedPrincipalsList from './SelectedPrincipalsList';
|
||||
import { SearchPicker } from './SearchPicker';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
interface PeoplePickerProps {
|
||||
onSelectionChange: (principals: TPrincipal[]) => void;
|
||||
|
|
@ -21,7 +21,6 @@ export default function PeoplePicker({
|
|||
typeFilter = null,
|
||||
}: PeoplePickerProps) {
|
||||
const localize = useLocalize();
|
||||
const { canViewUsers, canViewGroups, canViewRoles } = usePeoplePickerPermissions();
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [selectedShares, setSelectedShares] = useState<TPrincipal[]>([]);
|
||||
|
||||
|
|
@ -56,28 +55,6 @@ export default function PeoplePicker({
|
|||
console.error('Principal search error:', error);
|
||||
}
|
||||
|
||||
/** Get appropriate label based on permissions */
|
||||
const getSearchLabel = () => {
|
||||
const permissions = [canViewUsers, canViewGroups, canViewRoles];
|
||||
const permissionCount = permissions.filter(Boolean).length;
|
||||
|
||||
if (permissionCount === 3) {
|
||||
return localize('com_ui_search_users_groups_roles');
|
||||
} else if (permissionCount === 2) {
|
||||
if (canViewUsers && canViewGroups) {
|
||||
return localize('com_ui_search_users_groups');
|
||||
}
|
||||
} else if (canViewUsers) {
|
||||
return localize('com_ui_search_users');
|
||||
} else if (canViewGroups) {
|
||||
return localize('com_ui_search_groups');
|
||||
} else if (canViewRoles) {
|
||||
return localize('com_ui_search_roles');
|
||||
}
|
||||
|
||||
return localize('com_ui_search_users_groups');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`space-y-3 ${className}`}>
|
||||
<div className="relative">
|
||||
|
|
@ -107,7 +84,6 @@ export default function PeoplePicker({
|
|||
});
|
||||
setSearchQuery('');
|
||||
}}
|
||||
label={getSearchLabel()}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
@ -14,7 +14,7 @@ type SearchPickerProps<TOption extends { key: string }> = {
|
|||
onPick: (pickedOption: TOption) => void;
|
||||
placeholder?: string;
|
||||
inputClassName?: string;
|
||||
label: string;
|
||||
label?: string;
|
||||
resetValueOnHide?: boolean;
|
||||
isSmallScreen?: boolean;
|
||||
isLoading?: boolean;
|
||||
|
|
@ -69,29 +69,21 @@ export function SearchPicker<TOption extends { key: string; value: string }>({
|
|||
inputRef.current.focus();
|
||||
}
|
||||
};
|
||||
const showClearIcon = localQuery.trim().length > 0;
|
||||
const clearText = () => {
|
||||
setLocalQuery('');
|
||||
onQueryChange('');
|
||||
debouncedOnQueryChange.cancel();
|
||||
if (inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Ariakit.ComboboxProvider store={combobox}>
|
||||
<Ariakit.ComboboxLabel className="text-token-text-primary mb-2 block font-medium">
|
||||
<Ariakit.ComboboxLabel className="mb-2 block font-medium text-text-primary">
|
||||
{label}
|
||||
</Ariakit.ComboboxLabel>
|
||||
<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' : '',
|
||||
)}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Spinner className="absolute left-3 h-4 w-4 text-text-primary" />
|
||||
<Spinner className="absolute left-3 h-4 w-4" />
|
||||
) : (
|
||||
<Search className="absolute left-3 h-4 w-4 text-text-secondary group-focus-within:text-text-primary group-hover:text-text-primary" />
|
||||
)}
|
||||
|
|
@ -118,28 +110,14 @@ 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"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`${localize('com_ui_clear')} ${localize('com_ui_search')}`}
|
||||
className={cn(
|
||||
'absolute right-[7px] flex h-5 w-5 items-center justify-center rounded-full border-none bg-transparent p-0 transition-opacity duration-200',
|
||||
showClearIcon ? 'opacity-100' : 'opacity-0',
|
||||
isSmallScreen === true ? 'right-[16px]' : '',
|
||||
)}
|
||||
onClick={clearText}
|
||||
tabIndex={showClearIcon ? 0 : -1}
|
||||
disabled={!showClearIcon}
|
||||
>
|
||||
<X className="h-5 w-5 cursor-pointer" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
<Ariakit.ComboboxPopover
|
||||
portal={false} //todo fix focus when set to true
|
||||
gutter={10}
|
||||
// sameWidth
|
||||
gutter={8}
|
||||
sameWidth
|
||||
open={
|
||||
isLoading ||
|
||||
options.length > 0 ||
|
||||
|
|
@ -150,7 +128,7 @@ export function SearchPicker<TOption extends { key: string; value: string }>({
|
|||
autoFocusOnShow={false}
|
||||
modal={false}
|
||||
className={cn(
|
||||
'animate-popover z-[9999] min-w-64 overflow-hidden rounded-xl border border-border-light bg-surface-secondary shadow-lg',
|
||||
'animate-popover z-[9999] min-w-64 overflow-hidden rounded-2xl border border-border-light bg-surface-secondary shadow-lg',
|
||||
'[pointer-events:auto]', // Override body's pointer-events:none when in modal
|
||||
)}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -1,17 +1,17 @@
|
|||
import React, { useState, useId } from 'react';
|
||||
import * as Menu 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 React from 'react';
|
||||
import { Button, useMediaQuery } from '@librechat/client';
|
||||
import { Users, X, ExternalLink } from 'lucide-react';
|
||||
import { ResourceType } from 'librechat-data-provider';
|
||||
import type { TPrincipal, AccessRoleIds } from 'librechat-data-provider';
|
||||
import AccessRolesPicker from '~/components/Sharing/AccessRolesPicker';
|
||||
import PrincipalAvatar from '~/components/Sharing/PrincipalAvatar';
|
||||
import { getRoleLocalizationKeys } from '~/utils';
|
||||
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,13 +20,16 @@ export default function SelectedPrincipalsList({
|
|||
onRemoveHandler,
|
||||
className = '',
|
||||
onRoleChange,
|
||||
availableRoles,
|
||||
resourceType = ResourceType.AGENT,
|
||||
}: SelectedPrincipalsListProps) {
|
||||
const localize = useLocalize();
|
||||
const isMobile = useMediaQuery('(max-width: 768px)');
|
||||
|
||||
const getPrincipalDisplayInfo = (principal: TPrincipal) => {
|
||||
const displayName = principal.name || localize('com_ui_unknown');
|
||||
const subtitle = principal.email || `${principal.type} (${principal.source || 'local'})`;
|
||||
const subtitle = isMobile
|
||||
? `${principal.type} (${principal.source || 'local'})`
|
||||
: principal.email || `${principal.type} (${principal.source || 'local'})`;
|
||||
|
||||
return { displayName, subtitle };
|
||||
};
|
||||
|
|
@ -34,7 +37,7 @@ export default function SelectedPrincipalsList({
|
|||
if (principles.length === 0) {
|
||||
return (
|
||||
<div className={`space-y-3 ${className}`}>
|
||||
<div className="rounded-lg border border-dashed border-border py-8 text-center text-muted-foreground">
|
||||
<div className="rounded-lg border border-dashed border-border-medium py-8 text-center text-muted-foreground">
|
||||
<Users className="mx-auto mb-2 h-8 w-8 opacity-50" />
|
||||
<p className="mt-1 text-xs">{localize('com_ui_search_above_to_add_all')}</p>
|
||||
</div>
|
||||
|
|
@ -50,7 +53,7 @@ export default function SelectedPrincipalsList({
|
|||
return (
|
||||
<div
|
||||
key={share.idOnTheSource + '-principalList'}
|
||||
className="bg-surface flex items-center justify-between rounded-lg border border-border p-3"
|
||||
className="bg-surface flex items-center justify-between rounded-2xl border border-border p-3"
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-3">
|
||||
<PrincipalAvatar principal={share} size="md" />
|
||||
|
|
@ -71,19 +74,19 @@ 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
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => onRemoveHandler(share.idOnTheSource!)}
|
||||
className="h-8 w-8 p-0 hover:bg-destructive/10 hover:text-destructive"
|
||||
className="h-9 w-9 p-0 hover:border-destructive/10 hover:bg-destructive/10 hover:text-destructive"
|
||||
aria-label={localize('com_ui_remove_user', { 0: displayName })}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
|
|
@ -96,44 +99,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={
|
||||
<Menu.MenuButton className="flex h-8 items-center gap-2 rounded-md border border-border-medium bg-surface-secondary px-2 py-1 text-sm font-medium transition-colors duration-200 hover:bg-surface-tertiary">
|
||||
<span className="hidden sm:inline">{getLocalizedRoleName(currentRole)}</span>
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</Menu.MenuButton>
|
||||
}
|
||||
items={availableRoles?.map((role) => ({
|
||||
id: role.accessRoleId,
|
||||
label: getLocalizedRoleName(role.accessRoleId),
|
||||
onClick: () => onRoleChange(role.accessRoleId),
|
||||
}))}
|
||||
menuId={menuId}
|
||||
className="z-50 [pointer-events:auto]"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,83 @@
|
|||
import React, { useState, useMemo } from 'react';
|
||||
import type { TPrincipal, PrincipalType, 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?: PrincipalType.USER | PrincipalType.GROUP | PrincipalType.ROLE | null;
|
||||
excludeIds?: (string | undefined)[];
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -1,10 +1,20 @@
|
|||
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';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
interface PublicSharingToggleProps {
|
||||
isPublic: boolean;
|
||||
publicRole?: AccessRoleIds;
|
||||
onPublicToggle: (isPublic: boolean) => void;
|
||||
onPublicRoleChange: (role: AccessRoleIds) => void;
|
||||
resourceType?: ResourceType;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function PublicSharingToggle({
|
||||
isPublic,
|
||||
|
|
@ -12,51 +22,98 @@ export default function PublicSharingToggle({
|
|||
onPublicToggle,
|
||||
onPublicRoleChange,
|
||||
resourceType = ResourceType.AGENT,
|
||||
}: {
|
||||
isPublic: boolean;
|
||||
publicRole?: AccessRoleIds;
|
||||
onPublicToggle: (isPublic: boolean) => void;
|
||||
onPublicRoleChange: (role: AccessRoleIds) => void;
|
||||
resourceType?: ResourceType;
|
||||
}) {
|
||||
className,
|
||||
}: PublicSharingToggleProps) {
|
||||
const localize = useLocalize();
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between rounded-lg border p-4">
|
||||
<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">
|
||||
{localize('com_ui_public_access')}
|
||||
</h4>
|
||||
<p className="text-xs text-text-secondary">
|
||||
{localize('com_ui_public_access_description')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
checked={isPublic}
|
||||
onCheckedChange={onPublicToggle}
|
||||
aria-labelledby="public-access-toggle"
|
||||
/>
|
||||
</div>
|
||||
const handleToggle = React.useCallback(
|
||||
(checked: boolean) => {
|
||||
onPublicToggle(checked);
|
||||
},
|
||||
[onPublicToggle],
|
||||
);
|
||||
|
||||
{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>
|
||||
return (
|
||||
<div className={cn('space-y-4', className)}>
|
||||
{/* Main toggle section */}
|
||||
<div className="group relative rounded-lg">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className={cn(
|
||||
'transition-colors duration-200',
|
||||
isPublic ? 'text-blue-600 dark:text-blue-500' : 'text-text-secondary',
|
||||
)}
|
||||
>
|
||||
<Globe className="size-5" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Label
|
||||
htmlFor="public-access-toggle"
|
||||
className="cursor-pointer text-sm font-medium text-text-primary"
|
||||
>
|
||||
{localize('com_ui_public_access')}
|
||||
</Label>
|
||||
<InfoHoverCard side={ESide.Top} text={localize('com_ui_public_access_description')} />
|
||||
</div>
|
||||
</div>
|
||||
<AccessRolesPicker
|
||||
resourceType={resourceType}
|
||||
selectedRoleId={publicRole}
|
||||
onRoleChange={onPublicRoleChange}
|
||||
<Switch
|
||||
id="public-access-toggle"
|
||||
checked={isPublic}
|
||||
onCheckedChange={handleToggle}
|
||||
aria-label={localize('com_ui_public_access')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Permission level section with smooth animation */}
|
||||
<div
|
||||
className={cn(
|
||||
'transition-all duration-300 ease-in-out',
|
||||
isPublic ? 'max-h-32 opacity-100' : 'max-h-0 opacity-0',
|
||||
)}
|
||||
style={{ overflow: isPublic ? 'visible' : 'hidden' }}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-lg transition-all duration-300',
|
||||
isPublic ? 'bg-surface-secondary/50 translate-y-0' : '-translate-y-2',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className={cn(
|
||||
'transition-all duration-300',
|
||||
isPublic
|
||||
? 'scale-100 text-blue-600 dark:text-blue-500'
|
||||
: 'scale-95 text-text-secondary',
|
||||
)}
|
||||
>
|
||||
<Shield className="size-5" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<Label htmlFor="permission-level" className="text-sm font-medium text-text-primary">
|
||||
{localize('com_ui_public_permission_level')}
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'relative z-50 transition-all duration-300',
|
||||
isPublic ? 'scale-100 opacity-100' : 'scale-95 opacity-0',
|
||||
)}
|
||||
>
|
||||
<AccessRolesPicker
|
||||
id="permission-level"
|
||||
resourceType={resourceType}
|
||||
selectedRoleId={publicRole}
|
||||
onRoleChange={onPublicRoleChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue