mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-09-21 21:50:49 +02:00
🗨️ feat: Granular Prompt Permissions via ACL and Permission Bits
feat: Implement prompt permissions management and access control middleware fix: agent deletion process to remove associated permissions and ACL entries fix: Import Permissions for enhanced access control in GrantAccessDialog feat: use PromptGroup for access control - Added migration script for PromptGroup permissions, categorizing groups into global view access and private groups. - Created unit tests for the migration script to ensure correct categorization and permission granting. - Introduced middleware for checking access permissions on PromptGroups and prompts via their groups. - Updated routes to utilize new access control middleware for PromptGroups. - Enhanced access role definitions to include roles specific to PromptGroups. - Modified ACL entry schema and types to accommodate PromptGroup resource type. - Updated data provider to include new access role identifiers for PromptGroups. feat: add generic access management dialogs and hooks for resource permissions fix: remove duplicate imports in FileContext component fix: remove duplicate mongoose dependency in package.json feat: add access permissions handling for dynamic resource types and add promptGroup roles feat: implement centralized role localization and update access role types refactor: simplify author handling in prompt group routes and enhance ACL checks feat: implement addPromptToGroup functionality and update PromptForm to use it feat: enhance permission handling in ChatGroupItem, DashGroupItem, and PromptForm components chore: rename migration script for prompt group permissions and update package.json scripts chore: update prompt tests
This commit is contained in:
parent
8d51f450e8
commit
472c2f14e4
46 changed files with 3505 additions and 408 deletions
|
@ -4,7 +4,6 @@ const { logger } = require('@librechat/data-schemas');
|
||||||
const { SystemRoles, Tools, actionDelimiter } = require('librechat-data-provider');
|
const { SystemRoles, Tools, actionDelimiter } = require('librechat-data-provider');
|
||||||
const { GLOBAL_PROJECT_NAME, EPHEMERAL_AGENT_ID, mcp_delimiter } =
|
const { GLOBAL_PROJECT_NAME, EPHEMERAL_AGENT_ID, mcp_delimiter } =
|
||||||
require('librechat-data-provider').Constants;
|
require('librechat-data-provider').Constants;
|
||||||
// Default category value for new agents
|
|
||||||
const {
|
const {
|
||||||
getProjectByName,
|
getProjectByName,
|
||||||
addAgentIdsToProject,
|
addAgentIdsToProject,
|
||||||
|
@ -12,12 +11,14 @@ const {
|
||||||
removeAgentFromAllProjects,
|
removeAgentFromAllProjects,
|
||||||
} = require('./Project');
|
} = require('./Project');
|
||||||
const { getCachedTools } = require('~/server/services/Config');
|
const { getCachedTools } = require('~/server/services/Config');
|
||||||
|
const { removeAllPermissions } = require('~/server/services/PermissionService');
|
||||||
// Category values are now imported from shared constants
|
|
||||||
// Schema fields (category, support_contact, is_promoted) are defined in @librechat/data-schemas
|
|
||||||
const { getActions } = require('./Action');
|
|
||||||
const { Agent } = require('~/db/models');
|
const { Agent } = require('~/db/models');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Category values are now imported from shared constants
|
||||||
|
*/
|
||||||
|
const { getActions } = require('./Action');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create an agent with the provided data.
|
* Create an agent with the provided data.
|
||||||
* @param {Object} agentData - The agent data to create.
|
* @param {Object} agentData - The agent data to create.
|
||||||
|
@ -509,6 +510,10 @@ const deleteAgent = async (searchParameter) => {
|
||||||
const agent = await Agent.findOneAndDelete(searchParameter);
|
const agent = await Agent.findOneAndDelete(searchParameter);
|
||||||
if (agent) {
|
if (agent) {
|
||||||
await removeAgentFromAllProjects(agent.id);
|
await removeAgentFromAllProjects(agent.id);
|
||||||
|
await removeAllPermissions({
|
||||||
|
resourceType: 'agent',
|
||||||
|
resourceId: agent._id,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return agent;
|
return agent;
|
||||||
};
|
};
|
||||||
|
|
|
@ -28,6 +28,8 @@ const {
|
||||||
revertAgentVersion,
|
revertAgentVersion,
|
||||||
} = require('./Agent');
|
} = require('./Agent');
|
||||||
const { getCachedTools } = require('~/server/services/Config');
|
const { getCachedTools } = require('~/server/services/Config');
|
||||||
|
const permissionService = require('~/server/services/PermissionService');
|
||||||
|
const { AclEntry } = require('~/db/models');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @type {import('mongoose').Model<import('@librechat/data-schemas').IAgent>}
|
* @type {import('mongoose').Model<import('@librechat/data-schemas').IAgent>}
|
||||||
|
@ -407,12 +409,26 @@ describe('models/Agent', () => {
|
||||||
|
|
||||||
describe('Agent CRUD Operations', () => {
|
describe('Agent CRUD Operations', () => {
|
||||||
let mongoServer;
|
let mongoServer;
|
||||||
|
let AccessRole;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
mongoServer = await MongoMemoryServer.create();
|
mongoServer = await MongoMemoryServer.create();
|
||||||
const mongoUri = mongoServer.getUri();
|
const mongoUri = mongoServer.getUri();
|
||||||
Agent = mongoose.models.Agent || mongoose.model('Agent', agentSchema);
|
Agent = mongoose.models.Agent || mongoose.model('Agent', agentSchema);
|
||||||
await mongoose.connect(mongoUri);
|
await mongoose.connect(mongoUri);
|
||||||
|
|
||||||
|
// Initialize models
|
||||||
|
const dbModels = require('~/db/models');
|
||||||
|
AccessRole = dbModels.AccessRole;
|
||||||
|
|
||||||
|
// Create necessary access roles for agents
|
||||||
|
await AccessRole.create({
|
||||||
|
accessRoleId: 'agent_owner',
|
||||||
|
name: 'Owner',
|
||||||
|
description: 'Full control over agents',
|
||||||
|
resourceType: 'agent',
|
||||||
|
permBits: 15, // VIEW | EDIT | DELETE | SHARE
|
||||||
|
});
|
||||||
}, 20000);
|
}, 20000);
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
|
@ -468,6 +484,51 @@ describe('models/Agent', () => {
|
||||||
expect(agentAfterDelete).toBeNull();
|
expect(agentAfterDelete).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('should remove ACL entries when deleting an agent', async () => {
|
||||||
|
const agentId = `agent_${uuidv4()}`;
|
||||||
|
const authorId = new mongoose.Types.ObjectId();
|
||||||
|
|
||||||
|
// Create agent
|
||||||
|
const agent = await createAgent({
|
||||||
|
id: agentId,
|
||||||
|
name: 'Agent With Permissions',
|
||||||
|
provider: 'test',
|
||||||
|
model: 'test-model',
|
||||||
|
author: authorId,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Grant permissions (simulating sharing)
|
||||||
|
await permissionService.grantPermission({
|
||||||
|
principalType: 'user',
|
||||||
|
principalId: authorId,
|
||||||
|
resourceType: 'agent',
|
||||||
|
resourceId: agent._id,
|
||||||
|
accessRoleId: 'agent_owner',
|
||||||
|
grantedBy: authorId,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify ACL entry exists
|
||||||
|
const aclEntriesBefore = await AclEntry.find({
|
||||||
|
resourceType: 'agent',
|
||||||
|
resourceId: agent._id,
|
||||||
|
});
|
||||||
|
expect(aclEntriesBefore).toHaveLength(1);
|
||||||
|
|
||||||
|
// Delete the agent
|
||||||
|
await deleteAgent({ id: agentId });
|
||||||
|
|
||||||
|
// Verify agent is deleted
|
||||||
|
const agentAfterDelete = await getAgent({ id: agentId });
|
||||||
|
expect(agentAfterDelete).toBeNull();
|
||||||
|
|
||||||
|
// Verify ACL entries are removed
|
||||||
|
const aclEntriesAfter = await AclEntry.find({
|
||||||
|
resourceType: 'agent',
|
||||||
|
resourceId: agent._id,
|
||||||
|
});
|
||||||
|
expect(aclEntriesAfter).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
test('should list agents by author', async () => {
|
test('should list agents by author', async () => {
|
||||||
const authorId = new mongoose.Types.ObjectId();
|
const authorId = new mongoose.Types.ObjectId();
|
||||||
const otherAuthorId = new mongoose.Types.ObjectId();
|
const otherAuthorId = new mongoose.Types.ObjectId();
|
||||||
|
|
|
@ -7,6 +7,7 @@ const {
|
||||||
removeGroupIdsFromProject,
|
removeGroupIdsFromProject,
|
||||||
removeGroupFromAllProjects,
|
removeGroupFromAllProjects,
|
||||||
} = require('./Project');
|
} = require('./Project');
|
||||||
|
const { removeAllPermissions } = require('~/server/services/PermissionService');
|
||||||
const { PromptGroup, Prompt } = require('~/db/models');
|
const { PromptGroup, Prompt } = require('~/db/models');
|
||||||
const { escapeRegExp } = require('~/server/utils');
|
const { escapeRegExp } = require('~/server/utils');
|
||||||
|
|
||||||
|
@ -100,10 +101,6 @@ const getAllPromptGroups = async (req, filter) => {
|
||||||
try {
|
try {
|
||||||
const { name, ...query } = filter;
|
const { name, ...query } = filter;
|
||||||
|
|
||||||
if (!query.author) {
|
|
||||||
throw new Error('Author is required');
|
|
||||||
}
|
|
||||||
|
|
||||||
let searchShared = true;
|
let searchShared = true;
|
||||||
let searchSharedOnly = false;
|
let searchSharedOnly = false;
|
||||||
if (name) {
|
if (name) {
|
||||||
|
@ -153,10 +150,6 @@ const getPromptGroups = async (req, filter) => {
|
||||||
const validatedPageNumber = Math.max(parseInt(pageNumber, 10), 1);
|
const validatedPageNumber = Math.max(parseInt(pageNumber, 10), 1);
|
||||||
const validatedPageSize = Math.max(parseInt(pageSize, 10), 1);
|
const validatedPageSize = Math.max(parseInt(pageSize, 10), 1);
|
||||||
|
|
||||||
if (!query.author) {
|
|
||||||
throw new Error('Author is required');
|
|
||||||
}
|
|
||||||
|
|
||||||
let searchShared = true;
|
let searchShared = true;
|
||||||
let searchSharedOnly = false;
|
let searchSharedOnly = false;
|
||||||
if (name) {
|
if (name) {
|
||||||
|
@ -221,12 +214,16 @@ const getPromptGroups = async (req, filter) => {
|
||||||
* @returns {Promise<TDeletePromptGroupResponse>}
|
* @returns {Promise<TDeletePromptGroupResponse>}
|
||||||
*/
|
*/
|
||||||
const deletePromptGroup = async ({ _id, author, role }) => {
|
const deletePromptGroup = async ({ _id, author, role }) => {
|
||||||
const query = { _id, author };
|
// Build query - with ACL, author is optional
|
||||||
const groupQuery = { groupId: new ObjectId(_id), author };
|
const query = { _id };
|
||||||
if (role === SystemRoles.ADMIN) {
|
const groupQuery = { groupId: new ObjectId(_id) };
|
||||||
delete query.author;
|
|
||||||
delete groupQuery.author;
|
// Legacy: Add author filter if provided (backward compatibility)
|
||||||
|
if (author && role !== SystemRoles.ADMIN) {
|
||||||
|
query.author = author;
|
||||||
|
groupQuery.author = author;
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await PromptGroup.deleteOne(query);
|
const response = await PromptGroup.deleteOne(query);
|
||||||
|
|
||||||
if (!response || response.deletedCount === 0) {
|
if (!response || response.deletedCount === 0) {
|
||||||
|
@ -235,6 +232,13 @@ const deletePromptGroup = async ({ _id, author, role }) => {
|
||||||
|
|
||||||
await Prompt.deleteMany(groupQuery);
|
await Prompt.deleteMany(groupQuery);
|
||||||
await removeGroupFromAllProjects(_id);
|
await removeGroupFromAllProjects(_id);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await removeAllPermissions({ resourceType: 'promptGroup', resourceId: _id });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error removing promptGroup permissions:', error);
|
||||||
|
}
|
||||||
|
|
||||||
return { message: 'Prompt group deleted successfully' };
|
return { message: 'Prompt group deleted successfully' };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -424,12 +428,32 @@ module.exports = {
|
||||||
throw new Error('Failed to delete the prompt');
|
throw new Error('Failed to delete the prompt');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Remove all ACL entries for this prompt
|
||||||
|
try {
|
||||||
|
await removeAllPermissions({
|
||||||
|
resourceType: 'prompt',
|
||||||
|
resourceId: promptId,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error removing prompt permissions:', error);
|
||||||
|
}
|
||||||
|
|
||||||
const remainingPrompts = await Prompt.find({ groupId })
|
const remainingPrompts = await Prompt.find({ groupId })
|
||||||
.select('_id')
|
.select('_id')
|
||||||
.sort({ createdAt: 1 })
|
.sort({ createdAt: 1 })
|
||||||
.lean();
|
.lean();
|
||||||
|
|
||||||
if (remainingPrompts.length === 0) {
|
if (remainingPrompts.length === 0) {
|
||||||
|
// Remove all ACL entries for the promptGroup when deleting the last prompt
|
||||||
|
try {
|
||||||
|
await removeAllPermissions({
|
||||||
|
resourceType: 'promptGroup',
|
||||||
|
resourceId: groupId,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error removing promptGroup permissions:', error);
|
||||||
|
}
|
||||||
|
|
||||||
await PromptGroup.deleteOne({ _id: groupId });
|
await PromptGroup.deleteOne({ _id: groupId });
|
||||||
await removeGroupFromAllProjects(groupId);
|
await removeGroupFromAllProjects(groupId);
|
||||||
|
|
||||||
|
|
560
api/models/Prompt.spec.js
Normal file
560
api/models/Prompt.spec.js
Normal file
|
@ -0,0 +1,560 @@
|
||||||
|
const { ObjectId } = require('mongodb');
|
||||||
|
const { MongoMemoryServer } = require('mongodb-memory-server');
|
||||||
|
const mongoose = require('mongoose');
|
||||||
|
const { SystemRoles } = require('librechat-data-provider');
|
||||||
|
const { logger, PermissionBits } = require('@librechat/data-schemas');
|
||||||
|
|
||||||
|
// Mock the config/connect module to prevent connection attempts during tests
|
||||||
|
jest.mock('../../config/connect', () => jest.fn().mockResolvedValue(true));
|
||||||
|
|
||||||
|
const dbModels = require('~/db/models');
|
||||||
|
|
||||||
|
// Disable console for tests
|
||||||
|
logger.silent = true;
|
||||||
|
|
||||||
|
let mongoServer;
|
||||||
|
let Prompt, PromptGroup, AclEntry, AccessRole, User, Group, Project;
|
||||||
|
let promptFns, permissionService;
|
||||||
|
let testUsers, testGroups, testRoles;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
// Set up MongoDB memory server
|
||||||
|
mongoServer = await MongoMemoryServer.create();
|
||||||
|
const mongoUri = mongoServer.getUri();
|
||||||
|
await mongoose.connect(mongoUri);
|
||||||
|
|
||||||
|
// Initialize models
|
||||||
|
Prompt = dbModels.Prompt;
|
||||||
|
PromptGroup = dbModels.PromptGroup;
|
||||||
|
AclEntry = dbModels.AclEntry;
|
||||||
|
AccessRole = dbModels.AccessRole;
|
||||||
|
User = dbModels.User;
|
||||||
|
Group = dbModels.Group;
|
||||||
|
Project = dbModels.Project;
|
||||||
|
|
||||||
|
promptFns = require('~/models/Prompt');
|
||||||
|
permissionService = require('~/server/services/PermissionService');
|
||||||
|
|
||||||
|
// Create test data
|
||||||
|
await setupTestData();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await mongoose.disconnect();
|
||||||
|
await mongoServer.stop();
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function setupTestData() {
|
||||||
|
// Create access roles for promptGroups
|
||||||
|
testRoles = {
|
||||||
|
viewer: await AccessRole.create({
|
||||||
|
accessRoleId: 'promptGroup_viewer',
|
||||||
|
name: 'Viewer',
|
||||||
|
description: 'Can view promptGroups',
|
||||||
|
resourceType: 'promptGroup',
|
||||||
|
permBits: PermissionBits.VIEW,
|
||||||
|
}),
|
||||||
|
editor: await AccessRole.create({
|
||||||
|
accessRoleId: 'promptGroup_editor',
|
||||||
|
name: 'Editor',
|
||||||
|
description: 'Can view and edit promptGroups',
|
||||||
|
resourceType: 'promptGroup',
|
||||||
|
permBits: PermissionBits.VIEW | PermissionBits.EDIT,
|
||||||
|
}),
|
||||||
|
owner: await AccessRole.create({
|
||||||
|
accessRoleId: 'promptGroup_owner',
|
||||||
|
name: 'Owner',
|
||||||
|
description: 'Full control over promptGroups',
|
||||||
|
resourceType: 'promptGroup',
|
||||||
|
permBits:
|
||||||
|
PermissionBits.VIEW | PermissionBits.EDIT | PermissionBits.DELETE | PermissionBits.SHARE,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create test users
|
||||||
|
testUsers = {
|
||||||
|
owner: await User.create({
|
||||||
|
name: 'Prompt Owner',
|
||||||
|
email: 'owner@example.com',
|
||||||
|
role: SystemRoles.USER,
|
||||||
|
}),
|
||||||
|
editor: await User.create({
|
||||||
|
name: 'Prompt Editor',
|
||||||
|
email: 'editor@example.com',
|
||||||
|
role: SystemRoles.USER,
|
||||||
|
}),
|
||||||
|
viewer: await User.create({
|
||||||
|
name: 'Prompt Viewer',
|
||||||
|
email: 'viewer@example.com',
|
||||||
|
role: SystemRoles.USER,
|
||||||
|
}),
|
||||||
|
admin: await User.create({
|
||||||
|
name: 'Admin User',
|
||||||
|
email: 'admin@example.com',
|
||||||
|
role: SystemRoles.ADMIN,
|
||||||
|
}),
|
||||||
|
noAccess: await User.create({
|
||||||
|
name: 'No Access User',
|
||||||
|
email: 'noaccess@example.com',
|
||||||
|
role: SystemRoles.USER,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create test groups
|
||||||
|
testGroups = {
|
||||||
|
editors: await Group.create({
|
||||||
|
name: 'Prompt Editors',
|
||||||
|
description: 'Group with editor access',
|
||||||
|
}),
|
||||||
|
viewers: await Group.create({
|
||||||
|
name: 'Prompt Viewers',
|
||||||
|
description: 'Group with viewer access',
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
await Project.create({
|
||||||
|
name: 'Global',
|
||||||
|
description: 'Global project',
|
||||||
|
promptGroupIds: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Prompt ACL Permissions', () => {
|
||||||
|
describe('Creating Prompts with Permissions', () => {
|
||||||
|
it('should grant owner permissions when creating a prompt', async () => {
|
||||||
|
// First create a group
|
||||||
|
const testGroup = await PromptGroup.create({
|
||||||
|
name: 'Test Group',
|
||||||
|
category: 'testing',
|
||||||
|
author: testUsers.owner._id,
|
||||||
|
authorName: testUsers.owner.name,
|
||||||
|
productionId: new mongoose.Types.ObjectId(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const promptData = {
|
||||||
|
prompt: {
|
||||||
|
prompt: 'Test prompt content',
|
||||||
|
name: 'Test Prompt',
|
||||||
|
type: 'text',
|
||||||
|
groupId: testGroup._id,
|
||||||
|
},
|
||||||
|
author: testUsers.owner._id,
|
||||||
|
};
|
||||||
|
|
||||||
|
await promptFns.savePrompt(promptData);
|
||||||
|
|
||||||
|
// Manually grant permissions as would happen in the route
|
||||||
|
await permissionService.grantPermission({
|
||||||
|
principalType: 'user',
|
||||||
|
principalId: testUsers.owner._id,
|
||||||
|
resourceType: 'promptGroup',
|
||||||
|
resourceId: testGroup._id,
|
||||||
|
accessRoleId: 'promptGroup_owner',
|
||||||
|
grantedBy: testUsers.owner._id,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check ACL entry
|
||||||
|
const aclEntry = await AclEntry.findOne({
|
||||||
|
resourceType: 'promptGroup',
|
||||||
|
resourceId: testGroup._id,
|
||||||
|
principalType: 'user',
|
||||||
|
principalId: testUsers.owner._id,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(aclEntry).toBeTruthy();
|
||||||
|
expect(aclEntry.permBits).toBe(testRoles.owner.permBits);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Accessing Prompts', () => {
|
||||||
|
let testPromptGroup;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
// Create a prompt group
|
||||||
|
testPromptGroup = await PromptGroup.create({
|
||||||
|
name: 'Test Group',
|
||||||
|
author: testUsers.owner._id,
|
||||||
|
authorName: testUsers.owner.name,
|
||||||
|
productionId: new ObjectId(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create a prompt
|
||||||
|
await Prompt.create({
|
||||||
|
prompt: 'Test prompt for access control',
|
||||||
|
name: 'Access Test Prompt',
|
||||||
|
author: testUsers.owner._id,
|
||||||
|
groupId: testPromptGroup._id,
|
||||||
|
type: 'text',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Grant owner permissions
|
||||||
|
await permissionService.grantPermission({
|
||||||
|
principalType: 'user',
|
||||||
|
principalId: testUsers.owner._id,
|
||||||
|
resourceType: 'promptGroup',
|
||||||
|
resourceId: testPromptGroup._id,
|
||||||
|
accessRoleId: 'promptGroup_owner',
|
||||||
|
grantedBy: testUsers.owner._id,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await Prompt.deleteMany({});
|
||||||
|
await PromptGroup.deleteMany({});
|
||||||
|
await AclEntry.deleteMany({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('owner should have full access to their prompt', async () => {
|
||||||
|
const hasAccess = await permissionService.checkPermission({
|
||||||
|
userId: testUsers.owner._id,
|
||||||
|
resourceType: 'promptGroup',
|
||||||
|
resourceId: testPromptGroup._id,
|
||||||
|
requiredPermission: PermissionBits.VIEW,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(hasAccess).toBe(true);
|
||||||
|
|
||||||
|
const canEdit = await permissionService.checkPermission({
|
||||||
|
userId: testUsers.owner._id,
|
||||||
|
resourceType: 'promptGroup',
|
||||||
|
resourceId: testPromptGroup._id,
|
||||||
|
requiredPermission: PermissionBits.EDIT,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(canEdit).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('user with viewer role should only have view access', async () => {
|
||||||
|
// Grant viewer permissions
|
||||||
|
await permissionService.grantPermission({
|
||||||
|
principalType: 'user',
|
||||||
|
principalId: testUsers.viewer._id,
|
||||||
|
resourceType: 'promptGroup',
|
||||||
|
resourceId: testPromptGroup._id,
|
||||||
|
accessRoleId: 'promptGroup_viewer',
|
||||||
|
grantedBy: testUsers.owner._id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const canView = await permissionService.checkPermission({
|
||||||
|
userId: testUsers.viewer._id,
|
||||||
|
resourceType: 'promptGroup',
|
||||||
|
resourceId: testPromptGroup._id,
|
||||||
|
requiredPermission: PermissionBits.VIEW,
|
||||||
|
});
|
||||||
|
|
||||||
|
const canEdit = await permissionService.checkPermission({
|
||||||
|
userId: testUsers.viewer._id,
|
||||||
|
resourceType: 'promptGroup',
|
||||||
|
resourceId: testPromptGroup._id,
|
||||||
|
requiredPermission: PermissionBits.EDIT,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(canView).toBe(true);
|
||||||
|
expect(canEdit).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('user without permissions should have no access', async () => {
|
||||||
|
const hasAccess = await permissionService.checkPermission({
|
||||||
|
userId: testUsers.noAccess._id,
|
||||||
|
resourceType: 'promptGroup',
|
||||||
|
resourceId: testPromptGroup._id,
|
||||||
|
requiredPermission: PermissionBits.VIEW,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(hasAccess).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('admin should have access regardless of permissions', async () => {
|
||||||
|
// Admin users should work through normal permission system
|
||||||
|
// The middleware layer handles admin bypass, not the permission service
|
||||||
|
const hasAccess = await permissionService.checkPermission({
|
||||||
|
userId: testUsers.admin._id,
|
||||||
|
resourceType: 'promptGroup',
|
||||||
|
resourceId: testPromptGroup._id,
|
||||||
|
requiredPermission: PermissionBits.VIEW,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Without explicit permissions, even admin won't have access at this layer
|
||||||
|
expect(hasAccess).toBe(false);
|
||||||
|
|
||||||
|
// The actual admin bypass happens in the middleware layer (canAccessPromptResource)
|
||||||
|
// which checks req.user.role === SystemRoles.ADMIN
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Group-based Access', () => {
|
||||||
|
let testPromptGroup;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
// Create a prompt group first
|
||||||
|
testPromptGroup = await PromptGroup.create({
|
||||||
|
name: 'Group Access Test Group',
|
||||||
|
author: testUsers.owner._id,
|
||||||
|
authorName: testUsers.owner.name,
|
||||||
|
productionId: new ObjectId(),
|
||||||
|
});
|
||||||
|
|
||||||
|
await Prompt.create({
|
||||||
|
prompt: 'Group access test prompt',
|
||||||
|
name: 'Group Test',
|
||||||
|
author: testUsers.owner._id,
|
||||||
|
groupId: testPromptGroup._id,
|
||||||
|
type: 'text',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add users to groups
|
||||||
|
await User.findByIdAndUpdate(testUsers.editor._id, {
|
||||||
|
$push: { groups: testGroups.editors._id },
|
||||||
|
});
|
||||||
|
|
||||||
|
await User.findByIdAndUpdate(testUsers.viewer._id, {
|
||||||
|
$push: { groups: testGroups.viewers._id },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await Prompt.deleteMany({});
|
||||||
|
await AclEntry.deleteMany({});
|
||||||
|
await User.updateMany({}, { $set: { groups: [] } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('group members should inherit group permissions', async () => {
|
||||||
|
// Create a prompt group
|
||||||
|
const testPromptGroup = await PromptGroup.create({
|
||||||
|
name: 'Group Test Group',
|
||||||
|
author: testUsers.owner._id,
|
||||||
|
authorName: testUsers.owner.name,
|
||||||
|
productionId: new ObjectId(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add editor to the editors group
|
||||||
|
await Group.findByIdAndUpdate(testGroups.editors._id, {
|
||||||
|
$push: { memberIds: testUsers.editor._id },
|
||||||
|
});
|
||||||
|
|
||||||
|
const prompt = await promptFns.savePrompt({
|
||||||
|
author: testUsers.owner._id,
|
||||||
|
prompt: {
|
||||||
|
prompt: 'Group test prompt',
|
||||||
|
name: 'Group Test',
|
||||||
|
groupId: testPromptGroup._id,
|
||||||
|
type: 'text',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check if savePrompt returned an error
|
||||||
|
if (!prompt || !prompt.prompt) {
|
||||||
|
throw new Error(`Failed to save prompt: ${prompt?.message || 'Unknown error'}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Grant edit permissions to the group
|
||||||
|
await permissionService.grantPermission({
|
||||||
|
principalType: 'group',
|
||||||
|
principalId: testGroups.editors._id,
|
||||||
|
resourceType: 'promptGroup',
|
||||||
|
resourceId: testPromptGroup._id,
|
||||||
|
accessRoleId: 'promptGroup_editor',
|
||||||
|
grantedBy: testUsers.owner._id,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check if group member has access
|
||||||
|
const hasAccess = await permissionService.checkPermission({
|
||||||
|
userId: testUsers.editor._id,
|
||||||
|
resourceType: 'promptGroup',
|
||||||
|
resourceId: testPromptGroup._id,
|
||||||
|
requiredPermission: PermissionBits.EDIT,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(hasAccess).toBe(true);
|
||||||
|
|
||||||
|
// Check that non-member doesn't have access
|
||||||
|
const nonMemberAccess = await permissionService.checkPermission({
|
||||||
|
userId: testUsers.viewer._id,
|
||||||
|
resourceType: 'promptGroup',
|
||||||
|
resourceId: testPromptGroup._id,
|
||||||
|
requiredPermission: PermissionBits.EDIT,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(nonMemberAccess).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Public Access', () => {
|
||||||
|
let publicPromptGroup, privatePromptGroup;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
// Create separate prompt groups for public and private access
|
||||||
|
publicPromptGroup = await PromptGroup.create({
|
||||||
|
name: 'Public Access Test Group',
|
||||||
|
author: testUsers.owner._id,
|
||||||
|
authorName: testUsers.owner.name,
|
||||||
|
productionId: new ObjectId(),
|
||||||
|
});
|
||||||
|
|
||||||
|
privatePromptGroup = await PromptGroup.create({
|
||||||
|
name: 'Private Access Test Group',
|
||||||
|
author: testUsers.owner._id,
|
||||||
|
authorName: testUsers.owner.name,
|
||||||
|
productionId: new ObjectId(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create prompts in their respective groups
|
||||||
|
await Prompt.create({
|
||||||
|
prompt: 'Public prompt',
|
||||||
|
name: 'Public',
|
||||||
|
author: testUsers.owner._id,
|
||||||
|
groupId: publicPromptGroup._id,
|
||||||
|
type: 'text',
|
||||||
|
});
|
||||||
|
|
||||||
|
await Prompt.create({
|
||||||
|
prompt: 'Private prompt',
|
||||||
|
name: 'Private',
|
||||||
|
author: testUsers.owner._id,
|
||||||
|
groupId: privatePromptGroup._id,
|
||||||
|
type: 'text',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Grant public view access to publicPromptGroup
|
||||||
|
await permissionService.grantPermission({
|
||||||
|
principalType: 'public',
|
||||||
|
principalId: null,
|
||||||
|
resourceType: 'promptGroup',
|
||||||
|
resourceId: publicPromptGroup._id,
|
||||||
|
accessRoleId: 'promptGroup_viewer',
|
||||||
|
grantedBy: testUsers.owner._id,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Grant only owner access to privatePromptGroup
|
||||||
|
await permissionService.grantPermission({
|
||||||
|
principalType: 'user',
|
||||||
|
principalId: testUsers.owner._id,
|
||||||
|
resourceType: 'promptGroup',
|
||||||
|
resourceId: privatePromptGroup._id,
|
||||||
|
accessRoleId: 'promptGroup_owner',
|
||||||
|
grantedBy: testUsers.owner._id,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await Prompt.deleteMany({});
|
||||||
|
await PromptGroup.deleteMany({});
|
||||||
|
await AclEntry.deleteMany({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('public prompt should be accessible to any user', async () => {
|
||||||
|
const hasAccess = await permissionService.checkPermission({
|
||||||
|
userId: testUsers.noAccess._id,
|
||||||
|
resourceType: 'promptGroup',
|
||||||
|
resourceId: publicPromptGroup._id,
|
||||||
|
requiredPermission: PermissionBits.VIEW,
|
||||||
|
includePublic: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(hasAccess).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('private prompt should not be accessible to unauthorized users', async () => {
|
||||||
|
const hasAccess = await permissionService.checkPermission({
|
||||||
|
userId: testUsers.noAccess._id,
|
||||||
|
resourceType: 'promptGroup',
|
||||||
|
resourceId: privatePromptGroup._id,
|
||||||
|
requiredPermission: PermissionBits.VIEW,
|
||||||
|
includePublic: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(hasAccess).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Prompt Deletion', () => {
|
||||||
|
let testPromptGroup;
|
||||||
|
|
||||||
|
it('should remove ACL entries when prompt is deleted', async () => {
|
||||||
|
testPromptGroup = await PromptGroup.create({
|
||||||
|
name: 'Deletion Test Group',
|
||||||
|
author: testUsers.owner._id,
|
||||||
|
authorName: testUsers.owner.name,
|
||||||
|
productionId: new ObjectId(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const prompt = await promptFns.savePrompt({
|
||||||
|
author: testUsers.owner._id,
|
||||||
|
prompt: {
|
||||||
|
prompt: 'To be deleted',
|
||||||
|
name: 'Delete Test',
|
||||||
|
groupId: testPromptGroup._id,
|
||||||
|
type: 'text',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check if savePrompt returned an error
|
||||||
|
if (!prompt || !prompt.prompt) {
|
||||||
|
throw new Error(`Failed to save prompt: ${prompt?.message || 'Unknown error'}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const testPromptId = prompt.prompt._id;
|
||||||
|
const promptGroupId = testPromptGroup._id;
|
||||||
|
|
||||||
|
// Grant permission
|
||||||
|
await permissionService.grantPermission({
|
||||||
|
principalType: 'user',
|
||||||
|
principalId: testUsers.owner._id,
|
||||||
|
resourceType: 'promptGroup',
|
||||||
|
resourceId: testPromptGroup._id,
|
||||||
|
accessRoleId: 'promptGroup_owner',
|
||||||
|
grantedBy: testUsers.owner._id,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify ACL entry exists
|
||||||
|
const beforeDelete = await AclEntry.find({
|
||||||
|
resourceType: 'promptGroup',
|
||||||
|
resourceId: testPromptGroup._id,
|
||||||
|
});
|
||||||
|
expect(beforeDelete).toHaveLength(1);
|
||||||
|
|
||||||
|
// Delete the prompt
|
||||||
|
await promptFns.deletePrompt({
|
||||||
|
promptId: testPromptId,
|
||||||
|
groupId: promptGroupId,
|
||||||
|
author: testUsers.owner._id,
|
||||||
|
role: SystemRoles.USER,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify ACL entries are removed
|
||||||
|
const aclEntries = await AclEntry.find({
|
||||||
|
resourceType: 'promptGroup',
|
||||||
|
resourceId: testPromptGroup._id,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(aclEntries).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Backwards Compatibility', () => {
|
||||||
|
it('should handle prompts without ACL entries gracefully', async () => {
|
||||||
|
// Create a prompt group first
|
||||||
|
const promptGroup = await PromptGroup.create({
|
||||||
|
name: 'Legacy Test Group',
|
||||||
|
author: testUsers.owner._id,
|
||||||
|
authorName: testUsers.owner.name,
|
||||||
|
productionId: new ObjectId(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create a prompt without ACL entries (legacy prompt)
|
||||||
|
const legacyPrompt = await Prompt.create({
|
||||||
|
prompt: 'Legacy prompt without ACL',
|
||||||
|
name: 'Legacy',
|
||||||
|
author: testUsers.owner._id,
|
||||||
|
groupId: promptGroup._id,
|
||||||
|
type: 'text',
|
||||||
|
});
|
||||||
|
|
||||||
|
// The system should handle this gracefully
|
||||||
|
const prompt = await promptFns.getPrompt({ _id: legacyPrompt._id });
|
||||||
|
expect(prompt).toBeTruthy();
|
||||||
|
expect(prompt._id.toString()).toBe(legacyPrompt._id.toString());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
273
api/models/PromptGroupMigration.spec.js
Normal file
273
api/models/PromptGroupMigration.spec.js
Normal file
|
@ -0,0 +1,273 @@
|
||||||
|
const { ObjectId } = require('mongodb');
|
||||||
|
const { MongoMemoryServer } = require('mongodb-memory-server');
|
||||||
|
const mongoose = require('mongoose');
|
||||||
|
const { logger, PermissionBits } = require('@librechat/data-schemas');
|
||||||
|
const { Constants } = require('librechat-data-provider');
|
||||||
|
|
||||||
|
// Mock the config/connect module to prevent connection attempts during tests
|
||||||
|
jest.mock('../../config/connect', () => jest.fn().mockResolvedValue(true));
|
||||||
|
|
||||||
|
// Disable console for tests
|
||||||
|
logger.silent = true;
|
||||||
|
|
||||||
|
describe('PromptGroup Migration Script', () => {
|
||||||
|
let mongoServer;
|
||||||
|
let Prompt, PromptGroup, AclEntry, AccessRole, User, Project;
|
||||||
|
let migrateToPromptGroupPermissions;
|
||||||
|
let testOwner, testProject;
|
||||||
|
let ownerRole, viewerRole;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
// Set up MongoDB memory server
|
||||||
|
mongoServer = await MongoMemoryServer.create();
|
||||||
|
const mongoUri = mongoServer.getUri();
|
||||||
|
await mongoose.connect(mongoUri);
|
||||||
|
|
||||||
|
// Initialize models
|
||||||
|
const dbModels = require('~/db/models');
|
||||||
|
Prompt = dbModels.Prompt;
|
||||||
|
PromptGroup = dbModels.PromptGroup;
|
||||||
|
AclEntry = dbModels.AclEntry;
|
||||||
|
AccessRole = dbModels.AccessRole;
|
||||||
|
User = dbModels.User;
|
||||||
|
Project = dbModels.Project;
|
||||||
|
|
||||||
|
// Create test user
|
||||||
|
testOwner = await User.create({
|
||||||
|
name: 'Test Owner',
|
||||||
|
email: 'owner@test.com',
|
||||||
|
role: 'USER',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create test project with the proper name
|
||||||
|
const projectName = Constants.GLOBAL_PROJECT_NAME || 'instance';
|
||||||
|
testProject = await Project.create({
|
||||||
|
name: projectName,
|
||||||
|
description: 'Global project',
|
||||||
|
promptGroupIds: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create promptGroup access roles
|
||||||
|
ownerRole = await AccessRole.create({
|
||||||
|
accessRoleId: 'promptGroup_owner',
|
||||||
|
name: 'Owner',
|
||||||
|
description: 'Full control over promptGroups',
|
||||||
|
resourceType: 'promptGroup',
|
||||||
|
permBits:
|
||||||
|
PermissionBits.VIEW | PermissionBits.EDIT | PermissionBits.DELETE | PermissionBits.SHARE,
|
||||||
|
});
|
||||||
|
|
||||||
|
viewerRole = await AccessRole.create({
|
||||||
|
accessRoleId: 'promptGroup_viewer',
|
||||||
|
name: 'Viewer',
|
||||||
|
description: 'Can view promptGroups',
|
||||||
|
resourceType: 'promptGroup',
|
||||||
|
permBits: PermissionBits.VIEW,
|
||||||
|
});
|
||||||
|
|
||||||
|
await AccessRole.create({
|
||||||
|
accessRoleId: 'promptGroup_editor',
|
||||||
|
name: 'Editor',
|
||||||
|
description: 'Can view and edit promptGroups',
|
||||||
|
resourceType: 'promptGroup',
|
||||||
|
permBits: PermissionBits.VIEW | PermissionBits.EDIT,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Import migration function
|
||||||
|
const migration = require('../../config/migrate-prompt-permissions');
|
||||||
|
migrateToPromptGroupPermissions = migration.migrateToPromptGroupPermissions;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await mongoose.disconnect();
|
||||||
|
await mongoServer.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
// Clean up before each test
|
||||||
|
await Prompt.deleteMany({});
|
||||||
|
await PromptGroup.deleteMany({});
|
||||||
|
await AclEntry.deleteMany({});
|
||||||
|
// Reset the project's promptGroupIds array
|
||||||
|
testProject.promptGroupIds = [];
|
||||||
|
await testProject.save();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should categorize promptGroups correctly in dry run', async () => {
|
||||||
|
// Create global prompt group (in Global project)
|
||||||
|
const globalPromptGroup = await PromptGroup.create({
|
||||||
|
name: 'Global Group',
|
||||||
|
author: testOwner._id,
|
||||||
|
authorName: testOwner.name,
|
||||||
|
productionId: new ObjectId(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create private prompt group (not in any project)
|
||||||
|
const privatePromptGroup = await PromptGroup.create({
|
||||||
|
name: 'Private Group',
|
||||||
|
author: testOwner._id,
|
||||||
|
authorName: testOwner.name,
|
||||||
|
productionId: new ObjectId(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add global group to project's promptGroupIds array
|
||||||
|
testProject.promptGroupIds = [globalPromptGroup._id];
|
||||||
|
await testProject.save();
|
||||||
|
|
||||||
|
const result = await migrateToPromptGroupPermissions({ dryRun: true });
|
||||||
|
|
||||||
|
expect(result.dryRun).toBe(true);
|
||||||
|
expect(result.summary.total).toBe(2);
|
||||||
|
expect(result.summary.globalViewAccess).toBe(1);
|
||||||
|
expect(result.summary.privateGroups).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should grant appropriate permissions during migration', async () => {
|
||||||
|
// Create prompt groups
|
||||||
|
const globalPromptGroup = await PromptGroup.create({
|
||||||
|
name: 'Global Group',
|
||||||
|
author: testOwner._id,
|
||||||
|
authorName: testOwner.name,
|
||||||
|
productionId: new ObjectId(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const privatePromptGroup = await PromptGroup.create({
|
||||||
|
name: 'Private Group',
|
||||||
|
author: testOwner._id,
|
||||||
|
authorName: testOwner.name,
|
||||||
|
productionId: new ObjectId(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add global group to project's promptGroupIds array
|
||||||
|
testProject.promptGroupIds = [globalPromptGroup._id];
|
||||||
|
await testProject.save();
|
||||||
|
|
||||||
|
const result = await migrateToPromptGroupPermissions({ dryRun: false });
|
||||||
|
|
||||||
|
expect(result.migrated).toBe(2);
|
||||||
|
expect(result.errors).toBe(0);
|
||||||
|
expect(result.ownerGrants).toBe(2);
|
||||||
|
expect(result.publicViewGrants).toBe(1);
|
||||||
|
|
||||||
|
// Check global promptGroup permissions
|
||||||
|
const globalOwnerEntry = await AclEntry.findOne({
|
||||||
|
resourceType: 'promptGroup',
|
||||||
|
resourceId: globalPromptGroup._id,
|
||||||
|
principalType: 'user',
|
||||||
|
principalId: testOwner._id,
|
||||||
|
});
|
||||||
|
expect(globalOwnerEntry).toBeTruthy();
|
||||||
|
expect(globalOwnerEntry.permBits).toBe(ownerRole.permBits);
|
||||||
|
|
||||||
|
const globalPublicEntry = await AclEntry.findOne({
|
||||||
|
resourceType: 'promptGroup',
|
||||||
|
resourceId: globalPromptGroup._id,
|
||||||
|
principalType: 'public',
|
||||||
|
});
|
||||||
|
expect(globalPublicEntry).toBeTruthy();
|
||||||
|
expect(globalPublicEntry.permBits).toBe(viewerRole.permBits);
|
||||||
|
|
||||||
|
// Check private promptGroup permissions
|
||||||
|
const privateOwnerEntry = await AclEntry.findOne({
|
||||||
|
resourceType: 'promptGroup',
|
||||||
|
resourceId: privatePromptGroup._id,
|
||||||
|
principalType: 'user',
|
||||||
|
principalId: testOwner._id,
|
||||||
|
});
|
||||||
|
expect(privateOwnerEntry).toBeTruthy();
|
||||||
|
expect(privateOwnerEntry.permBits).toBe(ownerRole.permBits);
|
||||||
|
|
||||||
|
const privatePublicEntry = await AclEntry.findOne({
|
||||||
|
resourceType: 'promptGroup',
|
||||||
|
resourceId: privatePromptGroup._id,
|
||||||
|
principalType: 'public',
|
||||||
|
});
|
||||||
|
expect(privatePublicEntry).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should skip promptGroups that already have ACL entries', async () => {
|
||||||
|
// Create prompt groups
|
||||||
|
const promptGroup1 = await PromptGroup.create({
|
||||||
|
name: 'Group 1',
|
||||||
|
author: testOwner._id,
|
||||||
|
authorName: testOwner.name,
|
||||||
|
productionId: new ObjectId(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const promptGroup2 = await PromptGroup.create({
|
||||||
|
name: 'Group 2',
|
||||||
|
author: testOwner._id,
|
||||||
|
authorName: testOwner.name,
|
||||||
|
productionId: new ObjectId(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Grant permission to one promptGroup manually (simulating it already has ACL)
|
||||||
|
await AclEntry.create({
|
||||||
|
principalType: 'user',
|
||||||
|
principalId: testOwner._id,
|
||||||
|
principalModel: 'User',
|
||||||
|
resourceType: 'promptGroup',
|
||||||
|
resourceId: promptGroup1._id,
|
||||||
|
permBits: ownerRole.permBits,
|
||||||
|
roleId: ownerRole._id,
|
||||||
|
grantedBy: testOwner._id,
|
||||||
|
grantedAt: new Date(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await migrateToPromptGroupPermissions({ dryRun: false });
|
||||||
|
|
||||||
|
// Should only migrate promptGroup2, skip promptGroup1
|
||||||
|
expect(result.migrated).toBe(1);
|
||||||
|
expect(result.errors).toBe(0);
|
||||||
|
|
||||||
|
// Verify promptGroup2 now has permissions
|
||||||
|
const group2Entry = await AclEntry.findOne({
|
||||||
|
resourceType: 'promptGroup',
|
||||||
|
resourceId: promptGroup2._id,
|
||||||
|
});
|
||||||
|
expect(group2Entry).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle promptGroups with prompts correctly', async () => {
|
||||||
|
// Create a promptGroup with some prompts
|
||||||
|
const promptGroup = await PromptGroup.create({
|
||||||
|
name: 'Group with Prompts',
|
||||||
|
author: testOwner._id,
|
||||||
|
authorName: testOwner.name,
|
||||||
|
productionId: new ObjectId(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create some prompts in this group
|
||||||
|
await Prompt.create({
|
||||||
|
prompt: 'First prompt',
|
||||||
|
author: testOwner._id,
|
||||||
|
groupId: promptGroup._id,
|
||||||
|
type: 'text',
|
||||||
|
});
|
||||||
|
|
||||||
|
await Prompt.create({
|
||||||
|
prompt: 'Second prompt',
|
||||||
|
author: testOwner._id,
|
||||||
|
groupId: promptGroup._id,
|
||||||
|
type: 'text',
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await migrateToPromptGroupPermissions({ dryRun: false });
|
||||||
|
|
||||||
|
expect(result.migrated).toBe(1);
|
||||||
|
expect(result.errors).toBe(0);
|
||||||
|
|
||||||
|
// Verify the promptGroup has permissions
|
||||||
|
const groupEntry = await AclEntry.findOne({
|
||||||
|
resourceType: 'promptGroup',
|
||||||
|
resourceId: promptGroup._id,
|
||||||
|
});
|
||||||
|
expect(groupEntry).toBeTruthy();
|
||||||
|
|
||||||
|
// Verify no prompt-level permissions were created
|
||||||
|
const promptEntries = await AclEntry.find({
|
||||||
|
resourceType: 'prompt',
|
||||||
|
});
|
||||||
|
expect(promptEntries).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
|
@ -0,0 +1,60 @@
|
||||||
|
const { getPromptGroup } = require('~/models/Prompt');
|
||||||
|
const { canAccessResource } = require('./canAccessResource');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PromptGroup ID resolver function
|
||||||
|
* Resolves promptGroup ID to MongoDB ObjectId
|
||||||
|
*
|
||||||
|
* @param {string} groupId - PromptGroup ID from route parameter
|
||||||
|
* @returns {Promise<Object|null>} PromptGroup document with _id field, or null if not found
|
||||||
|
*/
|
||||||
|
const resolvePromptGroupId = async (groupId) => {
|
||||||
|
return await getPromptGroup({ _id: groupId });
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PromptGroup-specific middleware factory that creates middleware to check promptGroup access permissions.
|
||||||
|
* This middleware extends the generic canAccessResource to handle promptGroup ID resolution.
|
||||||
|
*
|
||||||
|
* @param {Object} options - Configuration options
|
||||||
|
* @param {number} options.requiredPermission - The permission bit required (1=view, 2=edit, 4=delete, 8=share)
|
||||||
|
* @param {string} [options.resourceIdParam='groupId'] - The name of the route parameter containing the promptGroup ID
|
||||||
|
* @returns {Function} Express middleware function
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // Basic usage for viewing promptGroups
|
||||||
|
* router.get('/prompts/groups/:groupId',
|
||||||
|
* canAccessPromptGroupResource({ requiredPermission: 1 }),
|
||||||
|
* getPromptGroup
|
||||||
|
* );
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // Custom resource ID parameter and edit permission
|
||||||
|
* router.patch('/prompts/groups/:id',
|
||||||
|
* canAccessPromptGroupResource({
|
||||||
|
* requiredPermission: 2,
|
||||||
|
* resourceIdParam: 'id'
|
||||||
|
* }),
|
||||||
|
* updatePromptGroup
|
||||||
|
* );
|
||||||
|
*/
|
||||||
|
const canAccessPromptGroupResource = (options) => {
|
||||||
|
const { requiredPermission, resourceIdParam = 'groupId' } = options;
|
||||||
|
|
||||||
|
if (!requiredPermission || typeof requiredPermission !== 'number') {
|
||||||
|
throw new Error(
|
||||||
|
'canAccessPromptGroupResource: requiredPermission is required and must be a number',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return canAccessResource({
|
||||||
|
resourceType: 'promptGroup',
|
||||||
|
requiredPermission,
|
||||||
|
resourceIdParam,
|
||||||
|
idResolver: resolvePromptGroupId,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
canAccessPromptGroupResource,
|
||||||
|
};
|
|
@ -0,0 +1,58 @@
|
||||||
|
const { getPrompt } = require('~/models/Prompt');
|
||||||
|
const { canAccessResource } = require('./canAccessResource');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prompt ID resolver function
|
||||||
|
* Resolves prompt ID to MongoDB ObjectId
|
||||||
|
*
|
||||||
|
* @param {string} promptId - Prompt ID from route parameter
|
||||||
|
* @returns {Promise<Object|null>} Prompt document with _id field, or null if not found
|
||||||
|
*/
|
||||||
|
const resolvePromptId = async (promptId) => {
|
||||||
|
return await getPrompt({ _id: promptId });
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prompt-specific middleware factory that creates middleware to check prompt access permissions.
|
||||||
|
* This middleware extends the generic canAccessResource to handle prompt ID resolution.
|
||||||
|
*
|
||||||
|
* @param {Object} options - Configuration options
|
||||||
|
* @param {number} options.requiredPermission - The permission bit required (1=view, 2=edit, 4=delete, 8=share)
|
||||||
|
* @param {string} [options.resourceIdParam='promptId'] - The name of the route parameter containing the prompt ID
|
||||||
|
* @returns {Function} Express middleware function
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // Basic usage for viewing prompts
|
||||||
|
* router.get('/prompts/:promptId',
|
||||||
|
* canAccessPromptResource({ requiredPermission: 1 }),
|
||||||
|
* getPrompt
|
||||||
|
* );
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // Custom resource ID parameter and edit permission
|
||||||
|
* router.patch('/prompts/:id',
|
||||||
|
* canAccessPromptResource({
|
||||||
|
* requiredPermission: 2,
|
||||||
|
* resourceIdParam: 'id'
|
||||||
|
* }),
|
||||||
|
* updatePrompt
|
||||||
|
* );
|
||||||
|
*/
|
||||||
|
const canAccessPromptResource = (options) => {
|
||||||
|
const { requiredPermission, resourceIdParam = 'promptId' } = options;
|
||||||
|
|
||||||
|
if (!requiredPermission || typeof requiredPermission !== 'number') {
|
||||||
|
throw new Error('canAccessPromptResource: requiredPermission is required and must be a number');
|
||||||
|
}
|
||||||
|
|
||||||
|
return canAccessResource({
|
||||||
|
resourceType: 'prompt',
|
||||||
|
requiredPermission,
|
||||||
|
resourceIdParam,
|
||||||
|
idResolver: resolvePromptId,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
canAccessPromptResource,
|
||||||
|
};
|
|
@ -0,0 +1,54 @@
|
||||||
|
const { getPrompt } = require('~/models/Prompt');
|
||||||
|
const { canAccessResource } = require('./canAccessResource');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prompt to PromptGroup ID resolver function
|
||||||
|
* Resolves prompt ID to its parent promptGroup ID
|
||||||
|
*
|
||||||
|
* @param {string} promptId - Prompt ID from route parameter
|
||||||
|
* @returns {Promise<Object|null>} Object with promptGroup's _id field, or null if not found
|
||||||
|
*/
|
||||||
|
const resolvePromptToGroupId = async (promptId) => {
|
||||||
|
const prompt = await getPrompt({ _id: promptId });
|
||||||
|
if (!prompt || !prompt.groupId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
// Return an object with _id that matches the promptGroup ID
|
||||||
|
return { _id: prompt.groupId };
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Middleware factory that checks promptGroup permissions when accessing individual prompts.
|
||||||
|
* This allows permission management at the promptGroup level while still supporting
|
||||||
|
* individual prompt access patterns.
|
||||||
|
*
|
||||||
|
* @param {Object} options - Configuration options
|
||||||
|
* @param {number} options.requiredPermission - The permission bit required (1=view, 2=edit, 4=delete, 8=share)
|
||||||
|
* @param {string} [options.resourceIdParam='promptId'] - The name of the route parameter containing the prompt ID
|
||||||
|
* @returns {Function} Express middleware function
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // Check promptGroup permissions when viewing a prompt
|
||||||
|
* router.get('/prompts/:promptId',
|
||||||
|
* canAccessPromptViaGroup({ requiredPermission: 1 }),
|
||||||
|
* getPrompt
|
||||||
|
* );
|
||||||
|
*/
|
||||||
|
const canAccessPromptViaGroup = (options) => {
|
||||||
|
const { requiredPermission, resourceIdParam = 'promptId' } = options;
|
||||||
|
|
||||||
|
if (!requiredPermission || typeof requiredPermission !== 'number') {
|
||||||
|
throw new Error('canAccessPromptViaGroup: requiredPermission is required and must be a number');
|
||||||
|
}
|
||||||
|
|
||||||
|
return canAccessResource({
|
||||||
|
resourceType: 'promptGroup',
|
||||||
|
requiredPermission,
|
||||||
|
resourceIdParam,
|
||||||
|
idResolver: resolvePromptToGroupId,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
canAccessPromptViaGroup,
|
||||||
|
};
|
|
@ -1,9 +1,15 @@
|
||||||
const { canAccessResource } = require('./canAccessResource');
|
const { canAccessResource } = require('./canAccessResource');
|
||||||
const { canAccessAgentResource } = require('./canAccessAgentResource');
|
const { canAccessAgentResource } = require('./canAccessAgentResource');
|
||||||
const { canAccessAgentFromBody } = require('./canAccessAgentFromBody');
|
const { canAccessAgentFromBody } = require('./canAccessAgentFromBody');
|
||||||
|
const { canAccessPromptResource } = require('./canAccessPromptResource');
|
||||||
|
const { canAccessPromptViaGroup } = require('./canAccessPromptViaGroup');
|
||||||
|
const { canAccessPromptGroupResource } = require('./canAccessPromptGroupResource');
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
canAccessResource,
|
canAccessResource,
|
||||||
canAccessAgentResource,
|
canAccessAgentResource,
|
||||||
canAccessAgentFromBody,
|
canAccessAgentFromBody,
|
||||||
|
canAccessPromptResource,
|
||||||
|
canAccessPromptViaGroup,
|
||||||
|
canAccessPromptGroupResource,
|
||||||
};
|
};
|
||||||
|
|
|
@ -46,11 +46,35 @@ router.get('/:resourceType/:resourceId', getResourcePermissions);
|
||||||
*/
|
*/
|
||||||
router.put(
|
router.put(
|
||||||
'/:resourceType/:resourceId',
|
'/:resourceType/:resourceId',
|
||||||
canAccessResource({
|
// Use middleware that dynamically handles resource type and permissions
|
||||||
resourceType: 'agent',
|
(req, res, next) => {
|
||||||
requiredPermission: PermissionBits.SHARE,
|
const { resourceType } = req.params;
|
||||||
resourceIdParam: 'resourceId',
|
|
||||||
}),
|
// Define resource-specific middleware based on resourceType
|
||||||
|
let middleware;
|
||||||
|
|
||||||
|
if (resourceType === 'agent') {
|
||||||
|
middleware = canAccessResource({
|
||||||
|
resourceType: 'agent',
|
||||||
|
requiredPermission: PermissionBits.SHARE,
|
||||||
|
resourceIdParam: 'resourceId',
|
||||||
|
});
|
||||||
|
} else if (resourceType === 'promptGroup') {
|
||||||
|
middleware = canAccessResource({
|
||||||
|
resourceType: 'promptGroup',
|
||||||
|
requiredPermission: PermissionBits.SHARE,
|
||||||
|
resourceIdParam: 'resourceId',
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'Bad Request',
|
||||||
|
message: `Unsupported resource type: ${resourceType}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute the middleware
|
||||||
|
middleware(req, res, next);
|
||||||
|
},
|
||||||
updateResourcePermissions,
|
updateResourcePermissions,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
const { logger } = require('@librechat/data-schemas');
|
const { logger, PermissionBits } = require('@librechat/data-schemas');
|
||||||
const { generateCheckAccess } = require('@librechat/api');
|
const { generateCheckAccess } = require('@librechat/api');
|
||||||
const { Permissions, SystemRoles, PermissionTypes } = require('librechat-data-provider');
|
const { Permissions, SystemRoles, PermissionTypes } = require('librechat-data-provider');
|
||||||
const {
|
const {
|
||||||
|
@ -16,7 +16,17 @@ const {
|
||||||
// updatePromptLabels,
|
// updatePromptLabels,
|
||||||
makePromptProduction,
|
makePromptProduction,
|
||||||
} = require('~/models/Prompt');
|
} = require('~/models/Prompt');
|
||||||
const { requireJwtAuth } = require('~/server/middleware');
|
const {
|
||||||
|
canAccessPromptGroupResource,
|
||||||
|
canAccessPromptViaGroup,
|
||||||
|
requireJwtAuth,
|
||||||
|
} = require('~/server/middleware');
|
||||||
|
const {
|
||||||
|
grantPermission,
|
||||||
|
getEffectivePermissions,
|
||||||
|
findAccessibleResources,
|
||||||
|
findPubliclyAccessibleResources,
|
||||||
|
} = require('~/server/services/PermissionService');
|
||||||
const { getRoleByName } = require('~/models/Role');
|
const { getRoleByName } = require('~/models/Role');
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
@ -48,43 +58,52 @@ router.use(checkPromptAccess);
|
||||||
* Route to get single prompt group by its ID
|
* Route to get single prompt group by its ID
|
||||||
* GET /groups/:groupId
|
* GET /groups/:groupId
|
||||||
*/
|
*/
|
||||||
router.get('/groups/:groupId', async (req, res) => {
|
router.get(
|
||||||
let groupId = req.params.groupId;
|
'/groups/:groupId',
|
||||||
const author = req.user.id;
|
canAccessPromptGroupResource({
|
||||||
|
requiredPermission: PermissionBits.VIEW,
|
||||||
|
}),
|
||||||
|
async (req, res) => {
|
||||||
|
const { groupId } = req.params;
|
||||||
|
|
||||||
const query = {
|
try {
|
||||||
_id: groupId,
|
const group = await getPromptGroup({ _id: groupId });
|
||||||
$or: [{ projectIds: { $exists: true, $ne: [], $not: { $size: 0 } } }, { author }],
|
|
||||||
};
|
|
||||||
|
|
||||||
if (req.user.role === SystemRoles.ADMIN) {
|
if (!group) {
|
||||||
delete query.$or;
|
return res.status(404).send({ message: 'Prompt group not found' });
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
res.status(200).send(group);
|
||||||
const group = await getPromptGroup(query);
|
} catch (error) {
|
||||||
|
logger.error('Error getting prompt group', error);
|
||||||
if (!group) {
|
res.status(500).send({ message: 'Error getting prompt group' });
|
||||||
return res.status(404).send({ message: 'Prompt group not found' });
|
|
||||||
}
|
}
|
||||||
|
},
|
||||||
res.status(200).send(group);
|
);
|
||||||
} catch (error) {
|
|
||||||
logger.error('Error getting prompt group', error);
|
|
||||||
res.status(500).send({ message: 'Error getting prompt group' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Route to fetch all prompt groups
|
* Route to fetch all prompt groups (ACL-aware)
|
||||||
* GET /groups
|
* GET /all
|
||||||
*/
|
*/
|
||||||
router.get('/all', async (req, res) => {
|
router.get('/all', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const groups = await getAllPromptGroups(req, {
|
const userId = req.user.id;
|
||||||
author: req.user._id,
|
|
||||||
|
// Get promptGroup IDs the user has VIEW access to via ACL
|
||||||
|
const accessibleIds = await findAccessibleResources({
|
||||||
|
userId,
|
||||||
|
resourceType: 'promptGroup',
|
||||||
|
requiredPermissions: PermissionBits.VIEW,
|
||||||
});
|
});
|
||||||
res.status(200).send(groups);
|
|
||||||
|
const groups = await getAllPromptGroups(req, {});
|
||||||
|
|
||||||
|
// Filter the results to only include accessible groups
|
||||||
|
const accessibleGroups = groups.filter((group) =>
|
||||||
|
accessibleIds.some((id) => id.toString() === group._id.toString()),
|
||||||
|
);
|
||||||
|
|
||||||
|
res.status(200).send(accessibleGroups);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(error);
|
logger.error(error);
|
||||||
res.status(500).send({ error: 'Error getting prompt groups' });
|
res.status(500).send({ error: 'Error getting prompt groups' });
|
||||||
|
@ -92,15 +111,44 @@ router.get('/all', async (req, res) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Route to fetch paginated prompt groups with filters
|
* Route to fetch paginated prompt groups with filters (ACL-aware)
|
||||||
* GET /groups
|
* GET /groups
|
||||||
*/
|
*/
|
||||||
router.get('/groups', async (req, res) => {
|
router.get('/groups', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const filter = req.query;
|
const userId = req.user.id;
|
||||||
/* Note: The aggregation requires an ObjectId */
|
const filter = { ...req.query };
|
||||||
filter.author = req.user._id;
|
delete filter.author; // Remove author filter as we'll use ACL
|
||||||
|
|
||||||
|
// Get promptGroup IDs the user has VIEW access to via ACL
|
||||||
|
const accessibleIds = await findAccessibleResources({
|
||||||
|
userId,
|
||||||
|
resourceType: 'promptGroup',
|
||||||
|
requiredPermissions: PermissionBits.VIEW,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get publicly accessible promptGroups
|
||||||
|
const publiclyAccessibleIds = await findPubliclyAccessibleResources({
|
||||||
|
resourceType: 'promptGroup',
|
||||||
|
requiredPermissions: PermissionBits.VIEW,
|
||||||
|
});
|
||||||
|
|
||||||
const groups = await getPromptGroups(req, filter);
|
const groups = await getPromptGroups(req, filter);
|
||||||
|
|
||||||
|
if (groups.promptGroups && groups.promptGroups.length > 0) {
|
||||||
|
groups.promptGroups = groups.promptGroups.filter((group) =>
|
||||||
|
accessibleIds.some((id) => id.toString() === group._id.toString()),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Mark public groups
|
||||||
|
groups.promptGroups = groups.promptGroups.map((group) => {
|
||||||
|
if (publiclyAccessibleIds.some((id) => id.equals(group._id))) {
|
||||||
|
group.isPublic = true;
|
||||||
|
}
|
||||||
|
return group;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
res.status(200).send(groups);
|
res.status(200).send(groups);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(error);
|
logger.error(error);
|
||||||
|
@ -109,16 +157,17 @@ router.get('/groups', async (req, res) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Updates or creates a prompt + promptGroup
|
* Creates a new prompt group with initial prompt
|
||||||
* @param {object} req
|
* @param {object} req
|
||||||
* @param {TCreatePrompt} req.body
|
* @param {TCreatePrompt} req.body
|
||||||
* @param {Express.Response} res
|
* @param {Express.Response} res
|
||||||
*/
|
*/
|
||||||
const createPrompt = async (req, res) => {
|
const createNewPromptGroup = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { prompt, group } = req.body;
|
const { prompt, group } = req.body;
|
||||||
if (!prompt) {
|
|
||||||
return res.status(400).send({ error: 'Prompt is required' });
|
if (!prompt || !group || !group.name) {
|
||||||
|
return res.status(400).send({ error: 'Prompt and group name are required' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const saveData = {
|
const saveData = {
|
||||||
|
@ -128,21 +177,81 @@ const createPrompt = async (req, res) => {
|
||||||
authorName: req.user.name,
|
authorName: req.user.name,
|
||||||
};
|
};
|
||||||
|
|
||||||
/** @type {TCreatePromptResponse} */
|
const result = await createPromptGroup(saveData);
|
||||||
let result;
|
|
||||||
if (group && group.name) {
|
// Grant owner permissions to the creator on the new promptGroup
|
||||||
result = await createPromptGroup(saveData);
|
if (result.prompt && result.prompt._id && result.prompt.groupId) {
|
||||||
} else {
|
try {
|
||||||
result = await savePrompt(saveData);
|
await grantPermission({
|
||||||
|
principalType: 'user',
|
||||||
|
principalId: req.user.id,
|
||||||
|
resourceType: 'promptGroup',
|
||||||
|
resourceId: result.prompt.groupId,
|
||||||
|
accessRoleId: 'promptGroup_owner',
|
||||||
|
grantedBy: req.user.id,
|
||||||
|
});
|
||||||
|
logger.debug(
|
||||||
|
`[createPromptGroup] Granted owner permissions to user ${req.user.id} for promptGroup ${result.prompt.groupId}`,
|
||||||
|
);
|
||||||
|
} catch (permissionError) {
|
||||||
|
logger.error(
|
||||||
|
`[createPromptGroup] Failed to grant owner permissions for promptGroup ${result.prompt.groupId}:`,
|
||||||
|
permissionError,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
res.status(200).send(result);
|
res.status(200).send(result);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(error);
|
logger.error(error);
|
||||||
res.status(500).send({ error: 'Error saving prompt' });
|
res.status(500).send({ error: 'Error creating prompt group' });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
router.post('/', checkPromptCreate, createPrompt);
|
/**
|
||||||
|
* Adds a new prompt to an existing prompt group
|
||||||
|
* @param {object} req
|
||||||
|
* @param {TCreatePrompt} req.body
|
||||||
|
* @param {Express.Response} res
|
||||||
|
*/
|
||||||
|
const addPromptToGroup = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { groupId } = req.params;
|
||||||
|
const { prompt } = req.body;
|
||||||
|
|
||||||
|
if (!prompt) {
|
||||||
|
return res.status(400).send({ error: 'Prompt is required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure the prompt is associated with the correct group
|
||||||
|
prompt.groupId = groupId;
|
||||||
|
|
||||||
|
const saveData = {
|
||||||
|
prompt,
|
||||||
|
author: req.user.id,
|
||||||
|
authorName: req.user.name,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await savePrompt(saveData);
|
||||||
|
res.status(200).send(result);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error);
|
||||||
|
res.status(500).send({ error: 'Error adding prompt to group' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create new prompt group (requires CREATE permission)
|
||||||
|
router.post('/', checkPromptCreate, createNewPromptGroup);
|
||||||
|
|
||||||
|
// Add prompt to existing group (requires EDIT permission on the group)
|
||||||
|
router.post(
|
||||||
|
'/groups/:groupId/prompts',
|
||||||
|
checkPromptAccess,
|
||||||
|
canAccessPromptGroupResource({
|
||||||
|
requiredPermission: PermissionBits.EDIT,
|
||||||
|
}),
|
||||||
|
addPromptToGroup,
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Updates a prompt group
|
* Updates a prompt group
|
||||||
|
@ -168,35 +277,73 @@ const patchPromptGroup = async (req, res) => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
router.patch('/groups/:groupId', checkGlobalPromptShare, patchPromptGroup);
|
router.patch(
|
||||||
|
'/groups/:groupId',
|
||||||
|
checkGlobalPromptShare,
|
||||||
|
canAccessPromptGroupResource({
|
||||||
|
requiredPermission: PermissionBits.EDIT,
|
||||||
|
}),
|
||||||
|
patchPromptGroup,
|
||||||
|
);
|
||||||
|
|
||||||
router.patch('/:promptId/tags/production', checkPromptCreate, async (req, res) => {
|
router.patch(
|
||||||
try {
|
'/:promptId/tags/production',
|
||||||
|
checkPromptCreate,
|
||||||
|
canAccessPromptViaGroup({
|
||||||
|
requiredPermission: PermissionBits.EDIT,
|
||||||
|
resourceIdParam: 'promptId',
|
||||||
|
}),
|
||||||
|
async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { promptId } = req.params;
|
||||||
|
const result = await makePromptProduction(promptId);
|
||||||
|
res.status(200).send(result);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error);
|
||||||
|
res.status(500).send({ error: 'Error updating prompt production' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/:promptId',
|
||||||
|
canAccessPromptViaGroup({
|
||||||
|
requiredPermission: PermissionBits.VIEW,
|
||||||
|
resourceIdParam: 'promptId',
|
||||||
|
}),
|
||||||
|
async (req, res) => {
|
||||||
const { promptId } = req.params;
|
const { promptId } = req.params;
|
||||||
const result = await makePromptProduction(promptId);
|
const prompt = await getPrompt({ _id: promptId });
|
||||||
res.status(200).send(result);
|
res.status(200).send(prompt);
|
||||||
} catch (error) {
|
},
|
||||||
logger.error(error);
|
);
|
||||||
res.status(500).send({ error: 'Error updating prompt production' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
router.get('/:promptId', async (req, res) => {
|
|
||||||
const { promptId } = req.params;
|
|
||||||
const author = req.user.id;
|
|
||||||
const query = { _id: promptId, author };
|
|
||||||
if (req.user.role === SystemRoles.ADMIN) {
|
|
||||||
delete query.author;
|
|
||||||
}
|
|
||||||
const prompt = await getPrompt(query);
|
|
||||||
res.status(200).send(prompt);
|
|
||||||
});
|
|
||||||
|
|
||||||
router.get('/', async (req, res) => {
|
router.get('/', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const author = req.user.id;
|
const author = req.user.id;
|
||||||
const { groupId } = req.query;
|
const { groupId } = req.query;
|
||||||
const query = { groupId, author };
|
|
||||||
|
// If requesting prompts for a specific group, check permissions
|
||||||
|
if (groupId) {
|
||||||
|
const permissions = await getEffectivePermissions({
|
||||||
|
userId: req.user.id,
|
||||||
|
resourceType: 'promptGroup',
|
||||||
|
resourceId: groupId,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!(permissions & PermissionBits.VIEW)) {
|
||||||
|
return res
|
||||||
|
.status(403)
|
||||||
|
.send({ error: 'Insufficient permissions to view prompts in this group' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// If user has access, fetch all prompts in the group (not just their own)
|
||||||
|
const prompts = await getPrompts({ groupId });
|
||||||
|
return res.status(200).send(prompts);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no groupId, return user's own prompts
|
||||||
|
const query = { author };
|
||||||
if (req.user.role === SystemRoles.ADMIN) {
|
if (req.user.role === SystemRoles.ADMIN) {
|
||||||
delete query.author;
|
delete query.author;
|
||||||
}
|
}
|
||||||
|
@ -240,7 +387,8 @@ const deletePromptController = async (req, res) => {
|
||||||
const deletePromptGroupController = async (req, res) => {
|
const deletePromptGroupController = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { groupId: _id } = req.params;
|
const { groupId: _id } = req.params;
|
||||||
const message = await deletePromptGroup({ _id, author: req.user.id, role: req.user.role });
|
// Don't pass author - permissions are now checked by middleware
|
||||||
|
const message = await deletePromptGroup({ _id, role: req.user.role });
|
||||||
res.send(message);
|
res.send(message);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Error deleting prompt group', error);
|
logger.error('Error deleting prompt group', error);
|
||||||
|
@ -248,7 +396,22 @@ const deletePromptGroupController = async (req, res) => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
router.delete('/:promptId', checkPromptCreate, deletePromptController);
|
router.delete(
|
||||||
router.delete('/groups/:groupId', checkPromptCreate, deletePromptGroupController);
|
'/:promptId',
|
||||||
|
checkPromptCreate,
|
||||||
|
canAccessPromptViaGroup({
|
||||||
|
requiredPermission: PermissionBits.DELETE,
|
||||||
|
resourceIdParam: 'promptId',
|
||||||
|
}),
|
||||||
|
deletePromptController,
|
||||||
|
);
|
||||||
|
router.delete(
|
||||||
|
'/groups/:groupId',
|
||||||
|
checkPromptCreate,
|
||||||
|
canAccessPromptGroupResource({
|
||||||
|
requiredPermission: PermissionBits.DELETE,
|
||||||
|
}),
|
||||||
|
deletePromptGroupController,
|
||||||
|
);
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|
612
api/server/routes/prompts.test.js
Normal file
612
api/server/routes/prompts.test.js
Normal file
|
@ -0,0 +1,612 @@
|
||||||
|
const request = require('supertest');
|
||||||
|
const express = require('express');
|
||||||
|
const { MongoMemoryServer } = require('mongodb-memory-server');
|
||||||
|
const mongoose = require('mongoose');
|
||||||
|
const { ObjectId } = require('mongodb');
|
||||||
|
const { SystemRoles } = require('librechat-data-provider');
|
||||||
|
const { PermissionBits } = require('@librechat/data-schemas');
|
||||||
|
|
||||||
|
// Mock modules before importing
|
||||||
|
jest.mock('~/server/services/Config', () => ({
|
||||||
|
getCachedTools: jest.fn().mockResolvedValue({}),
|
||||||
|
getCustomConfig: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('~/models/Role', () => ({
|
||||||
|
getRoleByName: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('~/server/middleware', () => ({
|
||||||
|
requireJwtAuth: (req, res, next) => next(),
|
||||||
|
canAccessPromptResource: jest.requireActual('~/server/middleware').canAccessPromptResource,
|
||||||
|
canAccessPromptViaGroup: jest.requireActual('~/server/middleware').canAccessPromptViaGroup,
|
||||||
|
canAccessPromptGroupResource:
|
||||||
|
jest.requireActual('~/server/middleware').canAccessPromptGroupResource,
|
||||||
|
}));
|
||||||
|
|
||||||
|
let app;
|
||||||
|
let mongoServer;
|
||||||
|
let promptRoutes;
|
||||||
|
let Prompt, PromptGroup, AclEntry, AccessRole, User;
|
||||||
|
let testUsers, testRoles;
|
||||||
|
let grantPermission;
|
||||||
|
|
||||||
|
// Helper function to set user in middleware
|
||||||
|
function setTestUser(app, user) {
|
||||||
|
app.use((req, res, next) => {
|
||||||
|
req.user = {
|
||||||
|
...(user.toObject ? user.toObject() : user),
|
||||||
|
id: user.id || user._id.toString(),
|
||||||
|
_id: user._id,
|
||||||
|
name: user.name,
|
||||||
|
role: user.role,
|
||||||
|
};
|
||||||
|
if (user.role === SystemRoles.ADMIN) {
|
||||||
|
console.log('Setting admin user with role:', req.user.role);
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
mongoServer = await MongoMemoryServer.create();
|
||||||
|
const mongoUri = mongoServer.getUri();
|
||||||
|
await mongoose.connect(mongoUri);
|
||||||
|
|
||||||
|
// Initialize models
|
||||||
|
const dbModels = require('~/db/models');
|
||||||
|
Prompt = dbModels.Prompt;
|
||||||
|
PromptGroup = dbModels.PromptGroup;
|
||||||
|
AclEntry = dbModels.AclEntry;
|
||||||
|
AccessRole = dbModels.AccessRole;
|
||||||
|
User = dbModels.User;
|
||||||
|
|
||||||
|
// Import permission service
|
||||||
|
const permissionService = require('~/server/services/PermissionService');
|
||||||
|
grantPermission = permissionService.grantPermission;
|
||||||
|
|
||||||
|
// Create test data
|
||||||
|
await setupTestData();
|
||||||
|
|
||||||
|
// Setup Express app
|
||||||
|
app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
|
||||||
|
// Mock authentication middleware - default to owner
|
||||||
|
setTestUser(app, testUsers.owner);
|
||||||
|
|
||||||
|
// Import routes after mocks are set up
|
||||||
|
promptRoutes = require('./prompts');
|
||||||
|
app.use('/api/prompts', promptRoutes);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await mongoose.disconnect();
|
||||||
|
await mongoServer.stop();
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function setupTestData() {
|
||||||
|
// Create access roles for promptGroups
|
||||||
|
testRoles = {
|
||||||
|
viewer: await AccessRole.create({
|
||||||
|
accessRoleId: 'promptGroup_viewer',
|
||||||
|
name: 'Viewer',
|
||||||
|
resourceType: 'promptGroup',
|
||||||
|
permBits: PermissionBits.VIEW,
|
||||||
|
}),
|
||||||
|
editor: await AccessRole.create({
|
||||||
|
accessRoleId: 'promptGroup_editor',
|
||||||
|
name: 'Editor',
|
||||||
|
resourceType: 'promptGroup',
|
||||||
|
permBits: PermissionBits.VIEW | PermissionBits.EDIT,
|
||||||
|
}),
|
||||||
|
owner: await AccessRole.create({
|
||||||
|
accessRoleId: 'promptGroup_owner',
|
||||||
|
name: 'Owner',
|
||||||
|
resourceType: 'promptGroup',
|
||||||
|
permBits:
|
||||||
|
PermissionBits.VIEW | PermissionBits.EDIT | PermissionBits.DELETE | PermissionBits.SHARE,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create test users
|
||||||
|
testUsers = {
|
||||||
|
owner: await User.create({
|
||||||
|
id: new ObjectId().toString(),
|
||||||
|
_id: new ObjectId(),
|
||||||
|
name: 'Prompt Owner',
|
||||||
|
email: 'owner@example.com',
|
||||||
|
role: SystemRoles.USER,
|
||||||
|
}),
|
||||||
|
viewer: await User.create({
|
||||||
|
id: new ObjectId().toString(),
|
||||||
|
_id: new ObjectId(),
|
||||||
|
name: 'Prompt Viewer',
|
||||||
|
email: 'viewer@example.com',
|
||||||
|
role: SystemRoles.USER,
|
||||||
|
}),
|
||||||
|
editor: await User.create({
|
||||||
|
id: new ObjectId().toString(),
|
||||||
|
_id: new ObjectId(),
|
||||||
|
name: 'Prompt Editor',
|
||||||
|
email: 'editor@example.com',
|
||||||
|
role: SystemRoles.USER,
|
||||||
|
}),
|
||||||
|
noAccess: await User.create({
|
||||||
|
id: new ObjectId().toString(),
|
||||||
|
_id: new ObjectId(),
|
||||||
|
name: 'No Access',
|
||||||
|
email: 'noaccess@example.com',
|
||||||
|
role: SystemRoles.USER,
|
||||||
|
}),
|
||||||
|
admin: await User.create({
|
||||||
|
id: new ObjectId().toString(),
|
||||||
|
_id: new ObjectId(),
|
||||||
|
name: 'Admin',
|
||||||
|
email: 'admin@example.com',
|
||||||
|
role: SystemRoles.ADMIN,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mock getRoleByName
|
||||||
|
const { getRoleByName } = require('~/models/Role');
|
||||||
|
getRoleByName.mockImplementation((roleName) => {
|
||||||
|
switch (roleName) {
|
||||||
|
case SystemRoles.USER:
|
||||||
|
return { permissions: { PROMPTS: { USE: true, CREATE: true } } };
|
||||||
|
case SystemRoles.ADMIN:
|
||||||
|
return { permissions: { PROMPTS: { USE: true, CREATE: true, SHARED_GLOBAL: true } } };
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Prompt Routes - ACL Permissions', () => {
|
||||||
|
let consoleErrorSpy;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
consoleErrorSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Simple test to verify route is loaded
|
||||||
|
it('should have routes loaded', async () => {
|
||||||
|
// This should at least not crash
|
||||||
|
const response = await request(app).get('/api/prompts/test-404');
|
||||||
|
console.log('Test 404 response status:', response.status);
|
||||||
|
console.log('Test 404 response body:', response.body);
|
||||||
|
// We expect a 401 or 404, not 500
|
||||||
|
expect(response.status).not.toBe(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /api/prompts - Create Prompt', () => {
|
||||||
|
afterEach(async () => {
|
||||||
|
await Prompt.deleteMany({});
|
||||||
|
await PromptGroup.deleteMany({});
|
||||||
|
await AclEntry.deleteMany({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create a prompt and grant owner permissions', async () => {
|
||||||
|
const promptData = {
|
||||||
|
prompt: {
|
||||||
|
prompt: 'Test prompt content',
|
||||||
|
type: 'text',
|
||||||
|
},
|
||||||
|
group: {
|
||||||
|
name: 'Test Prompt Group',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await request(app).post('/api/prompts').send(promptData);
|
||||||
|
|
||||||
|
if (response.status !== 200) {
|
||||||
|
console.log('POST /api/prompts error status:', response.status);
|
||||||
|
console.log('POST /api/prompts error body:', response.body);
|
||||||
|
console.log('Console errors:', consoleErrorSpy.mock.calls);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('POST response:', response.body);
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.body.prompt).toBeDefined();
|
||||||
|
expect(response.body.prompt.prompt).toBe(promptData.prompt.prompt);
|
||||||
|
|
||||||
|
// Check ACL entry was created
|
||||||
|
const aclEntry = await AclEntry.findOne({
|
||||||
|
resourceType: 'promptGroup',
|
||||||
|
resourceId: response.body.prompt.groupId,
|
||||||
|
principalType: 'user',
|
||||||
|
principalId: testUsers.owner._id,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(aclEntry).toBeTruthy();
|
||||||
|
expect(aclEntry.roleId.toString()).toBe(testRoles.owner._id.toString());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create a prompt group with prompt and grant owner permissions', async () => {
|
||||||
|
const promptData = {
|
||||||
|
prompt: {
|
||||||
|
prompt: 'Group prompt content',
|
||||||
|
// Remove 'name' from prompt - it's not in the schema
|
||||||
|
},
|
||||||
|
group: {
|
||||||
|
name: 'Test Group',
|
||||||
|
category: 'testing',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await request(app).post('/api/prompts').send(promptData).expect(200);
|
||||||
|
|
||||||
|
expect(response.body.prompt).toBeDefined();
|
||||||
|
expect(response.body.group).toBeDefined();
|
||||||
|
expect(response.body.group.name).toBe(promptData.group.name);
|
||||||
|
|
||||||
|
// Check ACL entry was created for the promptGroup
|
||||||
|
const aclEntry = await AclEntry.findOne({
|
||||||
|
resourceType: 'promptGroup',
|
||||||
|
resourceId: response.body.group._id,
|
||||||
|
principalType: 'user',
|
||||||
|
principalId: testUsers.owner._id,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(aclEntry).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /api/prompts/:promptId - Get Prompt', () => {
|
||||||
|
let testPrompt;
|
||||||
|
let testGroup;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
// Create a prompt group first
|
||||||
|
testGroup = await PromptGroup.create({
|
||||||
|
name: 'Test Group',
|
||||||
|
category: 'testing',
|
||||||
|
author: testUsers.owner._id,
|
||||||
|
authorName: testUsers.owner.name,
|
||||||
|
productionId: new ObjectId(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create a prompt
|
||||||
|
testPrompt = await Prompt.create({
|
||||||
|
prompt: 'Test prompt for retrieval',
|
||||||
|
name: 'Get Test',
|
||||||
|
author: testUsers.owner._id,
|
||||||
|
type: 'text',
|
||||||
|
groupId: testGroup._id,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await Prompt.deleteMany({});
|
||||||
|
await PromptGroup.deleteMany({});
|
||||||
|
await AclEntry.deleteMany({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should retrieve prompt when user has view permissions', async () => {
|
||||||
|
// Grant view permissions on the promptGroup
|
||||||
|
await grantPermission({
|
||||||
|
principalType: 'user',
|
||||||
|
principalId: testUsers.owner._id,
|
||||||
|
resourceType: 'promptGroup',
|
||||||
|
resourceId: testGroup._id,
|
||||||
|
accessRoleId: 'promptGroup_viewer',
|
||||||
|
grantedBy: testUsers.owner._id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await request(app).get(`/api/prompts/${testPrompt._id}`).expect(200);
|
||||||
|
|
||||||
|
expect(response.body._id).toBe(testPrompt._id.toString());
|
||||||
|
expect(response.body.prompt).toBe(testPrompt.prompt);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should deny access when user has no permissions', async () => {
|
||||||
|
// Change the user to one without access
|
||||||
|
setTestUser(app, testUsers.noAccess);
|
||||||
|
|
||||||
|
const response = await request(app).get(`/api/prompts/${testPrompt._id}`).expect(403);
|
||||||
|
|
||||||
|
// Verify error response
|
||||||
|
expect(response.body.error).toBe('Forbidden');
|
||||||
|
expect(response.body.message).toBe('Insufficient permissions to access this promptGroup');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow admin access without explicit permissions', async () => {
|
||||||
|
// First, reset the app to remove previous middleware
|
||||||
|
app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
|
||||||
|
// Set admin user BEFORE adding routes
|
||||||
|
app.use((req, res, next) => {
|
||||||
|
req.user = {
|
||||||
|
...testUsers.admin.toObject(),
|
||||||
|
id: testUsers.admin._id.toString(),
|
||||||
|
_id: testUsers.admin._id,
|
||||||
|
name: testUsers.admin.name,
|
||||||
|
role: testUsers.admin.role,
|
||||||
|
};
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Now add the routes
|
||||||
|
const promptRoutes = require('./prompts');
|
||||||
|
app.use('/api/prompts', promptRoutes);
|
||||||
|
|
||||||
|
console.log('Admin user:', testUsers.admin);
|
||||||
|
console.log('Admin role:', testUsers.admin.role);
|
||||||
|
console.log('SystemRoles.ADMIN:', SystemRoles.ADMIN);
|
||||||
|
|
||||||
|
const response = await request(app).get(`/api/prompts/${testPrompt._id}`).expect(200);
|
||||||
|
|
||||||
|
expect(response.body._id).toBe(testPrompt._id.toString());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('DELETE /api/prompts/:promptId - Delete Prompt', () => {
|
||||||
|
let testPrompt;
|
||||||
|
let testGroup;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
// Create group with prompt
|
||||||
|
testGroup = await PromptGroup.create({
|
||||||
|
name: 'Delete Test Group',
|
||||||
|
category: 'testing',
|
||||||
|
author: testUsers.owner._id,
|
||||||
|
authorName: testUsers.owner.name,
|
||||||
|
productionId: new ObjectId(),
|
||||||
|
});
|
||||||
|
|
||||||
|
testPrompt = await Prompt.create({
|
||||||
|
prompt: 'Test prompt for deletion',
|
||||||
|
name: 'Delete Test',
|
||||||
|
author: testUsers.owner._id,
|
||||||
|
type: 'text',
|
||||||
|
groupId: testGroup._id,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add prompt to group
|
||||||
|
testGroup.productionId = testPrompt._id;
|
||||||
|
testGroup.promptIds = [testPrompt._id];
|
||||||
|
await testGroup.save();
|
||||||
|
|
||||||
|
// Grant owner permissions on the promptGroup
|
||||||
|
await grantPermission({
|
||||||
|
principalType: 'user',
|
||||||
|
principalId: testUsers.owner._id,
|
||||||
|
resourceType: 'promptGroup',
|
||||||
|
resourceId: testGroup._id,
|
||||||
|
accessRoleId: 'promptGroup_owner',
|
||||||
|
grantedBy: testUsers.owner._id,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await Prompt.deleteMany({});
|
||||||
|
await PromptGroup.deleteMany({});
|
||||||
|
await AclEntry.deleteMany({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should delete prompt when user has delete permissions', async () => {
|
||||||
|
const response = await request(app)
|
||||||
|
.delete(`/api/prompts/${testPrompt._id}`)
|
||||||
|
.query({ groupId: testGroup._id.toString() })
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(response.body.prompt).toBe('Prompt deleted successfully');
|
||||||
|
|
||||||
|
// Verify prompt was deleted
|
||||||
|
const deletedPrompt = await Prompt.findById(testPrompt._id);
|
||||||
|
expect(deletedPrompt).toBeNull();
|
||||||
|
|
||||||
|
// Verify ACL entries were removed
|
||||||
|
const aclEntries = await AclEntry.find({
|
||||||
|
resourceType: 'promptGroup',
|
||||||
|
resourceId: testGroup._id,
|
||||||
|
});
|
||||||
|
expect(aclEntries).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should deny deletion when user lacks delete permissions', async () => {
|
||||||
|
// Create a prompt as a different user (not the one trying to delete)
|
||||||
|
const authorPrompt = await Prompt.create({
|
||||||
|
prompt: 'Test prompt by another user',
|
||||||
|
name: 'Another User Prompt',
|
||||||
|
author: testUsers.editor._id, // Different author
|
||||||
|
type: 'text',
|
||||||
|
groupId: testGroup._id,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Grant only viewer permissions to viewer user on the promptGroup
|
||||||
|
await grantPermission({
|
||||||
|
principalType: 'user',
|
||||||
|
principalId: testUsers.viewer._id,
|
||||||
|
resourceType: 'promptGroup',
|
||||||
|
resourceId: testGroup._id,
|
||||||
|
accessRoleId: 'promptGroup_viewer',
|
||||||
|
grantedBy: testUsers.editor._id,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Recreate app with viewer user
|
||||||
|
app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
app.use((req, res, next) => {
|
||||||
|
req.user = {
|
||||||
|
...testUsers.viewer.toObject(),
|
||||||
|
id: testUsers.viewer._id.toString(),
|
||||||
|
_id: testUsers.viewer._id,
|
||||||
|
name: testUsers.viewer.name,
|
||||||
|
role: testUsers.viewer.role,
|
||||||
|
};
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
const promptRoutes = require('./prompts');
|
||||||
|
app.use('/api/prompts', promptRoutes);
|
||||||
|
|
||||||
|
await request(app)
|
||||||
|
.delete(`/api/prompts/${authorPrompt._id}`)
|
||||||
|
.query({ groupId: testGroup._id.toString() })
|
||||||
|
.expect(403);
|
||||||
|
|
||||||
|
// Verify prompt still exists
|
||||||
|
const prompt = await Prompt.findById(authorPrompt._id);
|
||||||
|
expect(prompt).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PATCH /api/prompts/:promptId/tags/production - Make Production', () => {
|
||||||
|
let testPrompt;
|
||||||
|
let testGroup;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
// Create group
|
||||||
|
testGroup = await PromptGroup.create({
|
||||||
|
name: 'Production Test Group',
|
||||||
|
category: 'testing',
|
||||||
|
author: testUsers.owner._id,
|
||||||
|
authorName: testUsers.owner.name,
|
||||||
|
productionId: new ObjectId(),
|
||||||
|
});
|
||||||
|
|
||||||
|
testPrompt = await Prompt.create({
|
||||||
|
prompt: 'Test prompt for production',
|
||||||
|
name: 'Production Test',
|
||||||
|
author: testUsers.owner._id,
|
||||||
|
type: 'text',
|
||||||
|
groupId: testGroup._id,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await Prompt.deleteMany({});
|
||||||
|
await PromptGroup.deleteMany({});
|
||||||
|
await AclEntry.deleteMany({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should make prompt production when user has edit permissions', async () => {
|
||||||
|
// Grant edit permissions on the promptGroup
|
||||||
|
await grantPermission({
|
||||||
|
principalType: 'user',
|
||||||
|
principalId: testUsers.owner._id,
|
||||||
|
resourceType: 'promptGroup',
|
||||||
|
resourceId: testGroup._id,
|
||||||
|
accessRoleId: 'promptGroup_editor',
|
||||||
|
grantedBy: testUsers.owner._id,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Recreate app to ensure fresh middleware
|
||||||
|
app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
app.use((req, res, next) => {
|
||||||
|
req.user = {
|
||||||
|
...testUsers.owner.toObject(),
|
||||||
|
id: testUsers.owner._id.toString(),
|
||||||
|
_id: testUsers.owner._id,
|
||||||
|
name: testUsers.owner.name,
|
||||||
|
role: testUsers.owner.role,
|
||||||
|
};
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
const promptRoutes = require('./prompts');
|
||||||
|
app.use('/api/prompts', promptRoutes);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.patch(`/api/prompts/${testPrompt._id}/tags/production`)
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(response.body.message).toBe('Prompt production made successfully');
|
||||||
|
|
||||||
|
// Verify the group was updated
|
||||||
|
const updatedGroup = await PromptGroup.findById(testGroup._id);
|
||||||
|
expect(updatedGroup.productionId.toString()).toBe(testPrompt._id.toString());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should deny making production when user lacks edit permissions', async () => {
|
||||||
|
// Grant only view permissions to viewer on the promptGroup
|
||||||
|
await grantPermission({
|
||||||
|
principalType: 'user',
|
||||||
|
principalId: testUsers.viewer._id,
|
||||||
|
resourceType: 'promptGroup',
|
||||||
|
resourceId: testGroup._id,
|
||||||
|
accessRoleId: 'promptGroup_viewer',
|
||||||
|
grantedBy: testUsers.owner._id,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Recreate app with viewer user
|
||||||
|
app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
app.use((req, res, next) => {
|
||||||
|
req.user = {
|
||||||
|
...testUsers.viewer.toObject(),
|
||||||
|
id: testUsers.viewer._id.toString(),
|
||||||
|
_id: testUsers.viewer._id,
|
||||||
|
name: testUsers.viewer.name,
|
||||||
|
role: testUsers.viewer.role,
|
||||||
|
};
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
const promptRoutes = require('./prompts');
|
||||||
|
app.use('/api/prompts', promptRoutes);
|
||||||
|
|
||||||
|
await request(app).patch(`/api/prompts/${testPrompt._id}/tags/production`).expect(403);
|
||||||
|
|
||||||
|
// Verify prompt hasn't changed
|
||||||
|
const unchangedGroup = await PromptGroup.findById(testGroup._id);
|
||||||
|
expect(unchangedGroup.productionId.toString()).not.toBe(testPrompt._id.toString());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Public Access', () => {
|
||||||
|
let publicPrompt;
|
||||||
|
let publicGroup;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
// Create a prompt group
|
||||||
|
publicGroup = await PromptGroup.create({
|
||||||
|
name: 'Public Test Group',
|
||||||
|
category: 'testing',
|
||||||
|
author: testUsers.owner._id,
|
||||||
|
authorName: testUsers.owner.name,
|
||||||
|
productionId: new ObjectId(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create a public prompt
|
||||||
|
publicPrompt = await Prompt.create({
|
||||||
|
prompt: 'Public prompt content',
|
||||||
|
name: 'Public Test',
|
||||||
|
author: testUsers.owner._id,
|
||||||
|
type: 'text',
|
||||||
|
groupId: publicGroup._id,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Grant public viewer access on the promptGroup
|
||||||
|
await grantPermission({
|
||||||
|
principalType: 'public',
|
||||||
|
principalId: null,
|
||||||
|
resourceType: 'promptGroup',
|
||||||
|
resourceId: publicGroup._id,
|
||||||
|
accessRoleId: 'promptGroup_viewer',
|
||||||
|
grantedBy: testUsers.owner._id,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await Prompt.deleteMany({});
|
||||||
|
await PromptGroup.deleteMany({});
|
||||||
|
await AclEntry.deleteMany({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow any user to view public prompts', async () => {
|
||||||
|
// Change user to someone without explicit permissions
|
||||||
|
setTestUser(app, testUsers.noAccess);
|
||||||
|
|
||||||
|
const response = await request(app).get(`/api/prompts/${publicPrompt._id}`).expect(200);
|
||||||
|
|
||||||
|
expect(response.body._id).toBe(publicPrompt._id.toString());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -706,6 +706,31 @@ const bulkUpdateResourcePermissions = async ({
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove all permissions for a specific resource
|
||||||
|
* @param {Object} params - Parameters for removing permissions
|
||||||
|
* @param {string} params.resourceType - Type of resource (e.g., 'agent', 'prompt')
|
||||||
|
* @param {string|mongoose.Types.ObjectId} params.resourceId - The ID of the resource
|
||||||
|
* @returns {Promise<Object>} Delete result
|
||||||
|
*/
|
||||||
|
const removeAllPermissions = async ({ resourceType, resourceId }) => {
|
||||||
|
try {
|
||||||
|
if (!resourceId || !mongoose.Types.ObjectId.isValid(resourceId)) {
|
||||||
|
throw new Error(`Invalid resource ID: ${resourceId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await AclEntry.deleteMany({
|
||||||
|
resourceType,
|
||||||
|
resourceId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`[PermissionService.removeAllPermissions] Error: ${error.message}`);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
grantPermission,
|
grantPermission,
|
||||||
checkPermission,
|
checkPermission,
|
||||||
|
@ -718,4 +743,5 @@ module.exports = {
|
||||||
ensurePrincipalExists,
|
ensurePrincipalExists,
|
||||||
ensureGroupPrincipalExists,
|
ensureGroupPrincipalExists,
|
||||||
syncUserEntraGroupMemberships,
|
syncUserEntraGroupMemberships,
|
||||||
|
removeAllPermissions,
|
||||||
};
|
};
|
||||||
|
|
|
@ -7,8 +7,9 @@ import {
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@librechat/client';
|
} from '@librechat/client';
|
||||||
|
import { PERMISSION_BITS } from 'librechat-data-provider';
|
||||||
import type { TPromptGroup } from 'librechat-data-provider';
|
import type { TPromptGroup } from 'librechat-data-provider';
|
||||||
import { useLocalize, useSubmitMessage, useCustomLink, useAuthContext } from '~/hooks';
|
import { useLocalize, useSubmitMessage, useCustomLink, useResourcePermissions } from '~/hooks';
|
||||||
import VariableDialog from '~/components/Prompts/Groups/VariableDialog';
|
import VariableDialog from '~/components/Prompts/Groups/VariableDialog';
|
||||||
import PreviewPrompt from '~/components/Prompts/PreviewPrompt';
|
import PreviewPrompt from '~/components/Prompts/PreviewPrompt';
|
||||||
import ListCard from '~/components/Prompts/Groups/ListCard';
|
import ListCard from '~/components/Prompts/Groups/ListCard';
|
||||||
|
@ -22,7 +23,6 @@ function ChatGroupItem({
|
||||||
instanceProjectId?: string;
|
instanceProjectId?: string;
|
||||||
}) {
|
}) {
|
||||||
const localize = useLocalize();
|
const localize = useLocalize();
|
||||||
const { user } = useAuthContext();
|
|
||||||
const { submitPrompt } = useSubmitMessage();
|
const { submitPrompt } = useSubmitMessage();
|
||||||
const [isPreviewDialogOpen, setPreviewDialogOpen] = useState(false);
|
const [isPreviewDialogOpen, setPreviewDialogOpen] = useState(false);
|
||||||
const [isVariableDialogOpen, setVariableDialogOpen] = useState(false);
|
const [isVariableDialogOpen, setVariableDialogOpen] = useState(false);
|
||||||
|
@ -32,7 +32,10 @@ function ChatGroupItem({
|
||||||
() => instanceProjectId != null && group.projectIds?.includes(instanceProjectId),
|
() => instanceProjectId != null && group.projectIds?.includes(instanceProjectId),
|
||||||
[group, instanceProjectId],
|
[group, instanceProjectId],
|
||||||
);
|
);
|
||||||
const isOwner = useMemo(() => user?.id === group.author, [user, group]);
|
|
||||||
|
// Check permissions for the promptGroup
|
||||||
|
const { hasPermission } = useResourcePermissions('promptGroup', group._id || '');
|
||||||
|
const canEdit = hasPermission(PERMISSION_BITS.EDIT);
|
||||||
|
|
||||||
const onCardClick: React.MouseEventHandler<HTMLButtonElement> = () => {
|
const onCardClick: React.MouseEventHandler<HTMLButtonElement> = () => {
|
||||||
const text = group.productionPrompt?.prompt;
|
const text = group.productionPrompt?.prompt;
|
||||||
|
@ -108,10 +111,10 @@ function ChatGroupItem({
|
||||||
<TextSearch className="mr-2 h-4 w-4" aria-hidden="true" />
|
<TextSearch className="mr-2 h-4 w-4" aria-hidden="true" />
|
||||||
<span>{localize('com_ui_preview')}</span>
|
<span>{localize('com_ui_preview')}</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
{isOwner && (
|
{canEdit && (
|
||||||
<DropdownMenuGroup>
|
<DropdownMenuGroup>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
disabled={!isOwner}
|
disabled={!canEdit}
|
||||||
className="cursor-pointer rounded-lg text-text-secondary hover:bg-surface-hover focus:bg-surface-hover disabled:cursor-not-allowed"
|
className="cursor-pointer rounded-lg text-text-secondary hover:bg-surface-hover focus:bg-surface-hover disabled:cursor-not-allowed"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import { memo, useState, useRef, useMemo, useCallback, KeyboardEvent } from 'react';
|
import { memo, useState, useRef, useMemo, useCallback, KeyboardEvent } from 'react';
|
||||||
import { EarthIcon, Pen } from 'lucide-react';
|
import { EarthIcon, Pen } from 'lucide-react';
|
||||||
import { useNavigate, useParams } from 'react-router-dom';
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
import { SystemRoles, type TPromptGroup } from 'librechat-data-provider';
|
import { PERMISSION_BITS, type TPromptGroup } from 'librechat-data-provider';
|
||||||
import {
|
import {
|
||||||
Input,
|
Input,
|
||||||
Label,
|
Label,
|
||||||
|
@ -13,7 +13,7 @@ import {
|
||||||
} from '@librechat/client';
|
} from '@librechat/client';
|
||||||
import { useDeletePromptGroup, useUpdatePromptGroup } from '~/data-provider';
|
import { useDeletePromptGroup, useUpdatePromptGroup } from '~/data-provider';
|
||||||
import CategoryIcon from '~/components/Prompts/Groups/CategoryIcon';
|
import CategoryIcon from '~/components/Prompts/Groups/CategoryIcon';
|
||||||
import { useLocalize, useAuthContext } from '~/hooks';
|
import { useLocalize, useResourcePermissions } from '~/hooks';
|
||||||
import { cn } from '~/utils';
|
import { cn } from '~/utils';
|
||||||
|
|
||||||
interface DashGroupItemProps {
|
interface DashGroupItemProps {
|
||||||
|
@ -25,12 +25,14 @@ function DashGroupItemComponent({ group, instanceProjectId }: DashGroupItemProps
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const localize = useLocalize();
|
const localize = useLocalize();
|
||||||
const { user } = useAuthContext();
|
|
||||||
|
|
||||||
const blurTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
const blurTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
const [nameInputValue, setNameInputValue] = useState(group.name);
|
const [nameInputValue, setNameInputValue] = useState(group.name);
|
||||||
|
|
||||||
const isOwner = useMemo(() => user?.id === group.author, [user?.id, group.author]);
|
const { hasPermission } = useResourcePermissions('promptGroup', group._id || '');
|
||||||
|
const canEdit = hasPermission(PERMISSION_BITS.EDIT);
|
||||||
|
const canDelete = hasPermission(PERMISSION_BITS.DELETE);
|
||||||
|
|
||||||
const isGlobalGroup = useMemo(
|
const isGlobalGroup = useMemo(
|
||||||
() => instanceProjectId && group.projectIds?.includes(instanceProjectId),
|
() => instanceProjectId && group.projectIds?.includes(instanceProjectId),
|
||||||
[group.projectIds, instanceProjectId],
|
[group.projectIds, instanceProjectId],
|
||||||
|
@ -105,78 +107,78 @@ function DashGroupItemComponent({ group, instanceProjectId }: DashGroupItemProps
|
||||||
aria-label={localize('com_ui_global_group')}
|
aria-label={localize('com_ui_global_group')}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{(isOwner || user?.role === SystemRoles.ADMIN) && (
|
{canEdit && (
|
||||||
<>
|
<OGDialog>
|
||||||
<OGDialog>
|
<OGDialogTrigger asChild>
|
||||||
<OGDialogTrigger asChild>
|
<Button
|
||||||
<Button
|
variant="ghost"
|
||||||
variant="ghost"
|
onClick={(e) => e.stopPropagation()}
|
||||||
onClick={(e) => e.stopPropagation()}
|
className="h-8 w-8 p-0 hover:bg-surface-hover"
|
||||||
className="h-8 w-8 p-0 hover:bg-surface-hover"
|
aria-label={localize('com_ui_rename_prompt') + ' ' + group.name}
|
||||||
aria-label={localize('com_ui_rename_prompt') + ' ' + group.name}
|
>
|
||||||
>
|
<Pen className="icon-sm text-text-primary" aria-hidden="true" />
|
||||||
<Pen className="icon-sm text-text-primary" aria-hidden="true" />
|
</Button>
|
||||||
</Button>
|
</OGDialogTrigger>
|
||||||
</OGDialogTrigger>
|
<OGDialogTemplate
|
||||||
<OGDialogTemplate
|
showCloseButton={false}
|
||||||
showCloseButton={false}
|
title={localize('com_ui_rename_prompt')}
|
||||||
title={localize('com_ui_rename_prompt')}
|
className="w-11/12 max-w-lg"
|
||||||
className="w-11/12 max-w-lg"
|
main={
|
||||||
main={
|
<div className="flex w-full flex-col items-center gap-2">
|
||||||
<div className="flex w-full flex-col items-center gap-2">
|
<div className="grid w-full items-center gap-2">
|
||||||
<div className="grid w-full items-center gap-2">
|
<Input
|
||||||
<Input
|
value={nameInputValue}
|
||||||
value={nameInputValue}
|
onChange={(e) => setNameInputValue(e.target.value)}
|
||||||
onChange={(e) => setNameInputValue(e.target.value)}
|
className="w-full"
|
||||||
className="w-full"
|
aria-label={localize('com_ui_rename_prompt') + ' ' + group.name}
|
||||||
aria-label={localize('com_ui_rename_prompt') + ' ' + group.name}
|
/>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
}
|
</div>
|
||||||
selection={{
|
}
|
||||||
selectHandler: handleSaveRename,
|
selection={{
|
||||||
selectClasses:
|
selectHandler: handleSaveRename,
|
||||||
'bg-surface-submit hover:bg-surface-submit-hover text-white disabled:hover:bg-surface-submit',
|
selectClasses:
|
||||||
selectText: localize('com_ui_save'),
|
'bg-surface-submit hover:bg-surface-submit-hover text-white disabled:hover:bg-surface-submit',
|
||||||
isLoading,
|
selectText: localize('com_ui_save'),
|
||||||
}}
|
isLoading,
|
||||||
/>
|
}}
|
||||||
</OGDialog>
|
/>
|
||||||
|
</OGDialog>
|
||||||
|
)}
|
||||||
|
|
||||||
<OGDialog>
|
{canDelete && (
|
||||||
<OGDialogTrigger asChild>
|
<OGDialog>
|
||||||
<Button
|
<OGDialogTrigger asChild>
|
||||||
variant="ghost"
|
<Button
|
||||||
className="h-8 w-8 p-0 hover:bg-surface-hover"
|
variant="ghost"
|
||||||
onClick={(e) => e.stopPropagation()}
|
className="h-8 w-8 p-0 hover:bg-surface-hover"
|
||||||
aria-label={localize('com_ui_delete_prompt') + ' ' + group.name}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
aria-label={localize('com_ui_delete_prompt') + ' ' + group.name}
|
||||||
<TrashIcon className="icon-sm text-text-primary" aria-hidden="true" />
|
>
|
||||||
</Button>
|
<TrashIcon className="icon-sm text-text-primary" aria-hidden="true" />
|
||||||
</OGDialogTrigger>
|
</Button>
|
||||||
<OGDialogTemplate
|
</OGDialogTrigger>
|
||||||
showCloseButton={false}
|
<OGDialogTemplate
|
||||||
title={localize('com_ui_delete_prompt')}
|
showCloseButton={false}
|
||||||
className="w-11/12 max-w-lg"
|
title={localize('com_ui_delete_prompt')}
|
||||||
main={
|
className="w-11/12 max-w-lg"
|
||||||
<div className="flex w-full flex-col items-center gap-2">
|
main={
|
||||||
<div className="grid w-full items-center gap-2">
|
<div className="flex w-full flex-col items-center gap-2">
|
||||||
<Label htmlFor="confirm-delete" className="text-left text-sm font-medium">
|
<div className="grid w-full items-center gap-2">
|
||||||
{localize('com_ui_delete_confirm')} <strong>{group.name}</strong>
|
<Label htmlFor="confirm-delete" className="text-left text-sm font-medium">
|
||||||
</Label>
|
{localize('com_ui_delete_confirm')} <strong>{group.name}</strong>
|
||||||
</div>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
}
|
</div>
|
||||||
selection={{
|
}
|
||||||
selectHandler: triggerDelete,
|
selection={{
|
||||||
selectClasses:
|
selectHandler: triggerDelete,
|
||||||
'bg-red-600 dark:bg-red-600 hover:bg-red-700 dark:hover:bg-red-800 text-white',
|
selectClasses:
|
||||||
selectText: localize('com_ui_delete'),
|
'bg-red-600 dark:bg-red-600 hover:bg-red-700 dark:hover:bg-red-800 text-white',
|
||||||
}}
|
selectText: localize('com_ui_delete'),
|
||||||
/>
|
}}
|
||||||
</OGDialog>
|
/>
|
||||||
</>
|
</OGDialog>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -6,16 +6,16 @@ import { Menu, Rocket } from 'lucide-react';
|
||||||
import { useForm, FormProvider } from 'react-hook-form';
|
import { useForm, FormProvider } from 'react-hook-form';
|
||||||
import { useParams, useOutletContext } from 'react-router-dom';
|
import { useParams, useOutletContext } from 'react-router-dom';
|
||||||
import { Button, Skeleton, useToastContext } from '@librechat/client';
|
import { Button, Skeleton, useToastContext } from '@librechat/client';
|
||||||
import { SystemRoles, PermissionTypes, Permissions } from 'librechat-data-provider';
|
import { Permissions, PermissionTypes, PERMISSION_BITS } from 'librechat-data-provider';
|
||||||
import type { TCreatePrompt, TPrompt, TPromptGroup } from 'librechat-data-provider';
|
import type { TCreatePrompt, TPrompt, TPromptGroup } from 'librechat-data-provider';
|
||||||
import {
|
import {
|
||||||
useGetPrompts,
|
useGetPrompts,
|
||||||
useCreatePrompt,
|
|
||||||
useGetPromptGroup,
|
useGetPromptGroup,
|
||||||
|
useAddPromptToGroup,
|
||||||
useUpdatePromptGroup,
|
useUpdatePromptGroup,
|
||||||
useMakePromptProduction,
|
useMakePromptProduction,
|
||||||
} from '~/data-provider';
|
} from '~/data-provider';
|
||||||
import { useAuthContext, usePromptGroupsNav, useHasAccess, useLocalize } from '~/hooks';
|
import { useResourcePermissions, usePromptGroupsNav, useHasAccess, useLocalize } from '~/hooks';
|
||||||
import CategorySelector from './Groups/CategorySelector';
|
import CategorySelector from './Groups/CategorySelector';
|
||||||
import NoPromptGroup from './Groups/NoPromptGroup';
|
import NoPromptGroup from './Groups/NoPromptGroup';
|
||||||
import PromptVariables from './PromptVariables';
|
import PromptVariables from './PromptVariables';
|
||||||
|
@ -39,6 +39,7 @@ interface RightPanelProps {
|
||||||
selectionIndex: number;
|
selectionIndex: number;
|
||||||
selectedPromptId?: string;
|
selectedPromptId?: string;
|
||||||
isLoadingPrompts: boolean;
|
isLoadingPrompts: boolean;
|
||||||
|
canEdit: boolean;
|
||||||
setSelectionIndex: React.Dispatch<React.SetStateAction<number>>;
|
setSelectionIndex: React.Dispatch<React.SetStateAction<number>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -49,6 +50,7 @@ const RightPanel = React.memo(
|
||||||
selectedPrompt,
|
selectedPrompt,
|
||||||
selectedPromptId,
|
selectedPromptId,
|
||||||
isLoadingPrompts,
|
isLoadingPrompts,
|
||||||
|
canEdit,
|
||||||
selectionIndex,
|
selectionIndex,
|
||||||
setSelectionIndex,
|
setSelectionIndex,
|
||||||
}: RightPanelProps) => {
|
}: RightPanelProps) => {
|
||||||
|
@ -84,16 +86,19 @@ const RightPanel = React.memo(
|
||||||
<div className="mb-2 flex flex-col lg:flex-row lg:items-center lg:justify-center lg:gap-x-2 xl:flex-row xl:space-y-0">
|
<div className="mb-2 flex flex-col lg:flex-row lg:items-center lg:justify-center lg:gap-x-2 xl:flex-row xl:space-y-0">
|
||||||
<CategorySelector
|
<CategorySelector
|
||||||
currentCategory={groupCategory}
|
currentCategory={groupCategory}
|
||||||
onValueChange={(value) =>
|
onValueChange={
|
||||||
updateGroupMutation.mutate({
|
canEdit
|
||||||
id: groupId,
|
? (value) =>
|
||||||
payload: { name: groupName, category: value },
|
updateGroupMutation.mutate({
|
||||||
})
|
id: groupId,
|
||||||
|
payload: { name: groupName, category: value },
|
||||||
|
})
|
||||||
|
: undefined
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<div className="mt-2 flex flex-row items-center justify-center gap-x-2 lg:mt-0">
|
<div className="mt-2 flex flex-row items-center justify-center gap-x-2 lg:mt-0">
|
||||||
{hasShareAccess && <SharePrompt group={group} disabled={isLoadingGroup} />}
|
{hasShareAccess && <SharePrompt group={group} disabled={isLoadingGroup} />}
|
||||||
{editorMode === PromptsEditorMode.ADVANCED && (
|
{editorMode === PromptsEditorMode.ADVANCED && canEdit && (
|
||||||
<Button
|
<Button
|
||||||
variant="submit"
|
variant="submit"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
@ -115,7 +120,8 @@ const RightPanel = React.memo(
|
||||||
isLoadingGroup ||
|
isLoadingGroup ||
|
||||||
!selectedPrompt ||
|
!selectedPrompt ||
|
||||||
selectedPrompt._id === group?.productionId ||
|
selectedPrompt._id === group?.productionId ||
|
||||||
makeProductionMutation.isLoading
|
makeProductionMutation.isLoading ||
|
||||||
|
!canEdit
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Rocket className="size-5 cursor-pointer text-white" />
|
<Rocket className="size-5 cursor-pointer text-white" />
|
||||||
|
@ -154,7 +160,6 @@ RightPanel.displayName = 'RightPanel';
|
||||||
const PromptForm = () => {
|
const PromptForm = () => {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const localize = useLocalize();
|
const localize = useLocalize();
|
||||||
const { user } = useAuthContext();
|
|
||||||
const { showToast } = useToastContext();
|
const { showToast } = useToastContext();
|
||||||
const alwaysMakeProd = useRecoilValue(store.alwaysMakeProd);
|
const alwaysMakeProd = useRecoilValue(store.alwaysMakeProd);
|
||||||
const promptId = params.promptId || '';
|
const promptId = params.promptId || '';
|
||||||
|
@ -175,7 +180,14 @@ const PromptForm = () => {
|
||||||
{ enabled: !!promptId },
|
{ enabled: !!promptId },
|
||||||
);
|
);
|
||||||
|
|
||||||
const isOwner = useMemo(() => (user && group ? user.id === group.author : false), [user, group]);
|
// Check permissions for the promptGroup
|
||||||
|
const { hasPermission, isLoading: permissionsLoading } = useResourcePermissions(
|
||||||
|
'promptGroup',
|
||||||
|
group?._id || '',
|
||||||
|
);
|
||||||
|
|
||||||
|
const canEdit = hasPermission(PERMISSION_BITS.EDIT);
|
||||||
|
const canView = hasPermission(PERMISSION_BITS.VIEW);
|
||||||
|
|
||||||
const methods = useForm({
|
const methods = useForm({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
|
@ -206,13 +218,12 @@ const PromptForm = () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
const makeProductionMutation = useMakePromptProduction();
|
const makeProductionMutation = useMakePromptProduction();
|
||||||
|
const addPromptToGroupMutation = useAddPromptToGroup({
|
||||||
const createPromptMutation = useCreatePrompt({
|
|
||||||
onMutate: (variables) => {
|
onMutate: (variables) => {
|
||||||
reset(
|
reset(
|
||||||
{
|
{
|
||||||
prompt: variables.prompt.prompt,
|
prompt: variables.prompt.prompt,
|
||||||
category: variables.group ? variables.group.category : '',
|
category: group?.category || '',
|
||||||
},
|
},
|
||||||
{ keepDirtyValues: true },
|
{ keepDirtyValues: true },
|
||||||
);
|
);
|
||||||
|
@ -228,14 +239,17 @@ const PromptForm = () => {
|
||||||
|
|
||||||
reset({
|
reset({
|
||||||
prompt: data.prompt.prompt,
|
prompt: data.prompt.prompt,
|
||||||
promptName: data.group ? data.group.name : '',
|
promptName: group?.name || '',
|
||||||
category: data.group ? data.group.category : '',
|
category: group?.category || '',
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const onSave = useCallback(
|
const onSave = useCallback(
|
||||||
(value: string) => {
|
(value: string) => {
|
||||||
|
if (!canEdit) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!value) {
|
if (!value) {
|
||||||
// TODO: show toast, cannot be empty.
|
// TODO: show toast, cannot be empty.
|
||||||
return;
|
return;
|
||||||
|
@ -243,10 +257,17 @@ const PromptForm = () => {
|
||||||
if (!selectedPrompt) {
|
if (!selectedPrompt) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const groupId = selectedPrompt.groupId || group?._id;
|
||||||
|
if (!groupId) {
|
||||||
|
console.error('No groupId available');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const tempPrompt: TCreatePrompt = {
|
const tempPrompt: TCreatePrompt = {
|
||||||
prompt: {
|
prompt: {
|
||||||
type: selectedPrompt.type ?? 'text',
|
type: selectedPrompt.type ?? 'text',
|
||||||
groupId: selectedPrompt.groupId ?? '',
|
groupId: groupId,
|
||||||
prompt: value,
|
prompt: value,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
@ -255,9 +276,10 @@ const PromptForm = () => {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
createPromptMutation.mutate(tempPrompt);
|
// We're adding to an existing group, so use the addPromptToGroup mutation
|
||||||
|
addPromptToGroupMutation.mutate({ ...tempPrompt, groupId });
|
||||||
},
|
},
|
||||||
[selectedPrompt, createPromptMutation],
|
[selectedPrompt, group, addPromptToGroupMutation, canEdit],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleLoadingComplete = useCallback(() => {
|
const handleLoadingComplete = useCallback(() => {
|
||||||
|
@ -268,11 +290,11 @@ const PromptForm = () => {
|
||||||
}, [isLoadingGroup, isLoadingPrompts]);
|
}, [isLoadingGroup, isLoadingPrompts]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (prevIsEditingRef.current && !isEditing) {
|
if (prevIsEditingRef.current && !isEditing && canEdit) {
|
||||||
handleSubmit((data) => onSave(data.prompt))();
|
handleSubmit((data) => onSave(data.prompt))();
|
||||||
}
|
}
|
||||||
prevIsEditingRef.current = isEditing;
|
prevIsEditingRef.current = isEditing;
|
||||||
}, [isEditing, onSave, handleSubmit]);
|
}, [isEditing, onSave, handleSubmit, canEdit]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
handleLoadingComplete();
|
handleLoadingComplete();
|
||||||
|
@ -334,16 +356,19 @@ const PromptForm = () => {
|
||||||
return <SkeletonForm />;
|
return <SkeletonForm />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isOwner && groupsQuery.data && user?.role !== SystemRoles.ADMIN) {
|
// Show read-only view if user doesn't have edit permission
|
||||||
|
if (!canEdit && !permissionsLoading && groupsQuery.data) {
|
||||||
const fetchedPrompt = findPromptGroup(
|
const fetchedPrompt = findPromptGroup(
|
||||||
groupsQuery.data,
|
groupsQuery.data,
|
||||||
(group) => group._id === params.promptId,
|
(group) => group._id === params.promptId,
|
||||||
);
|
);
|
||||||
if (!fetchedPrompt) {
|
if (!fetchedPrompt && !canView) {
|
||||||
return <NoPromptGroup />;
|
return <NoPromptGroup />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <PromptDetails group={fetchedPrompt} />;
|
if (fetchedPrompt || group) {
|
||||||
|
return <PromptDetails group={fetchedPrompt || group} />;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!group || group._id == null) {
|
if (!group || group._id == null) {
|
||||||
|
@ -373,10 +398,13 @@ const PromptForm = () => {
|
||||||
<PromptName
|
<PromptName
|
||||||
name={groupName}
|
name={groupName}
|
||||||
onSave={(value) => {
|
onSave={(value) => {
|
||||||
if (!group._id) {
|
if (!canEdit || !group._id) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
updateGroupMutation.mutate({ id: group._id, payload: { name: value } });
|
updateGroupMutation.mutate({
|
||||||
|
id: group._id,
|
||||||
|
payload: { name: value },
|
||||||
|
});
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
|
@ -398,6 +426,7 @@ const PromptForm = () => {
|
||||||
selectionIndex={selectionIndex}
|
selectionIndex={selectionIndex}
|
||||||
selectedPromptId={selectedPromptId}
|
selectedPromptId={selectedPromptId}
|
||||||
isLoadingPrompts={isLoadingPrompts}
|
isLoadingPrompts={isLoadingPrompts}
|
||||||
|
canEdit={canEdit}
|
||||||
setSelectionIndex={setSelectionIndex}
|
setSelectionIndex={setSelectionIndex}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
@ -409,15 +438,21 @@ const PromptForm = () => {
|
||||||
<Skeleton className="h-96" aria-live="polite" />
|
<Skeleton className="h-96" aria-live="polite" />
|
||||||
) : (
|
) : (
|
||||||
<div className="mb-2 flex h-full flex-col gap-4">
|
<div className="mb-2 flex h-full flex-col gap-4">
|
||||||
<PromptEditor name="prompt" isEditing={isEditing} setIsEditing={setIsEditing} />
|
<PromptEditor
|
||||||
|
name="prompt"
|
||||||
|
isEditing={isEditing}
|
||||||
|
setIsEditing={(value) => canEdit && setIsEditing(value)}
|
||||||
|
/>
|
||||||
<PromptVariables promptText={promptText} />
|
<PromptVariables promptText={promptText} />
|
||||||
<Description
|
<Description
|
||||||
initialValue={group.oneliner ?? ''}
|
initialValue={group.oneliner ?? ''}
|
||||||
onValueChange={handleUpdateOneliner}
|
onValueChange={canEdit ? handleUpdateOneliner : undefined}
|
||||||
|
disabled={!canEdit}
|
||||||
/>
|
/>
|
||||||
<Command
|
<Command
|
||||||
initialValue={group.command ?? ''}
|
initialValue={group.command ?? ''}
|
||||||
onValueChange={handleUpdateCommand}
|
onValueChange={canEdit ? handleUpdateCommand : undefined}
|
||||||
|
disabled={!canEdit}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
@ -432,6 +467,7 @@ const PromptForm = () => {
|
||||||
selectedPrompt={selectedPrompt}
|
selectedPrompt={selectedPrompt}
|
||||||
selectedPromptId={selectedPromptId}
|
selectedPromptId={selectedPromptId}
|
||||||
isLoadingPrompts={isLoadingPrompts}
|
isLoadingPrompts={isLoadingPrompts}
|
||||||
|
canEdit={canEdit}
|
||||||
setSelectionIndex={setSelectionIndex}
|
setSelectionIndex={setSelectionIndex}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
@ -471,6 +507,7 @@ const PromptForm = () => {
|
||||||
selectedPrompt={selectedPrompt}
|
selectedPrompt={selectedPrompt}
|
||||||
selectedPromptId={selectedPromptId}
|
selectedPromptId={selectedPromptId}
|
||||||
isLoadingPrompts={isLoadingPrompts}
|
isLoadingPrompts={isLoadingPrompts}
|
||||||
|
canEdit={canEdit}
|
||||||
setSelectionIndex={setSelectionIndex}
|
setSelectionIndex={setSelectionIndex}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,90 +1,57 @@
|
||||||
import React, { useEffect, useMemo } from 'react';
|
import React from 'react';
|
||||||
import { Share2Icon } from 'lucide-react';
|
import { Share2Icon } from 'lucide-react';
|
||||||
import { useForm, Controller } from 'react-hook-form';
|
|
||||||
import { Permissions } from 'librechat-data-provider';
|
|
||||||
import {
|
import {
|
||||||
Button,
|
SystemRoles,
|
||||||
Switch,
|
Permissions,
|
||||||
OGDialog,
|
PermissionTypes,
|
||||||
OGDialogTitle,
|
PERMISSION_BITS,
|
||||||
OGDialogClose,
|
|
||||||
OGDialogContent,
|
|
||||||
OGDialogTrigger,
|
|
||||||
useToastContext,
|
|
||||||
} from '@librechat/client';
|
|
||||||
import type {
|
|
||||||
TPromptGroup,
|
|
||||||
TStartupConfig,
|
|
||||||
TUpdatePromptGroupPayload,
|
|
||||||
} from 'librechat-data-provider';
|
} from 'librechat-data-provider';
|
||||||
import { useUpdatePromptGroup, useGetStartupConfig } from '~/data-provider';
|
import { Button } from '@librechat/client';
|
||||||
import { useLocalize } from '~/hooks';
|
import type { TPromptGroup } from 'librechat-data-provider';
|
||||||
|
import { useAuthContext, useHasAccess, useResourcePermissions } from '~/hooks';
|
||||||
|
import { GenericGrantAccessDialog } from '~/components/Sharing';
|
||||||
|
|
||||||
type FormValues = {
|
const SharePrompt = React.memo(
|
||||||
[Permissions.SHARED_GLOBAL]: boolean;
|
({ group, disabled }: { group?: TPromptGroup; disabled: boolean }) => {
|
||||||
};
|
const { user } = useAuthContext();
|
||||||
|
|
||||||
const SharePrompt = ({ group, disabled }: { group?: TPromptGroup; disabled: boolean }) => {
|
// Check if user has permission to share prompts globally
|
||||||
const localize = useLocalize();
|
const hasAccessToSharePrompts = useHasAccess({
|
||||||
const { showToast } = useToastContext();
|
permissionType: PermissionTypes.PROMPTS,
|
||||||
const updateGroup = useUpdatePromptGroup();
|
permission: Permissions.SHARED_GLOBAL,
|
||||||
const { data: startupConfig = {} as TStartupConfig, isFetching } = useGetStartupConfig();
|
|
||||||
const { instanceProjectId } = startupConfig;
|
|
||||||
const groupIsGlobal = useMemo(
|
|
||||||
() => ((group?.projectIds ?? []) as string[]).includes(instanceProjectId as string),
|
|
||||||
[group, instanceProjectId],
|
|
||||||
);
|
|
||||||
|
|
||||||
const {
|
|
||||||
control,
|
|
||||||
setValue,
|
|
||||||
handleSubmit,
|
|
||||||
formState: { isSubmitting },
|
|
||||||
} = useForm<FormValues>({
|
|
||||||
mode: 'onChange',
|
|
||||||
defaultValues: {
|
|
||||||
[Permissions.SHARED_GLOBAL]: groupIsGlobal,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setValue(Permissions.SHARED_GLOBAL, groupIsGlobal);
|
|
||||||
}, [groupIsGlobal, setValue]);
|
|
||||||
|
|
||||||
if (group == null || !instanceProjectId) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const onSubmit = (data: FormValues) => {
|
|
||||||
const groupId = group._id ?? '';
|
|
||||||
if (groupId === '' || !instanceProjectId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data[Permissions.SHARED_GLOBAL] === true && groupIsGlobal) {
|
|
||||||
showToast({
|
|
||||||
message: localize('com_ui_prompt_already_shared_to_all'),
|
|
||||||
status: 'info',
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload = {} as TUpdatePromptGroupPayload;
|
|
||||||
if (data[Permissions.SHARED_GLOBAL] === true) {
|
|
||||||
payload.projectIds = [startupConfig.instanceProjectId];
|
|
||||||
} else {
|
|
||||||
payload.removeProjectIds = [startupConfig.instanceProjectId];
|
|
||||||
}
|
|
||||||
|
|
||||||
updateGroup.mutate({
|
|
||||||
id: groupId,
|
|
||||||
payload,
|
|
||||||
});
|
});
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
// Check user's permissions on this specific promptGroup
|
||||||
<OGDialog>
|
// The query will be disabled if groupId is empty
|
||||||
<OGDialogTrigger asChild>
|
const groupId = group?._id || '';
|
||||||
|
const { hasPermission, isLoading: permissionsLoading } = useResourcePermissions(
|
||||||
|
'promptGroup',
|
||||||
|
groupId,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Early return if no group
|
||||||
|
if (!group || !groupId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const canShareThisPrompt = hasPermission(PERMISSION_BITS.SHARE);
|
||||||
|
|
||||||
|
const shouldShowShareButton =
|
||||||
|
(group.author === user?.id || user?.role === SystemRoles.ADMIN || canShareThisPrompt) &&
|
||||||
|
hasAccessToSharePrompts &&
|
||||||
|
!permissionsLoading;
|
||||||
|
|
||||||
|
if (!shouldShowShareButton) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<GenericGrantAccessDialog
|
||||||
|
resourceDbId={groupId}
|
||||||
|
resourceName={group.name}
|
||||||
|
resourceType="promptGroup"
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
<Button
|
<Button
|
||||||
variant="default"
|
variant="default"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
@ -94,50 +61,11 @@ const SharePrompt = ({ group, disabled }: { group?: TPromptGroup; disabled: bool
|
||||||
>
|
>
|
||||||
<Share2Icon className="size-5 cursor-pointer text-white" />
|
<Share2Icon className="size-5 cursor-pointer text-white" />
|
||||||
</Button>
|
</Button>
|
||||||
</OGDialogTrigger>
|
</GenericGrantAccessDialog>
|
||||||
<OGDialogContent className="w-11/12 max-w-lg" role="dialog" aria-labelledby="dialog-title">
|
);
|
||||||
<OGDialogTitle id="dialog-title" className="truncate pr-2" title={group.name}>
|
},
|
||||||
{localize('com_ui_share_var', { 0: `"${group.name}"` })}
|
);
|
||||||
</OGDialogTitle>
|
|
||||||
<form className="p-2" onSubmit={handleSubmit(onSubmit)} aria-describedby="form-description">
|
SharePrompt.displayName = 'SharePrompt';
|
||||||
<div id="form-description" className="sr-only">
|
|
||||||
{localize('com_ui_share_form_description')}
|
|
||||||
</div>
|
|
||||||
<div className="mb-4 flex items-center justify-between gap-2 py-4">
|
|
||||||
<div className="flex items-center" id="share-to-all-users">
|
|
||||||
{localize('com_ui_share_to_all_users')}
|
|
||||||
</div>
|
|
||||||
<Controller
|
|
||||||
name={Permissions.SHARED_GLOBAL}
|
|
||||||
control={control}
|
|
||||||
disabled={isFetching === true || updateGroup.isLoading || !instanceProjectId}
|
|
||||||
render={({ field }) => (
|
|
||||||
<Switch
|
|
||||||
{...field}
|
|
||||||
checked={field.value}
|
|
||||||
onCheckedChange={field.onChange}
|
|
||||||
value={field.value.toString()}
|
|
||||||
aria-labelledby="share-to-all-users"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-end">
|
|
||||||
<OGDialogClose asChild>
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
disabled={isSubmitting || isFetching}
|
|
||||||
variant="submit"
|
|
||||||
aria-label={localize('com_ui_save')}
|
|
||||||
>
|
|
||||||
{localize('com_ui_save')}
|
|
||||||
</Button>
|
|
||||||
</OGDialogClose>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</OGDialogContent>
|
|
||||||
</OGDialog>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default SharePrompt;
|
export default SharePrompt;
|
||||||
|
|
261
client/src/components/Sharing/GenericGrantAccessDialog.tsx
Normal file
261
client/src/components/Sharing/GenericGrantAccessDialog.tsx
Normal file
|
@ -0,0 +1,261 @@
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Share2Icon, Users, Loader, Shield, Link, CopyCheck } from 'lucide-react';
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
OGDialog,
|
||||||
|
OGDialogTitle,
|
||||||
|
OGDialogClose,
|
||||||
|
OGDialogContent,
|
||||||
|
OGDialogTrigger,
|
||||||
|
useToastContext,
|
||||||
|
} from '@librechat/client';
|
||||||
|
import type { TPrincipal } from 'librechat-data-provider';
|
||||||
|
import { useLocalize, useCopyToClipboard } from '~/hooks';
|
||||||
|
import { usePeoplePickerPermissions, useResourcePermissionState } from '~/hooks/Sharing';
|
||||||
|
import GenericManagePermissionsDialog from './GenericManagePermissionsDialog';
|
||||||
|
import PeoplePicker from '../SidePanel/Agents/Sharing/PeoplePicker/PeoplePicker';
|
||||||
|
import AccessRolesPicker from '../SidePanel/Agents/Sharing/AccessRolesPicker';
|
||||||
|
import PublicSharingToggle from './PublicSharingToggle';
|
||||||
|
import { cn, removeFocusOutlines } from '~/utils';
|
||||||
|
|
||||||
|
export default function GenericGrantAccessDialog({
|
||||||
|
resourceName,
|
||||||
|
resourceDbId,
|
||||||
|
resourceId,
|
||||||
|
resourceType,
|
||||||
|
onGrantAccess,
|
||||||
|
disabled = false,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
resourceDbId?: string | null;
|
||||||
|
resourceId?: string | null;
|
||||||
|
resourceName?: string;
|
||||||
|
resourceType: string;
|
||||||
|
onGrantAccess?: (shares: TPrincipal[], isPublic: boolean, publicRole: string) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
const localize = useLocalize();
|
||||||
|
const { showToast } = useToastContext();
|
||||||
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
|
const [isCopying, setIsCopying] = useState(false);
|
||||||
|
|
||||||
|
// Use shared hooks
|
||||||
|
const { hasPeoplePickerAccess, peoplePickerTypeFilter } = usePeoplePickerPermissions();
|
||||||
|
const {
|
||||||
|
config,
|
||||||
|
updatePermissionsMutation,
|
||||||
|
currentShares,
|
||||||
|
currentIsPublic,
|
||||||
|
currentPublicRole,
|
||||||
|
isPublic,
|
||||||
|
setIsPublic,
|
||||||
|
publicRole,
|
||||||
|
setPublicRole,
|
||||||
|
} = useResourcePermissionState(resourceType, resourceDbId, isModalOpen);
|
||||||
|
|
||||||
|
const [newShares, setNewShares] = useState<TPrincipal[]>([]);
|
||||||
|
const [defaultPermissionId, setDefaultPermissionId] = useState<string>(
|
||||||
|
config?.defaultViewerRoleId ?? '',
|
||||||
|
);
|
||||||
|
|
||||||
|
const resourceUrl = config?.getResourceUrl ? config?.getResourceUrl(resourceId || '') : '';
|
||||||
|
const copyResourceUrl = useCopyToClipboard({ text: resourceUrl });
|
||||||
|
|
||||||
|
if (!resourceDbId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!config) {
|
||||||
|
console.error(`Unsupported resource type: ${resourceType}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleGrantAccess = async () => {
|
||||||
|
try {
|
||||||
|
const sharesToAdd = newShares.map((share) => ({
|
||||||
|
...share,
|
||||||
|
accessRoleId: defaultPermissionId,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const allShares = [...currentShares, ...sharesToAdd];
|
||||||
|
|
||||||
|
await updatePermissionsMutation.mutateAsync({
|
||||||
|
resourceType,
|
||||||
|
resourceId: resourceDbId,
|
||||||
|
data: {
|
||||||
|
updated: sharesToAdd,
|
||||||
|
removed: [],
|
||||||
|
public: isPublic,
|
||||||
|
publicAccessRoleId: isPublic ? publicRole : undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (onGrantAccess) {
|
||||||
|
onGrantAccess(allShares, isPublic, publicRole);
|
||||||
|
}
|
||||||
|
|
||||||
|
showToast({
|
||||||
|
message: `Access granted successfully to ${newShares.length} ${newShares.length === 1 ? 'person' : 'people'}${isPublic ? ' and made public' : ''}`,
|
||||||
|
status: 'success',
|
||||||
|
});
|
||||||
|
|
||||||
|
setNewShares([]);
|
||||||
|
setDefaultPermissionId(config?.defaultViewerRoleId);
|
||||||
|
setIsPublic(false);
|
||||||
|
setPublicRole(config?.defaultViewerRoleId);
|
||||||
|
setIsModalOpen(false);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error granting access:', error);
|
||||||
|
showToast({
|
||||||
|
message: 'Failed to grant access. Please try again.',
|
||||||
|
status: 'error',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
setNewShares([]);
|
||||||
|
setDefaultPermissionId(config?.defaultViewerRoleId);
|
||||||
|
setIsPublic(false);
|
||||||
|
setPublicRole(config?.defaultViewerRoleId);
|
||||||
|
setIsModalOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const totalCurrentShares = currentShares.length + (currentIsPublic ? 1 : 0);
|
||||||
|
const submitButtonActive =
|
||||||
|
newShares.length > 0 || isPublic !== currentIsPublic || publicRole !== currentPublicRole;
|
||||||
|
|
||||||
|
const TriggerComponent = children ? (
|
||||||
|
children
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
className={cn(
|
||||||
|
'btn btn-neutral border-token-border-light relative h-9 rounded-lg font-medium',
|
||||||
|
removeFocusOutlines,
|
||||||
|
)}
|
||||||
|
aria-label={localize('com_ui_share_var', {
|
||||||
|
0: config?.getShareMessage(resourceName),
|
||||||
|
})}
|
||||||
|
type="button"
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-center gap-2 text-blue-500">
|
||||||
|
<Share2Icon className="icon-md h-4 w-4" />
|
||||||
|
{totalCurrentShares > 0 && (
|
||||||
|
<span className="rounded-full bg-blue-100 px-1.5 py-0.5 text-xs font-medium text-blue-800 dark:bg-blue-900 dark:text-blue-300">
|
||||||
|
{totalCurrentShares}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<OGDialog open={isModalOpen} onOpenChange={setIsModalOpen} modal>
|
||||||
|
<OGDialogTrigger asChild>{TriggerComponent}</OGDialogTrigger>
|
||||||
|
|
||||||
|
<OGDialogContent className="max-h-[90vh] w-11/12 overflow-y-auto md:max-w-3xl">
|
||||||
|
<OGDialogTitle>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Users className="h-5 w-5" />
|
||||||
|
{localize('com_ui_share_var', {
|
||||||
|
0: config?.getShareMessage(resourceName),
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</OGDialogTitle>
|
||||||
|
|
||||||
|
<div className="space-y-6 p-2">
|
||||||
|
{hasPeoplePickerAccess && (
|
||||||
|
<>
|
||||||
|
<PeoplePicker
|
||||||
|
onSelectionChange={setNewShares}
|
||||||
|
placeholder={localize('com_ui_search_people_placeholder')}
|
||||||
|
typeFilter={peoplePickerTypeFilter}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Shield className="h-4 w-4 text-text-secondary" />
|
||||||
|
<label className="text-sm font-medium text-text-primary">
|
||||||
|
{localize('com_ui_permission_level')}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<AccessRolesPicker
|
||||||
|
resourceType={resourceType}
|
||||||
|
selectedRoleId={defaultPermissionId}
|
||||||
|
onRoleChange={setDefaultPermissionId}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<PublicSharingToggle
|
||||||
|
isPublic={isPublic}
|
||||||
|
publicRole={publicRole}
|
||||||
|
onPublicToggle={setIsPublic}
|
||||||
|
onPublicRoleChange={setPublicRole}
|
||||||
|
resourceType={resourceType}
|
||||||
|
/>
|
||||||
|
<div className="flex justify-between border-t pt-4">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{hasPeoplePickerAccess && (
|
||||||
|
<GenericManagePermissionsDialog
|
||||||
|
resourceDbId={resourceDbId}
|
||||||
|
resourceName={resourceName}
|
||||||
|
resourceType={resourceType}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{resourceId && resourceUrl && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
if (isCopying) return;
|
||||||
|
copyResourceUrl(setIsCopying);
|
||||||
|
showToast({
|
||||||
|
message: localize('com_ui_agent_url_copied'),
|
||||||
|
status: 'success',
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
disabled={isCopying}
|
||||||
|
className={cn('shrink-0', isCopying ? 'cursor-default' : '')}
|
||||||
|
aria-label={localize('com_ui_copy_url_to_clipboard')}
|
||||||
|
title={
|
||||||
|
isCopying
|
||||||
|
? config?.getCopyUrlMessage()
|
||||||
|
: localize('com_ui_copy_url_to_clipboard')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{isCopying ? <CopyCheck className="h-4 w-4" /> : <Link className="h-4 w-4" />}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<OGDialogClose asChild>
|
||||||
|
<Button variant="outline" onClick={handleCancel}>
|
||||||
|
{localize('com_ui_cancel')}
|
||||||
|
</Button>
|
||||||
|
</OGDialogClose>
|
||||||
|
<Button
|
||||||
|
onClick={handleGrantAccess}
|
||||||
|
disabled={updatePermissionsMutation.isLoading || !submitButtonActive}
|
||||||
|
className="min-w-[120px]"
|
||||||
|
>
|
||||||
|
{updatePermissionsMutation.isLoading ? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Loader className="h-4 w-4 animate-spin" />
|
||||||
|
{localize('com_ui_granting')}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
localize('com_ui_grant_access')
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</OGDialogContent>
|
||||||
|
</OGDialog>
|
||||||
|
);
|
||||||
|
}
|
345
client/src/components/Sharing/GenericManagePermissionsDialog.tsx
Normal file
345
client/src/components/Sharing/GenericManagePermissionsDialog.tsx
Normal file
|
@ -0,0 +1,345 @@
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { TPrincipal } from 'librechat-data-provider';
|
||||||
|
import { Settings, Users, Loader, UserCheck, Trash2, Shield } from 'lucide-react';
|
||||||
|
import { useGetAccessRolesQuery } from 'librechat-data-provider/react-query';
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
OGDialog,
|
||||||
|
OGDialogTitle,
|
||||||
|
OGDialogClose,
|
||||||
|
OGDialogContent,
|
||||||
|
OGDialogTrigger,
|
||||||
|
useToastContext,
|
||||||
|
} from '@librechat/client';
|
||||||
|
import SelectedPrincipalsList from '../SidePanel/Agents/Sharing/PeoplePicker/SelectedPrincipalsList';
|
||||||
|
import { useResourcePermissionState } from '~/hooks/Sharing';
|
||||||
|
import PublicSharingToggle from './PublicSharingToggle';
|
||||||
|
import { cn, removeFocusOutlines } from '~/utils';
|
||||||
|
import { useLocalize } from '~/hooks';
|
||||||
|
|
||||||
|
export default function GenericManagePermissionsDialog({
|
||||||
|
resourceDbId,
|
||||||
|
resourceName,
|
||||||
|
resourceType,
|
||||||
|
onUpdatePermissions,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
resourceDbId: string;
|
||||||
|
resourceName?: string;
|
||||||
|
resourceType: string;
|
||||||
|
onUpdatePermissions?: (shares: TPrincipal[], isPublic: boolean, publicRole: string) => void;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
const localize = useLocalize();
|
||||||
|
const { showToast } = useToastContext();
|
||||||
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
|
const [hasChanges, setHasChanges] = useState(false);
|
||||||
|
|
||||||
|
const {
|
||||||
|
config,
|
||||||
|
permissionsData,
|
||||||
|
isLoadingPermissions,
|
||||||
|
permissionsError,
|
||||||
|
updatePermissionsMutation,
|
||||||
|
currentShares,
|
||||||
|
currentIsPublic,
|
||||||
|
currentPublicRole,
|
||||||
|
isPublic: managedIsPublic,
|
||||||
|
setIsPublic: setManagedIsPublic,
|
||||||
|
publicRole: managedPublicRole,
|
||||||
|
setPublicRole: setManagedPublicRole,
|
||||||
|
} = useResourcePermissionState(resourceType, resourceDbId, isModalOpen);
|
||||||
|
|
||||||
|
const { data: accessRoles } = useGetAccessRolesQuery(resourceType);
|
||||||
|
|
||||||
|
const [managedShares, setManagedShares] = useState<TPrincipal[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (permissionsData && isModalOpen) {
|
||||||
|
const shares = permissionsData.principals || [];
|
||||||
|
setManagedShares(shares);
|
||||||
|
setHasChanges(false);
|
||||||
|
}
|
||||||
|
}, [permissionsData, isModalOpen]);
|
||||||
|
|
||||||
|
if (!resourceDbId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!config) {
|
||||||
|
console.error(`Unsupported resource type: ${resourceType}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (permissionsError) {
|
||||||
|
return <div className="text-sm text-red-600">{localize('com_ui_permissions_failed_load')}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRemoveShare = (idOnTheSource: string) => {
|
||||||
|
setManagedShares(managedShares.filter((s) => s.idOnTheSource !== idOnTheSource));
|
||||||
|
setHasChanges(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRoleChange = (idOnTheSource: string, newRole: string) => {
|
||||||
|
setManagedShares(
|
||||||
|
managedShares.map((s) =>
|
||||||
|
s.idOnTheSource === idOnTheSource ? { ...s, accessRoleId: newRole } : s,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
setHasChanges(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveChanges = async () => {
|
||||||
|
try {
|
||||||
|
const originalSharesMap = new Map(
|
||||||
|
currentShares.map((share) => [`${share.type}-${share.idOnTheSource}`, share]),
|
||||||
|
);
|
||||||
|
const managedSharesMap = new Map(
|
||||||
|
managedShares.map((share) => [`${share.type}-${share.idOnTheSource}`, share]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const updated = managedShares.filter((share) => {
|
||||||
|
const key = `${share.type}-${share.idOnTheSource}`;
|
||||||
|
const original = originalSharesMap.get(key);
|
||||||
|
return !original || original.accessRoleId !== share.accessRoleId;
|
||||||
|
});
|
||||||
|
|
||||||
|
const removed = currentShares.filter((share) => {
|
||||||
|
const key = `${share.type}-${share.idOnTheSource}`;
|
||||||
|
return !managedSharesMap.has(key);
|
||||||
|
});
|
||||||
|
|
||||||
|
await updatePermissionsMutation.mutateAsync({
|
||||||
|
resourceType,
|
||||||
|
resourceId: resourceDbId,
|
||||||
|
data: {
|
||||||
|
updated,
|
||||||
|
removed,
|
||||||
|
public: managedIsPublic,
|
||||||
|
publicAccessRoleId: managedIsPublic ? managedPublicRole : undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (onUpdatePermissions) {
|
||||||
|
onUpdatePermissions(managedShares, managedIsPublic, managedPublicRole);
|
||||||
|
}
|
||||||
|
|
||||||
|
showToast({
|
||||||
|
message: localize('com_ui_permissions_updated_success'),
|
||||||
|
status: 'success',
|
||||||
|
});
|
||||||
|
|
||||||
|
setIsModalOpen(false);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating permissions:', error);
|
||||||
|
showToast({
|
||||||
|
message: localize('com_ui_permissions_failed_update'),
|
||||||
|
status: 'error',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
setManagedShares(currentShares);
|
||||||
|
setManagedIsPublic(currentIsPublic);
|
||||||
|
setManagedPublicRole(currentPublicRole || config?.defaultViewerRoleId || '');
|
||||||
|
setIsModalOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRevokeAll = () => {
|
||||||
|
setManagedShares([]);
|
||||||
|
setManagedIsPublic(false);
|
||||||
|
setHasChanges(true);
|
||||||
|
};
|
||||||
|
const handlePublicToggle = (isPublic: boolean) => {
|
||||||
|
setManagedIsPublic(isPublic);
|
||||||
|
setHasChanges(true);
|
||||||
|
if (!isPublic) {
|
||||||
|
setManagedPublicRole(config?.defaultViewerRoleId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const handlePublicRoleChange = (role: string) => {
|
||||||
|
setManagedPublicRole(role);
|
||||||
|
setHasChanges(true);
|
||||||
|
};
|
||||||
|
const totalShares = managedShares.length + (managedIsPublic ? 1 : 0);
|
||||||
|
const originalTotalShares = currentShares.length + (currentIsPublic ? 1 : 0);
|
||||||
|
|
||||||
|
/** Check if there's at least one owner (user, group, or public with owner role) */
|
||||||
|
const hasAtLeastOneOwner =
|
||||||
|
managedShares.some((share) => share.accessRoleId === config?.defaultOwnerRoleId) ||
|
||||||
|
(managedIsPublic && managedPublicRole === config?.defaultOwnerRoleId);
|
||||||
|
|
||||||
|
let peopleLabel = localize('com_ui_people');
|
||||||
|
if (managedShares.length === 1) {
|
||||||
|
peopleLabel = localize('com_ui_person');
|
||||||
|
}
|
||||||
|
|
||||||
|
const buttonAriaLabel = config?.getManageMessage(resourceName);
|
||||||
|
const dialogTitle = config?.getManageMessage(resourceName);
|
||||||
|
|
||||||
|
let publicSuffix = '';
|
||||||
|
if (managedIsPublic) {
|
||||||
|
publicSuffix = localize('com_ui_and_public');
|
||||||
|
}
|
||||||
|
|
||||||
|
const TriggerComponent = children ? (
|
||||||
|
children
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
className={cn(
|
||||||
|
'btn btn-neutral border-token-border-light relative h-9 rounded-lg font-medium',
|
||||||
|
removeFocusOutlines,
|
||||||
|
)}
|
||||||
|
aria-label={buttonAriaLabel}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-center gap-2 text-blue-500">
|
||||||
|
<Settings className="icon-md h-4 w-4" />
|
||||||
|
<span className="hidden sm:inline">{localize('com_ui_manage')}</span>
|
||||||
|
{originalTotalShares > 0 && `(${originalTotalShares})`}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<OGDialog open={isModalOpen} onOpenChange={setIsModalOpen}>
|
||||||
|
<OGDialogTrigger asChild>{TriggerComponent}</OGDialogTrigger>
|
||||||
|
|
||||||
|
<OGDialogContent className="max-h-[90vh] w-11/12 overflow-y-auto md:max-w-3xl">
|
||||||
|
<OGDialogTitle>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Shield className="h-5 w-5 text-blue-500" />
|
||||||
|
{dialogTitle}
|
||||||
|
</div>
|
||||||
|
</OGDialogTitle>
|
||||||
|
|
||||||
|
<div className="space-y-6 p-2">
|
||||||
|
<div className="rounded-lg bg-surface-tertiary p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-medium text-text-primary">
|
||||||
|
{localize('com_ui_current_access')}
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-text-secondary">
|
||||||
|
{(() => {
|
||||||
|
if (totalShares === 0) {
|
||||||
|
return localize('com_ui_no_users_groups_access');
|
||||||
|
}
|
||||||
|
return localize('com_ui_shared_with_count', {
|
||||||
|
0: managedShares.length,
|
||||||
|
1: peopleLabel,
|
||||||
|
2: publicSuffix,
|
||||||
|
});
|
||||||
|
})()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{(managedShares.length > 0 || managedIsPublic) && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleRevokeAll}
|
||||||
|
className="text-red-600 hover:text-red-700"
|
||||||
|
>
|
||||||
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
|
{localize('com_ui_revoke_all')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(() => {
|
||||||
|
if (isLoadingPermissions) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center p-8">
|
||||||
|
<Loader className="h-6 w-6 animate-spin" />
|
||||||
|
<span className="ml-2 text-sm text-text-secondary">
|
||||||
|
{localize('com_ui_loading_permissions')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (managedShares.length > 0) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h3 className="mb-3 flex items-center gap-2 text-sm font-medium text-text-primary">
|
||||||
|
<UserCheck className="h-4 w-4" />
|
||||||
|
{localize('com_ui_user_group_permissions')} ({managedShares.length})
|
||||||
|
</h3>
|
||||||
|
<SelectedPrincipalsList
|
||||||
|
principles={managedShares}
|
||||||
|
onRemoveHandler={handleRemoveShare}
|
||||||
|
availableRoles={accessRoles || []}
|
||||||
|
onRoleChange={(id, newRole) => handleRoleChange(id, newRole)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border-2 border-dashed border-border-light p-8 text-center">
|
||||||
|
<Users className="mx-auto h-8 w-8 text-text-secondary" />
|
||||||
|
<p className="mt-2 text-sm text-text-secondary">
|
||||||
|
{localize('com_ui_no_individual_access')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 className="mb-3 text-sm font-medium text-text-primary">
|
||||||
|
{localize('com_ui_public_access')}
|
||||||
|
</h3>
|
||||||
|
<PublicSharingToggle
|
||||||
|
isPublic={managedIsPublic}
|
||||||
|
publicRole={managedPublicRole}
|
||||||
|
onPublicToggle={handlePublicToggle}
|
||||||
|
onPublicRoleChange={handlePublicRoleChange}
|
||||||
|
resourceType={resourceType}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-3 border-t pt-4">
|
||||||
|
<OGDialogClose asChild>
|
||||||
|
<Button variant="outline" onClick={handleCancel}>
|
||||||
|
{localize('com_ui_cancel')}
|
||||||
|
</Button>
|
||||||
|
</OGDialogClose>
|
||||||
|
<Button
|
||||||
|
onClick={handleSaveChanges}
|
||||||
|
disabled={
|
||||||
|
updatePermissionsMutation.isLoading ||
|
||||||
|
!hasChanges ||
|
||||||
|
isLoadingPermissions ||
|
||||||
|
!hasAtLeastOneOwner
|
||||||
|
}
|
||||||
|
className="min-w-[120px]"
|
||||||
|
>
|
||||||
|
{updatePermissionsMutation.isLoading ? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Loader className="h-4 w-4 animate-spin" />
|
||||||
|
{localize('com_ui_saving')}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
localize('com_ui_save_changes')
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{hasChanges && (
|
||||||
|
<div className="text-xs text-orange-600 dark:text-orange-400">
|
||||||
|
* {localize('com_ui_unsaved_changes')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!hasAtLeastOneOwner && hasChanges && (
|
||||||
|
<div className="text-xs text-red-600 dark:text-red-400">
|
||||||
|
* {localize('com_ui_at_least_one_owner_required')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</OGDialogContent>
|
||||||
|
</OGDialog>
|
||||||
|
);
|
||||||
|
}
|
60
client/src/components/Sharing/PublicSharingToggle.tsx
Normal file
60
client/src/components/Sharing/PublicSharingToggle.tsx
Normal file
|
@ -0,0 +1,60 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { Globe, Shield } from 'lucide-react';
|
||||||
|
import { Switch } from '@librechat/client';
|
||||||
|
import AccessRolesPicker from '../SidePanel/Agents/Sharing/AccessRolesPicker';
|
||||||
|
import { useLocalize } from '~/hooks';
|
||||||
|
|
||||||
|
export default function PublicSharingToggle({
|
||||||
|
isPublic,
|
||||||
|
publicRole,
|
||||||
|
onPublicToggle,
|
||||||
|
onPublicRoleChange,
|
||||||
|
resourceType = 'agent',
|
||||||
|
}: {
|
||||||
|
isPublic: boolean;
|
||||||
|
publicRole: string;
|
||||||
|
onPublicToggle: (isPublic: boolean) => void;
|
||||||
|
onPublicRoleChange: (role: string) => void;
|
||||||
|
resourceType?: string;
|
||||||
|
}) {
|
||||||
|
const localize = useLocalize();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between rounded-lg border p-4">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<Globe className="mt-0.5 h-5 w-5 text-blue-500" />
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium text-text-primary">
|
||||||
|
{localize('com_ui_public_access')}
|
||||||
|
</h4>
|
||||||
|
<p className="text-xs text-text-secondary">
|
||||||
|
{localize('com_ui_public_access_description')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={isPublic}
|
||||||
|
onCheckedChange={onPublicToggle}
|
||||||
|
aria-labelledby="public-access-toggle"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isPublic && (
|
||||||
|
<div className="ml-8 space-y-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Shield className="h-4 w-4 text-text-secondary" />
|
||||||
|
<label className="text-sm font-medium text-text-primary">
|
||||||
|
{localize('com_ui_public_permission_level')}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<AccessRolesPicker
|
||||||
|
resourceType={resourceType}
|
||||||
|
selectedRoleId={publicRole}
|
||||||
|
onRoleChange={onPublicRoleChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
3
client/src/components/Sharing/index.ts
Normal file
3
client/src/components/Sharing/index.ts
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
export { default as GenericGrantAccessDialog } from './GenericGrantAccessDialog';
|
||||||
|
export { default as GenericManagePermissionsDialog } from './GenericManagePermissionsDialog';
|
||||||
|
export { default as PublicSharingToggle } from './PublicSharingToggle';
|
|
@ -8,7 +8,7 @@ import {
|
||||||
} from 'librechat-data-provider';
|
} from 'librechat-data-provider';
|
||||||
import type { AgentForm, AgentPanelProps } from '~/common';
|
import type { AgentForm, AgentPanelProps } from '~/common';
|
||||||
import { useLocalize, useAuthContext, useHasAccess, useResourcePermissions } from '~/hooks';
|
import { useLocalize, useAuthContext, useHasAccess, useResourcePermissions } from '~/hooks';
|
||||||
import GrantAccessDialog from './Sharing/GrantAccessDialog';
|
import { GenericGrantAccessDialog } from '~/components/Sharing';
|
||||||
import { useUpdateAgentMutation } from '~/data-provider';
|
import { useUpdateAgentMutation } from '~/data-provider';
|
||||||
import AdvancedButton from './Advanced/AdvancedButton';
|
import AdvancedButton from './Advanced/AdvancedButton';
|
||||||
import VersionButton from './Version/VersionButton';
|
import VersionButton from './Version/VersionButton';
|
||||||
|
@ -80,10 +80,11 @@ export default function AgentFooter({
|
||||||
{(agent?.author === user?.id || user?.role === SystemRoles.ADMIN || canShareThisAgent) &&
|
{(agent?.author === user?.id || user?.role === SystemRoles.ADMIN || canShareThisAgent) &&
|
||||||
hasAccessToShareAgents &&
|
hasAccessToShareAgents &&
|
||||||
!permissionsLoading && (
|
!permissionsLoading && (
|
||||||
<GrantAccessDialog
|
<GenericGrantAccessDialog
|
||||||
agentDbId={agent?._id}
|
resourceDbId={agent?._id}
|
||||||
agentId={agent_id}
|
resourceId={agent_id}
|
||||||
agentName={agent?.name ?? ''}
|
resourceName={agent?.name ?? ''}
|
||||||
|
resourceType="agent"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{agent && agent.author === user?.id && <DuplicateAgent agent_id={agent_id} />}
|
{agent && agent.author === user?.id && <DuplicateAgent agent_id={agent_id} />}
|
||||||
|
|
|
@ -12,8 +12,6 @@ import {
|
||||||
DropdownPopup,
|
DropdownPopup,
|
||||||
AttachmentIcon,
|
AttachmentIcon,
|
||||||
CircleHelpIcon,
|
CircleHelpIcon,
|
||||||
AttachmentIcon,
|
|
||||||
CircleHelpIcon,
|
|
||||||
SharePointIcon,
|
SharePointIcon,
|
||||||
HoverCardPortal,
|
HoverCardPortal,
|
||||||
HoverCardContent,
|
HoverCardContent,
|
||||||
|
|
|
@ -6,13 +6,13 @@ import { ACCESS_ROLE_IDS } from 'librechat-data-provider';
|
||||||
import { useGetAccessRolesQuery } from 'librechat-data-provider/react-query';
|
import { useGetAccessRolesQuery } from 'librechat-data-provider/react-query';
|
||||||
import type { AccessRole } from 'librechat-data-provider';
|
import type { AccessRole } from 'librechat-data-provider';
|
||||||
import type * as t from '~/common';
|
import type * as t from '~/common';
|
||||||
|
import { cn, getRoleLocalizationKeys } from '~/utils';
|
||||||
import { useLocalize } from '~/hooks';
|
import { useLocalize } from '~/hooks';
|
||||||
import { cn } from '~/utils';
|
|
||||||
|
|
||||||
interface AccessRolesPickerProps {
|
interface AccessRolesPickerProps {
|
||||||
resourceType?: string;
|
resourceType?: string;
|
||||||
selectedRoleId?: string;
|
selectedRoleId?: ACCESS_ROLE_IDS;
|
||||||
onRoleChange: (roleId: string) => void;
|
onRoleChange: (roleId: ACCESS_ROLE_IDS) => void;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -24,42 +24,17 @@ export default function AccessRolesPicker({
|
||||||
}: AccessRolesPickerProps) {
|
}: AccessRolesPickerProps) {
|
||||||
const localize = useLocalize();
|
const localize = useLocalize();
|
||||||
const [isOpen, setIsOpen] = React.useState(false);
|
const [isOpen, setIsOpen] = React.useState(false);
|
||||||
|
|
||||||
// Fetch access roles from API
|
|
||||||
const { data: accessRoles, isLoading: rolesLoading } = useGetAccessRolesQuery(resourceType);
|
const { data: accessRoles, isLoading: rolesLoading } = useGetAccessRolesQuery(resourceType);
|
||||||
|
|
||||||
// Helper function to get localized role name and description
|
/** Helper function to get localized role name and description */
|
||||||
const getLocalizedRoleInfo = (roleId: string) => {
|
const getLocalizedRoleInfo = (roleId: ACCESS_ROLE_IDS) => {
|
||||||
switch (roleId) {
|
const keys = getRoleLocalizationKeys(roleId);
|
||||||
case 'agent_viewer':
|
return {
|
||||||
return {
|
name: localize(keys.name),
|
||||||
name: localize('com_ui_role_viewer'),
|
description: localize(keys.description),
|
||||||
description: localize('com_ui_role_viewer_desc'),
|
};
|
||||||
};
|
|
||||||
case 'agent_editor':
|
|
||||||
return {
|
|
||||||
name: localize('com_ui_role_editor'),
|
|
||||||
description: localize('com_ui_role_editor_desc'),
|
|
||||||
};
|
|
||||||
case 'agent_manager':
|
|
||||||
return {
|
|
||||||
name: localize('com_ui_role_manager'),
|
|
||||||
description: localize('com_ui_role_manager_desc'),
|
|
||||||
};
|
|
||||||
case 'agent_owner':
|
|
||||||
return {
|
|
||||||
name: localize('com_ui_role_owner'),
|
|
||||||
description: localize('com_ui_role_owner_desc'),
|
|
||||||
};
|
|
||||||
default:
|
|
||||||
return {
|
|
||||||
name: localize('com_ui_unknown'),
|
|
||||||
description: localize('com_ui_unknown'),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Find the currently selected role
|
|
||||||
const selectedRole = accessRoles?.find((role) => role.accessRoleId === selectedRoleId);
|
const selectedRole = accessRoles?.find((role) => role.accessRoleId === selectedRoleId);
|
||||||
const selectedRoleInfo = selectedRole ? getLocalizedRoleInfo(selectedRole.accessRoleId) : null;
|
const selectedRoleInfo = selectedRole ? getLocalizedRoleInfo(selectedRole.accessRoleId) : null;
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import React, { useState, useEffect, useMemo } from 'react';
|
import React, { useState, useEffect, useMemo } from 'react';
|
||||||
import { ACCESS_ROLE_IDS, PermissionTypes } from 'librechat-data-provider';
|
import { ACCESS_ROLE_IDS, PermissionTypes, Permissions } from 'librechat-data-provider';
|
||||||
import { Share2Icon, Users, Loader, Shield, Link, CopyCheck } from 'lucide-react';
|
import { Share2Icon, Users, Loader, Shield, Link, CopyCheck } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
useGetResourcePermissionsQuery,
|
useGetResourcePermissionsQuery,
|
||||||
|
@ -49,7 +49,7 @@ export default function GrantAccessDialog({
|
||||||
});
|
});
|
||||||
const hasPeoplePickerAccess = canViewUsers || canViewGroups;
|
const hasPeoplePickerAccess = canViewUsers || canViewGroups;
|
||||||
|
|
||||||
// Determine type filter based on permissions
|
/** Type filter based on permissions */
|
||||||
const peoplePickerTypeFilter = useMemo(() => {
|
const peoplePickerTypeFilter = useMemo(() => {
|
||||||
if (canViewUsers && canViewGroups) {
|
if (canViewUsers && canViewGroups) {
|
||||||
return null; // Both types allowed
|
return null; // Both types allowed
|
||||||
|
|
|
@ -2,7 +2,8 @@ import React, { useState, useId } from 'react';
|
||||||
import * as Menu from '@ariakit/react/menu';
|
import * as Menu from '@ariakit/react/menu';
|
||||||
import { Button, DropdownPopup } from '@librechat/client';
|
import { Button, DropdownPopup } from '@librechat/client';
|
||||||
import { Users, X, ExternalLink, ChevronDown } from 'lucide-react';
|
import { Users, X, ExternalLink, ChevronDown } from 'lucide-react';
|
||||||
import type { TPrincipal, TAccessRole } from 'librechat-data-provider';
|
import type { TPrincipal, TAccessRole, ACCESS_ROLE_IDS } from 'librechat-data-provider';
|
||||||
|
import { getRoleLocalizationKeys } from '~/utils';
|
||||||
import PrincipalAvatar from '../PrincipalAvatar';
|
import PrincipalAvatar from '../PrincipalAvatar';
|
||||||
import { useLocalize } from '~/hooks';
|
import { useLocalize } from '~/hooks';
|
||||||
|
|
||||||
|
@ -97,8 +98,8 @@ export default function SelectedPrincipalsList({
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RoleSelectorProps {
|
interface RoleSelectorProps {
|
||||||
currentRole: string;
|
currentRole: ACCESS_ROLE_IDS;
|
||||||
onRoleChange: (newRole: string) => void;
|
onRoleChange: (newRole: ACCESS_ROLE_IDS) => void;
|
||||||
availableRoles: Omit<TAccessRole, 'resourceType'>[];
|
availableRoles: Omit<TAccessRole, 'resourceType'>[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -107,19 +108,9 @@ function RoleSelector({ currentRole, onRoleChange, availableRoles }: RoleSelecto
|
||||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||||
const localize = useLocalize();
|
const localize = useLocalize();
|
||||||
|
|
||||||
const getLocalizedRoleName = (roleId: string) => {
|
const getLocalizedRoleName = (roleId: ACCESS_ROLE_IDS) => {
|
||||||
switch (roleId) {
|
const keys = getRoleLocalizationKeys(roleId);
|
||||||
case 'agent_viewer':
|
return localize(keys.name);
|
||||||
return localize('com_ui_role_viewer');
|
|
||||||
case 'agent_editor':
|
|
||||||
return localize('com_ui_role_editor');
|
|
||||||
case 'agent_manager':
|
|
||||||
return localize('com_ui_role_manager');
|
|
||||||
case 'agent_owner':
|
|
||||||
return localize('com_ui_role_owner');
|
|
||||||
default:
|
|
||||||
return localize('com_ui_unknown');
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -139,7 +130,6 @@ function RoleSelector({ currentRole, onRoleChange, availableRoles }: RoleSelecto
|
||||||
items={availableRoles?.map((role) => ({
|
items={availableRoles?.map((role) => ({
|
||||||
id: role.accessRoleId,
|
id: role.accessRoleId,
|
||||||
label: getLocalizedRoleName(role.accessRoleId),
|
label: getLocalizedRoleName(role.accessRoleId),
|
||||||
|
|
||||||
onClick: () => onRoleChange(role.accessRoleId),
|
onClick: () => onRoleChange(role.accessRoleId),
|
||||||
}))}
|
}))}
|
||||||
menuId={menuId}
|
menuId={menuId}
|
||||||
|
|
|
@ -145,23 +145,44 @@ jest.mock('../AdminSettings', () => ({
|
||||||
|
|
||||||
jest.mock('../DeleteButton', () => ({
|
jest.mock('../DeleteButton', () => ({
|
||||||
__esModule: true,
|
__esModule: true,
|
||||||
default: jest.fn(() => <div data-testid="delete-button" />),
|
default: ({ agent_id }: { agent_id: string }) => (
|
||||||
}));
|
<button data-testid="delete-button" data-agent-id={agent_id} title="Delete Agent" />
|
||||||
|
),
|
||||||
jest.mock('../Sharing/GrantAccessDialog', () => ({
|
|
||||||
__esModule: true,
|
|
||||||
default: jest.fn(() => <div data-testid="grant-access-dialog" />),
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
jest.mock('../DuplicateAgent', () => ({
|
jest.mock('../DuplicateAgent', () => ({
|
||||||
__esModule: true,
|
__esModule: true,
|
||||||
default: jest.fn(() => <div data-testid="duplicate-agent" />),
|
default: ({ agent_id }: { agent_id: string }) => (
|
||||||
|
<button data-testid="duplicate-button" data-agent-id={agent_id} title="Duplicate Agent" />
|
||||||
|
),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
jest.mock('~/components', () => ({
|
jest.mock('~/components', () => ({
|
||||||
Spinner: () => <div data-testid="spinner" />,
|
Spinner: () => <div data-testid="spinner" />,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
jest.mock('~/components/Sharing', () => ({
|
||||||
|
GenericGrantAccessDialog: ({
|
||||||
|
resourceDbId,
|
||||||
|
resourceId,
|
||||||
|
resourceName,
|
||||||
|
resourceType,
|
||||||
|
}: {
|
||||||
|
resourceDbId: string;
|
||||||
|
resourceId: string;
|
||||||
|
resourceName: string;
|
||||||
|
resourceType: string;
|
||||||
|
}) => (
|
||||||
|
<div
|
||||||
|
data-testid="grant-access-dialog"
|
||||||
|
data-resource-db-id={resourceDbId}
|
||||||
|
data-resource-id={resourceId}
|
||||||
|
data-resource-name={resourceName}
|
||||||
|
data-resource-type={resourceType}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
describe('AgentFooter', () => {
|
describe('AgentFooter', () => {
|
||||||
const mockUsers = {
|
const mockUsers = {
|
||||||
regular: mockUser,
|
regular: mockUser,
|
||||||
|
|
|
@ -139,6 +139,37 @@ export const useCreatePrompt = (
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const useAddPromptToGroup = (
|
||||||
|
options?: t.CreatePromptOptions,
|
||||||
|
): UseMutationResult<
|
||||||
|
t.TCreatePromptResponse,
|
||||||
|
unknown,
|
||||||
|
t.TCreatePrompt & { groupId: string },
|
||||||
|
unknown
|
||||||
|
> => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { onSuccess, ...rest } = options || {};
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ groupId, ...payload }: t.TCreatePrompt & { groupId: string }) =>
|
||||||
|
dataService.addPromptToGroup(groupId, payload),
|
||||||
|
...rest,
|
||||||
|
onSuccess: (response, variables, context) => {
|
||||||
|
const { prompt } = response;
|
||||||
|
queryClient.setQueryData(
|
||||||
|
[QueryKeys.prompts, variables.prompt.groupId],
|
||||||
|
(oldData: t.TPrompt[] | undefined) => {
|
||||||
|
return [prompt, ...(oldData ?? [])];
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (onSuccess) {
|
||||||
|
onSuccess(response, variables, context);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
export const useDeletePrompt = (
|
export const useDeletePrompt = (
|
||||||
options?: t.DeletePromptOptions,
|
options?: t.DeletePromptOptions,
|
||||||
): UseMutationResult<t.TDeletePromptResponse, unknown, t.TDeletePromptVariables, unknown> => {
|
): UseMutationResult<t.TDeletePromptResponse, unknown, t.TDeletePromptVariables, unknown> => {
|
||||||
|
|
2
client/src/hooks/Sharing/index.ts
Normal file
2
client/src/hooks/Sharing/index.ts
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
export { usePeoplePickerPermissions } from './usePeoplePickerPermissions';
|
||||||
|
export { useResourcePermissionState } from './useResourcePermissionState';
|
39
client/src/hooks/Sharing/usePeoplePickerPermissions.ts
Normal file
39
client/src/hooks/Sharing/usePeoplePickerPermissions.ts
Normal file
|
@ -0,0 +1,39 @@
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import { PermissionTypes, Permissions } from 'librechat-data-provider';
|
||||||
|
import { useHasAccess } from '~/hooks';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to check people picker permissions and return the appropriate type filter
|
||||||
|
* @returns Object with permission states and type filter
|
||||||
|
*/
|
||||||
|
export const usePeoplePickerPermissions = () => {
|
||||||
|
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;
|
||||||
|
|
||||||
|
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]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
canViewUsers,
|
||||||
|
canViewGroups,
|
||||||
|
hasPeoplePickerAccess,
|
||||||
|
peoplePickerTypeFilter,
|
||||||
|
};
|
||||||
|
};
|
79
client/src/hooks/Sharing/useResourcePermissionState.ts
Normal file
79
client/src/hooks/Sharing/useResourcePermissionState.ts
Normal file
|
@ -0,0 +1,79 @@
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
useGetResourcePermissionsQuery,
|
||||||
|
useUpdateResourcePermissionsMutation,
|
||||||
|
} from 'librechat-data-provider/react-query';
|
||||||
|
import type { TPrincipal } from 'librechat-data-provider';
|
||||||
|
import { getResourceConfig } from '~/utils';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to manage resource permission state including current shares, public access, and mutations
|
||||||
|
* @param resourceType - Type of resource (e.g., 'agent', 'promptGroup')
|
||||||
|
* @param resourceDbId - Database ID of the resource
|
||||||
|
* @param isModalOpen - Whether the modal is open (for effect dependencies)
|
||||||
|
* @returns Object with permission state and update mutation
|
||||||
|
*/
|
||||||
|
export const useResourcePermissionState = (
|
||||||
|
resourceType: string,
|
||||||
|
resourceDbId: string | null | undefined,
|
||||||
|
isModalOpen: boolean = false,
|
||||||
|
) => {
|
||||||
|
const config = getResourceConfig(resourceType);
|
||||||
|
|
||||||
|
// Only enable the query if we have a valid resourceDbId
|
||||||
|
const isValidResourceId = !!resourceDbId && resourceDbId.trim() !== '';
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: permissionsData,
|
||||||
|
isLoading: isLoadingPermissions,
|
||||||
|
error: permissionsError,
|
||||||
|
} = useGetResourcePermissionsQuery(resourceType, resourceDbId || '', {
|
||||||
|
enabled: isValidResourceId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const updatePermissionsMutation = useUpdateResourcePermissionsMutation();
|
||||||
|
|
||||||
|
// Extract current shares from permissions data
|
||||||
|
const currentShares: TPrincipal[] =
|
||||||
|
permissionsData?.principals?.map((principal) => ({
|
||||||
|
type: principal.type,
|
||||||
|
id: principal.id,
|
||||||
|
name: principal.name,
|
||||||
|
email: principal.email,
|
||||||
|
source: principal.source,
|
||||||
|
avatar: principal.avatar,
|
||||||
|
description: principal.description,
|
||||||
|
accessRoleId: principal.accessRoleId,
|
||||||
|
idOnTheSource: principal.idOnTheSource,
|
||||||
|
})) || [];
|
||||||
|
|
||||||
|
const currentIsPublic = permissionsData?.public ?? false;
|
||||||
|
const currentPublicRole = permissionsData?.publicAccessRoleId || config?.defaultViewerRoleId;
|
||||||
|
|
||||||
|
// State for managing public access
|
||||||
|
const [isPublic, setIsPublic] = useState(false);
|
||||||
|
const [publicRole, setPublicRole] = useState<string>(config?.defaultViewerRoleId ?? '');
|
||||||
|
|
||||||
|
// Sync state with permissions data when modal opens
|
||||||
|
useEffect(() => {
|
||||||
|
if (permissionsData && isModalOpen) {
|
||||||
|
setIsPublic(currentIsPublic ?? false);
|
||||||
|
setPublicRole(currentPublicRole ?? '');
|
||||||
|
}
|
||||||
|
}, [permissionsData, isModalOpen, currentIsPublic, currentPublicRole]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
config,
|
||||||
|
permissionsData,
|
||||||
|
isLoadingPermissions,
|
||||||
|
permissionsError,
|
||||||
|
updatePermissionsMutation,
|
||||||
|
currentShares,
|
||||||
|
currentIsPublic,
|
||||||
|
currentPublicRole,
|
||||||
|
isPublic,
|
||||||
|
setIsPublic,
|
||||||
|
publicRole,
|
||||||
|
setPublicRole,
|
||||||
|
};
|
||||||
|
};
|
|
@ -1188,11 +1188,13 @@
|
||||||
"com_ui_user_group_permissions": "User & Group Permissions",
|
"com_ui_user_group_permissions": "User & Group Permissions",
|
||||||
"com_ui_no_individual_access": "No individual users or groups have access to this agent",
|
"com_ui_no_individual_access": "No individual users or groups have access to this agent",
|
||||||
"com_ui_public_access": "Public Access",
|
"com_ui_public_access": "Public Access",
|
||||||
|
"com_ui_public_access_description": "Anyone can access this resource publicly",
|
||||||
"com_ui_save_changes": "Save Changes",
|
"com_ui_save_changes": "Save Changes",
|
||||||
"com_ui_unsaved_changes": "You have unsaved changes",
|
"com_ui_unsaved_changes": "You have unsaved changes",
|
||||||
"com_ui_share_with_everyone": "Share with everyone",
|
"com_ui_share_with_everyone": "Share with everyone",
|
||||||
"com_ui_make_agent_available_all_users": "Make this agent available to all LibreChat users",
|
"com_ui_make_agent_available_all_users": "Make this agent available to all LibreChat users",
|
||||||
"com_ui_public_access_level": "Public access level",
|
"com_ui_public_access_level": "Public access level",
|
||||||
|
"com_ui_public_permission_level": "Public permission level",
|
||||||
"com_ui_at_least_one_owner_required": "At least one owner is required",
|
"com_ui_at_least_one_owner_required": "At least one owner is required",
|
||||||
"com_agents_marketplace": "Agent Marketplace",
|
"com_agents_marketplace": "Agent Marketplace",
|
||||||
"com_agents_all": "All Agents",
|
"com_agents_all": "All Agents",
|
||||||
|
|
|
@ -14,6 +14,8 @@ export * from './textarea';
|
||||||
export * from './messages';
|
export * from './messages';
|
||||||
export * from './languages';
|
export * from './languages';
|
||||||
export * from './endpoints';
|
export * from './endpoints';
|
||||||
|
export * from './resources';
|
||||||
|
export * from './roles';
|
||||||
export * from './localStorage';
|
export * from './localStorage';
|
||||||
export * from './promptGroups';
|
export * from './promptGroups';
|
||||||
export { default as cn } from './cn';
|
export { default as cn } from './cn';
|
||||||
|
|
43
client/src/utils/resources.ts
Normal file
43
client/src/utils/resources.ts
Normal file
|
@ -0,0 +1,43 @@
|
||||||
|
import { ACCESS_ROLE_IDS } from 'librechat-data-provider';
|
||||||
|
|
||||||
|
export interface ResourceConfig {
|
||||||
|
resourceType: string;
|
||||||
|
defaultViewerRoleId: string;
|
||||||
|
defaultEditorRoleId: string;
|
||||||
|
defaultOwnerRoleId: string;
|
||||||
|
getResourceUrl?: (resourceId: string) => string;
|
||||||
|
getResourceName: (resourceName?: string) => string;
|
||||||
|
getShareMessage: (resourceName?: string) => string;
|
||||||
|
getManageMessage: (resourceName?: string) => string;
|
||||||
|
getCopyUrlMessage: () => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RESOURCE_CONFIGS: Record<string, ResourceConfig> = {
|
||||||
|
agent: {
|
||||||
|
resourceType: 'agent',
|
||||||
|
defaultViewerRoleId: ACCESS_ROLE_IDS.AGENT_VIEWER,
|
||||||
|
defaultEditorRoleId: ACCESS_ROLE_IDS.AGENT_EDITOR,
|
||||||
|
defaultOwnerRoleId: ACCESS_ROLE_IDS.AGENT_OWNER,
|
||||||
|
getResourceUrl: (agentId: string) => `${window.location.origin}/c/new?agent_id=${agentId}`,
|
||||||
|
getResourceName: (name?: string) => (name && name !== '' ? `"${name}"` : 'agent'),
|
||||||
|
getShareMessage: (name?: string) => (name && name !== '' ? `"${name}"` : 'agent'),
|
||||||
|
getManageMessage: (name?: string) =>
|
||||||
|
`Manage permissions for ${name && name !== '' ? `"${name}"` : 'agent'}`,
|
||||||
|
getCopyUrlMessage: () => 'Agent URL copied',
|
||||||
|
},
|
||||||
|
promptGroup: {
|
||||||
|
resourceType: 'promptGroup',
|
||||||
|
defaultViewerRoleId: ACCESS_ROLE_IDS.PROMPTGROUP_VIEWER,
|
||||||
|
defaultEditorRoleId: ACCESS_ROLE_IDS.PROMPTGROUP_EDITOR,
|
||||||
|
defaultOwnerRoleId: ACCESS_ROLE_IDS.PROMPTGROUP_OWNER,
|
||||||
|
getResourceName: (name?: string) => (name && name !== '' ? `"${name}"` : 'prompt'),
|
||||||
|
getShareMessage: (name?: string) => (name && name !== '' ? `"${name}"` : 'prompt'),
|
||||||
|
getManageMessage: (name?: string) =>
|
||||||
|
`Manage permissions for ${name && name !== '' ? `"${name}"` : 'prompt'}`,
|
||||||
|
getCopyUrlMessage: () => 'Prompt URL copied',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getResourceConfig = (resourceType: string): ResourceConfig | undefined => {
|
||||||
|
return RESOURCE_CONFIGS[resourceType];
|
||||||
|
};
|
52
client/src/utils/roles.ts
Normal file
52
client/src/utils/roles.ts
Normal file
|
@ -0,0 +1,52 @@
|
||||||
|
import type { ACCESS_ROLE_IDS } from 'librechat-data-provider';
|
||||||
|
import type { TranslationKeys } from '~/hooks/useLocalize';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Centralized mapping for role localizations
|
||||||
|
* Maps role IDs to their localization keys
|
||||||
|
*/
|
||||||
|
export const ROLE_LOCALIZATIONS = {
|
||||||
|
agent_viewer: {
|
||||||
|
name: 'com_ui_role_viewer' as const,
|
||||||
|
description: 'com_ui_role_viewer_desc' as const,
|
||||||
|
} as const,
|
||||||
|
agent_editor: {
|
||||||
|
name: 'com_ui_role_editor' as const,
|
||||||
|
description: 'com_ui_role_editor_desc' as const,
|
||||||
|
} as const,
|
||||||
|
agent_manager: {
|
||||||
|
name: 'com_ui_role_manager' as const,
|
||||||
|
description: 'com_ui_role_manager_desc' as const,
|
||||||
|
} as const,
|
||||||
|
agent_owner: {
|
||||||
|
name: 'com_ui_role_owner' as const,
|
||||||
|
description: 'com_ui_role_owner_desc' as const,
|
||||||
|
} as const,
|
||||||
|
// PromptGroup roles
|
||||||
|
promptGroup_viewer: {
|
||||||
|
name: 'com_ui_role_viewer' as const,
|
||||||
|
description: 'com_ui_role_viewer_desc' as const,
|
||||||
|
} as const,
|
||||||
|
promptGroup_editor: {
|
||||||
|
name: 'com_ui_role_editor' as const,
|
||||||
|
description: 'com_ui_role_editor_desc' as const,
|
||||||
|
} as const,
|
||||||
|
promptGroup_owner: {
|
||||||
|
name: 'com_ui_role_owner' as const,
|
||||||
|
description: 'com_ui_role_owner_desc' as const,
|
||||||
|
} as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get localization keys for a given role ID
|
||||||
|
* @param roleId - The role ID to get localization keys for
|
||||||
|
* @returns Object with name and description localization keys, or unknown keys if not found
|
||||||
|
*/
|
||||||
|
export const getRoleLocalizationKeys = (
|
||||||
|
roleId: ACCESS_ROLE_IDS,
|
||||||
|
): {
|
||||||
|
name: TranslationKeys;
|
||||||
|
description: TranslationKeys;
|
||||||
|
} => {
|
||||||
|
return ROLE_LOCALIZATIONS[roleId] || { name: 'com_ui_unknown', description: 'com_ui_unknown' };
|
||||||
|
};
|
235
config/migrate-prompt-permissions.js
Normal file
235
config/migrate-prompt-permissions.js
Normal file
|
@ -0,0 +1,235 @@
|
||||||
|
const path = require('path');
|
||||||
|
const { logger } = require('@librechat/data-schemas');
|
||||||
|
require('module-alias')({ base: path.resolve(__dirname, '..', 'api') });
|
||||||
|
|
||||||
|
const { GLOBAL_PROJECT_NAME } = require('librechat-data-provider').Constants;
|
||||||
|
const connect = require('./connect');
|
||||||
|
|
||||||
|
const { grantPermission } = require('~/server/services/PermissionService');
|
||||||
|
const { getProjectByName } = require('~/models/Project');
|
||||||
|
const { findRoleByIdentifier } = require('~/models');
|
||||||
|
const { PromptGroup } = require('~/db/models');
|
||||||
|
|
||||||
|
async function migrateToPromptGroupPermissions({ dryRun = true, batchSize = 100 } = {}) {
|
||||||
|
await connect();
|
||||||
|
|
||||||
|
logger.info('Starting PromptGroup Permissions Migration', { dryRun, batchSize });
|
||||||
|
|
||||||
|
// Verify required roles exist
|
||||||
|
const ownerRole = await findRoleByIdentifier('promptGroup_owner');
|
||||||
|
const viewerRole = await findRoleByIdentifier('promptGroup_viewer');
|
||||||
|
const editorRole = await findRoleByIdentifier('promptGroup_editor');
|
||||||
|
|
||||||
|
if (!ownerRole || !viewerRole || !editorRole) {
|
||||||
|
throw new Error('Required promptGroup roles not found. Run role seeding first.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get global project prompt group IDs
|
||||||
|
const globalProject = await getProjectByName(GLOBAL_PROJECT_NAME, ['promptGroupIds']);
|
||||||
|
const globalPromptGroupIds = new Set(
|
||||||
|
(globalProject?.promptGroupIds || []).map((id) => id.toString()),
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.info(`Found ${globalPromptGroupIds.size} prompt groups in global project`);
|
||||||
|
|
||||||
|
// Find promptGroups without ACL entries
|
||||||
|
const promptGroupsToMigrate = await PromptGroup.aggregate([
|
||||||
|
{
|
||||||
|
$lookup: {
|
||||||
|
from: 'aclentries',
|
||||||
|
localField: '_id',
|
||||||
|
foreignField: 'resourceId',
|
||||||
|
as: 'aclEntries',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
$addFields: {
|
||||||
|
promptGroupAclEntries: {
|
||||||
|
$filter: {
|
||||||
|
input: '$aclEntries',
|
||||||
|
as: 'aclEntry',
|
||||||
|
cond: {
|
||||||
|
$and: [
|
||||||
|
{ $eq: ['$$aclEntry.resourceType', 'promptGroup'] },
|
||||||
|
{ $eq: ['$$aclEntry.principalType', 'user'] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
$match: {
|
||||||
|
author: { $exists: true, $ne: null },
|
||||||
|
promptGroupAclEntries: { $size: 0 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
$project: {
|
||||||
|
_id: 1,
|
||||||
|
name: 1,
|
||||||
|
author: 1,
|
||||||
|
authorName: 1,
|
||||||
|
category: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const categories = {
|
||||||
|
globalViewAccess: [], // PromptGroup in global project -> Public VIEW
|
||||||
|
privateGroups: [], // Not in global project -> Private (owner only)
|
||||||
|
};
|
||||||
|
|
||||||
|
promptGroupsToMigrate.forEach((group) => {
|
||||||
|
const isGlobalGroup = globalPromptGroupIds.has(group._id.toString());
|
||||||
|
|
||||||
|
if (isGlobalGroup) {
|
||||||
|
categories.globalViewAccess.push(group);
|
||||||
|
} else {
|
||||||
|
categories.privateGroups.push(group);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info('PromptGroup categorization:', {
|
||||||
|
globalViewAccess: categories.globalViewAccess.length,
|
||||||
|
privateGroups: categories.privateGroups.length,
|
||||||
|
total: promptGroupsToMigrate.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (dryRun) {
|
||||||
|
return {
|
||||||
|
migrated: 0,
|
||||||
|
errors: 0,
|
||||||
|
dryRun: true,
|
||||||
|
summary: {
|
||||||
|
globalViewAccess: categories.globalViewAccess.length,
|
||||||
|
privateGroups: categories.privateGroups.length,
|
||||||
|
total: promptGroupsToMigrate.length,
|
||||||
|
},
|
||||||
|
details: {
|
||||||
|
globalViewAccess: categories.globalViewAccess.map((g) => ({
|
||||||
|
name: g.name,
|
||||||
|
_id: g._id,
|
||||||
|
category: g.category || 'uncategorized',
|
||||||
|
permissions: 'Owner + Public VIEW',
|
||||||
|
})),
|
||||||
|
privateGroups: categories.privateGroups.map((g) => ({
|
||||||
|
name: g.name,
|
||||||
|
_id: g._id,
|
||||||
|
category: g.category || 'uncategorized',
|
||||||
|
permissions: 'Owner only',
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = {
|
||||||
|
migrated: 0,
|
||||||
|
errors: 0,
|
||||||
|
publicViewGrants: 0,
|
||||||
|
ownerGrants: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Process in batches
|
||||||
|
for (let i = 0; i < promptGroupsToMigrate.length; i += batchSize) {
|
||||||
|
const batch = promptGroupsToMigrate.slice(i, i + batchSize);
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`Processing batch ${Math.floor(i / batchSize) + 1}/${Math.ceil(promptGroupsToMigrate.length / batchSize)}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const group of batch) {
|
||||||
|
try {
|
||||||
|
const isGlobalGroup = globalPromptGroupIds.has(group._id.toString());
|
||||||
|
|
||||||
|
// Always grant owner permission to author
|
||||||
|
await grantPermission({
|
||||||
|
principalType: 'user',
|
||||||
|
principalId: group.author,
|
||||||
|
resourceType: 'promptGroup',
|
||||||
|
resourceId: group._id,
|
||||||
|
accessRoleId: 'promptGroup_owner',
|
||||||
|
grantedBy: group.author,
|
||||||
|
});
|
||||||
|
results.ownerGrants++;
|
||||||
|
|
||||||
|
// Grant public view permissions for promptGroups in global project
|
||||||
|
if (isGlobalGroup) {
|
||||||
|
await grantPermission({
|
||||||
|
principalType: 'public',
|
||||||
|
principalId: null,
|
||||||
|
resourceType: 'promptGroup',
|
||||||
|
resourceId: group._id,
|
||||||
|
accessRoleId: 'promptGroup_viewer',
|
||||||
|
grantedBy: group.author,
|
||||||
|
});
|
||||||
|
results.publicViewGrants++;
|
||||||
|
}
|
||||||
|
|
||||||
|
results.migrated++;
|
||||||
|
logger.debug(
|
||||||
|
`Migrated promptGroup "${group.name}" [${isGlobalGroup ? 'Global View' : 'Private'}]`,
|
||||||
|
{
|
||||||
|
groupId: group._id,
|
||||||
|
author: group.author,
|
||||||
|
isGlobalGroup,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
results.errors++;
|
||||||
|
logger.error(`Failed to migrate promptGroup "${group.name}"`, {
|
||||||
|
groupId: group._id,
|
||||||
|
author: group.author,
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Brief pause between batches
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('PromptGroup migration completed', results);
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (require.main === module) {
|
||||||
|
const dryRun = process.argv.includes('--dry-run');
|
||||||
|
const batchSize =
|
||||||
|
parseInt(process.argv.find((arg) => arg.startsWith('--batch-size='))?.split('=')[1]) || 100;
|
||||||
|
|
||||||
|
migrateToPromptGroupPermissions({ dryRun, batchSize })
|
||||||
|
.then((result) => {
|
||||||
|
if (dryRun) {
|
||||||
|
console.log('\n=== DRY RUN RESULTS ===');
|
||||||
|
console.log(`Total promptGroups to migrate: ${result.summary.total}`);
|
||||||
|
console.log(`- Global View Access: ${result.summary.globalViewAccess} promptGroups`);
|
||||||
|
console.log(`- Private PromptGroups: ${result.summary.privateGroups} promptGroups`);
|
||||||
|
|
||||||
|
if (result.details.globalViewAccess.length > 0) {
|
||||||
|
console.log('\nGlobal View Access promptGroups (first 10):');
|
||||||
|
result.details.globalViewAccess.slice(0, 10).forEach((group, i) => {
|
||||||
|
console.log(` ${i + 1}. "${group.name}" [${group.category}] (${group._id})`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.details.privateGroups.length > 0) {
|
||||||
|
console.log('\nPrivate promptGroups (first 10):');
|
||||||
|
result.details.privateGroups.slice(0, 10).forEach((group, i) => {
|
||||||
|
console.log(` ${i + 1}. "${group.name}" [${group.category}] (${group._id})`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\nTo run the actual migration, remove the --dry-run flag');
|
||||||
|
} else {
|
||||||
|
console.log('\nMigration Results:', JSON.stringify(result, null, 2));
|
||||||
|
}
|
||||||
|
process.exit(0);
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('PromptGroup migration failed:', error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { migrateToPromptGroupPermissions };
|
|
@ -77,7 +77,10 @@
|
||||||
"flush-cache": "node config/flush-cache.js",
|
"flush-cache": "node config/flush-cache.js",
|
||||||
"migrate:agent-permissions:dry-run": "node config/migrate-agent-permissions.js --dry-run",
|
"migrate:agent-permissions:dry-run": "node config/migrate-agent-permissions.js --dry-run",
|
||||||
"migrate:agent-permissions": "node config/migrate-agent-permissions.js",
|
"migrate:agent-permissions": "node config/migrate-agent-permissions.js",
|
||||||
"migrate:agent-permissions:batch": "node config/migrate-agent-permissions.js --batch-size=50"
|
"migrate:agent-permissions:batch": "node config/migrate-agent-permissions.js --batch-size=50",
|
||||||
|
"migrate:prompt-permissions:dry-run": "node config/migrate-prompt-permissions.js --dry-run",
|
||||||
|
"migrate:prompt-permissions": "node config/migrate-prompt-permissions.js",
|
||||||
|
"migrate:prompt-permissions:batch": "node config/migrate-prompt-permissions.js --batch-size=50"
|
||||||
},
|
},
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
|
|
|
@ -38,11 +38,17 @@ export const PERMISSION_BITS = {
|
||||||
/**
|
/**
|
||||||
* Standard access role IDs
|
* Standard access role IDs
|
||||||
*/
|
*/
|
||||||
export const ACCESS_ROLE_IDS = {
|
export enum ACCESS_ROLE_IDS {
|
||||||
AGENT_VIEWER: 'agent_viewer',
|
AGENT_VIEWER = 'agent_viewer',
|
||||||
AGENT_EDITOR: 'agent_editor',
|
AGENT_EDITOR = 'agent_editor',
|
||||||
AGENT_OWNER: 'agent_owner', // Future use
|
AGENT_OWNER = 'agent_owner', // Future use
|
||||||
} as const;
|
PROMPT_VIEWER = 'prompt_viewer',
|
||||||
|
PROMPT_EDITOR = 'prompt_editor',
|
||||||
|
PROMPT_OWNER = 'prompt_owner',
|
||||||
|
PROMPTGROUP_VIEWER = 'promptGroup_viewer',
|
||||||
|
PROMPTGROUP_EDITOR = 'promptGroup_editor',
|
||||||
|
PROMPTGROUP_OWNER = 'promptGroup_owner',
|
||||||
|
}
|
||||||
|
|
||||||
// ===== ZOD SCHEMAS =====
|
// ===== ZOD SCHEMAS =====
|
||||||
|
|
||||||
|
@ -58,7 +64,7 @@ export const principalSchema = z.object({
|
||||||
avatar: z.string().optional(), // for user and group types
|
avatar: z.string().optional(), // for user and group types
|
||||||
description: z.string().optional(), // for group type
|
description: z.string().optional(), // for group type
|
||||||
idOnTheSource: z.string().optional(), // Entra ID for users/groups
|
idOnTheSource: z.string().optional(), // Entra ID for users/groups
|
||||||
accessRoleId: z.string().optional(), // Access role ID for permissions
|
accessRoleId: z.nativeEnum(ACCESS_ROLE_IDS).optional(), // Access role ID for permissions
|
||||||
memberCount: z.number().optional(), // for group type
|
memberCount: z.number().optional(), // for group type
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -66,7 +72,7 @@ export const principalSchema = z.object({
|
||||||
* Access role schema - defines named permission sets
|
* Access role schema - defines named permission sets
|
||||||
*/
|
*/
|
||||||
export const accessRoleSchema = z.object({
|
export const accessRoleSchema = z.object({
|
||||||
accessRoleId: z.string(),
|
accessRoleId: z.nativeEnum(ACCESS_ROLE_IDS),
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
description: z.string().optional(),
|
description: z.string().optional(),
|
||||||
resourceType: z.string().default('agent'),
|
resourceType: z.string().default('agent'),
|
||||||
|
|
|
@ -148,6 +148,8 @@ export const config = () => '/api/config';
|
||||||
|
|
||||||
export const prompts = () => '/api/prompts';
|
export const prompts = () => '/api/prompts';
|
||||||
|
|
||||||
|
export const addPromptToGroup = (groupId: string) => `/api/prompts/groups/${groupId}/prompts`;
|
||||||
|
|
||||||
export const assistants = ({
|
export const assistants = ({
|
||||||
path = '',
|
path = '',
|
||||||
options,
|
options,
|
||||||
|
|
|
@ -727,6 +727,13 @@ export function createPrompt(payload: t.TCreatePrompt): Promise<t.TCreatePromptR
|
||||||
return request.post(endpoints.postPrompt(), payload);
|
return request.post(endpoints.postPrompt(), payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function addPromptToGroup(
|
||||||
|
groupId: string,
|
||||||
|
payload: t.TCreatePrompt,
|
||||||
|
): Promise<t.TCreatePromptResponse> {
|
||||||
|
return request.post(endpoints.addPromptToGroup(groupId), payload);
|
||||||
|
}
|
||||||
|
|
||||||
export function updatePromptGroup(
|
export function updatePromptGroup(
|
||||||
variables: t.TUpdatePromptGroupVariables,
|
variables: t.TUpdatePromptGroupVariables,
|
||||||
): Promise<t.TUpdatePromptGroupResponse> {
|
): Promise<t.TUpdatePromptGroupResponse> {
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
import type { InfiniteData } from '@tanstack/react-query';
|
import type { InfiniteData } from '@tanstack/react-query';
|
||||||
|
import type { ACCESS_ROLE_IDS } from '../accessPermissions';
|
||||||
import type * as a from '../types/agents';
|
import type * as a from '../types/agents';
|
||||||
import type * as s from '../schemas';
|
import type * as s from '../schemas';
|
||||||
import type * as t from '../types';
|
import type * as t from '../types';
|
||||||
|
@ -158,7 +159,7 @@ export type PrincipalSearchResponse = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AccessRole = {
|
export type AccessRole = {
|
||||||
accessRoleId: string;
|
accessRoleId: ACCESS_ROLE_IDS;
|
||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
permBits: number;
|
permBits: number;
|
||||||
|
|
|
@ -66,7 +66,6 @@
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
"meilisearch": "^0.38.0",
|
"meilisearch": "^0.38.0",
|
||||||
"mongoose": "^8.12.1",
|
"mongoose": "^8.12.1",
|
||||||
"mongoose": "^8.12.1",
|
|
||||||
"nanoid": "^3.3.7",
|
"nanoid": "^3.3.7",
|
||||||
"winston": "^3.17.0",
|
"winston": "^3.17.0",
|
||||||
"winston-daily-rotate-file": "^5.0.0"
|
"winston-daily-rotate-file": "^5.0.0"
|
||||||
|
|
|
@ -124,6 +124,50 @@ export function createAccessRoleMethods(mongoose: typeof import('mongoose')) {
|
||||||
resourceType: 'agent',
|
resourceType: 'agent',
|
||||||
permBits: RoleBits.OWNER,
|
permBits: RoleBits.OWNER,
|
||||||
},
|
},
|
||||||
|
// Prompt access roles
|
||||||
|
{
|
||||||
|
accessRoleId: 'prompt_viewer',
|
||||||
|
name: 'com_ui_role_viewer',
|
||||||
|
description: 'com_ui_role_viewer_desc',
|
||||||
|
resourceType: 'prompt',
|
||||||
|
permBits: RoleBits.VIEWER,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessRoleId: 'prompt_editor',
|
||||||
|
name: 'com_ui_role_editor',
|
||||||
|
description: 'com_ui_role_editor_desc',
|
||||||
|
resourceType: 'prompt',
|
||||||
|
permBits: RoleBits.EDITOR,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessRoleId: 'prompt_owner',
|
||||||
|
name: 'com_ui_role_owner',
|
||||||
|
description: 'com_ui_role_owner_desc',
|
||||||
|
resourceType: 'prompt',
|
||||||
|
permBits: RoleBits.OWNER,
|
||||||
|
},
|
||||||
|
// PromptGroup access roles
|
||||||
|
{
|
||||||
|
accessRoleId: 'promptGroup_viewer',
|
||||||
|
name: 'com_ui_role_viewer',
|
||||||
|
description: 'com_ui_role_viewer_desc',
|
||||||
|
resourceType: 'promptGroup',
|
||||||
|
permBits: RoleBits.VIEWER,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessRoleId: 'promptGroup_editor',
|
||||||
|
name: 'com_ui_role_editor',
|
||||||
|
description: 'com_ui_role_editor_desc',
|
||||||
|
resourceType: 'promptGroup',
|
||||||
|
permBits: RoleBits.EDITOR,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessRoleId: 'promptGroup_owner',
|
||||||
|
name: 'com_ui_role_owner',
|
||||||
|
description: 'com_ui_role_owner_desc',
|
||||||
|
resourceType: 'promptGroup',
|
||||||
|
permBits: RoleBits.OWNER,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const result: Record<string, IAccessRole> = {};
|
const result: Record<string, IAccessRole> = {};
|
||||||
|
|
|
@ -16,7 +16,7 @@ const accessRoleSchema = new Schema<IAccessRole>(
|
||||||
description: String,
|
description: String,
|
||||||
resourceType: {
|
resourceType: {
|
||||||
type: String,
|
type: String,
|
||||||
enum: ['agent', 'project', 'file'],
|
enum: ['agent', 'project', 'file', 'prompt', 'promptGroup'],
|
||||||
required: true,
|
required: true,
|
||||||
default: 'agent',
|
default: 'agent',
|
||||||
},
|
},
|
||||||
|
|
|
@ -25,7 +25,7 @@ const aclEntrySchema = new Schema<IAclEntry>(
|
||||||
},
|
},
|
||||||
resourceType: {
|
resourceType: {
|
||||||
type: String,
|
type: String,
|
||||||
enum: ['agent', 'project', 'file'],
|
enum: ['agent', 'project', 'file', 'prompt', 'promptGroup'],
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
resourceId: {
|
resourceId: {
|
||||||
|
|
|
@ -7,8 +7,8 @@ export type AclEntry = {
|
||||||
principalId?: Types.ObjectId;
|
principalId?: Types.ObjectId;
|
||||||
/** The model name for the principal ('User' or 'Group') */
|
/** The model name for the principal ('User' or 'Group') */
|
||||||
principalModel?: 'User' | 'Group';
|
principalModel?: 'User' | 'Group';
|
||||||
/** The type of resource ('agent', 'project', 'file') */
|
/** The type of resource ('agent', 'project', 'file', 'prompt', 'promptGroup') */
|
||||||
resourceType: 'agent' | 'project' | 'file';
|
resourceType: 'agent' | 'project' | 'file' | 'prompt' | 'promptGroup';
|
||||||
/** The ID of the resource */
|
/** The ID of the resource */
|
||||||
resourceId: Types.ObjectId;
|
resourceId: Types.ObjectId;
|
||||||
/** Permission bits for this entry */
|
/** Permission bits for this entry */
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue