Преглед изворни кода

Merge pull request #551 from weseek/feat/mail-notification

Feat/mail notification
Yuki Takei пре 7 година
родитељ
комит
7d4832e497
33 измењених фајлова са 994 додато и 58 уклоњено
  1. 17 0
      lib/crowi/index.js
  2. 19 0
      lib/form/admin/notificationGlobal.js
  3. 1 0
      lib/form/index.js
  4. 9 0
      lib/locales/en-US/notifications/comment.txt
  5. 5 0
      lib/locales/en-US/notifications/pageCreate.txt
  6. 5 0
      lib/locales/en-US/notifications/pageDelete.txt
  7. 5 0
      lib/locales/en-US/notifications/pageEdit.txt
  8. 5 0
      lib/locales/en-US/notifications/pageLike.txt
  9. 5 0
      lib/locales/en-US/notifications/pageMove.txt
  10. 0 0
      lib/locales/ja/notifications/comment.txt
  11. 0 0
      lib/locales/ja/notifications/pageCreate.txt
  12. 0 0
      lib/locales/ja/notifications/pageDelete.txt
  13. 0 0
      lib/locales/ja/notifications/pageEdit.txt
  14. 0 0
      lib/locales/ja/notifications/pageLike.txt
  15. 0 0
      lib/locales/ja/notifications/pageMove.txt
  16. 10 0
      lib/models/GlobalNotificationSetting.js
  17. 16 0
      lib/models/GlobalNotificationSetting/GlobalNotificationMailSetting.js
  18. 16 0
      lib/models/GlobalNotificationSetting/GlobalNotificationSlackSetting.js
  19. 113 0
      lib/models/GlobalNotificationSetting/index.js
  20. 3 0
      lib/models/index.js
  21. 1 1
      lib/models/page.js
  22. 128 15
      lib/routes/admin.js
  23. 6 1
      lib/routes/comment.js
  24. 6 0
      lib/routes/index.js
  25. 64 34
      lib/routes/page.js
  26. 185 0
      lib/service/global-notification.js
  27. 157 0
      lib/views/admin/global-notification-detail.html
  28. 115 0
      lib/views/admin/global-notification.html
  29. 7 7
      lib/views/admin/notification.html
  30. 3 0
      resource/styles/agile-admin/inverse/colors/_apply-colors-dark.scss
  31. 3 0
      resource/styles/agile-admin/inverse/colors/_apply-colors-light.scss
  32. 18 0
      resource/styles/agile-admin/inverse/colors/_apply-colors.scss
  33. 72 0
      resource/styles/scss/_admin.scss

+ 17 - 0
lib/crowi/index.js

@@ -37,6 +37,7 @@ function Crowi(rootdir, env) {
   this.mailer = {};
   this.mailer = {};
   this.interceptorManager = {};
   this.interceptorManager = {};
   this.passportService = null;
   this.passportService = null;
+  this.globalNotificationService = null;
   this.xss = new Xss();
   this.xss = new Xss();
 
 
   this.tokens = null;
   this.tokens = null;
@@ -90,6 +91,8 @@ Crowi.prototype.init = function() {
       return self.setupSlack();
       return self.setupSlack();
     }).then(function() {
     }).then(function() {
       return self.setupCsrf();
       return self.setupCsrf();
+    }).then(function() {
+      return self.setUpGlobalNotification();
     });
     });
 };
 };
 
 
@@ -246,6 +249,10 @@ Crowi.prototype.getInterceptorManager = function() {
   return this.interceptorManager;
   return this.interceptorManager;
 };
 };
 
 
+Crowi.prototype.getGlobalNotificationService = function() {
+  return this.globalNotificationService;
+};
+
 Crowi.prototype.setupPassport = function() {
 Crowi.prototype.setupPassport = function() {
   const config = this.getConfig();
   const config = this.getConfig();
   const Config = this.model('Config');
   const Config = this.model('Config');
@@ -452,4 +459,14 @@ Crowi.prototype.require = function(modulePath) {
   return require(modulePath);
   return require(modulePath);
 };
 };
 
 
+/**
+ * setup GlobalNotificationService
+ */
+Crowi.prototype.setUpGlobalNotification = function() {
+  const GlobalNotificationService = require('../service/global-notification');
+  if (this.globalNotificationService == null) {
+    this.globalNotificationService = new GlobalNotificationService(this);
+  }
+};
+
 module.exports = Crowi;
 module.exports = Crowi;

+ 19 - 0
lib/form/admin/notificationGlobal.js

@@ -0,0 +1,19 @@
+'use strict';
+
+const form = require('express-form');
+const field = form.field;
+
+module.exports = form(
+  field('notificationGlobal[id]').trim(),
+  field('notificationGlobal[triggerPath]').trim(),
+  field('notificationGlobal[notifyToType]').trim(),
+  field('notificationGlobal[toEmail]').trim(),
+  field('notificationGlobal[slackChannels]').trim(),
+  field('notificationGlobal[triggerEvent:pageCreate]').trim(),
+  field('notificationGlobal[triggerEvent:pageEdit]').trim(),
+  field('notificationGlobal[triggerEvent:pageDelete]').trim(),
+  field('notificationGlobal[triggerEvent:pageMove]').trim(),
+  field('notificationGlobal[triggerEvent:pageLike]').trim(),
+  field('notificationGlobal[triggerEvent:comment]').trim(),
+);
+

+ 1 - 0
lib/form/index.js

@@ -36,5 +36,6 @@ module.exports = {
     slackIwhSetting: require('./admin/slackIwhSetting'),
     slackIwhSetting: require('./admin/slackIwhSetting'),
     slackSetting: require('./admin/slackSetting'),
     slackSetting: require('./admin/slackSetting'),
     userGroupCreate: require('./admin/userGroupCreate'),
     userGroupCreate: require('./admin/userGroupCreate'),
+    notificationGlobal: require('./admin/notificationGlobal'),
   },
   },
 };
 };

+ 9 - 0
lib/locales/en-US/notifications/comment.txt

@@ -0,0 +1,9 @@
+{{ username }} commented on {{ path }}.
+
+----------------------
+
+{{ comment }}
+
+----------------------
+
+Growi: {{ appTitle }}

+ 5 - 0
lib/locales/en-US/notifications/pageCreate.txt

@@ -0,0 +1,5 @@
+{{ username }} created a new page under {{ path }}.
+
+----------------------
+
+Growi: {{ appTitle }}

+ 5 - 0
lib/locales/en-US/notifications/pageDelete.txt

@@ -0,0 +1,5 @@
+{{ username }} deleted the page  {{ path }}.
+
+----------------------
+
+Growi: {{ appTitle }}

+ 5 - 0
lib/locales/en-US/notifications/pageEdit.txt

@@ -0,0 +1,5 @@
+{{ username }} edited the page {{ path }}.
+
+----------------------
+
+Growi: {{ appTitle }}

+ 5 - 0
lib/locales/en-US/notifications/pageLike.txt

