فهرست منبع

Admin panel:
Only invited user can register in strict mode,
Set mail server in admin panel,
Switch strict mode in admin panel,
Invite people to system in admin panel

lkisme 8 سال پیش
والد
کامیت
1dfb6ef477

+ 3 - 1
.eslintrc.json

@@ -119,6 +119,8 @@
     "allowIsBoardMember": true,
     "allowIsBoardMemberByCard": true,
     "Emoji": true,
-    "Checklists": true
+    "Checklists": true,
+    "Settings": true,
+    "InvitationCodes": true
   }
 }

+ 2 - 0
client/components/main/layouts.js

@@ -1,4 +1,6 @@
 Meteor.subscribe('boards');
+Meteor.subscribe('setting');
+Meteor.subscribe('user-admin');
 
 BlazeLayout.setRoot('body');
 

+ 5 - 0
client/components/settings/invitationCode.jade

@@ -0,0 +1,5 @@
+template(name='invitationCode')
+  .at-input#invitationcode
+    label(for='at-field-code') {{_ 'invitation-code'}}
+
+    input#at-field-invitationcode(type="text" name='at-field-invitationcode' placeholder="{{_ 'invitation-code'}}")

+ 6 - 0
client/components/settings/invitationCode.js

@@ -0,0 +1,6 @@
+Template.invitationCode.onRendered(() => {
+  const strict = Settings.findOne().strict;
+  if(!strict){
+    $('#invitationcode').hide();
+  }
+});

+ 72 - 0
client/components/settings/settingBody.jade

@@ -0,0 +1,72 @@
+template(name="setting")
+  .setting-content
+    .content-title
+      span Settings
+    .content-body
+      .side-menu
+        ul
+          li.active
+            a.js-setting-menu(data-id="general-setting") System
+          li
+            a.js-setting-menu(data-id="email-setting") Email
+      .main-body
+        if loading.get
+          +spinner
+        else if generalSetting.get
+          +general
+        else if emailSetting.get
+          +email
+        
+template(name="general")
+  ul#general-setting.setting-detail
+    li
+      a.flex.js-toggle-strict-mode
+        .materialCheckBox(class="{{#if currentSetting.strict}}is-checked{{/if}}")
+
+        span Use Strict Mode
+    li
+      .invite-people(class="{{#if currentSetting.strict}}{{else}}hide{{/if}}")
+        ul
+          li
+            .title Invite People
+            textarea#email-to-invite.form-control(rows='5', placeholder="Email Adresses")
+          li
+            .title To board(s)
+            .bg-white
+              each boards
+                a.option.flex.js-toggle-board-choose(id= _id)
+                  .materialCheckBox(data-id= _id)
+
+                  span= title
+
+          li
+            button.js-email-invite.primary Invite
+
+template(name='email')
+  ul#email-setting.setting-detail
+    li.smtp-form
+      .title SMTP Host {{currentSetting.mailServer.port}}
+      .description The address of the SMTP server that handles your emails.
+      .form-group
+        input.form-control#mail-server-host(type="text", placeholder="smtp.domain.com" value="{{currentSetting.mailServer.host}}")
+    li.smtp-form
+      .title SMTP Port
+      .description The port your SMTP server uses for outgoing emails.
+      .form-group
+        input.form-control#mail-server-port(type="text", placeholder="25" value="{{currentSetting.mailServer.port}}")
+    li.smtp-form
+      .title SMTP user name
+      .form-group
+        input.form-control#mail-server-username(type="text", placeholder="user name" value="{{currentSetting.mailServer.username}}")
+    li.smtp-form
+      .title SMTP password
+      .form-group
+        input.form-control#mail-server-password(type="text", placeholder="password" value="{{currentSetting.mailServer.password}}")
+    li.smtp-form
+      .title From
+      .Email address you want to use to send emails.
+      .form-group
+        input.form-control#mail-server-from(type="email", placeholder="no-reply@domain.com" value="{{currentSetting.mailServer.from}}")
+
+    li
+      button.js-save.primary Save              

+ 126 - 0
client/components/settings/settingBody.js

