diff --git a/api/server/controllers/PermissionsController.js b/api/server/controllers/PermissionsController.js index fab559396..f44aebae7 100644 --- a/api/server/controllers/PermissionsController.js +++ b/api/server/controllers/PermissionsController.js @@ -364,7 +364,7 @@ const getUserEffectivePermissions = async (req, res) => { */ const searchPrincipals = async (req, res) => { try { - const { q: query, limit = 20, type } = req.query; + const { q: query, limit = 20, types } = req.query; if (!query || query.trim().length === 0) { return res.status(400).json({ @@ -379,22 +379,34 @@ const searchPrincipals = async (req, res) => { } const searchLimit = Math.min(Math.max(1, parseInt(limit) || 10), 50); - const typeFilter = [PrincipalType.USER, PrincipalType.GROUP, PrincipalType.ROLE].includes(type) - ? type - : null; - const localResults = await searchLocalPrincipals(query.trim(), searchLimit, typeFilter); + let typeFilters = null; + if (types) { + const typesArray = Array.isArray(types) ? types : types.split(','); + const validTypes = typesArray.filter((t) => + [PrincipalType.USER, PrincipalType.GROUP, PrincipalType.ROLE].includes(t), + ); + typeFilters = validTypes.length > 0 ? validTypes : null; + } + + const localResults = await searchLocalPrincipals(query.trim(), searchLimit, typeFilters); let allPrincipals = [...localResults]; const useEntraId = entraIdPrincipalFeatureEnabled(req.user); if (useEntraId && localResults.length < searchLimit) { try { - const graphTypeMap = { - user: 'users', - group: 'groups', - null: 'all', - }; + let graphType = 'all'; + if (typeFilters && typeFilters.length === 1) { + const graphTypeMap = { + [PrincipalType.USER]: 'users', + [PrincipalType.GROUP]: 'groups', + }; + const mappedType = graphTypeMap[typeFilters[0]]; + if (mappedType) { + graphType = mappedType; + } + } const authHeader = req.headers.authorization; const accessToken = @@ -405,7 +417,7 @@ const searchPrincipals = async (req, res) => { accessToken, req.user.openidId, query.trim(), - graphTypeMap[typeFilter], + graphType, searchLimit - localResults.length, ); @@ -436,21 +448,22 @@ const searchPrincipals = async (req, res) => { _searchScore: calculateRelevanceScore(item, query.trim()), })); - allPrincipals = sortPrincipalsByRelevance(scoredResults) + const finalResults = sortPrincipalsByRelevance(scoredResults) .slice(0, searchLimit) .map((result) => { const { _searchScore, ...resultWithoutScore } = result; return resultWithoutScore; }); + res.status(200).json({ query: query.trim(), limit: searchLimit, - type: typeFilter, - results: allPrincipals, - count: allPrincipals.length, + types: typeFilters, + results: finalResults, + count: finalResults.length, sources: { - local: allPrincipals.filter((r) => r.source === 'local').length, - entra: allPrincipals.filter((r) => r.source === 'entra').length, + local: finalResults.filter((r) => r.source === 'local').length, + entra: finalResults.filter((r) => r.source === 'entra').length, }, }); } catch (error) { diff --git a/client/src/components/Sharing/PeoplePicker/PeoplePicker.tsx b/client/src/components/Sharing/PeoplePicker/PeoplePicker.tsx deleted file mode 100644 index 181df6cad..000000000 --- a/client/src/components/Sharing/PeoplePicker/PeoplePicker.tsx +++ /dev/null @@ -1,103 +0,0 @@ -import React, { useState, useMemo } from 'react'; -import { PrincipalType } from 'librechat-data-provider'; -import type { TPrincipal, PrincipalSearchParams } from 'librechat-data-provider'; -import { useSearchPrincipalsQuery } from 'librechat-data-provider/react-query'; -import PeoplePickerSearchItem from './PeoplePickerSearchItem'; -import SelectedPrincipalsList from './SelectedPrincipalsList'; -import { SearchPicker } from './SearchPicker'; -import { useLocalize } from '~/hooks'; - -interface PeoplePickerProps { - onSelectionChange: (principals: TPrincipal[]) => void; - placeholder?: string; - className?: string; - typeFilter?: PrincipalType.USER | PrincipalType.GROUP | PrincipalType.ROLE | null; -} - -export default function PeoplePicker({ - onSelectionChange, - placeholder, - className = '', - typeFilter = null, -}: PeoplePickerProps) { - const localize = useLocalize(); - const [searchQuery, setSearchQuery] = useState(''); - const [selectedShares, setSelectedShares] = useState([]); - - const searchParams: PrincipalSearchParams = useMemo( - () => ({ - q: searchQuery, - limit: 30, - ...(typeFilter && { type: typeFilter }), - }), - [searchQuery, typeFilter], - ); - - const { - data: searchResponse, - isLoading: queryIsLoading, - error, - } = useSearchPrincipalsQuery(searchParams, { - enabled: searchQuery.length >= 2, - }); - - const isLoading = searchQuery.length >= 2 && queryIsLoading; - - const selectableResults = useMemo(() => { - const results = searchResponse?.results || []; - - return results.filter( - (result) => !selectedShares.some((share) => share.idOnTheSource === result.idOnTheSource), - ); - }, [searchResponse?.results, selectedShares]); - - if (error) { - console.error('Principal search error:', error); - } - - return ( -
-
- - 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) => } - 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(''); - }} - isLoading={isLoading} - /> -
- - { - setSelectedShares((prev) => { - const newArray = prev.filter((share) => share.idOnTheSource !== idOnTheSource); - onSelectionChange(newArray); - return newArray; - }); - }} - /> -
- ); -} diff --git a/client/src/components/Sharing/PeoplePicker/UnifiedPeopleSearch.tsx b/client/src/components/Sharing/PeoplePicker/UnifiedPeopleSearch.tsx index 27c20574a..05092bed2 100644 --- a/client/src/components/Sharing/PeoplePicker/UnifiedPeopleSearch.tsx +++ b/client/src/components/Sharing/PeoplePicker/UnifiedPeopleSearch.tsx @@ -9,7 +9,7 @@ interface UnifiedPeopleSearchProps { onAddPeople: (principals: TPrincipal[]) => void; placeholder?: string; className?: string; - typeFilter?: PrincipalType.USER | PrincipalType.GROUP | PrincipalType.ROLE | null; + typeFilter?: Array | null; excludeIds?: (string | undefined)[]; } @@ -27,7 +27,7 @@ export default function UnifiedPeopleSearch({ () => ({ q: searchQuery, limit: 30, - ...(typeFilter && { type: typeFilter }), + ...(typeFilter && typeFilter.length > 0 && { types: typeFilter }), }), [searchQuery, typeFilter], ); diff --git a/client/src/components/Sharing/PeoplePicker/index.ts b/client/src/components/Sharing/PeoplePicker/index.ts index c1b8ace82..db793484a 100644 --- a/client/src/components/Sharing/PeoplePicker/index.ts +++ b/client/src/components/Sharing/PeoplePicker/index.ts @@ -1,4 +1,3 @@ -export { default as PeoplePicker } from './PeoplePicker'; export { default as PeoplePickerSearchItem } from './PeoplePickerSearchItem'; export { default as SelectedPrincipalsList } from './SelectedPrincipalsList'; export { default as UnifiedPeopleSearch } from './UnifiedPeopleSearch'; diff --git a/client/src/hooks/Sharing/usePeoplePickerPermissions.ts b/client/src/hooks/Sharing/usePeoplePickerPermissions.ts index 940439105..d8579d37a 100644 --- a/client/src/hooks/Sharing/usePeoplePickerPermissions.ts +++ b/client/src/hooks/Sharing/usePeoplePickerPermissions.ts @@ -24,21 +24,28 @@ export const usePeoplePickerPermissions = () => { const hasPeoplePickerAccess = canViewUsers || canViewGroups || canViewRoles; - const peoplePickerTypeFilter: - | PrincipalType.USER - | PrincipalType.GROUP - | PrincipalType.ROLE - | null = useMemo(() => { - if (canViewUsers && canViewGroups && canViewRoles) { - return null; // All types allowed - } else if (canViewUsers) { - return PrincipalType.USER; - } else if (canViewGroups) { - return PrincipalType.GROUP; - } else if (canViewRoles) { - return PrincipalType.ROLE; + const peoplePickerTypeFilter: Array< + PrincipalType.USER | PrincipalType.GROUP | PrincipalType.ROLE + > | null = useMemo(() => { + const allowedTypes: Array = []; + + if (canViewUsers) { + allowedTypes.push(PrincipalType.USER); } - return null; + if (canViewGroups) { + allowedTypes.push(PrincipalType.GROUP); + } + if (canViewRoles) { + allowedTypes.push(PrincipalType.ROLE); + } + + // Return null if no types are allowed (will show no results) + // or if all types are allowed (no filtering needed) + if (allowedTypes.length === 0 || allowedTypes.length === 3) { + return null; + } + + return allowedTypes; }, [canViewUsers, canViewGroups, canViewRoles]); return { diff --git a/packages/data-provider/src/api-endpoints.ts b/packages/data-provider/src/api-endpoints.ts index 866c35469..6c3c2b4f9 100644 --- a/packages/data-provider/src/api-endpoints.ts +++ b/packages/data-provider/src/api-endpoints.ts @@ -315,15 +315,15 @@ export const memory = (key: string) => `${memories()}/${encodeURIComponent(key)} export const memoryPreferences = () => `${memories()}/preferences`; export const searchPrincipals = (params: q.PrincipalSearchParams) => { - const { q: query, limit, type } = params; + const { q: query, limit, types } = params; let url = `/api/permissions/search-principals?q=${encodeURIComponent(query)}`; if (limit !== undefined) { url += `&limit=${limit}`; } - if (type !== undefined) { - url += `&type=${type}`; + if (types && types.length > 0) { + url += `&types=${types.join(',')}`; } return url; diff --git a/packages/data-provider/src/types/queries.ts b/packages/data-provider/src/types/queries.ts index f5ebebb2c..0aa78d054 100644 --- a/packages/data-provider/src/types/queries.ts +++ b/packages/data-provider/src/types/queries.ts @@ -129,13 +129,13 @@ export type MemoriesResponse = { export type PrincipalSearchParams = { q: string; limit?: number; - type?: p.PrincipalType.USER | p.PrincipalType.GROUP | p.PrincipalType.ROLE; + types?: Array; }; export type PrincipalSearchResponse = { query: string; limit: number; - type?: p.PrincipalType.USER | p.PrincipalType.GROUP | p.PrincipalType.ROLE; + types?: Array; results: p.TPrincipalSearchResult[]; count: number; sources: { diff --git a/packages/data-schemas/src/methods/userGroup.roles.spec.ts b/packages/data-schemas/src/methods/userGroup.roles.spec.ts index fa50c6f4c..da9184df7 100644 --- a/packages/data-schemas/src/methods/userGroup.roles.spec.ts +++ b/packages/data-schemas/src/methods/userGroup.roles.spec.ts @@ -234,7 +234,7 @@ describe('Role-based Permissions Integration', () => { }); test('should filter search results by role type', async () => { - const results = await methods.searchPrincipals('mod', 10, PrincipalType.ROLE); + const results = await methods.searchPrincipals('mod', 10, [PrincipalType.ROLE]); expect(results.every((r) => r.type === PrincipalType.ROLE)).toBe(true); expect(results).toHaveLength(1); @@ -247,7 +247,7 @@ describe('Role-based Permissions Integration', () => { await Role.create({ name: `testrole${i}` }); } - const results = await methods.searchPrincipals('testrole', 5, PrincipalType.ROLE); + const results = await methods.searchPrincipals('testrole', 5, [PrincipalType.ROLE]); expect(results).toHaveLength(5); expect(results.every((r) => r.type === PrincipalType.ROLE)).toBe(true); @@ -275,14 +275,14 @@ describe('Role-based Permissions Integration', () => { }); test('should handle case-insensitive role search', async () => { - const results = await methods.searchPrincipals('ADMIN', 10, PrincipalType.ROLE); + const results = await methods.searchPrincipals('ADMIN', 10, [PrincipalType.ROLE]); expect(results).toHaveLength(1); expect(results[0].name).toBe('admin'); }); test('should return empty array for no role matches', async () => { - const results = await methods.searchPrincipals('nonexistentrole', 10, PrincipalType.ROLE); + const results = await methods.searchPrincipals('nonexistentrole', 10, [PrincipalType.ROLE]); expect(results).toEqual([]); }); diff --git a/packages/data-schemas/src/methods/userGroup.ts b/packages/data-schemas/src/methods/userGroup.ts index 2464e35c9..bec28343f 100644 --- a/packages/data-schemas/src/methods/userGroup.ts +++ b/packages/data-schemas/src/methods/userGroup.ts @@ -495,14 +495,14 @@ export function createUserGroupMethods(mongoose: typeof import('mongoose')) { * Returns combined results in TPrincipalSearchResult format without sorting * @param searchPattern - The pattern to search for * @param limitPerType - Maximum number of results to return - * @param typeFilter - Optional filter: PrincipalType.USER, PrincipalType.GROUP, or null for all + * @param typeFilter - Optional array of types to filter by, or null for all types * @param session - Optional MongoDB session for transactions * @returns Array of principals in TPrincipalSearchResult format */ async function searchPrincipals( searchPattern: string, limitPerType: number = 10, - typeFilter: PrincipalType.USER | PrincipalType.GROUP | PrincipalType.ROLE | null = null, + typeFilter: Array | null = null, session?: ClientSession, ): Promise { if (!searchPattern || searchPattern.trim().length === 0) { @@ -512,7 +512,7 @@ export function createUserGroupMethods(mongoose: typeof import('mongoose')) { const trimmedPattern = searchPattern.trim(); const promises: Promise[] = []; - if (!typeFilter || typeFilter === PrincipalType.USER) { + if (!typeFilter || typeFilter.includes(PrincipalType.USER)) { /** Note: searchUsers is imported from ~/models and needs to be passed in or implemented */ const userFields = 'name email username avatar provider idOnTheSource'; /** For now, we'll use a direct query instead of searchUsers */ @@ -547,7 +547,7 @@ export function createUserGroupMethods(mongoose: typeof import('mongoose')) { promises.push(Promise.resolve([])); } - if (!typeFilter || typeFilter === PrincipalType.GROUP) { + if (!typeFilter || typeFilter.includes(PrincipalType.GROUP)) { promises.push( findGroupsByNamePattern(trimmedPattern, null, limitPerType, session).then((groups) => groups.map(transformGroupToTPrincipalSearchResult), @@ -557,7 +557,7 @@ export function createUserGroupMethods(mongoose: typeof import('mongoose')) { promises.push(Promise.resolve([])); } - if (!typeFilter || typeFilter === PrincipalType.ROLE) { + if (!typeFilter || typeFilter.includes(PrincipalType.ROLE)) { const Role = mongoose.models.Role as Model; if (Role) { const regex = new RegExp(trimmedPattern, 'i'); @@ -575,6 +575,7 @@ export function createUserGroupMethods(mongoose: typeof import('mongoose')) { type: PrincipalType.ROLE, name: role.name, source: 'local' as const, + idOnTheSource: role.name, })), ), );