@@ -0,0 +1,5 @@
+{{ username }} liked the page {{ path }}.
+
+----------------------
+
+Growi: {{ appTitle }}

+ 5 - 0
lib/locales/en-US/notifications/pageMove.txt

@@ -0,0 +1,5 @@
+{{ username }} renamed the page {{ oldPath }} to {{ newPath }}.
+
+----------------------
+
+Growi: {{ appTitle }}

+ 0 - 0
lib/locales/ja/notifications/comment.txt


+ 0 - 0
lib/locales/ja/notifications/pageCreate.txt


+ 0 - 0
lib/locales/ja/notifications/pageDelete.txt


+ 0 - 0
lib/locales/ja/notifications/pageEdit.txt


+ 0 - 0
lib/locales/ja/notifications/pageLike.txt


+ 0 - 0
lib/locales/ja/notifications/pageMove.txt


+ 10 - 0
lib/models/GlobalNotificationSetting.js

@@ -0,0 +1,10 @@
+const mongoose = require('mongoose');
+const GlobalNotificationSetting = require('./GlobalNotificationSetting/index');
+const GlobalNotificationSettingClass = GlobalNotificationSetting.class;
+const GlobalNotificationSettingSchema = GlobalNotificationSetting.schema;
+
+module.exports = function(crowi) {
+  GlobalNotificationSettingClass.crowi = crowi;
+  GlobalNotificationSettingSchema.loadClass(GlobalNotificationSettingClass);
+  return mongoose.model('GlobalNotificationSetting', GlobalNotificationSettingSchema);
+};

+ 16 - 0
lib/models/GlobalNotificationSetting/GlobalNotificationMailSetting.js

@@ -0,0 +1,16 @@
+const mongoose = require('mongoose');
+const GlobalNotificationSetting = require('./index');
+const GlobalNotificationSettingClass = GlobalNotificationSetting.class;
+const GlobalNotificationSettingSchema = GlobalNotificationSetting.schema;
+
+module.exports = function(crowi) {
+  GlobalNotificationSettingClass.crowi = crowi;
+  GlobalNotificationSettingSchema.loadClass(GlobalNotificationSettingClass);
+
+  const GlobalNotificationSettingModel = mongoose.model('GlobalNotificationSetting', GlobalNotificationSettingSchema);
+  const GlobalNotificationMailSettingModel = GlobalNotificationSettingModel.discriminator('mail', new mongoose.Schema({
+    toEmail: String,
+  }, {discriminatorKey: 'type'}));
+
+  return GlobalNotificationMailSettingModel;
+};

+ 16 - 0
lib/models/GlobalNotificationSetting/GlobalNotificationSlackSetting.js

@@ -0,0 +1,16 @@
+const mongoose = require('mongoose');
+const GlobalNotificationSetting = require('./index');
+const GlobalNotificationSettingClass = GlobalNotificationSetting.class;
+const GlobalNotificationSettingSchema = GlobalNotificationSetting.schema;
+
+module.exports = function(crowi) {
+  GlobalNotificationSettingClass.crowi = crowi;
+  GlobalNotificationSettingSchema.loadClass(GlobalNotificationSettingClass);
+
+  const GlobalNotificationSettingModel = mongoose.model('GlobalNotificationSetting', GlobalNotificationSettingSchema);
+  const GlobalNotificationSlackSettingModel = GlobalNotificationSettingModel.discriminator('slack', new mongoose.Schema({
+    slackChannels: String,
+  }, {discriminatorKey: 'type'}));
+
+  return GlobalNotificationSlackSettingModel;
+};

+ 113 - 0
lib/models/GlobalNotificationSetting/index.js

@@ -0,0 +1,113 @@
+const mongoose = require('mongoose');
+
+/**
+ * parent schema for GlobalNotificationSetting model
+ */
+const globalNotificationSettingSchema = new mongoose.Schema({
+  isEnabled: { type: Boolean, required: true, default: true },
+  triggerPath: { type: String, required: true },
+  triggerEvents: { type: [String] },
+});
+
+
+/**
+ * GlobalNotificationSetting Class
+ * @class GlobalNotificationSetting
+ */
+class GlobalNotificationSetting {
+
+  constructor(crowi) {
+    this.crowi = crowi;
+  }
+
+  /**
+   * enable notification setting
+   * @param {string} id
+   */
+  static async enable(id) {
+    const setting = await this.findOne({_id: id});
+
+    setting.isEnabled = true;
+    setting.save();
+
+    return setting;
+  }
+
+  /**
+   * disable notification setting
+   * @param {string} id
+   */
+  static async disable(id) {
+    const setting = await this.findOne({_id: id});
+
+    setting.isEnabled = false;
+    setting.save();
+
+    return setting;
+  }
+
+  /**
+   * find all notification settings
+   */
+  static async findAll() {
+    const settings = await this.find().sort({ triggerPath: 1 });
+
+    return settings;
+  }
+
+  /**
+   * find a list of notification settings by path and a list of events
+   * @param {string} path
+   * @param {string} event
+   */
+  static async findSettingByPathAndEvent(path, event) {
+    const pathsToMatch = generatePathsToMatch(path);
+
+    const settings = await this.find({
+      triggerPath: {$in: pathsToMatch},
+      triggerEvents: event,
+      isEnabled: true
+    })
+    .sort({ triggerPath: 1 });
+
+    return settings;
+  }
+}
+
+
+// move this to util
+// remove this from models/page
+const cutOffLastSlash = path => {
+  const lastSlash = path.lastIndexOf('/');
+  return path.substr(0, lastSlash);
+};
+
+const generatePathsOnTree = (path, pathList) => {
+  pathList.push(path);
+
+  if (path === '') {
+    return pathList;
+  }
+
+  const newPath = cutOffLastSlash(path);
+
+  return generatePathsOnTree(newPath, pathList);
+};
+
+const generatePathsToMatch = (originalPath) => {
+  const pathList = generatePathsOnTree(originalPath, []);
+  return pathList.map(path => {
+    if (path !== originalPath) {
+      return path + '/*';
+    }
+    else {
+      return path;
+    }
+  });
+};
+
+
+module.exports = {
+  class: GlobalNotificationSetting,
+  schema: globalNotificationSettingSchema,
+};

+ 3 - 0
lib/models/index.js

@@ -12,4 +12,7 @@ module.exports = {
   Comment: require('./comment'),
   Comment: require('./comment'),
   Attachment: require('./attachment'),
   Attachment: require('./attachment'),
   UpdatePost: require('./updatePost'),
   UpdatePost: require('./updatePost'),
+  GlobalNotificationSetting: require('./GlobalNotificationSetting'),
+  GlobalNotificationMailSetting: require('./GlobalNotificationSetting/GlobalNotificationMailSetting'),
+  GlobalNotificationSlackSetting: require('./GlobalNotificationSetting/GlobalNotificationSlackSetting'),
 };
 };

+ 1 - 1
lib/models/page.js