@@ -0,0 +1,126 @@
+Meteor.subscribe('setting');
+Meteor.subscribe('mailServer');
+
+BlazeComponent.extendComponent({
+  onCreated() {
+    this.error = new ReactiveVar('');
+    this.loading = new ReactiveVar(false);
+    this.generalSetting = new ReactiveVar(true);
+    this.emailSetting = new ReactiveVar(false);
+  },
+
+  setError(error) {
+    this.error.set(error);
+  },
+
+  setLoading(w) {
+    this.loading.set(w);
+  },
+
+  checkField(selector) {
+    const value = $(selector).val();
+    if(!value || value.trim() === ''){
+      $(selector).parents('li.smtp-form').addClass('has-error');
+      throw Error('blank field');
+    } else {
+      return value;
+    }
+  },
+
+  currentSetting(){
+    return Settings.findOne();
+  },
+
+  boards() {
+    return Boards.find({
+      archived: false,
+      'members.userId': Meteor.userId(),
+      'members.isAdmin': true,
+    }, {
+      sort: ['title'],
+    });
+  },
+  toggleStrictMode(){
+    this.setLoading(true);
+    const isStrictMode = this.currentSetting().strict;
+    Settings.update(Settings.findOne()._id, {$set:{strict: !isStrictMode}});
+    this.setLoading(false);
+    if(isStrictMode){
+      $('.invite-people').slideUp();
+    }else{
+      $('.invite-people').slideDown();
+    }
+  },
+
+  switchMenu(event){
+    const target = $(event.target);
+    if(!target.hasClass('active')){
+      $('.side-menu li.active').removeClass('active');
+      target.parent().addClass('active');
+      const targetID = target.data('id');
+      this.generalSetting.set('general-setting' === targetID);
+      this.emailSetting.set('email-setting' === targetID);
+    }
+  },
+
+  checkBoard(event){
+    let target = $(event.target);
+    if(!target.hasClass('js-toggle-board-choose')){
+      target = target.parent();
+    }
+    const checkboxId = target.attr('id');
+    $(`#${checkboxId} .materialCheckBox`).toggleClass('is-checked');
+    $(`#${checkboxId}`).toggleClass('is-checked');
+  },
+
+  inviteThroughEmail(){
+    this.setLoading(true);
+    const emails = $('#email-to-invite').val().trim().split('\n').join(',').split(',');
+    const boardsToInvite = [];
+    $('.js-toggle-board-choose .materialCheckBox.is-checked').each(function () {
+      boardsToInvite.push($(this).data('id'));
+    });
+    const validEmails = [];
+    emails.forEach((email) => {
+      if (email && SimpleSchema.RegEx.Email.test(email.trim())) {
+        validEmails.push(email.trim());
+      }
+    });
+    Meteor.call('sendInvitation', validEmails, boardsToInvite, () => {
+      // if (!err) {
+      //   TODO - show more info to user
+      // }
+      this.setLoading(false);
+    });
+  },
+
+  saveMailServerInfo(){
+    this.setLoading(true);
+    $('li').removeClass('has-error');
+
+    try{
+      const host = this.checkField('#mail-server-host');
+      const port = this.checkField('#mail-server-port');
+      const username = this.checkField('#mail-server-username');
+      const password = this.checkField('#mail-server-password');
+      const from = this.checkField('#mail-server-from');
+      Settings.update(Settings.findOne()._id, {$set:{'mailServer.host':host, 'mailServer.port': port, 'mailServer.username': username,
+          'mailServer.password': password, 'mailServer.from': from}});
+    } catch (e) {
+      return;
+    } finally {
+      this.setLoading(false);
+    }
+
+  },
+
+  events(){
+    return [{
+      'click a.js-toggle-strict-mode': this.toggleStrictMode,
+      'click a.js-setting-menu': this.switchMenu,
+      'click a.js-toggle-board-choose': this.checkBoard,
+      'click button.js-email-invite': this.inviteThroughEmail,
+      'click button.js-save': this.saveMailServerInfo,
+    }];
+  },
+}).register('setting');

+ 112 - 0
client/components/settings/settingBody.styl

