summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorfloatinghotpot <rjfun.mobile@gmail.com>2015-12-07 11:15:57 +0800
committerfloatinghotpot <rjfun.mobile@gmail.com>2015-12-07 11:15:57 +0800
commit011f53ad0828c0979d15e232abf501180c741288 (patch)
tree41330fe4e47c443dd9fefd0493f30a186e4c4999
parentd4c5310d65cbdfbd002288d33eba429ace33bc3c (diff)
downloadwekan-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/packages1
-rw-r--r--client/components/boards/boardsList.jade25
-rw-r--r--client/components/boards/boardsList.js18
-rw-r--r--client/components/boards/boardsList.styl9
-rw-r--r--client/components/sidebar/sidebar.jade51
-rw-r--r--client/components/sidebar/sidebar.js109
-rw-r--r--client/components/users/userAvatar.js5
-rw-r--r--client/components/users/userAvatar.styl4
-rw-r--r--i18n/en.i18n.json15
-rw-r--r--models/boards.js86
-rw-r--r--models/users.js105
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 || {};