@@ -1047,7 +1047,7 @@ module.exports = function(crowi) {
     var newRevision = await Revision.prepareRevision(pageData, body, user);
     var newRevision = await Revision.prepareRevision(pageData, body, user);
 
 
     const revision = await Page.pushRevision(pageData, newRevision, user);
     const revision = await Page.pushRevision(pageData, newRevision, user);
-    const savedPage = await Page.findPageByPath(revision.path).populate('revision');
+    const savedPage = await Page.findPageByPath(revision.path).populate('revision').populate('creator');
     if (grant != null) {
     if (grant != null) {
       const grantData = await Page.updateGrant(savedPage, grant, user, grantUserGroupId);
       const grantData = await Page.updateGrant(savedPage, grant, user, grantUserGroupId);
       debug('Page grant update:', grantData);
       debug('Page grant update:', grantData);

+ 128 - 15
lib/routes/admin.js

@@ -2,6 +2,7 @@ module.exports = function(crowi, app) {
   'use strict';
   'use strict';
 
 
   var debug = require('debug')('growi:routes:admin')
   var debug = require('debug')('growi:routes:admin')
+    , logger = require('@alias/logger')('growi:routes:admin')
     , fs = require('fs')
     , fs = require('fs')
     , models = crowi.models
     , models = crowi.models
     , Page = models.Page
     , Page = models.Page
@@ -11,6 +12,9 @@ module.exports = function(crowi, app) {
     , UserGroup = models.UserGroup
     , UserGroup = models.UserGroup
     , UserGroupRelation = models.UserGroupRelation
     , UserGroupRelation = models.UserGroupRelation
     , Config = models.Config
     , Config = models.Config
+    , GlobalNotificationSetting = models.GlobalNotificationSetting
+    , GlobalNotificationMailSetting = models.GlobalNotificationMailSetting
+    , GlobalNotificationSlackSetting = models.GlobalNotificationSlackSetting
     , PluginUtils = require('../plugins/plugin-utils')
     , PluginUtils = require('../plugins/plugin-utils')
     , pluginUtils = new PluginUtils()
     , pluginUtils = new PluginUtils()
     , ApiResponse = require('../util/apiResponse')
     , ApiResponse = require('../util/apiResponse')
@@ -187,13 +191,12 @@ module.exports = function(crowi, app) {
 
 
   // app.get('/admin/notification'               , admin.notification.index);
   // app.get('/admin/notification'               , admin.notification.index);
   actions.notification = {};
   actions.notification = {};
-  actions.notification.index = function(req, res) {
-    var config = crowi.getConfig();
-    var UpdatePost = crowi.model('UpdatePost');
-    var slackSetting = Config.setupCofigFormData('notification', config);
-    var hasSlackIwhUrl = Config.hasSlackIwhUrl(config);
-    var hasSlackToken = Config.hasSlackToken(config);
-    var slack = crowi.slack;
+  actions.notification.index = async(req, res) => {
+    const config = crowi.getConfig();
+    const UpdatePost = crowi.model('UpdatePost');
+    let slackSetting = Config.setupCofigFormData('notification', config);
+    const hasSlackIwhUrl = Config.hasSlackIwhUrl(config);
+    const hasSlackToken = Config.hasSlackToken(config);
 
 
     if (!Config.hasSlackIwhUrl(req.config)) {
     if (!Config.hasSlackIwhUrl(req.config)) {
       slackSetting['slack:incomingWebhookUrl'] = '';
       slackSetting['slack:incomingWebhookUrl'] = '';
@@ -204,14 +207,15 @@ module.exports = function(crowi, app) {
       req.session.slackSetting = null;
       req.session.slackSetting = null;
     }
     }
 
 
-    UpdatePost.findAll()
-    .then(function(settings) {
-      return res.render('admin/notification', {
-        settings,
-        slackSetting,
-        hasSlackIwhUrl,
-        hasSlackToken,
-      });
+    const globalNotifications = await GlobalNotificationSetting.findAll();
+    const userNotifications = await UpdatePost.findAll();
+
+    return res.render('admin/notification', {
+      userNotifications,
+      slackSetting,
+      hasSlackIwhUrl,
+      hasSlackToken,
+      globalNotifications,
     });
     });
   };
   };
 
 
@@ -309,6 +313,84 @@ module.exports = function(crowi, app) {
     });
     });
   };
   };
 
 
+  actions.globalNotification = {};
+  actions.globalNotification.detail = async(req, res) => {
+    const notificationSettingId = req.params.id;
+    let renderVars = {};
+
+    if (notificationSettingId) {
+      try {
+        renderVars.setting = await GlobalNotificationSetting.findOne({_id: notificationSettingId});
+      }
+      catch (err) {
+        logger.error(`Error in finding a global notification setting with {_id: ${notificationSettingId}}`);
+      }
+    }
+
+    return res.render('admin/global-notification-detail', renderVars);
+  };
+
+  actions.globalNotification.create = (req, res) => {
+    const form = req.form.notificationGlobal;
+    let setting;
+
+    switch (form.notifyToType) {
+      case 'mail':
+        setting = new GlobalNotificationMailSetting(crowi);
+        setting.toEmail = form.toEmail;
+        break;
+      // case 'slack':
+      //   setting = new GlobalNotificationSlackSetting(crowi);
+      //   setting.slackChannels = form.slackChannels;
+      //   break;
+      default:
+        logger.error('GlobalNotificationSetting Type Error: undefined type');
+        req.flash('errorMessage', 'Error occurred in creating a new global notification setting: undefined notification type');
+        return res.redirect('/admin/notification#global-notification');
+    }
+
+    setting.triggerPath = form.triggerPath;
+    setting.triggerEvents = getNotificationEvents(form);
+    setting.save();
+
+    return res.redirect('/admin/notification#global-notification');
+  };
+
+  actions.globalNotification.update = async(req, res) => {
+    const form = req.form.notificationGlobal;
+    const setting = await GlobalNotificationSetting.findOne({_id: form.id});
+
+    switch (form.notifyToType) {
+      case 'mail':
+        setting.toEmail = form.toEmail;
+        break;
+      // case 'slack':
+      //   setting.slackChannels = form.slackChannels;
+      //   break;
+      default:
+        logger.error('GlobalNotificationSetting Type Error: undefined type');
+        req.flash('errorMessage', 'Error occurred in updating the global notification setting: undefined notification type');
+        return res.redirect('/admin/notification#global-notification');
+    }
+
+    setting.triggerPath = form.triggerPath;
+    setting.triggerEvents = getNotificationEvents(form);
+    setting.save();
+
+    return res.redirect('/admin/notification#global-notification');
+  };
+
+  const getNotificationEvents = (form) => {
+    let triggerEvents = [];
+    const triggerEventKeys = Object.keys(form).filter(key => key.match(/^triggerEvent/));
+    triggerEventKeys.forEach(key => {
+      if (form[key]) {
+        triggerEvents.push(form[key]);
+      }
+    });
+    return triggerEvents;
+  };
+
   actions.search.buildIndex = function(req, res) {
   actions.search.buildIndex = function(req, res) {
     var search = crowi.getSearcher();
     var search = crowi.getSearcher();
     if (!search) {
     if (!search) {
@@ -1070,6 +1152,37 @@ module.exports = function(crowi, app) {
     });
     });
   };
   };
 
 
+  actions.api.toggleIsEnabledForGlobalNotification = async(req, res) => {
+    const id = req.query.id;
+    const isEnabled = (req.query.isEnabled == 'true');
+
+    try {
+      if (isEnabled) {
+        await GlobalNotificationSetting.disable(id);
+      }
+      else {
+        await GlobalNotificationSetting.enable(id);
+      }
+
+      return res.json(ApiResponse.success());
+    }
+    catch (err) {
+      return res.json(ApiResponse.error());
+    }
+  };
+
+  actions.api.removeGlobalNotification = async(req, res) => {
+    const id = req.query.id;
+
+    try {
+      await GlobalNotificationSetting.findOneAndRemove({_id: id});
+      return res.json(ApiResponse.success());
+    }
+    catch (err) {
+      return res.json(ApiResponse.error());
+    }
+  };
+
   /**
   /**
    * save settings, update config cache, and response json
    * save settings, update config cache, and response json
    *
    *

+ 6 - 1
lib/routes/comment.js

@@ -7,6 +7,7 @@ module.exports = function(crowi, app) {
     , User = crowi.model('User')
     , User = crowi.model('User')
     , Page = crowi.model('Page')
     , Page = crowi.model('Page')
     , ApiResponse = require('../util/apiResponse')
     , ApiResponse = require('../util/apiResponse')
+    , globalNotificationService = crowi.getGlobalNotificationService()
     , actions = {}
     , actions = {}
     , api = {};
     , api = {};
 
 
@@ -79,10 +80,14 @@ module.exports = function(crowi, app) {
 
 
     res.json(ApiResponse.success({comment: createdComment}));
     res.json(ApiResponse.success({comment: createdComment}));
 
 
+    const path = page.path;
+
+    // global notification
+    globalNotificationService.notifyComment(createdComment, path);
+
     // slack notification
     // slack notification
     if (slackNotificationForm.isSlackEnabled) {
     if (slackNotificationForm.isSlackEnabled) {
       const user = await User.findUserByUsername(req.user.username);
       const user = await User.findUserByUsername(req.user.username);
-      const path = page.path;
       const channels = slackNotificationForm.slackChannels;
       const channels = slackNotificationForm.slackChannels;
 
 
       if (channels) {
       if (channels) {

+ 6 - 0
lib/routes/index.js

@@ -106,6 +106,12 @@ module.exports = function(crowi, app) {
   app.post('/_api/admin/notification.add'    , loginRequired(crowi, app) , middleware.adminRequired() , csrf, admin.api.notificationAdd);
   app.post('/_api/admin/notification.add'    , loginRequired(crowi, app) , middleware.adminRequired() , csrf, admin.api.notificationAdd);
   app.post('/_api/admin/notification.remove' , loginRequired(crowi, app) , middleware.adminRequired() , csrf, admin.api.notificationRemove);
   app.post('/_api/admin/notification.remove' , loginRequired(crowi, app) , middleware.adminRequired() , csrf, admin.api.notificationRemove);
   app.get('/_api/admin/users.search'         , loginRequired(crowi, app) , middleware.adminRequired() , admin.api.usersSearch);
   app.get('/_api/admin/users.search'         , loginRequired(crowi, app) , middleware.adminRequired() , admin.api.usersSearch);
+  app.get('/admin/global-notification/detail', loginRequired(crowi, app) , middleware.adminRequired() , admin.globalNotification.detail);
+  app.get('/admin/global-notification/detail/:id', loginRequired(crowi, app) , middleware.adminRequired() , admin.globalNotification.detail);
+  app.post('/admin/global-notification/create', loginRequired(crowi, app) , middleware.adminRequired() , form.admin.notificationGlobal, admin.globalNotification.create);
+  app.post('/_api/admin/global-notification/toggleIsEnabled', loginRequired(crowi, app) , middleware.adminRequired() , admin.api.toggleIsEnabledForGlobalNotification);
+  app.post('/admin/global-notification/update', loginRequired(crowi, app) , middleware.adminRequired() , form.admin.notificationGlobal, admin.globalNotification.update);
+  app.post('/admin/global-notification/remove', loginRequired(crowi, app) , middleware.adminRequired() , admin.api.removeGlobalNotification);
 
 
   app.get('/admin/users'                , loginRequired(crowi, app) , middleware.adminRequired() , admin.user.index);
   app.get('/admin/users'                , loginRequired(crowi, app) , middleware.adminRequired() , admin.user.index);
   app.post('/admin/user/invite'         , form.admin.userInvite ,  loginRequired(crowi, app) , middleware.adminRequired() , csrf, admin.user.invite);
   app.post('/admin/user/invite'         , form.admin.userInvite ,  loginRequired(crowi, app) , middleware.adminRequired() , csrf, admin.user.invite);

+ 64 - 34
lib/routes/page.js

@@ -16,6 +16,7 @@ module.exports = function(crowi, app) {
     , pagePathUtil = require('../util/pagePathUtil')
     , pagePathUtil = require('../util/pagePathUtil')
     , swig = require('swig-templates')
     , swig = require('swig-templates')
     , getToday = require('../util/getToday')
     , getToday = require('../util/getToday')
+    , globalNotificationService = crowi.getGlobalNotificationService()
 
 
     , actions = {};
     , actions = {};
 
 
@@ -703,12 +704,22 @@ module.exports = function(crowi, app) {
 
 
       if (data) {
       if (data) {
         previousRevision = data.revision;
         previousRevision = data.revision;
-        return Page.updatePage(data, body, req.user, { grant, grantUserGroupId });
+        return Page.updatePage(data, body, req.user, { grant, grantUserGroupId })
+          .then((page) => {
+            // global notification
+            globalNotificationService.notifyPageEdit(page);
+            return page;
+          });
       }
       }
       else {
       else {
         // new page
         // new page
         updateOrCreate = 'create';
         updateOrCreate = 'create';
-        return Page.create(path, body, req.user, { grant, grantUserGroupId });
+        return Page.create(path, body, req.user, { grant, grantUserGroupId })
+          .then((page) => {
+            // global notification
+            globalNotificationService.notifyPageCreate(page);
+            return page;
+          });
       }
       }
     }).then(function(data) {
     }).then(function(data) {
       // data is a saved page data with revision.
       // data is a saved page data with revision.
@@ -990,10 +1001,17 @@ module.exports = function(crowi, app) {
     Page.findPageByIdAndGrantedUser(id, req.user)
     Page.findPageByIdAndGrantedUser(id, req.user)
     .then(function(pageData) {
     .then(function(pageData) {
       return pageData.like(req.user);
       return pageData.like(req.user);
-    }).then(function(data) {
-      var result = {page: data};
-      return res.json(ApiResponse.success(result));
-    }).catch(function(err) {
+    })
+    .then(function(page) {
+      var result = {page: page};
+      res.json(ApiResponse.success(result));
+      return page;
+    })
+    .then((page) => {
+      // global notification
+      return globalNotificationService.notifyPageLike(page, req.user);
+    })
+    .catch(function(err) {
       debug('Like failed', err);
       debug('Like failed', err);
       return res.json(ApiResponse.error({}));
       return res.json(ApiResponse.error({}));
     });
     });
@@ -1068,40 +1086,47 @@ module.exports = function(crowi, app) {
     const isRecursively = (req.body.recursively !== undefined);
     const isRecursively = (req.body.recursively !== undefined);
 
 
     Page.findPageByIdAndGrantedUser(pageId, req.user)
     Page.findPageByIdAndGrantedUser(pageId, req.user)
-    .then(function(pageData) {
-      debug('Delete page', pageData._id, pageData.path);
+      .then(function(pageData) {
+        debug('Delete page', pageData._id, pageData.path);
 
 
-      if (isCompletely) {
-        if (isRecursively) {
-          return Page.completelyDeletePageRecursively(pageData, req.user);
-        }
-        else {
-          return Page.completelyDeletePage(pageData, req.user);
+        if (isCompletely) {
+          if (isRecursively) {
+            return Page.completelyDeletePageRecursively(pageData, req.user);
+          }
+          else {
+            return Page.completelyDeletePage(pageData, req.user);
+          }
         }
         }
-      }
 
 
-      // else
+        // else
 
 
-      if (!pageData.isUpdatable(previousRevision)) {
-        throw new Error('Someone could update this page, so couldn\'t delete.');
-      }
+        if (!pageData.isUpdatable(previousRevision)) {
+          throw new Error('Someone could update this page, so couldn\'t delete.');
+        }
 
 
-      if (isRecursively) {
-        return Page.deletePageRecursively(pageData, req.user);
-      }
-      else {
-        return Page.deletePage(pageData, req.user);
-      }
-    }).then(function(data) {
-      debug('Page deleted', data.path);
-      var result = {};
-      result.page = data;
+        if (isRecursively) {
+          return Page.deletePageRecursively(pageData, req.user);
+        }
+        else {
+          return Page.deletePage(pageData, req.user);
+        }
+      })
+      .then(function(data) {
+        debug('Page deleted', data.path);
+        var result = {};
+        result.page = data;
 
 
-      return res.json(ApiResponse.success(result));
-    }).catch(function(err) {
-      debug('Error occured while get setting', err, err.stack);
-      return res.json(ApiResponse.error('Failed to delete page.'));
-    });
+        res.json(ApiResponse.success(result));
+        return data;
+      })
+      .then((page) => {
+        // global notification
+        return globalNotificationService.notifyPageDelete(page);
+      })
+      .catch(function(err) {
+        debug('Error occured while get setting', err, err.stack);
+        return res.json(ApiResponse.error('Failed to delete page.'));
+      });
   };
   };
 
 
   /**
   /**
@@ -1192,10 +1217,15 @@ module.exports = function(crowi, app) {
 
 
         return res.json(ApiResponse.success(result));
         return res.json(ApiResponse.success(result));
       })
       })
+      .then(() => {
+        // global notification
+        globalNotificationService.notifyPageMove(page, req.body.path, req.user);
+      })
       .catch(function(err) {
       .catch(function(err) {
         return res.json(ApiResponse.error('Failed to update page.'));
         return res.json(ApiResponse.error('Failed to update page.'));
       });
       });
     });
     });
+
   };
   };
 
 
   /**
   /**

+ 185 - 0
lib/service/global-notification.js

@@ -0,0 +1,185 @@
+const debug = require('debug')('growi:service:GlobalNotification');
+/**
+ * the service class of GlobalNotificationSetting
+ */
+class GlobalNotificationService {
+
+  constructor(crowi) {
+    this.crowi = crowi;
+    this.config = crowi.getConfig();
+    this.mailer = crowi.getMailer();
+    this.GlobalNotification = crowi.model('GlobalNotificationSetting');
+    this.User = crowi.model('User');
+    this.Config = crowi.model('Config');
+    this.appTitle = this.Config.appTitle(this.config);
+  }
+
+  notifyByMail(notification, mailOption) {
+    this.mailer.send(Object.assign(mailOption, {to: notification.toEmail}));
+  }
+
+  notifyBySlack(notification, slackOption) {
+    // send slack notification here
+  }
+
+  sendNotification(notifications, option) {
+    notifications.forEach(notification => {
+      if (notification.__t === 'mail') {
+        this.notifyByMail(notification, option.mail);
+      }
+      else if (notification.__t === 'slack') {
+        this.notifyBySlack(notification, option.slack);
+      }
+    });
+  }
+
+  /**
+   * send notification at page creation
+   * @memberof GlobalNotification
+   * @param {obejct} page
+   */
+  async notifyPageCreate(page) {
+    const notifications = await this.GlobalNotification.Parent.findSettingByPathAndEvent(page.path, 'pageCreate');
+    const lang = 'en-US'; //FIXME
+    const option = {
+      mail: {
+        subject: `#pageCreate - ${page.creator.username} created ${page.path}`,
+        template: `../../locales/${lang}/notifications/pageCreate.txt`,
+        vars: {
+          appTitle: this.appTitle,
+          path: page.path,
+          username: page.creator.username,
+        }
+      },
+      slack: {},
+    };
+
+    this.sendNotification(notifications, option);
+  }
+
+  /**
+   * send notification at page edit
+   * @memberof GlobalNotification
+   * @param {obejct} page
+   */
+  async notifyPageEdit(page) {
+    const notifications = await this.GlobalNotification.Parent.findSettingByPathAndEvent(page.path, 'pageEdit');
+    const lang = 'en-US'; //FIXME
+    const option = {
+      mail: {
+        subject: `#pageEdit - ${page.creator.username} edited ${page.path}`,
+        template: `../../locales/${lang}/notifications/pageEdit.txt`,
+        vars: {
+          appTitle: this.appTitle,
+          path: page.path,
+          username: page.creator.username,
+        }
+      },
+      slack: {},
+    };
+
+    this.sendNotification(notifications, option);
+  }
+
+  /**
+   * send notification at page deletion
+   * @memberof GlobalNotification
+   * @param {obejct} page
+   */
+  async notifyPageDelete(page) {
+    const notifications = await this.GlobalNotification.Parent.findSettingByPathAndEvent(page.path, 'pageDelete');
+    const lang = 'en-US'; //FIXME
+    const option = {
+      mail: {
+        subject: `#pageDelete - ${page.creator.username} deleted ${page.path}`,  //FIXME
+        template: `../../locales/${lang}/notifications/pageDelete.txt`,
+        vars: {
+          appTitle: this.appTitle,
+          path: page.path,
+          username: page.creator.username,
+        }
+      },
+      slack: {},
+    };
+
+    this.sendNotification(notifications, option);
+  }
+
+  /**
+   * send notification at page move
+   * @memberof GlobalNotification
+   * @param {obejct} page
+   */
+  async notifyPageMove(page, oldPagePath, user) {
+    const notifications = await this.GlobalNotification.Parent.findSettingByPathAndEvent(page.path, 'pageMove');
+    const lang = 'en-US'; //FIXME
+    const option = {
+      mail: {
+        subject: `#pageMove - ${user.username} moved ${page.path} to ${page.path}`, //FIXME
+        template: `../../locales/${lang}/notifications/pageMove.txt`,
+        vars: {
+          appTitle: this.appTitle,
+          oldPath: oldPagePath,
+          newPath: page.path,
+          username: user.username,
+        }
+      },
+      slack: {},
+    };
+
+    this.sendNotification(notifications, option);
+  }
+
+  /**
+   * send notification at page like
+   * @memberof GlobalNotification
+   * @param {obejct} page
+   */
+  async notifyPageLike(page, user) {
+    const notifications = await this.GlobalNotification.Parent.findSettingByPathAndEvent(page.path, 'pageLike');
+    const lang = 'en-US'; //FIXME
+    const option = {
+      mail: {
+        subject: `#pageLike - ${user.username} liked ${page.path}`,
+        template: `../../locales/${lang}/notifications/pageLike.txt`,
+        vars: {
+          appTitle: this.appTitle,
+          path: page.path,
+          username: page.creator.username,
+        }
+      },
+      slack: {},
+    };
+
+    this.sendNotification(notifications, option);
+  }
+
+  /**
+   * send notification at page comment
+   * @memberof GlobalNotification
+   * @param {obejct} page
+   * @param {obejct} comment
+   */
+  async notifyComment(comment, path) {
+    const notifications = await this.GlobalNotification.Parent.findSettingByPathAndEvent(path, 'comment');
+    const lang = 'en-US'; //FIXME
+    const user = await this.User.findOne({_id: comment.creator});
+    const option = {
+      mail: {
+        subject: `#comment - ${user.username} commented on ${path}`,
+        template: `../../locales/${lang}/notifications/comment.txt`,
+        vars: {
+          appTitle: this.appTitle,
+          path: path,
+          username: user.username,
+          comment: comment.comment,
+        }
+      },
+      slack: {},
+    };
+
+    this.sendNotification(notifications, option);
+  }
+}
+
+module.exports = GlobalNotificationService;

+ 157 - 0
lib/views/admin/global-notification-detail.html

@@ -0,0 +1,157 @@
+{% extends '../layout/admin.html' %}
+
+{% block html_title %}{{ customTitle(t('Notification settings')) }}{% endblock %}
+
+{% block content_header %}
+<div class="header-wrap">
+  <header id="page-header">
+    <h1 class="title" id="">{{ t('Notification settings') }}</h1>
+  </header>
+</div>
+{% endblock %}
+
+{% block content_main %}
+<div class="content-main">
+  {% set smessage = req.flash('successMessage') %}
+  {% if smessage.length %}
+  <div class="alert alert-success">
+    {{ smessage }}
+  </div>
+  {% endif %}
+
+  {% set emessage = req.flash('errorMessage') %}
+  {% if emessage.length %}
+  <div class="alert alert-danger">
+    {{ emessage }}
+  </div>
+  {% endif %}
+
+  <div class="row">
+    <div class="col-md-3">
+      {% include './widget/menu.html' with {current: 'notification'} %}
+    </div>
+
+    <div class="col-md-9">
+      <a href="/admin/notification#global-notification" class="btn btn-default">
+        <i class="icon-fw ti-arrow-left" aria-hidden="true"></i>
+        通知設定一覧に戻る
+      </a>
+
+      {% if setting %}
+        {% set actionPath = '/admin/global-notification/update' %}
+      {% else %}
+        {% set actionPath = '/admin/global-notification/create' %}
+      {% endif %}
+      <div class="m-t-20 form-box col-md-11">
+        <form action="{{ actionPath }}" method="post" class="form-horizontal" role="form">
+          <legend>通知設定詳細</legend>
+
+          <fieldset class="col-sm-offset-1 col-sm-4">
+            <div class="form-group">
+              <label for="triggerPath" class="control-label">トリガーパス</label><br />
+              <input class="form-control" type="text" name="notificationGlobal[triggerPath]" value="{{ setting.triggerPath || '' }}" required>
+            </div>
+
+            <div class="form-group">
+              <label for="notificationGlobal[notifyToType]"class="control-label">通知先</label><br />
+              <div class="radio radio-primary">
+                <input type="radio" id="mail" name="notificationGlobal[notifyToType]" value="mail" {% if setting.__t == 'mail' %}checked{% endif %}>
+                <label for="mail">
+                  <p class="font-weight-bold">Email</p>
+                </label>
+              </div>
+              <!-- <div class="radio radio-primary">
+                <input type="radio" id="slack" name="notificationGlobal[notifyToType]" value="slack" {% if setting.__t == 'slack' %}checked{% endif %}>
+                <label for="slack">
+                  <p class="font-weight-bold">Slack</p>
+                </label>
+              </div> -->
+            </div>
+
+            <div class="form-group notify-to-option {% if setting.__t != 'mail' %}d-none{% endif %}" id="mail-input">
+              <label for="notificationGlobal[toEmail]"class="control-label">Email</label><br />
+              <input class="form-control" type="text" name="notificationGlobal[toEmail]" value="{{ setting.toEmail || '' }}">
+            </div>
+
+            <!-- <div class="form-group notify-to-option {% if setting.__t != 'slack' %}d-none{% endif %}" id="slack-input">
+              <label for="notificationGlobal[slackChannels]"class="control-label">Slack Channels</label><br />
+              <input class="form-control" type="text" name="notificationGlobal[slackChannels]" value="{{ setting.slackChannels || '' }}">
+            </div> -->
+          </fieldset>
+
+          <fieldset class="col-sm-offset-1 col-sm-4">
+            <div class="form-group">
+              <label for="triggerEvent"class="control-label">トリガーイベント</label><br />
+              <div class="checkbox checkbox-info">
+                <input type="checkbox" id="trigger-event-pageCreate" name="notificationGlobal[triggerEvent:pageCreate]" value="pageCreate"
+                  {% if setting && (setting.triggerEvents.indexOf('pageCreate') != -1) %}checked{% endif %} />
+                <label for="trigger-event-pageCreate">
+                  <span class="label label-info"><i class="icon-doc"></i> CREATE</span> - When New Page is Created
+                </label>
+              </div>
+              <div class="checkbox checkbox-info">
+                <input type="checkbox" id="trigger-event-pageEdit" name="notificationGlobal[triggerEvent:pageEdit]" value="pageEdit"
+                  {% if setting && (setting.triggerEvents.indexOf('pageEdit') != -1) %}checked{% endif %} />
+                <label for="trigger-event-pageEdit">
+                  <span class="label label-info"><i class="icon-doc"></i> EDIT</span> - When Page is Edited
+                </label>
+              </div>
+              <div class="checkbox checkbox-info">
+                <input type="checkbox" id="trigger-event-pageDelete" name="notificationGlobal[triggerEvent:pageDelete]" value="pageDelete"
+                  {% if setting && (setting.triggerEvents.indexOf('pageDelete') != -1) %}checked{% endif %} />
+                <label for="trigger-event-pageDelete">
+                  <span class="label label-info"><i class="icon-doc"></i> DELETE</span> - When is Deleted
+                </label>
+              </div>
+              <div class="checkbox checkbox-info">
+                <input type="checkbox" id="trigger-event-pageMove" name="notificationGlobal[triggerEvent:pageMove]" value="pageMove"
+                  {% if setting && (setting.triggerEvents.indexOf('pageMove') != -1) %}checked{% endif %} />
+                <label for="trigger-event-pageMove">
+                  <span class="label label-info"><i class="icon-doc"></i> MOVE</span> - When Page is Moved (Renamed)
+                </label>
+              </div>
+              <div class="checkbox checkbox-info">
+                  <input type="checkbox" id="trigger-event-pageLike" name="notificationGlobal[triggerEvent:pageLike]" value="pageLike"
+                    {% if setting && (setting.triggerEvents.indexOf('pageLike') != -1) %}checked{% endif %} />
+                  <label for="trigger-event-pageLike">
+                    <span class="label label-info"><i class="icon-doc"></i> LIKE</span> - When Someone Likes Page
+                  </label>
+                </div>
+              <div class="checkbox checkbox-info">
+                <input type="checkbox" id="trigger-event-comment" name="notificationGlobal[triggerEvent:comment]" value="comment"
+                  {% if setting && (setting.triggerEvents.indexOf('comment') != -1) %}checked{% endif %} />
+                <label for="trigger-event-comment">
+                  <span class="label label-info"><i class="icon-fw icon-bubbles"></i> POST</span> - When Someone Comments on Page
+                </label>
+              </div>
+            </div>
+          </fieldset>
+
+          <div class="col-sm-offset-5 col-sm-12 m-t-20">
+            <input type="hidden" name="notificationGlobal[id]" value="{{ setting.id }}">
+            <input type="hidden" name="_csrf" value="{{ csrf() }}">
+            <button type="submit" class="btn btn-primary">{{ t('Update') }}</button>
+          </div>
+        </form>
+      </div>
+    </div>
+  </div>
+</div>
+
+<script>
+  $('input[name="notificationGlobal[notifyToType]"]').change(function() {
+    var val = $(this).val();
+    $('.notify-to-option').addClass('d-none');
+    $('#' + val + '-input').removeClass('d-none');
+  });
+
+  $('button#global-notificatin-delete').submit(function() {
+    alert(123)
+  });
+</script>
+{% endblock content_main %}
+
+{% block content_footer %}
+{% endblock content_footer %}
+
+

+ 115 - 0
lib/views/admin/global-notification.html

@@ -0,0 +1,115 @@
+<a href="/admin/global-notification/detail">
+  <p class="btn btn-default">通知設定の追加</p>
+</a>
+<h2>通知設定一覧</h2>
+
+{% set tags = {
+  pageCreate: '<span class="label label-info" data-toggle="tooltip" data-placement="top" title="Page Create"><i class="icon-doc"></i> CREATE</span>',
+  pageEdit: '<span class="label label-info" data-toggle="tooltip" data-placement="top" title="Page Edit"><i class="icon-doc"></i> EDIT</span>',
+  pageDelete: '<span class="label label-info" data-toggle="tooltip" data-placement="top" title="Page Delte"><i class="icon-doc"></i> DELETE</span>',
+  pageMove: '<span class="label label-info" data-toggle="tooltip" data-placement="top" title="Page Move"><i class="icon-doc"></i> MOVE</span>',
+  pageLike: '<span class="label label-info" data-toggle="tooltip" data-placement="top" title="Page Like"><i class="icon-doc"></i> LIKE</span>',
+  comment: '<span class="label label-info" data-toggle="tooltip" data-placement="top" title="New Comment"><i class="icon-fw icon-bubbles"></i> POST</span>'
+} %}
+
+<table class="table table-bordered">
+  <thead>
+    <th>ON/OFF</th>
+    <th>Trigger Path (expression with <code>*</code> is supported)</th>
+    <th>Trigger Events</th>
+    <th>Notify To</th>
+    <th>Action</th>
+  </thead>
+  <tbody class="admin-notif-list">
+    {% set detailPageUrl = '/admin/global-notification/detail' %}
+    {% for globalNotif in globalNotifications %}
+    <tr class="clickable-row" data-href="{{ detailPageUrl }}" data-updatepost-id="{{ globalNotif._id.toString() }}">
+      <td class="unclickable align-middle">
+        <label class="switch">
+          <input type="checkbox" class="isEnabledToggle" {% if globalNotif.isEnabled %}checked{% endif %}>
+          <span class="slider round"></span>
+        </label>
+      </td>
+      <td>
+        {{ globalNotif.triggerPath }}
+      </td>
+      <td style="max-width: 200px;">
+        {% for event in globalNotif.triggerEvents %}
+          {{ tags[event] | safe }}
+        {% endfor %}
+      </td>
+      <td>
+        {% if globalNotif.__t == 'mail' %}<span data-toggle="tooltip" data-placement="top" title="Email"><i class="ti-email"></i> {{ globalNotif.toEmail }}</span>
+        {% elseif globalNotif.__t == 'slack' %}<span data-toggle="tooltip" data-placement="top" title="Slack"><i class="fa fa-slack"></i> {{ globalNotif.slackChannels }}</span>
+        {% endif %}
+      </td>
+      <td class="unclickable">
+        <p class="btn btn-danger btn-delete">{{ t('Delete') }}</p>
+      </td>
+    </tr>
+    {% endfor %}
+  </tbody>
+</table>
+
+<script>
+  $(".clickable-row > :not('.unclickable')").click(function(event) {
+    var $target = $(event.currentTarget).parent();
+    window.location = $target.data("href") + "/" + $target.data("updatepost-id");
+  });
+
+  $(".unclickable > .btn-delete").click(function(event) {
+    var $targetRow = $(event.currentTarget).closest("tr");
+    var id = $targetRow.data("updatepost-id");
+    $.post('/admin/global-notification/remove?id=' + id, function(res) {
+      if (res.ok) {
+        $targetRow.closest('tr').remove();
+        $('.admin-notification > .row > .col-md-9').prepend(
+          '<div class=\"alert alert-success\">Successfully Deleted</div>'
+        );
+        $message = $('.admin-notification > .row > .col-md-9 > .alert.alert-success');
+        setTimeout(function()
+            {
+              $message.fadeOut({
+                complete: function() {
+                  $message.remove();
+                }
+              });
+            }, 2000);
+      }
+      else {
+        $('.admin-notification > .row > .col-md-9').prepend(
+          '<div class=\"alert alert-danger\">Error occurred in deleting global notifcation setting.</div>'
+        );
+        location.reload();
+      }
+    });
+  });
+
+  $(".isEnabledToggle").on("change", function(event) {
+    var $targetRow = $(event.currentTarget).closest("tr");
+    var id = $targetRow.data("updatepost-id");
+    var isEnabled = !$targetRow.find(".isEnabledToggle").is(':checked');
+    $.post('/_api/admin/global-notification/toggleIsEnabled?id=' + id + '&isEnabled=' + isEnabled, function(res) {
+      if (res.ok) {
+        $('.admin-notification > .row > .col-md-9').prepend(
+          '<div class=\"alert alert-success\">Successfully Upated</div>'
+        );
+        $message = $('.admin-notification > .row > .col-md-9 > .alert.alert-success');
+        setTimeout(function()
+            {
+              $message.fadeOut({
+                complete: function() {
+                  $message.remove();
+                }
+              });
+            }, 2000);
+      }
+      else {
+        $('.admin-notification > .row > .col-md-9').prepend(
+          '<div class=\"alert alert-danger\">Error occurred in deleting global notifcation setting.</div>'
+        );
+        location.reload();
+      }
+    });
+  });
+</script>

+ 7 - 7
lib/views/admin/notification.html

@@ -11,7 +11,7 @@
 {% endblock %}
 {% endblock %}
 
 
 {% block content_main %}
 {% block content_main %}
-<div class="content-main">
+<div class="content-main admin-notification">
   <div class="row">
   <div class="row">
     <div class="col-md-3">
     <div class="col-md-3">
       {% include './widget/menu.html' with {current: 'notification'} %}
       {% include './widget/menu.html' with {current: 'notification'} %}
@@ -229,17 +229,17 @@
               </tr>
               </tr>
               </form>
               </form>
 
 
-              {% for notif in settings %}
-              <tr class="admin-notif-row" data-updatepost-id="{{ notif._id.toString() }}">
+              {% for userNotif in userNotifications %}
+              <tr class="admin-notif-row" data-updatepost-id="{{ userNotif._id.toString() }}">
                 <td>
                 <td>
-                  {{ notif.pathPattern }}
+                  {{ userNotif.pathPattern }}
                 </td>
                 </td>
                 <td>
                 <td>
-                  {{ notif.channel }}
+                  {{ userNotif.channel }}
                 </td>
                 </td>
                 <td>
                 <td>
                   <form class="admin-remove-updatepost">
                   <form class="admin-remove-updatepost">
-                    <input type="hidden" name="id" value="{{ notif._id.toString() }}">
+                    <input type="hidden" name="id" value="{{ userNotif._id.toString() }}">
                     <input type="hidden" name="_csrf" value="{{ csrf() }}">
                     <input type="hidden" name="_csrf" value="{{ csrf() }}">
                     <input type="submit" value="Delete" class="btn btn-default">
                     <input type="submit" value="Delete" class="btn btn-default">
                   </form>
                   </form>
@@ -251,7 +251,7 @@
         </div><!-- /#user-trigger-notification -->
         </div><!-- /#user-trigger-notification -->
 
 
         <div id="global-notification" class="tab-pane" role="tabpanel" >
         <div id="global-notification" class="tab-pane" role="tabpanel" >
-          <p class="alert alert-info">not implemented now</p>
+          {% include './global-notification.html' %}
         </div><!-- /#global-notification -->
         </div><!-- /#global-notification -->
 
 
       </div><!-- /.tab-content -->
       </div><!-- /.tab-content -->

+ 3 - 0
resource/styles/agile-admin/inverse/colors/_apply-colors-dark.scss

@@ -161,6 +161,9 @@ legend {
       border-color: darken($themecolor,15%);
       border-color: darken($themecolor,15%);
     }
     }
   }
   }
