diff --git a/client/components/activities/comments.jade b/client/components/activities/comments.jade index f459fc664..68e477d44 100644 --- a/client/components/activities/comments.jade +++ b/client/components/activities/comments.jade @@ -54,9 +54,11 @@ template(name="commentReactions") span.reaction-codepoint !{reaction.reactionCodepoint} span.reaction-count #{reaction.userIds.length} if (currentUser.isBoardMember) - a.open-comment-reaction-popup(title="{{_ 'addReactionPopup-title'}}") - span(title="{{_ 'reaction' }}") 😀 - span(title="{{_ 'add' }}") ➕ + unless currentUser.isReadOnly + unless currentUser.isReadAssignedOnly + a.open-comment-reaction-popup(title="{{_ 'addReactionPopup-title'}}") + span(title="{{_ 'reaction' }}") 😀 + span(title="{{_ 'add' }}") ➕ template(name="addReactionPopup") .reactions-popup diff --git a/client/components/boards/boardsList.js b/client/components/boards/boardsList.js index 66adc5be2..d6a1b097e 100644 --- a/client/components/boards/boardsList.js +++ b/client/components/boards/boardsList.js @@ -196,7 +196,11 @@ BlazeComponent.extendComponent({ return ret; }, currentMenuPath() { - const sel = this.selectedMenu.get(); + const selectedMenuVar = this.selectedMenu; + if (!selectedMenuVar) { + return { icon: '🗂️', text: TAPi18n.__('allboards.workspaces') }; + } + const sel = selectedMenuVar.get(); const currentUser = ReactiveCache.getCurrentUser(); // Helper to find space by id in tree diff --git a/client/components/cards/cardDetails.jade b/client/components/cards/cardDetails.jade index 0b8f23c7a..bc11a8958 100644 --- a/client/components/cards/cardDetails.jade +++ b/client/components/cards/cardDetails.jade @@ -19,14 +19,12 @@ template(name="cardDetails") | 🔽 a.close-card-details.js-close-card-details(title="{{_ 'close-card'}}") | ❌ - if canModifyCard - if cardMaximized - a.minimize-card-details.js-minimize-card-details(title="{{_ 'minimize-card'}}") - | 🔽 - else - a.maximize-card-details.js-maximize-card-details(title="{{_ 'maximize-card'}}") - | 🔼 - if canModifyCard + if cardMaximized + a.minimize-card-details.js-minimize-card-details(title="{{_ 'minimize-card'}}") + | 🔽 + else + a.maximize-card-details.js-maximize-card-details(title="{{_ 'maximize-card'}}") + | 🔼 a.card-details-menu.js-open-card-details-menu(title="{{_ 'cardDetailsActionsPopup-title'}}") | ☰ a.card-copy-button.js-copy-link( @@ -35,8 +33,9 @@ template(name="cardDetails") href="{{ originRelativeUrl }}" ) span.emoji-icon 🔗 - span.card-drag-handle.js-card-drag-handle(title="Drag card") - | ↕️ + if canModifyCard + span.card-drag-handle.js-card-drag-handle(title="Drag card") + | ↕️ span.copied-tooltip {{_ 'copied'}} else a.close-card-details.js-close-card-details(title="{{_ 'close-card'}}") @@ -50,24 +49,23 @@ template(name="cardDetails") | 🖥️ else | 📱 - if canModifyCard - if cardMaximized - a.minimize-card-details.js-minimize-card-details(title="{{_ 'minimize-card'}}") - | 🔽 - else - a.maximize-card-details.js-maximize-card-details(title="{{_ 'maximize-card'}}") - | 🔼 - a.card-details-menu-mobile-web.js-open-card-details-menu(title="{{_ 'cardDetailsActionsPopup-title'}}") - | ☰ - a.card-copy-mobile-button.js-copy-link( - id="cardURL_copy" - title="{{_ 'copy-card-link-to-clipboard'}}" - href="{{ originRelativeUrl }}" - ) - span.emoji-icon 🔗 - span.copied-tooltip {{_ 'copied'}} + if cardMaximized + a.minimize-card-details.js-minimize-card-details(title="{{_ 'minimize-card'}}") + | 🔽 + else + a.maximize-card-details.js-maximize-card-details(title="{{_ 'maximize-card'}}") + | 🔼 + a.card-details-menu-mobile-web.js-open-card-details-menu(title="{{_ 'cardDetailsActionsPopup-title'}}") + | ☰ + a.card-copy-mobile-button.js-copy-link( + id="cardURL_copy" + title="{{_ 'copy-card-link-to-clipboard'}}" + href="{{ originRelativeUrl }}" + ) + span.emoji-icon 🔗 + span.copied-tooltip {{_ 'copied'}} h2.card-details-title.js-card-title( - class="{{#if canModifyCard}}js-open-inlined-form is-editable{{/if}}") + class="{{#if canModifyCard}}js-open-inlined-form is-editable{{else}}js-card-title-drag-handle{{/if}}") +viewer if currentBoard.allowsCardNumber span.card-number @@ -636,13 +634,15 @@ template(name="cardDetails") if currentBoard.allowsComments if currentUser.isBoardMember unless currentUser.isNoComments - +commentForm + unless currentUser.isReadOnly + unless currentUser.isReadAssignedOnly + +commentForm +comments hr .card-details-right - unless currentUser.isNoComments + if currentUser.isBoardAdmin .activity-title h3.card-details-item-title | 📜 @@ -655,7 +655,7 @@ template(name="cardDetails") input.toggle-switch(type="checkbox" id="toggleShowActivitiesCard") label.toggle-label(for="toggleShowActivitiesCard") - unless currentUser.isNoComments + if currentUser.isBoardAdmin if isLoaded.get if isLinkedCard +activities(card=this mode="linkedcard") @@ -741,55 +741,107 @@ template(name="cardDetailsActionsPopup") else | 👁️ | {{_ 'show-list-on-minicard'}} - hr + if canModifyCard + hr + else + unless currentUser.isReadOnly + unless currentUser.isReadAssignedOnly + hr ul.pop-over-list li a.js-export-card | 📤 | {{_ 'export-card'}} - hr - ul.pop-over-list - li - a.js-move-card-to-top - | ⬆️ - | {{_ 'moveCardToTop-title'}} - li - a.js-move-card-to-bottom - | ⬇️ - | {{_ 'moveCardToBottom-title'}} - hr - ul.pop-over-list - if currentUser.isBoardAdmin - li - a.js-move-card - | ➡️ - | {{_ 'moveCardPopup-title'}} - unless currentUser.isWorker - li - a.js-copy-card - | 📋 - | {{_ 'copyCardPopup-title'}} - unless currentUser.isWorker - ul.pop-over-list - li - a.js-copy-checklist-cards - | 📋 - | 📋 - | {{_ 'copyManyCardsPopup-title'}} - unless archived - hr - ul.pop-over-list - li - a.js-archive - | ➡️ - | 📦 - | {{_ 'archive-card'}} + unless canModifyCard + unless currentUser.isReadOnly + unless currentUser.isReadAssignedOnly + hr + ul.pop-over-list + li + a.js-move-card-to-top + | ⬆️ + | {{_ 'moveCardToTop-title'}} + li + a.js-move-card-to-bottom + | ⬇️ + | {{_ 'moveCardToBottom-title'}} + hr + ul.pop-over-list + if currentUser.isBoardAdmin + li + a.js-move-card + | ➡️ + | {{_ 'moveCardPopup-title'}} + unless currentUser.isWorker + li + a.js-copy-card + | 📋 + | {{_ 'copyCardPopup-title'}} + unless currentUser.isWorker + ul.pop-over-list + li + a.js-copy-checklist-cards + | 📋 + | 📋 + | {{_ 'copyManyCardsPopup-title'}} + unless archived + hr + ul.pop-over-list + li + a.js-archive + | ➡️ + | 📦 + | {{_ 'archive-card'}} + hr + ul.pop-over-list + li + a.js-more + span.emoji-icon 🔗 + | {{_ 'cardMorePopup-title'}} + if canModifyCard hr ul.pop-over-list li - a.js-more - span.emoji-icon 🔗 - | {{_ 'cardMorePopup-title'}} + a.js-move-card-to-top + | ⬆️ + | {{_ 'moveCardToTop-title'}} + li + a.js-move-card-to-bottom + | ⬇️ + | {{_ 'moveCardToBottom-title'}} + hr + ul.pop-over-list + if currentUser.isBoardAdmin + li + a.js-move-card + | ➡️ + | {{_ 'moveCardPopup-title'}} + unless currentUser.isWorker + li + a.js-copy-card + | 📋 + | {{_ 'copyCardPopup-title'}} + unless currentUser.isWorker + ul.pop-over-list + li + a.js-copy-checklist-cards + | 📋 + | 📋 + | {{_ 'copyManyCardsPopup-title'}} + unless archived + hr + ul.pop-over-list + li + a.js-archive + | ➡️ + | 📦 + | {{_ 'archive-card'}} + hr + ul.pop-over-list + li + a.js-more + span.emoji-icon 🔗 + | {{_ 'cardMorePopup-title'}} template(name="exportCardPopup") ul.pop-over-list diff --git a/client/components/cards/cardDetails.js b/client/components/cards/cardDetails.js index aff8b5e6a..0577a4111 100644 --- a/client/components/cards/cardDetails.js +++ b/client/components/cards/cardDetails.js @@ -100,6 +100,11 @@ BlazeComponent.extendComponent({ return !Utils.getPopupCardId() && ReactiveCache.getCurrentUser().hasCardMaximized(); }, + showActivities() { + const user = ReactiveCache.getCurrentUser(); + return user && user.hasShowActivities(); + }, + cardCollapsed() { const user = ReactiveCache.getCurrentUser(); if (user && user.profile) { @@ -350,6 +355,37 @@ BlazeComponent.extendComponent({ $(document).on('mousemove', onMouseMove); $(document).on('mouseup', onMouseUp); }, + 'mousedown .js-card-title-drag-handle'(event) { + // Allow dragging from title for ReadOnly users + // Don't interfere with text selection + if (event.target.tagName === 'A' || $(event.target).closest('a').length > 0) { + return; // Don't drag if clicking on links + } + + event.preventDefault(); + const $card = $(event.target).closest('.card-details'); + const startX = event.clientX; + const startY = event.clientY; + const startLeft = $card.offset().left; + const startTop = $card.offset().top; + + const onMouseMove = (e) => { + const deltaX = e.clientX - startX; + const deltaY = e.clientY - startY; + $card.css({ + left: startLeft + deltaX + 'px', + top: startTop + deltaY + 'px' + }); + }; + + const onMouseUp = () => { + $(document).off('mousemove', onMouseMove); + $(document).off('mouseup', onMouseUp); + }; + + $(document).on('mousemove', onMouseMove); + $(document).on('mouseup', onMouseUp); + }, 'click .js-close-card-details'() { // Get board ID from either the card data or current board in session const card = this.currentData() || this.data(); @@ -517,9 +553,6 @@ BlazeComponent.extendComponent({ Session.set('cardDetailsIsDragging', false); Session.set('cardDetailsIsMouseDown', false); }, - 'click #toggleShowActivitiesCard'() { - this.data().toggleShowActivities(); - }, 'click #toggleHideCheckedChecklistItems'() { this.data().toggleHideCheckedChecklistItems(); }, diff --git a/client/components/lists/listBody.js b/client/components/lists/listBody.js index adc099321..a9271ca6b 100644 --- a/client/components/lists/listBody.js +++ b/client/components/lists/listBody.js @@ -209,6 +209,12 @@ BlazeComponent.extendComponent({ evt.stopImmediatePropagation(); evt.preventDefault(); Utils.goBoardId(Session.get('currentBoard')); + } else { + // Allow normal href navigation, but if it's the same card URL, + // we'll handle it by directly setting the session + evt.preventDefault(); + const card = this.currentData(); + Session.set('currentCard', card._id); } }, diff --git a/client/components/lists/listHeader.jade b/client/components/lists/listHeader.jade index 5558ef10f..7d637e3d7 100644 --- a/client/components/lists/listHeader.jade +++ b/client/components/lists/listHeader.jade @@ -58,9 +58,11 @@ template(name="listHeader") i.list-header-watch-icon | 👁️ div.list-header-menu unless currentUser.isCommentOnly - if canSeeAddCard - a.js-add-card.list-header-plus-top(title="{{_ 'add-card-to-top-of-list'}}") ➕ - a.js-open-list-menu(title="{{_ 'listActionPopup-title'}}") ☰ + unless currentUser.isReadOnly + unless currentUser.isReadAssignedOnly + if canSeeAddCard + a.js-add-card.list-header-plus-top(title="{{_ 'add-card-to-top-of-list'}}") ➕ + a.js-open-list-menu(title="{{_ 'listActionPopup-title'}}") ☰ else a.list-header-menu-icon.js-select-list ▶️ unless currentUser.isWorker @@ -72,13 +74,15 @@ template(name="listHeader") unless collapsed div.list-header-menu unless currentUser.isCommentOnly - //if isBoardAdmin - // a.fa.js-list-star.list-header-plus-top(class="fa-star{{#unless starred}}-o{{/unless}}") - if isTouchScreenOrShowDesktopDragHandles - a.list-header-handle-desktop.handle.js-list-handle(title="{{_ 'drag-list'}}") ↕️ - if canSeeAddCard - a.js-add-card.list-header-plus-top(title="{{_ 'add-card-to-top-of-list'}}") ➕ - a.js-open-list-menu(title="{{_ 'listActionPopup-title'}}") ☰ + unless currentUser.isReadOnly + unless currentUser.isReadAssignedOnly + //if isBoardAdmin + // a.fa.js-list-star.list-header-plus-top(class="fa-star{{#unless starred}}-o{{/unless}}") + if isTouchScreenOrShowDesktopDragHandles + a.list-header-handle-desktop.handle.js-list-handle(title="{{_ 'drag-list'}}") ↕️ + if canSeeAddCard + a.js-add-card.list-header-plus-top(title="{{_ 'add-card-to-top-of-list'}}") ➕ + a.js-open-list-menu(title="{{_ 'listActionPopup-title'}}") ☰ template(name="editListTitleForm") .list-composer @@ -89,18 +93,20 @@ template(name="editListTitleForm") | ❌ template(name="listActionPopup") - ul.pop-over-list - li - a.js-add-card.list-header-plus-bottom - | ➕ - | ⬇️ - | {{_ 'add-card-to-bottom-of-list'}} - hr - ul.pop-over-list - li - a.js-set-list-width - | ↔️ - | {{_ 'set-list-width'}} + unless currentUser.isReadOnly + unless currentUser.isReadAssignedOnly + ul.pop-over-list + li + a.js-add-card.list-header-plus-bottom + | ➕ + | ⬇️ + | {{_ 'add-card-to-bottom-of-list'}} + hr + ul.pop-over-list + li + a.js-set-list-width + | ↔️ + | {{_ 'set-list-width'}} ul.pop-over-list li a.js-toggle-watch-list @@ -111,38 +117,40 @@ template(name="listActionPopup") | 🙈 | {{_ 'watch'}} unless currentUser.isCommentOnly - unless currentUser.isWorker - ul.pop-over-list - li - a.js-set-color-list - | 🎨 - | {{_ 'set-color-list'}} - ul.pop-over-list - if cards.length - li - a.js-select-cards - | ☑️ - | {{_ 'list-select-cards'}} - if currentUser.isBoardAdmin - ul.pop-over-list - li - a.js-set-wip-limit - | 🚫 - | {{#if isWipLimitEnabled }}{{_ 'edit-wip-limit'}}{{else}}{{_ 'setWipLimitPopup-title'}}{{/if}} - unless currentUser.isWorker - hr - ul.pop-over-list - li - a.js-close-list - | ➡️ - | 📦 - | {{_ 'archive-list'}} - hr - ul.pop-over-list - li - a.js-more - | 🔗 - | {{_ 'listMorePopup-title'}} + unless currentUser.isReadOnly + unless currentUser.isReadAssignedOnly + unless currentUser.isWorker + ul.pop-over-list + li + a.js-set-color-list + | 🎨 + | {{_ 'set-color-list'}} + ul.pop-over-list + if cards.length + li + a.js-select-cards + | ☑️ + | {{_ 'list-select-cards'}} + if currentUser.isBoardAdmin + ul.pop-over-list + li + a.js-set-wip-limit + | 🚫 + | {{#if isWipLimitEnabled }}{{_ 'edit-wip-limit'}}{{else}}{{_ 'setWipLimitPopup-title'}}{{/if}} + unless currentUser.isWorker + hr + ul.pop-over-list + li + a.js-close-list + | ➡️ + | 📦 + | {{_ 'archive-list'}} + hr + ul.pop-over-list + li + a.js-more + | 🔗 + | {{_ 'listMorePopup-title'}} template(name="boardLists") ul.pop-over-list diff --git a/client/components/sidebar/sidebar.jade b/client/components/sidebar/sidebar.jade index 889b7a6f1..4658a5269 100644 --- a/client/components/sidebar/sidebar.jade +++ b/client/components/sidebar/sidebar.jade @@ -46,7 +46,7 @@ template(name='homeSidebar') span {{#if isShowWeekOfYear}}✅{{else}}⬜{{/if}} span {{_ 'show-week-of-year'}} hr - unless currentUser.isNoComments + if currentUser.isBoardAdmin h3.activity-title | 💬 | {{_ 'activities'}} diff --git a/client/components/sidebar/sidebarArchives.jade b/client/components/sidebar/sidebarArchives.jade index 66d1cde6c..e8d6dddc0 100644 --- a/client/components/sidebar/sidebarArchives.jade +++ b/client/components/sidebar/sidebarArchives.jade @@ -3,26 +3,30 @@ template(name="archivesSidebar") +basicTabs(tabs=tabs) +tabContent(slug="cards") unless isWorker - p.quiet - a.js-restore-all-cards {{_ 'restore-all'}} - if currentUser.isBoardAdmin - | - - a.js-delete-all-cards {{_ 'delete-all'}} + unless currentUser.isReadOnly + unless currentUser.isReadAssignedOnly + p.quiet + a.js-restore-all-cards {{_ 'restore-all'}} + if currentUser.isBoardAdmin + | - + a.js-delete-all-cards {{_ 'delete-all'}} each archivedCards .minicard-wrapper.js-minicard +minicard(this) if currentUser.isBoardMember unless isWorker - p.quiet - if this.archivedAt - | {{_ 'archived-at' }} - | - | {{ moment this.archivedAt 'LLL' }} - br - a.js-restore-card {{_ 'restore'}} - if currentUser.isBoardAdmin - | - - a.js-delete-card {{_ 'delete'}} + unless currentUser.isReadOnly + unless currentUser.isReadAssignedOnly + p.quiet + if this.archivedAt + | {{_ 'archived-at' }} + | + | {{ moment this.archivedAt 'LLL' }} + br + a.js-restore-card {{_ 'restore'}} + if currentUser.isBoardAdmin + | - + a.js-delete-card {{_ 'delete'}} if cardIsInArchivedList p.quiet.small ({{_ 'warn-list-archived'}}) else @@ -30,53 +34,61 @@ template(name="archivesSidebar") +tabContent(slug="lists") unless isWorker - p.quiet - a.js-restore-all-lists {{_ 'restore-all'}} - if currentUser.isBoardAdmin - | - - a.js-delete-all-lists {{_ 'delete-all'}} + unless currentUser.isReadOnly + unless currentUser.isReadAssignedOnly + p.quiet + a.js-restore-all-lists {{_ 'restore-all'}} + if currentUser.isBoardAdmin + | - + a.js-delete-all-lists {{_ 'delete-all'}} ul.archived-lists each archivedLists li.archived-lists-item = title if currentUser.isBoardMember unless isWorker - p.quiet - if this.archivedAt - | {{_ 'archived-at' }} - | - | {{ moment this.archivedAt 'LLL' }} - br - a.js-restore-list {{_ 'restore'}} - if currentUser.isBoardAdmin - | - - a.js-delete-list {{_ 'delete'}} + unless currentUser.isReadOnly + unless currentUser.isReadAssignedOnly + p.quiet + if this.archivedAt + | {{_ 'archived-at' }} + | + | {{ moment this.archivedAt 'LLL' }} + br + a.js-restore-list {{_ 'restore'}} + if currentUser.isBoardAdmin + | - + a.js-delete-list {{_ 'delete'}} else li.no-items-message {{_ 'no-archived-lists'}} +tabContent(slug="swimlanes") unless isWorker - p.quiet - a.js-restore-all-swimlanes {{_ 'restore-all'}} - if currentUser.isBoardAdmin - | - - a.js-delete-all-swimlanes {{_ 'delete-all'}} + unless currentUser.isReadOnly + unless currentUser.isReadAssignedOnly + p.quiet + a.js-restore-all-swimlanes {{_ 'restore-all'}} + if currentUser.isBoardAdmin + | - + a.js-delete-all-swimlanes {{_ 'delete-all'}} ul.archived-lists each archivedSwimlanes li.archived-lists-item = title if currentUser.isBoardMember unless isWorker - p.quiet - if this.archivedAt - | {{_ 'archived-at' }} - | - | {{ moment this.archivedAt 'LLL' }} - br - a.js-restore-swimlane {{_ 'restore'}} - if currentUser.isBoardAdmin - | - - a.js-delete-swimlane {{_ 'delete'}} + unless currentUser.isReadOnly + unless currentUser.isReadAssignedOnly + p.quiet + if this.archivedAt + | {{_ 'archived-at' }} + | + | {{ moment this.archivedAt 'LLL' }} + br + a.js-restore-swimlane {{_ 'restore'}} + if currentUser.isBoardAdmin + | - + a.js-delete-swimlane {{_ 'delete'}} else li.no-items-message {{_ 'no-archived-swimlanes'}} else diff --git a/client/components/swimlanes/swimlaneHeader.jade b/client/components/swimlanes/swimlaneHeader.jade index 4f9105e69..6cfa52150 100644 --- a/client/components/swimlanes/swimlaneHeader.jade +++ b/client/components/swimlanes/swimlaneHeader.jade @@ -25,23 +25,25 @@ template(name="swimlaneFixedHeader") .swimlane-header-menu if currentUser unless currentUser.isCommentOnly - unless currentUser.isWorker - a.swimlane-collapse-indicator.js-collapse-swimlane.swimlane-header-collapse(title="{{_ 'collapse'}}") - if collapseSwimlane - | ▶ - else - | 🔽 - a.js-open-add-swimlane-menu.swimlane-header-plus-icon(title="{{_ 'add-swimlane'}}") - | ➕ - if isTouchScreenOrShowDesktopDragHandles - unless isTouchScreen - a.swimlane-header-handle.handle.js-swimlane-header-handle - | ↕️ - if isTouchScreen - a.swimlane-header-miniscreen-handle.handle.js-swimlane-header-handle - | ↕️ - a.js-open-swimlane-menu(title="{{_ 'swimlaneActionPopup-title'}}") - | ☰ + unless currentUser.isReadOnly + unless currentUser.isReadAssignedOnly + unless currentUser.isWorker + a.swimlane-collapse-indicator.js-collapse-swimlane.swimlane-header-collapse(title="{{_ 'collapse'}}") + if collapseSwimlane + | ▶ + else + | 🔽 + a.js-open-add-swimlane-menu.swimlane-header-plus-icon(title="{{_ 'add-swimlane'}}") + | ➕ + if isTouchScreenOrShowDesktopDragHandles + unless isTouchScreen + a.swimlane-header-handle.handle.js-swimlane-header-handle + | ↕️ + if isTouchScreen + a.swimlane-header-miniscreen-handle.handle.js-swimlane-header-handle + | ↕️ + a.js-open-swimlane-menu(title="{{_ 'swimlaneActionPopup-title'}}") + | ☰ template(name="editSwimlaneTitleForm") .list-composer @@ -54,44 +56,48 @@ template(name="editSwimlaneTitleForm") template(name="swimlaneActionPopup") if currentUser unless currentUser.isCommentOnly - ul.pop-over-list - if currentUser.isBoardAdmin - li: a.js-set-swimlane-color - | 🎨 - | {{_ 'select-color'}} - li: a.js-set-swimlane-height - | ↕️ - | {{_ 'set-swimlane-height'}} - if currentUser.isBoardAdmin - unless this.isTemplateContainer - hr + unless currentUser.isReadOnly + unless currentUser.isReadAssignedOnly ul.pop-over-list - li: a.js-close-swimlane - | ▶️ - | 📦 - | {{_ 'archive-swimlane'}} - ul.pop-over-list - li: a.js-copy-swimlane - | 📋 - | {{_ 'copy-swimlane'}} - ul.pop-over-list - li: a.js-move-swimlane - | ⬆️ - | {{_ 'move-swimlane'}} + if currentUser.isBoardAdmin + li: a.js-set-swimlane-color + | 🎨 + | {{_ 'select-color'}} + li: a.js-set-swimlane-height + | ↕️ + | {{_ 'set-swimlane-height'}} + if currentUser.isBoardAdmin + unless this.isTemplateContainer + hr + ul.pop-over-list + li: a.js-close-swimlane + | ▶️ + | 📦 + | {{_ 'archive-swimlane'}} + ul.pop-over-list + li: a.js-copy-swimlane + | 📋 + | {{_ 'copy-swimlane'}} + ul.pop-over-list + li: a.js-move-swimlane + | ⬆️ + | {{_ 'move-swimlane'}} template(name="swimlaneAddPopup") if currentUser unless currentUser.isCommentOnly - form - input.swimlane-name-input.full-line(type="text" placeholder="{{_ 'add-swimlane'}}" - autocomplete="off" autofocus) - .edit-controls.clearfix - button.primary.confirm(type="submit") {{_ 'add'}} - unless currentBoard.isTemplatesBoard - unless currentBoard.isTemplateBoard - span.quiet - | {{_ 'or'}} - a.js-swimlane-template {{_ 'template'}} + unless currentUser.isReadOnly + unless currentUser.isReadAssignedOnly + form + input.swimlane-name-input.full-line(type="text" placeholder="{{_ 'add-swimlane'}}" + autocomplete="off" autofocus) + .edit-controls.clearfix + button.primary.confirm(type="submit") {{_ 'add'}} + unless currentBoard.isTemplatesBoard + unless currentBoard.isTemplateBoard + span.quiet + | {{_ 'or'}} + a.js-swimlane-template {{_ 'template'}} template(name="setSwimlaneColorPopup") form.edit-label.swimlane-color-popup diff --git a/client/components/swimlanes/swimlanes.jade b/client/components/swimlanes/swimlanes.jade index 25e634573..be39d4eac 100644 --- a/client/components/swimlanes/swimlanes.jade +++ b/client/components/swimlanes/swimlanes.jade @@ -48,7 +48,9 @@ template(name="listsGroup") template(name="addListForm") unless currentUser.isWorker unless currentUser.isCommentOnly - .list.list-composer.js-list-composer(class="{{#if isMiniScreen}}mini-list{{/if}}") + unless currentUser.isReadOnly + unless currentUser.isReadAssignedOnly + .list.list-composer.js-list-composer(class="{{#if isMiniScreen}}mini-list{{/if}}") .list-header-add +inlinedForm(autoclose=false) input.list-name-input.full-line(type="text" placeholder="{{_ 'add-list'}}" diff --git a/client/lib/utils.js b/client/lib/utils.js index fbcba009a..b0ba5cbb0 100644 --- a/client/lib/utils.js +++ b/client/lib/utils.js @@ -247,7 +247,9 @@ Utils = { currentUser && currentUser.isBoardMember() && !currentUser.isCommentOnly() && - !currentUser.isWorker() + !currentUser.isWorker() && + !currentUser.isReadOnly() && + !currentUser.isReadAssignedOnly() ); return ret; }, @@ -256,7 +258,9 @@ Utils = { const ret = ( currentUser && currentUser.isBoardMember() && - !currentUser.isCommentOnly() + !currentUser.isCommentOnly() && + !currentUser.isReadOnly() && + !currentUser.isReadAssignedOnly() ); return ret; }, @@ -265,7 +269,9 @@ Utils = { const ret = ( currentUser && currentUser.isBoardMember() && - !currentUser.isCommentOnly() + !currentUser.isCommentOnly() && + !currentUser.isReadOnly() && + !currentUser.isReadAssignedOnly() ); return ret; }, diff --git a/config/router.js b/config/router.js index 64ca7066e..1c6789ca5 100644 --- a/config/router.js +++ b/config/router.js @@ -127,36 +127,7 @@ FlowRouter.route('/public', { }, }); -FlowRouter.route('/b/:id/:slug', { - name: 'board', - action(params) { - const currentBoard = params.id; - const previousBoard = Session.get('currentBoard'); - Session.set('currentBoard', currentBoard); - Session.set('currentCard', null); - Session.set('popupCardId', null); - Session.set('popupCardBoardId', null); - - // If we close a card, we'll execute again this route action but we don't - // want to excape every current actions (filters, etc.) - if (previousBoard !== currentBoard) { - Filter.reset(); - Session.set('sortBy', ''); - EscapeActions.executeAll(); - } else { - EscapeActions.executeUpTo('popup-close'); - } - - Utils.manageCustomUI(); - Utils.manageMatomo(); - - this.render('defaultLayout', { - headerBar: 'boardHeaderBar', - content: 'board', - }); - }, -}); - +// Card route MUST be registered BEFORE board route so it matches first FlowRouter.route('/b/:boardId/:slug/:cardId', { name: 'card', action(params) { @@ -187,6 +158,48 @@ FlowRouter.route('/b/:boardId/:slug/:cardId', { }, }); +FlowRouter.route('/b/:id/:slug', { + name: 'board', + action(params) { + const pathSegments = FlowRouter.current().path.split('/').filter(s => s); + + // If we have 4+ segments (b, boardId, slug, cardId), this is a card view + if (pathSegments.length >= 4) { + return; + } + + // If slug contains "/" it means a cardId was matched by this greedy pattern + if (params.slug && params.slug.includes('/')) { + return; + } + + const currentBoard = params.id; + const previousBoard = Session.get('currentBoard'); + Session.set('currentBoard', currentBoard); + Session.set('currentCard', null); + Session.set('popupCardId', null); + Session.set('popupCardBoardId', null); + + // If we close a card, we'll execute again this route action but we don't + // want to excape every current actions (filters, etc.) + if (previousBoard !== currentBoard) { + Filter.reset(); + Session.set('sortBy', ''); + EscapeActions.executeAll(); + } else { + EscapeActions.executeUpTo('popup-close'); + } + + Utils.manageCustomUI(); + Utils.manageMatomo(); + + this.render('defaultLayout', { + headerBar: 'boardHeaderBar', + content: 'board', + }); + }, +}); + FlowRouter.route('/shortcuts', { name: 'shortcuts', action() { diff --git a/imports/i18n/data/en.i18n.json b/imports/i18n/data/en.i18n.json index 42be4c4b6..0daa885d9 100644 --- a/imports/i18n/data/en.i18n.json +++ b/imports/i18n/data/en.i18n.json @@ -124,7 +124,7 @@ "addMemberPopup-title": "Members", "memberPopup-title": "Member Settings", "admin": "Admin", - "admin-desc": "Can view and edit cards, remove members, and change settings for the board.", + "admin-desc": "Can view and edit cards, remove members, and change settings for the board. Can view activities.", "admin-announcement": "Announcement", "admin-announcement-active": "Active System-Wide Announcement", "admin-announcement-title": "Announcement from Administrator", @@ -335,7 +335,7 @@ "comment-delete": "Are you sure you want to delete the comment?", "deleteCommentPopup-title": "Delete comment?", "no-comments": "No comments", - "no-comments-desc": "Can not see comments and activities.", + "no-comments-desc": "Can not see comments.", "read-only": "Read Only", "read-only-desc": "Can view cards only. Can not edit.", "read-assigned-only": "Only Assigned Read", diff --git a/models/attachments.js b/models/attachments.js index 2c5af186e..6e38227fd 100644 --- a/models/attachments.js +++ b/models/attachments.js @@ -176,7 +176,8 @@ Attachments = new FilesCollection({ if (Meteor.isServer) { Attachments.allow({ insert(userId, fileObj) { - return allowIsBoardMember(userId, ReactiveCache.getBoard(fileObj.boardId)); + // ReadOnly users cannot upload attachments + return allowIsBoardMemberWithWriteAccess(userId, ReactiveCache.getBoard(fileObj.boardId)); }, update(userId, fileObj, fields) { // Only allow updates to specific fields that don't affect security @@ -190,7 +191,8 @@ if (Meteor.isServer) { return false; } - return allowIsBoardMember(userId, ReactiveCache.getBoard(fileObj.boardId)); + // ReadOnly users cannot update attachments + return allowIsBoardMemberWithWriteAccess(userId, ReactiveCache.getBoard(fileObj.boardId)); }, remove(userId, fileObj) { // Additional security check: ensure the file belongs to the board the user has access to @@ -209,7 +211,8 @@ if (Meteor.isServer) { return false; } - return allowIsBoardMember(userId, board); + // ReadOnly users cannot delete attachments + return allowIsBoardMemberWithWriteAccess(userId, board); }, fetch: ['meta', 'boardId'], }); diff --git a/models/cardComments.js b/models/cardComments.js index 2c891909b..7cdf52b5d 100644 --- a/models/cardComments.js +++ b/models/cardComments.js @@ -82,7 +82,8 @@ CardComments.attachSchema( CardComments.allow({ insert(userId, doc) { - return allowIsBoardMember(userId, ReactiveCache.getBoard(doc.boardId)); + // ReadOnly users cannot add comments. Only members who can comment are allowed. + return allowIsBoardMemberCommentOnly(userId, ReactiveCache.getBoard(doc.boardId)); }, update(userId, doc) { return userId === doc.userId || allowIsBoardAdmin(userId, ReactiveCache.getBoard(doc.boardId)); diff --git a/models/cards.js b/models/cards.js index fb97bd99e..3a6f5bbc7 100644 --- a/models/cards.js +++ b/models/cards.js @@ -518,7 +518,7 @@ Cards.attachSchema( ); // Centralized update policy for Cards -// Security: deny any direct client updates to 'vote' fields; require membership otherwise +// Security: deny any direct client updates to 'vote' fields; require write access otherwise canUpdateCard = function(userId, doc, fields) { if (!userId) return false; const fieldNames = fields || []; @@ -530,19 +530,22 @@ canUpdateCard = function(userId, doc, fields) { if (_.some(fieldNames, f => typeof f === 'string' && (f === 'poker' || f.indexOf('poker.') === 0))) { return false; } - return allowIsBoardMember(userId, ReactiveCache.getBoard(doc.boardId)); + // ReadOnly users cannot edit cards + return allowIsBoardMemberWithWriteAccess(userId, ReactiveCache.getBoard(doc.boardId)); }; Cards.allow({ insert(userId, doc) { - return allowIsBoardMember(userId, ReactiveCache.getBoard(doc.boardId)); + // ReadOnly users cannot create cards + return allowIsBoardMemberWithWriteAccess(userId, ReactiveCache.getBoard(doc.boardId)); }, update(userId, doc, fields) { return canUpdateCard(userId, doc, fields); }, remove(userId, doc) { - return allowIsBoardMember(userId, ReactiveCache.getBoard(doc.boardId)); + // ReadOnly users cannot delete cards + return allowIsBoardMemberWithWriteAccess(userId, ReactiveCache.getBoard(doc.boardId)); }, fetch: ['boardId'], }); diff --git a/models/checklistItems.js b/models/checklistItems.js index 946adefeb..95e29d23b 100644 --- a/models/checklistItems.js +++ b/models/checklistItems.js @@ -70,13 +70,16 @@ ChecklistItems.attachSchema( ChecklistItems.allow({ insert(userId, doc) { - return allowIsBoardMemberByCard(userId, ReactiveCache.getCard(doc.cardId)); + // ReadOnly users cannot create checklist items + return allowIsBoardMemberWithWriteAccessByCard(userId, ReactiveCache.getCard(doc.cardId)); }, update(userId, doc) { - return allowIsBoardMemberByCard(userId, ReactiveCache.getCard(doc.cardId)); + // ReadOnly users cannot edit checklist items + return allowIsBoardMemberWithWriteAccessByCard(userId, ReactiveCache.getCard(doc.cardId)); }, remove(userId, doc) { - return allowIsBoardMemberByCard(userId, ReactiveCache.getCard(doc.cardId)); + // ReadOnly users cannot delete checklist items + return allowIsBoardMemberWithWriteAccessByCard(userId, ReactiveCache.getCard(doc.cardId)); }, fetch: ['userId', 'cardId'], }); diff --git a/models/checklists.js b/models/checklists.js index 40a348ba5..606e58f3f 100644 --- a/models/checklists.js +++ b/models/checklists.js @@ -170,13 +170,16 @@ Checklists.helpers({ Checklists.allow({ insert(userId, doc) { - return allowIsBoardMemberByCard(userId, ReactiveCache.getCard(doc.cardId)); + // ReadOnly users cannot create checklists + return allowIsBoardMemberWithWriteAccessByCard(userId, ReactiveCache.getCard(doc.cardId)); }, update(userId, doc) { - return allowIsBoardMemberByCard(userId, ReactiveCache.getCard(doc.cardId)); + // ReadOnly users cannot edit checklists + return allowIsBoardMemberWithWriteAccessByCard(userId, ReactiveCache.getCard(doc.cardId)); }, remove(userId, doc) { - return allowIsBoardMemberByCard(userId, ReactiveCache.getCard(doc.cardId)); + // ReadOnly users cannot delete checklists + return allowIsBoardMemberWithWriteAccessByCard(userId, ReactiveCache.getCard(doc.cardId)); }, fetch: ['userId', 'cardId'], }); diff --git a/models/lists.js b/models/lists.js index 95820f03b..7564f7dbb 100644 --- a/models/lists.js +++ b/models/lists.js @@ -181,13 +181,16 @@ Lists.attachSchema( Lists.allow({ insert(userId, doc) { - return allowIsBoardMemberCommentOnly(userId, ReactiveCache.getBoard(doc.boardId)); + // ReadOnly and CommentOnly users cannot create lists + return allowIsBoardMemberWithWriteAccess(userId, ReactiveCache.getBoard(doc.boardId)); }, update(userId, doc) { - return allowIsBoardMemberCommentOnly(userId, ReactiveCache.getBoard(doc.boardId)); + // ReadOnly and CommentOnly users cannot edit lists + return allowIsBoardMemberWithWriteAccess(userId, ReactiveCache.getBoard(doc.boardId)); }, remove(userId, doc) { - return allowIsBoardMemberCommentOnly(userId, ReactiveCache.getBoard(doc.boardId)); + // ReadOnly and CommentOnly users cannot delete lists + return allowIsBoardMemberWithWriteAccess(userId, ReactiveCache.getBoard(doc.boardId)); }, fetch: ['boardId'], }); diff --git a/models/swimlanes.js b/models/swimlanes.js index f57ec2ff1..64dbfe529 100644 --- a/models/swimlanes.js +++ b/models/swimlanes.js @@ -132,13 +132,16 @@ Swimlanes.attachSchema( Swimlanes.allow({ insert(userId, doc) { - return allowIsBoardMemberCommentOnly(userId, ReactiveCache.getBoard(doc.boardId)); + // ReadOnly and CommentOnly users cannot create swimlanes + return allowIsBoardMemberWithWriteAccess(userId, ReactiveCache.getBoard(doc.boardId)); }, update(userId, doc) { - return allowIsBoardMemberCommentOnly(userId, ReactiveCache.getBoard(doc.boardId)); + // ReadOnly and CommentOnly users cannot edit swimlanes + return allowIsBoardMemberWithWriteAccess(userId, ReactiveCache.getBoard(doc.boardId)); }, remove(userId, doc) { - return allowIsBoardMemberCommentOnly(userId, ReactiveCache.getBoard(doc.boardId)); + // ReadOnly and CommentOnly users cannot delete swimlanes + return allowIsBoardMemberWithWriteAccess(userId, ReactiveCache.getBoard(doc.boardId)); }, fetch: ['boardId'], }); diff --git a/models/users.js b/models/users.js index e784ae699..b3f40b993 100644 --- a/models/users.js +++ b/models/users.js @@ -271,6 +271,13 @@ Users.attachSchema( type: Boolean, optional: true, }, + 'profile.showActivities': { + /** + * does the user want to show activities in card details? + */ + type: Boolean, + optional: true, + }, 'profile.customFieldsGrid': { /** * has user at card Custom Fields have Grid (false) or one per row (true) layout? @@ -875,6 +882,16 @@ if (Meteor.isClient) { return board && board.hasCommentOnly(this._id); }, + isReadOnly() { + const board = Utils.getCurrentBoard(); + return board && board.hasReadOnly(this._id); + }, + + isReadAssignedOnly() { + const board = Utils.getCurrentBoard(); + return board && board.hasReadAssignedOnly(this._id); + }, + isNotWorker() { const board = Utils.getCurrentBoard(); return board && board.hasMember(this._id) && !board.hasWorker(this._id); @@ -1206,6 +1223,11 @@ Users.helpers({ return profile.cardMaximized || false; }, + hasShowActivities() { + const profile = this.profile || {}; + return profile.showActivities || false; + }, + hasHiddenMinicardLabelText() { const profile = this.profile || {}; return profile.hiddenMinicardLabelText || false; @@ -1753,6 +1775,14 @@ Users.mutations({ }; }, + toggleShowActivities(value = false) { + return { + $set: { + 'profile.showActivities': !value, + }, + }; + }, + toggleLabelText(value = false) { return { $set: { diff --git a/server/lib/utils.js b/server/lib/utils.js index bd6037b59..a19456b1a 100644 --- a/server/lib/utils.js +++ b/server/lib/utils.js @@ -20,6 +20,17 @@ allowIsBoardMemberNoComments = function(userId, board) { return board && board.hasMember(userId) && !board.hasNoComments(userId); }; +// Check if user has write access to board (can create/edit cards and lists) +allowIsBoardMemberWithWriteAccess = function(userId, board) { + return board && board.members && board.members.some(e => e.userId === userId && e.isActive && !e.isNoComments && !e.isCommentOnly && !e.isWorker && !e.isReadOnly && !e.isReadAssignedOnly); +}; + +// Check if user has write access via a card's board +allowIsBoardMemberWithWriteAccessByCard = function(userId, card) { + const board = card && card.board && card.board(); + return allowIsBoardMemberWithWriteAccess(userId, board); +}; + allowIsBoardMemberByCard = function(userId, card) { const board = card.board(); return board && board.hasMember(userId); diff --git a/server/publications/activities.js b/server/publications/activities.js index e55d627c0..1243a07f3 100644 --- a/server/publications/activities.js +++ b/server/publications/activities.js @@ -21,6 +21,28 @@ Meteor.publish('activities', function(kind, id, limit, showActivities) { return this.ready(); } + // Check user permissions - only BoardAdmin can view activities + if (this.userId) { + const user = ReactiveCache.getUser(this.userId); + const board = ReactiveCache.getBoard(id); + + if (user && board) { + // Find user membership in board + const membership = board.members.find(m => m.userId === this.userId); + + // Only BoardAdmin can view activities + if (!membership || !membership.isAdmin) { + return this.ready(); + } + } else { + // If board or user not found, deny + return this.ready(); + } + } else { + // If not logged in, deny + return this.ready(); + } + // Get linkedBoard let linkedElmtId = [id]; if (kind == 'board') { diff --git a/server/publications/boards.js b/server/publications/boards.js index bac769f16..54db5c23d 100644 --- a/server/publications/boards.js +++ b/server/publications/boards.js @@ -296,14 +296,23 @@ Meteor.publishRelations('board', function(boardId, isArchived) { const linkedBoardCards = this.join(Cards); linkedBoardCards.selector = _ids => ({ boardId: _ids }); + // Build card selector based on user's permissions + const cardSelector = { + boardId: { $in: [boardId, board.subtasksDefaultBoardId] }, + archived: isArchived, + }; + + // Check if current user has assigned-only permissions + if (thisUserId && board.members) { + const member = _.findWhere(board.members, { userId: thisUserId, isActive: true }); + if (member && (member.isNormalAssignedOnly || member.isCommentAssignedOnly || member.isReadAssignedOnly)) { + // User with assigned-only permissions should only see cards assigned to them + cardSelector.assignees = { $in: [thisUserId] }; + } + } + this.cursor( - ReactiveCache.getCards({ - boardId: { $in: [boardId, board.subtasksDefaultBoardId] }, - archived: isArchived, - }, - {}, - true, - ), + ReactiveCache.getCards(cardSelector, {}, true), function(cardId, card) { if (card.type === 'cardType-linkedCard') { const impCardId = card.linkedId; diff --git a/server/publications/cards.js b/server/publications/cards.js index b0b0cb8c8..ecb9d6477 100644 --- a/server/publications/cards.js +++ b/server/publications/cards.js @@ -75,6 +75,24 @@ import Team from "../../models/team"; Meteor.publish('card', cardId => { check(cardId, String); + + const userId = Meteor.userId(); + const card = ReactiveCache.getCard({ _id: cardId }); + + // If user has assigned-only permissions, check if they're assigned to this card + if (userId && card && card.boardId) { + const board = ReactiveCache.getBoard({ _id: card.boardId }); + if (board && board.members) { + const member = _.findWhere(board.members, { userId: userId, isActive: true }); + if (member && (member.isNormalAssignedOnly || member.isCommentAssignedOnly || member.isReadAssignedOnly)) { + // User with assigned-only permissions can only view cards assigned to them + if (!card.assignees || !card.assignees.includes(userId)) { + return []; // Don't publish if user is not assigned + } + } + } + } + const ret = ReactiveCache.getCards( { _id: cardId }, {}, @@ -88,6 +106,24 @@ Meteor.publish('card', cardId => { */ Meteor.publishRelations('popupCardData', function(cardId) { check(cardId, String); + + const userId = this.userId; + const card = ReactiveCache.getCard({ _id: cardId }); + + // If user has assigned-only permissions, check if they're assigned to this card + if (userId && card && card.boardId) { + const board = ReactiveCache.getBoard({ _id: card.boardId }); + if (board && board.members) { + const member = _.findWhere(board.members, { userId: userId, isActive: true }); + if (member && (member.isNormalAssignedOnly || member.isCommentAssignedOnly || member.isReadAssignedOnly)) { + // User with assigned-only permissions can only view cards assigned to them + if (!card.assignees || !card.assignees.includes(userId)) { + return this.ready(); // Don't publish if user is not assigned + } + } + } + } + this.cursor( ReactiveCache.getCards( { _id: cardId },