Просмотр исходного кода

Merge branch 'master' into feat/integrate-with-hackmd

Yuki Takei 7 лет назад
Родитель
Сommit
423a1a4fb6
73 измененных файлов с 1953 добавлено и 857 удалено
  1. 1 0
      .babelrc
  2. 1 0
      .eslintrc.js
  3. 4 0
      .gitignore
  4. 15 1
      CHANGES.md
  5. 2 2
      README.md
  6. 73 45
      config/webpack.common.js
  7. 42 85
      config/webpack.dev.js
  8. 51 0
      config/webpack.dll.js
  9. 45 96
      config/webpack.prod.js
  10. 1 1
      lib/crowi/express-init.js
  11. 8 2
      lib/crowi/index.js
  12. 12 0
      lib/form/admin/securityPassportGitHub.js
  13. 12 0
      lib/form/admin/securityPassportGoogle.js
  14. 2 0
      lib/form/index.js
  15. 33 17
      lib/locales/en-US/translation.json
  16. 1 1
      lib/locales/en-US/welcome.md
  17. 36 19
      lib/locales/ja/translation.json
  18. 1 1
      lib/locales/ja/welcome.md
  19. 5 5
      lib/models/bookmark.js
  20. 17 4
      lib/models/config.js
  21. 12 34
      lib/models/page.js
  22. 11 12
      lib/models/revision.js
  23. 71 3
      lib/routes/admin.js
  24. 21 17
      lib/routes/comment.js
  25. 8 1
      lib/routes/index.js
  26. 155 67
      lib/routes/login-passport.js
  27. 0 21
      lib/routes/page.js
  28. 3 3
      lib/routes/revision.js
  29. 104 0
      lib/service/passport.js
  30. 10 0
      lib/util/swigFunctions.js
  31. 1 1
      lib/views/_form.html
  32. 5 5
      lib/views/admin/customize.html
  33. 1 1
      lib/views/admin/index.html
  34. 3 4
      lib/views/admin/markdown.html
  35. 14 11
      lib/views/admin/security.html
  36. 88 4
      lib/views/admin/widget/passport/github.html
  37. 83 74
      lib/views/admin/widget/passport/google-oauth.html
  38. 2 2
      lib/views/admin/widget/passport/ldap.html
  39. 1 1
      lib/views/admin/widget/theme-colorbox.html
  40. 1 7
      lib/views/layout-crowi/widget/page_side_header.html
  41. 3 3
      lib/views/layout-growi/widget/header.html
  42. 1 1
      lib/views/layout/admin.html
  43. 16 15
      lib/views/layout/layout.html
  44. 71 4
      lib/views/login.html
  45. 9 9
      lib/views/me/external-accounts.html
  46. 1 32
      lib/views/modal/create_page.html
  47. 3 1
      lib/views/modal/create_template.html
  48. 1 1
      lib/views/modal/shortcuts.html
  49. 6 7
      lib/views/page_presentation.html
  50. 2 9
      lib/views/widget/page_attachments.html
  51. 1 1
      lib/views/widget/page_list.html
  52. 1 1
      lib/views/widget/system-version.html
  53. 28 19
      package.json
  54. 0 1
      public/css/.gitignore
  55. 19 18
      resource/js/app.js
  56. 2 2
      resource/js/components/Admin/CustomCssEditor.js
  57. 2 2
      resource/js/components/Admin/CustomHeaderEditor.js
  58. 2 2
      resource/js/components/Admin/CustomScriptEditor.js
  59. 108 28
      resource/js/components/PageComment/CommentForm.js
  60. 2 2
      resource/js/components/PageEditor.js
  61. 17 0
      resource/js/components/PageEditor/AbstractEditor.js
  62. 47 13
      resource/js/components/PageEditor/CodeMirrorEditor.js
  63. 14 0
      resource/js/components/PageEditor/Editor.js
  64. 27 4
      resource/js/components/PageEditor/TextAreaEditor.js
  65. 1 1
      resource/js/components/PageList/Page.js
  66. 1 1
      resource/js/components/SearchTypeahead.js
  67. 2 0
      resource/js/ie11-polyfill.js
  68. 0 1
      resource/js/legacy/crowi-form.js
  69. 0 1
      resource/js/legacy/crowi.js
  70. 14 3
      resource/js/util/GrowiRenderer.js
  71. 51 0
      resource/js/util/codemirror/autorefresh.ext.js
  72. 51 3
      resource/styles/scss/_login.scss
  73. 494 125
      yarn.lock

+ 1 - 0
.babelrc

