mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-22 19:30:15 +01:00
🗨️ feat: Granular Prompt Permissions via ACL and Permission Bits
feat: Implement prompt permissions management and access control middleware fix: agent deletion process to remove associated permissions and ACL entries fix: Import Permissions for enhanced access control in GrantAccessDialog feat: use PromptGroup for access control - Added migration script for PromptGroup permissions, categorizing groups into global view access and private groups. - Created unit tests for the migration script to ensure correct categorization and permission granting. - Introduced middleware for checking access permissions on PromptGroups and prompts via their groups. - Updated routes to utilize new access control middleware for PromptGroups. - Enhanced access role definitions to include roles specific to PromptGroups. - Modified ACL entry schema and types to accommodate PromptGroup resource type. - Updated data provider to include new access role identifiers for PromptGroups. feat: add generic access management dialogs and hooks for resource permissions fix: remove duplicate imports in FileContext component fix: remove duplicate mongoose dependency in package.json feat: add access permissions handling for dynamic resource types and add promptGroup roles feat: implement centralized role localization and update access role types refactor: simplify author handling in prompt group routes and enhance ACL checks feat: implement addPromptToGroup functionality and update PromptForm to use it feat: enhance permission handling in ChatGroupItem, DashGroupItem, and PromptForm components chore: rename migration script for prompt group permissions and update package.json scripts chore: update prompt tests
This commit is contained in:
parent
80ff49b789
commit
0cb217d22c
46 changed files with 3505 additions and 408 deletions
261
client/src/components/Sharing/GenericGrantAccessDialog.tsx
Normal file
261
client/src/components/Sharing/GenericGrantAccessDialog.tsx
Normal file
|
|
@ -0,0 +1,261 @@
|
|||
import React, { useState } from 'react';
|
||||
import { Share2Icon, Users, Loader, Shield, Link, CopyCheck } from 'lucide-react';
|
||||
import {
|
||||
Button,
|
||||
OGDialog,
|
||||
OGDialogTitle,
|
||||
OGDialogClose,
|
||||
OGDialogContent,
|
||||
OGDialogTrigger,
|
||||
useToastContext,
|
||||
} from '@librechat/client';
|
||||
import type { TPrincipal } from 'librechat-data-provider';
|
||||
import { useLocalize, useCopyToClipboard } from '~/hooks';
|
||||
import { usePeoplePickerPermissions, useResourcePermissionState } from '~/hooks/Sharing';
|
||||
import GenericManagePermissionsDialog from './GenericManagePermissionsDialog';
|
||||
import PeoplePicker from '../SidePanel/Agents/Sharing/PeoplePicker/PeoplePicker';
|
||||
import AccessRolesPicker from '../SidePanel/Agents/Sharing/AccessRolesPicker';
|
||||
import PublicSharingToggle from './PublicSharingToggle';
|
||||
import { cn, removeFocusOutlines } from '~/utils';
|
||||
|
||||
export default function GenericGrantAccessDialog({
|
||||
resourceName,
|
||||
resourceDbId,
|
||||
resourceId,
|
||||
resourceType,
|
||||
onGrantAccess,
|
||||
disabled = false,
|
||||
children,
|
||||
}: {
|
||||
resourceDbId?: string | null;
|
||||
resourceId?: string | null;
|
||||
resourceName?: string;
|
||||
resourceType: string;
|
||||
onGrantAccess?: (shares: TPrincipal[], isPublic: boolean, publicRole: string) => void;
|
||||
disabled?: boolean;
|
||||
children?: React.ReactNode;
|
||||
}) {
|
||||
const localize = useLocalize();
|
||||
const { showToast } = useToastContext();
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [isCopying, setIsCopying] = useState(false);
|
||||
|
||||
// Use shared hooks
|
||||
const { hasPeoplePickerAccess, peoplePickerTypeFilter } = usePeoplePickerPermissions();
|
||||
const {
|
||||
config,
|
||||
updatePermissionsMutation,
|
||||
currentShares,
|
||||
currentIsPublic,
|
||||
currentPublicRole,
|
||||
isPublic,
|
||||
setIsPublic,
|
||||
publicRole,
|
||||
setPublicRole,
|
||||
} = useResourcePermissionState(resourceType, resourceDbId, isModalOpen);
|
||||
|
||||
const [newShares, setNewShares] = useState<TPrincipal[]>([]);
|
||||
const [defaultPermissionId, setDefaultPermissionId] = useState<string>(
|
||||
config?.defaultViewerRoleId ?? '',
|
||||
);
|
||||
|
||||
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 handleGrantAccess = async () => {
|
||||
try {
|
||||
const sharesToAdd = newShares.map((share) => ({
|
||||
...share,
|
||||
accessRoleId: defaultPermissionId,
|
||||
}));
|
||||
|
||||
const allShares = [...currentShares, ...sharesToAdd];
|
||||
|
||||
await updatePermissionsMutation.mutateAsync({
|
||||
resourceType,
|
||||
resourceId: resourceDbId,
|
||||
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(config?.defaultViewerRoleId);
|
||||
setIsPublic(false);
|
||||
setPublicRole(config?.defaultViewerRoleId);
|
||||
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(config?.defaultViewerRoleId);
|
||||
setIsPublic(false);
|
||||
setPublicRole(config?.defaultViewerRoleId);
|
||||
setIsModalOpen(false);
|
||||
};
|
||||
|
||||
const totalCurrentShares = currentShares.length + (currentIsPublic ? 1 : 0);
|
||||
const submitButtonActive =
|
||||
newShares.length > 0 || isPublic !== currentIsPublic || publicRole !== currentPublicRole;
|
||||
|
||||
const TriggerComponent = children ? (
|
||||
children
|
||||
) : (
|
||||
<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: 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" />
|
||||
{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>
|
||||
);
|
||||
|
||||
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">
|
||||
{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 && (
|
||||
<GenericManagePermissionsDialog
|
||||
resourceDbId={resourceDbId}
|
||||
resourceName={resourceName}
|
||||
resourceType={resourceType}
|
||||
/>
|
||||
)}
|
||||
{resourceId && resourceUrl && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
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-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>
|
||||
);
|
||||
}
|
||||
345
client/src/components/Sharing/GenericManagePermissionsDialog.tsx
Normal file
345
client/src/components/Sharing/GenericManagePermissionsDialog.tsx
Normal file
|
|
@ -0,0 +1,345 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import { TPrincipal } from 'librechat-data-provider';
|
||||
import { Settings, Users, Loader, UserCheck, Trash2, Shield } from 'lucide-react';
|
||||
import { useGetAccessRolesQuery } from 'librechat-data-provider/react-query';
|
||||
import {
|
||||
Button,
|
||||
OGDialog,
|
||||
OGDialogTitle,
|
||||
OGDialogClose,
|
||||
OGDialogContent,
|
||||
OGDialogTrigger,
|
||||
useToastContext,
|
||||
} from '@librechat/client';
|
||||
import SelectedPrincipalsList from '../SidePanel/Agents/Sharing/PeoplePicker/SelectedPrincipalsList';
|
||||
import { useResourcePermissionState } from '~/hooks/Sharing';
|
||||
import PublicSharingToggle from './PublicSharingToggle';
|
||||
import { cn, removeFocusOutlines } from '~/utils';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
export default function GenericManagePermissionsDialog({
|
||||
resourceDbId,
|
||||
resourceName,
|
||||
resourceType,
|
||||
onUpdatePermissions,
|
||||
children,
|
||||
}: {
|
||||
resourceDbId: string;
|
||||
resourceName?: string;
|
||||
resourceType: string;
|
||||
onUpdatePermissions?: (shares: TPrincipal[], isPublic: boolean, publicRole: string) => 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
|
||||
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>
|
||||
);
|
||||
}
|
||||
60
client/src/components/Sharing/PublicSharingToggle.tsx
Normal file
60
client/src/components/Sharing/PublicSharingToggle.tsx
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import React from 'react';
|
||||
import { Globe, Shield } from 'lucide-react';
|
||||
import { Switch } from '@librechat/client';
|
||||
import AccessRolesPicker from '../SidePanel/Agents/Sharing/AccessRolesPicker';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
export default function PublicSharingToggle({
|
||||
isPublic,
|
||||
publicRole,
|
||||
onPublicToggle,
|
||||
onPublicRoleChange,
|
||||
resourceType = 'agent',
|
||||
}: {
|
||||
isPublic: boolean;
|
||||
publicRole: string;
|
||||
onPublicToggle: (isPublic: boolean) => void;
|
||||
onPublicRoleChange: (role: string) => void;
|
||||
resourceType?: string;
|
||||
}) {
|
||||
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>
|
||||
|
||||
{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>
|
||||
<AccessRolesPicker
|
||||
resourceType={resourceType}
|
||||
selectedRoleId={publicRole}
|
||||
onRoleChange={onPublicRoleChange}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
3
client/src/components/Sharing/index.ts
Normal file
3
client/src/components/Sharing/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export { default as GenericGrantAccessDialog } from './GenericGrantAccessDialog';
|
||||
export { default as GenericManagePermissionsDialog } from './GenericManagePermissionsDialog';
|
||||
export { default as PublicSharingToggle } from './PublicSharingToggle';
|
||||
Loading…
Add table
Add a link
Reference in a new issue