mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-02-06 01:31:49 +01:00
🔐 feat: Granular Role-based Permissions + Entra ID Group Discovery (#7804)
* feat: Add granular role-based permissions system with Entra ID integration
- Implement RBAC with viewer/editor/owner roles using bitwise permissions
- Add AccessRole, AclEntry, and Group models for permission management
- Create PermissionService for core permission logic and validation
- Integrate Microsoft Graph API for Entra ID user/group search
- Add middleware for resource access validation with custom ID resolvers
- Implement bulk permission updates with transaction support
- Create permission management UI with people picker and role selection
- Add public sharing capabilities for resources
- Include database migration for existing agent ownership
- Support hybrid local/Entra ID identity management
- Add comprehensive test coverage for all new services
chore: Update @librechat/data-schemas to version 0.0.9 and export common module in index.ts
fix: Update userGroup tests to mock logger correctly and change principalId expectation from null to undefined
* fix(data-schemas): use partial index for group idOnTheSource uniqueness
Replace sparse index with partial filter expression to allow multiple local groups
while maintaining unique constraint for external source IDs. The sparse option
on compound indexes doesn't work as expected when one field is always present.
* fix: imports in migrate-agent-permissions.js
* chore(data-schemas): add comprehensive README for data schemas package
- Introduced a detailed README.md file outlining the structure, architecture patterns, and best practices for the LibreChat Data Schemas package.
- Included guidelines for creating new entities, type definitions, schema files, model factory functions, and database methods.
- Added examples and common patterns to enhance understanding and usage of the package.
* chore: remove unused translation keys from localization file
* ci: fix existing tests based off new permission handling
- Renamed test cases to reflect changes in permission checks being handled at the route level.
- Updated assertions to verify that agents are returned regardless of user permissions due to the new permission system.
- Adjusted mocks in AppService and PermissionService tests to ensure proper functionality without relying on actual implementations.
* ci: add unit tests for access control middleware
- Introduced tests for the `canAccessAgentResource` middleware to validate permission checks for agent resources.
- Implemented tests for various scenarios including user roles, ACL entries, and permission levels.
- Added tests for the `checkAccess` function to ensure proper permission handling based on user roles and permissions.
- Utilized MongoDB in-memory server for isolated test environments.
* refactor: remove unused mocks from GraphApiService tests
* ci: enhance AgentFooter tests with improved mocks and permission handling
- Updated mocks for `useWatch`, `useAuthContext`, `useHasAccess`, and `useResourcePermissions` to streamline test setup.
- Adjusted assertions to reflect changes in UI based on agent ID and user roles.
- Replaced `share-agent` component with `grant-access-dialog` in tests to align with recent UI updates.
- Added tests for handling null agent data and permissions loading scenarios.
* ci: enhance GraphApiService tests with MongoDB in-memory server
- Updated test setup to use MongoDB in-memory server for isolated testing.
- Refactored beforeEach to beforeAll for database connection management.
- Cleared database before each test to ensure a clean state.
- Retained existing mocks while improving test structure for better clarity.
* ci: enhance GraphApiService tests with additional logger mocks
- Added mock implementation for logger methods in GraphApiService tests to improve error and debug logging during test execution.
- Ensured existing mocks remain intact while enhancing test coverage and clarity.
* chore: address ESLint Warnings
* - add cursor-based pagination to getListAgentsByAccess and update handler
- add index on updatedAt and _id in agent schema for improved query performance
* refactor permission service with reuse of model methods from data-schema package
* - Fix ObjectId comparison in getListAgentsHandler using .equals() method instead of strict equality
- Add findPubliclyAccessibleResources function to PermissionService for bulk public resource queries
- Add hasPublicPermission function to PermissionService for individual resource public permission checks
- Update getAgentHandler to use hasPublicPermission for accurate individual agent public status
- Replace instanceProjectId-based global checks with isPublic property from backend in client code
- Add isPublic property to Agent type definition
- Add NODE_TLS_REJECT_UNAUTHORIZED debug setting to VS Code launch config
* feat: add check for People.Read scope in searchContacts
* fix: add roleId parameter to grantPermission and update tests for GraphApiService
* refactor: remove problematic projection pipelines in getResourcePermissions for document db aws compatibility
* feat: enhance agent permissions migration with DocumentDB compatibility and add dry-run script
* feat: add support for including Entra ID group owners as members in permissions management + fix Group members paging
* feat: enforce at least one owner requirement for permission updates and add corresponding localization messages
* refactor: remove German locale (must be added via i18n)
* chore: linting in `api/models/Agent.js` and removed unused variables
* chore: linting, remove unused vars, and remove project-related parameters from `updateAgentHandler`
* chore: address ESLint errors
* chore: revert removal of unused vars for versioning
---------
Co-authored-by: Atef Bellaaj <slalom.bellaaj@external.daimlertruck.com>
This commit is contained in:
parent
01e9b196bc
commit
65c81955f0
99 changed files with 11322 additions and 624 deletions
|
|
@ -7,6 +7,7 @@ export type TAgentOption = OptionWithIcon &
|
|||
knowledge_files?: Array<[string, ExtendedFile]>;
|
||||
context_files?: Array<[string, ExtendedFile]>;
|
||||
code_files?: Array<[string, ExtendedFile]>;
|
||||
_id?: string;
|
||||
};
|
||||
|
||||
export type TAgentCapabilities = {
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
@ -242,19 +264,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,272 +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();
|
||||
|
|
|
|||
|
|
@ -97,7 +97,13 @@ const Dropdown: React.FC<DropdownProps> = ({
|
|||
<Select.SelectPopover
|
||||
portal={portal}
|
||||
store={selectProps}
|
||||
className={cn('popover-ui', sizeClasses, className, 'max-h-[80vh] overflow-y-auto')}
|
||||
className={cn(
|
||||
'popover-ui',
|
||||
sizeClasses,
|
||||
className,
|
||||
'max-h-[80vh] overflow-y-auto',
|
||||
'[pointer-events:auto]', // Override body's pointer-events:none when in modal
|
||||
)}
|
||||
>
|
||||
{options.map((item, index) => {
|
||||
if (isDivider(item)) {
|
||||
|
|
|
|||
192
client/src/components/ui/SearchPicker.tsx
Normal file
192
client/src/components/ui/SearchPicker.tsx
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import * as Ariakit from '@ariakit/react';
|
||||
import { Search, X } from 'lucide-react';
|
||||
import { cn } from '~/utils';
|
||||
import { Spinner } from '~/components/svg';
|
||||
import { Skeleton } from '~/components/ui';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
type SearchPickerProps<TOption extends { key: string }> = {
|
||||
options: TOption[];
|
||||
renderOptions: (option: TOption) => React.ReactElement;
|
||||
query: string;
|
||||
onQueryChange: (query: string) => void;
|
||||
onPick: (pickedOption: TOption) => void;
|
||||
placeholder?: string;
|
||||
inputClassName?: string;
|
||||
label: string;
|
||||
resetValueOnHide?: boolean;
|
||||
isSmallScreen?: boolean;
|
||||
isLoading?: boolean;
|
||||
minQueryLengthForNoResults?: number;
|
||||
};
|
||||
|
||||
export function SearchPicker<TOption extends { key: string; value: string }>({
|
||||
options,
|
||||
renderOptions,
|
||||
onPick,
|
||||
onQueryChange,
|
||||
query,
|
||||
label,
|
||||
isSmallScreen = false,
|
||||
placeholder,
|
||||
resetValueOnHide = false,
|
||||
isLoading = false,
|
||||
minQueryLengthForNoResults = 2,
|
||||
}: SearchPickerProps<TOption>) {
|
||||
const localize = useLocalize();
|
||||
const [_open, setOpen] = React.useState(false);
|
||||
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||
const combobox = Ariakit.useComboboxStore({
|
||||
resetValueOnHide,
|
||||
});
|
||||
const onPickHandler = (option: TOption) => {
|
||||
onQueryChange('');
|
||||
onPick(option);
|
||||
setOpen(false);
|
||||
if (inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
}
|
||||
};
|
||||
const showClearIcon = query.trim().length > 0;
|
||||
const clearText = () => {
|
||||
onQueryChange('');
|
||||
if (inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
}
|
||||
};
|
||||
return (
|
||||
<Ariakit.ComboboxProvider store={combobox}>
|
||||
<Ariakit.ComboboxLabel className="text-token-text-primary mb-2 block font-medium">
|
||||
{label}
|
||||
</Ariakit.ComboboxLabel>
|
||||
<div className="py-1.5">
|
||||
<div
|
||||
className={cn(
|
||||
'group relative mt-1 flex h-10 cursor-pointer items-center gap-3 rounded-lg border-border-medium px-3 py-2 text-text-primary transition-colors duration-200 focus-within:bg-surface-hover hover:bg-surface-hover',
|
||||
isSmallScreen === true ? 'mb-2 h-14 rounded-2xl' : '',
|
||||
)}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Spinner className="absolute left-3 h-4 w-4 text-text-primary" />
|
||||
) : (
|
||||
<Search className="absolute left-3 h-4 w-4 text-text-secondary group-focus-within:text-text-primary group-hover:text-text-primary" />
|
||||
)}
|
||||
<Ariakit.Combobox
|
||||
ref={inputRef}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Escape' && combobox.getState().open) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onQueryChange('');
|
||||
setOpen(false);
|
||||
}
|
||||
}}
|
||||
store={combobox}
|
||||
setValueOnClick={false}
|
||||
setValueOnChange={false}
|
||||
onChange={(e) => {
|
||||
onQueryChange(e.target.value);
|
||||
}}
|
||||
value={query}
|
||||
// autoSelect
|
||||
placeholder={placeholder || localize('com_ui_select_options')}
|
||||
className="m-0 mr-0 w-full rounded-md border-none bg-transparent p-0 py-2 pl-9 pr-3 text-sm leading-tight text-text-primary placeholder-text-secondary placeholder-opacity-100 focus:outline-none focus-visible:outline-none group-focus-within:placeholder-text-primary group-hover:placeholder-text-primary"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`${localize('com_ui_clear')} ${localize('com_ui_search')}`}
|
||||
className={cn(
|
||||
'absolute right-[7px] flex h-5 w-5 items-center justify-center rounded-full border-none bg-transparent p-0 transition-opacity duration-200',
|
||||
showClearIcon ? 'opacity-100' : 'opacity-0',
|
||||
isSmallScreen === true ? 'right-[16px]' : '',
|
||||
)}
|
||||
onClick={clearText}
|
||||
tabIndex={showClearIcon ? 0 : -1}
|
||||
disabled={!showClearIcon}
|
||||
>
|
||||
<X className="h-5 w-5 cursor-pointer" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<Ariakit.ComboboxPopover
|
||||
portal={false} //todo fix focus when set to true
|
||||
gutter={10}
|
||||
// sameWidth
|
||||
open={
|
||||
isLoading ||
|
||||
options.length > 0 ||
|
||||
(query.trim().length >= minQueryLengthForNoResults && !isLoading)
|
||||
}
|
||||
store={combobox}
|
||||
unmountOnHide
|
||||
autoFocusOnShow={false}
|
||||
modal={false}
|
||||
className={cn(
|
||||
'animate-popover z-[9999] min-w-64 overflow-hidden rounded-xl border border-border-light bg-surface-secondary shadow-lg',
|
||||
'[pointer-events:auto]', // Override body's pointer-events:none when in modal
|
||||
)}
|
||||
>
|
||||
{(() => {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-2 p-2">
|
||||
{Array.from({ length: 3 }).map((_, index) => (
|
||||
<div key={index} className="flex items-center gap-3 px-3 py-2">
|
||||
<Skeleton className="h-8 w-8 rounded-full" />
|
||||
<div className="flex-1 space-y-1">
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
<Skeleton className="h-3 w-1/2" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (options.length > 0) {
|
||||
return options.map((o) => (
|
||||
<Ariakit.ComboboxItem
|
||||
key={o.key}
|
||||
focusOnHover
|
||||
// hideOnClick
|
||||
value={o.value}
|
||||
selectValueOnClick={false}
|
||||
onClick={() => onPickHandler(o)}
|
||||
className={cn(
|
||||
'flex w-full cursor-pointer items-center px-3 text-sm',
|
||||
'text-text-primary hover:bg-surface-tertiary',
|
||||
'data-[active-item]:bg-surface-tertiary',
|
||||
)}
|
||||
render={renderOptions(o)}
|
||||
></Ariakit.ComboboxItem>
|
||||
));
|
||||
}
|
||||
|
||||
if (query.trim().length >= minQueryLengthForNoResults) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center justify-center px-4 py-8 text-center',
|
||||
'text-sm text-text-secondary',
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<Search className="h-8 w-8 text-text-tertiary opacity-50" />
|
||||
<div className="font-medium">{localize('com_ui_no_results_found')}</div>
|
||||
<div className="text-xs text-text-tertiary">
|
||||
{localize('com_ui_try_adjusting_search')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
})()}
|
||||
</Ariakit.ComboboxPopover>
|
||||
</Ariakit.ComboboxProvider>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import React from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import { Root, Trigger, Content, Portal } from '@radix-ui/react-popover';
|
||||
import MenuItem from '~/components/Chat/Menus/UI/MenuItem';
|
||||
import type { Option } from '~/common';
|
||||
|
|
@ -32,6 +32,7 @@ function SelectDropDownPop({
|
|||
footer,
|
||||
}: SelectDropDownProps) {
|
||||
const localize = useLocalize();
|
||||
const [open, setOpen] = useState(false);
|
||||
const transitionProps = { className: 'top-full mt-3' };
|
||||
if (showAbove) {
|
||||
transitionProps.className = 'bottom-full mb-3';
|
||||
|
|
@ -54,8 +55,13 @@ function SelectDropDownPop({
|
|||
const hasSearchRender = Boolean(searchRender);
|
||||
const options = hasSearchRender ? filteredValues : availableValues;
|
||||
|
||||
const handleSelect = (selectedValue: string) => {
|
||||
setValue(selectedValue);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Root>
|
||||
<Root open={open} onOpenChange={setOpen}>
|
||||
<div className={'flex items-center justify-center gap-2'}>
|
||||
<div className={'relative w-full'}>
|
||||
<Trigger asChild>
|
||||
|
|
@ -108,19 +114,32 @@ function SelectDropDownPop({
|
|||
side="bottom"
|
||||
align="start"
|
||||
className={cn(
|
||||
'mr-3 mt-2 max-h-[52vh] w-full max-w-[85vw] overflow-hidden overflow-y-auto rounded-lg border border-gray-200 bg-white shadow-lg dark:border-gray-700 dark:bg-gray-700 dark:text-white sm:max-w-full lg:max-h-[52vh]',
|
||||
'z-50 mr-3 mt-2 max-h-[52vh] w-full max-w-[85vw] overflow-hidden overflow-y-auto rounded-lg border border-gray-200 bg-white shadow-lg dark:border-gray-700 dark:bg-gray-700 dark:text-white sm:max-w-full lg:max-h-[52vh]',
|
||||
hasSearchRender && 'relative',
|
||||
)}
|
||||
>
|
||||
{searchRender}
|
||||
{options.map((option) => {
|
||||
if (typeof option === 'string') {
|
||||
return (
|
||||
<MenuItem
|
||||
key={option}
|
||||
title={option}
|
||||
value={option}
|
||||
selected={!!(value && value === option)}
|
||||
onClick={() => handleSelect(option)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<MenuItem
|
||||
key={option}
|
||||
title={option}
|
||||
value={option}
|
||||
selected={!!(value && value === option)}
|
||||
onClick={() => setValue(option)}
|
||||
key={option.value}
|
||||
title={option.label}
|
||||
description={option.description}
|
||||
value={option.value}
|
||||
icon={option.icon}
|
||||
selected={!!(value && value === option.value)}
|
||||
onClick={() => handleSelect(option.value)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -83,6 +83,10 @@ export const useUpdateAgentMutation = (
|
|||
});
|
||||
|
||||
queryClient.setQueryData<t.Agent>([QueryKeys.agent, variables.agent_id], updatedAgent);
|
||||
queryClient.setQueryData<t.Agent>(
|
||||
[QueryKeys.agent, variables.agent_id, 'expanded'],
|
||||
updatedAgent,
|
||||
);
|
||||
return options?.onSuccess?.(updatedAgent, variables, context);
|
||||
},
|
||||
},
|
||||
|
|
@ -121,6 +125,7 @@ export const useDeleteAgentMutation = (
|
|||
});
|
||||
|
||||
queryClient.removeQueries([QueryKeys.agent, variables.agent_id]);
|
||||
queryClient.removeQueries([QueryKeys.agent, variables.agent_id, 'expanded']);
|
||||
|
||||
return options?.onSuccess?.(_data, variables, data);
|
||||
},
|
||||
|
|
@ -241,6 +246,10 @@ export const useUpdateAgentAction = (
|
|||
});
|
||||
|
||||
queryClient.setQueryData<t.Agent>([QueryKeys.agent, variables.agent_id], updatedAgent);
|
||||
queryClient.setQueryData<t.Agent>(
|
||||
[QueryKeys.agent, variables.agent_id, 'expanded'],
|
||||
updatedAgent,
|
||||
);
|
||||
return options?.onSuccess?.(updateAgentActionResponse, variables, context);
|
||||
},
|
||||
});
|
||||
|
|
@ -293,8 +302,7 @@ export const useDeleteAgentAction = (
|
|||
};
|
||||
},
|
||||
);
|
||||
|
||||
queryClient.setQueryData<t.Agent>([QueryKeys.agent, variables.agent_id], (prev) => {
|
||||
const updaterFn = (prev) => {
|
||||
if (!prev) {
|
||||
return prev;
|
||||
}
|
||||
|
|
@ -303,7 +311,12 @@ export const useDeleteAgentAction = (
|
|||
...prev,
|
||||
tools: prev.tools?.filter((tool) => !tool.includes(domain ?? '')),
|
||||
};
|
||||
});
|
||||
};
|
||||
queryClient.setQueryData<t.Agent>([QueryKeys.agent, variables.agent_id], updaterFn);
|
||||
queryClient.setQueryData<t.Agent>(
|
||||
[QueryKeys.agent, variables.agent_id, 'expanded'],
|
||||
updaterFn,
|
||||
);
|
||||
return options?.onSuccess?.(_data, variables, context);
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -54,7 +54,7 @@ export const useListAgentsQuery = <TData = t.AgentListResponse>(
|
|||
};
|
||||
|
||||
/**
|
||||
* Hook for retrieving details about a single agent
|
||||
* Hook for retrieving basic details about a single agent (VIEW permission)
|
||||
*/
|
||||
export const useGetAgentByIdQuery = (
|
||||
agent_id: string,
|
||||
|
|
@ -75,3 +75,26 @@ export const useGetAgentByIdQuery = (
|
|||
},
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook for retrieving full agent details including sensitive configuration (EDIT permission)
|
||||
*/
|
||||
export const useGetExpandedAgentByIdQuery = (
|
||||
agent_id: string,
|
||||
config?: UseQueryOptions<t.Agent>,
|
||||
): QueryObserverResult<t.Agent> => {
|
||||
return useQuery<t.Agent>(
|
||||
[QueryKeys.agent, agent_id, 'expanded'],
|
||||
() =>
|
||||
dataService.getExpandedAgentById({
|
||||
agent_id,
|
||||
}),
|
||||
{
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
refetchOnMount: false,
|
||||
retry: false,
|
||||
...config,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -125,8 +125,7 @@ export const useEndpoints = ({
|
|||
if (ep === EModelEndpoint.agents && agents.length > 0) {
|
||||
result.models = agents.map((agent) => ({
|
||||
name: agent.id,
|
||||
isGlobal:
|
||||
(instanceProjectId != null && agent.projectIds?.includes(instanceProjectId)) ?? false,
|
||||
isGlobal: agent.isPublic ?? false,
|
||||
}));
|
||||
result.agentNames = agents.reduce((acc, agent) => {
|
||||
acc[agent.id] = agent.name || '';
|
||||
|
|
|
|||
|
|
@ -35,3 +35,4 @@ export { default as useOnClickOutside } from './useOnClickOutside';
|
|||
export { default as useSpeechToText } from './Input/useSpeechToText';
|
||||
export { default as useTextToSpeech } from './Input/useTextToSpeech';
|
||||
export { default as useGenerationsByLatest } from './useGenerationsByLatest';
|
||||
export { useResourcePermissions } from './useResourcePermissions';
|
||||
|
|
|
|||
25
client/src/hooks/useResourcePermissions.ts
Normal file
25
client/src/hooks/useResourcePermissions.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import {
|
||||
useGetEffectivePermissionsQuery,
|
||||
hasPermissions,
|
||||
} from 'librechat-data-provider/react-query';
|
||||
|
||||
/**
|
||||
* fetches resource permissions once and returns a function to check any permission
|
||||
* More efficient when checking multiple permissions for the same resource
|
||||
* @param resourceType - Type of resource (e.g., 'agent')
|
||||
* @param resourceId - ID of the resource
|
||||
* @returns Object with hasPermission function and loading state
|
||||
*/
|
||||
export const useResourcePermissions = (resourceType: string, resourceId: string) => {
|
||||
const { data, isLoading } = useGetEffectivePermissionsQuery(resourceType, resourceId);
|
||||
|
||||
const hasPermission = (requiredPermission: number): boolean => {
|
||||
return data ? hasPermissions(data.permissionBits, requiredPermission) : false;
|
||||
};
|
||||
|
||||
return {
|
||||
hasPermission,
|
||||
isLoading,
|
||||
permissionBits: data?.permissionBits || 0,
|
||||
};
|
||||
};
|
||||
|
|
@ -919,4 +919,4 @@
|
|||
"com_ui_zoom": "Zoom",
|
||||
"com_user_message": "Du",
|
||||
"com_warning_resubmit_unsupported": "Das erneute Senden der KI-Nachricht wird für diesen Endpunkt nicht unterstützt."
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@
|
|||
"com_a11y_ai_composing": "The AI is still composing.",
|
||||
"com_a11y_end": "The AI has finished their reply.",
|
||||
"com_a11y_start": "The AI has started their reply.",
|
||||
"com_agents_allow_editing": "Allow other users to edit your agent",
|
||||
"com_agents_by_librechat": "by LibreChat",
|
||||
"com_agents_code_interpreter": "When enabled, allows your agent to leverage the LibreChat Code Interpreter API to run generated code, including file processing, securely. Requires a valid API key.",
|
||||
"com_agents_code_interpreter_title": "Code Interpreter API",
|
||||
|
|
@ -530,10 +529,8 @@
|
|||
"com_ui_agent_deleted": "Successfully deleted agent",
|
||||
"com_ui_agent_duplicate_error": "There was an error duplicating the agent",
|
||||
"com_ui_agent_duplicated": "Agent duplicated successfully",
|
||||
"com_ui_agent_editing_allowed": "Other users can already edit this agent",
|
||||
"com_ui_agent_recursion_limit": "Max Agent Steps",
|
||||
"com_ui_agent_recursion_limit_info": "Limits how many steps the agent can take in a run before giving a final response. Default is 25 steps. A step is either an AI API request or a tool usage round. For example, a basic tool interaction takes 3 steps: initial request, tool usage, and follow-up request.",
|
||||
"com_ui_agent_shared_to_all": "something needs to go here. was empty",
|
||||
"com_ui_agent_var": "{{0}} agent",
|
||||
"com_ui_agent_version": "Version",
|
||||
"com_ui_agent_version_active": "Active Version",
|
||||
|
|
@ -644,6 +641,19 @@
|
|||
"com_ui_copy_code": "Copy code",
|
||||
"com_ui_copy_link": "Copy link",
|
||||
"com_ui_copy_to_clipboard": "Copy to clipboard",
|
||||
"com_ui_copy_url_to_clipboard": "Copy URL to clipboard",
|
||||
"com_ui_agent_url_copied": "Agent URL copied to clipboard",
|
||||
"com_ui_search_people_placeholder": "Search for people or groups by name or email",
|
||||
"com_ui_permission_level": "Permission Level",
|
||||
"com_ui_grant_access": "Grant Access",
|
||||
"com_ui_granting": "Granting...",
|
||||
"com_ui_search_users_groups": "Search Users and Groups",
|
||||
"com_ui_search_default_placeholder": "Search by name or email (min 2 chars)",
|
||||
"com_ui_user": "User",
|
||||
"com_ui_group": "Group",
|
||||
"com_ui_search_above_to_add": "Search above to add users or groups",
|
||||
"com_ui_azure_ad": "Entra ID",
|
||||
"com_ui_remove_user": "Remove {{0}}",
|
||||
"com_ui_create": "Create",
|
||||
"com_ui_create_link": "Create link",
|
||||
"com_ui_create_memory": "Create Memory",
|
||||
|
|
@ -854,7 +864,6 @@
|
|||
"com_ui_no_backup_codes": "No backup codes available. Please generate new ones",
|
||||
"com_ui_no_bookmarks": "it seems like you have no bookmarks yet. Click on a chat and add a new one",
|
||||
"com_ui_no_category": "No category",
|
||||
"com_ui_no_changes": "No changes to update",
|
||||
"com_ui_no_data": "something needs to go here. was empty",
|
||||
"com_ui_no_personalization_available": "No personalization options are currently available",
|
||||
"com_ui_no_read_access": "You don't have permission to view memories",
|
||||
|
|
@ -1055,5 +1064,37 @@
|
|||
"com_ui_yes": "Yes",
|
||||
"com_ui_zoom": "Zoom",
|
||||
"com_user_message": "You",
|
||||
"com_warning_resubmit_unsupported": "Resubmitting the AI message is not supported for this endpoint."
|
||||
}
|
||||
"com_warning_resubmit_unsupported": "Resubmitting the AI message is not supported for this endpoint.",
|
||||
"com_ui_select_options": "Select options...",
|
||||
"com_ui_no_results_found": "No results found",
|
||||
"com_ui_try_adjusting_search": "Try adjusting your search terms",
|
||||
"com_ui_role_viewer": "Viewer",
|
||||
"com_ui_role_editor": "Editor",
|
||||
"com_ui_role_manager": "Manager",
|
||||
"com_ui_role_owner": "Owner",
|
||||
"com_ui_role_viewer_desc": "Can view and use the agent but cannot modify it",
|
||||
"com_ui_role_editor_desc": "Can view and modify the agent",
|
||||
"com_ui_role_manager_desc": "Can view, modify, and delete the agent",
|
||||
"com_ui_role_owner_desc": "Has full control over the agent including sharing it",
|
||||
"com_ui_permissions_failed_load": "Failed to load permissions. Please try again.",
|
||||
"com_ui_permissions_updated_success": "Permissions updated successfully",
|
||||
"com_ui_permissions_failed_update": "Failed to update permissions. Please try again.",
|
||||
"com_ui_manage_permissions_for": "Manage Permissions for",
|
||||
"com_ui_current_access": "Current Access",
|
||||
"com_ui_no_users_groups_access": "No users or groups have access",
|
||||
"com_ui_shared_with_count": "Shared with {{0}} {{1}}{{2}}",
|
||||
"com_ui_person": "person",
|
||||
"com_ui_people": "people",
|
||||
"com_ui_and_public": " and public",
|
||||
"com_ui_revoke_all": "Revoke All",
|
||||
"com_ui_loading_permissions": "Loading permissions...",
|
||||
"com_ui_user_group_permissions": "User & Group Permissions",
|
||||
"com_ui_no_individual_access": "No individual users or groups have access to this agent",
|
||||
"com_ui_public_access": "Public Access",
|
||||
"com_ui_save_changes": "Save Changes",
|
||||
"com_ui_unsaved_changes": "You have unsaved changes",
|
||||
"com_ui_share_with_everyone": "Share with everyone",
|
||||
"com_ui_make_agent_available_all_users": "Make this agent available to all LibreChat users",
|
||||
"com_ui_public_access_level": "Public access level",
|
||||
"com_ui_at_least_one_owner_required": "At least one owner is required"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -63,8 +63,7 @@ export const processAgentOption = ({
|
|||
fileMap?: Record<string, TFile | undefined>;
|
||||
instanceProjectId?: string;
|
||||
}): TAgentOption => {
|
||||
const isGlobal =
|
||||
(instanceProjectId != null && _agent?.projectIds?.includes(instanceProjectId)) ?? false;
|
||||
const isGlobal = _agent?.isPublic ?? false;
|
||||
const agent: TAgentOption = {
|
||||
...(_agent ?? ({} as Agent)),
|
||||
label: _agent?.name ?? '',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue