mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-01-08 19:48:51 +01:00
🔐 feat: Granular Role-based Permissions + Entra ID Group Discovery (#7804)
This commit is contained in:
parent
d471209ced
commit
c6d4629fd1
99 changed files with 11318 additions and 621 deletions
|
|
@ -1,16 +1,21 @@
|
|||
import { useWatch, useFormContext } from 'react-hook-form';
|
||||
import { SystemRoles, Permissions, PermissionTypes } from 'librechat-data-provider';
|
||||
import {
|
||||
SystemRoles,
|
||||
Permissions,
|
||||
PermissionTypes,
|
||||
PERMISSION_BITS,
|
||||
} from 'librechat-data-provider';
|
||||
import type { AgentForm, AgentPanelProps } from '~/common';
|
||||
import { useLocalize, useAuthContext, useHasAccess } from '~/hooks';
|
||||
import { useLocalize, useAuthContext, useHasAccess, useResourcePermissions } from '~/hooks';
|
||||
import GrantAccessDialog from './Sharing/GrantAccessDialog';
|
||||
import { useUpdateAgentMutation } from '~/data-provider';
|
||||
import AdvancedButton from './Advanced/AdvancedButton';
|
||||
import VersionButton from './Version/VersionButton';
|
||||
import DuplicateAgent from './DuplicateAgent';
|
||||
import AdminSettings from './AdminSettings';
|
||||
import DeleteButton from './DeleteButton';
|
||||
import { Spinner } from '~/components';
|
||||
import ShareAgent from './ShareAgent';
|
||||
import { Panel } from '~/common';
|
||||
import VersionButton from './Version/VersionButton';
|
||||
|
||||
export default function AgentFooter({
|
||||
activePanel,
|
||||
|
|
@ -32,12 +37,17 @@ export default function AgentFooter({
|
|||
const { control } = methods;
|
||||
const agent = useWatch({ control, name: 'agent' });
|
||||
const agent_id = useWatch({ control, name: 'id' });
|
||||
|
||||
const hasAccessToShareAgents = useHasAccess({
|
||||
permissionType: PermissionTypes.AGENTS,
|
||||
permission: Permissions.SHARED_GLOBAL,
|
||||
});
|
||||
const { hasPermission, isLoading: permissionsLoading } = useResourcePermissions(
|
||||
'agent',
|
||||
agent?._id || '',
|
||||
);
|
||||
|
||||
const canShareThisAgent = hasPermission(PERMISSION_BITS.SHARE);
|
||||
const canDeleteThisAgent = hasPermission(PERMISSION_BITS.DELETE);
|
||||
const renderSaveButton = () => {
|
||||
if (createMutation.isLoading || updateMutation.isLoading) {
|
||||
return <Spinner className="icon-md" aria-hidden="true" />;
|
||||
|
|
@ -59,18 +69,21 @@ export default function AgentFooter({
|
|||
{user?.role === SystemRoles.ADMIN && showButtons && <AdminSettings />}
|
||||
{/* Context Button */}
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<DeleteButton
|
||||
agent_id={agent_id}
|
||||
setCurrentAgentId={setCurrentAgentId}
|
||||
createMutation={createMutation}
|
||||
/>
|
||||
{(agent?.author === user?.id || user?.role === SystemRoles.ADMIN) &&
|
||||
hasAccessToShareAgents && (
|
||||
<ShareAgent
|
||||
{(agent?.author === user?.id || user?.role === SystemRoles.ADMIN || canDeleteThisAgent) &&
|
||||
!permissionsLoading && (
|
||||
<DeleteButton
|
||||
agent_id={agent_id}
|
||||
setCurrentAgentId={setCurrentAgentId}
|
||||
createMutation={createMutation}
|
||||
/>
|
||||
)}
|
||||
{(agent?.author === user?.id || user?.role === SystemRoles.ADMIN || canShareThisAgent) &&
|
||||
hasAccessToShareAgents &&
|
||||
!permissionsLoading && (
|
||||
<GrantAccessDialog
|
||||
agentDbId={agent?._id}
|
||||
agentId={agent_id}
|
||||
agentName={agent?.name ?? ''}
|
||||
projectIds={agent?.projectIds ?? []}
|
||||
isCollaborative={agent?.isCollaborative}
|
||||
/>
|
||||
)}
|
||||
{agent && agent.author === user?.id && <DuplicateAgent agent_id={agent_id} />}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import {
|
|||
SystemRoles,
|
||||
EModelEndpoint,
|
||||
TAgentsEndpoint,
|
||||
PERMISSION_BITS,
|
||||
TEndpointsConfig,
|
||||
isAssistantsEndpoint,
|
||||
} from 'librechat-data-provider';
|
||||
|
|
@ -16,8 +17,10 @@ import {
|
|||
useCreateAgentMutation,
|
||||
useUpdateAgentMutation,
|
||||
useGetAgentByIdQuery,
|
||||
useGetExpandedAgentByIdQuery,
|
||||
} from '~/data-provider';
|
||||
import { createProviderOption, getDefaultAgentFormValues } from '~/utils';
|
||||
import { useResourcePermissions } from '~/hooks/useResourcePermissions';
|
||||
import { useSelectAgent, useLocalize, useAuthContext } from '~/hooks';
|
||||
import { useAgentPanelContext } from '~/Providers/AgentPanelContext';
|
||||
import AgentPanelSkeleton from './AgentPanelSkeleton';
|
||||
|
|
@ -50,10 +53,29 @@ export default function AgentPanel({
|
|||
const { onSelect: onSelectAgent } = useSelectAgent();
|
||||
|
||||
const modelsQuery = useGetModelsQuery();
|
||||
const agentQuery = useGetAgentByIdQuery(current_agent_id ?? '', {
|
||||
|
||||
// Basic agent query for initial permission check
|
||||
const basicAgentQuery = useGetAgentByIdQuery(current_agent_id ?? '', {
|
||||
enabled: !!(current_agent_id ?? '') && current_agent_id !== Constants.EPHEMERAL_AGENT_ID,
|
||||
});
|
||||
|
||||
const { hasPermission, isLoading: permissionsLoading } = useResourcePermissions(
|
||||
'agent',
|
||||
basicAgentQuery.data?._id || '',
|
||||
);
|
||||
|
||||
const canEdit = hasPermission(PERMISSION_BITS.EDIT);
|
||||
|
||||
const expandedAgentQuery = useGetExpandedAgentByIdQuery(current_agent_id ?? '', {
|
||||
enabled:
|
||||
!!(current_agent_id ?? '') &&
|
||||
current_agent_id !== Constants.EPHEMERAL_AGENT_ID &&
|
||||
canEdit &&
|
||||
!permissionsLoading,
|
||||
});
|
||||
|
||||
const agentQuery = canEdit && expandedAgentQuery.data ? expandedAgentQuery : basicAgentQuery;
|
||||
|
||||
const models = useMemo(() => modelsQuery.data ?? {}, [modelsQuery.data]);
|
||||
const methods = useForm<AgentForm>({
|
||||
defaultValues: getDefaultAgentFormValues(),
|
||||
|
|
@ -248,19 +270,16 @@ export default function AgentPanel({
|
|||
}, [agent_id, onSelectAgent]);
|
||||
|
||||
const canEditAgent = useMemo(() => {
|
||||
const canEdit =
|
||||
(agentQuery.data?.isCollaborative ?? false)
|
||||
? true
|
||||
: agentQuery.data?.author === user?.id || user?.role === SystemRoles.ADMIN;
|
||||
if (!agentQuery.data?.id) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return agentQuery.data?.id != null && agentQuery.data.id ? canEdit : true;
|
||||
}, [
|
||||
agentQuery.data?.isCollaborative,
|
||||
agentQuery.data?.author,
|
||||
agentQuery.data?.id,
|
||||
user?.id,
|
||||
user?.role,
|
||||
]);
|
||||
if (agentQuery.data?.author === user?.id || user?.role === SystemRoles.ADMIN) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return canEdit;
|
||||
}, [agentQuery.data?.author, agentQuery.data?.id, user?.id, user?.role, canEdit]);
|
||||
|
||||
return (
|
||||
<FormProvider {...methods}>
|
||||
|
|
|
|||
|
|
@ -43,9 +43,7 @@ export default function AgentSelect({
|
|||
|
||||
const resetAgentForm = useCallback(
|
||||
(fullAgent: Agent) => {
|
||||
const { instanceProjectId } = startupConfig ?? {};
|
||||
const isGlobal =
|
||||
(instanceProjectId != null && fullAgent.projectIds?.includes(instanceProjectId)) ?? false;
|
||||
const isGlobal = fullAgent.isPublic ?? false;
|
||||
const update = {
|
||||
...fullAgent,
|
||||
provider: createProviderOption(fullAgent.provider),
|
||||
|
|
|
|||
|
|
@ -1,270 +0,0 @@
|
|||
import React, { useEffect, useMemo } from 'react';
|
||||
import { Share2Icon } from 'lucide-react';
|
||||
import { useForm, Controller } from 'react-hook-form';
|
||||
import { Permissions } from 'librechat-data-provider';
|
||||
import type { TStartupConfig, AgentUpdateParams } from 'librechat-data-provider';
|
||||
import {
|
||||
Button,
|
||||
Switch,
|
||||
OGDialog,
|
||||
OGDialogTitle,
|
||||
OGDialogClose,
|
||||
OGDialogContent,
|
||||
OGDialogTrigger,
|
||||
} from '~/components/ui';
|
||||
import { useUpdateAgentMutation, useGetStartupConfig } from '~/data-provider';
|
||||
import { cn, removeFocusOutlines } from '~/utils';
|
||||
import { useToastContext } from '~/Providers';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
type FormValues = {
|
||||
[Permissions.SHARED_GLOBAL]: boolean;
|
||||
[Permissions.UPDATE]: boolean;
|
||||
};
|
||||
|
||||
export default function ShareAgent({
|
||||
agent_id = '',
|
||||
agentName,
|
||||
projectIds = [],
|
||||
isCollaborative = false,
|
||||
}: {
|
||||
agent_id?: string;
|
||||
agentName?: string;
|
||||
projectIds?: string[];
|
||||
isCollaborative?: boolean;
|
||||
}) {
|
||||
const localize = useLocalize();
|
||||
const { showToast } = useToastContext();
|
||||
const { data: startupConfig = {} as TStartupConfig, isFetching } = useGetStartupConfig();
|
||||
const { instanceProjectId } = startupConfig;
|
||||
const agentIsGlobal = useMemo(
|
||||
() => !!projectIds.includes(instanceProjectId),
|
||||
[projectIds, instanceProjectId],
|
||||
);
|
||||
|
||||
const {
|
||||
watch,
|
||||
control,
|
||||
setValue,
|
||||
getValues,
|
||||
handleSubmit,
|
||||
formState: { isSubmitting },
|
||||
} = useForm<FormValues>({
|
||||
mode: 'onChange',
|
||||
defaultValues: {
|
||||
[Permissions.SHARED_GLOBAL]: agentIsGlobal,
|
||||
[Permissions.UPDATE]: isCollaborative,
|
||||
},
|
||||
});
|
||||
|
||||
const sharedGlobalValue = watch(Permissions.SHARED_GLOBAL);
|
||||
|
||||
useEffect(() => {
|
||||
if (!sharedGlobalValue) {
|
||||
setValue(Permissions.UPDATE, false);
|
||||
}
|
||||
}, [sharedGlobalValue, setValue]);
|
||||
|
||||
useEffect(() => {
|
||||
setValue(Permissions.SHARED_GLOBAL, agentIsGlobal);
|
||||
setValue(Permissions.UPDATE, isCollaborative);
|
||||
}, [agentIsGlobal, isCollaborative, setValue]);
|
||||
|
||||
const updateAgent = useUpdateAgentMutation({
|
||||
onSuccess: (data) => {
|
||||
showToast({
|
||||
message: `${localize('com_assistants_update_success')} ${
|
||||
data.name ?? localize('com_ui_agent')
|
||||
}`,
|
||||
status: 'success',
|
||||
});
|
||||
},
|
||||
onError: (err) => {
|
||||
const error = err as Error;
|
||||
showToast({
|
||||
message: `${localize('com_agents_update_error')}${
|
||||
error.message ? ` ${localize('com_ui_error')}: ${error.message}` : ''
|
||||
}`,
|
||||
status: 'error',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
if (!agent_id || !instanceProjectId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const onSubmit = (data: FormValues) => {
|
||||
if (!agent_id || !instanceProjectId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = {} as AgentUpdateParams;
|
||||
|
||||
if (data[Permissions.UPDATE] !== isCollaborative) {
|
||||
payload.isCollaborative = data[Permissions.UPDATE];
|
||||
}
|
||||
|
||||
if (data[Permissions.SHARED_GLOBAL] !== agentIsGlobal) {
|
||||
if (data[Permissions.SHARED_GLOBAL]) {
|
||||
payload.projectIds = [startupConfig.instanceProjectId];
|
||||
} else {
|
||||
payload.removeProjectIds = [startupConfig.instanceProjectId];
|
||||
payload.isCollaborative = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(payload).length > 0) {
|
||||
updateAgent.mutate({
|
||||
agent_id,
|
||||
data: payload,
|
||||
});
|
||||
} else {
|
||||
showToast({
|
||||
message: localize('com_ui_no_changes'),
|
||||
status: 'info',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<OGDialog>
|
||||
<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" />
|
||||
</div>
|
||||
</button>
|
||||
</OGDialogTrigger>
|
||||
<OGDialogContent className="w-11/12 md:max-w-xl">
|
||||
<OGDialogTitle>
|
||||
{localize('com_ui_share_var', {
|
||||
0: agentName != null && agentName !== '' ? `"${agentName}"` : localize('com_ui_agent'),
|
||||
})}
|
||||
</OGDialogTitle>
|
||||
<form
|
||||
className="p-2"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleSubmit(onSubmit)(e);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2 py-2">
|
||||
<div className="flex items-center">
|
||||
<button
|
||||
type="button"
|
||||
className="mr-2 cursor-pointer"
|
||||
disabled={isFetching || updateAgent.isLoading || !instanceProjectId}
|
||||
onClick={() =>
|
||||
setValue(Permissions.SHARED_GLOBAL, !getValues(Permissions.SHARED_GLOBAL), {
|
||||
shouldDirty: true,
|
||||
})
|
||||
}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
setValue(Permissions.SHARED_GLOBAL, !getValues(Permissions.SHARED_GLOBAL), {
|
||||
shouldDirty: true,
|
||||
});
|
||||
}
|
||||
}}
|
||||
aria-checked={getValues(Permissions.SHARED_GLOBAL)}
|
||||
role="checkbox"
|
||||
>
|
||||
{localize('com_ui_share_to_all_users')}
|
||||
</button>
|
||||
<label htmlFor={Permissions.SHARED_GLOBAL} className="select-none">
|
||||
{agentIsGlobal && (
|
||||
<span className="ml-2 text-xs">{localize('com_ui_agent_shared_to_all')}</span>
|
||||
)}
|
||||
</label>
|
||||
</div>
|
||||
<Controller
|
||||
name={Permissions.SHARED_GLOBAL}
|
||||
control={control}
|
||||
disabled={isFetching || updateAgent.isLoading || !instanceProjectId}
|
||||
render={({ field }) => (
|
||||
<Switch
|
||||
{...field}
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
value={field.value.toString()}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-4 flex items-center justify-between gap-2 py-2">
|
||||
<div className="flex items-center">
|
||||
<button
|
||||
type="button"
|
||||
className="mr-2 cursor-pointer"
|
||||
disabled={
|
||||
isFetching || updateAgent.isLoading || !instanceProjectId || !sharedGlobalValue
|
||||
}
|
||||
onClick={() =>
|
||||
setValue(Permissions.UPDATE, !getValues(Permissions.UPDATE), {
|
||||
shouldDirty: true,
|
||||
})
|
||||
}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
setValue(Permissions.UPDATE, !getValues(Permissions.UPDATE), {
|
||||
shouldDirty: true,
|
||||
});
|
||||
}
|
||||
}}
|
||||
aria-checked={getValues(Permissions.UPDATE)}
|
||||
role="checkbox"
|
||||
>
|
||||
{localize('com_agents_allow_editing')}
|
||||
</button>
|
||||
{/* <label htmlFor={Permissions.UPDATE} className="select-none">
|
||||
{agentIsGlobal && (
|
||||
<span className="ml-2 text-xs">{localize('com_ui_agent_editing_allowed')}</span>
|
||||
)}
|
||||
</label> */}
|
||||
</div>
|
||||
<Controller
|
||||
name={Permissions.UPDATE}
|
||||
control={control}
|
||||
disabled={
|
||||
isFetching || updateAgent.isLoading || !instanceProjectId || !sharedGlobalValue
|
||||
}
|
||||
render={({ field }) => (
|
||||
<Switch
|
||||
{...field}
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
value={field.value.toString()}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<OGDialogClose asChild>
|
||||
<Button
|
||||
variant="submit"
|
||||
size="sm"
|
||||
type="submit"
|
||||
disabled={isSubmitting || isFetching}
|
||||
>
|
||||
{localize('com_ui_save')}
|
||||
</Button>
|
||||
</OGDialogClose>
|
||||
</div>
|
||||
</form>
|
||||
</OGDialogContent>
|
||||
</OGDialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,99 @@
|
|||
import React from 'react';
|
||||
import { ACCESS_ROLE_IDS } from 'librechat-data-provider';
|
||||
import type { AccessRole } from 'librechat-data-provider';
|
||||
import { SelectDropDownPop } from '~/components/ui';
|
||||
import { useGetAccessRolesQuery } from 'librechat-data-provider/react-query';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
interface AccessRolesPickerProps {
|
||||
resourceType?: string;
|
||||
selectedRoleId?: string;
|
||||
onRoleChange: (roleId: string) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function AccessRolesPicker({
|
||||
resourceType = 'agent',
|
||||
selectedRoleId = ACCESS_ROLE_IDS.AGENT_VIEWER,
|
||||
onRoleChange,
|
||||
className = '',
|
||||
}: AccessRolesPickerProps) {
|
||||
const localize = useLocalize();
|
||||
|
||||
// Fetch access roles from API
|
||||
const { data: accessRoles, isLoading: rolesLoading } = useGetAccessRolesQuery(resourceType);
|
||||
|
||||
// Helper function to get localized role name and description
|
||||
const getLocalizedRoleInfo = (roleId: string) => {
|
||||
switch (roleId) {
|
||||
case 'agent_viewer':
|
||||
return {
|
||||
name: localize('com_ui_role_viewer'),
|
||||
description: localize('com_ui_role_viewer_desc'),
|
||||
};
|
||||
case 'agent_editor':
|
||||
return {
|
||||
name: localize('com_ui_role_editor'),
|
||||
description: localize('com_ui_role_editor_desc'),
|
||||
};
|
||||
case 'agent_manager':
|
||||
return {
|
||||
name: localize('com_ui_role_manager'),
|
||||
description: localize('com_ui_role_manager_desc'),
|
||||
};
|
||||
case 'agent_owner':
|
||||
return {
|
||||
name: localize('com_ui_role_owner'),
|
||||
description: localize('com_ui_role_owner_desc'),
|
||||
};
|
||||
default:
|
||||
return {
|
||||
name: localize('com_ui_unknown'),
|
||||
description: localize('com_ui_unknown'),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// Find the currently selected role
|
||||
const selectedRole = accessRoles?.find((role) => role.accessRoleId === selectedRoleId);
|
||||
|
||||
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-gray-300 border-t-blue-600"></div>
|
||||
<span className="ml-2 text-sm text-gray-500">Loading roles...</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<SelectDropDownPop
|
||||
availableValues={accessRoles.map((role: AccessRole) => {
|
||||
const localizedInfo = getLocalizedRoleInfo(role.accessRoleId);
|
||||
return {
|
||||
value: role.accessRoleId,
|
||||
label: localizedInfo.name,
|
||||
description: localizedInfo.description,
|
||||
};
|
||||
})}
|
||||
showLabel={false}
|
||||
value={
|
||||
selectedRole
|
||||
? (() => {
|
||||
const localizedInfo = getLocalizedRoleInfo(selectedRole.accessRoleId);
|
||||
return {
|
||||
value: selectedRole.accessRoleId,
|
||||
label: localizedInfo.name,
|
||||
description: localizedInfo.description,
|
||||
};
|
||||
})()
|
||||
: null
|
||||
}
|
||||
setValue={onRoleChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,266 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import { Share2Icon, Users, Loader, Shield, Link, CopyCheck } from 'lucide-react';
|
||||
import { ACCESS_ROLE_IDS } from 'librechat-data-provider';
|
||||
import type { TPrincipal } from 'librechat-data-provider';
|
||||
import {
|
||||
Button,
|
||||
OGDialog,
|
||||
OGDialogTitle,
|
||||
OGDialogClose,
|
||||
OGDialogContent,
|
||||
OGDialogTrigger,
|
||||
} from '~/components/ui';
|
||||
import { cn, removeFocusOutlines } from '~/utils';
|
||||
import { useToastContext } from '~/Providers';
|
||||
import { useLocalize, useCopyToClipboard } from '~/hooks';
|
||||
import {
|
||||
useGetResourcePermissionsQuery,
|
||||
useUpdateResourcePermissionsMutation,
|
||||
} from 'librechat-data-provider/react-query';
|
||||
|
||||
import PeoplePicker from './PeoplePicker/PeoplePicker';
|
||||
import PublicSharingToggle from './PublicSharingToggle';
|
||||
import ManagePermissionsDialog from './ManagePermissionsDialog';
|
||||
import AccessRolesPicker from './AccessRolesPicker';
|
||||
|
||||
export default function GrantAccessDialog({
|
||||
agentName,
|
||||
onGrantAccess,
|
||||
resourceType = 'agent',
|
||||
agentDbId,
|
||||
agentId,
|
||||
}: {
|
||||
agentDbId?: string | null;
|
||||
agentId?: string | null;
|
||||
agentName?: string;
|
||||
onGrantAccess?: (shares: TPrincipal[], isPublic: boolean, publicRole: string) => void;
|
||||
resourceType?: string;
|
||||
}) {
|
||||
const localize = useLocalize();
|
||||
const { showToast } = useToastContext();
|
||||
|
||||
const {
|
||||
data: permissionsData,
|
||||
// isLoading: isLoadingPermissions,
|
||||
// error: permissionsError,
|
||||
} = useGetResourcePermissionsQuery(resourceType, agentDbId!, {
|
||||
enabled: !!agentDbId,
|
||||
});
|
||||
|
||||
const updatePermissionsMutation = useUpdateResourcePermissionsMutation();
|
||||
|
||||
const [newShares, setNewShares] = useState<TPrincipal[]>([]);
|
||||
const [defaultPermissionId, setDefaultPermissionId] = useState<string>(
|
||||
ACCESS_ROLE_IDS.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 || ACCESS_ROLE_IDS.AGENT_VIEWER;
|
||||
|
||||
const [isPublic, setIsPublic] = useState(false);
|
||||
const [publicRole, setPublicRole] = useState<string>(ACCESS_ROLE_IDS.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(ACCESS_ROLE_IDS.AGENT_VIEWER);
|
||||
setIsPublic(false);
|
||||
setPublicRole(ACCESS_ROLE_IDS.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(ACCESS_ROLE_IDS.AGENT_VIEWER);
|
||||
setIsPublic(false);
|
||||
setPublicRole(ACCESS_ROLE_IDS.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">
|
||||
<PeoplePicker
|
||||
onSelectionChange={setNewShares}
|
||||
placeholder={localize('com_ui_search_people_placeholder')}
|
||||
/>
|
||||
|
||||
<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">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,349 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import { Settings, Users, Loader, UserCheck, Trash2, Shield } from 'lucide-react';
|
||||
import { ACCESS_ROLE_IDS, TPrincipal } from 'librechat-data-provider';
|
||||
import {
|
||||
Button,
|
||||
OGDialog,
|
||||
OGDialogTitle,
|
||||
OGDialogClose,
|
||||
OGDialogContent,
|
||||
OGDialogTrigger,
|
||||
} from '~/components/ui';
|
||||
import { cn, removeFocusOutlines } from '~/utils';
|
||||
import { useToastContext } from '~/Providers';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import {
|
||||
useGetAccessRolesQuery,
|
||||
useGetResourcePermissionsQuery,
|
||||
useUpdateResourcePermissionsMutation,
|
||||
} from 'librechat-data-provider/react-query';
|
||||
|
||||
import SelectedPrincipalsList from './PeoplePicker/SelectedPrincipalsList';
|
||||
import PublicSharingToggle from './PublicSharingToggle';
|
||||
|
||||
export default function ManagePermissionsDialog({
|
||||
agentDbId,
|
||||
agentName,
|
||||
resourceType = 'agent',
|
||||
onUpdatePermissions,
|
||||
}: {
|
||||
agentDbId: string;
|
||||
agentName?: string;
|
||||
resourceType?: string;
|
||||
onUpdatePermissions?: (shares: TPrincipal[], isPublic: boolean, publicRole: string) => void;
|
||||
}) {
|
||||
const localize = useLocalize();
|
||||
const { showToast } = useToastContext();
|
||||
|
||||
const {
|
||||
data: permissionsData,
|
||||
isLoading: isLoadingPermissions,
|
||||
error: permissionsError,
|
||||
} = useGetResourcePermissionsQuery(resourceType, agentDbId, {
|
||||
enabled: !!agentDbId,
|
||||
});
|
||||
const {
|
||||
data: accessRoles,
|
||||
// isLoading,
|
||||
} = useGetAccessRolesQuery(resourceType);
|
||||
|
||||
const updatePermissionsMutation = useUpdateResourcePermissionsMutation();
|
||||
|
||||
const [managedShares, setManagedShares] = useState<TPrincipal[]>([]);
|
||||
const [managedIsPublic, setManagedIsPublic] = useState(false);
|
||||
const [managedPublicRole, setManagedPublicRole] = useState<string>(ACCESS_ROLE_IDS.AGENT_VIEWER);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [hasChanges, setHasChanges] = useState(false);
|
||||
|
||||
const currentShares: TPrincipal[] = permissionsData?.principals || [];
|
||||
|
||||
const isPublic = permissionsData?.public || false;
|
||||
const publicRole = permissionsData?.publicAccessRoleId || ACCESS_ROLE_IDS.AGENT_VIEWER;
|
||||
|
||||
useEffect(() => {
|
||||
if (permissionsData) {
|
||||
setManagedShares(currentShares);
|
||||
setManagedIsPublic(isPublic);
|
||||
setManagedPublicRole(publicRole);
|
||||
setHasChanges(false);
|
||||
}
|
||||
}, [permissionsData, isModalOpen]);
|
||||
|
||||
if (!agentDbId) {
|
||||
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: agentDbId,
|
||||
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(isPublic);
|
||||
setManagedPublicRole(publicRole);
|
||||
setIsModalOpen(false);
|
||||
};
|
||||
|
||||
const handleRevokeAll = () => {
|
||||
setManagedShares([]);
|
||||
setManagedIsPublic(false);
|
||||
setHasChanges(true);
|
||||
};
|
||||
const handlePublicToggle = (isPublic: boolean) => {
|
||||
setManagedIsPublic(isPublic);
|
||||
setHasChanges(true);
|
||||
if (!isPublic) {
|
||||
setManagedPublicRole(ACCESS_ROLE_IDS.AGENT_VIEWER);
|
||||
}
|
||||
};
|
||||
const handlePublicRoleChange = (role: string) => {
|
||||
setManagedPublicRole(role);
|
||||
setHasChanges(true);
|
||||
};
|
||||
const totalShares = managedShares.length + (managedIsPublic ? 1 : 0);
|
||||
const originalTotalShares = currentShares.length + (isPublic ? 1 : 0);
|
||||
|
||||
/** Check if there's at least one owner (user, group, or public with owner role) */
|
||||
const hasAtLeastOneOwner =
|
||||
managedShares.some((share) => share.accessRoleId === ACCESS_ROLE_IDS.AGENT_OWNER) ||
|
||||
(managedIsPublic && managedPublicRole === ACCESS_ROLE_IDS.AGENT_OWNER);
|
||||
|
||||
let peopleLabel = localize('com_ui_people');
|
||||
if (managedShares.length === 1) {
|
||||
peopleLabel = localize('com_ui_person');
|
||||
}
|
||||
|
||||
let buttonAriaLabel = localize('com_ui_manage_permissions_for') + ' agent';
|
||||
if (agentName != null && agentName !== '') {
|
||||
buttonAriaLabel = localize('com_ui_manage_permissions_for') + ` "${agentName}"`;
|
||||
}
|
||||
|
||||
let dialogTitle = localize('com_ui_manage_permissions_for') + ' Agent';
|
||||
if (agentName != null && agentName !== '') {
|
||||
dialogTitle = localize('com_ui_manage_permissions_for') + ` "${agentName}"`;
|
||||
}
|
||||
|
||||
let publicSuffix = '';
|
||||
if (managedIsPublic) {
|
||||
publicSuffix = localize('com_ui_and_public');
|
||||
}
|
||||
|
||||
return (
|
||||
<OGDialog open={isModalOpen} onOpenChange={setIsModalOpen}>
|
||||
<OGDialogTrigger asChild>
|
||||
<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>
|
||||
</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}
|
||||
/>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,101 @@
|
|||
import React, { useState, useMemo } from 'react';
|
||||
import type { TPrincipal, PrincipalSearchParams } from 'librechat-data-provider';
|
||||
import { useSearchPrincipalsQuery } from 'librechat-data-provider/react-query';
|
||||
|
||||
import { SearchPicker } from '~/components/ui/SearchPicker';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import PeoplePickerSearchItem from './PeoplePickerSearchItem';
|
||||
import SelectedPrincipalsList from './SelectedPrincipalsList';
|
||||
|
||||
interface PeoplePickerProps {
|
||||
onSelectionChange: (principals: TPrincipal[]) => void;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function PeoplePicker({
|
||||
onSelectionChange,
|
||||
placeholder,
|
||||
className = '',
|
||||
}: PeoplePickerProps) {
|
||||
const localize = useLocalize();
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [selectedShares, setSelectedShares] = useState<TPrincipal[]>([]);
|
||||
|
||||
const searchParams: PrincipalSearchParams = useMemo(
|
||||
() => ({
|
||||
q: searchQuery,
|
||||
limit: 30,
|
||||
}),
|
||||
[searchQuery],
|
||||
);
|
||||
|
||||
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) => !selectedShares.some((share) => share.idOnTheSource === result.idOnTheSource),
|
||||
);
|
||||
}, [searchResponse?.results, selectedShares]);
|
||||
|
||||
if (error) {
|
||||
console.error('Principal search error:', error);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`space-y-3 ${className}`}>
|
||||
<div className="relative">
|
||||
<SearchPicker<TPrincipal & { key: string; value: string }>
|
||||
options={selectableResults.map((s) => {
|
||||
const key = s.idOnTheSource || 'unknown' + 'picker_key';
|
||||
const value = s.idOnTheSource || 'Unknown';
|
||||
return {
|
||||
...s,
|
||||
id: s.id ?? undefined,
|
||||
key,
|
||||
value,
|
||||
};
|
||||
})}
|
||||
renderOptions={(o) => <PeoplePickerSearchItem principal={o} />}
|
||||
placeholder={placeholder || localize('com_ui_search_default_placeholder')}
|
||||
query={searchQuery}
|
||||
onQueryChange={(query: string) => {
|
||||
setSearchQuery(query);
|
||||
}}
|
||||
onPick={(principal) => {
|
||||
console.log('Selected Principal:', principal);
|
||||
setSelectedShares((prev) => {
|
||||
const newArray = [...prev, principal];
|
||||
onSelectionChange([...newArray]);
|
||||
return newArray;
|
||||
});
|
||||
setSearchQuery('');
|
||||
}}
|
||||
label={localize('com_ui_search_users_groups')}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SelectedPrincipalsList
|
||||
principles={selectedShares}
|
||||
onRemoveHandler={(idOnTheSource: string) => {
|
||||
setSelectedShares((prev) => {
|
||||
const newArray = prev.filter((share) => share.idOnTheSource !== idOnTheSource);
|
||||
onSelectionChange(newArray);
|
||||
return newArray;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
import React, { forwardRef } from 'react';
|
||||
import type { TPrincipal } from 'librechat-data-provider';
|
||||
import { cn } from '~/utils';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import PrincipalAvatar from '../PrincipalAvatar';
|
||||
|
||||
interface PeoplePickerSearchItemProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
principal: TPrincipal;
|
||||
}
|
||||
|
||||
const PeoplePickerSearchItem = forwardRef<HTMLDivElement, PeoplePickerSearchItemProps>(
|
||||
function PeoplePickerSearchItem(
|
||||
{ principal, className, style, onClick, ...props },
|
||||
forwardedRef,
|
||||
) {
|
||||
const localize = useLocalize();
|
||||
const { name, email, type } = principal;
|
||||
|
||||
// Display name with fallback
|
||||
const displayName = name || localize('com_ui_unknown');
|
||||
const subtitle = email || `${type} (${principal.source || 'local'})`;
|
||||
|
||||
return (
|
||||
<div
|
||||
{...props}
|
||||
ref={forwardedRef}
|
||||
className={cn('flex items-center gap-3 p-2', className)}
|
||||
style={style}
|
||||
onClick={(event) => {
|
||||
onClick?.(event);
|
||||
}}
|
||||
>
|
||||
<PrincipalAvatar principal={principal} size="md" />
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-medium text-text-primary">{displayName}</div>
|
||||
<div className="truncate text-xs text-text-secondary">{subtitle}</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-shrink-0">
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center rounded-full px-2 py-1 text-xs font-medium',
|
||||
type === 'user'
|
||||
? 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300'
|
||||
: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300',
|
||||
)}
|
||||
>
|
||||
{type === 'user' ? localize('com_ui_user') : localize('com_ui_group')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export default PeoplePickerSearchItem;
|
||||
|
|
@ -0,0 +1,149 @@
|
|||
import React, { useState, useId } from 'react';
|
||||
import { Users, X, ExternalLink, ChevronDown } from 'lucide-react';
|
||||
import * as Menu from '@ariakit/react/menu';
|
||||
import type { TPrincipal, TAccessRole } from 'librechat-data-provider';
|
||||
import { Button, DropdownPopup } from '~/components/ui';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import PrincipalAvatar from '../PrincipalAvatar';
|
||||
|
||||
interface SelectedPrincipalsListProps {
|
||||
principles: TPrincipal[];
|
||||
onRemoveHandler: (idOnTheSource: string) => void;
|
||||
onRoleChange?: (idOnTheSource: string, newRoleId: string) => void;
|
||||
availableRoles?: Omit<TAccessRole, 'resourceType'>[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function SelectedPrincipalsList({
|
||||
principles,
|
||||
onRemoveHandler,
|
||||
className = '',
|
||||
onRoleChange,
|
||||
availableRoles,
|
||||
}: SelectedPrincipalsListProps) {
|
||||
const localize = useLocalize();
|
||||
|
||||
const getPrincipalDisplayInfo = (principal: TPrincipal) => {
|
||||
const displayName = principal.name || localize('com_ui_unknown');
|
||||
const subtitle = principal.email || `${principal.type} (${principal.source || 'local'})`;
|
||||
|
||||
return { displayName, subtitle };
|
||||
};
|
||||
|
||||
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">
|
||||
<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')}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`space-y-3 ${className}`}>
|
||||
<div className="space-y-2">
|
||||
{principles.map((share) => {
|
||||
const { displayName, subtitle } = getPrincipalDisplayInfo(share);
|
||||
return (
|
||||
<div
|
||||
key={share.idOnTheSource + '-principalList'}
|
||||
className="bg-surface flex items-center justify-between rounded-lg border border-border p-3"
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-3">
|
||||
<PrincipalAvatar principal={share} size="md" />
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-medium">{displayName}</div>
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<span>{subtitle}</span>
|
||||
{share.source === 'entra' && (
|
||||
<>
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
<span>{localize('com_ui_azure_ad')}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-shrink-0 items-center gap-2">
|
||||
{!!share.accessRoleId && !!onRoleChange && (
|
||||
<RoleSelector
|
||||
currentRole={share.accessRoleId}
|
||||
onRoleChange={(newRole) => {
|
||||
onRoleChange?.(share.idOnTheSource!, newRole);
|
||||
}}
|
||||
availableRoles={availableRoles ?? []}
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onRemoveHandler(share.idOnTheSource!)}
|
||||
className="h-8 w-8 p-0 hover:bg-destructive/10 hover:text-destructive"
|
||||
aria-label={localize('com_ui_remove_user', { 0: displayName })}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface RoleSelectorProps {
|
||||
currentRole: string;
|
||||
onRoleChange: (newRole: string) => 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: string) => {
|
||||
switch (roleId) {
|
||||
case 'agent_viewer':
|
||||
return localize('com_ui_role_viewer');
|
||||
case 'agent_editor':
|
||||
return localize('com_ui_role_editor');
|
||||
case 'agent_manager':
|
||||
return localize('com_ui_role_manager');
|
||||
case 'agent_owner':
|
||||
return localize('com_ui_role_owner');
|
||||
default:
|
||||
return localize('com_ui_unknown');
|
||||
}
|
||||
};
|
||||
|
||||
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,101 @@
|
|||
import React from 'react';
|
||||
import { Users, User } from 'lucide-react';
|
||||
import type { TPrincipal } from 'librechat-data-provider';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
interface PrincipalAvatarProps {
|
||||
principal: TPrincipal;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function PrincipalAvatar({
|
||||
principal,
|
||||
size = 'md',
|
||||
className,
|
||||
}: PrincipalAvatarProps) {
|
||||
const { avatar, type, name } = principal;
|
||||
const displayName = name || 'Unknown';
|
||||
|
||||
// Size variants
|
||||
const sizeClasses = {
|
||||
sm: 'h-6 w-6',
|
||||
md: 'h-8 w-8',
|
||||
lg: 'h-10 w-10',
|
||||
};
|
||||
|
||||
const iconSizeClasses = {
|
||||
sm: 'h-3 w-3',
|
||||
md: 'h-4 w-4',
|
||||
lg: 'h-5 w-5',
|
||||
};
|
||||
|
||||
const avatarSizeClass = sizeClasses[size];
|
||||
const iconSizeClass = iconSizeClasses[size];
|
||||
|
||||
// Avatar or icon logic
|
||||
if (avatar) {
|
||||
return (
|
||||
<div className={cn('flex-shrink-0', className)}>
|
||||
<img
|
||||
src={avatar}
|
||||
alt={`${displayName} avatar`}
|
||||
className={cn(avatarSizeClass, 'rounded-full object-cover')}
|
||||
onError={(e) => {
|
||||
// Fallback to icon if image fails to load
|
||||
const target = e.target as HTMLImageElement;
|
||||
target.style.display = 'none';
|
||||
target.nextElementSibling?.classList.remove('hidden');
|
||||
}}
|
||||
/>
|
||||
{/* Hidden fallback icon that shows if image fails */}
|
||||
<div className={cn('hidden', avatarSizeClass)}>
|
||||
{type === 'user' ? (
|
||||
<div
|
||||
className={cn(
|
||||
avatarSizeClass,
|
||||
'flex items-center justify-center rounded-full bg-blue-100 dark:bg-blue-900',
|
||||
)}
|
||||
>
|
||||
<User className={cn(iconSizeClass, 'text-blue-600 dark:text-blue-400')} />
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={cn(
|
||||
avatarSizeClass,
|
||||
'flex items-center justify-center rounded-full bg-green-100 dark:bg-green-900',
|
||||
)}
|
||||
>
|
||||
<Users className={cn(iconSizeClass, 'text-green-600 dark:text-green-400')} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Fallback icon based on type
|
||||
return (
|
||||
<div className={cn('flex-shrink-0', className)}>
|
||||
{type === 'user' ? (
|
||||
<div
|
||||
className={cn(
|
||||
avatarSizeClass,
|
||||
'flex items-center justify-center rounded-full bg-blue-100 dark:bg-blue-900',
|
||||
)}
|
||||
>
|
||||
<User className={cn(iconSizeClass, 'text-blue-600 dark:text-blue-400')} />
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={cn(
|
||||
avatarSizeClass,
|
||||
'flex items-center justify-center rounded-full bg-green-100 dark:bg-green-900',
|
||||
)}
|
||||
>
|
||||
<Users className={cn(iconSizeClass, 'text-green-600 dark:text-green-400')} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
import React from 'react';
|
||||
import { Globe } from 'lucide-react';
|
||||
import { Switch } from '~/components/ui';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import AccessRolesPicker from './AccessRolesPicker';
|
||||
|
||||
interface PublicSharingToggleProps {
|
||||
isPublic: boolean;
|
||||
publicRole: string;
|
||||
onPublicToggle: (isPublic: boolean) => void;
|
||||
onPublicRoleChange: (role: string) => void;
|
||||
className?: string;
|
||||
resourceType?: string;
|
||||
}
|
||||
|
||||
export default function PublicSharingToggle({
|
||||
isPublic,
|
||||
publicRole,
|
||||
onPublicToggle,
|
||||
onPublicRoleChange,
|
||||
className = '',
|
||||
resourceType = 'agent',
|
||||
}: PublicSharingToggleProps) {
|
||||
const localize = useLocalize();
|
||||
|
||||
return (
|
||||
<div className={`space-y-3 border-t pt-4 ${className}`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="flex items-center gap-2 text-sm font-medium">
|
||||
<Globe className="h-4 w-4" />
|
||||
{localize('com_ui_share_with_everyone')}
|
||||
</h3>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{localize('com_ui_make_agent_available_all_users')}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={isPublic}
|
||||
onCheckedChange={onPublicToggle}
|
||||
aria-label={localize('com_ui_share_with_everyone')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isPublic && (
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium">
|
||||
{localize('com_ui_public_access_level')}
|
||||
</label>
|
||||
<AccessRolesPicker
|
||||
resourceType={resourceType}
|
||||
selectedRoleId={publicRole}
|
||||
onRoleChange={onPublicRoleChange}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,31 +1,41 @@
|
|||
import React from 'react';
|
||||
import { SystemRoles } from 'librechat-data-provider';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import type { UseMutationResult } from '@tanstack/react-query';
|
||||
import '@testing-library/jest-dom/extend-expect';
|
||||
import type { Agent, AgentCreateParams, TUser } from 'librechat-data-provider';
|
||||
import AgentFooter from '../AgentFooter';
|
||||
import { Panel } from '~/common';
|
||||
import type { Agent, AgentCreateParams, TUser } from 'librechat-data-provider';
|
||||
import { SystemRoles } from 'librechat-data-provider';
|
||||
import * as reactHookForm from 'react-hook-form';
|
||||
import * as hooks from '~/hooks';
|
||||
import type { UseMutationResult } from '@tanstack/react-query';
|
||||
|
||||
const mockUseWatch = jest.fn();
|
||||
const mockUseAuthContext = jest.fn();
|
||||
const mockUseHasAccess = jest.fn();
|
||||
const mockUseResourcePermissions = jest.fn();
|
||||
|
||||
jest.mock('react-hook-form', () => ({
|
||||
useFormContext: () => ({
|
||||
control: {},
|
||||
}),
|
||||
useWatch: () => {
|
||||
return {
|
||||
agent: {
|
||||
name: 'Test Agent',
|
||||
author: 'user-123',
|
||||
projectIds: ['project-1'],
|
||||
isCollaborative: false,
|
||||
},
|
||||
id: 'agent-123',
|
||||
};
|
||||
},
|
||||
useWatch: (params) => mockUseWatch(params),
|
||||
}));
|
||||
|
||||
// Default mock implementations
|
||||
mockUseWatch.mockImplementation(({ name }) => {
|
||||
if (name === 'agent') {
|
||||
return {
|
||||
_id: 'agent-db-123',
|
||||
name: 'Test Agent',
|
||||
author: 'user-123',
|
||||
projectIds: ['project-1'],
|
||||
isCollaborative: false,
|
||||
};
|
||||
}
|
||||
if (name === 'id') {
|
||||
return 'agent-123';
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const mockUser = {
|
||||
id: 'user-123',
|
||||
username: 'testuser',
|
||||
|
|
@ -39,6 +49,26 @@ const mockUser = {
|
|||
updatedAt: '2023-01-01T00:00:00.000Z',
|
||||
} as TUser;
|
||||
|
||||
// Default auth context
|
||||
mockUseAuthContext.mockReturnValue({
|
||||
user: mockUser,
|
||||
token: 'mock-token',
|
||||
isAuthenticated: true,
|
||||
error: undefined,
|
||||
login: jest.fn(),
|
||||
logout: jest.fn(),
|
||||
setError: jest.fn(),
|
||||
roles: {},
|
||||
});
|
||||
|
||||
// Default access and permissions
|
||||
mockUseHasAccess.mockReturnValue(true);
|
||||
mockUseResourcePermissions.mockReturnValue({
|
||||
hasPermission: () => true,
|
||||
isLoading: false,
|
||||
permissionBits: 0,
|
||||
});
|
||||
|
||||
jest.mock('~/hooks', () => ({
|
||||
useLocalize: () => (key) => {
|
||||
const translations = {
|
||||
|
|
@ -47,17 +77,9 @@ jest.mock('~/hooks', () => ({
|
|||
};
|
||||
return translations[key] || key;
|
||||
},
|
||||
useAuthContext: () => ({
|
||||
user: mockUser,
|
||||
token: 'mock-token',
|
||||
isAuthenticated: true,
|
||||
error: undefined,
|
||||
login: jest.fn(),
|
||||
logout: jest.fn(),
|
||||
setError: jest.fn(),
|
||||
roles: {},
|
||||
}),
|
||||
useHasAccess: () => true,
|
||||
useAuthContext: () => mockUseAuthContext(),
|
||||
useHasAccess: () => mockUseHasAccess(),
|
||||
useResourcePermissions: () => mockUseResourcePermissions(),
|
||||
}));
|
||||
|
||||
const createBaseMutation = <T = Agent, P = any>(
|
||||
|
|
@ -126,9 +148,9 @@ jest.mock('../DeleteButton', () => ({
|
|||
default: jest.fn(() => <div data-testid="delete-button" />),
|
||||
}));
|
||||
|
||||
jest.mock('../ShareAgent', () => ({
|
||||
jest.mock('../Sharing/GrantAccessDialog', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(() => <div data-testid="share-agent" />),
|
||||
default: jest.fn(() => <div data-testid="grant-access-dialog" />),
|
||||
}));
|
||||
|
||||
jest.mock('../DuplicateAgent', () => ({
|
||||
|
|
@ -186,6 +208,40 @@ describe('AgentFooter', () => {
|
|||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
// Reset to default mock implementations
|
||||
mockUseWatch.mockImplementation(({ name }) => {
|
||||
if (name === 'agent') {
|
||||
return {
|
||||
_id: 'agent-db-123',
|
||||
name: 'Test Agent',
|
||||
author: 'user-123',
|
||||
projectIds: ['project-1'],
|
||||
isCollaborative: false,
|
||||
};
|
||||
}
|
||||
if (name === 'id') {
|
||||
return 'agent-123';
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
// Reset auth context to default user
|
||||
mockUseAuthContext.mockReturnValue({
|
||||
user: mockUser,
|
||||
token: 'mock-token',
|
||||
isAuthenticated: true,
|
||||
error: undefined,
|
||||
login: jest.fn(),
|
||||
logout: jest.fn(),
|
||||
setError: jest.fn(),
|
||||
roles: {},
|
||||
});
|
||||
// Reset access and permissions to defaults
|
||||
mockUseHasAccess.mockReturnValue(true);
|
||||
mockUseResourcePermissions.mockReturnValue({
|
||||
hasPermission: () => true,
|
||||
isLoading: false,
|
||||
permissionBits: 0,
|
||||
});
|
||||
});
|
||||
|
||||
describe('Main Functionality', () => {
|
||||
|
|
@ -196,8 +252,8 @@ describe('AgentFooter', () => {
|
|||
expect(screen.getByTestId('version-button')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('delete-button')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('admin-settings')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('share-agent')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('duplicate-agent')).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId('grant-access-dialog')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('duplicate-agent')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('spinner')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
|
@ -227,42 +283,125 @@ describe('AgentFooter', () => {
|
|||
});
|
||||
|
||||
test('adjusts UI based on agent ID existence', () => {
|
||||
jest.spyOn(reactHookForm, 'useWatch').mockImplementation(() => ({
|
||||
agent: { name: 'Test Agent', author: 'user-123' },
|
||||
id: undefined,
|
||||
}));
|
||||
mockUseWatch.mockImplementation(({ name }) => {
|
||||
if (name === 'agent') {
|
||||
return null; // No agent means no delete/share/duplicate buttons
|
||||
}
|
||||
if (name === 'id') {
|
||||
return undefined; // No ID means create mode
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
|
||||
// When there's no agent, permissions should also return false
|
||||
mockUseResourcePermissions.mockReturnValue({
|
||||
hasPermission: () => false,
|
||||
isLoading: false,
|
||||
permissionBits: 0,
|
||||
});
|
||||
|
||||
render(<AgentFooter {...defaultProps} />);
|
||||
expect(screen.getByText('Save')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('version-button')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('adjusts UI based on user role', () => {
|
||||
jest.spyOn(hooks, 'useAuthContext').mockReturnValue(createAuthContext(mockUsers.admin));
|
||||
render(<AgentFooter {...defaultProps} />);
|
||||
expect(screen.queryByTestId('admin-settings')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('share-agent')).not.toBeInTheDocument();
|
||||
|
||||
jest.clearAllMocks();
|
||||
jest.spyOn(hooks, 'useAuthContext').mockReturnValue(createAuthContext(mockUsers.different));
|
||||
render(<AgentFooter {...defaultProps} />);
|
||||
expect(screen.queryByTestId('share-agent')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('Create')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('version-button')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('delete-button')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('grant-access-dialog')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('duplicate-agent')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('adjusts UI based on permissions', () => {
|
||||
jest.spyOn(hooks, 'useHasAccess').mockReturnValue(false);
|
||||
test('adjusts UI based on user role', () => {
|
||||
mockUseAuthContext.mockReturnValue(createAuthContext(mockUsers.admin));
|
||||
const { unmount } = render(<AgentFooter {...defaultProps} />);
|
||||
expect(screen.getByTestId('admin-settings')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('grant-access-dialog')).toBeInTheDocument();
|
||||
|
||||
// Clean up the first render
|
||||
unmount();
|
||||
|
||||
jest.clearAllMocks();
|
||||
mockUseAuthContext.mockReturnValue(createAuthContext(mockUsers.different));
|
||||
mockUseWatch.mockImplementation(({ name }) => {
|
||||
if (name === 'agent') {
|
||||
return { name: 'Test Agent', author: 'different-author', _id: 'agent-123' };
|
||||
}
|
||||
if (name === 'id') {
|
||||
return 'agent-123';
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
render(<AgentFooter {...defaultProps} />);
|
||||
expect(screen.queryByTestId('share-agent')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('grant-access-dialog')).toBeInTheDocument(); // Still shows because hasAccess is true
|
||||
expect(screen.queryByTestId('duplicate-agent')).not.toBeInTheDocument(); // Should not show for different author
|
||||
});
|
||||
|
||||
test('adjusts UI based on permissions', () => {
|
||||
mockUseHasAccess.mockReturnValue(false);
|
||||
// Also need to ensure the agent is not owned by the user and user is not admin
|
||||
mockUseWatch.mockImplementation(({ name }) => {
|
||||
if (name === 'agent') {
|
||||
return {
|
||||
_id: 'agent-db-123',
|
||||
name: 'Test Agent',
|
||||
author: 'different-user', // Different author
|
||||
projectIds: ['project-1'],
|
||||
isCollaborative: false,
|
||||
};
|
||||
}
|
||||
if (name === 'id') {
|
||||
return 'agent-123';
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
// Mock permissions to not allow sharing
|
||||
mockUseResourcePermissions.mockReturnValue({
|
||||
hasPermission: () => false, // No permissions
|
||||
isLoading: false,
|
||||
permissionBits: 0,
|
||||
});
|
||||
render(<AgentFooter {...defaultProps} />);
|
||||
expect(screen.queryByTestId('grant-access-dialog')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('hides action buttons when permissions are loading', () => {
|
||||
// Ensure we have an agent that would normally show buttons
|
||||
mockUseWatch.mockImplementation(({ name }) => {
|
||||
if (name === 'agent') {
|
||||
return {
|
||||
_id: 'agent-db-123',
|
||||
name: 'Test Agent',
|
||||
author: 'user-123', // Same as current user
|
||||
projectIds: ['project-1'],
|
||||
isCollaborative: false,
|
||||
};
|
||||
}
|
||||
if (name === 'id') {
|
||||
return 'agent-123';
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
mockUseResourcePermissions.mockReturnValue({
|
||||
hasPermission: () => true,
|
||||
isLoading: true, // This should hide the buttons
|
||||
permissionBits: 0,
|
||||
});
|
||||
render(<AgentFooter {...defaultProps} />);
|
||||
expect(screen.queryByTestId('delete-button')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('grant-access-dialog')).not.toBeInTheDocument();
|
||||
// Duplicate button should still show as it doesn't depend on permissions loading
|
||||
expect(screen.getByTestId('duplicate-agent')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
test('handles null agent data', () => {
|
||||
jest.spyOn(reactHookForm, 'useWatch').mockImplementation(() => ({
|
||||
agent: null,
|
||||
id: 'agent-123',
|
||||
}));
|
||||
mockUseWatch.mockImplementation(({ name }) => {
|
||||
if (name === 'agent') {
|
||||
return null;
|
||||
}
|
||||
if (name === 'id') {
|
||||
return 'agent-123';
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
|
||||
render(<AgentFooter {...defaultProps} />);
|
||||
expect(screen.getByText('Save')).toBeInTheDocument();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue