Skip to content

Commit

Permalink
🔒 feat: RBAC for Multi-Convo Feature (#3964)
Browse files Browse the repository at this point in the history
* fix: remove duplicate keys in German language translations

* wip: multi-convo role permissions

* ci: Update loadDefaultInterface tests due to MULTI_CONVO

* ci: update Role.spec.js with tests for MULTI_CONVO permission type

* fix: Update ContentParts component to handle undefined content array

* feat: render Multi-Convo based on UI permissions
  • Loading branch information
danny-avila authored Sep 9, 2024
1 parent d59b621 commit 748b41e
Show file tree
Hide file tree
Showing 18 changed files with 302 additions and 56 deletions.
2 changes: 2 additions & 0 deletions api/models/Role.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ const {
agentPermissionsSchema,
promptPermissionsSchema,
bookmarkPermissionsSchema,
multiConvoPermissionsSchema,
} = require('librechat-data-provider');
const getLogStores = require('~/cache/getLogStores');
const Role = require('~/models/schema/roleSchema');
Expand Down Expand Up @@ -75,6 +76,7 @@ const permissionSchemas = {
[PermissionTypes.AGENTS]: agentPermissionsSchema,
[PermissionTypes.PROMPTS]: promptPermissionsSchema,
[PermissionTypes.BOOKMARKS]: bookmarkPermissionsSchema,
[PermissionTypes.MULTI_CONVO]: multiConvoPermissionsSchema,
};

/**
Expand Down
104 changes: 104 additions & 0 deletions api/models/Role.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,75 @@ describe('updateAccessPermissions', () => {
SHARED_GLOBAL: true,
});
});

it('should update MULTI_CONVO permissions', async () => {
await new Role({
name: SystemRoles.USER,
[PermissionTypes.MULTI_CONVO]: {
USE: false,
},
}).save();

await updateAccessPermissions(SystemRoles.USER, {
[PermissionTypes.MULTI_CONVO]: {
USE: true,
},
});

const updatedRole = await Role.findOne({ name: SystemRoles.USER }).lean();
expect(updatedRole[PermissionTypes.MULTI_CONVO]).toEqual({
USE: true,
});
});

it('should update MULTI_CONVO permissions along with other permission types', async () => {
await new Role({
name: SystemRoles.USER,
[PermissionTypes.PROMPTS]: {
CREATE: true,
USE: true,
SHARED_GLOBAL: false,
},
[PermissionTypes.MULTI_CONVO]: {
USE: false,
},
}).save();

await updateAccessPermissions(SystemRoles.USER, {
[PermissionTypes.PROMPTS]: { SHARED_GLOBAL: true },
[PermissionTypes.MULTI_CONVO]: { USE: true },
});

const updatedRole = await Role.findOne({ name: SystemRoles.USER }).lean();
expect(updatedRole[PermissionTypes.PROMPTS]).toEqual({
CREATE: true,
USE: true,
SHARED_GLOBAL: true,
});
expect(updatedRole[PermissionTypes.MULTI_CONVO]).toEqual({
USE: true,
});
});

it('should not update MULTI_CONVO permissions when no changes are needed', async () => {
await new Role({
name: SystemRoles.USER,
[PermissionTypes.MULTI_CONVO]: {
USE: true,
},
}).save();

await updateAccessPermissions(SystemRoles.USER, {
[PermissionTypes.MULTI_CONVO]: {
USE: true,
},
});

const updatedRole = await Role.findOne({ name: SystemRoles.USER }).lean();
expect(updatedRole[PermissionTypes.MULTI_CONVO]).toEqual({
USE: true,
});
});
});

describe('initializeRoles', () => {
Expand Down Expand Up @@ -313,4 +382,39 @@ describe('initializeRoles', () => {
expect(adminRole[PermissionTypes.AGENTS].USE).toBeDefined();
expect(adminRole[PermissionTypes.AGENTS].SHARED_GLOBAL).toBeDefined();
});

it('should include MULTI_CONVO permissions when creating default roles', async () => {
await initializeRoles();

const adminRole = await Role.findOne({ name: SystemRoles.ADMIN }).lean();
const userRole = await Role.findOne({ name: SystemRoles.USER }).lean();

expect(adminRole[PermissionTypes.MULTI_CONVO]).toBeDefined();
expect(userRole[PermissionTypes.MULTI_CONVO]).toBeDefined();

// Check if MULTI_CONVO permissions match defaults
expect(adminRole[PermissionTypes.MULTI_CONVO].USE).toBe(
roleDefaults[SystemRoles.ADMIN][PermissionTypes.MULTI_CONVO].USE,
);
expect(userRole[PermissionTypes.MULTI_CONVO].USE).toBe(
roleDefaults[SystemRoles.USER][PermissionTypes.MULTI_CONVO].USE,
);
});

it('should add MULTI_CONVO permissions to existing roles without them', async () => {
const partialUserRole = {
name: SystemRoles.USER,
[PermissionTypes.PROMPTS]: roleDefaults[SystemRoles.USER][PermissionTypes.PROMPTS],
[PermissionTypes.BOOKMARKS]: roleDefaults[SystemRoles.USER][PermissionTypes.BOOKMARKS],
};

await new Role(partialUserRole).save();

await initializeRoles();

const userRole = await Role.findOne({ name: SystemRoles.USER }).lean();

expect(userRole[PermissionTypes.MULTI_CONVO]).toBeDefined();
expect(userRole[PermissionTypes.MULTI_CONVO].USE).toBeDefined();
});
});
6 changes: 6 additions & 0 deletions api/models/schema/roleSchema.js
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,12 @@ const roleSchema = new mongoose.Schema({
default: true,
},
},
[PermissionTypes.MULTI_CONVO]: {
[Permissions.USE]: {
type: Boolean,
default: true,
},
},
});

const Role = mongoose.model('Role', roleSchema);
Expand Down
2 changes: 2 additions & 0 deletions api/server/services/start/interface.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,13 @@ async function loadDefaultInterface(config, configDefaults, roleName = SystemRol
termsOfService: interfaceConfig?.termsOfService ?? defaults.termsOfService,
bookmarks: interfaceConfig?.bookmarks ?? defaults.bookmarks,
prompts: interfaceConfig?.prompts ?? defaults.prompts,
multiConvo: interfaceConfig?.multiConvo ?? defaults.multiConvo,
});

await updateAccessPermissions(roleName, {
[PermissionTypes.PROMPTS]: { [Permissions.USE]: loadedInterface.prompts },
[PermissionTypes.BOOKMARKS]: { [Permissions.USE]: loadedInterface.bookmarks },
[PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: loadedInterface.multiConvo },
});

let i = 0;
Expand Down
71 changes: 71 additions & 0 deletions api/server/services/start/interface.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ describe('loadDefaultInterface', () => {
expect(updateAccessPermissions).toHaveBeenCalledWith(SystemRoles.USER, {
[PermissionTypes.PROMPTS]: { [Permissions.USE]: true },
[PermissionTypes.BOOKMARKS]: { [Permissions.USE]: true },
[PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: undefined },
});
});

Expand All @@ -28,6 +29,7 @@ describe('loadDefaultInterface', () => {
expect(updateAccessPermissions).toHaveBeenCalledWith(SystemRoles.USER, {
[PermissionTypes.PROMPTS]: { [Permissions.USE]: false },
[PermissionTypes.BOOKMARKS]: { [Permissions.USE]: false },
[PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: undefined },
});
});

Expand All @@ -40,6 +42,7 @@ describe('loadDefaultInterface', () => {
expect(updateAccessPermissions).toHaveBeenCalledWith(SystemRoles.USER, {
[PermissionTypes.PROMPTS]: { [Permissions.USE]: undefined },
[PermissionTypes.BOOKMARKS]: { [Permissions.USE]: undefined },
[PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: undefined },
});
});

Expand All @@ -52,6 +55,7 @@ describe('loadDefaultInterface', () => {
expect(updateAccessPermissions).toHaveBeenCalledWith(SystemRoles.USER, {
[PermissionTypes.PROMPTS]: { [Permissions.USE]: undefined },
[PermissionTypes.BOOKMARKS]: { [Permissions.USE]: undefined },
[PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: undefined },
});
});

Expand All @@ -64,6 +68,7 @@ describe('loadDefaultInterface', () => {
expect(updateAccessPermissions).toHaveBeenCalledWith(SystemRoles.USER, {
[PermissionTypes.PROMPTS]: { [Permissions.USE]: true },
[PermissionTypes.BOOKMARKS]: { [Permissions.USE]: false },
[PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: undefined },
});
});

Expand All @@ -76,6 +81,72 @@ describe('loadDefaultInterface', () => {
expect(updateAccessPermissions).toHaveBeenCalledWith(SystemRoles.USER, {
[PermissionTypes.PROMPTS]: { [Permissions.USE]: true },
[PermissionTypes.BOOKMARKS]: { [Permissions.USE]: true },
[PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: undefined },
});
});

it('should call updateAccessPermissions with the correct parameters when multiConvo is true', async () => {
const config = { interface: { multiConvo: true } };
const configDefaults = { interface: {} };

await loadDefaultInterface(config, configDefaults);

expect(updateAccessPermissions).toHaveBeenCalledWith(SystemRoles.USER, {
[PermissionTypes.PROMPTS]: { [Permissions.USE]: undefined },
[PermissionTypes.BOOKMARKS]: { [Permissions.USE]: undefined },
[PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: true },
});
});

it('should call updateAccessPermissions with false when multiConvo is false', async () => {
const config = { interface: { multiConvo: false } };
const configDefaults = { interface: {} };

await loadDefaultInterface(config, configDefaults);

expect(updateAccessPermissions).toHaveBeenCalledWith(SystemRoles.USER, {
[PermissionTypes.PROMPTS]: { [Permissions.USE]: undefined },
[PermissionTypes.BOOKMARKS]: { [Permissions.USE]: undefined },
[PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: false },
});
});

it('should call updateAccessPermissions with undefined when multiConvo is not specified in config', async () => {
const config = {};
const configDefaults = { interface: {} };

await loadDefaultInterface(config, configDefaults);

expect(updateAccessPermissions).toHaveBeenCalledWith(SystemRoles.USER, {
[PermissionTypes.PROMPTS]: { [Permissions.USE]: undefined },
[PermissionTypes.BOOKMARKS]: { [Permissions.USE]: undefined },
[PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: undefined },
});
});

it('should call updateAccessPermissions with all interface options including multiConvo', async () => {
const config = { interface: { prompts: true, bookmarks: false, multiConvo: true } };
const configDefaults = { interface: {} };

await loadDefaultInterface(config, configDefaults);

expect(updateAccessPermissions).toHaveBeenCalledWith(SystemRoles.USER, {
[PermissionTypes.PROMPTS]: { [Permissions.USE]: true },
[PermissionTypes.BOOKMARKS]: { [Permissions.USE]: false },
[PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: true },
});
});

it('should use default values for multiConvo when config is undefined', async () => {
const config = undefined;
const configDefaults = { interface: { prompts: true, bookmarks: true, multiConvo: false } };

await loadDefaultInterface(config, configDefaults);

expect(updateAccessPermissions).toHaveBeenCalledWith(SystemRoles.USER, {
[PermissionTypes.PROMPTS]: { [Permissions.USE]: true },
[PermissionTypes.BOOKMARKS]: { [Permissions.USE]: true },
[PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: false },
});
});
});
7 changes: 6 additions & 1 deletion client/src/components/Chat/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@ export default function Header() {
permission: Permissions.USE,
});

const hasAccessToMultiConvo = useHasAccess({
permissionType: PermissionTypes.MULTI_CONVO,
permission: Permissions.USE,
});

const isSmallScreen = useMediaQuery('(max-width: 768px)');

return (
Expand All @@ -38,7 +43,7 @@ export default function Header() {
{<HeaderOptions interfaceConfig={interfaceConfig} />}
{interfaceConfig.presets === true && <PresetsMenu />}
{hasAccessToBookmarks === true && <BookmarkMenu />}
<AddMultiConvo />
{hasAccessToMultiConvo === true && <AddMultiConvo />}
{isSmallScreen && (
<ExportAndShareMenu
isSharedButtonEnabled={startupConfig?.sharedLinksEnabled ?? false}
Expand Down
5 changes: 4 additions & 1 deletion client/src/components/Chat/Messages/Content/ContentParts.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import type { TMessageContentParts } from 'librechat-data-provider';
import Part from './Part';

type ContentPartsProps = {
content: Array<TMessageContentParts | undefined>;
content: Array<TMessageContentParts | undefined> | undefined;
messageId: string;
isCreatedByUser: boolean;
isLast: boolean;
Expand All @@ -12,6 +12,9 @@ type ContentPartsProps = {

const ContentParts = memo(
({ content, messageId, isCreatedByUser, isLast, isSubmitting }: ContentPartsProps) => {
if (!content) {
return null;
}
return (
<>
{content
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useRecoilState } from 'recoil';
import HoverCardSettings from '../HoverCardSettings';
import HoverCardSettings from '~/components/Nav/SettingsTabs/HoverCardSettings';
import { Switch } from '~/components/ui';
import { useLocalize } from '~/hooks';
import store from '~/store';
Expand Down
44 changes: 35 additions & 9 deletions client/src/components/Nav/SettingsTabs/Commands/Commands.tsx
Original file line number Diff line number Diff line change
@@ -1,26 +1,52 @@
import { memo } from 'react';
import * as Tabs from '@radix-ui/react-tabs';
import { SettingsTabValues } from 'librechat-data-provider';
import { SettingsTabValues, PermissionTypes, Permissions } from 'librechat-data-provider';
import HoverCardSettings from '~/components/Nav/SettingsTabs/HoverCardSettings';
import { useLocalize, useHasAccess } from '~/hooks';
import SlashCommandSwitch from './SlashCommandSwitch';
import PlusCommandSwitch from './PlusCommandSwitch';
import AtCommandSwitch from './AtCommandSwitch';

function Commands() {
const localize = useLocalize();

const hasAccessToPrompts = useHasAccess({
permissionType: PermissionTypes.PROMPTS,
permission: Permissions.USE,
});

const hasAccessToMultiConvo = useHasAccess({
permissionType: PermissionTypes.MULTI_CONVO,
permission: Permissions.USE,
});

return (
<Tabs.Content
value={SettingsTabValues.COMMANDS}
role="tabpanel"
className="w-full md:min-h-[271px]"
>
<div className="flex flex-col gap-3 text-sm text-text-primary">
<div className="border-b border-border-medium pb-3 last-of-type:border-b-0">
<AtCommandSwitch />
</div>
<div className="border-b border-border-medium pb-3 last-of-type:border-b-0">
<PlusCommandSwitch />
<div className="space-y-4">
<div className="flex items-center gap-2">
<h3 className="text-lg font-medium text-text-primary">
{localize('com_nav_chat_commands')}
</h3>
<HoverCardSettings side="bottom" text="com_nav_chat_commands_info" />
</div>
<div className="border-b border-border-medium pb-3 last-of-type:border-b-0">
<SlashCommandSwitch />
<div className="flex flex-col gap-3 text-sm text-text-primary">
<div className="border-b border-border-medium pb-3 last-of-type:border-b-0">
<AtCommandSwitch />
</div>
{hasAccessToMultiConvo === true && (
<div className="border-b border-border-medium pb-3 last-of-type:border-b-0">
<PlusCommandSwitch />
</div>
)}
{hasAccessToPrompts === true && (
<div className="border-b border-border-medium pb-3 last-of-type:border-b-0">
<SlashCommandSwitch />
</div>
)}
</div>
</div>
</Tabs.Content>
Expand Down
4 changes: 2 additions & 2 deletions client/src/components/Nav/SettingsTabs/HoverCardSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,12 @@ const HoverCardSettings = ({ side, text }) => {
return (
<HoverCard openDelay={500}>
<HoverCardTrigger>
<CircleHelpIcon className="h-5 w-5 text-gray-500" />{' '}
<CircleHelpIcon className="h-5 w-5 text-text-tertiary" />{' '}
</HoverCardTrigger>
<HoverCardPortal>
<HoverCardContent side={side} className="z-[999] w-80">
<div className="space-y-2">
<p className="text-sm text-gray-600 dark:text-gray-300">{localize(text)}</p>
<p className="text-sm text-text-secondary">{localize(text)}</p>
</div>
</HoverCardContent>
</HoverCardPortal>
Expand Down
Loading

0 comments on commit 748b41e

Please sign in to comment.