🛡️ feat: Add Role Dropdown to Prompt/Agents Admin Settings (#4922)

* style: update AdminSettings dialog content styles for improved accessibility/theming

* style: update icon colors in ExportAndShareMenu for improved theming

* feat: enhance DropdownPopup component with additional props for customization

* feat: add role selection dropdown to AdminSettings for enhanced user permissions management

* feat: add role selection dropdown to AdminSettings for Prompt permission management

* style: add gap to button in AdminSettings for improved layout

* feat: add warning message for Admin role access in Permissions settings
This commit is contained in:
Danny Avila 2024-12-09 19:50:03 -05:00 committed by GitHub
parent 1c05251826
commit 4640e1b124
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 227 additions and 95 deletions

View file

@ -50,12 +50,12 @@ export default function ExportAndShareMenu({
{ {
label: localize('com_endpoint_export'), label: localize('com_endpoint_export'),
onClick: exportHandler, onClick: exportHandler,
icon: <Upload className="icon-md mr-2 dark:text-gray-300" />, icon: <Upload className="icon-md mr-2 text-text-secondary" />,
}, },
{ {
label: localize('com_ui_share'), label: localize('com_ui_share'),
onClick: shareHandler, onClick: shareHandler,
icon: <Share2 className="icon-md mr-2 dark:text-gray-300" />, icon: <Share2 className="icon-md mr-2 text-text-secondary" />,
show: isSharedButtonEnabled, show: isSharedButtonEnabled,
}, },
]; ];
@ -72,7 +72,7 @@ export default function ExportAndShareMenu({
aria-label="Export options" aria-label="Export options"
className="inline-flex size-10 items-center justify-center rounded-lg border border-border-light bg-transparent text-text-primary transition-all ease-in-out hover:bg-surface-tertiary disabled:pointer-events-none disabled:opacity-50 radix-state-open:bg-surface-tertiary" className="inline-flex size-10 items-center justify-center rounded-lg border border-border-light bg-transparent text-text-primary transition-all ease-in-out hover:bg-surface-tertiary disabled:pointer-events-none disabled:opacity-50 radix-state-open:bg-surface-tertiary"
> >
<Upload className="icon-md dark:text-gray-300" aria-hidden="true" focusable="false" /> <Upload className="icon-md text-text-secondary" aria-hidden="true" focusable="false" />
</Ariakit.MenuButton> </Ariakit.MenuButton>
} }
items={dropdownItems} items={dropdownItems}

View file

@ -1,4 +1,5 @@
import { useMemo, useEffect } from 'react'; import * as Ariakit from '@ariakit/react';
import { useMemo, useEffect, useState } from 'react';
import { ShieldEllipsis } from 'lucide-react'; import { ShieldEllipsis } from 'lucide-react';
import { useForm, Controller } from 'react-hook-form'; import { useForm, Controller } from 'react-hook-form';
import { Permissions, SystemRoles, roleDefaults, PermissionTypes } from 'librechat-data-provider'; import { Permissions, SystemRoles, roleDefaults, PermissionTypes } from 'librechat-data-provider';
@ -6,7 +7,7 @@ import type { Control, UseFormSetValue, UseFormGetValues } from 'react-hook-form
import { OGDialog, OGDialogTitle, OGDialogContent, OGDialogTrigger } from '~/components/ui'; import { OGDialog, OGDialogTitle, OGDialogContent, OGDialogTrigger } from '~/components/ui';
import { useUpdatePromptPermissionsMutation } from '~/data-provider'; import { useUpdatePromptPermissionsMutation } from '~/data-provider';
import { useLocalize, useAuthContext } from '~/hooks'; import { useLocalize, useAuthContext } from '~/hooks';
import { Button, Switch } from '~/components/ui'; import { Button, Switch, DropdownPopup } from '~/components/ui';
import { useToastContext } from '~/Providers'; import { useToastContext } from '~/Providers';
type FormValues = Record<Permissions, boolean>; type FormValues = Record<Permissions, boolean>;
@ -19,8 +20,6 @@ type LabelControllerProps = {
getValues: UseFormGetValues<FormValues>; getValues: UseFormGetValues<FormValues>;
}; };
const defaultValues = roleDefaults[SystemRoles.USER];
const LabelController: React.FC<LabelControllerProps> = ({ const LabelController: React.FC<LabelControllerProps> = ({
control, control,
promptPerm, promptPerm,
@ -32,7 +31,6 @@ const LabelController: React.FC<LabelControllerProps> = ({
<button <button
className="cursor-pointer select-none" className="cursor-pointer select-none"
type="button" type="button"
// htmlFor={promptPerm}
onClick={() => onClick={() =>
setValue(promptPerm, !getValues(promptPerm), { setValue(promptPerm, !getValues(promptPerm), {
shouldDirty: true, shouldDirty: true,
@ -70,6 +68,16 @@ const AdminSettings = () => {
}, },
}); });
const [isRoleMenuOpen, setIsRoleMenuOpen] = useState(false);
const [selectedRole, setSelectedRole] = useState<SystemRoles>(SystemRoles.USER);
const defaultValues = useMemo(() => {
if (roles?.[selectedRole]) {
return roles[selectedRole][PermissionTypes.PROMPTS];
}
return roleDefaults[selectedRole][PermissionTypes.PROMPTS];
}, [roles, selectedRole]);
const { const {
reset, reset,
control, control,
@ -79,20 +87,16 @@ const AdminSettings = () => {
formState: { isSubmitting }, formState: { isSubmitting },
} = useForm<FormValues>({ } = useForm<FormValues>({
mode: 'onChange', mode: 'onChange',
defaultValues: useMemo(() => { defaultValues,
if (roles?.[SystemRoles.USER]) {
return roles[SystemRoles.USER][PermissionTypes.PROMPTS];
}
return defaultValues[PermissionTypes.PROMPTS];
}, [roles]),
}); });
useEffect(() => { useEffect(() => {
if (roles?.[SystemRoles.USER]?.[PermissionTypes.PROMPTS]) { if (roles?.[selectedRole]?.[PermissionTypes.PROMPTS]) {
reset(roles[SystemRoles.USER][PermissionTypes.PROMPTS]); reset(roles[selectedRole][PermissionTypes.PROMPTS]);
} else {
reset(roleDefaults[selectedRole][PermissionTypes.PROMPTS]);
} }
}, [roles, reset]); }, [roles, selectedRole, reset]);
if (user?.role !== SystemRoles.ADMIN) { if (user?.role !== SystemRoles.ADMIN) {
return null; return null;
@ -103,20 +107,35 @@ const AdminSettings = () => {
promptPerm: Permissions.SHARED_GLOBAL, promptPerm: Permissions.SHARED_GLOBAL,
label: localize('com_ui_prompts_allow_share_global'), label: localize('com_ui_prompts_allow_share_global'),
}, },
{
promptPerm: Permissions.USE,
label: localize('com_ui_prompts_allow_use'),
},
{ {
promptPerm: Permissions.CREATE, promptPerm: Permissions.CREATE,
label: localize('com_ui_prompts_allow_create'), label: localize('com_ui_prompts_allow_create'),
}, },
{
promptPerm: Permissions.USE,
label: localize('com_ui_prompts_allow_use'),
},
]; ];
const onSubmit = (data: FormValues) => { const onSubmit = (data: FormValues) => {
mutate({ roleName: SystemRoles.USER, updates: data }); mutate({ roleName: selectedRole, updates: data });
}; };
const roleDropdownItems = [
{
label: SystemRoles.USER,
onClick: () => {
setSelectedRole(SystemRoles.USER);
},
},
{
label: SystemRoles.ADMIN,
onClick: () => {
setSelectedRole(SystemRoles.ADMIN);
},
},
];
return ( return (
<OGDialog> <OGDialog>
<OGDialogTrigger asChild> <OGDialogTrigger asChild>
@ -129,33 +148,70 @@ const AdminSettings = () => {
<span className="hidden sm:flex">{localize('com_ui_admin')}</span> <span className="hidden sm:flex">{localize('com_ui_admin')}</span>
</Button> </Button>
</OGDialogTrigger> </OGDialogTrigger>
<OGDialogContent className="bg-white dark:border-gray-700 dark:bg-gray-850 dark:text-gray-300"> <OGDialogContent className="w-1/4 border-border-light bg-surface-primary text-text-primary">
<OGDialogTitle>{`${localize('com_ui_admin_settings')} - ${localize( <OGDialogTitle>
'com_ui_prompts', {`${localize('com_ui_admin_settings')} - ${localize('com_ui_prompts')}`}
)}`}</OGDialogTitle> </OGDialogTitle>
<form className="p-2" onSubmit={handleSubmit(onSubmit)}> <div className="p-2">
<div className="py-5"> {/* Role selection dropdown */}
{labelControllerData.map(({ promptPerm, label }) => ( <div className="flex items-center gap-2">
<LabelController <span className="font-medium">{localize('com_ui_role_select')}:</span>
key={promptPerm} <DropdownPopup
control={control} menuId="prompt-role-dropdown"
promptPerm={promptPerm} isOpen={isRoleMenuOpen}
label={label} setIsOpen={setIsRoleMenuOpen}
getValues={getValues} trigger={
setValue={setValue} <Ariakit.MenuButton className="inline-flex w-1/4 items-center justify-center rounded-lg border border-border-light bg-transparent px-2 py-1 text-text-primary transition-all ease-in-out hover:bg-surface-tertiary">
/> {selectedRole}
))} </Ariakit.MenuButton>
}
items={roleDropdownItems}
className="border border-border-light bg-surface-primary"
itemClassName="hover:bg-surface-tertiary items-center justify-center"
sameWidth={true}
/>
</div> </div>
<div className="flex justify-end"> <form onSubmit={handleSubmit(onSubmit)}>
<button <div className="py-5">
type="submit" {labelControllerData.map(({ promptPerm, label }) => (
disabled={isSubmitting || isLoading} <div key={promptPerm}>
className="btn rounded bg-green-500 font-bold text-white transition-all hover:bg-green-600" <LabelController
> control={control}
{localize('com_ui_save')} promptPerm={promptPerm}
</button> label={label}
</div> getValues={getValues}
</form> setValue={setValue}
/>
{selectedRole === SystemRoles.ADMIN && promptPerm === Permissions.USE && (
<>
<div className="mb-2 max-w-full whitespace-normal break-words text-sm text-red-600">
<span>{localize('com_ui_admin_access_warning')}</span>
{'\n'}
<a
href="https://www.librechat.ai/docs/configuration/librechat_yaml/object_structure/interface"
target="_blank"
rel="noreferrer"
className="text-blue-500 underline"
>
{localize('com_ui_more_info')}
</a>
</div>
</>
)}
</div>
))}
</div>
<div className="flex justify-end">
<button
type="submit"
disabled={isSubmitting || isLoading}
className="btn rounded bg-green-500 font-bold text-white transition-all hover:bg-green-600"
>
{localize('com_ui_save')}
</button>
</div>
</form>
</div>
</OGDialogContent> </OGDialogContent>
</OGDialog> </OGDialog>
); );

View file

@ -1,12 +1,13 @@
import { useMemo, useEffect } from 'react'; import * as Ariakit from '@ariakit/react';
import { useMemo, useEffect, useState } from 'react';
import { ShieldEllipsis } from 'lucide-react'; import { ShieldEllipsis } from 'lucide-react';
import { useForm, Controller } from 'react-hook-form'; import { useForm, Controller } from 'react-hook-form';
import { Permissions, SystemRoles, roleDefaults, PermissionTypes } from 'librechat-data-provider'; import { Permissions, SystemRoles, roleDefaults, PermissionTypes } from 'librechat-data-provider';
import type { Control, UseFormSetValue, UseFormGetValues } from 'react-hook-form'; import type { Control, UseFormSetValue, UseFormGetValues } from 'react-hook-form';
import { OGDialog, OGDialogTitle, OGDialogContent, OGDialogTrigger } from '~/components/ui'; import { OGDialog, OGDialogTitle, OGDialogContent, OGDialogTrigger } from '~/components/ui';
import { useUpdateAgentPermissionsMutation } from '~/data-provider'; import { useUpdateAgentPermissionsMutation } from '~/data-provider';
import { Button, Switch, DropdownPopup } from '~/components/ui';
import { useLocalize, useAuthContext } from '~/hooks'; import { useLocalize, useAuthContext } from '~/hooks';
import { Button, Switch } from '~/components/ui';
import { useToastContext } from '~/Providers'; import { useToastContext } from '~/Providers';
type FormValues = Record<Permissions, boolean>; type FormValues = Record<Permissions, boolean>;
@ -19,8 +20,6 @@ type LabelControllerProps = {
getValues: UseFormGetValues<FormValues>; getValues: UseFormGetValues<FormValues>;
}; };
const defaultValues = roleDefaults[SystemRoles.USER];
const LabelController: React.FC<LabelControllerProps> = ({ const LabelController: React.FC<LabelControllerProps> = ({
control, control,
agentPerm, agentPerm,
@ -69,6 +68,16 @@ const AdminSettings = () => {
}, },
}); });
const [isRoleMenuOpen, setIsRoleMenuOpen] = useState(false);
const [selectedRole, setSelectedRole] = useState<SystemRoles>(SystemRoles.USER);
const defaultValues = useMemo(() => {
if (roles?.[selectedRole]) {
return roles[selectedRole][PermissionTypes.AGENTS];
}
return roleDefaults[selectedRole][PermissionTypes.AGENTS];
}, [roles, selectedRole]);
const { const {
reset, reset,
control, control,
@ -78,20 +87,16 @@ const AdminSettings = () => {
formState: { isSubmitting }, formState: { isSubmitting },
} = useForm<FormValues>({ } = useForm<FormValues>({
mode: 'onChange', mode: 'onChange',
defaultValues: useMemo(() => { defaultValues,
if (roles?.[SystemRoles.USER]) {
return roles[SystemRoles.USER][PermissionTypes.AGENTS];
}
return defaultValues[PermissionTypes.AGENTS];
}, [roles]),
}); });
useEffect(() => { useEffect(() => {
if (roles?.[SystemRoles.USER]?.[PermissionTypes.AGENTS]) { if (roles?.[selectedRole]?.[PermissionTypes.AGENTS]) {
reset(roles[SystemRoles.USER][PermissionTypes.AGENTS]); reset(roles[selectedRole][PermissionTypes.AGENTS]);
} else {
reset(roleDefaults[selectedRole][PermissionTypes.AGENTS]);
} }
}, [roles, reset]); }, [roles, selectedRole, reset]);
if (user?.role !== SystemRoles.ADMIN) { if (user?.role !== SystemRoles.ADMIN) {
return null; return null;
@ -102,59 +107,112 @@ const AdminSettings = () => {
agentPerm: Permissions.SHARED_GLOBAL, agentPerm: Permissions.SHARED_GLOBAL,
label: localize('com_ui_agents_allow_share_global'), label: localize('com_ui_agents_allow_share_global'),
}, },
{
agentPerm: Permissions.USE,
label: localize('com_ui_agents_allow_use'),
},
{ {
agentPerm: Permissions.CREATE, agentPerm: Permissions.CREATE,
label: localize('com_ui_agents_allow_create'), label: localize('com_ui_agents_allow_create'),
}, },
{
agentPerm: Permissions.USE,
label: localize('com_ui_agents_allow_use'),
},
]; ];
const onSubmit = (data: FormValues) => { const onSubmit = (data: FormValues) => {
mutate({ roleName: SystemRoles.USER, updates: data }); mutate({ roleName: selectedRole, updates: data });
}; };
const roleDropdownItems = [
{
label: SystemRoles.USER,
onClick: () => {
setSelectedRole(SystemRoles.USER);
},
},
{
label: SystemRoles.ADMIN,
onClick: () => {
setSelectedRole(SystemRoles.ADMIN);
},
},
];
return ( return (
<OGDialog> <OGDialog>
<OGDialogTrigger asChild> <OGDialogTrigger asChild>
<Button <Button
size={'sm'} size={'sm'}
variant={'outline'} variant={'outline'}
className="btn btn-neutral border-token-border-light relative my-1 h-9 w-full rounded-lg font-medium" className="btn btn-neutral border-token-border-light relative my-1 h-9 w-full gap-1 rounded-lg font-medium"
> >
<ShieldEllipsis className="cursor-pointer" /> <ShieldEllipsis className="cursor-pointer" />
{localize('com_ui_admin_settings')} {localize('com_ui_admin_settings')}
</Button> </Button>
</OGDialogTrigger> </OGDialogTrigger>
<OGDialogContent className="w-1/4 bg-white dark:border-gray-700 dark:bg-gray-850 dark:text-gray-300"> <OGDialogContent className="w-1/4 border-border-light bg-surface-primary text-text-primary">
<OGDialogTitle>{`${localize('com_ui_admin_settings')} - ${localize( <OGDialogTitle>{`${localize('com_ui_admin_settings')} - ${localize(
'com_ui_agents', 'com_ui_agents',
)}`}</OGDialogTitle> )}`}</OGDialogTitle>
<form className="p-2" onSubmit={handleSubmit(onSubmit)}> <div className="p-2">
<div className="py-5"> {/* Role selection dropdown */}
{labelControllerData.map(({ agentPerm, label }) => ( <div className="flex items-center gap-2">
<LabelController <span className="font-medium">{localize('com_ui_role_select')}:</span>
key={agentPerm} <DropdownPopup
control={control} menuId="role-dropdown"
agentPerm={agentPerm} isOpen={isRoleMenuOpen}
label={label} setIsOpen={setIsRoleMenuOpen}
getValues={getValues} trigger={
setValue={setValue} <Ariakit.MenuButton className="inline-flex w-1/4 items-center justify-center rounded-lg border border-border-light bg-transparent px-2 py-1 text-text-primary transition-all ease-in-out hover:bg-surface-tertiary">
/> {selectedRole}
))} </Ariakit.MenuButton>
}
items={roleDropdownItems}
className="border border-border-light bg-surface-primary"
itemClassName="hover:bg-surface-tertiary items-center justify-center"
sameWidth={true}
/>
</div> </div>
<div className="flex justify-end"> {/* Permissions form */}
<button <form onSubmit={handleSubmit(onSubmit)}>
type="submit" <div className="py-5">
disabled={isSubmitting || isLoading} {labelControllerData.map(({ agentPerm, label }) => (
className="btn rounded bg-green-500 font-bold text-white transition-all hover:bg-green-600" <div key={agentPerm}>
> <LabelController
{localize('com_ui_save')} control={control}
</button> agentPerm={agentPerm}
</div> label={label}
</form> getValues={getValues}
setValue={setValue}
/>
{selectedRole === SystemRoles.ADMIN && agentPerm === Permissions.USE && (
<>
<div className="mb-2 max-w-full whitespace-normal break-words text-sm text-red-600">
<span>{localize('com_ui_admin_access_warning')}</span>
{'\n'}
<a
href="https://www.librechat.ai/docs/configuration/librechat_yaml/object_structure/interface"
target="_blank"
rel="noreferrer"
className="text-blue-500 underline"
>
{localize('com_ui_more_info')}
</a>
</div>
</>
)}
</div>
))}
</div>
<div className="flex justify-end">
<button
type="submit"
disabled={isSubmitting || isLoading}
className="btn rounded bg-green-500 font-bold text-white transition-all hover:bg-green-600"
>
{localize('com_ui_save')}
</button>
</div>
</form>
</div>
</OGDialogContent> </OGDialogContent>
</OGDialog> </OGDialog>
); );

View file

@ -17,7 +17,10 @@ interface DropdownProps {
setIsOpen: (isOpen: boolean) => void; setIsOpen: (isOpen: boolean) => void;
className?: string; className?: string;
iconClassName?: string; iconClassName?: string;
itemClassName?: string;
sameWidth?: boolean;
anchor?: { x: string; y: string }; anchor?: { x: string; y: string };
gutter?: number;
modal?: boolean; modal?: boolean;
menuId: string; menuId: string;
} }
@ -29,7 +32,11 @@ const DropdownPopup: React.FC<DropdownProps> = ({
setIsOpen, setIsOpen,
menuId, menuId,
modal, modal,
gutter = 8,
sameWidth,
className,
iconClassName, iconClassName,
itemClassName,
}) => { }) => {
const menu = Ariakit.useMenuStore({ open: isOpen, setOpen: setIsOpen }); const menu = Ariakit.useMenuStore({ open: isOpen, setOpen: setIsOpen });
@ -38,9 +45,13 @@ const DropdownPopup: React.FC<DropdownProps> = ({
{trigger} {trigger}
<Ariakit.Menu <Ariakit.Menu
id={menuId} id={menuId}
className="absolute z-50 mt-2 overflow-hidden rounded-lg bg-header-primary p-1.5 shadow-lg outline-none focus-visible:ring-2 focus-visible:ring-ring-primary" className={cn(
gutter={8} 'absolute z-50 mt-2 overflow-hidden rounded-lg bg-header-primary p-1.5 shadow-lg outline-none focus-visible:ring-2 focus-visible:ring-ring-primary',
className,
)}
gutter={gutter}
modal={modal} modal={modal}
sameWidth={sameWidth}
> >
{items {items
.filter((item) => item.show !== false) .filter((item) => item.show !== false)
@ -50,7 +61,10 @@ const DropdownPopup: React.FC<DropdownProps> = ({
) : ( ) : (
<Ariakit.MenuItem <Ariakit.MenuItem
key={index} key={index}
className="group flex w-full cursor-pointer items-center gap-2 rounded-lg p-2.5 text-sm text-text-primary outline-none transition-colors duration-200 hover:bg-surface-hover focus:bg-surface-hover" className={cn(
'group flex w-full cursor-pointer items-center gap-2 rounded-lg p-2.5 text-sm text-text-primary outline-none transition-colors duration-200 hover:bg-surface-hover focus:bg-surface-hover',
itemClassName,
)}
disabled={item.disabled} disabled={item.disabled}
onClick={(event) => { onClick={(event) => {
event.preventDefault(); event.preventDefault();

View file

@ -206,6 +206,9 @@ export default {
com_ui_version_var: 'Version {0}', com_ui_version_var: 'Version {0}',
com_ui_advanced: 'Advanced', com_ui_advanced: 'Advanced',
com_ui_admin_settings: 'Admin Settings', com_ui_admin_settings: 'Admin Settings',
com_ui_admin_access_warning:
'Disabling Admin access to this feature may cause unexpected UI issues requiring refresh. If saved, the only way to revert is via the interface setting in librechat.yaml config which affects all roles.',
com_ui_role_select: 'Role',
com_ui_error_save_admin_settings: 'There was an error saving your admin settings.', com_ui_error_save_admin_settings: 'There was an error saving your admin settings.',
com_ui_prompt_preview_not_shared: 'The author has not allowed collaboration for this prompt.', com_ui_prompt_preview_not_shared: 'The author has not allowed collaboration for this prompt.',
com_ui_prompt_name_required: 'Prompt Name is required', com_ui_prompt_name_required: 'Prompt Name is required',
@ -381,6 +384,7 @@ export default {
com_ui_unarchive: 'Unarchive', com_ui_unarchive: 'Unarchive',
com_ui_unarchive_error: 'Failed to unarchive conversation', com_ui_unarchive_error: 'Failed to unarchive conversation',
com_ui_more_options: 'More', com_ui_more_options: 'More',
com_ui_more_info: 'More info',
com_ui_preview: 'Preview', com_ui_preview: 'Preview',
com_ui_upload: 'Upload', com_ui_upload: 'Upload',
com_ui_connect: 'Connect', com_ui_connect: 'Connect',