@@ -0,0 +1,112 @@
+.flex
+  display: -webkit-box
+  display: -moz-box
+  display: -webkit-flex
+  display: -moz-flex
+  display: -ms-flexbox
+  display: flex
+
+.setting-content
+  padding 30px
+  color: #727479
+  background: #dedede
+  width 100%
+  height 100%
+  position: absolute;
+
+  .content-title
+    font-size 20px
+
+  .content-body
+    display flex
+    padding-top 15px
+    height 100%
+
+    .side-menu
+      background-color: #f7f7f7;
+      border: 1px solid #f0f0f0;
+      border-radius: 4px;
+      width: 250px;
+      box-shadow: inset -1px -1px 3px rgba(0,0,0,.05);
+
+      ul
+
+        li
+          margin: 0.1rem 0.2rem;
+
+          &.active
+            background #fff
+            box-shadow 0 1px 2px rgba(0,0,0,0.15);
+
+          &:hover
+            background #fff
+            box-shadow 0 1px 2px rgba(0,0,0,0.15);
+          a
+            @extends .flex
+            padding: 1rem 0 1rem 1rem
+            width: 100% - 5rem
+
+
+            span
+              font-size: 13px
+
+    .main-body
+      padding: 0.1em 1em
+
+      ul
+        li
+          padding: 0.5rem 0.5rem;
+
+          a
+            .is-checked
+              border-bottom: 2px solid #2980b9;
+              border-right: 2px solid #2980b9;
+              
+            span 
+              padding: 0 0.5rem
+              
+          .invite-people
+            padding-left 20px;
+            li
+              min-width: 500px;
+
+              ul.no-margin-bottom
+                margin-bottom: 0;
+
+              .bg-white
+                a
+                  background  #f7f7f7
+                  &.is-checked
+                    background #fff
+
+
+.option
+  @extends .flex
+  -webkit-border-radius: 3px;
+  border-radius: 3px;
+  background: #fff;
+  text-decoration: none;
+  -webkit-box-shadow: 0 1px 2px rgba(0,0,0,0.2);
+  box-shadow: 0 1px 2px rgba(0,0,0,0.2);
+  margin-top: 5px;
+  padding: 5px;
+
+.title
+  font-weight 700;
+  margin-bottom 0.5rem;
+.description
+  margin-bottom 0.5rem;
+.bg-white
+  background #f9fbfc;
+
+.form-control.has-error
+  border-color: #a94442;
+  box-shadow: inset 0 1px 1px rgba(0,0,0,.075);
+
+li.has-error
+  color #a94442
+  .form-group
+    .form-control
+      border-color: #a94442;
+      box-shadow: inset 0 1px 1px rgba(0,0,0,.075);
+

+ 21 - 0
client/components/settings/settingHeader.jade

@@ -0,0 +1,21 @@
+template(name="settingHeaderBar")
+  h1.header-setting-menu
+    span {{_ 'admin-panel'}}
+
+  .setting-header-btns.left
+    unless isMiniScreen
+      unless isSandstorm
+        if currentUser
+          a.setting-header-btn.settings.active
+            i.fa(class="fa-cog")
+            span {{_ 'option-setting'}}
+//TODO 
+//          a.setting-header-btn.people
+//            i.fa(class="fa-users")
+//            span {{_ 'option-people'}}
+
+        else
+          a.setting-header-btn.js-log-in(
+            title="{{_ 'log-in'}}")
+            i.fa.fa-sign-in
+            span {{_ 'log-in'}}

+ 25 - 0
client/components/settings/settingHeader.styl

@@ -0,0 +1,25 @@
+#header #header-main-bar .setting-header-btn
+  &.active,
+  &:hover:not(.is-disabled)
+    background: rgba(0, 0, 0, .15)
+  color: darken(white, 5%)
+  margin-left: 20px;
+  padding-right: 10px;
+  height: 28px;
+  font-size: 13px;
+  float: left;
+  overflow: hidden;
+  line-height: @height;
+  margin: 0 2px;
+
+  i.fa
+    float: left
+    display: block
+    line-height: 28px
+    color: darken(white, 5%)
+    margin: 0 10px
+
+  + span
+    display: inline-block
+    margin-top: 1px
+    margin-right: 10px