@@ -1,4 +1,5 @@
 {
+  "plugins": ["lodash"],
   "presets": [
     ["env", {
       "targets": {

+ 1 - 0
.eslintrc.js

@@ -69,6 +69,7 @@ module.exports = {
       "error",
       { "args": "none" }
     ],
+    "no-var": [ "error" ],
     "quotes": [
       "error",
       "single"

+ 4 - 0
.gitignore

@@ -18,8 +18,12 @@ package-lock.json
 
 # Dist #
 /report/
+/public/*.chunk.js
+/public/*.bundle.js
+/public/manifest.json
 /public/dll
 /public/js
+/public/styles
 /public/uploads
 /src/*/__build__/
 /__build__/**

+ 15 - 1
CHANGES.md

@@ -1,7 +1,21 @@
 CHANGES
 ========
 
-## 3.1.7-RC
+## 3.1.8-RC
+
+* Feature: Login with Google Account
+* Feature: Login with GitHub Account
+* Feature: Attach files in Comment
+* Improvement: Write comment with CodeMirror Editor
+* Improvement: Place the commented page at the beginning of the list
+* Improvement: Resolve errors on IE11 (Experimental)
+* Support: Migrate to webpack 4 
+* Support: Upgrade libs
+    * react-bootstrap-typeahead
+    * react-codemirror2
+    * webpack
+
+## 3.1.7
 
 * Fix: Update hidden input 'pageForm[grant]' when save with Ctrl-S
 * Fix: Show alert message when conflict

+ 2 - 2
README.md

@@ -4,8 +4,8 @@
   </a>
 </p>
 <p align="center">
-  <a href="https://github.com/weseek/crowi-plus/releases/latest"><img src="https://img.shields.io/github/release/weseek/crowi-plus.svg"></a>
-  <a href="https://growi-slackin.weseek.co.jp/"><img src="https://crowi-plus-slackin.weseek.co.jp/badge.svg"></a>
+  <a href="https://github.com/weseek/growi/releases/latest"><img src="https://img.shields.io/github/release/weseek/growi.svg"></a>
+  <a href="https://growi-slackin.weseek.co.jp/"><img src="https://growi-slackin.weseek.co.jp/badge.svg"></a>
 </p>
 
 <p align="center">

+ 73 - 45
config/webpack.common.js

@@ -8,32 +8,41 @@ const helpers = require('./helpers');
 /*
  * Webpack Plugins
  */
-const AssetsPlugin = require('assets-webpack-plugin');
-const CommonsChunkPlugin = require('webpack/lib/optimize/CommonsChunkPlugin');
+const WebpackAssetsManifest = require('webpack-assets-manifest');
+const LodashModuleReplacementPlugin = require('lodash-webpack-plugin');
 
 /*
  * Webpack configuration
  *
  * See: http://webpack.github.io/docs/configuration.html#cli
  */
-module.exports = function(options) {
+module.exports = (options) => {
   return {
-    entry: {
-      'app':                  './resource/js/app',
-      'legacy':               './resource/js/legacy/crowi',
-      'legacy-form':          './resource/js/legacy/crowi-form',
-      'legacy-admin':         './resource/js/legacy/crowi-admin',
-      'legacy-presentation':  './resource/js/legacy/crowi-presentation',
-      'plugin':               './resource/js/plugin',
-      'style':                './resource/styles/scss/style.scss',
-      'style-theme-default':  './resource/styles/scss/theme/default.scss',
-      'style-theme-default-dark':  './resource/styles/scss/theme/default-dark.scss',
-      'style-theme-nature':   './resource/styles/scss/theme/nature.scss',
-      'style-theme-mono-blue':   './resource/styles/scss/theme/mono-blue.scss',
-      'style-theme-future': './resource/styles/scss/theme/future.scss',
-      'style-theme-blue-night': './resource/styles/scss/theme/blue-night.scss',
-      'style-presentation':   './resource/styles/scss/style-presentation.scss',
-    },
+    mode: options.mode,
+    entry: Object.assign({
+      'js/app':                   './resource/js/app',
+      'js/legacy':                './resource/js/legacy/crowi',
+      'js/legacy-form':           './resource/js/legacy/crowi-form',
+      'js/legacy-admin':          './resource/js/legacy/crowi-admin',
+      'js/legacy-presentation':   './resource/js/legacy/crowi-presentation',
+      'js/plugin':                './resource/js/plugin',
+      'js/ie11-polyfill':         './resource/js/ie11-polyfill',
+      // styles
+      'styles/style':                './resource/styles/scss/style.scss',
+      'styles/style-presentation':   './resource/styles/scss/style-presentation.scss',
+      // themes
+      'styles/theme-default':        './resource/styles/scss/theme/default.scss',
+      'styles/theme-default-dark':   './resource/styles/scss/theme/default-dark.scss',
+      'styles/theme-nature':         './resource/styles/scss/theme/nature.scss',
+      'styles/theme-mono-blue':      './resource/styles/scss/theme/mono-blue.scss',
+      'styles/theme-future':         './resource/styles/scss/theme/future.scss',
+      'styles/theme-blue-night':     './resource/styles/scss/theme/blue-night.scss',
+    }, options.entry || {}),  // Merge with env dependent settings
+    output: Object.assign({
+      path: helpers.root('public'),
+      publicPath: '/',
+      filename: '[name].bundle.js',
+    }, options.output || {}), // Merge with env dependent settings
     externals: {
       // require("jquery") is external and available
       //  on the global var jQuery
@@ -53,7 +62,7 @@ module.exports = function(options) {
       }
     },
     module: {
-      rules: [
+      rules: options.module.rules.concat([
         {
           test: /.jsx?$/,
           exclude: {
@@ -64,10 +73,7 @@ module.exports = function(options) {
             ]
           },
           use: [{
-            loader: 'babel-loader?cacheDirectory',
-            options: {
-              plugins: ['lodash'],
-            }
+            loader: 'babel-loader?cacheDirectory'
           }]
         },
         {
@@ -98,40 +104,62 @@ module.exports = function(options) {
         */
         {
           test: /\.(eot|woff2?|svg|ttf)([?]?.*)$/,
-          use: 'file-loader',
+          use: 'null-loader',
         }
-      ]
+      ])
     },
-    plugins: [
+    plugins: options.plugins.concat([
 
-      new AssetsPlugin({
-        path: helpers.root('public/js'),
-        filename: 'webpack-assets.json',
-        prettyPrint: true,
-      }),
+      new WebpackAssetsManifest({ publicPath: true }),
 
-      new CommonsChunkPlugin({
-        name: 'commons',
-        chunks: ['app', 'legacy', 'legacy-form', 'legacy-admin'],
-        minChunks: module => /node_modules/.test(module.resource),
-      }),
-      new CommonsChunkPlugin({
-        name: 'commons',
-        chunks: ['commons', 'legacy-presentation'],
-      }),
-      new CommonsChunkPlugin({
-        name: 'commons',
-        chunks: ['commons', 'plugin'],
+      new webpack.DefinePlugin({
+        'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV),
       }),
 
       // ignore
       new webpack.IgnorePlugin(/^\.\/lib\/deflate\.js/, /markdown-it-plantuml/),
 
+      new LodashModuleReplacementPlugin({
+        flattening: true
+      }),
+
       new webpack.ProvidePlugin({ // refs externals
         jQuery: 'jquery',
         $: 'jquery',
       }),
 
-    ]
+    ]),
+
+    devtool: options.devtool,
+    target: 'web', // Make web variables accessible to webpack, e.g. window
+    optimization: {
+      namedModules: true,
+      splitChunks: {
+        cacheGroups: {
+          commons: {
+            test: /resource/,
+            chunks: 'initial',
+            name: 'js/commons',
+            minChunks: 2,
+            minSize: 1,
+            priority: 20
+          },
+          vendors: {
+            test: /node_modules/,
+            chunks: (chunk) => {
+              return chunk.name != null && !chunk.name.match(/legacy-presentation|ie11-polyfill/);
+            },
+            name: 'js/vendors',
+            // minChunks: 2,
+            minSize: 1,
+            priority: 10,
+            enforce: true
+          }
+        }
+      },
+      minimizer: options.optimization.minimizer || [],
+    },
+    performance: options.performance || {},
+    stats: options.stats || {},
   };
 };

+ 42 - 85
config/webpack.dev.js

@@ -5,98 +5,55 @@
 const path = require('path');
 const webpack = require('webpack');
 const helpers = require('./helpers');
-const webpackMerge = require('webpack-merge');
-const webpackMergeDll = webpackMerge.strategy({plugins: 'replace'});
-const commonConfig = require('./webpack.common.js');
 
 /*
  * Webpack Plugins
  */
-const DllBundlesPlugin = require('webpack-dll-bundles-plugin').DllBundlesPlugin;
 const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
 
-/*
+/**
  * Webpack Constants
  */
 const ANALYZE = process.env.ANALYZE;
-const ENV = process.env.ENV = process.env.NODE_ENV = 'development';
-
-/*
- * Webpack configuration
- *
- * See: http://webpack.github.io/docs/configuration.html#cli
- */
-module.exports = function(options) {
-  return webpackMerge(commonConfig({ env: ENV }), {
-    devtool: 'cheap-module-eval-source-map',
-    entry: {
-      dev: './resource/js/dev',
-    },
-    output: {
-      path: helpers.root('public/js'),
-      publicPath: '/js/',
-      filename: '[name].bundle.js',
-    },
-    resolve: {
-      extensions: ['.js', '.json'],
-      modules: [helpers.root('src'), helpers.root('node_modules'), path.join(process.env.HOME, '.node_modules')],
-    },
-    module: {
-      rules: [
-        {
-          test: /\.css$/,
-          use: [
-            'style-loader',
-            { loader: 'css-loader', options: { sourceMap: true } },
-          ],
-          include: [helpers.root('resource/styles/scss')]
-        },
-        {
-          test: /\.scss$/,
-          use: [
-            'style-loader',
-            { loader: 'css-loader', options: { sourceMap: true } },
-            { loader: 'sass-loader', options: { sourceMap: true } },
-          ],
-          include: [helpers.root('resource/styles/scss')]
-        },
-      ],
-    },
-    plugins: [
-
-      new DllBundlesPlugin({
-        bundles: {
-          vendor: [
-            'axios',
-            'codemirror',
-            'date-fns',
-            'diff',
-            'diff2html',
-            'jquery-ui',
-            'markdown-it',
-            'metismenu',
-            'react',
-            'react-dom',
-            'react-bootstrap',
-            'react-bootstrap-typeahead',
-            'react-dropzone',
-            'socket.io-client',
-            'toastr',
-          ],
-        },
-        dllDir: helpers.root('public/dll'),
-        webpackConfig: webpackMergeDll(commonConfig({env: ENV}), {
-          devtool: undefined,
-          plugins: [],
-        })
-      }),
-
-      new webpack.NoEmitOnErrorsPlugin(),
-
-      new BundleAnalyzerPlugin({
-        analyzerMode: ANALYZE ? 'server' : 'disabled',
-      }),
 
-    ]
-  });
-};
+module.exports = require('./webpack.common')({
+  mode: 'development',
+  devtool: 'cheap-module-eval-source-map',
+  entry: {
+    'js/dev': './resource/js/dev',
+  },
+  resolve: {
+    // TODO merge in webpack.common.js
+    modules: [path.join(process.env.HOME, '.node_modules')],
+  },
+  module: {
+    rules: [
+      {
+        test: /\.scss$/,
+        use: [
+          'style-loader',
+          { loader: 'css-loader', options: { sourceMap: true } },
+          { loader: 'sass-loader', options: { sourceMap: true } },
+        ],
+        include: [helpers.root('resource/styles/scss')]
+      },
+    ],
+  },
+  plugins: [
+
+    new webpack.DllReferencePlugin({
+      context: helpers.root(),
+      manifest: require(helpers.root('public/dll', 'manifest.json')),
+    }),
+
+    new BundleAnalyzerPlugin({
+      analyzerMode: ANALYZE ? 'server' : 'disabled',
+    }),
+
+  ],
+  optimization: {},
+  performance: {
+    hints: false
+  }
+
+});

+ 51 - 0
config/webpack.dll.js

@@ -0,0 +1,51 @@
+/**
+ * @author: Yuki Takei <yuki@weseek.co.jp>
+ */
+const webpack = require('webpack');
+const helpers = require('./helpers');
+
+
+module.exports = {
+  mode: 'development',
+  entry: {
+    dlls: [
+      // Libraries
+      'axios',
+      'babel-polyfill',
+      'bootstrap-select',
+      'browser-bunyan', 'bunyan-format',
+      'codemirror', 'react-codemirror2',
+      'clipboard',
+      'date-fns',
+      'diff2html',
+      'debug',
+      'entities',
+      'i18next', 'i18next-browser-languagedetector',
+      'jquery-slimscroll',
+      'lodash', 'pako',
+      'markdown-it', 'csv-to-markdown-table',
+      'react', 'react-dom',
+      'react-bootstrap', 'react-bootstrap-typeahead', 'react-i18next', 'react-dropzone',
+      'socket.io-client',
+      'toastr',
+      'xss',
+      // GROWI Libraries
+      'growi-pluginkit',
+    ]
+  },
+  output: {
+    path: helpers.root('public/dll'),
+    filename: 'dll.js',
+    library: 'growi_dlls',
+  },
+  resolve: {
+    extensions: ['.js', '.json'],
+    modules: [helpers.root('src'), helpers.root('node_modules')],
+  },
+  plugins: [
+    new webpack.DllPlugin({
+      path: helpers.root('public/dll/manifest.json'),
+      name: 'growi_dlls'
+    })
+  ],
+};

+ 45 - 96
config/webpack.prod.js

@@ -3,117 +3,66 @@
  */
 
 const helpers = require('./helpers');
-const webpack = require('webpack');
-const webpackMerge = require('webpack-merge'); // used to merge webpack configs
-const commonConfig = require('./webpack.common.js'); // the settings that are common to prod and dev
 
 /**
  * Webpack Plugins
  */
-const ExtractTextPlugin = require('extract-text-webpack-plugin');
-const UglifyJsPlugin = require('webpack/lib/optimize/UglifyJsPlugin');
-const OptimizeJsPlugin = require('optimize-js-plugin');
+const UglifyJsPlugin = require('uglifyjs-webpack-plugin');
+const MiniCssExtractPlugin = require('mini-css-extract-plugin');
+const OptimizeCSSAssetsPlugin = require('optimize-css-assets-webpack-plugin');
 const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
 
 /**
  * Webpack Constants
  */
 const ANALYZE = process.env.ANALYZE;
-const ENV = process.env.NODE_ENV = process.env.ENV = 'production';
 
-module.exports = function(env) {
-  return webpackMerge(commonConfig({ env: ENV }), {
-    devtool: undefined,
-    output: {
-      path: helpers.root('public/js'),
-      publicPath: '/js/',
-      filename: '[name].[chunkhash].bundle.js',
-      sourceMapFilename: '[name].[chunkhash].bundle.map',
-      chunkFilename: '[id].[chunkhash].chunk.js'
-    },
-    module: {
-      rules: [
-        {
-          test: /\.scss$/,
-          use: ExtractTextPlugin.extract({
-            fallback: 'style-loader',
-            use: [
-              { loader: 'css-loader', options: {
-                sourceMap: false,
-                minimize: true
-              } },
-              { loader: 'postcss-loader', options: {
-                sourceMap: false,
-                plugins: (loader) => [
-                  require('autoprefixer')()
-                ]
-              } },
-              { loader: 'sass-loader', options: { sourceMap: false } }
+module.exports = require('./webpack.common')({
+  mode: 'production',
+  devtool: undefined,
+  output: {
+    filename: '[name].[chunkhash].bundle.js',
+    chunkFilename: '[name].[chunkhash].chunk.js'
+  },
+  module: {
+    rules: [
+      {
+        test: /\.scss$/,
+        use: [
+          MiniCssExtractPlugin.loader,
+          'css-loader',
+          { loader: 'postcss-loader', options: {
+            sourceMap: false,
+            plugins: (loader) => [
+              require('autoprefixer')()
             ]
-          }),
-          include: [helpers.root('resource/styles/scss')]
-        }
-      ]
-    },
-    plugins: [
+          } },
+          'sass-loader'
+        ],
+        include: [helpers.root('resource/styles/scss')]
+      }
+    ]
+  },
+  plugins: [
 
-      new webpack.DefinePlugin({
-        'process.env': {
-          NODE_ENV: JSON.stringify(ENV),
-        }
-      }),
-
-      new ExtractTextPlugin('[name].[contenthash].css'),
+    new MiniCssExtractPlugin({
+      filename: '[name].[hash].css',
+    }),
 
-      new OptimizeJsPlugin({
-        sourceMap: false
-      }),
+    new BundleAnalyzerPlugin({
+      analyzerMode: ANALYZE ? 'static' : 'disabled',
+      reportFilename: helpers.root('report/bundle-analyzer.html'),
+      openAnalyzer: false,
+    }),
 
+  ],
+  optimization: {
+    minimizer: [
       new UglifyJsPlugin({
-        // beautify: true, //debug
-        // mangle: false, //debug
-        // dead_code: false, //debug
-        // unused: false, //debug
-        // deadCode: false, //debug
-        // compress: {
-        //   screw_ie8: true,
-        //   keep_fnames: true,
-        //   drop_debugger: false,
-        //   dead_code: false,
-        //   unused: false
-        // }, // debug
-        // comments: true, //debug
-
-
-        beautify: false, //prod
-        output: {
-          comments: false
-        }, //prod
-        mangle: {
-          screw_ie8: true
-        }, //prod
-        compress: {
-          screw_ie8: true,
-          warnings: false,
-          conditionals: true,
-          unused: true,
-          comparisons: true,
-          sequences: true,
-          dead_code: true,
-          evaluate: true,
-          if_return: true,
-          join_vars: true,
-          negate_iife: false // we need this for lazy v8
-        },
+        cache: true,
+        parallel: true,
       }),
-
-      new BundleAnalyzerPlugin({
-        analyzerMode: ANALYZE ? 'static' : 'disabled',
-        reportFilename: helpers.root('report/bundle-analyzer.html'),
-        openAnalyzer: false,
-      }),
-
+      new OptimizeCSSAssetsPlugin({})
     ],
-
-  });
-};
+  },
+});

+ 1 - 1
lib/crowi/express-init.js

@@ -89,7 +89,7 @@ module.exports = function(crowi, app) {
   app.use(express.static(crowi.publicDir, staticOption));
   app.engine('html', swig.renderFile);
   app.use(webpackAssets(
-    path.join(crowi.publicDir, 'js/webpack-assets.json'),
+    path.join(crowi.publicDir, 'manifest.json'),
     { devMode: (crowi.node_env === 'development') })
   );
   // app.set('view cache', false);  // Default: true in production, otherwise undefined. -- 2017.07.04 Yuki Takei

+ 8 - 2
lib/crowi/index.js

@@ -262,8 +262,14 @@ Crowi.prototype.setupPassport = function() {
   this.passportService.setupSerializer();
   // setup strategies
   this.passportService.setupLocalStrategy();
-  this.passportService.setupLdapStrategy();
-
+  try {
+    this.passportService.setupLdapStrategy();
+    this.passportService.setupGoogleStrategy();
+    this.passportService.setupGitHubStrategy();
+  }
+  catch (err) {
+    logger.error(err);
+  }
   return Promise.resolve();
 };
 

+ 12 - 0
lib/form/admin/securityPassportGitHub.js

@@ -0,0 +1,12 @@
+'use strict';
+
+var form = require('express-form')
+  , field = form.field
+  ;
+
+module.exports = form(
+  field('settingForm[security:passport-github:isEnabled]').trim().toBooleanStrict().required(),
+  field('settingForm[security:passport-github:clientId]').trim(),
+  field('settingForm[security:passport-github:clientSecret]').trim(),
+  field('settingForm[security:passport-github:isSameUsernameTreatedAsIdenticalUser]').trim().toBooleanStrict()
+);

+ 12 - 0
lib/form/admin/securityPassportGoogle.js

@@ -0,0 +1,12 @@
+'use strict';
+
+var form = require('express-form')
+  , field = form.field
+  ;
+
+module.exports = form(
+  field('settingForm[security:passport-google:isEnabled]').trim().toBooleanStrict().required(),
+  field('settingForm[security:passport-google:clientId]').trim(),
+  field('settingForm[security:passport-google:clientSecret]').trim(),
+  field('settingForm[security:passport-google:isSameUsernameTreatedAsIdenticalUser]').trim().toBooleanStrict()
+);

+ 2 - 0
lib/form/index.js

@@ -19,6 +19,8 @@ module.exports = {
     securityGoogle: require('./admin/securityGoogle'),
     securityMechanism: require('./admin/securityMechanism'),
     securityPassportLdap: require('./admin/securityPassportLdap'),
+    securityPassportGoogle: require('./admin/securityPassportGoogle'),
+    securityPassportGitHub: require('./admin/securityPassportGitHub'),
     markdown: require('./admin/markdown'),
     customcss: require('./admin/customcss'),
     customscript: require('./admin/customscript'),

+ 33 - 17
lib/locales/en-US/translation.json

@@ -307,22 +307,23 @@
     "username_email_password": "Username, Email and Password authentication",
     "ldap_auth": "LDAP authentication",
     "google_auth2": "Google OAuth2 authentication",
+    "google_auth2_by_crowi_desc": "However, this feature does not create new users, butit only makes it possible to login to the existing user who set up the association.",
     "facebook_auth2": "Facebook OAuth2 authentication",
     "twitter_auth2": "Twitter OAuth authentication",
-    "github_auth2": "Github OAuth2 authentication",
+    "github_auth2": "GitHub OAuth2 authentication",
     "crowi_auth": "Crowi classic authentication mechanism",
 		"require_server_restart": "Restarting the server is required.",
 		"server_on_passport_auth": "The server is running with Passport authentication mechanism.",
 		"server_on_crowi_auth": "The server is running with official Crowi authentication mechanism.",
 		"google_setting": "Google Setting",
-    "connect_api_manager": "You can use your Google account to sign up and login after creating OAuth2 ClientId at <a href=\"https://console.cloud.google.com/apis/credentials\">API Manager on Google Cloud Platform</a>",
-		"access_api_manager": "Access <a href=\"https://console.cloud.google.com/apis/credentials\">API Manager</a>",
+    "connect_api_manager": "You can use your Google account to sign up and login after creating OAuth2 ClientId at <a href=\"https://console.cloud.google.com/apis/credentials\" target=\"_blank\">API Manager on Google Cloud Platform</a>",
+		"access_api_manager": "Access <a href=\"%s\" target=\"_blank\">%s</a>",
 		"create_project": "Create Project if no projects have been created.",
 		"create_auth_to_oauth": "\"Create credentials\" -> \"OAuth clientID\"",
 		"select_webapp": "Select \"Web Application\"",
-    "change_redirect_url": "Enter <code>https://${crowi.host}/google/callback</code> <br>(where < code > $ {crowi.host} < /code> is your host name) for \"Authorized redirect URIs\".",
+    "change_redirect_url": "Enter <code>https://${crowi.host}/google/callback</code> <br>(where <code>${crowi.host}</code> is your host name) for \"Authorized redirect URIs\".",
     "clientID": "Client ID",
-    "client_secret": "クライアントシークレット",
+    "client_secret": "Client Secret",
     "guest_mode": {
       "deny": "Deny Unregistered Users",
       "readonly": "View Only"
@@ -332,8 +333,10 @@
       "restricted": "Reuqire Admin permission",
       "closed": "Invitation Only"
     },
-    "configuration": "Configuration",
+    "configuration": " Configuration",
     "optional": "Optional",
+    "Treat username matching as identical": "Automatically bind external accounts newly logged in to local accounts when <code>%s</code> match",
+    "Treat username matching as identical_warn": "WARNING: Be aware of security because the system treats the same user as a match of <code>%s</code>.",
     "ldap": {
       "server_url_detail": "The LDAP URL of the directory service in the format <code>ldap://host:port/DN</code> or <code>ldaps://host:port/DN</code>.",
       "bind_mode": "Binding Mode",
@@ -353,8 +356,6 @@
       "search_filter_example2": "Match with 'sAMAccountName' for Active Directory",
       "username_detail": "Specification of mappings for <code>username</code> when creating new users",
       "name_detail": "Specification of mappings for <code>name</code> when creating new users",
-      "Treat username matching as identical": "Automatically bind external accounts newly logged in to local accounts when <code>username</code> match",
-  		"Treat username matching as identical_warn": "WARNING: Be aware of security because the system treats the same user as a match of <code>username</code>.",
       "group_search_base_DN": "Group Search Base DN",
       "group_search_base_DN_detail": "The base DN from which to search for groups. If defined, also <code>Group Search Filter</code> must be defined for the search to work.",
       "group_search_filter": "Group Search Filter",
@@ -365,13 +366,29 @@
       "group_search_user_DN_property_detail": "The property of user object to use in <code>&#123;&#123;dn&#125;&#125;</code> interpolation of <code>Group Search Filter</code>.",
       "test_config": "Test Saved Configuration"
     },
-    "Google OAuth": {
-    },
-    "Facebook": {
-    },
-    "Twitter": {
-    },
-    "Github": {
+    "OAuth": {
+      "register": "Register for %s",
+      "change_redirect_url": "Enter <code>%s</code> <br>(where <code>%s</code> is your host name) for \"Authorized redirect URIs\".",
+      "Google": {
+        "name": "Google OAuth",
+        "register_1": "Access <a href=\"%s\" target=\"_blank\">%s</a>",
+        "register_2": "Create Project if no projects exist",
+        "register_3": "Create Credentials &rightarrow; OAuth client ID &rightarrow; Select \"Web application\"",
+        "register_4": "Register your OAuth App with one of Authorized redirect URIs as <code>%s</code> (where <code>%s</code> is your hostname)",
+        "register_5": "Copy and paste your ClientID and Client Secret above"
+      },
+      "Facebook": {
+        "name": "Facebook OAuth"
+      },
+      "Twitter": {
+        "name": "Twitter OAuth"
+      },
+      "GitHub": {
+        "name": "GitHub OAuth",
+        "register_1": "Access <a href=\"%s\" target=\"_blank\">%s</a>",
+        "register_2": "Register your OAuth App with \"Authorization callback URL\" as <code>%s</code> (where <code>%s</code> is your hostname)",
+        "register_3": "Copy and paste your ClientID and Client Secret above"
+      }
     }
 	},
 
@@ -380,8 +397,7 @@
     "Enable Line Break": "Enable Line Break",
     "Enable Line Break desc": "Treat line break in the text page as <code>&lt;br&gt;</code> in HTML",
     "Enable Line Break for comment": "Enable Line Break in comment",
-    "Enable Line Break for comment desc": "Treat line break in comment as <code>&lt;br&gt;</code> in HTML",
-    "TBD": "(TBD: Markdown function in the comment section has not been implemented yet)"
+    "Enable Line Break for comment desc": "Treat line break in comment as <code>&lt;br&gt;</code> in HTML"
   },
 
   "customize_page": {

+ 1 - 1
lib/locales/en-US/welcome.md

@@ -1,6 +1,6 @@
 # Welcome to GROWI :anchor:
 
-[![Github Releases](https://img.shields.io/github/release/weseek/growi.svg)](https://github.com/weseek/growi/releases/latest)
+[![GitHub Releases](https://img.shields.io/github/release/weseek/growi.svg)](https://github.com/weseek/growi/releases/latest)
 [![MIT License](https://img.shields.io/badge/license-MIT-blue.svg?style=flat)](LICENSE)
 
 <div class="panel panel-default">

+ 36 - 19
lib/locales/ja/translation.json

@@ -84,7 +84,7 @@
   "Table of Contents": "目次",
   "Management Wiki Home": "Wiki管理トップ",
   "App settings": "アプリ設定",
-  "Markdown settings": "Markdown設定",
+  "Markdown settings": "マークダウン設定",
   "Customize": "カスタマイズ",
   "Notification settings": "通知設定",
   "User management": "ユーザー管理",
@@ -248,11 +248,11 @@
     },
     "children": {
       "label": "同一階層テンプレート",
-      "desc": "テンプレートページが存在する階層にのみ適されます"
+      "desc": "テンプレートページが存在する階層にのみ適されます"
     },
     "decendants": {
       "label": "下位層テンプレート",
-      "desc": "テンプレートページが存在する下位層のすべてのページに適されます"
+      "desc": "テンプレートページが存在する下位層のすべてのページに適されます"
     }
   },
 
@@ -325,15 +325,16 @@
     "username_email_password": "ユーザー名、Eメール、パスワードでの認証",
     "ldap_auth": "LDAP 認証",
     "google_auth2": "Google OAuth2 認証",
+    "google_auth2_by_crowi_desc": "ただし、この機能では新たなユーザーは作成されず、関連付け設定を行った既存ユーザーをログインできるようにするだけです。",
     "facebook_auth2": "Facebook OAuth2 認証",
     "twitter_auth2": "Twitter OAuth 認証",
-    "github_auth2": "Github OAuth2 認証",
+    "github_auth2": "GitHub OAuth2 認証",
     "require_server_restart": "サーバーを再起動してください。",
     "server_on_passport_auth": "Passport 認証機構でサーバーが稼働しています。",
     "server_on_crowi_auth": "Crowi Classic 認証機構でサーバーが稼働しています。",
     "google_setting": "Google 設定",
-    "connect_api_manager": "Google Cloud Platform の <a href=\"https://console.cloud.google.com/apis/credentials\">API Manager</a>から OAuth2 Client ID を作成すると、Google アカウントにコネクトして登録やログインが可能になります。",
-    "access_api_manager": "<a href=\"https://console.cloud.google.com/apis/credentials\">API Manager</a> へアクセス",
+    "connect_api_manager": "Google Cloud Platform の <a href=\"https://console.cloud.google.com/apis/credentials\" target=\"_blank\">API Manager</a>から OAuth2 Client ID を作成すると、Google アカウントにコネクトして登録やログインが可能になります。",
+    "access_api_manager": "<a href=\"%s\" target=\"_blank\">%s</a> へアクセス",
     "create_project": "プロジェクトを作成していない場合は作成してください",
     "create_auth_to_oauth": "「認証情報を作成」-> OAuthクライアントID",
     "select_webapp": "「ウェブアプリケーション」を選択",
@@ -349,8 +350,10 @@
       "restricted": "制限 (登録完了には管理者の承認が必要)",
       "closed": "非公開 (登録には管理者による招待が必要)"
     },
-    "configuration": "コンフィギュレーション",
+    "configuration": "設定",
     "optional": "オプション",
+    "Treat username matching as identical": "新規ログイン時、<code>%s</code> が一致したローカルアカウントが存在した場合は自動的に紐付ける",
+    "Treat username matching as identical_warn": "警告: <code>%s</code> の一致を以て同一ユーザーであるとみなすので、セキュリティに注意してください",
     "ldap": {
       "server_url_detail": "LDAP URLを <code>ldap://host:port/DN</code> または <code>ldaps://host:port/DN</code> の形式で入力してください。",
       "bind_mode": "Bind モード",
@@ -370,8 +373,6 @@
       "search_filter_example2": "'sAMAccountName' に一致 (Active Directory)",
       "username_detail": "新規ユーザーのアカウント名(<code>username</code>)に関連付ける属性",
       "name_detail": "新規ユーザーの表示名(<code>name</code>)に関連付ける属性",
-      "Treat username matching as identical": "新規ログイン時、<code>username</code> が一致したローカルアカウントが存在した場合は自動的に紐付ける",
-      "Treat username matching as identical_warn": "WARNING: <code>username</code> の一致を以て同一ユーザーであるとみなすので、セキュリティに注意してください",
       "group_search_base_DN": "グループ検索ベース DN",
       "group_search_base_DN_detail": "グループ検索を実行するベース DN。利用する場合は <code>グループ検索フィルター</code> も入力する必要があります。",
       "group_search_filter": "グループ検索フィルター",
@@ -379,15 +380,32 @@
       "group_search_filter_detail2": "ログイン対象ユーザーオブジェクトのプロパティーで置換する場合は <code>&#123;&#123;dn&#125;&#125;</code> を用いてください。",
       "group_search_filter_detail3": "<code>(&(cn=group1)(memberUid=&#123;&#123;dn&#125;&#125;))</code> は <code>cn=group1</code> と、ユーザーの <code>uid</code> を含む <code>memberUid</code> を持つグループにヒットします(<code>ユーザーの DN プロパティー</code> がデフォルトから変更されていない場合)",
       "group_search_user_DN_property": "ユーザーの DN プロパティー",
-      "group_search_user_DN_property_detail": "<code>グループ検索フィルター</code> 内の <code>&#123;&#123;dn&#125;&#125;</code> で置換される、ユーザーオブジェクトのプロパティー"
+      "group_search_user_DN_property_detail": "<code>グループ検索フィルター</code> 内の <code>&#123;&#123;dn&#125;&#125;</code> で置換される、ユーザーオブジェクトのプロパティー",
+      "test_config": "ログインテスト"
     },
-    "Google OAuth": {
-    },
-    "Facebook": {
-    },
-    "Twitter": {
-    },
-    "Github": {
+    "OAuth": {
+      "register": "%sに登録",
+      "change_redirect_url": "承認済みのリダイレクトURLに、 <code>%s</code> を入力<br>(<code>%s</code>は環境に合わせて変更してください)",
+      "Google": {
+        "name": "Google OAuth認証",
+        "register_1": "<a href=\"%s\" target=\"_blank\">%s</a>へアクセス",
+        "register_2": "プロジェクトがない場合はプロジェクトを作成",
+        "register_3": "認証情報を作成 &rightarrow; OAuthクライアントID &rightarrow; ウェブアプリケーションを選択",
+        "register_4": "承認済みのリダイレクトURIを<code>%s</code>としてGrowiを登録 (<code>%s</code>は環境に合わせて変更してください)",
+        "register_5": "上記フォームにクライアントIDとクライアントシークレットを入力"
+      },
+      "Facebook": {
+        "name": "Facebook OAuth認証"
+      },
+      "Twitter": {
+        "name": "Twitter OAuth認証"
+      },
+      "GitHub": {
+        "name": "GitHub OAuth認証",
+        "register_1": "<a href=\"%s\" target=\"_blank\">%s</a>へアクセス",
+        "register_2": "\"Authorization callback URL\"を<code>%s</code>としてGrowiを登録 (<code>%s</code>は環境に合わせて変更してください)",
+        "register_3": "上記フォームにクライアントIDとクライアントシークレットを入力"
+      }
     }
   },
   "markdown_setting": {
@@ -395,8 +413,7 @@
     "Enable Line Break": "Line Break を有効にする",
     "Enable Line Break desc": "ページテキスト中の改行を、HTML内で<code>&lt;br&gt;</code>として扱います",
     "Enable Line Break for comment": "コメント欄で Line Break を有効にする",
-    "Enable Line Break for comment desc": "コメント中の改行を、HTML内で<code>&lt;br&gt;</code>として扱います",
-    "TBD": "(TBD: コメント欄の Markdown 化は未だ実装されていません)"
+    "Enable Line Break for comment desc": "コメント中の改行を、HTML内で<code>&lt;br&gt;</code>として扱います"
 
   },
 

+ 1 - 1
lib/locales/ja/welcome.md

@@ -1,6 +1,6 @@
 # Welcome to GROWI :anchor:
 
-[![Github Releases](https://img.shields.io/github/release/weseek/growi.svg)](https://github.com/weseek/growi/releases/latest)
+[![GitHub Releases](https://img.shields.io/github/release/weseek/growi.svg)](https://github.com/weseek/growi/releases/latest)
 [![MIT License](https://img.shields.io/badge/license-MIT-blue.svg?style=flat)](LICENSE)
 
 <div class="panel panel-default">

+ 5 - 5
lib/models/bookmark.js

@@ -13,9 +13,9 @@ module.exports = function(crowi) {
   bookmarkSchema.index({page: 1, user: 1}, {unique: true});
 
   bookmarkSchema.statics.populatePage = function(bookmarks, requestUser) {
-    var Bookmark = this;
-    var User = crowi.model('User');
-    var Page = crowi.model('Page');
+    const Bookmark = this;
+    const User = crowi.model('User');
+    const Page = crowi.model('Page');
 
     requestUser = requestUser || null;
 
@@ -36,13 +36,13 @@ module.exports = function(crowi) {
           return bookmark.page.isGrantedFor(requestUser);
         });
 
-        return Bookmark.populate(bookmarks, {path: 'page.revision.author', model: 'User', select: User.USER_PUBLIC_FIELDS});
+        return Bookmark.populate(bookmarks, {path: 'lastUpdateUser', model: 'User', select: User.USER_PUBLIC_FIELDS});
       });
   };
 
   // bookmark チェック用
   bookmarkSchema.statics.findByPageIdAndUserId = function(pageId, userId) {
-    var Bookmark = this;
+    const Bookmark = this;
 
     return new Promise(function(resolve, reject) {
       return Bookmark.findOne({ page: pageId, user: userId }, function(err, doc) {

+ 17 - 4
lib/models/config.js

@@ -64,6 +64,8 @@ module.exports = function(crowi) {
       'security:passport-ldap:groupSearchFilter' : undefined,
       'security:passport-ldap:groupDnProperty' : undefined,
       'security:passport-ldap:isSameUsernameTreatedAsIdenticalUser': false,
+      'security:passport-google:isEnabled' : false,
+      'security:passport-github:isEnabled' : false,
 
       'aws:bucket'          : 'growi',
       'aws:region'          : 'ap-northeast-1',
@@ -99,7 +101,7 @@ module.exports = function(crowi) {
 
   function getDefaultMarkdownConfigs() {
     return {
-      'markdown:isEnabledLinebreaks': true,
+      'markdown:isEnabledLinebreaks': false,
       'markdown:isEnabledLinebreaksInComments': true,
     };
   }
@@ -267,6 +269,16 @@ module.exports = function(crowi) {
     return getValueForCrowiNS(config, key);
   };
 
+  configSchema.statics.isEnabledPassportGoogle = function(config) {
+    const key = 'security:passport-google:isEnabled';
+    return getValueForCrowiNS(config, key);
+  };
+
+  configSchema.statics.isEnabledPassportGitHub = function(config) {
+    const key = 'security:passport-github:isEnabled';
+    return getValueForCrowiNS(config, key);
+  };
+
   configSchema.statics.isSameUsernameTreatedAsIdenticalUser = function(config, providerType) {
     const key = `security:passport-${providerType}:isSameUsernameTreatedAsIdenticalUser`;
     return getValueForCrowiNS(config, key);
@@ -305,7 +317,7 @@ module.exports = function(crowi) {
 
     // return default value if undefined
     if (undefined === config.markdown || undefined === config.markdown[key]) {
-      return getDefaultMarkdownConfigs[key];
+      return getDefaultMarkdownConfigs()[key];
     }
 
     return config.markdown[key];
@@ -316,7 +328,7 @@ module.exports = function(crowi) {
 
     // return default value if undefined
     if (undefined === config.markdown || undefined === config.markdown[key]) {
-      return getDefaultMarkdownConfigs[key];
+      return getDefaultMarkdownConfigs()[key];
     }
 
     return config.markdown[key];
@@ -460,7 +472,8 @@ module.exports = function(crowi) {
       },
       behaviorType: Config.behaviorType(config),
       layoutType: Config.layoutType(config),
-      isEnabledLineBreaks: Config.isEnabledLinebreaks(config),
+      isEnabledLinebreaks: Config.isEnabledLinebreaks(config),
+      isEnabledLinebreaksInComments: Config.isEnabledLinebreaksInComments(config),
       highlightJsStyleBorder: Config.highlightJsStyleBorder(config),
       isSavedStatesOfTabChanges: Config.isSavedStatesOfTabChanges(config),
       env: {

+ 12 - 34
lib/models/page.js

@@ -37,8 +37,6 @@ module.exports = function(crowi) {
     grant: { type: Number, default: GRANT_PUBLIC, index: true },
     grantedUsers: [{ type: ObjectId, ref: 'User' }],
     creator: { type: ObjectId, ref: 'User', index: true },
-    // lastUpdateUser: this schema is from 1.5.x (by deletion feature), and null is default.
-    // the last update user on the screen is by revesion.author for B.C.
     lastUpdateUser: { type: ObjectId, ref: 'User', index: true },
     liker: [{ type: ObjectId, ref: 'User', index: true }],
     seenUsers: [{ type: ObjectId, ref: 'User', index: true }],
@@ -263,8 +261,8 @@ module.exports = function(crowi) {
   };
 
   pageSchema.statics.populatePageData = function(pageData, revisionId) {
-    var Page = crowi.model('Page');
-    var User = crowi.model('User');
+    const Page = crowi.model('Page');
+    const User = crowi.model('User');
 
     pageData.latestRevision = pageData.revision;
     if (revisionId) {
@@ -491,8 +489,8 @@ module.exports = function(crowi) {
 
   // find page and check if granted user
   pageSchema.statics.findPage = function(path, userData, revisionId, ignoreNotFound) {
-    var self = this;
-    var PageGroupRelation = crowi.model('PageGroupRelation');
+    const self = this;
+    const PageGroupRelation = crowi.model('PageGroupRelation');
 
     return new Promise(function(resolve, reject) {
       self.findOne({path: path}, function(err, pageData) {
@@ -505,7 +503,7 @@ module.exports = function(crowi) {
             return resolve(null);
           }
 
-          var pageNotFoundError = new Error('Page Not Found');
+          const pageNotFoundError = new Error('Page Not Found');
           pageNotFoundError.name = 'Crowi:Page:NotFound';
           return reject(pageNotFoundError);
         }
@@ -532,26 +530,6 @@ module.exports = function(crowi) {
     });
   };
 
-  // check if a given page has a children and decendants tempalte
-  pageSchema.statics.checkIfTemplatesExist = function(path) {
-    const Page = this;
-    const pathList = generatePathsOnTree(path, []);
-    const regexpList = pathList.map(path => new RegExp(`${path}/_{1,2}template`));
-    let templateInfo = {
-      childrenTemplateExists: false,
-      decendantsTemplateExists: false,
-    };
-
-    return Page
-      .find({path: {$in: regexpList}})
-      .then(templates => {
-        templateInfo.childrenTemplateExists = (assignTemplateByType(templates, path, '_') ? true : false);
-        templateInfo.decendantsTemplateExists = (assignTemplateByType(templates, path, '__') ? true : false);
-
-        return templateInfo;
-      });
-  };
-
   /**
    * find all templates applicable to the new page
    */
@@ -641,12 +619,12 @@ module.exports = function(crowi) {
   };
 
   pageSchema.statics.findListByPageIds = function(ids, options) {
-    var Page = this;
-    var User = crowi.model('User');
-    var options = options || {}
-      , limit = options.limit || 50
+    const Page = this;
+    const User = crowi.model('User');
+    const limit = options.limit || 50
       , offset = options.skip || 0
       ;
+    options = options || {};
 
     return new Promise(function(resolve, reject) {
       Page
@@ -663,7 +641,7 @@ module.exports = function(crowi) {
           return reject(err);
         }
 
-        Page.populate(pages, {path: 'revision.author', model: 'User', select: User.USER_PUBLIC_FIELDS}, function(err, data) {
+        Page.populate(pages, {path: 'lastUpdateUser', model: 'User', select: User.USER_PUBLIC_FIELDS}, function(err, data) {
           if (err) {
             return reject(err);
           }
@@ -715,7 +693,7 @@ module.exports = function(crowi) {
       .populate('revision')
       .exec()
       .then(function(pages) {
-        return Page.populate(pages, {path: 'revision.author', model: 'User', select: User.USER_PUBLIC_FIELDS}).then(resolve);
+        return Page.populate(pages, {path: 'lastUpdateUser', model: 'User', select: User.USER_PUBLIC_FIELDS}).then(resolve);
       });
     });
   };
@@ -796,7 +774,7 @@ module.exports = function(crowi) {
 
       q.exec()
         .then(function(pages) {
-          Page.populate(pages, {path: 'revision.author', model: 'User', select: User.USER_PUBLIC_FIELDS})
+          Page.populate(pages, {path: 'lastUpdateUser', model: 'User', select: User.USER_PUBLIC_FIELDS})
           .then(resolve)
           .catch(reject);
         });

+ 11 - 12
lib/models/revision.js

@@ -3,12 +3,11 @@ module.exports = function(crowi) {
   const logger = require('@alias/logger')('growi:models:revision');
   /* eslint-enable */
 
-  var mongoose = require('mongoose')
+  const mongoose = require('mongoose')
     , ObjectId = mongoose.Schema.Types.ObjectId
-    , revisionSchema;
+    ;
 
-
-  revisionSchema = new mongoose.Schema({
+  const revisionSchema = new mongoose.Schema({
     path: { type: String, required: true },
     body: { type: String, required: true, get: (data) => {
       // replace CR/CRLF to LF above v3.1.5
@@ -42,7 +41,7 @@ module.exports = function(crowi) {
   };
 
   revisionSchema.statics.findRevision = function(id) {
-    var Revision = this;
+    const Revision = this;
 
     return new Promise(function(resolve, reject) {
       Revision.findById(id)
@@ -58,7 +57,7 @@ module.exports = function(crowi) {
   };
 
   revisionSchema.statics.findRevisions = function(ids) {
-    var Revision = this,
+    const Revision = this,
       User = crowi.model('User');
 
     if (!Array.isArray(ids)) {
@@ -88,7 +87,7 @@ module.exports = function(crowi) {
   };
 
   revisionSchema.statics.findRevisionList = function(path, options) {
-    var Revision = this,
+    const Revision = this,
       User = crowi.model('User');
 
     return new Promise(function(resolve, reject) {
@@ -106,7 +105,7 @@ module.exports = function(crowi) {
   };
 
   revisionSchema.statics.updateRevisionListByPath = function(path, updateData, options) {
-    var Revision = this;
+    const Revision = this;
 
     return new Promise(function(resolve, reject) {
       Revision.update({path: path}, {$set: updateData}, {multi: true}, function(err, data) {
@@ -120,18 +119,18 @@ module.exports = function(crowi) {
   };
 
   revisionSchema.statics.prepareRevision = function(pageData, body, user, options) {
-    var Revision = this;
+    const Revision = this;
 
     if (!options) {
       options = {};
     }
-    var format = options.format || 'markdown';
+    const format = options.format || 'markdown';
 
     if (!user._id) {
       throw new Error('Error: user should have _id');
     }
 
-    var newRevision = new Revision();
+    const newRevision = new Revision();
     newRevision.path = pageData.path;
     newRevision.body = body;
     newRevision.format = format;
@@ -142,7 +141,7 @@ module.exports = function(crowi) {
   };
 
   revisionSchema.statics.removeRevisionsByPath = function(path) {
-    var Revision = this;
+    const Revision = this;
 
     return new Promise(function(resolve, reject) {
       Revision.remove({path: path}, function(err, data) {

+ 71 - 3
lib/routes/admin.js

@@ -137,8 +137,8 @@ module.exports = function(crowi, app) {
     settingForm = Config.setupCofigFormData('crowi', req.config);
 
     const highlightJsCssSelectorOptions = {
-      'github':           { name: '[Light] Github',         border: false },
-      'github-gist':      { name: '[Light] Github Gist',    border: true },
+      'github':           { name: '[Light] GitHub',         border: false },
+      'github-gist':      { name: '[Light] GitHub Gist',    border: true },
       'atom-one-light':   { name: '[Light] Atom One Light', border: true },
       'xcode':            { name: '[Light] Xcode',          border: true },
       'vs':               { name: '[Light] Vs',             border: true },
@@ -914,8 +914,76 @@ module.exports = function(crowi, app) {
       });
   };
 
+  actions.api.securityPassportGoogleSetting = async(req, res) => {
+    const form = req.form.settingForm;
+
+    if (!req.form.isValid) {
+      return res.json({status: false, message: req.form.errors.join('\n')});
+    }
+
+    debug('form content', form);
+    await saveSettingAsync(form);
+    const config = await crowi.getConfig();
+
+    // reset strategy
+    await crowi.passportService.resetGoogleStrategy();
+    // setup strategy
+    if (Config.isEnabledPassportGoogle(config)) {
+      try {
+        await crowi.passportService.setupGoogleStrategy(true);
+      }
+      catch (err) {
+        // reset
+        await crowi.passportService.resetGoogleStrategy();
+        return res.json({status: false, message: err.message});
+      }
+    }
+
+    return res.json({status: true});
+  };
+
+  actions.api.securityPassportGitHubSetting = async(req, res) => {
+    const form = req.form.settingForm;
+
+    if (!req.form.isValid) {
+      return res.json({status: false, message: req.form.errors.join('\n')});
+    }
+
+    debug('form content', form);
+    await saveSettingAsync(form);
+    const config = await crowi.getConfig();
+
+    // reset strategy
+    await crowi.passportService.resetGitHubStrategy();
+    // setup strategy
+    if (Config.isEnabledPassportGoogle(config)) {
+      try {
+        await crowi.passportService.setupGitHubStrategy(true);
+      }
+      catch (err) {
+        // reset
+        await crowi.passportService.resetGoogleStrategy();
+        return res.json({status: false, message: err.message});
+      }
+    }
+
+    return res.json({status: true});
+  };
+
   actions.api.customizeSetting = function(req, res) {
-    var form = req.form.settingForm;
+    const form = req.form.settingForm;
+
+    if (req.form.isValid) {
+      debug('form content', form);
+      return saveSetting(req, res, form);
+    }
+    else {
+      return res.json({status: false, message: req.form.errors.join('\n')});
+    }
+  };
+
+  actions.api.customizeSetting = function(req, res) {
+    const form = req.form.settingForm;
 
     if (req.form.isValid) {
       debug('form content', form);

+ 21 - 17
lib/routes/comment.js

@@ -1,9 +1,8 @@
 module.exports = function(crowi, app) {
   'use strict';
 
-  var debug = require('debug')('growi:routs:comment')
+  const debug = require('debug')('growi:routs:comment')
     , Comment = crowi.model('Comment')
-    , User = crowi.model('User')
     , Page = crowi.model('Page')
     , ApiResponse = require('../util/apiResponse')
     , actions = {}
@@ -20,8 +19,8 @@ module.exports = function(crowi, app) {
    * @apiParam {String} revision_id Revision Id.
    */
   api.get = function(req, res) {
-    var pageId = req.query.page_id;
-    var revisionId = req.query.revision_id;
+    const pageId = req.query.page_id;
+    const revisionId = req.query.revision_id;
 
     if (revisionId) {
       return Comment.getCommentsByRevisionId(revisionId)
@@ -50,27 +49,32 @@ module.exports = function(crowi, app) {
    * @apiParam {String} comment Comment body
    * @apiParam {Number} comment_position=-1 Line number of the comment
    */
-  api.add = function(req, res) {
-    var form = req.form.commentForm;
+  api.add = async function(req, res) {
+    const form = req.form.commentForm;
 
     if (!req.form.isValid) {
       // return res.json(ApiResponse.error('Invalid comment.'));
       return res.json(ApiResponse.error('コメントを入力してください。'));
     }
 
-    var pageId = form.page_id;
-    var revisionId = form.revision_id;
-    var comment = form.comment;
-    var position = form.comment_position || -1;
-    var isMarkdown = form.is_markdown;
+    const pageId = form.page_id;
+    const revisionId = form.revision_id;
+    const comment = form.comment;
+    const position = form.comment_position || -1;
+    const isMarkdown = form.is_markdown;
 
-    return Comment.create(pageId, req.user._id, revisionId, comment, position, isMarkdown)
-      .then(function(createdComment) {
-        createdComment.creator = req.user;
-        return res.json(ApiResponse.success({comment: createdComment}));
-      }).catch(function(err) {
+    const createdComment = await Comment.create(pageId, req.user._id, revisionId, comment, position, isMarkdown)
+      .catch(function(err) {
         return res.json(ApiResponse.error(err));
       });
+
+    // update page
+    await Page.findOneAndUpdate({ _id: pageId }, {
+      lastUpdateUser: req.user,
+      updatedAt: new Date()
+    });
+
+    return res.json(ApiResponse.success({comment: createdComment}));
   };
 
   /**
@@ -81,7 +85,7 @@ module.exports = function(crowi, app) {
    * @apiParam {String} comment_id Comment Id.
    */
   api.remove = function(req, res) {
-    var commentId = req.body.comment_id;
+    const commentId = req.body.comment_id;
     if (!commentId) {
       return Promise.resolve(res.json(ApiResponse.error('\'comment_id\' is undefined')));
     }

+ 8 - 1
lib/routes/index.js

@@ -67,6 +67,14 @@ module.exports = function(crowi, app) {
   app.post('/_api/admin/security/mechanism'     , loginRequired(crowi, app) , middleware.adminRequired() , csrf, form.admin.securityMechanism, admin.api.securitySetting);
   app.post('/_api/admin/security/passport-ldap' , loginRequired(crowi, app) , middleware.adminRequired() , csrf, form.admin.securityPassportLdap, admin.api.securityPassportLdapSetting);
 
+  // OAuth
+  app.post('/_api/admin/security/passport-google' , loginRequired(crowi, app) , middleware.adminRequired() , csrf, form.admin.securityPassportGoogle, admin.api.securityPassportGoogleSetting);
+  app.post('/_api/admin/security/passport-github' , loginRequired(crowi, app) , middleware.adminRequired() , csrf, form.admin.securityPassportGitHub, admin.api.securityPassportGitHubSetting);
+  app.get('/passport/google'                      , loginPassport.loginPassportGoogle);
+  app.get('/passport/github'                      , loginPassport.loginPassportGitHub);
+  app.get('/passport/google/callback'             , loginPassport.loginPassportGoogleCallback);
+  app.get('/passport/github/callback'             , loginPassport.loginPassportGitHubCallback);
+
   // markdown admin
   app.get('/admin/markdown'                   , loginRequired(crowi, app) , middleware.adminRequired() , admin.markdown.index);
   app.post('/admin/markdown/lineBreaksSetting', loginRequired(crowi, app) , middleware.adminRequired() , csrf, form.admin.markdown, admin.markdown.lineBreaksSetting);
@@ -167,7 +175,6 @@ module.exports = function(crowi, app) {
   app.post('/_api/pages.revertRemove' , loginRequired(crowi, app) , csrf, page.api.revertRemove); // (Avoid from API Token)
   app.post('/_api/pages.unlink'       , loginRequired(crowi, app) , csrf, page.api.unlink); // (Avoid from API Token)
   app.post('/_api/pages.duplicate'    , accessTokenParser, loginRequired(crowi, app), csrf, page.api.duplicate);
-  app.get('/_api/pages.templates'   , accessTokenParser , loginRequired(crowi, app, false) , page.api.templates);
   app.get('/_api/comments.get'        , accessTokenParser , loginRequired(crowi, app, false) , comment.api.get);
   app.post('/_api/comments.add'       , form.comment, accessTokenParser , loginRequired(crowi, app) , csrf, comment.api.add);
   app.post('/_api/comments.remove'    , accessTokenParser , loginRequired(crowi, app) , csrf, comment.api.remove);

+ 155 - 67
lib/routes/login-passport.js

@@ -66,7 +66,7 @@ module.exports = function(crowi, app) {
    * @param {*} res
    * @param {*} next
    */
-  const loginWithLdap = (req, res, next) => {
+  const loginWithLdap = async(req, res, next) => {
     if (!passportService.isLdapStrategySetup) {
       debug('LdapStrategy has not been set up');
       return next();
@@ -78,77 +78,43 @@ module.exports = function(crowi, app) {
       });
     }
 
-    passport.authenticate('ldapauth', (err, ldapAccountInfo, info) => {
-      if (res.headersSent) {  // dirty hack -- 2017.09.25
-        return;               // cz: somehow passport.authenticate called twice when ECONNREFUSED error occurred
-      }
-
-      debug('--- authenticate with LdapStrategy ---');
-      debug('ldapAccountInfo', ldapAccountInfo);
-      debug('info', info);
+    const providerId = 'ldap';
+    const strategyName = 'ldapauth';
+    const ldapAccountInfo = await promisifiedPassportAuthentication(req, res, next, strategyName);
 
-      if (err) {  // DB Error
-        logger.error('LDAP Server Error: ', err);
-        req.flash('warningMessage', 'LDAP Server Error occured.');
-        return next(); // pass and the flash message is displayed when all of authentications are failed.
-      }
-
-      // authentication failure
-      if (!ldapAccountInfo) { return next() }
-      // check groups
-      if (!isValidLdapUserByGroupFilter(ldapAccountInfo)) {
-        return loginFailure(req, res, next);
-      }
+    // check groups for LDAP
+    if (!isValidLdapUserByGroupFilter(ldapAccountInfo)) {
+      return loginFailure(req, res, next);
+    }
 
-      /*
-       * authentication success
-       */
-      // it is guaranteed that username that is input from form can be acquired
-      // because this processes after authentication
-      const ldapAccountId = passportService.getLdapAccountIdFromReq(req);
+    /*
+      * authentication success
+      */
+    // it is guaranteed that username that is input from form can be acquired
+    // because this processes after authentication
+    const ldapAccountId = passportService.getLdapAccountIdFromReq(req);
+    const attrMapUsername = passportService.getLdapAttrNameMappedToUsername();
+    const attrMapName = passportService.getLdapAttrNameMappedToName();
+    const usernameToBeRegistered = ldapAccountInfo[attrMapUsername];
+    const nameToBeRegistered = ldapAccountInfo[attrMapName];
+    const userInfo = {
+      'id': ldapAccountId,
+      'username': usernameToBeRegistered,
+      'name': nameToBeRegistered
+    }
 
-      const attrMapUsername = passportService.getLdapAttrNameMappedToUsername();
-      const attrMapName = passportService.getLdapAttrNameMappedToName();
-      const usernameToBeRegistered = ldapAccountInfo[attrMapUsername];
-      const nameToBeRegistered = ldapAccountInfo[attrMapName];
+    const externalAccount = await getOrCreateUser(req, res, next, userInfo, providerId);
+    if (!externalAccount) {
+      return loginFailure(req, res, next);
+    }
 
-      // find or register(create) user
-      ExternalAccount.findOrRegister('ldap', ldapAccountId, usernameToBeRegistered, nameToBeRegistered)
-        .catch((err) => {
-          if (err.name === 'DuplicatedUsernameException') {
-            // get option
-            const isSameUsernameTreatedAsIdenticalUser = Config.isSameUsernameTreatedAsIdenticalUser(config, 'ldap');
-            if (isSameUsernameTreatedAsIdenticalUser) {
-              // associate to existing user
-              debug(`ExternalAccount '${ldapAccountId}' will be created and bound to the exisiting User account`);
-              return ExternalAccount.associate('ldap', ldapAccountId, err.user);
-            }
-          }
-          throw err;  // throw again
-        })
-        .then((externalAccount) => {
-          return externalAccount.getPopulatedUser();
-        })
-        .then((user) => {
-          // login
-          req.logIn(user, (err) => {
-            if (err) { return next() }
-            else {
-              return loginSuccess(req, res, user);
-            }
-          });
-        })
-        .catch((err) => {
-          if (err.name === 'DuplicatedUsernameException') {
-            req.flash('isDuplicatedUsernameExceptionOccured', true);
-            return next();
-          }
-          else {
-            return next(err);
-          }
-        });
+    const user = await externalAccount.getPopulatedUser();
 
-    })(req, res, next);
+    // login
+    await req.logIn(user, err => {
+      if (err) { return next(err) };
+      return loginSuccess(req, res, user);
+    });
   };
 
   /**
@@ -239,10 +205,132 @@ module.exports = function(crowi, app) {
     })(req, res, next);
   };
 
+  const loginPassportGoogle = function(req, res) {
+    if (!passportService.isGoogleStrategySetup) {
+      debug('GoogleStrategy has not been set up');
+      return;
+    }
+
+    passport.authenticate('google', {
+      scope: ['profile', 'email'],
+    })(req, res);
+  };
+
+  const loginPassportGoogleCallback = async(req, res, next) => {
+    const providerId = 'google';
+    const strategyName = 'google';
+    const response = await promisifiedPassportAuthentication(req, res, next, strategyName);
+    const userInfo = {
+      'id': response.id,
+      'username': response.displayName,
+      'name': `${response.name.givenName} ${response.name.familyName}`
+    }
+    const externalAccount = await getOrCreateUser(req, res, next, userInfo, providerId);
+    if (!externalAccount) {
+      return loginFailure(req, res, next);
+    }
+
+    const user = await externalAccount.getPopulatedUser();
+
+    // login
+    req.logIn(user, err => {
+      if (err) { return next(err) };
+      return loginSuccess(req, res, user);
+    });
+  };
+
+  const loginPassportGitHub = function(req, res) {
+    if (!passportService.isGitHubStrategySetup) {
+      debug('GitHubStrategy has not been set up');
+      return;
+    }
+
+    passport.authenticate('github')(req, res);
+  };
+
+  const loginPassportGitHubCallback = async(req, res, next) => {
+    const providerId = 'github';
+    const strategyName = 'github';
+    const response = await promisifiedPassportAuthentication(req, res, next, strategyName);
+    const userInfo = {
+      'id': response.id,
+      'username': response.username,
+      'name': response.displayName
+    }
+
+    const externalAccount = await getOrCreateUser(req, res, next, userInfo, providerId);
+    if (!externalAccount) {
+      return loginFailure(req, res, next);
+    }
+
+    const user = await externalAccount.getPopulatedUser();
+
+    // login
+    req.logIn(user, err => {
+      if (err) { return next(err) };
+      return loginSuccess(req, res, user);
+    });
+  };
+
+  const promisifiedPassportAuthentication = (req, res, next, strategyName) => {
+    return new Promise((resolve, reject) => {
+      passport.authenticate(strategyName, (err, response, info) => {
+        if (res.headersSent) {  // dirty hack -- 2017.09.25
+          return;               // cz: somehow passport.authenticate called twice when ECONNREFUSED error occurred
+        }
+
+        if (err) {
+          logger.error(`'${strategyName}' passport authentication error: `, err);
+          req.flash('warningMessage', `Error occured in '${strategyName}' passport authentication`);
+          return next(); // pass and the flash message is displayed when all of authentications are failed.
+        }
+
+        // authentication failure
+        if (!response) {
+          return next();
+        }
+
+        resolve(response)
+      })(req, res, next);
+    });
+  };
+
+  const getOrCreateUser = async(req, res, next, userInfo, providerId) => {
+    try {
+      // find or register(create) user
+      const externalAccount = await ExternalAccount.findOrRegister(
+        providerId,
+        userInfo.id,
+        userInfo.username,
+        userInfo.name
+      );
+      return externalAccount;
+    }
+    catch (err) {
+      if (err.name === 'DuplicatedUsernameException') {
+        // get option
+        const isSameUsernameTreatedAsIdenticalUser = Config.isSameUsernameTreatedAsIdenticalUser(config, providerId);
+        if (isSameUsernameTreatedAsIdenticalUser) {
+          // associate to existing user
+          debug(`ExternalAccount '${userInfo.username}' will be created and bound to the exisiting User account`);
+          return ExternalAccount.associate(providerId, userInfo.id, err.user);
+        }
+        else {
+          req.flash('provider-DuplicatedUsernameException', providerId);
+          return;
+        }
+      }
+    }
+  }
+
   return {
     loginFailure,
     loginWithLdap,
     testLdapCredentials,
     loginWithLocal,
+    loginPassportGoogle,
+    loginPassportGitHub,
+    loginPassportGoogleCallback,
+    loginPassportGitHubCallback,
   };
 };

+ 0 - 21
lib/routes/page.js

@@ -281,9 +281,6 @@ module.exports = function(crowi, app) {
         .then(function(tree) {
           renderVars.tree = tree;
         })
-        .then(function() {
-          return Page.checkIfTemplatesExist(path);
-        })
         .then(() => {
           return PageGroupRelation.findByPage(renderVars.page);
         })
@@ -1189,23 +1186,5 @@ module.exports = function(crowi, app) {
     });
   };
 
-  /**
-   * @api {get} /pages.templates Check if templates exist for page
-   * @apiName FindTemplates
-   * @apiGroup Page
-   *
-   * @apiParam {String} path
-   */
-  api.templates = function(req, res) {
-    const pagePath = req.query.path;
-    const templateFinder = Page.checkIfTemplatesExist(pagePath);
-
-    templateFinder.then(function(templateInfo) {
-      return res.json(ApiResponse.success(templateInfo));
-    }).catch(function(err) {
-      return res.json(ApiResponse.error(err));
-    });
-  };
-
   return actions;
 };

+ 3 - 3
lib/routes/revision.js

@@ -41,7 +41,7 @@ module.exports = function(crowi, app) {
    * @apiParam {String} page_id      Page Id.
    */
   actions.api.ids = function(req, res) {
-    var pageId = req.query.page_id || null;
+    const pageId = req.query.page_id || null;
 
     if (pageId && crowi.isPageId(pageId)) {
       Page.findPageByIdAndGrantedUser(pageId, req.user)
@@ -68,8 +68,8 @@ module.exports = function(crowi, app) {
    * @apiParam {String} page_id      Page Id.
    */
   actions.api.list = function(req, res) {
-    var revisionIds = (req.query.revision_ids || '').split(',');
-    var pageId = req.query.page_id || null;
+    const revisionIds = (req.query.revision_ids || '').split(',');
+    const pageId = req.query.page_id || null;
 
     if (pageId) {
       Page.findPageByIdAndGrantedUser(pageId, req.user)

+ 104 - 0
lib/service/passport.js

@@ -2,6 +2,8 @@ const debug = require('debug')('growi:service:PassportService');
 const passport = require('passport');
 const LocalStrategy = require('passport-local').Strategy;
 const LdapStrategy = require('passport-ldapauth');
+const GoogleStrategy = require('passport-google-auth').Strategy;
+const GitHubStrategy = require('passport-github').Strategy;
 
 /**
  * the service class of Passport
@@ -25,6 +27,11 @@ class PassportService {
      */
     this.isLdapStrategySetup = false;
 
+    /**
+     * the flag whether LdapStrategy is set up successfully
+     */
+    this.isGoogleStrategySetup = false;
+
     /**
      * the flag whether serializer/deserializer are set up successfully
      */
@@ -235,6 +242,103 @@ class PassportService {
     };
   }
 
+  /**
+   * Asynchronous configuration retrieval
+   *
+   * @memberof PassportService
+   */
+  setupGoogleStrategy() {
+    // check whether the strategy has already been set up
+    if (this.isGoogleStrategySetup) {
+      throw new Error('GoogleStrategy has already been set up');
+    }
+
+    const config = this.crowi.config;
+    const Config = this.crowi.model('Config');
+    //this
+    const isGoogleEnabled = Config.isEnabledPassportGoogle(config);
+
+    // when disabled
+    if (!isGoogleEnabled) {
+      return;
+    }
+
+    debug('GoogleStrategy: setting up..');
+    passport.use(new GoogleStrategy({
+      clientId: config.crowi['security:passport-google:clientId'],
+      clientSecret: config.crowi['security:passport-google:clientSecret'],
+      callbackURL: 'http://localhost:3000/passport/google/callback',  //change this
+      skipUserProfile: false,
+    }, function(accessToken, refreshToken, profile, done) {
+      if (profile) {
+        return done(null, profile);
+      }
+      else {
+        return done(null, false);
+      }
+    }));
+
+    this.isGoogleStrategySetup = true;
+    debug('GoogleStrategy: setup is done');
+  }
+
+  /**
+   * reset GoogleStrategy
+   *
+   * @memberof PassportService
+   */
+  resetGoogleStrategy() {
+    debug('GoogleStrategy: reset');
+    passport.unuse('google');
+    this.isGoogleStrategySetup = false;
+  }
+
+  setupGitHubStrategy() {
+    // check whether the strategy has already been set up
+    if (this.isGitHubStrategySetup) {
+      throw new Error('GitHubStrategy has already been set up');
+    }
+
+    const config = this.crowi.config;
+    const Config = this.crowi.model('Config');
+    //this
+    const isGitHubEnabled = Config.isEnabledPassportGitHub(config);
+
+    // when disabled
+    if (!isGitHubEnabled) {
+      return;
+    }
+
+    debug('GitHubStrategy: setting up..');
+    passport.use(new GitHubStrategy({
+      clientID: config.crowi['security:passport-github:clientId'],
+      clientSecret: config.crowi['security:passport-github:clientSecret'],
+      callbackURL: 'http://localhost:3000/passport/github/callback',  //change this
+      skipUserProfile: false,
+    }, function(accessToken, refreshToken, profile, done) {
+      if (profile) {
+        return done(null, profile);
+      }
+      else {
+        return done(null, false);
+      }
+    }));
+
+    this.isGitHubStrategySetup = true;
+    debug('GitHubStrategy: setup is done');
+  }
+
+  /**
+   * reset GoogleStrategy
+   *
+   * @memberof PassportService
+   */
+  resetGitHubStrategy() {
+    debug('GitHubStrategy: reset');
+    passport.unuse('github');
+    this.isGitHubStrategySetup = false;
+  }
+
   /**
    * setup serializer and deserializer
    *

+ 10 - 0
lib/util/swigFunctions.js

@@ -92,6 +92,16 @@ module.exports = function(crowi, app, req, locals) {
     return config.crowi['google:clientId'] && config.crowi['google:clientSecret'];
   };
 
+  locals.passportGoogleLoginEnabled = function() {
+    var config = crowi.getConfig();
+    return locals.isEnabledPassport() && config.crowi['security:passport-google:isEnabled'];
+  };
+
+  locals.passportGitHubLoginEnabled = function() {
+    var config = crowi.getConfig();
+    return locals.isEnabledPassport() && config.crowi['security:passport-github:isEnabled'];
+  };
+
   locals.searchConfigured = function() {
     if (crowi.getSearcher()) {
       return true;

+ 1 - 1
lib/views/_form.html

@@ -1,5 +1,5 @@
 {% block html_head_loading_legacy %}
-  <script src="{{ webpack_asset('legacy-form').js }}" defer></script>
+  <script src="{{ webpack_asset('js/legacy-form.js') }}" defer></script>
   {% parent %}
 {% endblock %}
 

+ 5 - 5
lib/views/admin/customize.html

@@ -4,11 +4,11 @@
 
 {% block style_css_block %}
   {% if env === 'development' %}
-    <script src="{{ webpack_asset('style').js }}"></script>
-    <script src="{{ webpack_asset('style-theme-' + theme()).js }}"></script>
+    <script src="{{ webpack_asset('styles/style.js') }}"></script>
+    <script src="{{ webpack_asset('styles/theme-' + theme() + '.js') }}"></script>
   {% else %}
-    <link rel="stylesheet" href="{{ webpack_asset('style').css }}">
-    <link rel="stylesheet" id="jssDefault" {# append id for theme selector #} href="{{ webpack_asset('style-theme-' + theme()).css }}">
+    <link rel="stylesheet" href="{{ webpack_asset('styles/style.css') }}">
+    <link rel="stylesheet" id="jssDefault" {# append id for theme selector #} href="{{ webpack_asset('styles/theme-' + theme() + '.css') }}">
   {% endif %}
 {% endblock %}
 
@@ -298,7 +298,7 @@
           </div>
 
           <div class="form-group">
-            <label for="settingForm[customize:highlightJsStyleBorder]" class="col-xs-3 control-label">(TBD) Border</label>
+            <label for="settingForm[customize:highlightJsStyleBorder]" class="col-xs-3 control-label">Border</label>
             <div class="col-xs-9">
               <div class="btn-group btn-toggle" data-toggle="buttons">
                 <label class="btn btn-default btn-rounded btn-outline {% if settingForm['customize:highlightJsStyleBorder'] %}active{% endif %}" data-active-class="primary" onclick="selectBorderOn()">

+ 1 - 1
lib/views/admin/index.html

@@ -68,7 +68,7 @@
         <tr>
           <td>{{ pluginName }}</td>
           <td class="text-center">{{ plugins[pluginName] }}</td>
-          <td class="text-center">(TBD)</td>
+          <td class="text-center"><span class="tbd">(TBD)</span></td>
         </tr>
         {% endfor %}
       </table>

+ 3 - 4
lib/views/admin/markdown.html

@@ -57,14 +57,13 @@
                     {% if !markdownSetting['markdown:isEnabledLinebreaks'] %}checked{% endif %}> OFF
               </label>
             </div>
-            <p class="help-block">{{ t("markdown_setting.Enable Line Break desc") }}
-</p>
+            <p class="help-block">{{ t("markdown_setting.Enable Line Break desc") }}</p>
           </div>
         </div>
 
         <div class="form-group">
           <label for="markdownSetting[markdown:isEnabledLinebreaksInComments]" class="col-xs-4 control-label">
-            (TBD)<br>{{ t("markdown_setting.Enable Line Break for comment") }}
+            {{ t("markdown_setting.Enable Line Break for comment") }}
           </label>
           <div class="col-xs-5">
             <div class="btn-group btn-toggle" data-toggle="buttons">
@@ -77,7 +76,7 @@
                     {% if !markdownSetting['markdown:isEnabledLinebreaksInComments'] %}checked{% endif %}> OFF
               </label>
             </div>
-            <p class="help-block">{{ t("markdown_setting.Enable Line Break for comment desc") }}<br>{{ t("markdown_setting.TBD") }}</p>
+            <p class="help-block">{{ t("markdown_setting.Enable Line Break for comment desc") }}</p>
           </div>
         </div>
 

+ 14 - 11
lib/views/admin/security.html

@@ -111,7 +111,7 @@
                   <input type="radio" id="radioPassportAuthMech" name="settingForm[security:isEnabledPassport]" value="true"
                       {% if true === settingForm['security:isEnabledPassport'] %}checked="checked"{% endif %}>
                   <label for="radioPassportAuthMech">
-                    <a href="http://passportjs.org/">
+                    <a href="http://passportjs.org/" target="_blank">
                       <img src="/images/admin/security/passport-logo.svg" class="passport-logo"> Passport
                     </a> {{ t("security_setting.auth_mechanism") }} <small class="text-success">({{ t("security_setting.recommended") }})</small>
                   </label>
@@ -120,10 +120,10 @@
               <ul>
                 <li>{{ t("security_setting.username_email_password") }}</li>
                 <li>{{ t("security_setting.ldap_auth") }}</li>
-                <li class="text-muted">(TBD) <del>{{ t("security_setting.google_auth2") }}</del></li>
+                <li>{{ t("security_setting.google_auth2") }}</li>
+                <li>{{ t("security_setting.github_auth2") }}</li>
                 <li class="text-muted">(TBD) <del>{{ t("security_setting.facebook_auth2") }}</del></li>
                 <li class="text-muted">(TBD) <del>{{ t("security_setting.twitter_auth2") }}</del></li>
-                <li class="text-muted">(TBD) <del>{{ t("security_setting.github_auth2") }}</del></li>
               </ul>
             </div>
             <div class="col-xs-6">
@@ -138,7 +138,10 @@
               </h4>
               <ul>
                 <li>{{ t("security_setting.username_email_password") }}</li>
-                <li>{{ t("security_setting.google_auth2") }}</li>
+                <li class="text-muted">
+                  {{ t("security_setting.google_auth2") }}
+                  <ul><li>{{ t("security_setting.google_auth2_by_crowi_desc") }}</li></ul>
+                </li>
               </ul>
             </div>
           </div>
@@ -179,7 +182,7 @@
               </p>
 
               <ol class="help-block">
-                <li>{{ t("security_setting.access_api_manager") }}</li>
+                <li>{{ t("security_setting.access_api_manager", "https://console.cloud.google.com/apis/credentials", "API Manager") }}</li>
                 <li>{{ t("security_setting.create_project") }}</li>
                 <li>{{ t("security_setting.create_auth_to_oauth") }}</li>
                 <ol>
@@ -233,17 +236,17 @@
               <a href="#passport-ldap" data-toggle="tab" role="tab"><i class="fa fa-sitemap"></i> LDAP</a>
             </li>
             <li>
-              <a href="#passport-google-oauth" data-toggle="tab" role="tab"><i class="fa fa-google"></i> (TBD) Google OAuth</a>
+              <a href="#passport-google-oauth" data-toggle="tab" role="tab"><i class="fa fa-google"></i> Google</a>
             </li>
             <li>
+              <a href="#passport-github" data-toggle="tab" role="tab"><i class="fa fa-github"></i> GitHub</a>
+            </li>
+            <li class="tbd">
               <a href="#passport-facebook" data-toggle="tab" role="tab"><i class="fa fa-facebook"></i> (TBD) Facebook</a>
             </li>
-            <li>
+            <li class="tbd">
               <a href="#passport-twitter" data-toggle="tab" role="tab"><i class="fa fa-twitter"></i> (TBD) Twitter</a>
             </li>
-            <li>
-              <a href="#passport-github" data-toggle="tab" role="tab"><i class="fa fa-github"></i> (TBD) Github</a>
-            </li>
           </ul>
 
           <div class="tab-content p-t-10" {% if isRestartingServerNeeded %}style="opacity: 0.4;"{% endif %}>
@@ -276,7 +279,7 @@
   </div>
 
   <script>
-    $('#generalSetting, #googleSetting, #mechanismSetting').each(function() {
+    $('#generalSetting, #googleSetting, #mechanismSetting, #githubSetting').each(function() {
       $(this).submit(function()
       {
         function showMessage(formId, msg, status) {

+ 88 - 4
lib/views/admin/widget/passport/github.html

@@ -1,6 +1,90 @@
-<form action="" method="post" class="form-horizontal passportStrategy" id="githubOauthSetting" role="form">
-  <fieldset>
-    <legend>Github OAuth {{ t("security_setting.configuration") }}</legend>
-    <p class="well">(TBD)</p>
+<form action="/_api/admin/security/passport-github" method="post" class="form-horizontal passportStrategy" id="githubSetting" role="form"
+    {% if isRestartingServerNeeded %}style="opacity: 0.4;"{% endif %}>
+  <legend class="alert-anchor">{{ t("security_setting.OAuth.GitHub.name") }}{{ t("security_setting.configuration") }}</legend>
+
+  {% set nameForIsGitHubEnabled = "settingForm[security:passport-github:isEnabled]" %}
+  {% set isGitHubEnabled = settingForm['security:passport-github:isEnabled'] %}
+
+  <div class="form-group">
+    <label for="{{nameForIsGitHubEnabled}}" class="col-xs-3 control-label">{{ t("security_setting.OAuth.GitHub.name") }}</label>
+    <div class="col-xs-6">
+      <div class="btn-group btn-toggle" data-toggle="buttons">
+        <label class="btn btn-default btn-rounded btn-outline {% if isGitHubEnabled %}active{% endif %}" data-active-class="primary">
+          <input name="{{nameForIsGitHubEnabled}}" value="true" type="radio"
+              {% if true === isGitHubEnabled %}checked{% endif %}> ON
+        </label>
+        <label class="btn btn-default btn-rounded btn-outline {% if !isGitHubEnabled %}active{% endif %}" data-active-class="default">
+          <input name="{{nameForIsGitHubEnabled}}" value="false" type="radio"
+              {% if !isGitHubEnabled %}checked{% endif %}> OFF
+        </label>
+      </div>
+    </div>
+  </div>
+  <fieldset id="passport-github-hide-when-disabled" {%if !isGitHubEnabled %}style="display: none;"{% endif %}>
+
+    <div class="form-group">
+      <label for="settingForm[security:passport-github:clientId]" class="col-xs-3 control-label">{{ t("security_setting.clientID") }}</label>
+      <div class="col-xs-6">
+        <input class="form-control" type="text" name="settingForm[security:passport-github:clientId]" value="{{ settingForm['security:passport-github:clientId'] || '' }}">
+      </div>
+    </div>
+
+    <div class="form-group">
+      <label for="settingForm[security:passport-github:clientSecret]" class="col-xs-3 control-label">{{ t("security_setting.client_secret") }}</label>
+      <div class="col-xs-6">
+        <input class="form-control" type="text" name="settingForm[security:passport-github:clientSecret]" value="{{ settingForm['security:passport-github:clientSecret'] || '' }}">
+      </div>
+    </div>
+    <div class="form-group">
+      <div class="col-xs-6 col-xs-offset-3">
+        <div class="checkbox checkbox-info">
+          <input type="checkbox" id="bindByUserName-GitHub" name="settingForm[security:passport-github:isSameUsernameTreatedAsIdenticalUser]" value="1"
+              {% if settingForm['security:passport-github:isSameUsernameTreatedAsIdenticalUser'] %}checked{% endif %} />
+          <label for="bindByUserName-GitHub">
+            {{ t("security_setting.Treat username matching as identical", "username") }}
+          </label>
+          <p class="help-block">
+            <small>
+              {{ t("security_setting.Treat username matching as identical_warn", "username") }}
+            </small>
+          </p>
+        </div>
+      </div>
+    </div>
+
   </fieldset>
+
+  <div class="form-group" id="btn-update">
+    <div class="col-xs-offset-3 col-xs-6">
+      <input type="hidden" name="_csrf" value="{{ csrf() }}">
+      <button type="submit" class="btn btn-primary">{{ t('Update') }}</button>
+    </div>
+  </div>
+
 </form>
+
+{# Help Section #}
+<hr>
+<h4>
+  <i class="fa fa-question-circle" aria-hidden="true"></i>
+  <a href="#collapseHelpForGithubOauth" data-toggle="collapse">How to configure GitHub OAuth?</a>
+</h4>
+<ol id="collapseHelpForGithubOauth" class="collapse">
+  <li>{{ t("security_setting.OAuth.GitHub.register_1", "https://github.com/settings/developers", "GitHub Developer Settings") }}</li>
+  <li>{{ t("security_setting.OAuth.GitHub.register_2", "https://${growi.host}/passport/github/callback", "${growi.host}") }}</li>
+  <li>{{ t("security_setting.OAuth.GitHub.register_3") }}</li>
+</ol>
+
+<script>
+  $('input[name="settingForm[security:passport-github:isEnabled]"]').change(function() {
+    const isEnabled = ($(this).val() === "true");
+
+    if (isEnabled) {
+      $('#passport-github-hide-when-disabled').show(400);
+    }
+    else {
+      $('#passport-github-hide-when-disabled').hide(400);
+    }
+  });
+</script>
+

+ 83 - 74
lib/views/admin/widget/passport/google-oauth.html

@@ -1,82 +1,91 @@
-<form action="" method="post" class="form-horizontal passportStrategy" id="googleOauthSetting" role="form">
-  <fieldset>
-    <legend>Google OAuth {{ t("security_setting.configuration") }}</legend>
-    <p class="well">(TBD)</p>
+<form action="/_api/admin/security/passport-google" method="post" class="form-horizontal passportStrategy" id="googleSetting" role="form"
+    {% if isRestartingServerNeeded %}style="opacity: 0.4;"{% endif %}>
+  <legend class="alert-anchor">{{ t("security_setting.OAuth.Google.name") }}{{ t("security_setting.configuration") }}</legend>
+
+  {% set nameForIsGoogleEnabled = "settingForm[security:passport-google:isEnabled]" %}
+  {% set isGoogleEnabled = settingForm['security:passport-google:isEnabled'] %}
+
+  <div class="form-group">
+    <label for="{{nameForIsGoogleEnabled}}" class="col-xs-3 control-label">{{ t("security_setting.OAuth.Google.name") }}</label>
+    <div class="col-xs-6">
+      <div class="btn-group btn-toggle" data-toggle="buttons">
+        <label class="btn btn-default btn-rounded btn-outline {% if isGoogleEnabled %}active{% endif %}" data-active-class="primary">
+          <input name="{{nameForIsGoogleEnabled}}" value="true" type="radio"
+              {% if true === isGoogleEnabled %}checked{% endif %}> ON
+        </label>
+        <label class="btn btn-default btn-rounded btn-outline {% if !isGoogleEnabled %}active{% endif %}" data-active-class="default">
+          <input name="{{nameForIsGoogleEnabled}}" value="false" type="radio"
+              {% if !isGoogleEnabled %}checked{% endif %}> OFF
+        </label>
+      </div>
+    </div>
+  </div>
+  <fieldset id="passport-google-hide-when-disabled" {%if !isGoogleEnabled %}style="display: none;"{% endif %}>
+
+    <div class="form-group">
+      <label for="settingForm[security:passport-google:clientId]" class="col-xs-3 control-label">{{ t("security_setting.clientID") }}</label>
+      <div class="col-xs-6">
+        <input class="form-control" type="text" name="settingForm[security:passport-google:clientId]" value="{{ settingForm['security:passport-google:clientId'] || '' }}">
+      </div>
+    </div>
+
+    <div class="form-group">
+      <label for="settingForm[security:passport-google:clientSecret]" class="col-xs-3 control-label">{{ t("security_setting.client_secret") }}</label>
+      <div class="col-xs-6">
+        <input class="form-control" type="text" name="settingForm[security:passport-google:clientSecret]" value="{{ settingForm['security:passport-google:clientSecret'] || '' }}">
+      </div>
+    </div>
+    <div class="form-group">
+      <div class="col-xs-6 col-xs-offset-3">
+        <div class="checkbox checkbox-info">
+          <input type="checkbox" id="bindByUserName-Google" name="settingForm[security:passport-google:isSameUsernameTreatedAsIdenticalUser]" value="1"
+              {% if settingForm['security:passport-google:isSameUsernameTreatedAsIdenticalUser'] %}checked{% endif %} />
+          <label for="bindByUserName-Google">
+            {{ t("security_setting.Treat username matching as identical", "username") }}
+          </label>
+          <p class="help-block">
+            <small>
+              {{ t("security_setting.Treat username matching as identical_warn", "username") }}
+            </small>
+          </p>
+        </div>
+      </div>
+    </div>
+
   </fieldset>
+
+  <div class="form-group" id="btn-update">
+    <div class="col-xs-offset-3 col-xs-6">
+      <input type="hidden" name="_csrf" value="{{ csrf() }}">
+      <button type="submit" class="btn btn-primary">{{ t('Update') }}</button>
+    </div>
+  </div>
+
 </form>
 
-{% if false %}
+{# Help Section #}
 <hr>
 <h4>
   <i class="fa fa-question-circle" aria-hidden="true"></i>
-  <a href="#collapseHelpForApp" data-toggle="collapse">How to configure Slack App?</a>
+  <a href="#collapseHelpForGoogleOauth" data-toggle="collapse">How to configure Google OAuth?</a>
 </h4>
-
-<ol id="collapseHelpForApp" class="collapse">
-  <li>
-    Register Slack App
-    <ol>
-      <li>
-        Create App from <a href="https://api.slack.com/applications/new">this link</a>, and fill the form out as below:
-        <dl class="dl-horizontal">
-          <dt>App Name</dt> <dd><code>growi</code> </dd>
-          <dt>Development Slack Team</dt> <dd>Select the team you want to notify to.</dd>
-        </dl>
-      </li>
-      <li><strong>Save</strong> it.</li>
-    </ol>
-  </li>
-  <li>
-    Get App Credentials
-    <ol>
-      <li>Go To "Basic Information" page and make a note "Client ID" and "Client Secret".</li>
-    </ol>
-  </li>
-  <li>
-    Set Redirect URLs
-    <ol>
-      <li>Go to "OAuth &amp; Permissions" page.</li>
-      <li>Add <code><script>document.write(location.origin);</script>/admin/notification/slackAuth</code> .</li>
-      <li>Don't forget to <strong>save</strong>.</li>
-    </ol>
-  </li>
-  <li>
-    Set Permission Scopes to the App
-    <ol>
-      <li>Go to "OAuth &amp; Permissions" page.</li>
-      <li>Add "Send messages as GROWI"(<code>chat:write:bot</code>).</li>
-      <li>Don't forget to <strong>save</strong>.</li>
-    </ol>
-  </li>
-  <li>
-    Create a bot user
-    <ol>
-      <li>Go to "Bot Users" page and add.</li>
-    </ol>
-  </li>
-  <li>
-    Install the app
-    <ol>
-      <li>Go to "Install App to Your Team" page and install.</li>
-    </ol>
-  </li>
-  <li>
-    (At Team) Approve the app
-    <ol>
-      <li>Go to the management Apps page for the team you installed the app and approve "growi".</li>
-    </ol>
-  </li>
-  <li>
-    (At Team) Invite the bot to your team
-    <ol>
-      <li>Invite the user you created in <code>4. Add a bot user</code> to the channel you notify to.</li>
-    </ol>
-  </li>
-  <li>
-    (At GROWI admin page) Input "clientId" and "clientSecret" and submit on this page.
-  </li>
-  <li>
-    (At GROWI admin page) Click "Connect to Slack" button to start OAuth process.
-  </li>
+<ol id="collapseHelpForGoogleOauth" class="collapse">
+  <li>{{ t("security_setting.OAuth.Google.register_1", "https://console.cloud.google.com/apis/credentials", "Google Cloud Platform API Manager") }}</li>
+  <li>{{ t("security_setting.OAuth.Google.register_2") }}</li>
+  <li>{{ t("security_setting.OAuth.Google.register_3") }}</li>
+  <li>{{ t("security_setting.OAuth.Google.register_4", "https://${growi.host}/passport/google/callback", "${growi.host}") }}</li>
+  <li>{{ t("security_setting.OAuth.Google.register_5") }}</li>
 </ol>
-{% endif %}
+
+<script>
+  $('input[name="settingForm[security:passport-google:isEnabled]"]').change(function() {
+    const isEnabled = ($(this).val() === "true");
+
+    if (isEnabled) {
+      $('#passport-google-hide-when-disabled').show(400);
+    }
+    else {
+      $('#passport-google-hide-when-disabled').hide(400);
+    }
+  });
+</script>

+ 2 - 2
lib/views/admin/widget/passport/ldap.html

@@ -139,11 +139,11 @@
             <input type="checkbox" id="cbSameUsernameTreatedAsIdenticalUser" name="settingForm[security:passport-ldap:isSameUsernameTreatedAsIdenticalUser]" value="1"
                 {% if settingForm['security:passport-ldap:isSameUsernameTreatedAsIdenticalUser'] %}checked{% endif %} />
             <label for="cbSameUsernameTreatedAsIdenticalUser">
-              {{ t("security_setting.ldap.Treat username matching as identical") }}
+              {{ t("security_setting.Treat username matching as identical", "username") }}
             </label>
             <p class="help-block">
               <small>
-                {{ t("security_setting.ldap.Treat username matching as identical_warn") }}
+                {{ t("security_setting.Treat username matching as identical_warn", "username") }}
               </small>
             </p>
           </div>

+ 1 - 1
lib/views/admin/widget/theme-colorbox.html

@@ -1,7 +1,7 @@
 <a id="theme-option-{{name}}" href="#"
     class="{{name}} {% if name === settingForm['customize:theme'] %}active{% endif %}"
     onclick="selectTheme('{{name}}')"
-    data-theme="{{ webpack_asset('style-theme-' + name).css }}">
+    data-theme="{{ webpack_asset('styles/theme-' + name + '.css') }}">
 
   <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
     <title>{{name}}</title>

+ 1 - 7
lib/views/layout-crowi/widget/page_side_header.html

@@ -13,13 +13,7 @@
       </p>
       <p class="created-at">
         {{ t('Created') }}: {{ page.createdAt|datetz('Y/m/d H:i:s') }}<br>
-
-        {% if page.lastUpdateUser %}
-          {{ t('Last updated') }}: {{ page.updatedAt|datetz('Y/m/d H:i:s') }} <a href="/user/{{ page.lastUpdateUser.username }}"><img src="{{ page.lastUpdateUser|picture }}" class="picture picture-xs img-circle" alt="{{ page.lastUpdateUser.name }}"></a>
-        {% else %}
-          {# for BC 1.5.x #}
-          {{ t('Last updated') }}: {{ page.updatedAt|datetz('Y/m/d H:i:s') }} <a href="/user/{{ page.revision.author.username }}"><img src="{{ page.revision.author|picture }}" class="picture picture-xs img-circle" alt="{{ page.revision.author.name }}"></a>
-        {% endif %}
+        {{ t('Last updated') }}: {{ page.updatedAt|datetz('Y/m/d H:i:s') }} <a href="/user/{{ page.revision.author.username }}"><img src="{{ page.revision.author|picture }}" class="picture picture-xs img-circle" alt="{{ page.revision.author.name }}"></a>
       </p>
     </div>
   </div>

+ 3 - 3
lib/views/layout-growi/widget/header.html

@@ -27,11 +27,11 @@
         </li>
         <li class="m-t-5">
           <div class="d-flex align-items-center">
-            <a class="m-r-5" href="{{ userPageRoot(page.lastUpdateUser) }}">
-              <img src="{{ page.lastUpdateUser|default(author)|picture }}" class="picture img-circle">
+            <a class="m-r-5" href="{{ userPageRoot(page.revision.author) }}">
+              <img src="{{ page.revision.author|default(author)|picture }}" class="picture img-circle">
             </a>
             <div>
-              <div>Updated by <a href="{{ userPageRoot(page.lastUpdateUser) }}">{{ page.lastUpdateUser.name|default(author.name) }}</a></div>
+              <div>Updated by <a href="{{ userPageRoot(page.revision.author) }}">{{ page.revision.author.name|default(author.name) }}</a></div>
               <div class="text-muted">{{ page.updatedAt|datetz('Y/m/d H:i:s') }}</div>
             </div>
           </div>

+ 1 - 1
lib/views/layout/admin.html

@@ -6,7 +6,7 @@
 
 {% block html_additional_headers %}
   {% parent %}
-  <script src="{{ webpack_asset('legacy-admin').js }}" defer></script>
+  <script src="{{ webpack_asset('js/legacy-admin.js') }}" defer></script>
 {% endblock %}
 
 {# disable custom script in admin page #}

+ 16 - 15
lib/views/layout/layout.html

@@ -16,20 +16,19 @@
 
   {{ customHeader() }}
 
-
   <!-- polyfills for IE11 -->
   <script>
     var userAgent = window.navigator.userAgent.toLowerCase();
     if (userAgent.indexOf('msie') != -1 || userAgent.indexOf('trident') != -1) {
       var scriptElement = document.createElement('script');
-      scriptElement.src = 'https://cdnjs.cloudflare.com/ajax/libs/babel-polyfill/6.23.0/polyfill.min.js';
+      scriptElement.src = '{{ webpack_asset("js/ie11-polyfill.js") }}';
       var headElement = document.getElementsByTagName('head')[0];
       headElement.appendChild(scriptElement);
     }
   </script>
 
-  <!-- jQuery, emojione -->
-  <script src="https://cdn.jsdelivr.net/combine/npm/emojione@3.1.2,npm/jquery@3.3.1"></script>
+  <!-- jQuery, emojione, bootstrap -->
+  <script src="https://cdn.jsdelivr.net/combine/npm/emojione@3.1.2,npm/jquery@3.3.1,npm/bootstrap@3.3.7/dist/js/bootstrap.min.js"></script>
   <!-- highlight.js -->
   <script src="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@9.12.0/build/highlight.min.js"></script>
   <script src="https://cdn.jsdelivr.net/combine/
@@ -63,33 +62,35 @@ gh/highlightjs/cdn-release@9.12.0/build/languages/yaml.min.js
   {% endif %}
 
   {% if env === 'development' %}
-    <script src="/dll/vendor.dll.js"></script>
-    <script src="{{ webpack_asset('dev').js }}" async></script>
+    <script src="/dll/dll.js"></script>
+    <script src="{{ webpack_asset('js/dev.js') }}" async></script>
     <!-- Browsersync -->
     <script id="__bs_script__">//<![CDATA[
       document.write("<script async src='http://HOST:3001/browser-sync/browser-sync-client.js?v=2.23.6'><\/script>".replace("HOST", location.hostname));
     //]]></script>
   {% endif %}
 
-  <script src="{{ webpack_asset('commons').js }}" defer></script>
+  <script src="{{ webpack_asset('js/vendors.js') }}" defer></script>
+  <script src="{{ webpack_asset('js/commons.js') }}" defer></script>
   {% if isEnabledPlugins() %}
-  <script src="{{ webpack_asset('plugin').js }}" defer></script>
+  <script src="{{ webpack_asset('js/plugin.js') }}" defer></script>
   {% endif %}
   {% block html_head_loading_legacy %}
-  <script src="{{ webpack_asset('legacy').js }}" defer></script>
+  <script src="{{ webpack_asset('js/legacy.js') }}" defer></script>
   {% endblock %}
   {% block html_head_loading_app %}
-  <script src="{{ webpack_asset('app').js }}" defer></script>
+  <script src="{{ webpack_asset('js/app.js') }}" defer></script>
   {% endblock %}
 
   <!-- styles -->
   {% block style_css_block %}
     {% if env === 'development' %}
-      <script src="{{ webpack_asset('style').js }}"></script>
-      <script src="{{ webpack_asset('style-theme-' + theme()).js }}"></script>
+      <script src="{{ webpack_asset('styles/style.js') }}"></script>
+      <script src="{{ webpack_asset('styles/theme-' + theme() + '.js') }}"></script>
     {% else %}
-      <link rel="stylesheet" href="{{ webpack_asset('style').css }}">
-      <link rel="stylesheet" href="{{ webpack_asset('style-theme-' + theme()).css }}">
+      <link rel="stylesheet" href="{{ webpack_asset('js/vendors.css') }}">
+      <link rel="stylesheet" href="{{ webpack_asset('styles/style.css') }}">
+      <link rel="stylesheet" href="{{ webpack_asset('styles/theme-' + theme() + '.css') }}">
     {% endif %}
   {% endblock %}
 
@@ -219,7 +220,7 @@ gh/highlightjs/cdn-release@9.12.0/build/languages/yaml.min.js
           {% endif %}
         </li>
 
-        <li><a href="#">(TBD) Create /Sidebar</a></li>
+        <li class="tbd"><a href="#">(TBD) Create /Sidebar</a></li>
       </ul>
     </div>
   </div>

+ 71 - 4
lib/views/login.html

@@ -42,12 +42,12 @@
         # The case that there already exists a user whose username matches ID of the newly created LDAP user
         # https://github.com/weseek/growi/issues/193
         #}
-        {% set isDuplicatedUsernameExceptionOccured = req.flash('isDuplicatedUsernameExceptionOccured') %}
-        {% if isDuplicatedUsernameExceptionOccured != null %}
+        {% set failedProviderForDuplicatedUsernameException = req.flash('provider-DuplicatedUsernameException') %}
+        {% if failedProviderForDuplicatedUsernameException != null %}
         <div class="alert alert-warning small">
           <p><strong><i class="icon-fw icon-ban"></i>DuplicatedUsernameException occured</strong></p>
           <p>
-            Your LDAP authentication was succeess, but a new user could not be created.
+            Your {{ failedProviderForDuplicatedUsernameException }} authentication was succeess, but a new user could not be created.
             See the issue <a href="https://github.com/weseek/growi/issues/193">#193</a>.
           </p>
         </div>
@@ -136,7 +136,7 @@
         <div class="input-group m-t-15 m-b-10 mx-auto">
           <form role="form" action="/login/google" method="get">
             <input type="hidden" name="_csrf" value="{{ csrf() }}">
-            <button type="submit" class="fcbtn btn btn-danger btn-1b btn-login-google">
+            <button type="submit" class="fcbtn btn btn-danger btn-1b btn-login-oauth" id="google">
               <span class="btn-label"><i class="icon-social-google"></i></span>
               {{ t('Sign in') }}
             </button>
@@ -145,7 +145,74 @@
         </div>
         {% endif %}
 
+        {% if passportGoogleLoginEnabled() || passportGitHubLoginEnabled() || passportFacebookLoginEnabled() || passportTwitterLoginEnabled() %}
+        <hr class="mb-1">
+        <div class="collapse collapse-oauth collapse-anchor">
+          <div class="spacer"></div>
+          <div class="d-flex flex-row justify-content-between flex-wrap">
+            {% if passportGoogleLoginEnabled() %}
+            <form role="form" action="/passport/google" class="d-inline-flex flex-column">
+              <button type="submit" class="fcbtn btn btn-1b btn-login-oauth d-flex" id="google">
+                <span class="btn-label"><i class="fa fa-google"></i></span>
+                <span class="btn-label-text">{{ t('Sign in') }}</span>
+              </button>
+              <div class="small text-right">by Google Account</div>
+            </form>
+            {% endif %}
+            {% if passportGitHubLoginEnabled() %}
+            <form role="form" action="/passport/github" class="d-inline-flex flex-column">
+              <input type="hidden" name="_csrf" value="{{ csrf() }}">
+              <button type="submit" class="fcbtn btn btn-1b btn-login-oauth d-inline-flex" id="github">
+                <span class="btn-label"><i class="fa fa-github"></i></span>
+                <span class="btn-label-text">{{ t('Sign in') }}</span>
+              </button>
+              <div class="small text-right">by GitHub Account</div>
+            </form>
+            {% endif %}
+            {% if passportFacebookLoginEnabled() %}
+            <form role="form" action="/passport/facebook" class="d-inline-flex flex-column">
+              <input type="hidden" name="_csrf" value="{{ csrf() }}">
+              <button type="submit" class="fcbtn btn btn-1b btn-login-oauth d-inline-flex" id="facebook">
+                <span class="btn-label"><i class="fa fa-facebook"></i></span>
+                <span class="btn-label-text">{{ t('Sign in') }}</span>
+              </button>
+              <div class="small text-right">by Facebook Account</div>
+            </form>
+            {% endif %}
+            {% if passportTwitterLoginEnabled() %}
+            <form role="form" action="/passport/twitter" class="d-inline-flex flex-column">
+              <input type="hidden" name="_csrf" value="{{ csrf() }}">
+              <button type="submit" class="fcbtn btn btn-1b btn-login-oauth d-inline-flex" id="twitter">
+                <span class="btn-label"><i class="fa fa-twitter"></i></span>
+                <span class="btn-label-text">{{ t('Sign in') }}</span>
+              </button>
+              <div class="small text-right">by Twitter Account</div>
+            </form>
+            {% endif %}
+          </div>{# ./d-flex flex-row flex-wrap #}
+          <div class="spacer"></div>
+        </div>
+        <hr class="mt-2 mb-0">
+        <div class="text-center">
+          <button class="collapse-anchor btn btn-xs btn-collapse-oauth mb-3" data-toggle="collapse" data-parent="#accordion" href="#collapse-oauth" aria-expanded="true" aria-controls="collapseOne">
+            OAuth
+          </button>
+        </div>
+        {% else %}
         <hr>
+        {% endif %}
+
+
+        <script>
+          $(".collapse-anchor").hover(
+            function() {
+              $('.collapse-oauth').collapse('show');
+            },
+            function() {
+              $('.collapse-oauth').collapse('hide');
+            }
+          );
+        </script>
 
         <div class="row">
           <div class="col-xs-12 text-right">

+ 9 - 9
lib/views/me/external-accounts.html

@@ -122,18 +122,18 @@
             <li class="active">
               <a href="#passport-ldap" data-toggle="tab" role="tab"><i class="fa fa-sitemap"></i> LDAP</a>
             </li>
-            <li>
+            <li class="tbd">
+              <a href="#passport-github" data-toggle="tab" role="tab"><i class="fa fa-github"></i> (TBD) GitHub</a>
+            </li>
+            <li class="tbd">
               <a href="#passport-google-oauth" data-toggle="tab" role="tab"><i class="fa fa-google"></i> (TBD) Google OAuth</a>
             </li>
-            <li>
+            <li class="tbd">
               <a href="#passport-facebook" data-toggle="tab" role="tab"><i class="fa fa-facebook"></i> (TBD) Facebook</a>
             </li>
-            <li>
+            <li class="tbd">
               <a href="#passport-twitter" data-toggle="tab" role="tab"><i class="fa fa-twitter"></i> (TBD) Twitter</a>
             </li>
-            <li>
-              <a href="#passport-github" data-toggle="tab" role="tab"><i class="fa fa-github"></i> (TBD) Github</a>
-            </li>
           </ul>
 
           <div class="tab-content passport-settings m-t-15">
@@ -153,15 +153,15 @@
               (TBD)
             </div>
 
-            <div id="passport-facebook" class="tab-pane" role="tabpanel">
+            <div id="passport-github" class="tab-pane" role="tabpanel">
               (TBD)
             </div>
 
-            <div id="passport-twitter" class="tab-pane" role="tabpanel">
+            <div id="passport-facebook" class="tab-pane" role="tabpanel">
               (TBD)
             </div>
 
-            <div id="passport-github" class="tab-pane" role="tabpanel">
+            <div id="passport-twitter" class="tab-pane" role="tabpanel">
               (TBD)
             </div>
 

+ 1 - 32
lib/views/modal/create_page.html

@@ -57,7 +57,7 @@
               <div class="create-page-button-container">
                 <a id="link-to-template" href="{{ page.path || path }}" class="fcbtn btn btn-outline btn-rounded btn-primary btn-1b disabled">
                   <i class="icon-fw icon-doc"></i>
-                  <span id="create-template-button-link">{{ t('Create') }}/{{ t('Edit') }}</span>
+                  <span id="create-template-button-link">{{ t('Edit') }}</span>
                 </a>
               </div>
             </div>
@@ -69,34 +69,3 @@
     </div><!-- /.modal-content -->
   </div><!-- /.modal-dialog -->
 </div><!-- /.modal -->
-<script>
-  let buttonTextChildren;
-  let buttonTextDecendants;
-  let pagePath = $("#link-to-template").attr("href");
-
-  if (pagePath.endsWith("/")) {
-      pagePath = pagePath.slice(0, -1);
-  };
-
-  $.get(`/_api/pages.templates?path=${pagePath}`)
-    .then(templateInfo => {
-      buttonTextChildren = templateInfo.childrenTemplateExists ? `{{ t('Edit') }}` : `{{ t('Create') }}`;
-      buttonTextDecendants = templateInfo.decendantsTemplateExists ? `{{ t('Edit') }}` : `{{ t('Create') }}`;
-    });
-
-  $("#template-type").on("change", () => {
-    // enable button
-    $('#link-to-template').removeClass("disabled");
-
-    if ($("#template-type").val() === "children") {
-      href = pagePath + "/_template#edit-form";
-      $("#link-to-template").attr("href", href);
-      $('#create-template-button-link').text(buttonTextChildren);
-    }
-    else if ($("#template-type").val() === "decentants") {
-      href = pagePath + "/__template#edit-form";
-      $("#link-to-template").attr("href", href);
-      $('#create-template-button-link').text(buttonTextDecendants);
-    };
-  });
-</script>

+ 3 - 1
lib/views/modal/create_template.html

@@ -20,6 +20,7 @@
                 <div class="panel-footer text-center">
                   <a href="{% if page.path.endsWith('/') %}{{ page.path }}{% else %}{{ page.path}}/{% endif %}_template#edit-form"
                       class="btn btn-sm btn-primary" id="template-button-children">
+                      {{ t("Edit") }}
                   </a>
                 </div>
               </div>
@@ -32,8 +33,9 @@
                   <p class="help-block text-center"><small>{{ t('template.decendants.desc') }}</small></p>
                 </div>
                 <div class="panel-footer text-center">
-                  <a href="{% if page.path.endsWith('/') %}{{ page.path }}{% else %}{{ page.path}}/{% endif %}__template#edit-form"
+                  <a href="{% if page.path.endsWith('/') %}{{ page.path }}{% else %}{{ page.path }}/{% endif %}__template#edit-form"
                       class="btn btn-sm btn-primary" id="template-button-decendants">
+                      {{ t("Edit") }}
                   </a>
                 </div>
               </div>

+ 1 - 1
lib/views/modal/shortcuts.html

@@ -67,7 +67,7 @@
     var platform = navigator.platform.toLowerCase();
     var isMac = (platform.indexOf('mac') > -1);
 
-    document.querySelectorAll('#shortcuts-modal .cmd-key').forEach((element) => {
+    document.querySelectorAll('#shortcuts-modal .cmd-key').forEach(function(element) {
       if (isMac) {
         element.classList.add('mac');
       }

+ 6 - 7
lib/views/page_presentation.html

@@ -35,19 +35,18 @@ gh/highlightjs/cdn-release@9.12.0/build/languages/yaml.min.js
 " defer></script>
 
     {% if env === 'development' %}
-      <script src="/dll/vendor.dll.js"></script>
-      <script src="{{ webpack_asset('dev').js }}" async></script>
+      <script src="/dll/dll.js"></script>
+      <script src="{{ webpack_asset('js/dev.js') }}" async></script>
     {% endif %}
 
-    <script src="{{ webpack_asset('commons').js }}" defer></script>
-    <script src="{{ webpack_asset('legacy-presentation').js }}" defer></script>
+    <script src="{{ webpack_asset('js/legacy-presentation.js') }}" defer></script>
 
     <title>{{ path|path2name }} | {{ path }}</title>
 
     <!-- styles -->
-    <link rel="stylesheet" href="{{ webpack_asset('style').css }}">
-    <link rel="stylesheet" href="{{ webpack_asset('style-theme-default').css }}">
-    <link rel="stylesheet" href="{{ webpack_asset('style-presentation').css }}">
+    <link rel="stylesheet" href="{{ webpack_asset('styles/style.css') }}">
+    <link rel="stylesheet" href="{{ webpack_asset('styles/theme-default.css') }}">
+    <link rel="stylesheet" href="{{ webpack_asset('styles/style-presentation.css') }}">
 
     <!-- Google Fonts -->
     <link href='https://fonts.googleapis.com/css?family=Lato:400,700' rel='stylesheet' type='text/css'>

+ 2 - 9
lib/views/widget/page_attachments.html

@@ -4,15 +4,8 @@
       <div class="page-attachments" id="page-attachment"></div>
 
       <p class="page-meta">
-        Path: <span id="pagePath">{{ page.path }}</span><br>
-        {# for BC #}
-        {% if page.lastUpdateUser %}
-          Last updated at {{ page.updatedAt|datetz('Y-m-d H:i:s') }} by <img src="{{ page.lastUpdateUser|picture }}" class="picture img-circle"> {{ page.lastUpdateUser.name }}<br>
-        {% else %}
-          Last updated at {{ page.revision.createdAt|datetz('Y-m-d H:i:s') }} by <img src="{{ page.revision.author|picture }}" class="picture img-circle"> {{ page.revision.author.name }}<br>
-        {% endif %}
-        {# /for BC #}
-        Created at {{ page.createdAt|datetz('Y-m-d H:i:s') }} by <img src="{{ page.creator|default(page.creator)|picture }}" class="picture img-circle"> {{ page.creator.name }}<br>
+        <p>Last revision posted at {{ page.revision.createdAt|datetz('Y-m-d H:i:s') }} by <a href="/user/{{ page.revision.author.username }}"><img src="{{ page.revision.author|picture }}" class="picture picture-sm img-circle"> {{ page.revision.author.name }}</a></p>
+        <p>Created at {{ page.createdAt|datetz('Y-m-d H:i:s') }} by <a href="/user/{{ page.creator.username }}"><img src="{{ page.creator|default(page.creator)|picture }}" class="picture picture-sm img-circle"> {{ page.creator.name }}</a></p>
       </p>
     </div>
   </div>

+ 1 - 1
lib/views/widget/page_list.html

@@ -8,7 +8,7 @@
 {% endif %}
 
 <li>
-  <img src="{{ page.revision.author|picture }}" class="picture img-circle">
+  <img src="{{ page.lastUpdateUser|picture }}" class="picture img-circle">
   <a href="{{ page.path }}"
     class="page-list-link"
     data-path="{{ page.path }}"

+ 1 - 1
lib/views/widget/system-version.html

@@ -13,7 +13,7 @@
   var platform = navigator.platform.toLowerCase();
   var isMac = (platform.indexOf('mac') > -1);
 
-  document.querySelectorAll('.system-version .cmd-key').forEach((element) => {
+  document.querySelectorAll('.system-version .cmd-key').forEach(function(element) {
     if (isMac) {
       element.classList.add('mac');
     }

+ 28 - 19
package.json

@@ -1,6 +1,6 @@
 {
   "name": "growi",
-  "version": "3.1.7-RC",
+  "version": "3.1.8-RC",
   "description": "Team collaboration software using markdown",
   "tags": [
     "wiki",
@@ -20,13 +20,17 @@
     "url": "https://github.com/weseek/growi/issues"
   },
   "scripts": {
-    "build:dev:analyze": "cross-env ANALYZE=1 npm run build:dev",
-    "build:dev:watch": "npm run build:dev -- --watch",
-    "build:dev": "npm run clean:js && webpack --config config/webpack.dev.js --progress --profile",
+    "build:dev:analyze": "npm-run-all -s build:dev:dll build:dev:app:analyze",
+    "build:dev:app:analyze": "cross-env ANALYZE=1 npm run build:dev:app:watch -- --profile",
+    "build:dev:app:watch": "npm run build:dev:app -- --watch",
+    "build:dev:app": "npm run clean:app && env-cmd config/env.dev.js webpack --config config/webpack.dev.js --progress",
+    "build:dev:dll": "webpack --config config/webpack.dll.js",
+    "build:dev:watch": "npm-run-all -s build:dev:dll build:dev:app:watch",
+    "build:dev": "npm-run-all -s build:dev:dll build:dev:app",
     "build:prod:analyze": "cross-env ANALYZE=1 npm run build:prod",
-    "build:prod": "npm run clean && webpack --config config/webpack.prod.js --progress --profile --bail",
+    "build:prod": "npm run clean && env-cmd config/env.prod.js webpack --config config/webpack.prod.js --profile --bail",
     "build": "npm run build:dev:watch",
-    "clean:js": "rimraf -- public/js",
+    "clean:app": "rimraf -- public/js public/styles",
     "clean:dll": "rimraf -- public/dll",
     "clean:report": "rimraf -- report",
     "clean": "npm-run-all -p clean:*",
@@ -35,13 +39,11 @@
     "lint": "eslint .",
     "mkdirp": "mkdirp",
     "plugin:def": "node bin/generate-plugin-definitions-source.js",
-    "prebuild:dev": "env-cmd config/env.dev.js npm run plugin:def",
+    "prebuild:dev:app": "env-cmd config/env.dev.js npm run plugin:def",
     "prebuild:prod": "npm run plugin:def",
     "prestart": "npm run build:prod",
-    "postserver:prod:container": "echo ---------------------------------------- && echo [WARNING] && echo   'server:prod:container' is deprecated. && echo   Please use 'sever:prod' && echo ----------------------------------------",
     "server:debug": "env-cmd config/env.dev.js node-dev --inspect app.js",
     "server:dev": "env-cmd config/env.dev.js node-dev --respawn app.js",
-    "server:prod:container": "npm run server:prod",
     "server:prod:ci": "npm run server:prod -- --ci",
     "server:prod": "env-cmd config/env.prod.js node app.js",
     "server": "npm run server:dev",
@@ -96,11 +98,10 @@
     "nodemailer-ses-transport": "~1.5.0",
     "npm-run-all": "^4.1.2",
     "passport": "^0.4.0",
+    "passport-github": "^1.1.0",
+    "passport-google-auth": "^1.0.2",
     "passport-ldapauth": "^2.0.0",
     "passport-local": "^1.0.0",
-    "react": "^16.2.0",
-    "react-dom": "^16.2.0",
-    "react-i18next": "^7.6.1",
     "rimraf": "^2.6.1",
     "slack-node": "^0.1.8",
     "socket.io": "^2.0.3",
@@ -111,11 +112,11 @@
   },
   "devDependencies": {
     "@alienfast/i18next-loader": "^1.0.16",
-    "assets-webpack-plugin": "^3.6.0",
     "autoprefixer": "^8.2.0",
     "babel-core": "^6.25.0",
     "babel-loader": "^7.1.1",
     "babel-plugin-lodash": "^3.3.2",
+    "babel-polyfill": "^6.26.0",
     "babel-preset-env": "^1.6.0",
     "babel-preset-react": "^6.24.1",
     "bootstrap-sass": "~3.3.6",
@@ -136,13 +137,13 @@
     "eazy-logger": "^3.0.2",
     "eslint": "^4.19.1",
     "eslint-plugin-react": "^7.7.0",
-    "extract-text-webpack-plugin": "^3.0.2",
     "file-loader": "^1.1.0",
     "i18next-browser-languagedetector": "^2.2.0",
     "jquery-slimscroll": "^1.3.8",
     "jquery-ui": "^1.12.1",
     "jquery.cookie": "~1.4.1",
     "load-css-file": "^1.0.0",
+    "lodash-webpack-plugin": "^0.11.5",
     "markdown-it": "^8.4.0",
     "markdown-it-blockdiag": "^1.0.0",
     "markdown-it-emoji": "^1.4.0",
@@ -154,20 +155,26 @@
     "markdown-it-toc-and-anchor-with-slugid": "^1.1.4",
     "markdown-table": "^1.1.1",
     "metismenu": "^2.7.4",
+    "mini-css-extract-plugin": "^0.4.0",
     "mocha": "^5.0.0",
     "morgan": "^1.9.0",
     "node-dev": "^3.1.3",
     "node-sass": "^4.5.0",
+    "nodelist-foreach-polyfill": "^1.2.0",
     "normalize-path": "^3.0.0",
+    "null-loader": "^0.1.1",
     "on-headers": "^1.0.1",
-    "optimize-js-plugin": "0.0.4",
+    "optimize-css-assets-webpack-plugin": "^4.0.2",
     "plantuml-encoder": "^1.2.5",
     "postcss-loader": "^2.1.3",
+    "react": "^16.2.0",
     "react-bootstrap": "^0.32.1",
-    "react-bootstrap-typeahead": "^3.0.3",
+    "react-bootstrap-typeahead": "^3.1.4",
     "react-clipboard.js": "^2.0.0",
-    "react-codemirror2": "^5.0.0",
+    "react-codemirror2": "^5.0.4",
+    "react-dom": "^16.2.0",
     "react-dropzone": "^4.2.7",
+    "react-i18next": "^7.6.1",
     "reveal.js": "^3.5.0",
     "sass-loader": "^7.0.1",
     "simple-load-script": "^1.0.2",
@@ -177,10 +184,12 @@
     "style-loader": "^0.21.0",
     "throttle-debounce": "^2.0.0",
     "toastr": "^2.1.2",
+    "uglifyjs-webpack-plugin": "^1.2.5",
     "url-join": "^4.0.0",
-    "webpack": "3.11.0",
+    "webpack": "^4.12.0",
+    "webpack-assets-manifest": "^3.0.1",
     "webpack-bundle-analyzer": "^2.9.0",
-    "webpack-dll-bundles-plugin": "^1.0.0-beta.5",
+    "webpack-cli": "^3.0.8",
     "webpack-merge": "~4.1.0"
   },
   "_moduleAliases": {

+ 0 - 1
public/css/.gitignore

@@ -1 +0,0 @@
-

+ 19 - 18
resource/js/app.js

@@ -80,14 +80,14 @@ const crowiRenderer = new GrowiRenderer(crowi, null, {
 window.crowiRenderer = crowiRenderer;
 
 // FIXME
-var isEnabledPlugins = $('body').data('plugin-enabled');
+const isEnabledPlugins = $('body').data('plugin-enabled');
 if (isEnabledPlugins) {
-  var crowiPlugin = window.crowiPlugin;
+  const crowiPlugin = window.crowiPlugin;
   crowiPlugin.installAll(crowi, crowiRenderer);
 }
 
-// configure renderer
-crowiRenderer.setup(crowi.config);
+// setup renderer after plugins are installed
+crowiRenderer.setup();
 
 // restore draft when the first time to edit
 const draft = crowi.findDraft(pagePath);
@@ -134,20 +134,6 @@ Object.keys(componentMappings).forEach((key) => {
   }
 });
 
-// render comment form
-const writeCommentElem = document.getElementById('page-comment-write');
-if (writeCommentElem) {
-  const pageCommentsElem = componentInstances['page-comments-list'];
-  const postCompleteHandler = (comment) => {
-    if (pageCommentsElem != null) {
-      pageCommentsElem.retrieveData();
-    }
-  };
-  ReactDOM.render(
-    <CommentForm crowi={crowi} pageId={pageId} revisionId={pageRevisionId} onPostComplete={postCompleteHandler} crowiRenderer={crowiRenderer}/>,
-    writeCommentElem);
-}
-
 /*
  * PageEditor
  */
@@ -178,6 +164,21 @@ if (pageEditorElem) {
   // set refs for pageEditor
   crowi.setPageEditor(pageEditor);
 }
+
+// render comment form
+const writeCommentElem = document.getElementById('page-comment-write');
+if (writeCommentElem) {
+  const pageCommentsElem = componentInstances['page-comments-list'];
+  const postCompleteHandler = (comment) => {
+    if (pageCommentsElem != null) {
+      pageCommentsElem.retrieveData();
+    }
+  };
+  ReactDOM.render(
+    <CommentForm crowi={crowi} crowiOriginRenderer={crowiRenderer} pageId={pageId} revisionId={pageRevisionId} pagePath={pagePath} onPostComplete={postCompleteHandler} editorOptions={editorOptions}/>,
+    writeCommentElem);
+}
+
 // render OptionsSelector
 const pageEditorOptionsSelectorElem = document.getElementById('page-editor-options-selector');
 if (pageEditorOptionsSelectorElem) {

+ 2 - 2
resource/js/components/Admin/CustomCssEditor.js

@@ -2,13 +2,13 @@ import React from 'react';
 import PropTypes from 'prop-types';
 
 import { UnControlled as CodeMirror } from 'react-codemirror2';
-require('codemirror/addon/display/autorefresh');
 require('codemirror/addon/lint/css-lint');
 require('codemirror/addon/hint/css-hint');
 require('codemirror/addon/hint/show-hint');
 require('codemirror/addon/edit/matchbrackets');
 require('codemirror/addon/edit/closebrackets');
 require('codemirror/mode/css/css');
+require('../../util/codemirror/autorefresh.ext');
 
 require('jquery-ui/ui/widgets/resizable');
 
@@ -32,7 +32,7 @@ export default class CustomCssEditor extends React.Component {
           tabSize: 2,
           indentUnit: 2,
           theme: 'eclipse',
-          autoRefresh: true,
+          autoRefresh: {force: true},   // force option is enabled by autorefresh.ext.js -- Yuki Takei
           matchBrackets: true,
           autoCloseBrackets: true,
           extraKeys: {'Ctrl-Space': 'autocomplete'},

+ 2 - 2
resource/js/components/Admin/CustomHeaderEditor.js

@@ -2,11 +2,11 @@ import React from 'react';
 import PropTypes from 'prop-types';
 
 import { UnControlled as CodeMirror } from 'react-codemirror2';
-require('codemirror/addon/display/autorefresh');
 require('codemirror/addon/hint/show-hint');
 require('codemirror/addon/edit/matchbrackets');
 require('codemirror/addon/edit/closebrackets');
 require('codemirror/mode/htmlmixed/htmlmixed');
+require('../../util/codemirror/autorefresh.ext');
 
 require('jquery-ui/ui/widgets/resizable');
 
@@ -30,7 +30,7 @@ export default class CustomHeaderEditor extends React.Component {
           tabSize: 2,
           indentUnit: 2,
           theme: 'eclipse',
-          autoRefresh: true,
+          autoRefresh: {force: true},   // force option is enabled by autorefresh.ext.js -- Yuki Takei
           matchBrackets: true,
           autoCloseBrackets: true,
           extraKeys: {'Ctrl-Space': 'autocomplete'},

+ 2 - 2
resource/js/components/Admin/CustomScriptEditor.js

@@ -2,13 +2,13 @@ import React from 'react';
 import PropTypes from 'prop-types';
 
 import { UnControlled as CodeMirror } from 'react-codemirror2';
-require('codemirror/addon/display/autorefresh');
 require('codemirror/addon/lint/javascript-lint');
 require('codemirror/addon/hint/javascript-hint');
 require('codemirror/addon/hint/show-hint');
 require('codemirror/addon/edit/matchbrackets');
 require('codemirror/addon/edit/closebrackets');
 require('codemirror/mode/javascript/javascript');
+require('../../util/codemirror/autorefresh.ext');
 
 require('jquery-ui/ui/widgets/resizable');
 
@@ -32,7 +32,7 @@ export default class CustomScriptEditor extends React.Component {
           tabSize: 2,
           indentUnit: 2,
           theme: 'eclipse',
-          autoRefresh: true,
+          autoRefresh: {force: true},   // force option is enabled by autorefresh.ext.js -- Yuki Takei
           matchBrackets: true,
           autoCloseBrackets: true,
           extraKeys: {'Ctrl-Space': 'autocomplete'},

+ 108 - 28
resource/js/components/PageComment/CommentForm.js

@@ -2,12 +2,16 @@ import React from 'react';
 import PropTypes from 'prop-types';
 import ReactUtils from '../ReactUtils';
 
-import CommentPreview from '../PageComment/CommentPreview';
-
 import Button from 'react-bootstrap/es/Button';
 import Tab from 'react-bootstrap/es/Tab';
 import Tabs from 'react-bootstrap/es/Tabs';
 import UserPicture from '../User/UserPicture';
+import * as toastr from 'toastr';
+
+import GrowiRenderer from '../../util/GrowiRenderer';
+
+import Editor from '../PageEditor/Editor';
+import CommentPreview from '../PageComment/CommentPreview';
 
 /**
  *
@@ -23,27 +27,40 @@ export default class CommentForm extends React.Component {
   constructor(props) {
     super(props);
 
+    const config = this.props.crowi.getConfig();
+    const isUploadable = config.upload.image || config.upload.file;
+    const isUploadableFile = config.upload.file;
+
     this.state = {
       comment: '',
       isMarkdown: true,
       html: '',
       key: 1,
+      isUploadable,
+      isUploadableFile,
+      errorMessage: undefined,
     };
 
+    this.growiRenderer = new GrowiRenderer(this.props.crowi, this.props.crowiOriginRenderer, {mode: 'comment'});
+
     this.updateState = this.updateState.bind(this);
+    this.updateStateCheckbox = this.updateStateCheckbox.bind(this);
     this.postComment = this.postComment.bind(this);
     this.renderHtml = this.renderHtml.bind(this);
     this.handleSelect = this.handleSelect.bind(this);
+    this.apiErrorHandler = this.apiErrorHandler.bind(this);
+    this.onUpload = this.onUpload.bind(this);
   }
 
-  updateState(event) {
-    const target = event.target;
-    const value = target.type === 'checkbox' ? target.checked : target.value;
-    const name = target.name;
+  updateState(value) {
+    this.setState({comment: value});
+  }
 
-    this.setState({
-      [name]: value
-    });
+  updateStateCheckbox(event) {
+    const value = event.target.checked;
+    this.setState({isMarkdown: value});
+    // changeMode
+    this.refs.editor.setGfmMode(value);
   }
 
   handleSelect(key) {
@@ -74,7 +91,14 @@ export default class CommentForm extends React.Component {
           isMarkdown: true,
           html: '',
           key: 1,
+          errorMessage: undefined,
         });
+        // reset value
+        this.refs.editor.setValue('');
+      })
+      .catch(err => {
+        const errorMessage = err.message || 'An unknown error occured when posting comment';
+        this.setState({ errorMessage });
       });
   }
 
@@ -87,26 +111,26 @@ export default class CommentForm extends React.Component {
   }
 
   renderHtml(markdown) {
-    var context = {
+    const context = {
       markdown,
       dom: this.previewElement,
     };
 
-    const crowiRenderer = this.props.crowiRenderer;
+    const growiRenderer = this.growiRenderer;
     const interceptorManager = this.props.crowi.interceptorManager;
     interceptorManager.process('preRenderCommnetPreview', context)
       .then(() => interceptorManager.process('prePreProcess', context))
       .then(() => {
-        context.markdown = crowiRenderer.preProcess(context.markdown);
+        context.markdown = growiRenderer.preProcess(context.markdown);
       })
       .then(() => interceptorManager.process('postPreProcess', context))
       .then(() => {
-        var parsedHTML = crowiRenderer.process(context.markdown);
+        const parsedHTML = growiRenderer.process(context.markdown);
         context['parsedHTML'] = parsedHTML;
       })
       .then(() => interceptorManager.process('prePostProcess', context))
       .then(() => {
-        context.parsedHTML = crowiRenderer.postProcess(context.parsedHTML, context.dom);
+        context.parsedHTML = growiRenderer.postProcess(context.parsedHTML, context.dom);
       })
       .then(() => interceptorManager.process('postPostProcess', context))
       .then(() => interceptorManager.process('preRenderCommentPreviewHtml', context))
@@ -121,6 +145,48 @@ export default class CommentForm extends React.Component {
     return {__html: html};
   }
 
+  onUpload(file) {
+    const endpoint = '/attachments.add';
+
+    // create a FromData instance
+    const formData = new FormData();
+    formData.append('_csrf', this.props.crowi.csrfToken);
+    formData.append('file', file);
+    formData.append('path', this.props.pagePath);
+    formData.append('page_id', this.props.pageId || 0);
+
+    // post
+    this.props.crowi.apiPost(endpoint, formData)
+      .then((res) => {
+        const url = res.url;
+        const attachment = res.attachment;
+        const fileName = attachment.originalName;
+
+        let insertText = `[${fileName}](${url})`;
+        // when image
+        if (attachment.fileFormat.startsWith('image/')) {
+          // modify to "![fileName](url)" syntax
+          insertText = '!' + insertText;
+        }
+        this.refs.editor.insertText(insertText);
+      })
+      .catch(this.apiErrorHandler)
+      // finally
+      .then(() => {
+        this.refs.editor.terminateUploadingState();
+      });
+  }
+
+  apiErrorHandler(error) {
+    toastr.error(error.message, 'Error occured', {
+      closeButton: true,
+      progressBar: true,
+      newestOnTop: false,
+      showDuration: '100',
+      hideDuration: '100',
+      timeOut: '3000',
+    });
+  }
 
   render() {
     const crowi = this.props.crowi;
@@ -129,6 +195,7 @@ export default class CommentForm extends React.Component {
     const creatorsPage = `/user/${username}`;
     const comment = this.state.comment;
     const commentPreview = this.state.isMarkdown ? this.getCommentHtml(): ReactUtils.nl2br(comment);
+    const emojiStrategy = this.props.crowi.getEmojiStrategy();
 
     return (
       <div>
@@ -144,8 +211,18 @@ export default class CommentForm extends React.Component {
                 <div className="comment-write">
                   <Tabs activeKey={this.state.key} id="comment-form-tabs" onSelect={this.handleSelect} animation={false}>
                     <Tab eventKey={1} title="Write">
-                      <textarea className="comment-form-comment form-control" id="comment-form-comment" name="comment" required placeholder="Write comments here..." value={this.state.comment} onChange={this.updateState} >
-                      </textarea>
+                      <Editor ref="editor"
+                        value={this.state.comment}
+                        isGfmMode={this.state.isMarkdown}
+                        editorOptions={this.props.editorOptions}
+                        lineNumbers={false}
+                        isMobile={this.props.crowi.isMobile}
+                        isUploadable={this.state.isUploadable}
+                        isUploadableFile={this.state.isUploadableFile}
+                        emojiStrategy={emojiStrategy}
+                        onChange={this.updateState}
+                        onUpload={this.onUpload}
+                      />
                     </Tab>
                     { this.state.isMarkdown == true &&
                     <Tab eventKey={2} title="Preview">
@@ -156,21 +233,19 @@ export default class CommentForm extends React.Component {
                     }
                   </Tabs>
                 </div>
-                <div className="comment-submit">
-                  <div className="pull-left">
+                <div className="comment-submit d-flex">
                   { this.state.key == 1 &&
                     <label>
-                      <input type="checkbox" id="comment-form-is-markdown" name="isMarkdown" checked={this.state.isMarkdown} value="1" onChange={this.updateState} /> Markdown
+                      <input type="checkbox" id="comment-form-is-markdown" name="isMarkdown" checked={this.state.isMarkdown} value="1" onChange={this.updateStateCheckbox} /> Markdown
                     </label>
                   }
-                  </div>
-                  <div className="pull-right">
-                    <Button type="submit" value="Submit" bsStyle="primary" className="fcbtn btn btn-sm btn-primary btn-outline btn-rounded btn-1b">
-                        Comment
-                    </Button>
-                  </div>
-                  <div className="clearfix">
-                  </div>
+                  <div style={{flex: 1}}></div>{/* spacer */}
+                  { this.state.errorMessage &&
+                    <span className="text-danger text-right mr-2">{this.state.errorMessage}</span>
+                  }
+                  <Button type="submit" value="Submit" bsStyle="primary" className="fcbtn btn btn-sm btn-primary btn-outline btn-rounded btn-1b">
+                    Comment
+                  </Button>
                 </div>
               </div>
             </div>
@@ -183,8 +258,13 @@ export default class CommentForm extends React.Component {
 
 CommentForm.propTypes = {
   crowi: PropTypes.object.isRequired,
+  crowiOriginRenderer: PropTypes.object.isRequired,
   onPostComplete: PropTypes.func,
   pageId: PropTypes.string,
   revisionId: PropTypes.string,
-  crowiRenderer:  PropTypes.object.isRequired,
+  pagePath: PropTypes.string,
+  editorOptions: PropTypes.object,
+};
+CommentForm.defaultProps = {
+  editorOptions: {},
 };

+ 2 - 2
resource/js/components/PageEditor.js

@@ -325,7 +325,7 @@ export default class PageEditor extends React.Component {
     this.setState({ markdown: value });
 
     // render html
-    var context = {
+    const context = {
       markdown: this.state.markdown,
       dom: this.previewElement,
       currentPagePath: decodeURIComponent(location.pathname)
@@ -340,7 +340,7 @@ export default class PageEditor extends React.Component {
       })
       .then(() => interceptorManager.process('postPreProcess', context))
       .then(() => {
-        var parsedHTML = growiRenderer.process(context.markdown);
+        const parsedHTML = growiRenderer.process(context.markdown);
         context['parsedHTML'] = parsedHTML;
       })
       .then(() => interceptorManager.process('prePostProcess', context))

+ 17 - 0
resource/js/components/PageEditor/AbstractEditor.js

@@ -21,6 +21,19 @@ export default class AbstractEditor extends React.Component {
   forceToFocus() {
   }
 
+  /**
+   * set new value
+   */
+  setValue(newValue) {
+  }
+
+  /**
+   * Enable/Disable GFM mode
+   * @param {bool} bool
+   */
+  setGfmMode(bool) {
+  }
+
   /**
    * set caret position of codemirror
    * @param {string} number
@@ -100,6 +113,7 @@ export default class AbstractEditor extends React.Component {
 
 AbstractEditor.propTypes = {
   value: PropTypes.string,
+  ifGfmMode: PropTypes.bool,
   editorOptions: PropTypes.object,
   onChange: PropTypes.func,
   onScroll: PropTypes.func,
@@ -108,4 +122,7 @@ AbstractEditor.propTypes = {
   onPasteFiles: PropTypes.func,
   onDragEnter: PropTypes.func,
 };
+AbstractEditor.defaultProps = {
+  isGfmMode: true,
+};
 

+ 47 - 13
resource/js/components/PageEditor/CodeMirrorEditor.js

@@ -10,7 +10,6 @@ const loadCssSync = require('load-css-file');
 import * as codemirror from 'codemirror';
 
 import { UnControlled as ReactCodeMirror } from 'react-codemirror2';
-require('codemirror/addon/display/autorefresh');
 require('codemirror/addon/edit/matchbrackets');
 require('codemirror/addon/edit/matchtags');
 require('codemirror/addon/edit/closetag');
@@ -27,6 +26,7 @@ require('codemirror/addon/fold/foldgutter.css');
 require('codemirror/addon/fold/markdown-fold');
 require('codemirror/addon/fold/brace-fold');
 require('codemirror/mode/gfm/gfm');
+require('../../util/codemirror/autorefresh.ext');
 
 import pasteHelper from './PasteHelper';
 import EmojiAutoCompleteHelper from './EmojiAutoCompleteHelper';
@@ -45,6 +45,7 @@ export default class CodeMirrorEditor extends AbstractEditor {
 
     this.state = {
       value: this.props.value,
+      isGfmMode: this.props.isGfmMode,
       isEnabledEmojiAutoComplete: false,
       isLoadingKeymap: false,
       additionalClass: '',
@@ -129,6 +130,26 @@ export default class CodeMirrorEditor extends AbstractEditor {
     }, 100);
   }
 
+  /**
+   * @inheritDoc
+   */
+  setValue(newValue) {
+    this.setState({ value: newValue });
+    this.getCodeMirror().getDoc().setValue(newValue);
+  }
+
+  /**
+   * @inheritDoc
+   */
+  setGfmMode(bool) {
+    this.setState({
+      isGfmMode: bool,
+      isEnabledEmojiAutoComplete: bool,
+    });
+    const mode = bool ? 'gfm' : undefined;
+    this.getCodeMirror().setOption('mode', mode);
+  }
+
   /**
    * @inheritDoc
    */
@@ -154,7 +175,7 @@ export default class CodeMirrorEditor extends AbstractEditor {
 
     const editor = this.getCodeMirror();
     // get top position of the line
-    var top = editor.charCoords({line, ch: 0}, 'local').top;
+    const top = editor.charCoords({line, ch: 0}, 'local').top;
     editor.scrollTo(null, top);
   }
 
@@ -322,7 +343,12 @@ export default class CodeMirrorEditor extends AbstractEditor {
    * handle ENTER key
    */
   handleEnterKey() {
-    var context = {
+    if (!this.state.isGfmMode) {
+      codemirror.commands.newlineAndIndent(this.getCodeMirror());
+      return;
+    }
+
+    const context = {
       handlers: [],  // list of handlers which process enter key
       editor: this,
     };
@@ -396,8 +422,13 @@ export default class CodeMirrorEditor extends AbstractEditor {
   }
 
   render() {
-    const theme = this.props.editorOptions.theme || 'elegant';
-    const styleActiveLine = this.props.editorOptions.styleActiveLine || undefined;
+    const mode = this.state.isGfmMode ? 'gfm' : undefined;
+    const defaultEditorOptions = {
+      theme: 'elegant',
+      lineNumbers: true,
+    };
+    const editorOptions = Object.assign(defaultEditorOptions, this.props.editorOptions || {});
+
     return <React.Fragment>
       <ReactCodeMirror
         ref="cm"
@@ -409,20 +440,20 @@ export default class CodeMirrorEditor extends AbstractEditor {
         }}
         value={this.state.value}
         options={{
-          mode: 'gfm',
-          theme: theme,
-          styleActiveLine: styleActiveLine,
-          lineNumbers: true,
+          mode: mode,
+          theme: editorOptions.theme,
+          styleActiveLine: editorOptions.styleActiveLine,
+          lineNumbers: this.props.lineNumbers,
           tabSize: 4,
           indentUnit: 4,
           lineWrapping: true,
-          autoRefresh: true,
+          autoRefresh: {force: true},   // force option is enabled by autorefresh.ext.js -- Yuki Takei
           autoCloseTags: true,
           matchBrackets: true,
           matchTags: {bothTags: true},
           // folding
-          foldGutter: true,
-          gutters: ['CodeMirror-linenumbers', 'CodeMirror-foldgutter'],
+          foldGutter: this.props.lineNumbers,
+          gutters: this.props.lineNumbers ? ['CodeMirror-linenumbers', 'CodeMirror-foldgutter'] : [],
           // match-highlighter, matchesonscrollbar, annotatescrollbar options
           highlightSelectionMatches: {annotateScrollbar: true},
           // markdown mode options
@@ -470,5 +501,8 @@ export default class CodeMirrorEditor extends AbstractEditor {
 
 CodeMirrorEditor.propTypes = Object.assign({
   emojiStrategy: PropTypes.object,
+  lineNumbers: PropTypes.bool,
 }, AbstractEditor.propTypes);
-
+CodeMirrorEditor.defaultProps = {
+  lineNumbers: true,
+};

+ 14 - 0
resource/js/components/PageEditor/Editor.js

@@ -50,6 +50,20 @@ export default class Editor extends AbstractEditor {
     this.getEditorSubstance().forceToFocus();
   }
 
+  /**
+   * @inheritDoc
+   */
+  setValue(newValue) {
+    this.getEditorSubstance().setValue(newValue);
+  }
+
+  /**
+   * @inheritDoc
+   */
+  setGfmMode(bool) {
+    this.getEditorSubstance().setGfmMode(bool);
+  }
+
   /**
    * @inheritDoc
    */

+ 27 - 4
resource/js/components/PageEditor/TextAreaEditor.js

@@ -20,6 +20,7 @@ export default class TextAreaEditor extends AbstractEditor {
 
     this.state = {
       value: this.props.value,
+      isGfmMode: this.props.isGfmMode,
     };
 
     this.init();
@@ -57,6 +58,23 @@ export default class TextAreaEditor extends AbstractEditor {
     }, 150);
   }
 
+  /**
+   * @inheritDoc
+   */
+  setValue(newValue) {
+    this.setState({ value: newValue });
+    this.textarea.value = newValue;
+  }
+
+  /**
+   * @inheritDoc
+   */
+  setGfmMode(bool) {
+    this.setState({
+      isGfmMode: bool,
+    });
+  }
+
   /**
    * @inheritDoc
    */
@@ -165,16 +183,20 @@ export default class TextAreaEditor extends AbstractEditor {
         return;
       }
 
-      event.preventDefault();
-      this.handleEnterKey();
+      this.handleEnterKey(event);
     }
   }
 
   /**
    * handle ENTER key
+   * @param {string} event
    */
-  handleEnterKey() {
-    var context = {
+  handleEnterKey(event) {
+    if (!this.state.isGfmMode) {
+      return; // do nothing
+    }
+
+    const context = {
       handlers: [],  // list of handlers which process enter key
       editor: this,
     };
@@ -182,6 +204,7 @@ export default class TextAreaEditor extends AbstractEditor {
     const interceptorManager = this.interceptorManager;
     interceptorManager.process('preHandleEnter', context)
       .then(() => {
+        event.preventDefault();
         if (context.handlers.length == 0) {
           mlu.newlineAndIndentContinueMarkdownList(this);
         }

+ 1 - 1
resource/js/components/PageList/Page.js

@@ -20,7 +20,7 @@ export default class Page extends React.Component {
 
     return (
       <li className="page-list-li d-flex align-items-center">
-        <UserPicture user={page.revision.author} />
+        <UserPicture user={page.lastUpdateUser} />
         <a className="page-list-link" href={link}>
           <PagePath page={page} excludePathString={this.props.excludePathString} />
         </a>

+ 1 - 1
resource/js/components/SearchTypeahead.js

@@ -106,7 +106,7 @@ export default class SearchTypeahead extends React.Component {
     const page = option;
     return (
       <span>
-      <UserPicture user={page.revision.author} size="sm" />
+      <UserPicture user={page.lastUpdateUser} size="sm" />
       <PagePath page={page} />
       <PageListMeta page={page} />
       </span>

+ 2 - 0
resource/js/ie11-polyfill.js

@@ -0,0 +1,2 @@
+import 'nodelist-foreach-polyfill';
+import 'babel-polyfill';

+ 0 - 1
resource/js/legacy/crowi-form.js

@@ -2,7 +2,6 @@ var pageId = $('#content-main').data('page-id');
 var pagePath= $('#content-main').data('path');
 
 require('bootstrap-select');
-require('bootstrap-sass');
 
 // show/hide
 function FetchPagesUpdatePostAndInsert(path) {

+ 0 - 1
resource/js/legacy/crowi.js

@@ -16,7 +16,6 @@ import Page from '../components/Page';
 const io = require('socket.io-client');
 const entities = require('entities');
 const escapeStringRegexp = require('escape-string-regexp');
-require('bootstrap-sass');
 require('jquery.cookie');
 
 require('./thirdparty-js/agile-admin');

+ 14 - 3
resource/js/util/GrowiRenderer.js

@@ -106,11 +106,22 @@ export default class GrowiRenderer {
 
   /**
    * setup with crowi config
-   * @param {any} config crowi config
    */
-  setup(config) {
+  setup() {
+    const crowiConfig = this.crowi.config;
+
+    let isEnabledLinebreaks = undefined;
+    switch (this.options.mode) {
+      case 'comment':
+        isEnabledLinebreaks = crowiConfig.isEnabledLinebreaksInComments;
+        break;
+      default:
+        isEnabledLinebreaks = crowiConfig.isEnabledLinebreaks;
+        break;
+    }
+
     this.md.set({
-      breaks: config.isEnabledLineBreaks,
+      breaks: isEnabledLinebreaks,
     });
 
     if (!this.isMarkdownItConfigured) {

+ 51 - 0
resource/js/util/codemirror/autorefresh.ext.js

@@ -0,0 +1,51 @@
+/**
+ * extends codemirror/addon/display/autorefresh
+ *
+ * @author Yuki Takei <yuki@weseek.co.jp>
+ * @see https://codemirror.net/addon/display/autorefresh.js
+ * @see https://github.com/scniro/react-codemirror2/issues/83#issuecomment-398825212
+ */
+/* eslint-disable */
+
+// CodeMirror, copyright (c) by Marijn Haverbeke and others
+// Distributed under an MIT license: http://codemirror.net/LICENSE
+
+(function(mod) {
+  mod(require("codemirror"));
+})(function(CodeMirror) {
+  "use strict"
+
+  CodeMirror.defineOption("autoRefresh", false, function(cm, val) {
+    if (cm.state.autoRefresh) {
+      stopListening(cm, cm.state.autoRefresh)
+      cm.state.autoRefresh = null
+    }
+    if (val && (val.force || cm.display.wrapper.offsetHeight == 0))
+      startListening(cm, cm.state.autoRefresh = {delay: val.delay || 250})
+  })
+
+  function startListening(cm, state) {
+    function check() {
+      if (cm.display.wrapper.offsetHeight) {
+        stopListening(cm, state)
+        if (cm.display.lastWrapHeight != cm.display.wrapper.clientHeight)
+          cm.refresh()
+      } else {
+        state.timeout = setTimeout(check, state.delay)
+      }
+    }
+    state.timeout = setTimeout(check, state.delay)
+    state.hurry = function() {
+      clearTimeout(state.timeout)
+      state.timeout = setTimeout(check, 50)
+    }
+    CodeMirror.on(window, "mouseup", state.hurry)
+    CodeMirror.on(window, "keyup", state.hurry)
+  }
+
+  function stopListening(_cm, state) {
+    clearTimeout(state.timeout)
+    CodeMirror.off(window, "mouseup", state.hurry)
+    CodeMirror.off(window, "keyup", state.hurry)
+  }
+});

+ 51 - 3
resource/styles/scss/_login.scss

@@ -98,8 +98,26 @@
     }
   }
 
+  .collapse-oauth {
+    overflow: hidden;
+    &:not(.in) {
+      height: 0;
+      padding: 0 !important;
+    }
+
+    form {
+      flex: 1;
+      @media(min-width: 350px) {
+        flex: 0.49;
+      }
+    }
+    .spacer {
+      height: 10px;
+    }
+  }
+
   // button style
-  .btn-login.fcbtn, .btn-register.fcbtn, .btn-login-google.fcbtn {
+  .btn-login.fcbtn, .btn-register.fcbtn, .btn-login-oauth.fcbtn, .btn-collapse-oauth {
     border: none;
     color: white;
     background-color: rgba(lighten(black, 20%), 0.4);
@@ -112,6 +130,12 @@
       border: none;
     }
   }
+  .btn-login-oauth {
+    flex: 1;
+    .btn-label-text {
+      flex: 1;
+    }
+  }
   .btn-login.fcbtn {
     .btn-label {
       background-color: rgba($brand-danger, 0.4);
@@ -120,9 +144,33 @@
       background-color: #7e4153;
     }
   }
-  .btn-login-google.fcbtn {
+  .btn-login-oauth.fcbtn#google {
+    .btn-label {
+      background: rgba(#f13d25, 0.4);
+    }
+    &:after {
+      background-color: #555;
+    }
+  }
+  .btn-login-oauth.fcbtn#github {
+    .btn-label {
+      background-color: rgba(#24292e, 0.4);
+    }
+    &:after {
+      background-color: #555;
+    }
+  }
+  .btn-login-oauth.fcbtn#facebook {
+    .btn-label {
+      background-color: rgba(#29487d, 0.4);
+    }
+    &:after {
+      background-color: #555;
+    }
+  }
+  .btn-login-oauth.fcbtn#twitter {
     .btn-label {
-      background-color: rgba(#444, 0.4);
+      background-color: rgba(#1da1f2, 0.4);
     }
     &:after {
       background-color: #555;

Разница между файлами не показана из-за своего большого размера
+ 494 - 125
yarn.lock


Некоторые файлы не были показаны из-за большого количества измененных файлов