From aac7c380c8c389b0683b2bd64e2cc856993f0e30 Mon Sep 17 00:00:00 2001 From: Lauri Ojansivu Date: Sun, 1 Mar 2020 20:59:53 +0200 Subject: - Fix critical and moderate security vulnerabilities reported at 2020-02-26 with responsible disclosure by [Dejan Zelic](https://twitter.com/dejandayoff), Justin Benjamin and others at [Offensive Security](https://twitter.com/offsectraining), that follow standard 90 days before public disclosure. Thanks to xet7. - Fix webhook error that prevented some card etc deleting from web UI of board. Thanks to xet7. - Add some more Font Awesome icons. Thanks to xet7. - Remove autofocus from many form input boxes so that they would not cause warnings. Thanks to xet7. --- client/components/settings/peopleBody.jade | 4 ++-- client/components/sidebar/sidebar.jade | 9 ++++++--- 2 files changed, 8 insertions(+), 5 deletions(-) (limited to 'client') diff --git a/client/components/settings/peopleBody.jade b/client/components/settings/peopleBody.jade index ca4bc382..fef1067e 100644 --- a/client/components/settings/peopleBody.jade +++ b/client/components/settings/peopleBody.jade @@ -110,7 +110,7 @@ template(name="editUserPopup") label.hide.userId(type="text" value=user._id) label | {{_ 'fullname'}} - input.js-profile-fullname(type="text" value=user.profile.fullname autofocus) + input.js-profile-fullname(type="text" value=user.profile.fullname) label | {{_ 'username'}} span.error.hide.username-taken @@ -159,7 +159,7 @@ template(name="newUserPopup") //label.hide.userId(type="text" value=user._id) label | {{_ 'fullname'}} - input.js-profile-fullname(type="text" value="" autofocus) + input.js-profile-fullname(type="text" value="") label | {{_ 'username'}} span.error.hide.username-taken diff --git a/client/components/sidebar/sidebar.jade b/client/components/sidebar/sidebar.jade index ebcd8486..f0b0e4be 100644 --- a/client/components/sidebar/sidebar.jade +++ b/client/components/sidebar/sidebar.jade @@ -245,7 +245,7 @@ template(name="outgoingWebhooksPopup") b   .materialCheckBox(class="{{#unless enabled}}is-checked{{/unless}}") input.js-outgoing-webhooks-title(placeholder="{{_ 'webhook-title'}}" type="text" name="title" value=title) - input.js-outgoing-webhooks-url(type="text" name="url" value=url autofocus) + input.js-outgoing-webhooks-url(type="text" name="url" value=url) input.js-outgoing-webhooks-token(placeholder="{{_ 'webhook-token' }}" type="text" value=token name="token") select.js-outgoing-webhooks-type(name="type") each _type in types @@ -257,7 +257,7 @@ template(name="outgoingWebhooksPopup") input(type="hidden" value=_id name="id") input.primary.wide(type="submit" value="{{_ 'save'}}") form.integration-form - input.js-outgoing-webhooks-title(placeholder="{{_ 'webhook-title'}}" type="text" name="title" autofocus) + input.js-outgoing-webhooks-title(placeholder="{{_ 'webhook-title'}}" type="text" name="title") input.js-outgoing-webhooks-url(placeholder="{{_ 'URL' }}" type="text" name="url") input.js-outgoing-webhooks-token(placeholder="{{_ 'webhook-token' }}" type="text" name="token") select.js-outgoing-webhooks-type(name="type") @@ -267,7 +267,10 @@ template(name="outgoingWebhooksPopup") template(name="boardMenuPopup") ul.pop-over-list - li: a.js-custom-fields {{_ 'custom-fields'}} + li + a.js-custom-fields + i.fa.fa-list-alt + | {{_ 'custom-fields'}} li a.js-open-archives i.fa.fa-archive -- cgit v1.2.3-1-g7c22 From 2b26bbe78a1a2b8b427963a6c44c3853efdb737e Mon Sep 17 00:00:00 2001 From: Lauri Ojansivu Date: Fri, 6 Mar 2020 03:52:12 +0200 Subject: Fix: img tag did not allow width and height. Removed swipebox from markdown editor img tag and updated marked markdown to newest version. Thanks to hradec and xet7 ! Closes #2956 --- client/components/main/editor.js | 4 ++++ 1 file changed, 4 insertions(+) (limited to 'client') diff --git a/client/components/main/editor.js b/client/components/main/editor.js index 39c03aa9..272be197 100755 --- a/client/components/main/editor.js +++ b/client/components/main/editor.js @@ -57,6 +57,9 @@ const sanitizeXss = (input, options) => { } } } + /* Don't use swipebox on markdown, so that img tag can now use width + * and height parameters. https://github.com/wekan/wekan/issues/2956 + * Previously this was added at https://github.com/wekan/wekan/pull/2593 } else if (tag === 'img') { if (!options.isClosing) { const src = getAttr('src'); @@ -64,6 +67,7 @@ const sanitizeXss = (input, options) => { return ``; } } + */ } return undefined; }, -- cgit v1.2.3-1-g7c22 From 482682e50079d70c5113169020d6834013b57c11 Mon Sep 17 00:00:00 2001 From: Lauri Ojansivu Date: Mon, 23 Mar 2020 22:29:20 +0200 Subject: SECURITY VULNERABILITY FIX: Fix XSS bug reported today 4 hours ago by Cyb3rjunky. Logged in users could run javascript in input fields. This affects Wekan versions v3.12-v3.84. In [Wekan v3.12](https://github.com/wekan/wekan/blob/master/CHANGELOG.md#v312-2019-08-09-wekan-release) there was [changes for XSS filter to allow inserting images, videos etc on comment WYSIWYG editor](https://github.com/wekan/wekan/pull/2593) so features related to that are now removed. After this fix, Javascript in input fields is not executed. Thanks to Cyb3rjunky and xet7 ! --- client/components/main/editor.js | 215 +++++++++++++-------------------------- 1 file changed, 68 insertions(+), 147 deletions(-) (limited to 'client') diff --git a/client/components/main/editor.js b/client/components/main/editor.js index 272be197..97a96b8e 100755 --- a/client/components/main/editor.js +++ b/client/components/main/editor.js @@ -1,93 +1,7 @@ -import _sanitizeXss from 'xss'; -const ASIS = 'asis'; -const sanitizeXss = (input, options) => { - const defaultAllowedIframeSrc = /^(https:){0,1}\/\/.*?(youtube|vimeo|dailymotion|youku)/i; - const allowedIframeSrcRegex = (function() { - let reg = defaultAllowedIframeSrc; - const SAFE_IFRAME_SRC_PATTERN = - Meteor.settings.public.SAFE_IFRAME_SRC_PATTERN; - try { - if (SAFE_IFRAME_SRC_PATTERN !== undefined) { - reg = new RegExp(SAFE_IFRAME_SRC_PATTERN, 'i'); - } - } catch (e) { - /*eslint no-console: ["error", { allow: ["warn", "error"] }] */ - - console.error('Wrong pattern specified', SAFE_IFRAM_SRC_PATTERN, e); - } - return reg; - })(); - const targetWindow = '_blank'; - const getHtmlDOM = html => { - const i = document.createElement('i'); - i.innerHTML = html; - return i.firstChild; - }; - options = { - onTag(tag, html, options) { - const htmlDOM = getHtmlDOM(html); - const getAttr = attr => { - return htmlDOM && attr && htmlDOM.getAttribute(attr); - }; - if (tag === 'iframe') { - const clipCls = 'note-vide-clip'; - if (!options.isClosing) { - const iframeCls = getAttr('class'); - let safe = iframeCls.indexOf(clipCls) > -1; - const src = getAttr('src'); - if (allowedIframeSrcRegex.exec(src)) { - safe = true; - } - if (safe) - return ``; - } else { - // remove tag - return ''; - } - } else if (tag === 'a') { - if (!options.isClosing) { - if (getAttr(ASIS) === 'true') { - // if has a ASIS attribute, don't do anything, it's a member id - return html; - } else { - const href = getAttr('href'); - if (href.match(/^((http(s){0,1}:){0,1}\/\/|\/)/)) { - // a valid url - return ``; - } - } - } - /* Don't use swipebox on markdown, so that img tag can now use width - * and height parameters. https://github.com/wekan/wekan/issues/2956 - * Previously this was added at https://github.com/wekan/wekan/pull/2593 - } else if (tag === 'img') { - if (!options.isClosing) { - const src = getAttr('src'); - if (src) { - return ``; - } - } - */ - } - return undefined; - }, - onTagAttr(tag, name, value) { - if (tag === 'img' && name === 'src') { - if (value && value.substr(0, 5) === 'data:') { - // allow image with dataURI src - return `${name}='${value}'`; - } - } else if (tag === 'a' && name === 'target') { - return `${name}='${targetWindow}'`; // always change a href target to a new window - } - return undefined; - }, - ...options, - }; - return _sanitizeXss(input, options); -}; Template.editor.onRendered(() => { const textareaSelector = 'textarea'; + const enableRicherEditor = + Meteor.settings.public.RICHER_CARD_COMMENT_EDITOR || true; const mentions = [ // User mentions { @@ -98,13 +12,7 @@ Template.editor.onRendered(() => { currentBoard .activeMembers() .map(member => { - const user = Users.findOne(member.userId); - if (user._id === Meteor.userId()) { - return null; - } - const value = user.username; - const username = - value && value.match(/\s+/) ? `"${value}"` : value; + const username = Users.findOne(member.userId).username; return username.includes(term) ? username : null; }) .filter(Boolean), @@ -124,16 +32,15 @@ Template.editor.onRendered(() => { autosize($textarea); $textarea.escapeableTextComplete(mentions); }; - if (Meteor.settings.public.RICHER_CARD_COMMENT_EDITOR !== false) { + if (enableRicherEditor) { const isSmall = Utils.isMiniScreen(); const toolbar = isSmall ? [ ['view', ['fullscreen']], ['table', ['table']], - ['font', ['bold']], - ['color', ['color']], - ['insert', ['video']], // iframe tag will be sanitized TODO if iframe[class=note-video-clip] can be added into safe list, insert video can be enabled + ['font', ['bold', 'underline']], //['fontsize', ['fontsize']], + ['color', ['color']], ] : [ ['style', ['style']], @@ -143,11 +50,47 @@ Template.editor.onRendered(() => { ['color', ['color']], ['para', ['ul', 'ol', 'paragraph']], ['table', ['table']], - ['insert', ['link', 'picture', 'video']], // iframe tag will be sanitized TODO if iframe[class=note-video-clip] can be added into safe list, insert video can be enabled + //['insert', ['link', 'picture', 'video']], // iframe tag will be sanitized TODO if iframe[class=note-video-clip] can be added into safe list, insert video can be enabled //['insert', ['link', 'picture']], // modal popup has issue somehow :( ['view', ['fullscreen', 'help']], ]; - const cleanPastedHTML = sanitizeXss; + const cleanPastedHTML = function(input) { + const badTags = [ + 'style', + 'script', + 'applet', + 'embed', + 'noframes', + 'noscript', + 'meta', + 'link', + 'button', + 'form', + ].join('|'); + const badPatterns = new RegExp( + `(?:${[ + `<(${badTags})s*[^>][\\s\\S]*?<\\/\\1>`, + `<(${badTags})[^>]*?\\/>`, + ].join('|')})`, + 'gi', + ); + let output = input; + // remove bad Tags + output = output.replace(badPatterns, ''); + // remove attributes ' style="..."' + const badAttributes = new RegExp( + `(?:${[ + 'on\\S+=([\'"]?).*?\\1', + 'href=([\'"]?)javascript:.*?\\2', + 'style=([\'"]?).*?\\3', + 'target=\\S+', + ].join('|')})`, + 'gi', + ); + output = output.replace(badAttributes, ''); + output = output.replace(/( { } return undefined; }; - let popupShown = false; inputs.each(function(idx, input) { mSummernotes[idx] = $(input).summernote({ placeholder, callbacks: { - onKeydown(e) { - if (popupShown) { - e.preventDefault(); - } - }, - onKeyup(e) { - if (popupShown) { - e.preventDefault(); - } - }, onInit(object) { const originalInput = this; - const setAutocomplete = function(jEditor) { - if (jEditor !== undefined) { - jEditor.escapeableTextComplete(mentions).on({ - 'textComplete:show'() { - popupShown = true; - }, - 'textComplete:hide'() { - popupShown = false; - }, - }); - } - }; - $(originalInput).on('submitted', function() { - // resetCommentInput has been called + $(originalInput).on('input', function() { + // when comment is submitted, the original textarea will be set to '', so shall we if (!this.value) { const sn = getSummernote(this); - sn && sn.summernote('code', ''); + sn && sn.summernote('reset'); + object && object.editingArea.find('.note-placeholder').show(); } }); const jEditor = object && object.editable; const toolbar = object && object.toolbar; - setAutocomplete(jEditor); + if (jEditor !== undefined) { + jEditor.escapeableTextComplete(mentions); + } if (toolbar !== undefined) { const fBtn = toolbar.find('.btn-fullscreen'); fBtn.on('click', function() { @@ -215,6 +138,7 @@ Template.editor.onRendered(() => { }); } }, + onImageUpload(files) { const $summernote = getSummernote(this); if (files && files.length > 0) { @@ -295,7 +219,7 @@ Template.editor.onRendered(() => { const someNote = getSummernote(object); const original = someNote.summernote('code'); const cleaned = cleanPastedHTML(original); //this is where to call whatever clean function you want. I have mine in a different file, called CleanPastedHTML. - someNote.summernote('code', ''); //clear original + someNote.summernote('reset'); //clear original someNote.summernote('pasteHTML', cleaned); //this sets the displayed content editor to the cleaned pasted code. }; setTimeout(function() { @@ -335,6 +259,8 @@ Template.editor.onRendered(() => { } }); +import sanitizeXss from 'xss'; + // XXX I believe we should compute a HTML rendered field on the server that // would handle markdown and user mentions. We can simply have two // fields, one source, and one compiled version (in HTML) and send only the @@ -356,12 +282,11 @@ Blaze.Template.registerHelper( } return member; }); - const mentionRegex = /\B@(?:(?:"([\w.\s]*)")|([\w.]+))/gi; // including space in username + const mentionRegex = /\B@([\w.]*)/gi; let currentMention; while ((currentMention = mentionRegex.exec(content)) !== null) { - const [fullMention, quoteduser, simple] = currentMention; - const username = quoteduser || simple; + const [fullMention, username] = currentMention; const knowedUser = _.findWhere(knowedUsers, { username }); if (!knowedUser) { continue; @@ -380,42 +305,38 @@ Blaze.Template.registerHelper( // `userId` to the popup as usual, and we need to store it in the DOM // using a data attribute. 'data-userId': knowedUser.userId, - [ASIS]: 'true', }, linkValue, ); content = content.replace(fullMention, Blaze.toHTML(link)); } + return HTML.Raw(sanitizeXss(content)); }), ); + Template.viewer.events({ // Viewer sometimes have click-able wrapper around them (for instance to edit // the corresponding text). Clicking a link shouldn't fire these actions, stop // we stop these event at the viewer component level. 'click a'(event, templateInstance) { - let prevent = true; + event.stopPropagation(); + + // XXX We hijack the build-in browser action because we currently don't have + // `_blank` attributes in viewer links, and the transformer function is + // handled by a third party package that we can't configure easily. Fix that + // by using directly `_blank` attribute in the rendered HTML. + event.preventDefault(); + const userId = event.currentTarget.dataset.userid; if (userId) { Popup.open('member').call({ userId }, event, templateInstance); } else { const href = event.currentTarget.href; - const child = event.currentTarget.firstElementChild; - if (child && child.tagName === 'IMG') { - prevent = false; - } else if (href) { + if (href) { window.open(href, '_blank'); } } - if (prevent) { - event.stopPropagation(); - - // XXX We hijack the build-in browser action because we currently don't have - // `_blank` attributes in viewer links, and the transformer function is - // handled by a third party package that we can't configure easily. Fix that - // by using directly `_blank` attribute in the rendered HTML. - event.preventDefault(); - } }, }); -- cgit v1.2.3-1-g7c22 From 12ab8fac5db9c5ac8069d0ca2bca340d6004a25b Mon Sep 17 00:00:00 2001 From: Lauri Ojansivu Date: Tue, 24 Mar 2020 11:04:04 +0200 Subject: Fix Rich editor can not be disabled, regression from changes yesterday at Wekan v3.85. Thanks to uusijani, vjrj and xet7 ! Closes #2967, closes #104 --- client/components/main/editor.js | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) (limited to 'client') diff --git a/client/components/main/editor.js b/client/components/main/editor.js index 97a96b8e..18b823a2 100755 --- a/client/components/main/editor.js +++ b/client/components/main/editor.js @@ -1,7 +1,5 @@ Template.editor.onRendered(() => { const textareaSelector = 'textarea'; - const enableRicherEditor = - Meteor.settings.public.RICHER_CARD_COMMENT_EDITOR || true; const mentions = [ // User mentions { @@ -32,7 +30,7 @@ Template.editor.onRendered(() => { autosize($textarea); $textarea.escapeableTextComplete(mentions); }; - if (enableRicherEditor) { + if (Meteor.settings.public.RICHER_CARD_COMMENT_EDITOR !== false) { const isSmall = Utils.isMiniScreen(); const toolbar = isSmall ? [ -- cgit v1.2.3-1-g7c22 From b9099a8b7ea6f63c79bdcbb871cb993b2cb7e325 Mon Sep 17 00:00:00 2001 From: Lauri Ojansivu Date: Tue, 24 Mar 2020 20:39:49 +0200 Subject: 1) Fix Pasting text into a card is adding a line before and after (and multiplies by pasting more) by changing paste "p" to "br". 2) Fixes to summernote and markdown comment editors, related to keeping them open when adding comments, having @member mention not close card, and disabling clicking of @member mention. Thanks to xet7 ! Closes #2890 --- client/components/activities/comments.js | 7 ++ client/components/main/editor.jade | 7 +- client/components/main/editor.js | 51 +++++++- client/lib/popup.js | 204 +++++++++++++++++++++++++++++++ 4 files changed, 263 insertions(+), 6 deletions(-) (limited to 'client') diff --git a/client/components/activities/comments.js b/client/components/activities/comments.js index 50ca019b..e885459e 100644 --- a/client/components/activities/comments.js +++ b/client/components/activities/comments.js @@ -33,6 +33,13 @@ BlazeComponent.extendComponent({ cardId, }); resetCommentInput(input); + // With Richer editor is in use, and comment is submitted, + // clear comment form with JQuery. Id #summernote is defined + // at client/components/main/editor.jade where it previously was + // id=id, now it is id="summernote". + if (Meteor.settings.public.RICHER_CARD_COMMENT_EDITOR === 'true') { + $('#summernote').summernote('code', ''); + } Tracker.flush(); autosize.update(input); input.trigger('submitted'); diff --git a/client/components/main/editor.jade b/client/components/main/editor.jade index dbd61715..5c5454ee 100644 --- a/client/components/main/editor.jade +++ b/client/components/main/editor.jade @@ -1,8 +1,13 @@ template(name="editor") + // With Richer editor is in use, and comment is submitted, + // clear comment form with JQuery Comment at + // client/components/activities/comments.js . Id #summernote is defined + // here at client/components/main/editor.jade where it previously was + // id=id, now it is id="summernote". textarea.editor( dir="auto" class="{{class}}" - id=id + id="summernote" autofocus=autofocus placeholder="{{_ 'comment-placeholder'}}") +Template.contentBlock diff --git a/client/components/main/editor.js b/client/components/main/editor.js index 18b823a2..3f09d284 100755 --- a/client/components/main/editor.js +++ b/client/components/main/editor.js @@ -30,7 +30,7 @@ Template.editor.onRendered(() => { autosize($textarea); $textarea.escapeableTextComplete(mentions); }; - if (Meteor.settings.public.RICHER_CARD_COMMENT_EDITOR !== false) { + if (Meteor.settings.public.RICHER_CARD_COMMENT_EDITOR === 'true') { const isSmall = Utils.isMiniScreen(); const toolbar = isSmall ? [ @@ -108,10 +108,37 @@ Template.editor.onRendered(() => { } return undefined; }; + // Prevent @member mentions on Add Comment input field + // from closing card, part 1. + let popupShown = false; inputs.each(function(idx, input) { mSummernotes[idx] = $(input).summernote({ placeholder, + // Prevent @member mentions on Add Comment input field + // from closing card, part 2. + onKeydown(e) { + if (popupShown) { + e.preventDefault(); + } + }, + onKeyup(e) { + if (popupShown) { + e.preventDefault(); + } + }, callbacks: { + // Prevent @member mentions on Add Comment input field + // from closing card, part 3. + onKeydown(e) { + if (popupShown) { + e.preventDefault(); + } + }, + onKeyup(e) { + if (popupShown) { + e.preventDefault(); + } + }, onInit(object) { const originalInput = this; $(originalInput).on('input', function() { @@ -136,7 +163,6 @@ Template.editor.onRendered(() => { }); } }, - onImageUpload(files) { const $summernote = getSummernote(this); if (files && files.length > 0) { @@ -215,6 +241,12 @@ Template.editor.onRendered(() => { const thisNote = this; const updatePastedText = function(object) { const someNote = getSummernote(object); + // Fix Pasting text into a card is adding a line before and after + // (and multiplies by pasting more) by changing paste "p" to "br". + // Fixes https://github.com/wekan/wekan/2890 . + // == Fix Start == + someNote.execCommand('defaultParagraphSeparator', false, 'br'); + // == Fix End == const original = someNote.summernote('code'); const cleaned = cleanPastedHTML(original); //this is where to call whatever clean function you want. I have mine in a different file, called CleanPastedHTML. someNote.summernote('reset'); //clear original @@ -291,11 +323,17 @@ Blaze.Template.registerHelper( } const linkValue = [' ', at, knowedUser.username]; - let linkClass = 'atMention js-open-member'; + //let linkClass = 'atMention js-open-member'; + let linkClass = 'atMention'; if (knowedUser.userId === Meteor.userId()) { linkClass += ' me'; } - const link = HTML.A( + // 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 + // text can be selected more easily. + //const link = HTML.A( + const link = HTML.U( { class: linkClass, // XXX Hack. Since we stringify this render function result below with @@ -329,7 +367,10 @@ Template.viewer.events({ const userId = event.currentTarget.dataset.userid; if (userId) { - Popup.open('member').call({ userId }, event, templateInstance); + // Prevent @member mentions on Add Comment input field + // from closing card, part 4. + PopupNoClose.open('member').call({ userId }, event, templateInstance); + event.preventDefault(); } else { const href = event.currentTarget.href; if (href) { diff --git a/client/lib/popup.js b/client/lib/popup.js index 8095fbd2..8a55c2df 100644 --- a/client/lib/popup.js +++ b/client/lib/popup.js @@ -206,3 +206,207 @@ escapeActions.forEach(actionName => { }, ); }); + +// Prevent @member mentions on Add Comment input field +// from closing card, part 5. +// This duplicate below of above popup function is needed, because at +// wekan/components/main/editor.js at bottom is popping up visible +// @member mention, and it seems to trigger closing also card popup, +// so in below closing popup is disabled. +window.PopupNoClose = new (class { + constructor() { + // The template we use to render popups + this.template = Template.popup; + + // We only want to display one popup at a time and we keep the view object + // in this `Popup.current` variable. If there is no popup currently opened + // the value is `null`. + this.current = null; + + // It's possible to open a sub-popup B from a popup A. In that case we keep + // the data of popup A so we can return back to it. Every time we open a new + // popup the stack grows, every time we go back the stack decrease, and if + // we close the popup the stack is reseted to the empty stack []. + this._stack = []; + + // We invalidate this internal dependency every time the top of the stack + // has changed and we want to re-render a popup with the new top-stack data. + this._dep = new Tracker.Dependency(); + } + + /// This function returns a callback that can be used in an event map: + /// Template.tplName.events({ + /// 'click .elementClass': Popup.open("popupName"), + /// }); + /// The popup inherit the data context of its parent. + open(name) { + const self = this; + const popupName = `${name}Popup`; + function clickFromPopup(evt) { + return $(evt.target).closest('.js-pop-over').length !== 0; + } + return function(evt) { + // If a popup is already opened, clicking again on the opener element + // should close it -- and interrupt the current `open` function. + /* + if (self.isOpen()) { + const previousOpenerElement = self._getTopStack().openerElement; + if (previousOpenerElement === evt.currentTarget) { + self.close(); + return; + } else { + $(previousOpenerElement).removeClass('is-active'); + } + } + */ + // We determine the `openerElement` (the DOM element that is being clicked + // and the one we take in reference to position the popup) from the event + // if the popup has no parent, or from the parent `openerElement` if it + // has one. This allows us to position a sub-popup exactly at the same + // position than its parent. + let openerElement; + if (clickFromPopup(evt)) { + openerElement = self._getTopStack().openerElement; + } else { + self._stack = []; + openerElement = evt.currentTarget; + } + $(openerElement).addClass('is-active'); + evt.preventDefault(); + + // We push our popup data to the stack. The top of the stack is always + // used as the data source for our current popup. + self._stack.push({ + popupName, + openerElement, + hasPopupParent: clickFromPopup(evt), + title: self._getTitle(popupName), + depth: self._stack.length, + offset: self._getOffset(openerElement), + dataContext: (this && this.currentData && this.currentData()) || this, + }); + + // If there are no popup currently opened we use the Blaze API to render + // one into the DOM. We use a reactive function as the data parameter that + // return the complete along with its top element and depends on our + // internal dependency that is being invalidated every time the top + // element of the stack has changed and we want to update the popup. + // + // Otherwise if there is already a popup open we just need to invalidate + // our internal dependency, and since we just changed the top element of + // our internal stack, the popup will be updated with the new data. + if (!self.isOpen()) { + self.current = Blaze.renderWithData( + self.template, + () => { + self._dep.depend(); + return { ...self._getTopStack(), stack: self._stack }; + }, + document.body, + ); + } else { + self._dep.changed(); + } + }; + } + + /// This function returns a callback that can be used in an event map: + /// Template.tplName.events({ + /// 'click .elementClass': Popup.afterConfirm("popupName", function() { + /// // What to do after the user has confirmed the action + /// }), + /// }); + afterConfirm(name, action) { + const self = this; + + return function(evt, tpl) { + const context = (this.currentData && this.currentData()) || this; + context.__afterConfirmAction = action; + self.open(name).call(context, evt, tpl); + }; + } + + /// The public reactive state of the popup. + isOpen() { + this._dep.changed(); + return Boolean(this.current); + } + + /// In case the popup was opened from a parent popup we can get back to it + /// with this `Popup.back()` function. You can go back several steps at once + /// by providing a number to this function, e.g. `Popup.back(2)`. In this case + /// intermediate popup won't even be rendered on the DOM. If the number of + /// steps back is greater than the popup stack size, the popup will be closed. + back(n = 1) { + if (this._stack.length > n) { + _.times(n, () => this._stack.pop()); + this._dep.changed(); + } + // else { + // this.close(); + //} + } + + /// Close the current opened popup. + /* + close() { + if (this.isOpen()) { + Blaze.remove(this.current); + this.current = null; + + const openerElement = this._getTopStack().openerElement; + $(openerElement).removeClass('is-active'); + + this._stack = []; + } + } + */ + + getOpenerComponent() { + const { openerElement } = Template.parentData(4); + return BlazeComponent.getComponentForElement(openerElement); + } + + // An utility fonction that returns the top element of the internal stack + _getTopStack() { + return this._stack[this._stack.length - 1]; + } + + // We automatically calculate the popup offset from the reference element + // position and dimensions. We also reactively use the window dimensions to + // ensure that the popup is always visible on the screen. + _getOffset(element) { + const $element = $(element); + return () => { + Utils.windowResizeDep.depend(); + + if (Utils.isMiniScreen()) return { left: 0, top: 0 }; + + const offset = $element.offset(); + const popupWidth = 300 + 15; + return { + left: Math.min(offset.left, $(window).width() - popupWidth), + top: offset.top + $element.outerHeight(), + }; + }; + } + + // We get the title from the translation files. Instead of returning the + // result, we return a function that compute the result and since `TAPi18n.__` + // is a reactive data source, the title will be changed reactively. + _getTitle(popupName) { + return () => { + const translationKey = `${popupName}-title`; + + // XXX There is no public API to check if there is an available + // translation for a given key. So we try to translate the key and if the + // translation output equals the key input we deduce that no translation + // was available and returns `false`. There is a (small) risk a false + // positives. + const title = TAPi18n.__(translationKey); + // when popup showed as full of small screen, we need a default header to clearly see [X] button + const defaultTitle = Utils.isMiniScreen() ? '' : false; + return title !== translationKey ? title : defaultTitle; + }; + } +})(); -- cgit v1.2.3-1-g7c22 From 5bd0459cc25183ecc779103fb56e14eb6707095d Mon Sep 17 00:00:00 2001 From: Lauri Ojansivu Date: Wed, 25 Mar 2020 13:35:54 +0200 Subject: Hide duplicate "Hide system messages" at Change Settings/Member Settings, because it's also on card slider. Thanks to notohiro and xet7 ! Closes #2837 --- client/components/users/userHeader.jade | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) (limited to 'client') diff --git a/client/components/users/userHeader.jade b/client/components/users/userHeader.jade index 9306d21d..1cd9da6b 100644 --- a/client/components/users/userHeader.jade +++ b/client/components/users/userHeader.jade @@ -98,12 +98,12 @@ template(name="changeLanguagePopup") template(name="changeSettingsPopup") ul.pop-over-list - li - a.js-toggle-system-messages - i.fa.fa-comments-o - | {{_ 'hide-system-messages'}} - if hiddenSystemMessages - i.fa.fa-check + //li + // a.js-toggle-system-messages + // i.fa.fa-comments-o + // | {{_ 'hide-system-messages'}} + // if hiddenSystemMessages + // i.fa.fa-check li a.js-toggle-desktop-drag-handles i.fa.fa-arrows -- cgit v1.2.3-1-g7c22