Compare commits
18 Commits
feature/cl
...
feat/user-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c696b935b8 | ||
|
|
b1147b6409 | ||
|
|
4448c13684 | ||
|
|
2fd04b6d65 | ||
|
|
a8955e51a3 | ||
|
|
d2559ba977 | ||
|
|
b50406ab9e | ||
|
|
acafdb54c2 | ||
|
|
ca3237c7be | ||
|
|
d3764fd9fe | ||
|
|
24fc57fd01 | ||
|
|
e0ab2c666a | ||
|
|
e8702e104d | ||
|
|
dd762f7223 | ||
|
|
4166eac99d | ||
|
|
82a1f554b5 | ||
|
|
0cdccb617c | ||
|
|
39649ce523 |
6
api/models/Group.js
Normal file
6
api/models/Group.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
const mongoose = require('mongoose');
|
||||||
|
const { groupSchema } = require('@librechat/data-schemas');
|
||||||
|
|
||||||
|
const Group = mongoose.model('Group', groupSchema);
|
||||||
|
|
||||||
|
module.exports = Group;
|
||||||
127
api/models/groupMethods.js
Normal file
127
api/models/groupMethods.js
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
const User = require('./User');
|
||||||
|
const Group = require('~/models/Group');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieve a group by ID and convert the found group document to a plain object.
|
||||||
|
*
|
||||||
|
* @param {string} groupId - The ID of the group to find and return as a plain object.
|
||||||
|
* @param {string|string[]} [fieldsToSelect] - The fields to include or exclude in the returned document.
|
||||||
|
* @returns {Promise<Object|null>} A plain object representing the group document, or `null` if no group is found.
|
||||||
|
*/
|
||||||
|
const getGroupById = (groupId, fieldsToSelect = null) => {
|
||||||
|
const query = Group.findById(groupId);
|
||||||
|
if (fieldsToSelect) {
|
||||||
|
query.select(fieldsToSelect);
|
||||||
|
}
|
||||||
|
return query.lean();
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search for a single group or multiple groups based on partial data and return them as plain objects.
|
||||||
|
*
|
||||||
|
* @param {Partial<Object>} searchCriteria - The partial data to use for searching groups.
|
||||||
|
* @param {string|string[]} [fieldsToSelect] - The fields to include or exclude in the returned documents.
|
||||||
|
* @returns {Promise<Object[]>} An array of plain objects representing the group documents.
|
||||||
|
*/
|
||||||
|
const findGroup = (searchCriteria, fieldsToSelect = null) => {
|
||||||
|
const query = Group.find(searchCriteria);
|
||||||
|
if (fieldsToSelect) {
|
||||||
|
query.select(fieldsToSelect);
|
||||||
|
}
|
||||||
|
return query.lean();
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update a group with new data without overwriting existing properties.
|
||||||
|
*
|
||||||
|
* @param {string} groupId - The ID of the group to update.
|
||||||
|
* @param {Object} updateData - An object containing the properties to update.
|
||||||
|
* @returns {Promise<Object|null>} The updated group document as a plain object, or `null` if no group is found.
|
||||||
|
*/
|
||||||
|
const updateGroup = (groupId, updateData) => {
|
||||||
|
return Group.findByIdAndUpdate(
|
||||||
|
groupId,
|
||||||
|
{ $set: updateData },
|
||||||
|
{ new: true, runValidators: true },
|
||||||
|
).lean();
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new group.
|
||||||
|
*
|
||||||
|
* @param {Object} data - The group data to be created.
|
||||||
|
* @returns {Promise<Object>} The created group document.
|
||||||
|
*/
|
||||||
|
const createGroup = async (data) => {
|
||||||
|
return await Group.create(data);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Count the number of group documents in the collection based on the provided filter.
|
||||||
|
*
|
||||||
|
* @param {Object} [filter={}] - The filter to apply when counting the documents.
|
||||||
|
* @returns {Promise<number>} The count of documents that match the filter.
|
||||||
|
*/
|
||||||
|
const countGroups = (filter = {}) => {
|
||||||
|
return Group.countDocuments(filter);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a group by its unique ID only if no user is assigned to it.
|
||||||
|
*
|
||||||
|
* @param {string} groupId - The ID of the group to delete.
|
||||||
|
* @returns {Promise<{ deletedCount: number, message: string }>} An object indicating the number of deleted documents.
|
||||||
|
*/
|
||||||
|
const deleteGroupById = async (groupId) => {
|
||||||
|
// Check if any users reference the group
|
||||||
|
const userCount = await User.countDocuments({ groups: groupId });
|
||||||
|
if (userCount > 0) {
|
||||||
|
return { deletedCount: 0, message: `Cannot delete group; it is assigned to ${userCount} user(s).` };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await Group.deleteOne({ _id: groupId });
|
||||||
|
if (result.deletedCount === 0) {
|
||||||
|
return { deletedCount: 0, message: 'No group found with that ID.' };
|
||||||
|
}
|
||||||
|
return { deletedCount: result.deletedCount, message: 'Group was deleted successfully.' };
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error('Error deleting group: ' + error.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Override deletion of a group by its unique ID.
|
||||||
|
* This function first removes the group ObjectId from all users' groups arrays,
|
||||||
|
* then proceeds to delete the group document.
|
||||||
|
*
|
||||||
|
* @param {string} groupId - The ID of the group to delete.
|
||||||
|
* @returns {Promise<{ deletedCount: number, message: string }>} An object indicating the deletion result.
|
||||||
|
*/
|
||||||
|
const overrideDeleteGroupById = async (groupId) => {
|
||||||
|
// Remove group references from all users
|
||||||
|
await User.updateMany(
|
||||||
|
{ groups: groupId },
|
||||||
|
{ $pull: { groups: groupId } },
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await Group.deleteOne({ _id: groupId });
|
||||||
|
if (result.deletedCount === 0) {
|
||||||
|
return { deletedCount: 0, message: 'No group found with that ID.' };
|
||||||
|
}
|
||||||
|
return { deletedCount: result.deletedCount, message: 'Group was deleted successfully (override).' };
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error('Error deleting group: ' + error.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
getGroupById,
|
||||||
|
findGroup,
|
||||||
|
updateGroup,
|
||||||
|
createGroup,
|
||||||
|
countGroups,
|
||||||
|
deleteGroupById,
|
||||||
|
overrideDeleteGroupById,
|
||||||
|
};
|
||||||
@@ -40,6 +40,7 @@ const { getPreset, getPresets, savePreset, deletePresets } = require('./Preset')
|
|||||||
const { createToken, findToken, updateToken, deleteTokens } = require('./Token');
|
const { createToken, findToken, updateToken, deleteTokens } = require('./Token');
|
||||||
const Balance = require('./Balance');
|
const Balance = require('./Balance');
|
||||||
const User = require('./User');
|
const User = require('./User');
|
||||||
|
const Group = require('./Group');
|
||||||
const Key = require('./Key');
|
const Key = require('./Key');
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
@@ -92,6 +93,7 @@ module.exports = {
|
|||||||
countActiveSessions,
|
countActiveSessions,
|
||||||
|
|
||||||
User,
|
User,
|
||||||
|
Group,
|
||||||
Key,
|
Key,
|
||||||
Balance,
|
Balance,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ const { HttpsProxyAgent } = require('https-proxy-agent');
|
|||||||
const { Issuer, Strategy: OpenIDStrategy, custom } = require('openid-client');
|
const { Issuer, Strategy: OpenIDStrategy, custom } = require('openid-client');
|
||||||
const { getStrategyFunctions } = require('~/server/services/Files/strategies');
|
const { getStrategyFunctions } = require('~/server/services/Files/strategies');
|
||||||
const { findUser, createUser, updateUser } = require('~/models/userMethods');
|
const { findUser, createUser, updateUser } = require('~/models/userMethods');
|
||||||
|
const { findGroup } = require('~/models/groupMethods');
|
||||||
const { hashToken } = require('~/server/utils/crypto');
|
const { hashToken } = require('~/server/utils/crypto');
|
||||||
const { isEnabled } = require('~/server/utils');
|
const { isEnabled } = require('~/server/utils');
|
||||||
const { logger } = require('~/config');
|
const { logger } = require('~/config');
|
||||||
@@ -105,6 +106,71 @@ function convertToUsername(input, defaultValue = '') {
|
|||||||
return defaultValue;
|
return defaultValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts roles from the specified token using configuration from environment variables.
|
||||||
|
* @param {object} tokenset - The token set returned by the OpenID provider.
|
||||||
|
* @returns {Array} The roles extracted from the token.
|
||||||
|
*/
|
||||||
|
function extractRoles(tokenset) {
|
||||||
|
const requiredRoleTokenKind = process.env.OPENID_REQUIRED_ROLE_TOKEN_KIND;
|
||||||
|
const requiredRoleParameterPath = process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH;
|
||||||
|
const token =
|
||||||
|
requiredRoleTokenKind === 'access'
|
||||||
|
? jwtDecode(tokenset.access_token)
|
||||||
|
: jwtDecode(tokenset.id_token);
|
||||||
|
const pathParts = requiredRoleParameterPath.split('.');
|
||||||
|
let found = true;
|
||||||
|
const roles = pathParts.reduce((acc, key) => {
|
||||||
|
if (!acc || !(key in acc)) {
|
||||||
|
found = false;
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return acc[key];
|
||||||
|
}, token);
|
||||||
|
if (!found) {
|
||||||
|
logger.error(
|
||||||
|
`[openidStrategy] Key '${requiredRoleParameterPath}' not found in ${requiredRoleTokenKind} token!`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return roles;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates the user's groups based on the provided roles.
|
||||||
|
* It removes any existing OpenID group references and then adds the groups
|
||||||
|
* that match the roles from the external group collection.
|
||||||
|
*
|
||||||
|
* @param {object} user - The user object.
|
||||||
|
* @param {Array} roles - The roles extracted from the token.
|
||||||
|
* @returns {Promise<Array>} The updated groups array.
|
||||||
|
*/
|
||||||
|
async function updateUserGroups(user, roles) {
|
||||||
|
user.groups = user.groups || [];
|
||||||
|
// Remove existing OpenID group references.
|
||||||
|
const currentOpenIdGroups = await findGroup({
|
||||||
|
_id: { $in: user.groups },
|
||||||
|
provider: 'openid',
|
||||||
|
});
|
||||||
|
const currentOpenIdGroupIds = new Set(
|
||||||
|
currentOpenIdGroups.map((g) => g._id.toString()),
|
||||||
|
);
|
||||||
|
user.groups = user.groups.filter(
|
||||||
|
(id) => !currentOpenIdGroupIds.has(id.toString()),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Look up groups matching the roles.
|
||||||
|
const matchingGroups = await findGroup({
|
||||||
|
provider: 'openid',
|
||||||
|
externalId: { $in: roles },
|
||||||
|
});
|
||||||
|
matchingGroups.forEach((group) => {
|
||||||
|
if (!user.groups.some((id) => id.toString() === group._id.toString())) {
|
||||||
|
user.groups.push(group._id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return user.groups;
|
||||||
|
}
|
||||||
|
|
||||||
async function setupOpenId() {
|
async function setupOpenId() {
|
||||||
try {
|
try {
|
||||||
if (process.env.PROXY) {
|
if (process.env.PROXY) {
|
||||||
@@ -134,8 +200,6 @@ async function setupOpenId() {
|
|||||||
}
|
}
|
||||||
const client = new issuer.Client(clientMetadata);
|
const client = new issuer.Client(clientMetadata);
|
||||||
const requiredRole = process.env.OPENID_REQUIRED_ROLE;
|
const requiredRole = process.env.OPENID_REQUIRED_ROLE;
|
||||||
const requiredRoleParameterPath = process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH;
|
|
||||||
const requiredRoleTokenKind = process.env.OPENID_REQUIRED_ROLE_TOKEN_KIND;
|
|
||||||
const openidLogin = new OpenIDStrategy(
|
const openidLogin = new OpenIDStrategy(
|
||||||
{
|
{
|
||||||
client,
|
client,
|
||||||
@@ -145,8 +209,13 @@ async function setupOpenId() {
|
|||||||
},
|
},
|
||||||
async (tokenset, userinfo, done) => {
|
async (tokenset, userinfo, done) => {
|
||||||
try {
|
try {
|
||||||
logger.info(`[openidStrategy] verify login openidId: ${userinfo.sub}`);
|
logger.info(
|
||||||
logger.debug('[openidStrategy] very login tokenset and userinfo', { tokenset, userinfo });
|
`[openidStrategy] verify login openidId: ${userinfo.sub}`,
|
||||||
|
);
|
||||||
|
logger.debug('[openidStrategy] verify login tokenset and userinfo', {
|
||||||
|
tokenset,
|
||||||
|
userinfo,
|
||||||
|
});
|
||||||
|
|
||||||
let user = await findUser({ openidId: userinfo.sub });
|
let user = await findUser({ openidId: userinfo.sub });
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -164,29 +233,10 @@ async function setupOpenId() {
|
|||||||
|
|
||||||
const fullName = getFullName(userinfo);
|
const fullName = getFullName(userinfo);
|
||||||
|
|
||||||
|
// Check for the required role using extracted roles.
|
||||||
|
let roles = [];
|
||||||
if (requiredRole) {
|
if (requiredRole) {
|
||||||
let decodedToken = '';
|
roles = extractRoles(tokenset);
|
||||||
if (requiredRoleTokenKind === 'access') {
|
|
||||||
decodedToken = jwtDecode(tokenset.access_token);
|
|
||||||
} else if (requiredRoleTokenKind === 'id') {
|
|
||||||
decodedToken = jwtDecode(tokenset.id_token);
|
|
||||||
}
|
|
||||||
const pathParts = requiredRoleParameterPath.split('.');
|
|
||||||
let found = true;
|
|
||||||
let roles = pathParts.reduce((o, key) => {
|
|
||||||
if (o === null || o === undefined || !(key in o)) {
|
|
||||||
found = false;
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
return o[key];
|
|
||||||
}, decodedToken);
|
|
||||||
|
|
||||||
if (!found) {
|
|
||||||
logger.error(
|
|
||||||
`[openidStrategy] Key '${requiredRoleParameterPath}' not found in ${requiredRoleTokenKind} token!`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!roles.includes(requiredRole)) {
|
if (!roles.includes(requiredRole)) {
|
||||||
return done(null, false, {
|
return done(null, false, {
|
||||||
message: `You must have the "${requiredRole}" role to log in.`,
|
message: `You must have the "${requiredRole}" role to log in.`,
|
||||||
@@ -243,6 +293,10 @@ async function setupOpenId() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (requiredRole) {
|
||||||
|
await updateUserGroups(user, roles);
|
||||||
|
}
|
||||||
|
|
||||||
user = await updateUser(user._id, user);
|
user = await updateUser(user._id, user);
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ jest.mock('~/models/userMethods', () => ({
|
|||||||
createUser: jest.fn(),
|
createUser: jest.fn(),
|
||||||
updateUser: jest.fn(),
|
updateUser: jest.fn(),
|
||||||
}));
|
}));
|
||||||
|
jest.mock('~/models/groupMethods', () => ({
|
||||||
|
findGroup: jest.fn().mockResolvedValue([]),
|
||||||
|
}));
|
||||||
jest.mock('~/server/utils/crypto', () => ({
|
jest.mock('~/server/utils/crypto', () => ({
|
||||||
hashToken: jest.fn().mockResolvedValue('hashed-token'),
|
hashToken: jest.fn().mockResolvedValue('hashed-token'),
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -76,6 +76,19 @@ const MenuItem: FC<MenuItemProps> = ({
|
|||||||
<div>
|
<div>
|
||||||
{title}
|
{title}
|
||||||
<div className="text-text-secondary">{description}</div>
|
<div className="text-text-secondary">{description}</div>
|
||||||
|
{spec.badges && spec.badges.length > 0 && (
|
||||||
|
<div className="mt-1 flex gap-2">
|
||||||
|
{spec.badges.map((badge, index) => (
|
||||||
|
<span
|
||||||
|
key={index}
|
||||||
|
className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-semibold shadow-sm"
|
||||||
|
style={{ backgroundColor: badge.color, color: '#fff' }}
|
||||||
|
>
|
||||||
|
{badge.text}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -19,9 +19,22 @@ export default function ModelSpecsMenu({ modelSpecs }: { modelSpecs?: TModelSpec
|
|||||||
const localize = useLocalize();
|
const localize = useLocalize();
|
||||||
const { data: endpointsConfig = {} as TEndpointsConfig } = useGetEndpointsQuery();
|
const { data: endpointsConfig = {} as TEndpointsConfig } = useGetEndpointsQuery();
|
||||||
const modularChat = useRecoilValue(store.modularChat);
|
const modularChat = useRecoilValue(store.modularChat);
|
||||||
|
const user = useRecoilValue(store.user);
|
||||||
const getDefaultConversation = useDefaultConvo();
|
const getDefaultConversation = useDefaultConvo();
|
||||||
const assistantMap = useAssistantsMapContext();
|
const assistantMap = useAssistantsMapContext();
|
||||||
|
|
||||||
|
const allowedModelSpecs = useMemo(() => {
|
||||||
|
if (!modelSpecs) {return [];}
|
||||||
|
return modelSpecs.filter(spec => {
|
||||||
|
// If no groups defined for spec, allow it.
|
||||||
|
if (!spec.groups || spec.groups.length === 0) {return true;}
|
||||||
|
// Otherwise, check if the user exists and has groups.
|
||||||
|
if (!user || !user.groups || user.groups.length === 0) {return false;}
|
||||||
|
// Check if at least one of the spec's groups is in the user's groups.
|
||||||
|
return spec.groups.some(groupId => user.groups.includes(groupId));
|
||||||
|
});
|
||||||
|
}, [modelSpecs, user]);
|
||||||
|
|
||||||
const onSelectSpec = (spec: TModelSpec) => {
|
const onSelectSpec = (spec: TModelSpec) => {
|
||||||
const { preset } = spec;
|
const { preset } = spec;
|
||||||
preset.iconURL = getModelSpecIconURL(spec);
|
preset.iconURL = getModelSpecIconURL(spec);
|
||||||
@@ -82,21 +95,15 @@ export default function ModelSpecsMenu({ modelSpecs }: { modelSpecs?: TModelSpec
|
|||||||
};
|
};
|
||||||
|
|
||||||
const selected = useMemo(() => {
|
const selected = useMemo(() => {
|
||||||
const spec = modelSpecs?.find((spec) => spec.name === conversation?.spec);
|
const spec = allowedModelSpecs.find((spec) => spec.name === conversation?.spec);
|
||||||
if (!spec) {
|
return spec || undefined;
|
||||||
return undefined;
|
}, [allowedModelSpecs, conversation?.spec]);
|
||||||
}
|
|
||||||
return spec;
|
|
||||||
}, [modelSpecs, conversation?.spec]);
|
|
||||||
|
|
||||||
const menuRef = useRef<HTMLDivElement>(null);
|
const menuRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const handleKeyDown = useCallback((event: KeyboardEvent) => {
|
const handleKeyDown = useCallback((event: KeyboardEvent) => {
|
||||||
const menuItems = menuRef.current?.querySelectorAll('[role="option"]');
|
const menuItems = menuRef.current?.querySelectorAll('[role="option"]');
|
||||||
if (!menuItems) {
|
if (!menuItems || !menuItems.length) {
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!menuItems.length) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,7 +139,7 @@ export default function ModelSpecsMenu({ modelSpecs }: { modelSpecs?: TModelSpec
|
|||||||
endpointsConfig={endpointsConfig}
|
endpointsConfig={endpointsConfig}
|
||||||
/>
|
/>
|
||||||
<Portal>
|
<Portal>
|
||||||
{modelSpecs && modelSpecs.length && (
|
{allowedModelSpecs && allowedModelSpecs.length > 0 && (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
position: 'fixed',
|
position: 'fixed',
|
||||||
@@ -154,7 +161,7 @@ export default function ModelSpecsMenu({ modelSpecs }: { modelSpecs?: TModelSpec
|
|||||||
className="models-scrollbar mt-2 max-h-[65vh] min-w-[340px] max-w-xs overflow-y-auto rounded-lg border border-gray-100 bg-white shadow-lg dark:border-gray-700 dark:bg-gray-700 dark:text-white lg:max-h-[75vh]"
|
className="models-scrollbar mt-2 max-h-[65vh] min-w-[340px] max-w-xs overflow-y-auto rounded-lg border border-gray-100 bg-white shadow-lg dark:border-gray-700 dark:bg-gray-700 dark:text-white lg:max-h-[75vh]"
|
||||||
>
|
>
|
||||||
<ModelSpecs
|
<ModelSpecs
|
||||||
specs={modelSpecs}
|
specs={allowedModelSpecs}
|
||||||
selected={selected}
|
selected={selected}
|
||||||
setSelected={onSelectSpec}
|
setSelected={onSelectSpec}
|
||||||
endpointsConfig={endpointsConfig}
|
endpointsConfig={endpointsConfig}
|
||||||
|
|||||||
69
config/assign-group.js
Normal file
69
config/assign-group.js
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
const path = require('path');
|
||||||
|
require('module-alias')({ base: path.resolve(__dirname, '..', 'api') });
|
||||||
|
const { askQuestion, silentExit } = require('./helpers');
|
||||||
|
const User = require('~/models/User');
|
||||||
|
const Group = require('~/models/Group');
|
||||||
|
const connect = require('./connect');
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
await connect();
|
||||||
|
|
||||||
|
console.purple('---------------------------------------');
|
||||||
|
console.purple('Assign a Group to a User');
|
||||||
|
console.purple('---------------------------------------');
|
||||||
|
|
||||||
|
// Read arguments from CLI or prompt the user
|
||||||
|
const userEmail = process.argv[2] || (await askQuestion('User email: '));
|
||||||
|
const groupName = process.argv[3] || (await askQuestion('Group name to assign: '));
|
||||||
|
|
||||||
|
// Validate email format
|
||||||
|
if (!userEmail.includes('@')) {
|
||||||
|
console.red('Error: Invalid email address!');
|
||||||
|
silentExit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the group by name
|
||||||
|
const group = await Group.findOne({ name: groupName });
|
||||||
|
if (!group) {
|
||||||
|
console.red('Error: No group with that name was found!');
|
||||||
|
silentExit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the user by email
|
||||||
|
const user = await User.findOne({ email: userEmail });
|
||||||
|
if (!user) {
|
||||||
|
console.red('Error: No user with that email was found!');
|
||||||
|
silentExit(1);
|
||||||
|
}
|
||||||
|
console.purple(`Found user: ${user.email}`);
|
||||||
|
|
||||||
|
// Assign the group to the user if not already assigned
|
||||||
|
try {
|
||||||
|
if (!Array.isArray(user.groups)) {
|
||||||
|
user.groups = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert both user group IDs and the target group ID to strings for comparison
|
||||||
|
const groupIdStr = group._id.toString();
|
||||||
|
const userGroupIds = user.groups.map(id => id.toString());
|
||||||
|
|
||||||
|
if (!userGroupIds.includes(groupIdStr)) {
|
||||||
|
user.groups.push(group._id);
|
||||||
|
await user.save();
|
||||||
|
console.green(`User ${user.email} successfully assigned to group ${group.name}!`);
|
||||||
|
} else {
|
||||||
|
console.yellow(`User ${user.email} is already assigned to group ${group.name}.`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.red('Error assigning group to user: ' + error.message);
|
||||||
|
silentExit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
silentExit(0);
|
||||||
|
})();
|
||||||
|
|
||||||
|
process.on('uncaughtException', (err) => {
|
||||||
|
console.error('There was an uncaught error:');
|
||||||
|
console.error(err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
62
config/create-group.js
Normal file
62
config/create-group.js
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
const path = require('path');
|
||||||
|
require('module-alias')({ base: path.resolve(__dirname, '..', 'api') });
|
||||||
|
const { askQuestion, silentExit } = require('./helpers');
|
||||||
|
const Group = require('~/models/Group');
|
||||||
|
const connect = require('./connect');
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
await connect();
|
||||||
|
|
||||||
|
console.purple('---------------------------------------');
|
||||||
|
console.purple('Create a New Group');
|
||||||
|
console.purple('---------------------------------------');
|
||||||
|
|
||||||
|
// Prompt for basic group info.
|
||||||
|
const groupName = process.argv[2] || (await askQuestion('Group name: '));
|
||||||
|
const groupDescription =
|
||||||
|
process.argv[3] || (await askQuestion('Group description (optional): '));
|
||||||
|
|
||||||
|
// Ask for the group type (local or openid; defaults to local)
|
||||||
|
let groupType =
|
||||||
|
process.argv[4] ||
|
||||||
|
(await askQuestion('Group type (local/openid, default is local): '));
|
||||||
|
groupType = groupType.trim().toLowerCase() || 'local';
|
||||||
|
|
||||||
|
let groupData;
|
||||||
|
if (groupType === 'openid') {
|
||||||
|
// For OpenID groups, prompt for an external ID.
|
||||||
|
const externalId =
|
||||||
|
process.argv[5] ||
|
||||||
|
(await askQuestion('External ID for OpenID group: '));
|
||||||
|
groupData = {
|
||||||
|
name: groupName,
|
||||||
|
description: groupDescription,
|
||||||
|
provider: 'openid',
|
||||||
|
externalId: externalId.trim(),
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
// For local groups, we only need name and description.
|
||||||
|
groupData = {
|
||||||
|
name: groupName,
|
||||||
|
description: groupDescription,
|
||||||
|
provider: 'local',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the group document
|
||||||
|
let group;
|
||||||
|
try {
|
||||||
|
group = await Group.create(groupData);
|
||||||
|
} catch (error) {
|
||||||
|
console.red('Error creating group: ' + error.message);
|
||||||
|
silentExit(1);
|
||||||
|
}
|
||||||
|
console.green(`Group created successfully with id: ${group._id}`);
|
||||||
|
silentExit(0);
|
||||||
|
})();
|
||||||
|
|
||||||
|
process.on('uncaughtException', (err) => {
|
||||||
|
console.error('There was an uncaught error:');
|
||||||
|
console.error(err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -104,6 +104,31 @@ registration:
|
|||||||
# userMax: 50
|
# userMax: 50
|
||||||
# userWindowInMinutes: 60 # Rate limit window for conversation imports per user
|
# userWindowInMinutes: 60 # Rate limit window for conversation imports per user
|
||||||
|
|
||||||
|
|
||||||
|
# Example Model Specifications
|
||||||
|
#modelSpecs:
|
||||||
|
# enforce: true
|
||||||
|
# prioritize: true
|
||||||
|
# list:
|
||||||
|
# - name: "4o-mini"
|
||||||
|
# label: "4o-mini"
|
||||||
|
# groups:
|
||||||
|
# - "67b9a5c64165f31925e9b25a"
|
||||||
|
# - "67b9a5c64165f31925e9b25f"
|
||||||
|
# description: "The most advanced frontier model from Azure OpenAI, suitable to solve complex multi-step problems."
|
||||||
|
# iconURL: "https://www.librechat.ai/librechat.png"
|
||||||
|
# badges:
|
||||||
|
# - text: "Test"
|
||||||
|
# color: "#FF0000"
|
||||||
|
# - text: "Beta"
|
||||||
|
# color: "#00FF00"
|
||||||
|
# preset:
|
||||||
|
# default: true
|
||||||
|
# endpoint: "azureOpenAI"
|
||||||
|
# model: "gpt-4o-mini"
|
||||||
|
# modelLabel: "4o-mini"
|
||||||
|
|
||||||
|
|
||||||
# Example Actions Object Structure
|
# Example Actions Object Structure
|
||||||
actions:
|
actions:
|
||||||
allowedDomains:
|
allowedDomains:
|
||||||
|
|||||||
@@ -32,6 +32,8 @@
|
|||||||
"reset-password": "node config/reset-password.js",
|
"reset-password": "node config/reset-password.js",
|
||||||
"ban-user": "node config/ban-user.js",
|
"ban-user": "node config/ban-user.js",
|
||||||
"delete-user": "node config/delete-user.js",
|
"delete-user": "node config/delete-user.js",
|
||||||
|
"create-group": "node config/create-group.js",
|
||||||
|
"assign-group": "node config/assign-group.js",
|
||||||
"update-banner": "node config/update-banner.js",
|
"update-banner": "node config/update-banner.js",
|
||||||
"delete-banner": "node config/delete-banner.js",
|
"delete-banner": "node config/delete-banner.js",
|
||||||
"backend": "cross-env NODE_ENV=production node api/server/index.js",
|
"backend": "cross-env NODE_ENV=production node api/server/index.js",
|
||||||
|
|||||||
@@ -8,6 +8,11 @@ import {
|
|||||||
authTypeSchema,
|
authTypeSchema,
|
||||||
} from './schemas';
|
} from './schemas';
|
||||||
|
|
||||||
|
export type TBadge = {
|
||||||
|
text: string;
|
||||||
|
color: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type TModelSpec = {
|
export type TModelSpec = {
|
||||||
name: string;
|
name: string;
|
||||||
label: string;
|
label: string;
|
||||||
@@ -19,8 +24,15 @@ export type TModelSpec = {
|
|||||||
showIconInHeader?: boolean;
|
showIconInHeader?: boolean;
|
||||||
iconURL?: string | EModelEndpoint; // Allow using project-included icons
|
iconURL?: string | EModelEndpoint; // Allow using project-included icons
|
||||||
authType?: AuthType;
|
authType?: AuthType;
|
||||||
|
groups?: string[];
|
||||||
|
badges?: TBadge[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const tBadgeSchema = z.object({
|
||||||
|
text: z.string(),
|
||||||
|
color: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
export const tModelSpecSchema = z.object({
|
export const tModelSpecSchema = z.object({
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
label: z.string(),
|
label: z.string(),
|
||||||
@@ -32,6 +44,8 @@ export const tModelSpecSchema = z.object({
|
|||||||
showIconInHeader: z.boolean().optional(),
|
showIconInHeader: z.boolean().optional(),
|
||||||
iconURL: z.union([z.string(), eModelEndpointSchema]).optional(),
|
iconURL: z.union([z.string(), eModelEndpointSchema]).optional(),
|
||||||
authType: authTypeSchema.optional(),
|
authType: authTypeSchema.optional(),
|
||||||
|
groups: z.array(z.string()).optional(),
|
||||||
|
badges: z.array(tBadgeSchema).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const specsConfigSchema = z.object({
|
export const specsConfigSchema = z.object({
|
||||||
|
|||||||
@@ -106,6 +106,16 @@ export type TBackupCode = {
|
|||||||
usedAt: Date | null;
|
usedAt: Date | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type TGroup = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
externalId?: string;
|
||||||
|
provider: 'local' | 'openid';
|
||||||
|
createdAt?: string;
|
||||||
|
updatedAt?: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type TUser = {
|
export type TUser = {
|
||||||
id: string;
|
id: string;
|
||||||
username: string;
|
username: string;
|
||||||
@@ -117,6 +127,7 @@ export type TUser = {
|
|||||||
plugins?: string[];
|
plugins?: string[];
|
||||||
twoFactorEnabled?: boolean;
|
twoFactorEnabled?: boolean;
|
||||||
backupCodes?: TBackupCode[];
|
backupCodes?: TBackupCode[];
|
||||||
|
groups: string[];
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import categoriesSchema from './schema/categories';
|
|||||||
import conversationTagSchema from './schema/conversationTag';
|
import conversationTagSchema from './schema/conversationTag';
|
||||||
import convoSchema from './schema/convo';
|
import convoSchema from './schema/convo';
|
||||||
import fileSchema from './schema/file';
|
import fileSchema from './schema/file';
|
||||||
|
import groupSchema from './schema/group';
|
||||||
import keySchema from './schema/key';
|
import keySchema from './schema/key';
|
||||||
import messageSchema from './schema/message';
|
import messageSchema from './schema/message';
|
||||||
import pluginAuthSchema from './schema/pluginAuth';
|
import pluginAuthSchema from './schema/pluginAuth';
|
||||||
@@ -32,6 +33,7 @@ export {
|
|||||||
conversationTagSchema,
|
conversationTagSchema,
|
||||||
convoSchema,
|
convoSchema,
|
||||||
fileSchema,
|
fileSchema,
|
||||||
|
groupSchema,
|
||||||
keySchema,
|
keySchema,
|
||||||
messageSchema,
|
messageSchema,
|
||||||
pluginAuthSchema,
|
pluginAuthSchema,
|
||||||
|
|||||||
39
packages/data-schemas/src/schema/group.ts
Normal file
39
packages/data-schemas/src/schema/group.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { Schema, Document } from 'mongoose';
|
||||||
|
|
||||||
|
export interface IGroup extends Document {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
externalId?: string;
|
||||||
|
provider: 'local' | 'openid';
|
||||||
|
createdAt?: Date;
|
||||||
|
updatedAt?: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
const groupSchema = new Schema<IGroup>(
|
||||||
|
{
|
||||||
|
name: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
unique: true,
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
type: String,
|
||||||
|
},
|
||||||
|
externalId: {
|
||||||
|
type: String,
|
||||||
|
unique: true,
|
||||||
|
required: function (this: IGroup) {
|
||||||
|
return this.provider !== 'local';
|
||||||
|
},
|
||||||
|
},
|
||||||
|
provider: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
default: 'local',
|
||||||
|
enum: ['local', 'openid'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ timestamps: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
export default groupSchema;
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Schema, Document } from 'mongoose';
|
import { Schema, Document, Types } from 'mongoose';
|
||||||
import { SystemRoles } from 'librechat-data-provider';
|
import { SystemRoles } from 'librechat-data-provider';
|
||||||
|
|
||||||
export interface IUser extends Document {
|
export interface IUser extends Document {
|
||||||
@@ -18,6 +18,7 @@ export interface IUser extends Document {
|
|||||||
discordId?: string;
|
discordId?: string;
|
||||||
appleId?: string;
|
appleId?: string;
|
||||||
plugins?: unknown[];
|
plugins?: unknown[];
|
||||||
|
groups?: Types.ObjectId[];
|
||||||
twoFactorEnabled?: boolean;
|
twoFactorEnabled?: boolean;
|
||||||
totpSecret?: string;
|
totpSecret?: string;
|
||||||
backupCodes?: Array<{
|
backupCodes?: Array<{
|
||||||
@@ -135,6 +136,11 @@ const User = new Schema<IUser>(
|
|||||||
plugins: {
|
plugins: {
|
||||||
type: Array,
|
type: Array,
|
||||||
},
|
},
|
||||||
|
groups: {
|
||||||
|
type: [Schema.Types.ObjectId],
|
||||||
|
ref: 'Group',
|
||||||
|
default: [],
|
||||||
|
},
|
||||||
twoFactorEnabled: {
|
twoFactorEnabled: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false,
|
default: false,
|
||||||
|
|||||||
Reference in New Issue
Block a user