Jelajahi Sumber

add: invite user via email, invited user can accept or decline, allow member to quit

floatinghotpot 9 tahun lalu
induk
melakukan
011f53ad08

+ 1 - 0
.meteor/packages

@@ -33,6 +33,7 @@ service-configuration
 useraccounts:core
 useraccounts:unstyled
 useraccounts:flow-routing
+email
 
 # Utilities
 check

+ 18 - 7
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'}}

+ 18 - 0
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');

+ 8 - 1
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
 

+ 33 - 18
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

+ 94 - 15
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) {

+ 4 - 1
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';

+ 4 - 0
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

+ 15 - 0
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>.",

+ 67 - 19
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) => {

+ 105 - 0
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 || {};