+ 2 - 0
client/components/users/userHeader.jade

@@ -17,6 +17,8 @@ template(name="memberMenuPopup")
       li: a.js-change-password {{_ 'changePasswordPopup-title'}}
       li: a.js-change-language {{_ 'changeLanguagePopup-title'}}
       li: a.js-edit-notification {{_ 'editNotificationPopup-title'}}
+    if currentUser.isAdmin
+      li: a.js-go-setting(href='/setting') {{_ 'admin-panel'}}
   hr
   ul.pop-over-list
     li: a.js-logout {{_ 'log-out'}}

+ 3 - 0
client/components/users/userHeader.js

@@ -15,6 +15,9 @@ Template.memberMenuPopup.events({
 
     AccountsTemplates.logout();
   },
+  'click .js-go-setting'() {
+    Popup.close();
+  },
 });
 
 Template.editProfilePopup.events({

+ 11 - 4
config/accounts.js

@@ -1,12 +1,21 @@
 const passwordField = AccountsTemplates.removeField('password');
 const emailField = AccountsTemplates.removeField('email');
+
 AccountsTemplates.addFields([{
   _id: 'username',
   type: 'text',
   displayName: 'username',
   required: true,
   minLength: 2,
-}, emailField, passwordField]);
+}, emailField, passwordField, {
+  _id: 'invitationcode',
+  type: 'text',
+  displayName: 'Invitation Code',
+  required: false,
+  minLength: 6,
+  errStr: 'Invitation code doesn\'t exist',
+  template: 'invitationCode',
+}]);
 
 AccountsTemplates.configure({
   defaultLayout: 'userFormsLayout',
@@ -48,9 +57,6 @@ AccountsTemplates.configureRoute('changePwd', {
 });
 
 if (Meteor.isServer) {
-  if (process.env.MAIL_FROM) {
-    Accounts.emailTemplates.from = process.env.MAIL_FROM;
-  }
 
   ['resetPassword-subject', 'resetPassword-text', 'verifyEmail-subject', 'verifyEmail-text', 'enrollAccount-subject', 'enrollAccount-text'].forEach((str) => {
     const [templateName, field] = str.split('-');
@@ -63,3 +69,4 @@ if (Meteor.isServer) {
     };
   });
 }
+

+ 10 - 0
config/router.js

@@ -99,6 +99,16 @@ FlowRouter.route('/import', {
   },
 });
 
+FlowRouter.route('/setting', {
+  name: 'setting',
+  action() {
+    BlazeLayout.render('defaultLayout', {
+      headerBar: 'settingHeaderBar',
+      content: 'setting',
+    });
+  },
+});
+
 FlowRouter.notFound = {
   action() {
     BlazeLayout.render('defaultLayout', { content: 'notFound' });

+ 10 - 2
i18n/en.i18n.json

@@ -323,5 +323,13 @@
     "welcome-board": "Welcome Board",
     "welcome-list1": "Basics",
     "welcome-list2": "Advanced",
-    "what-to-do": "What do you want to do?"
-}
+    "what-to-do": "What do you want to do?",
+    "admin-panel": "Admin Panel",
+    "system-setting": "System Setting",
+    "option-setting": "Settings",
+    "option-people": "People",
+    "invitation-code": "Invitation Code",
+    "email-invite-register-subject": "__inviter__ sent you an invitation",
+    "email-invite-register-text": "Dear __user__,\n\n__inviter__ invites you to Wekan for collaborations.\n\nPlease follow the link below:\n__url__\n\nAnd your invitation code is: __icode__\n\nThanks.\n",
+    "error-invitation-code-not-exist": "Invitation code doesn't exist"
+}

+ 9 - 2
i18n/zh-CN.i18n.json

@@ -322,5 +322,12 @@
     "welcome-board": "“欢迎”看板",
     "welcome-list1": "基本",
     "welcome-list2": "高阶",
-    "what-to-do": "要做什么?"
-}
+    "what-to-do": "要做什么?",
+    "system-setting": "系统设置",
+    "option-setting": "设置",
+    "option-people": "成员",
+    "invitation-code": "邀请码",
+    "email-invite-register-subject": "__inviter__ 向您发出邀请",
+    "email-invite-register-text": "尊敬的 __user__,\n\n__inviter__ 邀请您加入看板参与协作。\n\n请点击下面的链接访问进行注册:\n\n__url__\n您的邀请码是: __icode__\n\n谢谢。\n",
+    "error-invitation-code-not-exist": "验证码不存在"
+}

