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