mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-09-21 21:50:49 +02:00
🔄 refactor: Principal Type Handling in Search Principals to use Array
This commit is contained in:
parent
dcd96c29c5
commit
803ade8601
9 changed files with 68 additions and 151 deletions
|
@ -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) {
|
||||
|
|
|
@ -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<TPrincipal[]>([]);
|
||||
|
||||
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 (
|
||||
<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('');
|
||||
}}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SelectedPrincipalsList
|
||||
principles={selectedShares}
|
||||
onRemoveHandler={(idOnTheSource: string) => {
|
||||
setSelectedShares((prev) => {
|
||||
const newArray = prev.filter((share) => share.idOnTheSource !== idOnTheSource);
|
||||
onSelectionChange(newArray);
|
||||
return newArray;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -9,7 +9,7 @@ interface UnifiedPeopleSearchProps {
|
|||
onAddPeople: (principals: TPrincipal[]) => void;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
typeFilter?: PrincipalType.USER | PrincipalType.GROUP | PrincipalType.ROLE | null;
|
||||
typeFilter?: Array<PrincipalType.USER | PrincipalType.GROUP | PrincipalType.ROLE> | 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],
|
||||
);
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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<PrincipalType.USER | PrincipalType.GROUP | PrincipalType.ROLE> = [];
|
||||
|
||||
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 {
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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<p.PrincipalType.USER | p.PrincipalType.GROUP | p.PrincipalType.ROLE>;
|
||||
};
|
||||
|
||||
export type PrincipalSearchResponse = {
|
||||
query: string;
|
||||
limit: number;
|
||||
type?: p.PrincipalType.USER | p.PrincipalType.GROUP | p.PrincipalType.ROLE;
|
||||
types?: Array<p.PrincipalType.USER | p.PrincipalType.GROUP | p.PrincipalType.ROLE>;
|
||||
results: p.TPrincipalSearchResult[];
|
||||
count: number;
|
||||
sources: {
|
||||
|
|
|
@ -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([]);
|
||||
});
|
||||
|
|
|
@ -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<PrincipalType.USER | PrincipalType.GROUP | PrincipalType.ROLE> | null = null,
|
||||
session?: ClientSession,
|
||||
): Promise<TPrincipalSearchResult[]> {
|
||||
if (!searchPattern || searchPattern.trim().length === 0) {
|
||||
|
@ -512,7 +512,7 @@ export function createUserGroupMethods(mongoose: typeof import('mongoose')) {
|
|||
const trimmedPattern = searchPattern.trim();
|
||||
const promises: Promise<TPrincipalSearchResult[]>[] = [];
|
||||
|
||||
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<IRole>;
|
||||
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,
|
||||
})),
|
||||
),
|
||||
);
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue