mirror of
https://github.com/wekan/wekan.git
synced 2026-01-24 02:06:10 +01:00
Fixed Add member and @mentions.
Thanks to xet7 ! Fixes #6076, fixes #6077
This commit is contained in:
parent
603a65ef17
commit
ad511bd137
14 changed files with 413 additions and 149 deletions
|
|
@ -200,6 +200,7 @@ if (Meteor.isServer) {
|
|||
if (activity.commentId) {
|
||||
const comment = activity.comment();
|
||||
params.comment = comment.text;
|
||||
let hasMentions = false; // Track if comment has @mentions
|
||||
if (board) {
|
||||
const comment = params.comment;
|
||||
const knownUsers = board.members.map((member) => {
|
||||
|
|
@ -210,7 +211,9 @@ if (Meteor.isServer) {
|
|||
}
|
||||
return member;
|
||||
});
|
||||
const mentionRegex = /\B@(?:(?:"([\w.\s-]*)")|([\w.-]+))/gi; // including space in username
|
||||
// Match @mentions including usernames with @ symbols (like email addresses)
|
||||
// Pattern matches: @username, @user@example.com, @"quoted username"
|
||||
const mentionRegex = /\B@(?:(?:"([\w.\s-]*)")|([\w.@-]+))/gi;
|
||||
let currentMention;
|
||||
|
||||
while ((currentMention = mentionRegex.exec(comment)) !== null) {
|
||||
|
|
@ -222,14 +225,59 @@ if (Meteor.isServer) {
|
|||
|
||||
if (activity.boardId && username === 'board_members') {
|
||||
// mentions all board members
|
||||
const knownUids = knownUsers.map((u) => u.userId);
|
||||
watchers = _.union(watchers, [...knownUids]);
|
||||
const validUserIds = knownUsers
|
||||
.map((u) => u.userId)
|
||||
.filter((userId) => {
|
||||
const user = ReactiveCache.getUser(userId);
|
||||
return user && user._id;
|
||||
});
|
||||
watchers = _.union(watchers, validUserIds);
|
||||
title = 'act-atUserComment';
|
||||
hasMentions = true;
|
||||
} else if (activity.boardId && username === 'board_assignees') {
|
||||
// mentions all assignees of all cards on the board
|
||||
const allCards = ReactiveCache.getCards({ boardId: activity.boardId });
|
||||
const assigneeIds = [];
|
||||
allCards.forEach((card) => {
|
||||
if (card.assignees && card.assignees.length > 0) {
|
||||
card.assignees.forEach((assigneeId) => {
|
||||
// Only add if the user exists and is a board member
|
||||
const user = ReactiveCache.getUser(assigneeId);
|
||||
if (user && _.findWhere(knownUsers, { userId: assigneeId })) {
|
||||
assigneeIds.push(assigneeId);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
watchers = _.union(watchers, assigneeIds);
|
||||
title = 'act-atUserComment';
|
||||
hasMentions = true;
|
||||
} else if (activity.cardId && username === 'card_members') {
|
||||
// mentions all card members if assigned
|
||||
const card = activity.card();
|
||||
watchers = _.union(watchers, [...card.members]);
|
||||
if (card && card.members && card.members.length > 0) {
|
||||
// Filter to only valid users who are board members
|
||||
const validMembers = card.members.filter((memberId) => {
|
||||
const user = ReactiveCache.getUser(memberId);
|
||||
return user && user._id && _.findWhere(knownUsers, { userId: memberId });
|
||||
});
|
||||
watchers = _.union(watchers, validMembers);
|
||||
}
|
||||
title = 'act-atUserComment';
|
||||
hasMentions = true;
|
||||
} else if (activity.cardId && username === 'card_assignees') {
|
||||
// mentions all assignees of the current card
|
||||
const card = activity.card();
|
||||
if (card && card.assignees && card.assignees.length > 0) {
|
||||
// Filter to only valid users who are board members
|
||||
const validAssignees = card.assignees.filter((assigneeId) => {
|
||||
const user = ReactiveCache.getUser(assigneeId);
|
||||
return user && user._id && _.findWhere(knownUsers, { userId: assigneeId });
|
||||
});
|
||||
watchers = _.union(watchers, validAssignees);
|
||||
}
|
||||
title = 'act-atUserComment';
|
||||
hasMentions = true;
|
||||
} else {
|
||||
const atUser = _.findWhere(knownUsers, { username });
|
||||
if (!atUser) {
|
||||
|
|
@ -241,10 +289,12 @@ if (Meteor.isServer) {
|
|||
params.atEmails = atUser.emails;
|
||||
title = 'act-atUserComment';
|
||||
watchers = _.union(watchers, [uid]);
|
||||
hasMentions = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
params.commentId = comment._id;
|
||||
params.hasMentions = hasMentions; // Store for later use
|
||||
}
|
||||
if (activity.attachmentId) {
|
||||
params.attachment = activity.attachmentName;
|
||||
|
|
@ -327,13 +377,20 @@ if (Meteor.isServer) {
|
|||
_.where(board.watchers, { level: 'tracking' }),
|
||||
'userId',
|
||||
);
|
||||
watchers = _.union(
|
||||
watchers,
|
||||
watchingUsers,
|
||||
_.intersection(participants, trackingUsers),
|
||||
);
|
||||
// Only add board watchers if there were no @mentions in the comment
|
||||
// When users are explicitly @mentioned, only notify those users
|
||||
if (!params.hasMentions) {
|
||||
watchers = _.union(
|
||||
watchers,
|
||||
watchingUsers,
|
||||
_.intersection(participants, trackingUsers),
|
||||
);
|
||||
}
|
||||
}
|
||||
Notifications.getUsers(watchers).forEach((user) => {
|
||||
// Skip if user is undefined or doesn't have an _id (e.g., deleted user or invalid ID)
|
||||
if (!user || !user._id) return;
|
||||
|
||||
// Don't notify a user of their own behavior, EXCEPT for self-mentions
|
||||
const isSelfMention = (user._id === userId && title === 'act-atUserComment');
|
||||
if (user._id !== userId || isSelfMention) {
|
||||
|
|
|
|||
|
|
@ -932,8 +932,41 @@ Boards.helpers({
|
|||
},
|
||||
|
||||
activeMembers(){
|
||||
const members = _.where(this.members, { isActive: true });
|
||||
return _.sortBy(members, 'username');
|
||||
// Depend on the users collection for reactivity when users are loaded
|
||||
const memberUserIds = _.pluck(this.members, 'userId');
|
||||
const dummy = Meteor.users.find({ _id: { $in: memberUserIds } }).count();
|
||||
const members = _.filter(this.members, m => m.isActive === true);
|
||||
// Group by userId to handle duplicates
|
||||
const grouped = _.groupBy(members, 'userId');
|
||||
const uniqueMembers = _.values(grouped).map(group => {
|
||||
// Prefer admin member if exists, otherwise take the first
|
||||
const selected = _.find(group, m => m.isAdmin) || group[0];
|
||||
return selected;
|
||||
});
|
||||
// Filter out members where user is not loaded
|
||||
const filteredMembers = uniqueMembers.filter(member => {
|
||||
const user = ReactiveCache.getUser(member.userId);
|
||||
return user !== undefined;
|
||||
});
|
||||
|
||||
// Sort by role priority first (admin, normal, normal-assigned, no-comments, comment-only, comment-assigned, worker, read-only, read-assigned), then by fullname
|
||||
return _.sortBy(filteredMembers, member => {
|
||||
const user = ReactiveCache.getUser(member.userId);
|
||||
let rolePriority = 8; // Default for normal
|
||||
|
||||
if (member.isAdmin) rolePriority = 0;
|
||||
else if (member.isReadAssignedOnly) rolePriority = 8;
|
||||
else if (member.isReadOnly) rolePriority = 7;
|
||||
else if (member.isWorker) rolePriority = 6;
|
||||
else if (member.isCommentAssignedOnly) rolePriority = 5;
|
||||
else if (member.isCommentOnly) rolePriority = 4;
|
||||
else if (member.isNoComments) rolePriority = 3;
|
||||
else if (member.isNormalAssignedOnly) rolePriority = 2;
|
||||
else rolePriority = 1; // Normal
|
||||
|
||||
const fullname = user ? user.profile.fullname : '';
|
||||
return rolePriority + '-' + fullname;
|
||||
});
|
||||
},
|
||||
|
||||
activeOrgs() {
|
||||
|
|
@ -1900,6 +1933,17 @@ if (Meteor.isServer) {
|
|||
'profile.invitedBoards': boardId,
|
||||
},
|
||||
});
|
||||
|
||||
// Ensure the user is active on the board
|
||||
Boards.update({
|
||||
_id: boardId,
|
||||
'members.userId': Meteor.userId()
|
||||
}, {
|
||||
$set: {
|
||||
'members.$.isActive': true,
|
||||
modifiedAt: new Date()
|
||||
}
|
||||
});
|
||||
},
|
||||
myLabelNames() {
|
||||
let names = [];
|
||||
|
|
|
|||
197
models/users.js
197
models/users.js
|
|
@ -2533,14 +2533,9 @@ if (Meteor.isServer) {
|
|||
}
|
||||
const inviter = ReactiveCache.getCurrentUser();
|
||||
const board = ReactiveCache.getBoard(boardId);
|
||||
const allowInvite =
|
||||
inviter &&
|
||||
board &&
|
||||
board.members &&
|
||||
_.contains(_.pluck(board.members, 'userId'), inviter._id) &&
|
||||
_.where(board.members, {
|
||||
userId: inviter._id,
|
||||
})[0].isActive;
|
||||
const member = _.find(board.members, function(member) { return member.userId === inviter._id; });
|
||||
if (!member) throw new Meteor.Error('error-board-notAMember');
|
||||
const allowInvite = member.isActive;
|
||||
// GitHub issue 2060
|
||||
//_.where(board.members, { userId: inviter._id })[0].isAdmin;
|
||||
if (!allowInvite) throw new Meteor.Error('error-board-notAMember');
|
||||
|
|
@ -2596,16 +2591,26 @@ if (Meteor.isServer) {
|
|||
user = ReactiveCache.getUser(newUserId);
|
||||
}
|
||||
|
||||
board.addMember(user._id);
|
||||
user.addInvite(boardId);
|
||||
const memberIndex = board.members.findIndex(m => m.userId === user._id);
|
||||
if (memberIndex >= 0) {
|
||||
Boards.update(boardId, { $set: { [`members.${memberIndex}.isActive`]: true, modifiedAt: new Date() } });
|
||||
} else {
|
||||
Boards.update(boardId, { $push: { members: { userId: user._id, isAdmin: false, isActive: true, isNoComments: false, isCommentOnly: false, isWorker: false, isNormalAssignedOnly: false, isCommentAssignedOnly: false, isReadOnly: false, isReadAssignedOnly: false } }, $set: { modifiedAt: new Date() } });
|
||||
}
|
||||
Users.update(user._id, { $push: { 'profile.invitedBoards': boardId } });
|
||||
|
||||
//Check if there is a subtasks board
|
||||
if (board.subtasksDefaultBoardId) {
|
||||
const subBoard = ReactiveCache.getBoard(board.subtasksDefaultBoardId);
|
||||
//If there is, also add user to that board
|
||||
if (subBoard) {
|
||||
subBoard.addMember(user._id);
|
||||
user.addInvite(subBoard._id);
|
||||
const subMemberIndex = subBoard.members.findIndex(m => m.userId === user._id);
|
||||
if (subMemberIndex >= 0) {
|
||||
Boards.update(board.subtasksDefaultBoardId, { $set: { [`members.${subMemberIndex}.isActive`]: true, modifiedAt: new Date() } });
|
||||
} else {
|
||||
Boards.update(board.subtasksDefaultBoardId, { $push: { members: { userId: user._id, isAdmin: false, isActive: true, isNoComments: false, isCommentOnly: false, isWorker: false, isNormalAssignedOnly: false, isCommentAssignedOnly: false, isReadOnly: false, isReadAssignedOnly: false } }, $set: { modifiedAt: new Date() } });
|
||||
}
|
||||
Users.update(user._id, { $push: { 'profile.invitedBoards': subBoard._id } });
|
||||
}
|
||||
} try {
|
||||
const fullName =
|
||||
|
|
@ -3144,7 +3149,12 @@ if (Meteor.isServer) {
|
|||
} else {
|
||||
invitationCode.boardsToBeInvited.forEach((boardId) => {
|
||||
const board = ReactiveCache.getBoard(boardId);
|
||||
board.addMember(doc._id);
|
||||
const memberIndex = board.members.findIndex(m => m.userId === doc._id);
|
||||
if (memberIndex >= 0) {
|
||||
Boards.update(boardId, { $set: { [`members.${memberIndex}.isActive`]: true } });
|
||||
} else {
|
||||
Boards.update(boardId, { $push: { members: { userId: doc._id, isAdmin: false, isActive: true, isNoComments: false, isCommentOnly: false, isWorker: false, isNormalAssignedOnly: false, isCommentAssignedOnly: false, isReadOnly: false, isReadAssignedOnly: false } } });
|
||||
}
|
||||
});
|
||||
if (!doc.profile) {
|
||||
doc.profile = {};
|
||||
|
|
@ -3441,24 +3451,33 @@ if (Meteor.isServer) {
|
|||
data = ReactiveCache.getBoards({
|
||||
_id: boardId,
|
||||
}).map(function (board) {
|
||||
if (!board.hasMember(userId)) {
|
||||
board.addMember(userId);
|
||||
const hasMember = board.members.some(m => m.userId === userId && m.isActive);
|
||||
if (!hasMember) {
|
||||
const memberIndex = board.members.findIndex(m => m.userId === userId);
|
||||
if (memberIndex >= 0) {
|
||||
Boards.update(boardId, { $set: { [`members.${memberIndex}.isActive`]: true } });
|
||||
} else {
|
||||
Boards.update(boardId, { $push: { members: { userId: userId, isAdmin: false, isActive: true, isNoComments: false, isCommentOnly: false, isWorker: false, isNormalAssignedOnly: false, isCommentAssignedOnly: false, isReadOnly: false, isReadAssignedOnly: false } } });
|
||||
}
|
||||
|
||||
function isTrue(data) {
|
||||
return data.toLowerCase() === 'true';
|
||||
}
|
||||
board.setMemberPermission(
|
||||
userId,
|
||||
isTrue(isAdmin),
|
||||
isTrue(isNoComments),
|
||||
isTrue(isCommentOnly),
|
||||
isTrue(isWorker),
|
||||
isTrue(isNormalAssignedOnly),
|
||||
isTrue(isCommentAssignedOnly),
|
||||
isTrue(isReadOnly),
|
||||
isTrue(isReadAssignedOnly),
|
||||
userId,
|
||||
);
|
||||
const memberIndex2 = board.members.findIndex(m => m.userId === userId);
|
||||
if (memberIndex2 >= 0) {
|
||||
Boards.update(boardId, {
|
||||
$set: {
|
||||
[`members.${memberIndex2}.isAdmin`]: isTrue(isAdmin),
|
||||
[`members.${memberIndex2}.isNoComments`]: isTrue(isNoComments),
|
||||
[`members.${memberIndex2}.isCommentOnly`]: isTrue(isCommentOnly),
|
||||
[`members.${memberIndex2}.isWorker`]: isTrue(isWorker),
|
||||
[`members.${memberIndex2}.isNormalAssignedOnly`]: isTrue(isNormalAssignedOnly),
|
||||
[`members.${memberIndex2}.isCommentAssignedOnly`]: isTrue(isCommentAssignedOnly),
|
||||
[`members.${memberIndex2}.isReadOnly`]: isTrue(isReadOnly),
|
||||
[`members.${memberIndex2}.isReadAssignedOnly`]: isTrue(isReadAssignedOnly),
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
return {
|
||||
_id: board._id,
|
||||
|
|
@ -3506,8 +3525,19 @@ if (Meteor.isServer) {
|
|||
data = ReactiveCache.getBoards({
|
||||
_id: boardId,
|
||||
}).map(function (board) {
|
||||
if (board.hasMember(userId)) {
|
||||
board.removeMember(userId);
|
||||
const hasMember = board.members.some(m => m.userId === userId && m.isActive);
|
||||
if (hasMember) {
|
||||
const memberIndex = board.members.findIndex(m => m.userId === userId);
|
||||
if (memberIndex >= 0) {
|
||||
const member = board.members[memberIndex];
|
||||
const activeAdmins = board.members.filter(m => m.isActive && m.isAdmin);
|
||||
const allowRemove = !member.isAdmin || activeAdmins.length > 1;
|
||||
if (!allowRemove) {
|
||||
Boards.update(boardId, { $set: { [`members.${memberIndex}.isActive`]: true } });
|
||||
} else {
|
||||
Boards.update(boardId, { $set: { [`members.${memberIndex}.isActive`]: false, [`members.${memberIndex}.isAdmin`]: false } });
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
_id: board._id,
|
||||
|
|
@ -3684,47 +3714,92 @@ if (Meteor.isServer) {
|
|||
});
|
||||
|
||||
// Server-side method to sanitize user data for search results
|
||||
const sanitizeUserForSearch = (userData) => {
|
||||
// Only allow safe fields for user search
|
||||
const safeFields = {
|
||||
_id: 1,
|
||||
username: 1,
|
||||
'profile.fullname': 1,
|
||||
'profile.avatarUrl': 1,
|
||||
'profile.initials': 1,
|
||||
'emails.address': 1,
|
||||
'emails.verified': 1,
|
||||
authenticationMethod: 1,
|
||||
isAdmin: 1,
|
||||
loginDisabled: 1,
|
||||
teams: 1,
|
||||
orgs: 1,
|
||||
};
|
||||
|
||||
const sanitized = {};
|
||||
for (const field of Object.keys(safeFields)) {
|
||||
if (userData[field] !== undefined) {
|
||||
sanitized[field] = userData[field];
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure sensitive fields are never included
|
||||
delete sanitized.services;
|
||||
delete sanitized.resume;
|
||||
delete sanitized.email;
|
||||
delete sanitized.createdAt;
|
||||
delete sanitized.modifiedAt;
|
||||
delete sanitized.sessionData;
|
||||
delete sanitized.importUsernames;
|
||||
|
||||
if (process.env.DEBUG === 'true') {
|
||||
console.log('Sanitized user data for search:', Object.keys(sanitized));
|
||||
}
|
||||
|
||||
return sanitized;
|
||||
};
|
||||
|
||||
Meteor.methods({
|
||||
sanitizeUserForSearch(userData) {
|
||||
check(userData, Object);
|
||||
return sanitizeUserForSearch(userData);
|
||||
},
|
||||
searchUsers(query, boardId) {
|
||||
check(query, String);
|
||||
check(boardId, String);
|
||||
|
||||
// Only allow safe fields for user search
|
||||
const safeFields = {
|
||||
_id: 1,
|
||||
username: 1,
|
||||
'profile.fullname': 1,
|
||||
'profile.avatarUrl': 1,
|
||||
'profile.initials': 1,
|
||||
'emails.address': 1,
|
||||
'emails.verified': 1,
|
||||
authenticationMethod: 1,
|
||||
isAdmin: 1,
|
||||
loginDisabled: 1,
|
||||
teams: 1,
|
||||
orgs: 1,
|
||||
};
|
||||
|
||||
const sanitized = {};
|
||||
for (const field of Object.keys(safeFields)) {
|
||||
if (userData[field] !== undefined) {
|
||||
sanitized[field] = userData[field];
|
||||
}
|
||||
if (!this.userId) {
|
||||
throw new Meteor.Error('not-logged-in', 'User must be logged in');
|
||||
}
|
||||
|
||||
// Ensure sensitive fields are never included
|
||||
delete sanitized.services;
|
||||
delete sanitized.resume;
|
||||
delete sanitized.email;
|
||||
delete sanitized.createdAt;
|
||||
delete sanitized.modifiedAt;
|
||||
delete sanitized.sessionData;
|
||||
delete sanitized.importUsernames;
|
||||
const currentUser = ReactiveCache.getCurrentUser();
|
||||
const board = ReactiveCache.getBoard(boardId);
|
||||
|
||||
if (process.env.DEBUG === 'true') {
|
||||
console.log('Sanitized user data for search:', Object.keys(sanitized));
|
||||
// Check if current user is a member of the board
|
||||
const member = _.find(board.members, function(member) { return member.userId === currentUser._id; });
|
||||
if (!member || !member.isActive) {
|
||||
throw new Meteor.Error('not-authorized', 'User is not a member of this board');
|
||||
}
|
||||
|
||||
return sanitized;
|
||||
if (query.length < 2) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const searchRegex = new RegExp(query, 'i');
|
||||
const users = ReactiveCache.getUsers({
|
||||
$or: [
|
||||
{ username: searchRegex },
|
||||
{ 'profile.fullname': searchRegex },
|
||||
{ 'emails.address': searchRegex }
|
||||
]
|
||||
}, {
|
||||
fields: {
|
||||
_id: 1,
|
||||
username: 1,
|
||||
'profile.fullname': 1,
|
||||
'profile.avatarUrl': 1,
|
||||
'profile.initials': 1,
|
||||
'emails.address': 1
|
||||
},
|
||||
limit: 5
|
||||
});
|
||||
|
||||
return users.map(user => sanitizeUserForSearch(user));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue