diff --git a/client/components/cards/cardDetails.jade b/client/components/cards/cardDetails.jade
index bc11a8958..d4dd61299 100644
--- a/client/components/cards/cardDetails.jade
+++ b/client/components/cards/cardDetails.jade
@@ -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
- | ({{ user.username }})
+ = 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
- | ({{ user.username }})
+ = 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
- | ({{ currentUser.username }})
+ if currentUser.username
+ | (#{currentUser.username})
if currentUser.isCardAssignee
| ✅
diff --git a/client/components/cards/cardDetails.js b/client/components/cards/cardDetails.js
index 54f584180..8c146369f 100644
--- a/client/components/cards/cardDetails.js
+++ b/client/components/cards/cardDetails.js
@@ -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);
},
});
diff --git a/client/components/main/editor.js b/client/components/main/editor.js
index 8613d656c..6081605b4 100644
--- a/client/components/main/editor.js
+++ b/client/components/main/editor.js
@@ -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));
diff --git a/client/components/settings/settingHeader.js b/client/components/settings/settingHeader.js
index 673108d03..b52eb8f49 100644
--- a/client/components/settings/settingHeader.js
+++ b/client/components/settings/settingHeader.js
@@ -1,3 +1,5 @@
+import { FlowRouter } from 'meteor/ostrio:flow-router-extra';
+
Template.settingHeaderBar.helpers({
isSettingsActive() {
return FlowRouter.getRouteName() === 'setting' ? 'active' : '';
diff --git a/client/components/sidebar/sidebar.jade b/client/components/sidebar/sidebar.jade
index 4658a5269..463a365d7 100644
--- a/client/components/sidebar/sidebar.jade
+++ b/client/components/sidebar/sidebar.jade
@@ -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
| ({{username}})
- 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'}}
diff --git a/client/components/sidebar/sidebar.js b/client/components/sidebar/sidebar.js
index 7141e4a92..bda802102 100644
--- a/client/components/sidebar/sidebar.js
+++ b/client/components/sidebar/sidebar.js
@@ -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, () => {});
});
},
diff --git a/client/components/users/userAvatar.jade b/client/components/users/userAvatar.jade
index 652c0eedf..549b742f8 100644
--- a/client/components/users/userAvatar.jade
+++ b/client/components/users/userAvatar.jade
@@ -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)
diff --git a/client/components/users/userAvatar.js b/client/components/users/userAvatar.js
index c81ee7714..f291a32b5 100644
--- a/client/components/users/userAvatar.js
+++ b/client/components/users/userAvatar.js
@@ -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';
},
/*
diff --git a/imports/i18n/data/en.i18n.json b/imports/i18n/data/en.i18n.json
index 0daa885d9..b405bb78b 100644
--- a/imports/i18n/data/en.i18n.json
+++ b/imports/i18n/data/en.i18n.json
@@ -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 private.",
@@ -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.",
diff --git a/models/activities.js b/models/activities.js
index 077893437..7e3d0a22a 100644
--- a/models/activities.js
+++ b/models/activities.js
@@ -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) {
diff --git a/models/boards.js b/models/boards.js
index 787dc790c..56f21f37d 100644
--- a/models/boards.js
+++ b/models/boards.js
@@ -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 = [];
diff --git a/models/users.js b/models/users.js
index b3f40b993..501dfae0a 100644
--- a/models/users.js
+++ b/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));
}
});
}
diff --git a/server/notifications/notifications.js b/server/notifications/notifications.js
index 783ad9f3f..0d9b5259b 100644
--- a/server/notifications/notifications.js
+++ b/server/notifications/notifications.js
@@ -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')
diff --git a/server/notifications/profile.js b/server/notifications/profile.js
index 608931cf9..71e5fcd3f 100644
--- a/server/notifications/profile.js
+++ b/server/notifications/profile.js
@@ -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);
+ }
});
});