🛂 feat: Role as Permission Principal Type

WIP: Role as Permission Principal Type

WIP: add user role check optimization to user principal check, update type comparisons

WIP: cover edge cases for string vs ObjectId handling in permission granting and checking

chore: Update people picker access middleware to use PrincipalType constants

feat: Enhance people picker access control to include roles permissions

chore: add missing default role schema values for people picker perms, cleanup typing

feat: Enhance PeoplePicker component with role-specific UI and localization updates

chore: Add missing `VIEW_ROLES` permission to role schema
This commit is contained in:
Danny Avila 2025-08-03 19:24:40 -04:00
parent 28d63dab71
commit 39346d6b8e
No known key found for this signature in database
GPG key ID: BF31EEB2C5CA0956
49 changed files with 2879 additions and 258 deletions

View file

@ -4,8 +4,8 @@ import {
SystemRoles,
Permissions,
ResourceType,
PermissionTypes,
PermissionBits,
PermissionTypes,
} from 'librechat-data-provider';
import { Button } from '@librechat/client';
import type { TPromptGroup } from 'librechat-data-provider';

View file

@ -36,7 +36,7 @@ export default function GenericGrantAccessDialog({
resourceId?: string | null;
resourceName?: string;
resourceType: ResourceType;
onGrantAccess?: (shares: TPrincipal[], isPublic: boolean, publicRole: AccessRoleIds) => void;
onGrantAccess?: (shares: TPrincipal[], isPublic: boolean, publicRole?: AccessRoleIds) => void;
disabled?: boolean;
children?: React.ReactNode;
}) {

View file

@ -30,7 +30,7 @@ export default function GenericManagePermissionsDialog({
onUpdatePermissions?: (
shares: TPrincipal[],
isPublic: boolean,
publicRole: AccessRoleIds,
publicRole?: AccessRoleIds,
) => void;
children?: React.ReactNode;
}) {
@ -84,7 +84,7 @@ export default function GenericManagePermissionsDialog({
setHasChanges(true);
};
const handleRoleChange = (idOnTheSource: string, newRole: string) => {
const handleRoleChange = (idOnTheSource: string, newRole: AccessRoleIds) => {
setManagedShares(
managedShares.map((s) =>
s.idOnTheSource === idOnTheSource ? { ...s, accessRoleId: newRole } : s,
@ -162,7 +162,7 @@ export default function GenericManagePermissionsDialog({
setManagedPublicRole(config?.defaultViewerRoleId);
}
};
const handlePublicRoleChange = (role: string) => {
const handlePublicRoleChange = (role: AccessRoleIds) => {
setManagedPublicRole(role);
setHasChanges(true);
};

View file

@ -1,6 +1,6 @@
import React, { useState, useEffect, useMemo } from 'react';
import React, { useState, useEffect } from 'react';
import { ResourceType, AccessRoleIds } from 'librechat-data-provider';
import { Share2Icon, Users, Loader, Shield, Link, CopyCheck } from 'lucide-react';
import { Permissions, ResourceType, PermissionTypes, AccessRoleIds } from 'librechat-data-provider';
import {
useGetResourcePermissionsQuery,
useUpdateResourcePermissionsMutation,
@ -15,7 +15,7 @@ import {
useToastContext,
} from '@librechat/client';
import type { TPrincipal } from 'librechat-data-provider';
import { useLocalize, useCopyToClipboard, useHasAccess } from '~/hooks';
import { useLocalize, useCopyToClipboard, usePeoplePickerPermissions } from '~/hooks';
import ManagePermissionsDialog from './ManagePermissionsDialog';
import PublicSharingToggle from './PublicSharingToggle';
import AccessRolesPicker from './AccessRolesPicker';
@ -37,29 +37,7 @@ export default function GrantAccessDialog({
}) {
const localize = useLocalize();
const { showToast } = useToastContext();
// Check if user has permission to access people picker
const canViewUsers = useHasAccess({
permissionType: PermissionTypes.PEOPLE_PICKER,
permission: Permissions.VIEW_USERS,
});
const canViewGroups = useHasAccess({
permissionType: PermissionTypes.PEOPLE_PICKER,
permission: Permissions.VIEW_GROUPS,
});
const hasPeoplePickerAccess = canViewUsers || canViewGroups;
/** Type filter based on permissions */
const peoplePickerTypeFilter = useMemo(() => {
if (canViewUsers && canViewGroups) {
return null; // Both types allowed
} else if (canViewUsers) {
return 'user' as const;
} else if (canViewGroups) {
return 'group' as const;
}
return null;
}, [canViewUsers, canViewGroups]);
const { hasPeoplePickerAccess, peoplePickerTypeFilter } = usePeoplePickerPermissions();
const {
data: permissionsData,
@ -72,7 +50,7 @@ export default function GrantAccessDialog({
const updatePermissionsMutation = useUpdateResourcePermissionsMutation();
const [newShares, setNewShares] = useState<TPrincipal[]>([]);
const [defaultPermissionId, setDefaultPermissionId] = useState<string>(
const [defaultPermissionId, setDefaultPermissionId] = useState<AccessRoleIds>(
AccessRoleIds.AGENT_VIEWER,
);
const [isModalOpen, setIsModalOpen] = useState(false);

View file

@ -1,16 +1,17 @@
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 { useLocalize, usePeoplePickerPermissions } from '~/hooks';
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?: 'user' | 'group' | null;
typeFilter?: PrincipalType.USER | PrincipalType.GROUP | PrincipalType.ROLE | null;
}
export default function PeoplePicker({
@ -20,6 +21,7 @@ export default function PeoplePicker({
typeFilter = null,
}: PeoplePickerProps) {
const localize = useLocalize();
const { canViewUsers, canViewGroups, canViewRoles } = usePeoplePickerPermissions();
const [searchQuery, setSearchQuery] = useState('');
const [selectedShares, setSelectedShares] = useState<TPrincipal[]>([]);
@ -54,6 +56,28 @@ export default function PeoplePicker({
console.error('Principal search error:', error);
}
/** Get appropriate label based on permissions */
const getSearchLabel = () => {
const permissions = [canViewUsers, canViewGroups, canViewRoles];
const permissionCount = permissions.filter(Boolean).length;
if (permissionCount === 3) {
return localize('com_ui_search_users_groups_roles');
} else if (permissionCount === 2) {
if (canViewUsers && canViewGroups) {
return localize('com_ui_search_users_groups');
}
} else if (canViewUsers) {
return localize('com_ui_search_users');
} else if (canViewGroups) {
return localize('com_ui_search_groups');
} else if (canViewRoles) {
return localize('com_ui_search_roles');
}
return localize('com_ui_search_users_groups');
};
return (
<div className={`space-y-3 ${className}`}>
<div className="relative">
@ -83,7 +107,7 @@ export default function PeoplePicker({
});
setSearchQuery('');
}}
label={localize('com_ui_search_users_groups')}
label={getSearchLabel()}
isLoading={isLoading}
/>
</div>

View file

@ -1,8 +1,9 @@
import React, { forwardRef } from 'react';
import { PrincipalType } from 'librechat-data-provider';
import type { TPrincipal } from 'librechat-data-provider';
import { cn } from '~/utils';
import PrincipalAvatar from '~/components/Sharing/PrincipalAvatar';
import { useLocalize } from '~/hooks';
import PrincipalAvatar from '../PrincipalAvatar';
import { cn } from '~/utils';
interface PeoplePickerSearchItemProps extends React.HTMLAttributes<HTMLDivElement> {
principal: TPrincipal;
@ -16,10 +17,37 @@ const PeoplePickerSearchItem = forwardRef<HTMLDivElement, PeoplePickerSearchItem
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'})`;
/** Get badge styling based on type */
const getBadgeConfig = () => {
switch (type) {
case PrincipalType.USER:
return {
className: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300',
label: localize('com_ui_user'),
};
case PrincipalType.GROUP:
return {
className: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300',
label: localize('com_ui_group'),
};
case PrincipalType.ROLE:
return {
className: 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-300',
label: localize('com_ui_role'),
};
default:
return {
className: 'bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-300',
label: type,
};
}
};
const badgeConfig = getBadgeConfig();
return (
<div
{...props}
@ -41,12 +69,10 @@ const PeoplePickerSearchItem = forwardRef<HTMLDivElement, PeoplePickerSearchItem
<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',
badgeConfig.className,
)}
>
{type === 'user' ? localize('com_ui_user') : localize('com_ui_group')}
{badgeConfig.label}
</span>
</div>
</div>

View file

@ -3,8 +3,8 @@ import * as Menu from '@ariakit/react/menu';
import { Button, DropdownPopup } from '@librechat/client';
import { Users, X, ExternalLink, ChevronDown } from 'lucide-react';
import type { TPrincipal, TAccessRole, AccessRoleIds } from 'librechat-data-provider';
import PrincipalAvatar from '~/components/Sharing/PrincipalAvatar';
import { getRoleLocalizationKeys } from '~/utils';
import PrincipalAvatar from '../PrincipalAvatar';
import { useLocalize } from '~/hooks';
interface SelectedPrincipalsListProps {
@ -36,7 +36,7 @@ export default function SelectedPrincipalsList({
<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>
<p className="mt-1 text-xs">{localize('com_ui_search_above_to_add_all')}</p>
</div>
</div>
);

View file

@ -1,5 +1,6 @@
import React from 'react';
import { Users, User } from 'lucide-react';
import { Users, User, Shield } from 'lucide-react';
import { PrincipalType } from 'librechat-data-provider';
import type { TPrincipal } from 'librechat-data-provider';
import { cn } from '~/utils';
@ -17,7 +18,6 @@ export default function PrincipalAvatar({
const { avatar, type, name } = principal;
const displayName = name || 'Unknown';
// Size variants
const sizeClasses = {
sm: 'h-6 w-6',
md: 'h-8 w-8',
@ -33,7 +33,38 @@ export default function PrincipalAvatar({
const avatarSizeClass = sizeClasses[size];
const iconSizeClass = iconSizeClasses[size];
// Avatar or icon logic
/** Get icon component and styling based on type */
const getIconConfig = () => {
switch (type) {
case PrincipalType.USER:
return {
Icon: User,
containerClass: 'bg-blue-100 dark:bg-blue-900',
iconClass: 'text-blue-600 dark:text-blue-400',
};
case PrincipalType.GROUP:
return {
Icon: Users,
containerClass: 'bg-green-100 dark:bg-green-900',
iconClass: 'text-green-600 dark:text-green-400',
};
case PrincipalType.ROLE:
return {
Icon: Shield,
containerClass: 'bg-purple-100 dark:bg-purple-900',
iconClass: 'text-purple-600 dark:text-purple-400',
};
default:
return {
Icon: User,
containerClass: 'bg-gray-100 dark:bg-gray-900',
iconClass: 'text-gray-600 dark:text-gray-400',
};
}
};
const { Icon, containerClass, iconClass } = getIconConfig();
if (avatar) {
return (
<div className={cn('flex-shrink-0', className)}>
@ -50,52 +81,31 @@ export default function PrincipalAvatar({
/>
{/* 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
className={cn(
avatarSizeClass,
'flex items-center justify-center rounded-full',
containerClass,
)}
>
<Icon className={cn(iconSizeClass, iconClass)} />
</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
className={cn(
avatarSizeClass,
'flex items-center justify-center rounded-full',
containerClass,
)}
>
<Icon className={cn(iconSizeClass, iconClass)} />
</div>
</div>
);
}

View file

@ -14,7 +14,7 @@ export default function PublicSharingToggle({
resourceType = ResourceType.AGENT,
}: {
isPublic: boolean;
publicRole: AccessRoleIds;
publicRole?: AccessRoleIds;
onPublicToggle: (isPublic: boolean) => void;
onPublicRoleChange: (role: AccessRoleIds) => void;
resourceType?: ResourceType;

View file

@ -4,8 +4,8 @@ import {
SystemRoles,
Permissions,
ResourceType,
PermissionTypes,
PermissionBits,
PermissionTypes,
} from 'librechat-data-provider';
import type { AgentForm, AgentPanelProps } from '~/common';
import { useLocalize, useAuthContext, useHasAccess, useResourcePermissions } from '~/hooks';
@ -43,7 +43,7 @@ export default function AgentFooter({
permission: Permissions.SHARED_GLOBAL,
});
const { hasPermission, isLoading: permissionsLoading } = useResourcePermissions(
'agent',
ResourceType.AGENT,
agent?._id || '',
);

View file

@ -1,5 +1,5 @@
import { useMemo } from 'react';
import { PermissionTypes, Permissions } from 'librechat-data-provider';
import { PermissionTypes, PrincipalType, Permissions } from 'librechat-data-provider';
import { useHasAccess } from '~/hooks';
/**
@ -17,21 +17,33 @@ export const usePeoplePickerPermissions = () => {
permission: Permissions.VIEW_GROUPS,
});
const hasPeoplePickerAccess = canViewUsers || canViewGroups;
const canViewRoles = useHasAccess({
permissionType: PermissionTypes.PEOPLE_PICKER,
permission: Permissions.VIEW_ROLES,
});
const peoplePickerTypeFilter = useMemo(() => {
if (canViewUsers && canViewGroups) {
return null; // Both types allowed
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 'user' as const;
return PrincipalType.USER;
} else if (canViewGroups) {
return 'group' as const;
return PrincipalType.GROUP;
} else if (canViewRoles) {
return PrincipalType.ROLE;
}
return null;
}, [canViewUsers, canViewGroups]);
}, [canViewUsers, canViewGroups, canViewRoles]);
return {
canViewUsers,
canViewRoles,
canViewGroups,
hasPeoplePickerAccess,
peoplePickerTypeFilter,

View file

@ -700,10 +700,16 @@
"com_ui_grant_access": "Grant Access",
"com_ui_granting": "Granting...",
"com_ui_search_users_groups": "Search Users and Groups",
"com_ui_search_users_groups_roles": "Search Users, Groups, and Roles",
"com_ui_search_users": "Search Users",
"com_ui_search_groups": "Search Groups",
"com_ui_search_roles": "Search Roles",
"com_ui_search_default_placeholder": "Search by name or email (min 2 chars)",
"com_ui_user": "User",
"com_ui_group": "Group",
"com_ui_role": "Role",
"com_ui_search_above_to_add": "Search above to add users or groups",
"com_ui_search_above_to_add_all": "Search above to add users, groups, or roles",
"com_ui_azure_ad": "Entra ID",
"com_ui_remove_user": "Remove {{0}}",
"com_ui_create": "Create",