diff options
author | floatinghotpot <rjfun.mobile@gmail.com> | 2015-12-07 11:15:57 +0800 |
---|---|---|
committer | floatinghotpot <rjfun.mobile@gmail.com> | 2015-12-07 11:15:57 +0800 |
commit | 011f53ad0828c0979d15e232abf501180c741288 (patch) | |
tree | 41330fe4e47c443dd9fefd0493f30a186e4c4999 | |
parent | d4c5310d65cbdfbd002288d33eba429ace33bc3c (diff) | |
download | wekan-011f53ad0828c0979d15e232abf501180c741288.tar.gz wekan-011f53ad0828c0979d15e232abf501180c741288.tar.bz2 wekan-011f53ad0828c0979d15e232abf501180c741288.zip |
add: invite user via email, invited user can accept or decline, allow member to quit
-rw-r--r-- | .meteor/packages | 1 | ||||
-rw-r--r-- | client/components/boards/boardsList.jade | 25 | ||||
-rw-r--r-- | client/components/boards/boardsList.js | 18 | ||||
-rw-r--r-- | client/components/boards/boardsList.styl | 9 | ||||
-rw-r--r-- | client/components/sidebar/sidebar.jade | 51 | ||||
-rw-r--r-- | client/components/sidebar/sidebar.js | 109 | ||||
-rw-r--r-- | client/components/users/userAvatar.js | 5 | ||||
-rw-r--r-- | client/components/users/userAvatar.styl | 4 | ||||
-rw-r--r-- | i18n/en.i18n.json | 15 | ||||
-rw-r--r-- | models/boards.js | 86 | ||||
-rw-r--r-- | models/users.js | 105 |
11 files changed, 367 insertions, 61 deletions
diff --git a/.meteor/packages b/.meteor/packages index bdb0b60e..98c06cc9 100644 --- a/.meteor/packages +++ b/.meteor/packages @@ -33,6 +33,7 @@ service-configuration useraccounts:core useraccounts:unstyled useraccounts:flow-routing +email # Utilities check diff --git a/client/components/boards/boardsList.jade b/client/components/boards/boardsList.jade index 11333eee..464f9b97 100644 --- a/client/components/boards/boardsList.jade +++ b/client/components/boards/boardsList.jade @@ -3,11 +3,22 @@ template(name="boardList") ul.board-list.clearfix each boards li(class="{{#if isStarred}}starred{{/if}}" class=colorClass) - a.js-open-board(href="{{pathFor 'board' id=_id slug=slug}}") - span.details - span.board-list-item-name= title - i.fa.js-star-board( - class="fa-star{{#if isStarred}} is-star-active{{else}}-o{{/if}}" - title="{{_ 'star-board-title'}}") + if isInvited + .board-list-item + span.details + span.board-list-item-name= title + i.fa.js-star-board( + class="fa-star{{#if isStarred}} is-star-active{{else}}-o{{/if}}" + title="{{_ 'star-board-title'}}") + p.board-list-item-desc {{_ 'just-invited'}} + button.js-accept-invite.primary {{_ 'accept'}} + button.js-decline-invite {{_ 'decline'}} + else + a.js-open-board.board-list-item(href="{{pathFor 'board' id=_id slug=slug}}") + span.details + span.board-list-item-name= title + i.fa.js-star-board( + class="fa-star{{#if isStarred}} is-star-active{{else}}-o{{/if}}" + title="{{_ 'star-board-title'}}") li.js-add-board - a.label {{_ 'add-board'}} + a.board-list-item.label {{_ 'add-board'}} diff --git a/client/components/boards/boardsList.js b/client/components/boards/boardsList.js index 1a2d3c9a..131adf9d 100644 --- a/client/components/boards/boardsList.js +++ b/client/components/boards/boardsList.js @@ -17,6 +17,11 @@ BlazeComponent.extendComponent({ return user && user.hasStarred(this.currentData()._id); }, + isInvited() { + const user = Meteor.user(); + return user && user.isInvitedTo(this.currentData()._id); + }, + events() { return [{ 'click .js-add-board': Popup.open('createBoard'), @@ -25,6 +30,19 @@ BlazeComponent.extendComponent({ Meteor.user().toggleBoardStar(boardId); evt.preventDefault(); }, + 'click .js-accept-invite'() { + const boardId = this.currentData()._id; + Meteor.user().removeInvite(boardId); + }, + 'click .js-decline-invite'() { + const boardId = this.currentData()._id; + Meteor.call('quitBoard', boardId, (err, ret) => { + if (!err && ret) { + Meteor.user().removeInvite(boardId); + FlowRouter.go('home'); + } + }); + }, }]; }, }).register('boardList'); diff --git a/client/components/boards/boardsList.styl b/client/components/boards/boardsList.styl index 9978fab8..e24940a0 100644 --- a/client/components/boards/boardsList.styl +++ b/client/components/boards/boardsList.styl @@ -14,7 +14,7 @@ $spaceBetweenTiles = 16px .fa-star-o opacity: 1 - a + .board-list-item background-color: #999 color: #f6f6f6 height: 90px @@ -40,6 +40,13 @@ $spaceBetweenTiles = 16px font-weight: 400 line-height: 22px + .board-list-item-desc + color: rgba(255, 255, 255, .5) + display: block + font-size: 10px + font-weight: 400 + line-height: 18px + .js-add-board text-align:center diff --git a/client/components/sidebar/sidebar.jade b/client/components/sidebar/sidebar.jade index f98ea4ee..3a5c7fdb 100644 --- a/client/components/sidebar/sidebar.jade +++ b/client/components/sidebar/sidebar.jade @@ -33,6 +33,13 @@ template(name="membersWidget") a.member.add-member.js-manage-board-members i.fa.fa-plus .clearfix + if isInvited + hr + p + i.fa.fa-exclamation-circle + | {{_ 'just-invited'}} + button.js-member-invite-accept.primary {{_ 'accept'}} + button.js-member-invite-decline {{_ 'decline'}} template(name="labelsWidget") .board-widget.board-widget-labels @@ -56,6 +63,10 @@ template(name="memberPopup") h3 .js-profile= user.profile.fullname p.quiet @#{user.username} + if isInvited + p + i.fa.fa-exclamation-circle + | {{_ 'not-accepted-yet'}} ul.pop-over-list li @@ -68,9 +79,7 @@ template(name="memberPopup") span.quiet (#{memberType}) li if $eq currentUser._id userId - //- - XXX Not implemented! - // a.js-leave-member {{_ 'leave-board'}} + a.js-leave-member {{_ 'leave-board'}} else a.js-remove-member {{_ 'remove-from-board'}} @@ -83,23 +92,29 @@ template(name="addMemberPopup") .js-search-member +esInput(index="users") - ul.pop-over-list - +esEach(index="users") - li.item.js-member-item(class="{{#if isBoardMember}}disabled{{/if}}") - a.name.js-select-member(title="{{profile.name}} ({{username}})") - +userAvatar(userId=_id esSearch=true) - span.full-name - = profile.fullname - | (<span class="username">{{username}}</span>) - if isBoardMember - .quiet ({{_ 'joined'}}) + if loading.get + +spinner + else if error.get + .warning {{_ error.get}} + else + ul.pop-over-list + +esEach(index="users") + li.item.js-member-item(class="{{#if isBoardMember}}disabled{{/if}}") + a.name.js-select-member(title="{{profile.name}} ({{username}})") + +userAvatar(userId=_id esSearch=true) + span.full-name + = profile.fullname + | (<span class="username">{{username}}</span>) + if isBoardMember + .quiet ({{_ 'joined'}}) - +ifEsIsSearching(index='users') - +spinner + +ifEsIsSearching(index='users') + +spinner - +ifEsHasNoResults(index="users") - .manage-member-section - p.quiet {{_ 'no-results'}} + +ifEsHasNoResults(index="users") + .manage-member-section + p.quiet {{_ 'no-results'}} + button.js-email-invite.primary.full {{_ 'email-invite'}} template(name="changePermissionsPopup") ul.pop-over-list diff --git a/client/components/sidebar/sidebar.js b/client/components/sidebar/sidebar.js index ef071fe0..5b58dbd9 100644 --- a/client/components/sidebar/sidebar.js +++ b/client/components/sidebar/sidebar.js @@ -117,6 +117,9 @@ Template.memberPopup.helpers({ const type = Users.findOne(this.userId).isBoardAdmin() ? 'admin' : 'normal'; return TAPi18n.__(type).toLowerCase(); }, + isInvited() { + return Users.findOne(this.userId).isInvitedTo(Session.get('currentBoard')); + }, }); Template.memberPopup.events({ @@ -132,8 +135,13 @@ Template.memberPopup.events({ Popup.close(); }), 'click .js-leave-member'() { - // XXX Not implemented - Popup.close(); + const currentBoard = Boards.findOne(Session.get('currentBoard')); + Meteor.call('quitBoard', currentBoard, (err, ret) => { + if (!ret && ret) { + Popup.close(); + FlowRouter.go('home'); + } + }); }, }); @@ -146,9 +154,29 @@ Template.removeMemberPopup.helpers({ }, }); +Template.membersWidget.helpers({ + isInvited() { + const user = Meteor.user(); + return user && user.isInvitedTo(Session.get('currentBoard')); + }, +}); + Template.membersWidget.events({ 'click .js-member': Popup.open('member'), 'click .js-manage-board-members': Popup.open('addMember'), + 'click .js-member-invite-accept'() { + const boardId = Session.get('currentBoard'); + Meteor.user().removeInvite(boardId); + }, + 'click .js-member-invite-decline'() { + const boardId = Session.get('currentBoard'); + Meteor.call('quitBoard', boardId, (err, ret) => { + if (!err && ret) { + Meteor.user().removeInvite(boardId); + FlowRouter.go('home'); + } + }); + }, }); Template.labelsWidget.events({ @@ -194,25 +222,76 @@ function draggableMembersLabelsWidgets() { Template.membersWidget.onRendered(draggableMembersLabelsWidgets); Template.labelsWidget.onRendered(draggableMembersLabelsWidgets); -Template.addMemberPopup.helpers({ +BlazeComponent.extendComponent({ + template() { + return 'addMemberPopup'; + }, + + onCreated() { + this.error = new ReactiveVar(''); + this.loading = new ReactiveVar(false); + }, + + onRendered() { + this.find('.js-search-member input').focus(); + this.setLoading(false); + }, + isBoardMember() { - const user = Users.findOne(this._id); + const userId = this.currentData()._id; + const user = Users.findOne(userId); return user && user.isBoardMember(); }, -}); -Template.addMemberPopup.events({ - 'click .js-select-member'() { - const userId = this._id; - const currentBoard = Boards.findOne(Session.get('currentBoard')); - currentBoard.addMember(userId); - Popup.close(); + isValidEmail(email) { + return SimpleSchema.RegEx.Email.test(email); }, -}); -Template.addMemberPopup.onRendered(function() { - this.find('.js-search-member input').focus(); -}); + setError(error) { + this.error.set(error); + }, + + setLoading(w) { + this.loading.set(w); + }, + + isLoading() { + return this.loading.get(); + }, + + inviteUser(idNameEmail) { + const boardId = Session.get('currentBoard'); + this.setLoading(true); + 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.close(); + }); + }, + + events() { + return [{ + 'keyup input'() { + this.setError(''); + }, + 'click .js-select-member'() { + const userId = this.currentData()._id; + const currentBoard = Boards.findOne(Session.get('currentBoard')); + if (currentBoard.memberIndex(userId)<0) { + 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'); + }, + }]; + }, +}).register('addMemberPopup'); Template.changePermissionsPopup.events({ 'click .js-set-admin, click .js-set-normal'(event) { diff --git a/client/components/users/userAvatar.js b/client/components/users/userAvatar.js index 1f1da251..1e531882 100644 --- a/client/components/users/userAvatar.js +++ b/client/components/users/userAvatar.js @@ -22,8 +22,11 @@ Template.userAvatar.helpers({ }, presenceStatusClassName() { + const user = Users.findOne(this.userId); const userPresence = presences.findOne({ userId: this.userId }); - if (!userPresence) + if (user && user.isInvitedTo(Session.get('currentBoard'))) + return 'pending'; + else if (!userPresence) return 'disconnected'; else if (Session.equals('currentBoard', userPresence.state.currentBoardId)) return 'active'; diff --git a/client/components/users/userAvatar.styl b/client/components/users/userAvatar.styl index 83257792..b962b01c 100644 --- a/client/components/users/userAvatar.styl +++ b/client/components/users/userAvatar.styl @@ -56,6 +56,10 @@ avatar-radius = 50% background: #bdbdbd border-color: #ededed + &.pending + background: #e44242 + border-color: #f1dada + .edit-avatar position: absolute top: 0 diff --git a/i18n/en.i18n.json b/i18n/en.i18n.json index 3f9e4e8b..04c0959f 100644 --- a/i18n/en.i18n.json +++ b/i18n/en.i18n.json @@ -1,4 +1,5 @@ { + "accept": "Accept", "actions": "Actions", "activities": "Activities", "activity": "Activity", @@ -108,6 +109,7 @@ "createBoardPopup-title": "Create Board", "createLabelPopup-title": "Create Label", "current": "current", + "decline": "Decline", "default-avatar": "Default avatar", "delete": "Delete", "deleteLabelPopup-title": "Delete Label?", @@ -126,14 +128,25 @@ "email": "Email", "email-enrollAccount-subject": "An account created for you on __url__", "email-enrollAccount-text": "Hello __user__,\n\nTo start using the service, simply click the link below.\n\n__url__\n\nThanks.\n", + "email-fail": "Sending email failed", + "email-invalid": "Invalid email", + "email-invite": "Invite via Email", + "email-invite-subject": "__inviter__ sent you an invitation", + "email-invite-text": "Dear __user__,\n\n__inviter__ invites you to join board \"__board__\" for collaborations.\n\nPlease follow the link below:\n\n__url__\n\nThanks.\n", "email-resetPassword-subject": "Reset your password on __url__", "email-resetPassword-text": "Hello __user__,\n\nTo reset your password, simply click the link below.\n\n__url__\n\nThanks.\n", "email-verifyEmail-subject": "Verify your email address on __url__", "email-verifyEmail-text": "Hello __user__,\n\nTo verify your account email, simply click the link below.\n\n__url__\n\nThanks.\n", + "email-sent": "Email sent", + "error-board-doesNotExist": "This board does not exist", + "error-board-notAdmin": "You need to be admin of this board to do that", "error-board-notAMember": "You need to be a member of this board to do that", "error-json-malformed": "Your text is not valid JSON", "error-json-schema": "Your JSON data does not include the proper information in the correct format", "error-list-doesNotExist": "This list does not exist", + "error-user-doesNotExist": "This user does not exist", + "error-user-notAllowSelf": "This action on self is not allowed", + "error-user-notCreated": "This user is not created", "filter": "Filter", "filter-cards": "Filter Cards", "filter-clear": "Clear filter", @@ -155,6 +168,7 @@ "info": "Infos", "initials": "Initials", "joined": "joined", + "just-invited": "You are just invited to this board", "keyboard-shortcuts": "Keyboard shortcuts", "label-create": "Create a new label", "label-default": "%s label (default)", @@ -191,6 +205,7 @@ "no-results": "No results", "normal": "Normal", "normal-desc": "Can view and edit cards. Can't change settings.", + "not-accepted-yet": "Invitation not accepted yet", "optional": "optional", "or": "or", "page-maybe-private": "This page may be private. You may be able to view it by <a href='%s'>logging in</a>.", diff --git a/models/boards.js b/models/boards.js index 98d6ec77..c10e51a3 100644 --- a/models/boards.js +++ b/models/boards.js @@ -80,8 +80,7 @@ Boards.helpers({ }, lists() { - return Lists.find({ boardId: this._id, archived: false }, - { sort: { sort: 1 }}); + return Lists.find({ boardId: this._id, archived: false }, { sort: { sort: 1 }}); }, activities() { @@ -92,6 +91,14 @@ Boards.helpers({ return _.where(this.members, {isActive: true}); }, + activeAdmins() { + return _.where(this.members, {isActive: true, isAdmin: true}); + }, + + memberUsers() { + return Users.find({ _id: {$in: _.pluck(this.members, 'userId')} }); + }, + getLabel(name, color) { return _.findWhere(this.labels, { name, color }); }, @@ -172,20 +179,30 @@ Boards.mutations({ addMember(memberId) { const memberIndex = this.memberIndex(memberId); if (memberIndex === -1) { - return { - $push: { - members: { - userId: memberId, - isAdmin: false, - isActive: true, + const xIndex = this.memberIndex('x'); + if (xIndex === -1) { + return { + $push: { + members: { + userId: memberId, + isAdmin: false, + isActive: true, + }, }, - }, - }; + }; + } else { + return { + $set: { + [`members.${xIndex}.userId`]: memberId, + [`members.${xIndex}.isActive`]: true, + [`members.${xIndex}.isAdmin`]: false, + }, + }; + } } else { return { $set: { [`members.${memberIndex}.isActive`]: true, - [`members.${memberIndex}.isAdmin`]: false, }, }; } @@ -194,16 +211,34 @@ Boards.mutations({ removeMember(memberId) { const memberIndex = this.memberIndex(memberId); - return { - $set: { - [`members.${memberIndex}.isActive`]: false, - }, - }; + // we do not allow the only one admin to be removed + const allowRemove = (!this.members[memberIndex].isAdmin) || (this.activeAdmins().length > 1); + + if (allowRemove) { + return { + $set: { + [`members.${memberIndex}.userId`]: 'x', + [`members.${memberIndex}.isActive`]: false, + [`members.${memberIndex}.isAdmin`]: false, + }, + }; + } else { + return { + $set: { + [`members.${memberIndex}.isActive`]: true, + }, + }; + } }, setMemberPermission(memberId, isAdmin) { const memberIndex = this.memberIndex(memberId); + // do not allow change permission of self + if (memberId === Meteor.userId()) { + isAdmin = this.members[memberIndex].isAdmin; + } + return { $set: { [`members.${memberIndex}.isAdmin`]: isAdmin, @@ -240,9 +275,7 @@ if (Meteor.isServer) { return false; // If there is more than one admin, it's ok to remove anyone - const nbAdmins = _.filter(doc.members, (member) => { - return member.isAdmin; - }).length; + const nbAdmins = _.where(doc.members, {isActive: true, isAdmin: true}).length; if (nbAdmins > 1) return false; @@ -256,6 +289,21 @@ if (Meteor.isServer) { }, fetch: ['members'], }); + + Meteor.methods({ + quitBoard(boardId) { + check(boardId, String); + const board = Boards.findOne(boardId); + if (board) { + const userId = Meteor.userId(); + const index = board.memberIndex(userId); + if (index>=0) { + board.removeMember(userId); + return true; + } else throw new Meteor.Error('error-board-notAMember'); + } else throw new Meteor.Error('error-board-doesNotExist'); + }, + }); } Boards.before.insert((userId, doc) => { diff --git a/models/users.js b/models/users.js index 49c30127..2c9ae380 100644 --- a/models/users.js +++ b/models/users.js @@ -41,6 +41,16 @@ Users.helpers({ return _.contains(starredBoards, boardId); }, + invitedBoards() { + const {invitedBoards = []} = this.profile; + return Boards.find({archived: false, _id: {$in: invitedBoards}}); + }, + + isInvitedTo(boardId) { + const {invitedBoards = []} = this.profile; + return _.contains(invitedBoards, boardId); + }, + getAvatarUrl() { // Although we put the avatar picture URL in the `profile` object, we need // to support Sandstorm which put in the `picture` attribute by default. @@ -90,6 +100,22 @@ Users.mutations({ }; }, + addInvite(boardId) { + return { + $addToSet: { + 'profile.invitedBoards': boardId, + }, + }; + }, + + removeInvite(boardId) { + return { + $pull: { + 'profile.invitedBoards': boardId, + }, + }; + }, + setAvatarUrl(avatarUrl) { return { $set: { 'profile.avatarUrl': avatarUrl }}; }, @@ -107,6 +133,85 @@ Meteor.methods({ }, }); +if (Meteor.isServer) { + Meteor.methods({ + // we accept userId, username, email + inviteUserToBoard(username, boardId) { + check(username, String); + check(boardId, String); + + const inviter = Meteor.user(); + const board = Boards.findOne(boardId); + const allowInvite = inviter && + board && + board.members && + _.contains(_.pluck(board.members, 'userId'), inviter._id) && + _.where(board.members, {userId: inviter._id})[0].isActive && + _.where(board.members, {userId: inviter._id})[0].isAdmin; + if (!allowInvite) throw new Meteor.Error('error-board-notAMember'); + + this.unblock(); + + const posAt = username.indexOf('@'); + let user = null; + if (posAt>=0) { + user = Users.findOne({emails: {$elemMatch: {address: username}}}); + } else { + user = Users.findOne(username) || Users.findOne({ username }); + } + if (user) { + if (user._id === inviter._id) throw new Meteor.Error('error-user-notAllowSelf'); + } else { + if (posAt <= 0) throw new Meteor.Error('error-user-doesNotExist'); + + const email = username; + username = email.substring(0, posAt); + const newUserId = Accounts.createUser({ username, email }); + if (!newUserId) throw new Meteor.Error('error-user-notCreated'); + // assume new user speak same language with inviter + if (inviter.profile && inviter.profile.language) { + Users.update(newUserId, { + $set: { + 'profile.language': inviter.profile.language, + }, + }); + } + Accounts.sendEnrollmentEmail(newUserId); + user = Users.findOne(newUserId); + } + + board.addMember(user._id); + user.addInvite(boardId); + + if (!process.env.MAIL_URL || (!Email)) return { username: user.username }; + + try { + let rootUrl = Meteor.absoluteUrl.defaultOptions.rootUrl || ''; + if (!rootUrl.endsWith('/')) rootUrl = `${rootUrl}/`; + const boardUrl = `${rootUrl}b/${board._id}/${board.slug}`; + + const vars = { + user: user.username, + inviter: inviter.username, + board: board.title, + url: boardUrl, + }; + const lang = user.getLanguage(); + Email.send({ + to: user.emails[0].address, + from: Accounts.emailTemplates.from, + subject: TAPi18n.__('email-invite-subject', vars, lang), + text: TAPi18n.__('email-invite-text', vars, lang), + }); + } catch (e) { + throw new Meteor.Error('email-fail', e.message); + } + + return { username: user.username, email: user.emails[0].address }; + }, + }); +} + Users.before.insert((userId, doc) => { doc.profile = doc.profile || {}; |