+ 45 - 0
models/invitationCodes.js

@@ -0,0 +1,45 @@
+InvitationCodes = new Mongo.Collection('invitation_codes');
+
+InvitationCodes.attachSchema(new SimpleSchema({
+  code: {
+    type: String,
+  },
+  email: {
+    type: String,
+    unique: true,
+    regEx: SimpleSchema.RegEx.Email,
+  },
+  createdAt: {
+    type: Date,
+    denyUpdate: false,
+  },
+  // always be the admin if only one admin
+  authorId: {
+    type: String,
+  },
+  boardsToBeInvited: {
+    type: [String],
+    optional: true,
+  },
+  valid: {
+    type: Boolean,
+    defaultValue: true,
+  },
+}));
+
+InvitationCodes.helpers({
+  author(){
+    return Users.findOne(this.authorId);
+  },
+});
+
+// InvitationCodes.before.insert((userId, doc) => {
+  // doc.createdAt = new Date();
+  // doc.authorId = userId;
+// });
+
+if (Meteor.isServer) {
+  Boards.deny({
+    fetch: ['members'],
+  });
+}

+ 111 - 0
models/settings.js

@@ -0,0 +1,111 @@
+Settings = new Mongo.Collection('settings');
+
+Settings.attachSchema(new SimpleSchema({
+  strict: {
+    type: Boolean,
+  },
+  'mailServer.username': {
+    type: String,
+  },
+  'mailServer.password': {
+    type: String,
+  },
+  'mailServer.host': {
+    type: String,
+  },
+  'mailServer.port': {
+    type: String,
+  },
+  'mailServer.from': {
+    type: String,
+    defaultValue: 'Kanban',
+  },
+  createdAt: {
+    type: Date,
+    denyUpdate: true,
+  },
+  modifiedAt: {
+    type: Date,
+  },
+}));
+Settings.helpers({
+  mailUrl () {
+    const mailUrl = `smtp://${this.mailServer.username}:${this.mailServer.password}@${this.mailServer.host}:${this.mailServer.port}/`;
+    return mailUrl;
+  },
+});
+Settings.allow({
+  update(userId) {
+    const user = Users.findOne(userId);
+    return user && user.isAdmin;
+  },
+});
+
+Settings.before.update((userId, doc, fieldNames, modifier) => {
+  modifier.$set = modifier.$set || {};
+  modifier.$set.modifiedAt = new Date();
+});
+
+if (Meteor.isServer) {
+  Meteor.startup(() => {
+    const setting = Settings.findOne({});
+    if(!setting){
+      const now = new Date();
+      const defaultSetting = {strict: false, mailServer: {
+        username: '', password:'', host: '', port:'', from: '',
+      }, createdAt: now, modifiedAt: now};
+      Settings.insert(defaultSetting);
+    }
+    const newSetting = Settings.findOne();
+    process.env.MAIL_URL = newSetting.mailUrl();
+    Accounts.emailTemplates.from = newSetting.mailServer.from;
+  });
+
+  function getRandomNum (min, max) {
+    const range = max - min;
+    const rand = Math.random();
+    return (min + Math.round(rand * range));
+  }
+
+  function sendInvitationEmail (_id){
+    const icode = InvitationCodes.findOne(_id);
+    const author = Users.findOne(Meteor.userId());
+    try {
+      const params = {
+        email: icode.email,
+        inviter: Users.findOne(icode.authorId).username,
+        user: icode.email.split('@')[0],
+        icode: icode.code,
+        url: FlowRouter.url('sign-up'),
+      };
+      const lang = author.getLanguage();
+      Email.send({
+        to: icode.email,
+        from: Accounts.emailTemplates.from,
+        subject: TAPi18n.__('email-invite-register-subject', params, lang),
+        text: TAPi18n.__('email-invite-register-text', params, lang),
+      });
+    } catch (e) {
+      throw new Meteor.Error('email-fail', e.message);
+    }
+  }
+
+  Meteor.methods({
+    sendInvitation(emails, boards) {
+      check(emails, [String]);
+      check(boards, [String]);
+      const user = Users.findOne(Meteor.userId());
+      if(!user.isAdmin){
+        throw new Meteor.Error('not-allowed');
+      }
+      emails.forEach((email) => {
+        if (email && SimpleSchema.RegEx.Email.test(email)) {
+          const code = getRandomNum(100000, 999999);
+          InvitationCodes.insert({code, email, boardsToBeInvited: boards, createdAt: new Date(), authorId: Meteor.userId()}, function(err, _id){
+            if(!err && _id) sendInvitationEmail(_id);
+          });
+        }
+      });
+    },
+  });
+}

