| import React, { useState, useEffect } from 'react'; |
| import { AccessRoleIds, ResourceType } from 'librechat-data-provider'; |
| import { Share2Icon, Users, Link, CopyCheck, UserX, UserCheck } from 'lucide-react'; |
| import { |
| Label, |
| Button, |
| Spinner, |
| Skeleton, |
| OGDialog, |
| OGDialogTitle, |
| OGDialogClose, |
| OGDialogContent, |
| OGDialogTrigger, |
| useToastContext, |
| } from '@librechat/client'; |
| import type { TPrincipal } from 'librechat-data-provider'; |
| import { |
| usePeoplePickerPermissions, |
| useResourcePermissionState, |
| useCopyToClipboard, |
| useLocalize, |
| } from '~/hooks'; |
| import UnifiedPeopleSearch from './PeoplePicker/UnifiedPeopleSearch'; |
| import PeoplePickerAdminSettings from './PeoplePickerAdminSettings'; |
| import PublicSharingToggle from './PublicSharingToggle'; |
| import { SelectedPrincipalsList } from './PeoplePicker'; |
| import { cn } from '~/utils'; |
|
|
| export default function GenericGrantAccessDialog({ |
| resourceName, |
| resourceDbId, |
| resourceId, |
| resourceType, |
| onGrantAccess, |
| disabled = false, |
| children, |
| }: { |
| resourceDbId?: string | null; |
| resourceId?: string | null; |
| resourceName?: string; |
| resourceType: ResourceType; |
| onGrantAccess?: (shares: TPrincipal[], isPublic: boolean, publicRole?: AccessRoleIds) => void; |
| disabled?: boolean; |
| children?: React.ReactNode; |
| }) { |
| const localize = useLocalize(); |
| const { showToast } = useToastContext(); |
| const [isModalOpen, setIsModalOpen] = useState(false); |
| const [isCopying, setIsCopying] = useState(false); |
|
|
| |
| const { hasPeoplePickerAccess, peoplePickerTypeFilter } = usePeoplePickerPermissions(); |
| const { |
| config, |
| permissionsData, |
| isLoadingPermissions, |
| permissionsError, |
| updatePermissionsMutation, |
| currentShares, |
| currentIsPublic, |
| currentPublicRole, |
| isPublic, |
| setIsPublic, |
| publicRole, |
| setPublicRole, |
| } = useResourcePermissionState(resourceType, resourceDbId, isModalOpen); |
|
|
| |
| const [allShares, setAllShares] = useState<TPrincipal[]>([]); |
| const [hasChanges, setHasChanges] = useState(false); |
| const [defaultPermissionId, setDefaultPermissionId] = useState<AccessRoleIds | undefined>( |
| config?.defaultViewerRoleId, |
| ); |
|
|
| |
| 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 }); |
|
|
| if (!resourceDbId) { |
| return null; |
| } |
|
|
| if (!config) { |
| console.error(`Unsupported resource type: ${resourceType}`); |
| return null; |
| } |
|
|
| |
| const handleAddFromSearch = (newShares: TPrincipal[]) => { |
| const sharesToAdd = newShares.filter( |
| (newShare) => |
| !allShares.some((existing) => existing.idOnTheSource === newShare.idOnTheSource), |
| ); |
|
|
| const sharesWithDefaults = sharesToAdd.map((share) => ({ |
| ...share, |
| accessRoleId: defaultPermissionId || config?.defaultViewerRoleId, |
| isExisting: false, |
| })); |
|
|
| setAllShares((prev) => [...prev, ...sharesWithDefaults]); |
| setHasChanges(true); |
| }; |
|
|
| |
| const handleRemoveShare = (idOnTheSource: string) => { |
| setAllShares(allShares.filter((s) => s.idOnTheSource !== idOnTheSource)); |
| setHasChanges(true); |
| }; |
|
|
| |
| const handleRoleChange = (idOnTheSource: string, newRole: string) => { |
| setAllShares( |
| allShares.map((s) => |
| s.idOnTheSource === idOnTheSource ? { ...s, accessRoleId: newRole as AccessRoleIds } : s, |
| ), |
| ); |
| setHasChanges(true); |
| }; |
|
|
| |
| const handlePublicToggle = (isPublicValue: boolean) => { |
| setIsPublic(isPublicValue); |
| setHasChanges(true); |
| if (!isPublicValue) { |
| setPublicRole(config?.defaultViewerRoleId); |
| } |
| }; |
|
|
| |
| const handlePublicRoleChange = (role: string) => { |
| setPublicRole(role as AccessRoleIds); |
| setHasChanges(true); |
| }; |
|
|
| |
| const handleSave = async () => { |
| if (!allShares.length && !isPublic && !hasChanges) { |
| return; |
| } |
|
|
| try { |
| |
| const originalSharesMap = new Map( |
| currentShares.map((share) => [`${share.type}-${share.idOnTheSource}`, share]), |
| ); |
| const allSharesMap = new Map( |
| allShares.map((share) => [`${share.type}-${share.idOnTheSource}`, share]), |
| ); |
|
|
| |
| const updated = allShares.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 !allSharesMap.has(key); |
| }); |
|
|
| await updatePermissionsMutation.mutateAsync({ |
| resourceType, |
| resourceId: resourceDbId, |
| data: { |
| updated, |
| removed, |
| public: isPublic, |
| publicAccessRoleId: isPublic ? publicRole : undefined, |
| }, |
| }); |
|
|
| if (onGrantAccess) { |
| onGrantAccess(allShares, isPublic, publicRole); |
| } |
|
|
| showToast({ |
| message: localize('com_ui_permissions_updated_success'), |
| status: 'success', |
| }); |
|
|
| setHasChanges(false); |
| } catch (error) { |
| console.error('Error updating permissions:', error); |
| showToast({ |
| message: localize('com_ui_permissions_failed_update'), |
| status: 'error', |
| }); |
| } |
| }; |
|
|
| const handleCancel = () => { |
| |
| const shares = permissionsData?.principals || []; |
| setAllShares(shares.map((share) => ({ ...share, isExisting: true }))); |
| setDefaultPermissionId(config?.defaultViewerRoleId); |
| setIsPublic(currentIsPublic); |
| setPublicRole(currentPublicRole || config?.defaultViewerRoleId || ''); |
| setHasChanges(false); |
| setIsModalOpen(false); |
| }; |
|
|
| |
| const totalCurrentShares = currentShares.length + (currentIsPublic ? 1 : 0); |
|
|
| |
| const hasAtLeastOneOwner = |
| allShares.some((share) => share.accessRoleId === config?.defaultOwnerRoleId) || |
| (isPublic && publicRole === config?.defaultOwnerRoleId); |
|
|
| |
| const hasPublicChanges = isPublic !== currentIsPublic || publicRole !== currentPublicRole; |
| const submitButtonActive = hasChanges || hasPublicChanges; |
|
|
| |
| if (permissionsError) { |
| return <div className="text-sm text-red-600">{localize('com_ui_permissions_failed_load')}</div>; |
| } |
|
|
| const TriggerComponent = children ? ( |
| children |
| ) : ( |
| <Button |
| size="sm" |
| variant="outline" |
| aria-label={localize('com_ui_share_var', { |
| 0: config?.getShareMessage(resourceName), |
| })} |
| type="button" |
| disabled={disabled} |
| > |
| <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 && ( |
| <Label className="text-sm font-medium text-text-secondary">{totalCurrentShares}</Label> |
| )} |
| </div> |
| </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"> |
| <Users className="h-5 w-5" /> |
| {localize('com_ui_share_var', { |
| 0: config?.getShareMessage(resourceName), |
| })} |
| </div> |
| </OGDialogTitle> |
| |
| <div className="space-y-6 p-2"> |
| {/* 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> |
| |
| <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={handlePublicToggle} |
| onPublicRoleChange={handlePublicRoleChange} |
| resourceType={resourceType} |
| /> |
| |
| {/* Footer Actions */} |
| <div className="flex justify-between pt-4"> |
| <div className="flex gap-2"> |
| {resourceId && resourceUrl && ( |
| <Button |
| variant="outline" |
| onClick={() => { |
| if (isCopying) return; |
| copyResourceUrl(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 |
| ? config?.getCopyUrlMessage() |
| : 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-2"> |
| <PeoplePickerAdminSettings /> |
| <OGDialogClose asChild> |
| <Button |
| variant="outline" |
| onClick={handleCancel} |
| aria-label={localize('com_ui_cancel')} |
| > |
| {localize('com_ui_cancel')} |
| </Button> |
| </OGDialogClose> |
| <Button |
| onClick={handleSave} |
| disabled={ |
| updatePermissionsMutation.isLoading || |
| !submitButtonActive || |
| (hasChanges && !hasAtLeastOneOwner) |
| } |
| className="min-w-[120px]" |
| aria-label={localize('com_ui_save_changes')} |
| > |
| {updatePermissionsMutation.isLoading ? ( |
| <div className="flex items-center gap-2"> |
| <Spinner className="h-4 w-4" /> |
| {localize('com_ui_saving')} |
| </div> |
| ) : ( |
| localize('com_ui_save_changes') |
| )} |
| </Button> |
| </div> |
| </div> |
| </div> |
| </OGDialogContent> |
| </OGDialog> |
| ); |
| } |
|
|