+  .clickable-row:hover {
+    background-color: lighten($bodycolor, 10%);;
+  }
 }
 }
 
 
 /*
 /*

+ 3 - 0
resource/styles/agile-admin/inverse/colors/_apply-colors-light.scss

@@ -77,6 +77,9 @@
       border-color: lighten($themecolor,20%);
       border-color: lighten($themecolor,20%);
     }
     }
   }
   }
+  .clickable-row:hover {
+    background-color: darken($bodycolor, 10%);
+  }
 }
 }
 
 
 /*
 /*

+ 18 - 0
resource/styles/agile-admin/inverse/colors/_apply-colors.scss

@@ -250,6 +250,24 @@ legend {
   }
   }
 }
 }
 
 
+/*
+ * Form Slider
+ */
+.admin-page {
+  span.slider {
+    background-color: #ccc;
+    &:before {
+      background-color: white;
+    }
+  }
+  input:checked+.slider {
+    background-color: #007bff;
+  }
+  input:focus+.slider {
+    box-shadow: 0 0 1px #007bff;
+  }
+}
+
 
 
 /*
 /*
  * Crowi sidebar
  * Crowi sidebar

+ 72 - 0
resource/styles/scss/_admin.scss

@@ -42,6 +42,78 @@
     }
     }
   }
   }
 
 
+  .admin-notification {
+    .clickable-row {
+      .unclickable {
+        text-align: center;
+        vertical-align: middle;
+        width: 50px;
+        label.switch {
+          margin: 0;
+        }
+      }
+      :not(.unclickable) {
+        cursor: pointer;
+      }
+    }
+
+    /* slider checkbox */
+
+    /*
+    * Form Slider
+    */
+    .switch {
+      position: relative;
+      display: inline-block;
+      width: 30px;
+      height: 17px;
+    }
+
+    /* Hide default HTML checkbox */
+
+    .switch input {
+      display: none;
+    }
+
+    /* The slider */
+    .slider {
+      position: absolute;
+      cursor: pointer;
+      top: 0;
+      left: 0;
+      right: 0;
+      bottom: 0;
+      -webkit-transition: .4s;
+      transition: .4s;
+    }
+
+    .slider:before {
+      position: absolute;
+      content: "";
+      height: 13px;
+      width: 13px;
+      left: 2px;
+      bottom: 2px;
+      -webkit-transition: .4s;
+      transition: .4s;
+    }
+
+    input:checked+.slider:before {
+      -webkit-transform: translateX(13px);
+      -ms-transform: translateX(13px);
+      transform: translateX(13px);
+    }
+
+    /* Rounded sliders */
+    .slider.round {
+      border-radius: 34px;
+    }
+
+    .slider.round:before {
+      border-radius: 50%;
+    }
+  }
+
   // Toggle Twitter Bootstrap button class when active
   // Toggle Twitter Bootstrap button class when active
   // https://jsfiddle.net/ms040m01/3/
   // https://jsfiddle.net/ms040m01/3/
   @mixin active-color($color, $bg-color, $border-color) {
   @mixin active-color($color, $bg-color, $border-color) {