Fixed Add member and @mentions.

Thanks to xet7 !

Fixes #6076,
fixes #6077
This commit is contained in:
Lauri Ojansivu 2026-01-20 02:28:32 +02:00
parent 603a65ef17
commit ad511bd137
14 changed files with 413 additions and 149 deletions

View file

@ -909,10 +909,11 @@ template(name="cardMembersPopup")
each members
li.item(class="{{#if isCardMember}}active{{/if}}")
a.name.js-select-member(href="#")
+userAvatar(userId=user._id)
+userAvatar(userId=userId)
span.full-name
= user.profile.fullname
| (<span class="username">{{ user.username }}</span>)
= userData.profile.fullname
if userData.username
| (#{userData.username})
if isCardMember
| ✅
@ -923,10 +924,11 @@ template(name="cardAssigneesPopup")
each members
li.item(class="{{#if isCardAssignee}}active{{/if}}")
a.name.js-select-assignee(href="#")
+userAvatar(userId=user._id)
+userAvatar(userId=userId)
span.full-name
= user.profile.fullname
| (<span class="username">{{ user.username }}</span>)
= userData.profile.fullname
if userData.username
| (#{userData.username})
if isCardAssignee
| ✅
if currentUser.isWorker
@ -936,7 +938,8 @@ template(name="cardAssigneesPopup")
+userAvatar(userId=currentUser._id)
span.full-name
= currentUser.profile.fullname
| (<span class="username">{{ currentUser.username }}</span>)
if currentUser.username
| (#{currentUser.username})
if currentUser.isCardAssignee
| ✅

View file

@ -928,6 +928,12 @@ Template.cardMembersPopup.onCreated(function () {
});
Template.cardMembersPopup.events({
'click .js-select-member'(event) {
const card = Utils.getCurrentCard();
const memberId = this.userId;
card.toggleMember(memberId);
event.preventDefault();
},
'keyup .card-members-filter'(event) {
const members = filterMembers(event.target.value);
Template.instance().members.set(members);
@ -935,8 +941,23 @@ Template.cardMembersPopup.events({
});
Template.cardMembersPopup.helpers({
isCardMember() {
const card = Template.parentData();
const cardMembers = card.getMembers();
return _.contains(cardMembers, this.userId);
},
members() {
return _.sortBy(Template.instance().members.get(),'fullname');
const members = Template.instance().members.get();
const uniqueMembers = _.uniq(members, 'userId');
return _.sortBy(uniqueMembers, member => {
const user = ReactiveCache.getUser(member.userId);
return user ? user.profile.fullname : '';
});
},
userData() {
return ReactiveCache.getUser(this.userId);
},
});
@ -1910,10 +1931,15 @@ Template.cardAssigneesPopup.helpers({
},
members() {
return _.sortBy(Template.instance().members.get(),'fullname');
const members = Template.instance().members.get();
const uniqueMembers = _.uniq(members, 'userId');
return _.sortBy(uniqueMembers, member => {
const user = ReactiveCache.getUser(member.userId);
return user ? user.profile.fullname : '';
});
},
user() {
userData() {
return ReactiveCache.getUser(this.userId);
},
});

View file

@ -4,7 +4,17 @@ var converter = require('@wekanteam/html-to-markdown');
const specialHandles = [
{userId: 'board_members', username: 'board_members'},
{userId: 'card_members', username: 'card_members'}
{userId: 'card_members', username: 'card_members'},
{userId: 'board_assignees', username: 'board_assignees'},
{userId: 'card_assignees', username: 'card_assignees'}
];
const cardSpecialHandles = [
{userId: 'card_members', username: 'card_members'},
{userId: 'card_assignees', username: 'card_assignees'}
];
const boardSpecialHandles = [
{userId: 'board_members', username: 'board_members'},
{userId: 'board_assignees', username: 'board_assignees'}
];
const specialHandleNames = specialHandles.map(m => m.username);
@ -46,23 +56,26 @@ BlazeComponent.extendComponent({
search(term, callback) {
const currentBoard = Utils.getCurrentBoard();
const searchTerm = term.toLowerCase();
callback(
_.union(
currentBoard
.activeMembers()
.map(member => {
const user = ReactiveCache.getUser(member.userId);
const username = user.username.toLowerCase();
const fullName = user.profile && user.profile !== undefined && user.profile.fullname ? user.profile.fullname.toLowerCase() : "";
return username.includes(searchTerm) || fullName.includes(searchTerm) ? user : null;
})
.filter(Boolean), [...specialHandles])
);
const users = currentBoard
.activeMembers()
.map(member => {
const user = ReactiveCache.getUser(member.userId);
const username = user.username.toLowerCase();
const fullName = user.profile && user.profile !== undefined && user.profile.fullname ? user.profile.fullname.toLowerCase() : "";
return username.includes(searchTerm) || fullName.includes(searchTerm) ? user : null;
})
.filter(Boolean);
// Order: 1. Users, 2. Card-specific options, 3. Board-wide options
callback(_.union(users, cardSpecialHandles, boardSpecialHandles));
},
template(user) {
if (user.profile && user.profile.fullname) {
return (user.profile.fullname + " (" + user.username + ")");
}
// Translate special group mentions
if (specialHandleNames.includes(user.username)) {
return TAPi18n.__(user.username);
}
return user.username;
},
replace(user) {
@ -397,6 +410,14 @@ Blaze.Template.registerHelper(
if (knowedUser.userId === Meteor.userId()) {
linkClass += ' me';
}
// For special group mentions, display translated text
let displayText = knowedUser.username;
if (specialHandleNames.includes(knowedUser.username)) {
displayText = TAPi18n.__(knowedUser.username);
linkClass = 'atMention'; // Remove js-open-member for special handles
}
// This @user mention link generation did open same Wekan
// window in new tab, so now A is changed to U so it's
// underlined and there is no link popup. This way also
@ -411,7 +432,7 @@ Blaze.Template.registerHelper(
// using a data attribute.
'data-userId': knowedUser.userId,
},
linkValue,
[' ', at, displayText],
);
content = content.replace(fullMention, Blaze.toHTML(link));

View file

@ -1,3 +1,5 @@
import { FlowRouter } from 'meteor/ostrio:flow-router-extra';
Template.settingHeaderBar.helpers({
isSettingsActive() {
return FlowRouter.getRouteName() === 'setting' ? 'active' : '';

View file

@ -754,7 +754,6 @@ template(name="removeBoardTeamPopup")
template(name="addMemberPopup")
.js-search-member
input.js-search-member-input(type="text" placeholder="{{_ 'email-address'}}")
if loading.get
+spinner
else if error.get
@ -762,21 +761,12 @@ template(name="addMemberPopup")
else
ul.pop-over-list
each searchResults
li.item.js-member-item(class="{{#if isBoardMember}}disabled{{/if}}")
li.item.js-member-item
a.name.js-select-member(title="{{profile.fullname}} ({{username}})")
+userAvatar(userId=_id)
span.full-name
= profile.fullname
| (<span class="username">{{username}}</span>)
if isBoardMember
.quiet ({{_ 'joined'}})
if searching.get
+spinner
if noResults.get
.manage-member-section
p.quiet {{_ 'no-results'}}
button.js-email-invite.primary.full {{_ 'email-invite'}}
@ -831,6 +821,12 @@ template(name="changePermissionsPopup")
if isCommentAssignedOnly
| ✅
span.sub-name {{_ 'comment-assigned-only-desc'}}
li
a(class="{{#if isLastAdmin}}disabled{{else}}js-set-worker{{/if}}")
| {{_ 'worker'}}
if isWorker
| ✅
span.sub-name {{_ 'worker-desc'}}
li
a(class="{{#if isLastAdmin}}disabled{{else}}js-set-read-only{{/if}}")
| {{_ 'read-only'}}
@ -843,12 +839,6 @@ template(name="changePermissionsPopup")
if isReadAssignedOnly
| ✅
span.sub-name {{_ 'read-assigned-only-desc'}}
li
a(class="{{#if isLastAdmin}}disabled{{else}}js-set-worker{{/if}}")
| {{_ 'worker'}}
if isWorker
| ✅
span.sub-name {{_ 'worker-desc'}}
if isLastAdmin
hr
p.quiet.bottom {{_ 'last-admin-desc'}}

View file

@ -1474,8 +1474,9 @@ BlazeComponent.extendComponent({
isBoardMember() {
const userId = this.currentData()._id;
const user = ReactiveCache.getUser(userId);
return user && user.isBoardMember();
const boardId = Session.get('currentBoard');
const board = ReactiveCache.getBoard(boardId);
return board && board.hasMember(userId);
},
isValidEmail(email) {
@ -1504,14 +1505,20 @@ BlazeComponent.extendComponent({
Session.set('addMemberPopup.searching', true);
Session.set('addMemberPopup.noResults', false);
// Use the fallback search
const results = UserSearchIndex.search(query, { limit: 20 }).fetch();
Session.set('addMemberPopup.searchResults', results);
Session.set('addMemberPopup.searching', false);
if (results.length === 0) {
Session.set('addMemberPopup.noResults', true);
}
const boardId = Session.get('currentBoard');
Meteor.call('searchUsers', query, boardId, (error, results) => {
Session.set('addMemberPopup.searching', false);
if (error) {
console.error('Search error:', error);
Session.set('addMemberPopup.searchResults', []);
Session.set('addMemberPopup.noResults', true);
} else {
Session.set('addMemberPopup.searchResults', results);
if (results.length === 0) {
Session.set('addMemberPopup.noResults', true);
}
}
});
},
inviteUser(idNameEmail) {
@ -1520,9 +1527,11 @@ BlazeComponent.extendComponent({
const self = this;
Meteor.call('inviteUserToBoard', idNameEmail, boardId, (err, ret) => {
self.setLoading(false);
if (err) self.setError(err.error);
else if (ret.email) self.setError('email-sent');
else Popup.back();
if (err) {
self.setError(err.error);
} else {
Popup.back();
}
});
},
@ -1530,9 +1539,8 @@ BlazeComponent.extendComponent({
return [
{
'keyup .js-search-member-input'(event) {
this.setError('');
Session.set('addMemberPopup.error', '');
const query = event.target.value.trim();
this.searchQuery.set(query);
// Clear previous timeout
if (this.searchTimeout) {
@ -1546,16 +1554,13 @@ BlazeComponent.extendComponent({
},
'click .js-select-member'() {
const userId = this.currentData()._id;
const currentBoard = Utils.getCurrentBoard();
if (!currentBoard.hasMember(userId)) {
this.inviteUser(userId);
}
this.inviteUser(userId);
},
'click .js-email-invite'() {
const idNameEmail = $('.js-search-member-input').val();
if (idNameEmail.indexOf('@') < 0 || this.isValidEmail(idNameEmail)) {
this.inviteUser(idNameEmail);
} else this.setError('email-invalid');
} else Session.set('addMemberPopup.error', 'email-invalid');
},
},
];
@ -1565,7 +1570,6 @@ BlazeComponent.extendComponent({
Template.addMemberPopup.helpers({
searchResults() {
const results = Session.get('addMemberPopup.searchResults');
console.log('searchResults helper called, returning:', results);
return results;
},
searching() {
@ -1582,14 +1586,14 @@ Template.addMemberPopup.helpers({
},
isBoardMember() {
const userId = this._id;
const user = ReactiveCache.getUser(userId);
return user && user.isBoardMember();
const boardId = Session.get('currentBoard');
const board = ReactiveCache.getBoard(boardId);
return board && board.hasMember(userId);
}
})
Template.addMemberPopupTest.helpers({
searchResults() {
console.log('addMemberPopupTest searchResults helper called');
return Session.get('addMemberPopup.searchResults') || [];
}
})
@ -1675,8 +1679,15 @@ BlazeComponent.extendComponent({
this.page = new ReactiveVar(1);
this.autorun(() => {
const limitOrgs = this.page.get() * Number.MAX_SAFE_INTEGER;
this.subscribe('org', this.findOrgsOptions.get(), limitOrgs, () => {});
const limitTeams = this.page.get() * Number.MAX_SAFE_INTEGER;
this.subscribe('team', this.findOrgsOptions.get(), limitTeams, () => {});
});
this.findUsersOptions = new ReactiveVar({});
this.userPage = new ReactiveVar(1);
this.autorun(() => {
const limitUsers = this.userPage.get() * Number.MAX_SAFE_INTEGER;
this.subscribe('people', this.findUsersOptions.get(), limitUsers, () => {});
});
},

View file

@ -1,9 +1,12 @@
template(name="userAvatar")
a.member(class="js-{{#if assignee}}assignee{{else}}member{{/if}}" title="{{userData.profile.fullname}} ({{userData.username}}) {{_ memberType}}")
if userData.profile.avatarUrl
img.avatar.avatar-image(src="{{avatarUrl}}")
a.member(class="js-{{#if assignee}}assignee{{else}}member{{/if}}" title="{{#if userData}}{{userData.profile.fullname}} ({{userData.username}}) {{_ memberType}}{{/if}}")
if userData
if userData.profile.avatarUrl
img.avatar.avatar-image(src="{{avatarUrl}}")
else
+userAvatarInitials(userId=userData._id)
else
+userAvatarInitials(userId=userData._id)
+userAvatarInitials(userId=this.userId)
if showStatus
span.member-presence-status(class=presenceStatusClassName)

View file

@ -7,12 +7,13 @@ import Team from '/models/team';
Template.userAvatar.helpers({
userData() {
return ReactiveCache.getUser(this.userId, {
const user = ReactiveCache.getUser(this.userId, {
fields: {
profile: 1,
username: 1,
},
});
return user;
},
avatarUrl() {
@ -32,7 +33,21 @@ Template.userAvatar.helpers({
memberType() {
const user = ReactiveCache.getUser(this.userId);
return user && user.isBoardAdmin() ? 'admin' : 'normal';
if (!user) return '';
const board = Utils.getCurrentBoard();
if (!board) return '';
// Return role in priority order: Admin, Normal, NormalAssignedOnly, NoComments, CommentOnly, CommentAssignedOnly, Worker, ReadOnly, ReadAssignedOnly
if (user.isBoardAdmin()) return 'admin';
if (board.hasReadAssignedOnly(user._id)) return 'read-assigned-only';
if (board.hasReadOnly(user._id)) return 'read-only';
if (board.hasWorker(user._id)) return 'worker';
if (board.hasCommentAssignedOnly(user._id)) return 'comment-assigned-only';
if (board.hasCommentOnly(user._id)) return 'comment-only';
if (board.hasNoComments(user._id)) return 'no-comments';
if (board.hasNormalAssignedOnly(user._id)) return 'normal-assigned-only';
return 'normal';
},
/*

View file

@ -121,7 +121,7 @@
"add-after-list": "Add After List",
"add-members": "Add Members",
"added": "Added",
"addMemberPopup-title": "Members",
"addMemberPopup-title": "Add Members",
"memberPopup-title": "Member Settings",
"admin": "Admin",
"admin-desc": "Can view and edit cards, remove members, and change settings for the board. Can view activities.",
@ -172,6 +172,10 @@
"boardInfoOnMyBoards-title": "All Boards Settings",
"show-card-counter-per-list": "Show card count per list",
"show-board_members-avatar": "Show Board members avatars",
"board_members": "All board members",
"card_members": "All members of current card at this board",
"board_assignees": "All assignees of all cards at this board",
"card_assignees": "All assignees of current card at this board",
"board-nb-stars": "%s stars",
"board-not-found": "Board not found",
"board-private-info": "This board will be <strong>private</strong>.",
@ -975,7 +979,7 @@
"act-almostdue": "was reminding the current due (__timeValue__) of __card__ is approaching",
"act-pastdue": "was reminding the current due (__timeValue__) of __card__ is past",
"act-duenow": "was reminding the current due (__timeValue__) of __card__ is now",
"act-atUserComment": "You were mentioned in [__board__] __list__/__card__",
"act-atUserComment": "mentioned you on card __card__: __comment__ at list __list__ at swimlane __swimlane__ at board __board__",
"delete-user-confirm-popup": "Are you sure you want to delete this account? There is no undo.",
"delete-team-confirm-popup": "Are you sure you want to delete this team? There is no undo.",
"delete-org-confirm-popup": "Are you sure you want to delete this organization? There is no undo.",

View file

@ -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) {

View file

@ -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 = [];

View file

@ -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));
}
});
}

View file

@ -23,12 +23,15 @@ Notifications = {
const users = [];
watchers.forEach(userId => {
const user = ReactiveCache.getUser(userId);
if (user) users.push(user);
if (user && user._id) users.push(user);
});
return users;
},
notify: (user, title, description, params) => {
// Skip if user is invalid
if (!user || !user._id) return;
for (const k in notifyServices) {
const notifyImpl = notifyServices[k];
if (notifyImpl && typeof notifyImpl === 'function')

View file

@ -1,5 +1,15 @@
Meteor.startup(() => {
Notifications.subscribe('profile', (user, title, description, params) => {
user.addNotification(params.activityId);
try {
// Validate user object before processing
if (!user || !user._id) {
console.error('Invalid user object in notification:', { user, title, params });
return;
}
const modifier = user.addNotification(params.activityId);
Users.direct.update(user._id, modifier);
} catch (error) {
console.error('Error adding notification:', error);
}
});
});