+ 44 - 1
models/users.js

@@ -348,7 +348,7 @@ if (Meteor.isServer) {
         if (user._id === inviter._id) throw new Meteor.Error('error-user-notAllowSelf');
       } else {
         if (posAt <= 0) throw new Meteor.Error('error-user-doesNotExist');
-
+        if (Settings.findOne().strict) throw new Meteor.Error('error-user-notCreated');
         const email = username;
         username = email.substring(0, posAt);
         const newUserId = Accounts.createUser({ username, email });
@@ -389,6 +389,28 @@ if (Meteor.isServer) {
       return { username: user.username, email: user.emails[0].address };
     },
   });
+  Accounts.onCreateUser((options, user) => {
+    const userCount = Users.find().count();
+    if (userCount === 0){
+      user.isAdmin = true;
+      return user;
+    }
+    const strict = Settings.findOne().strict;
+    if (!strict) {
+      return user;
+    }
+
+    const iCode = options.profile.invitationcode | '';
+
+    const invitationCode = InvitationCodes.findOne({code: iCode, valid:true});
+    if (!invitationCode) {
+      throw new Meteor.Error('error-invitation-code-not-exist');
+    }else{
+      user.profile = {icode: options.profile.invitationcode};
+    }
+
+    return user;
+  });
 }
 
 if (Meteor.isServer) {
@@ -458,4 +480,25 @@ if (Meteor.isServer) {
       });
     });
   }
+
+  Users.after.insert((userId, doc) => {
+
+    //invite user to corresponding boards
+    const strict = Settings.findOne().strict;
+    if (strict) {
+      const user = Users.findOne(doc._id);
+      const invitationCode = InvitationCodes.findOne({code: user.profile.icode, valid:true});
+      if (!invitationCode) {
+        throw new Meteor.Error('error-user-notCreated');
+      }else{
+        invitationCode.boardsToBeInvited.forEach((boardId) => {
+          const board = Boards.findOne(boardId);
+          board.addMember(doc._id);
+        });
+        user.profile = {invitedBoards: invitationCode.boardsToBeInvited};
+        InvitationCodes.update(invitationCode._id, {$set: {valid:false}});
+      }
+    }
+  });
 }
+

+ 13 - 0
server/publications/settings.js

@@ -0,0 +1,13 @@
+Meteor.publish('setting', () => {
+  return Settings.find({}, {fields:{strict: 1}});
+});
+
+Meteor.publish('mailServer', function () {
+  if (!Match.test(this.userId, String))
+    return [];
+  const user = Users.findOne(this.userId);
+  if(user && user.isAdmin){
+    return Settings.find({}, {fields: {mailServer: 1}});
+  }
+  return [];
+});

+ 8 - 0
server/publications/users.js

@@ -9,3 +9,11 @@ Meteor.publish('user-miniprofile', function(userId) {
     },
   });
 });
+
+Meteor.publish('user-admin', function() {
+  return Meteor.users.find(this.userId, {
+    fields: {
+      isAdmin: 1,
+    },
+  });
+});