🛡️ 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'),
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'),
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,
},
];
@ -72,7 +72,7 @@ export default function ExportAndShareMenu({
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"
>
<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>
}
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 { useForm, Controller } from 'react-hook-form';
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 { useUpdatePromptPermissionsMutation } from '~/data-provider';
import { useLocalize, useAuthContext } from '~/hooks';
import { Button, Switch } from '~/components/ui';
import { Button, Switch, DropdownPopup } from '~/components/ui';
import { useToastContext } from '~/Providers';
type FormValues = Record<Permissions, boolean>;
@ -19,8 +20,6 @@ type LabelControllerProps = {
getValues: UseFormGetValues<FormValues>;
};
const defaultValues = roleDefaults[SystemRoles.USER];
const LabelController: React.FC<LabelControllerProps> = ({
control,
promptPerm,
@ -32,7 +31,6 @@ const LabelController: React.FC<LabelControllerProps> = ({
<button
className="cursor-pointer select-none"
type="button"
// htmlFor={promptPerm}
onClick={() =>
setValue(promptPerm, !getValues(promptPerm), {
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 {
reset,
control,
@ -79,20 +87,16 @@ const AdminSettings = () => {
formState: { isSubmitting },
} = useForm<FormValues>({
mode: 'onChange',
defaultValues: useMemo(() => {
if (roles?.[SystemRoles.USER]) {
return roles[SystemRoles.USER][PermissionTypes.PROMPTS];
}
return defaultValues[PermissionTypes.PROMPTS];
}, [roles]),
defaultValues,
});
useEffect(() => {
if (roles?.[SystemRoles.USER]?.[PermissionTypes.PROMPTS]) {
reset(roles[SystemRoles.USER][PermissionTypes.PROMPTS]);
if (roles?.[selectedRole]?.[PermissionTypes.PROMPTS]) {
reset(roles[selectedRole][PermissionTypes.PROMPTS]);
} else {
reset(roleDefaults[selectedRole][PermissionTypes.PROMPTS]);
}
}, [roles, reset]);
}, [roles, selectedRole, reset]);
if (user?.role !== SystemRoles.ADMIN) {
return null;
@ -103,20 +107,35 @@ const AdminSettings = () => {
promptPerm: Permissions.SHARED_GLOBAL,
label: localize('com_ui_prompts_allow_share_global'),
},
{
promptPerm: Permissions.USE,
label: localize('com_ui_prompts_allow_use'),
},
{
promptPerm: Permissions.CREATE,
label: localize('com_ui_prompts_allow_create'),
},
{
promptPerm: Permissions.USE,
label: localize('com_ui_prompts_allow_use'),
},
];
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 (
<OGDialog>
<OGDialogTrigger asChild>
@ -129,33 +148,70 @@ const AdminSettings = () => {
<span className="hidden sm:flex">{localize('com_ui_admin')}</span>
</Button>
</OGDialogTrigger>
<OGDialogContent className="bg-white dark:border-gray-700 dark:bg-gray-850 dark:text-gray-300">
<OGDialogTitle>{`${localize('com_ui_admin_settings')} - ${localize(
'com_ui_prompts',
)}`}</OGDialogTitle>
<form className="p-2" onSubmit={handleSubmit(onSubmit)}>
<div className="py-5">
{labelControllerData.map(({ promptPerm, label }) => (
<LabelController
key={promptPerm}
control={control}
promptPerm={promptPerm}
label={label}
getValues={getValues}
setValue={setValue}
/>
))}
<OGDialogContent className="w-1/4 border-border-light bg-surface-primary text-text-primary">
<OGDialogTitle>
{`${localize('com_ui_admin_settings')} - ${localize('com_ui_prompts')}`}
</OGDialogTitle>
<div className="p-2">
{/* Role selection dropdown */}
<div className="flex items-center gap-2">
<span className="font-medium">{localize('com_ui_role_select')}:</span>
<DropdownPopup
menuId="prompt-role-dropdown"
isOpen={isRoleMenuOpen}
setIsOpen={setIsRoleMenuOpen}
trigger={
<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 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>
<form onSubmit={handleSubmit(onSubmit)}>
<div className="py-5">
{labelControllerData.map(({ promptPerm, label }) => (
<div key={promptPerm}>
<LabelController
control={control}
promptPerm={promptPerm}
label={label}
getValues={getValues}
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>
</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 { useForm, Controller } from 'react-hook-form';
import { Permissions, SystemRoles, roleDefaults, PermissionTypes } from 'librechat-data-provider';
import type { Control, UseFormSetValue, UseFormGetValues } from 'react-hook-form';
import { OGDialog, OGDialogTitle, OGDialogContent, OGDialogTrigger } from '~/components/ui';
import { useUpdateAgentPermissionsMutation } from '~/data-provider';
import { Button, Switch, DropdownPopup } from '~/components/ui';
import { useLocalize, useAuthContext } from '~/hooks';
import { Button, Switch } from '~/components/ui';
import { useToastContext } from '~/Providers';
type FormValues = Record<Permissions, boolean>;
@ -19,8 +20,6 @@ type LabelControllerProps = {
getValues: UseFormGetValues<FormValues>;
};
const defaultValues = roleDefaults[SystemRoles.USER];
const LabelController: React.FC<LabelControllerProps> = ({
control,
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 {
reset,
control,
@ -78,20 +87,16 @@ const AdminSettings = () => {
formState: { isSubmitting },
} = useForm<FormValues>({
mode: 'onChange',
defaultValues: useMemo(() => {
if (roles?.[SystemRoles.USER]) {
return roles[SystemRoles.USER][PermissionTypes.AGENTS];
}
return defaultValues[PermissionTypes.AGENTS];
}, [roles]),
defaultValues,
});
useEffect(() => {
if (roles?.[SystemRoles.USER]?.[PermissionTypes.AGENTS]) {
reset(roles[SystemRoles.USER][PermissionTypes.AGENTS]);
if (roles?.[selectedRole]?.[PermissionTypes.AGENTS]) {
reset(roles[selectedRole][PermissionTypes.AGENTS]);
} else {
reset(roleDefaults[selectedRole][PermissionTypes.AGENTS]);
}
}, [roles, reset]);
}, [roles, selectedRole, reset]);
if (user?.role !== SystemRoles.ADMIN) {
return null;
@ -102,59 +107,112 @@ const AdminSettings = () => {
agentPerm: Permissions.SHARED_GLOBAL,
label: localize('com_ui_agents_allow_share_global'),
},
{
agentPerm: Permissions.USE,
label: localize('com_ui_agents_allow_use'),
},
{
agentPerm: Permissions.CREATE,
label: localize('com_ui_agents_allow_create'),
},
{
agentPerm: Permissions.USE,
label: localize('com_ui_agents_allow_use'),
},
];
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 (
<OGDialog>
<OGDialogTrigger asChild>
<Button
size={'sm'}
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" />
{localize('com_ui_admin_settings')}
</Button>
</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(
'com_ui_agents',
)}`}</OGDialogTitle>
<form className="p-2" onSubmit={handleSubmit(onSubmit)}>
<div className="py-5">
{labelControllerData.map(({ agentPerm, label }) => (
<LabelController
key={agentPerm}
control={control}
agentPerm={agentPerm}
label={label}
getValues={getValues}
setValue={setValue}
/>
))}
<div className="p-2">
{/* Role selection dropdown */}
<div className="flex items-center gap-2">
<span className="font-medium">{localize('com_ui_role_select')}:</span>
<DropdownPopup
menuId="role-dropdown"
isOpen={isRoleMenuOpen}
setIsOpen={setIsRoleMenuOpen}
trigger={
<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 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>
{/* Permissions form */}
<form onSubmit={handleSubmit(onSubmit)}>
<div className="py-5">
{labelControllerData.map(({ agentPerm, label }) => (
<div key={agentPerm}>
<LabelController
control={control}
agentPerm={agentPerm}
label={label}
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>
</OGDialog>
);

View file

@ -17,7 +17,10 @@ interface DropdownProps {
setIsOpen: (isOpen: boolean) => void;
className?: string;
iconClassName?: string;
itemClassName?: string;
sameWidth?: boolean;
anchor?: { x: string; y: string };
gutter?: number;
modal?: boolean;
menuId: string;
}
@ -29,7 +32,11 @@ const DropdownPopup: React.FC<DropdownProps> = ({
setIsOpen,
menuId,
modal,
gutter = 8,
sameWidth,
className,
iconClassName,
itemClassName,
}) => {
const menu = Ariakit.useMenuStore({ open: isOpen, setOpen: setIsOpen });
@ -38,9 +45,13 @@ const DropdownPopup: React.FC<DropdownProps> = ({
{trigger}
<Ariakit.Menu
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"
gutter={8}
className={cn(
'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}
sameWidth={sameWidth}
>
{items
.filter((item) => item.show !== false)
@ -50,7 +61,10 @@ const DropdownPopup: React.FC<DropdownProps> = ({
) : (
<Ariakit.MenuItem
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}
onClick={(event) => {
event.preventDefault();

View file

@ -206,6 +206,9 @@ export default {
com_ui_version_var: 'Version {0}',
com_ui_advanced: 'Advanced',
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_prompt_preview_not_shared: 'The author has not allowed collaboration for this prompt.',
com_ui_prompt_name_required: 'Prompt Name is required',
@ -381,6 +384,7 @@ export default {
com_ui_unarchive: 'Unarchive',
com_ui_unarchive_error: 'Failed to unarchive conversation',
com_ui_more_options: 'More',
com_ui_more_info: 'More info',
com_ui_preview: 'Preview',
com_ui_upload: 'Upload',
com_ui_connect: 'Connect',