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

Merge pull request #1141 from weseek/master

release v3.5.5
Yuki Takei 6 лет назад
Родитель
Сommit
a7fce73a86
48 измененных файлов с 671 добавлено и 387 удалено
  1. 17 1
      CHANGES.md
  2. 9 6
      README.md
  3. 8 8
      package.json
  4. 15 5
      resource/locales/en-US/translation.json
  5. 10 0
      resource/locales/ja/translation.json
  6. 2 0
      src/client/js/app.jsx
  7. 2 2
      src/client/js/components/PageComments.jsx
  8. 3 3
      src/client/js/components/StaffCredit/StaffCredit.jsx
  9. 119 0
      src/client/js/components/TableOfContents.jsx
  10. 0 65
      src/client/js/legacy/crowi.js
  11. 7 0
      src/client/js/services/PageContainer.js
  12. 2 2
      src/client/js/util/GrowiRenderer.js
  13. 4 4
      src/client/js/util/markdown-it/toc-and-anchor.js
  14. 10 8
      src/client/styles/scss/_login.scss
  15. 1 1
      src/server/crowi/index.js
  16. 1 0
      src/server/form/admin/aws.js
  17. 0 4
      src/server/form/admin/securityGeneral.js
  18. 1 2
      src/server/form/admin/securityPassportBasic.js
  19. 11 0
      src/server/form/admin/securityPassportLocal.js
  20. 1 0
      src/server/form/index.js
  21. 2 0
      src/server/models/config.js
  22. 19 14
      src/server/models/page.js
  23. 8 11
      src/server/models/user.js
  24. 49 15
      src/server/routes/admin.js
  25. 1 0
      src/server/routes/index.js
  26. 7 4
      src/server/routes/login-passport.js
  27. 1 4
      src/server/routes/login.js
  28. 12 0
      src/server/service/config-loader.js
  29. 27 20
      src/server/service/config-manager.js
  30. 13 8
      src/server/service/file-uploader/aws.js
  31. 3 1
      src/server/service/file-uploader/uploader.js
  32. 12 6
      src/server/service/passport.js
  33. 1 4
      src/server/util/i18nUserSettingDetector.js
  34. 1 4
      src/server/util/middlewares.js
  35. 4 11
      src/server/util/swigFunctions.js
  36. 13 0
      src/server/views/admin/app.html
  37. 1 1
      src/server/views/admin/external-accounts.html
  38. 22 36
      src/server/views/admin/security.html
  39. 23 12
      src/server/views/admin/widget/passport/basic.html
  40. 16 14
      src/server/views/admin/widget/passport/github.html
  41. 15 14
      src/server/views/admin/widget/passport/google-oauth.html
  42. 84 0
      src/server/views/admin/widget/passport/local.html
  43. 15 1
      src/server/views/admin/widget/passport/oidc.html
  44. 1 1
      src/server/views/admin/widget/passport/saml.html
  45. 14 16
      src/server/views/admin/widget/passport/twitter.html
  46. 33 17
      src/server/views/login.html
  47. 1 1
      src/test/setup.js
  48. 50 61
      yarn.lock

+ 17 - 1
CHANGES.md

@@ -1,8 +1,24 @@
 # CHANGES
 
-## 3.5.4-RC
+## 3.5.5-RC
+
+* Feature: Support S3-compatible object storage (e.g. MinIO)
+* Feature: Enable/Disable ID/Password Authentication
+* Improvement: Login Mechanism with HTTP Basic Authentication header
+* Improvement: Reactify Table Of Contents
+* Fix: Profile images are broken in User Management
+* Fix: Template page under root page doesn't work
+* Support: Upgrade libs
+    * csv-to-markdown-table
+    * express-validator
+    * markdown-it
+    * mini-css-extract-plugin
+    * react-hotkeys
+
+## 3.5.4
 
 * Fix: List private pages wrongly
+* Fix: Global Notification Trigger Path does not parse glob correctly
 * Fix: Consecutive page deletion requests cause unexpected complete page deletion
 
 ## 3.5.3

+ 9 - 6
README.md

@@ -185,13 +185,10 @@ Environment Variables
     * BLOCKDIAG_URI: URI to connect to [blockdiag](http://http://blockdiag.com/) server.
 * **Option (Overwritable in admin page)**
     * APP_SITE_URL: Site URL. e.g. `https://example.com`, `https://example.com:8080`
-    * OAUTH_GOOGLE_CLIENT_ID: Google API client id for OAuth login.
-    * OAUTH_GOOGLE_CLIENT_SECRET: Google API client secret for OAuth login.
-    * OAUTH_GITHUB_CLIENT_ID: GitHub API client id for OAuth login.
-    * OAUTH_GITHUB_CLIENT_SECRET: GitHub API client secret for OAuth login.
-    * OAUTH_TWITTER_CONSUMER_KEY: Twitter consumer key(API key) for OAuth login.
-    * OAUTH_TWITTER_CONSUMER_SECRET: Twitter consumer secret(API secret) for OAuth login.
+    * LOCAL_STRATEGY_ENABLED: Enable or disable ID/Pass login
+    * LOCAL_STRATEGY_USES_ONLY_ENV_VARS_FOR_SOME_OPTIONS: Prioritize env vars than values in DB for some ID/Pass login options
     * SAML_ENABLED: Enable or disable SAML
+    * SAML_USES_ONLY_ENV_VARS_FOR_SOME_OPTIONS: Prioritize env vars than values in DB for some SAML options
     * SAML_ENTRY_POINT: IdP entry point
     * SAML_ISSUER: Issuer string to supply to IdP
     * SAML_ATTR_MAPPING_ID: Attribute map for id
@@ -200,6 +197,12 @@ Environment Variables
     * SAML_ATTR_MAPPING_FIRST_NAME: Attribute map for first name
     * SAML_ATTR_MAPPING_LAST_NAME:  Attribute map for last name
     * SAML_CERT: PEM-encoded X.509 signing certificate string to validate the response from IdP
+    * OAUTH_GOOGLE_CLIENT_ID: Google API client id for OAuth login.
+    * OAUTH_GOOGLE_CLIENT_SECRET: Google API client secret for OAuth login.
+    * OAUTH_GITHUB_CLIENT_ID: GitHub API client id for OAuth login.
+    * OAUTH_GITHUB_CLIENT_SECRET: GitHub API client secret for OAuth login.
+    * OAUTH_TWITTER_CONSUMER_KEY: Twitter consumer key(API key) for OAuth login.
+    * OAUTH_TWITTER_CONSUMER_SECRET: Twitter consumer secret(API secret) for OAuth login.
 
 
 Documentation

+ 8 - 8
package.json

@@ -1,6 +1,6 @@
 {
   "name": "growi",
-  "version": "3.5.4-RC",
+  "version": "3.5.5-RC",
   "description": "Team collaboration software using markdown",
   "tags": [
     "wiki",
@@ -62,12 +62,12 @@
   "dependencies": {
     "//": [
       "check-node-version: see https://github.com/parshap/check-node-version/issues/35",
+      "entities: markdown-it@9.0.1 depends on entities@~1.1.1",
       "mongoose: somehow GlobalNotificationSetting CRUD does not work with mongoose v5.6.0"
     ],
     "async": "^3.0.1",
     "aws-sdk": "^2.88.0",
     "axios": "^0.19.0",
-    "basic-auth-connect": "~1.0.0",
     "body-parser": "^1.18.2",
     "bunyan": "^1.8.12",
     "bunyan-format": "^0.2.1",
@@ -89,7 +89,7 @@
     "express-form": "~0.12.0",
     "express-sanitizer": "^1.0.4",
     "express-session": "^1.16.1",
-    "express-validator": "^5.3.1",
+    "express-validator": "^6.1.1",
     "express-webpack-assets": "^0.1.0",
     "graceful-fs": "^4.1.11",
     "growi-commons": "^4.0.3",
@@ -152,9 +152,9 @@
     "colors": "^1.2.5",
     "commander": "^2.11.0",
     "connect-browser-sync": "^2.1.0",
-    "core-js": "^2.6.9",
+    "core-js": "=2.6.9",
     "css-loader": "^3.0.0",
-    "csv-to-markdown-table": "^0.5.0",
+    "csv-to-markdown-table": "^1.0.1",
     "date-fns": "^1.29.0",
     "diff2html": "^2.3.3",
     "eazy-logger": "^3.0.2",
@@ -174,7 +174,7 @@
     "jquery.cookie": "~1.4.1",
     "load-css-file": "^1.0.0",
     "lodash-webpack-plugin": "^0.11.5",
-    "markdown-it": "^8.4.0",
+    "markdown-it": "^9.0.1",
     "markdown-it-blockdiag": "^1.0.2",
     "markdown-it-emoji": "^1.4.0",
     "markdown-it-footnote": "^3.0.1",
@@ -185,7 +185,7 @@
     "markdown-it-toc-and-anchor-with-slugid": "^1.1.4",
     "markdown-table": "^1.1.1",
     "metismenu": "^3.0.3",
-    "mini-css-extract-plugin": "^0.7.0",
+    "mini-css-extract-plugin": "^0.8.0",
     "morgan": "^1.9.0",
     "node-dev": "^4.0.0",
     "node-sass": "^4.11.0",
@@ -205,7 +205,7 @@
     "react-dom": "^16.8.3",
     "react-dropzone": "^10.1.3",
     "react-frame-component": "^4.0.0",
-    "react-hotkeys": "^1.1.4",
+    "react-hotkeys": "^2.0.0",
     "react-i18next": "^10.6.1",
     "react-waypoint": "^9.0.0",
     "replacestream": "^4.0.3",

+ 15 - 5
resource/locales/en-US/translation.json

@@ -427,6 +427,8 @@
     "change_setting": "Caution:if you change this setting not completed, you will not be able to access files you have uploaded so far.",
     "region": "Region",
     "bucket name": "Bucket name",
+    "custom endpoint": "Custom endpoint",
+    "custom_endpoint_change": "Input the URL of the endpoint of an object storage service like MinIO that has a S3-compatible API.  Amazon S3 is used if empty.",
     "Plugin settings": "Plugin settings",
     "Enable plugin loading": "Enable plugin loading",
     "Load plugins": "Load plugins",
@@ -474,7 +476,7 @@
       "readonly": "Accept (Guests can read only)"
     },
     "registration_mode": {
-      "open": "Open (Anyone can registre)",
+      "open": "Open (Anyone can register)",
       "restricted": "Restricted (Requires approval by administrators)",
       "closed": "Closed (Invitation Only)"
     },
@@ -487,6 +489,9 @@
     "Use env var if empty": "Use env var <code>%s</code> if empty",
     "Use default if both are empty": "If both ​​are empty, the default value <code>%s</code> is used.",
     "missing mandatory configs": "The following mandatory items are not set in either database nor environment variables.",
+    "Local": {
+      "name": "ID/Password"
+    },
     "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",
@@ -527,6 +532,11 @@
       "Use env var if empty": "If the value in the database is empty, the value of the environment variable <code>%s</code> is used.",
       "note for the only env option": "The setting item that enables or disables the SAML authentication and the highlighted setting items use only the value of environment variables.<br>To change this setting, please change to false or delete the value of the environment variable <code>%s</code> ."
     },
+    "Basic": {
+      "name": "Basic Authentication",
+      "desc_1": "Login with <code>username</code> in Authorization header.",
+      "desc_2": "User will be automatically generated if not exist."
+    },
     "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\".",
@@ -535,7 +545,7 @@
         "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_4": "Register your OAuth App with one of Authorized redirect URIs as <code>%s</code>",
         "register_5": "Copy and paste your ClientID and Client Secret above"
       },
       "Facebook": {
@@ -546,13 +556,13 @@
         "register_1": "Access <a href=\"%s\" target=\"_blank\">%s</a>",
         "register_2": "Sign in Twitter",
         "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_4": "Register your OAuth App with one of Authorized redirect URIs as <code>%s</code>",
         "register_5": "Copy and paste your ClientID and Client Secret above"
       },
       "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_2": "Register your OAuth App with \"Authorization callback URL\" as <code>%s</code>",
         "register_3": "Copy and paste your ClientID and Client Secret above"
       },
       "OIDC": {
@@ -562,7 +572,7 @@
         "name_detail": "Specification of mappings for <code>name</code> when creating new users",
         "mapping_detail": "Specification of mappings for %s when creating new users",
         "register_1": "Contant to OIDC IdP Administrator",
-        "register_2": "Register your OIDC App with \"Authorization callback URL\" as <code>%s</code> (where <code>%s</code> is your hostname)",
+        "register_2": "Register your OIDC App with \"Authorization callback URL\" as <code>%s</code>",
         "register_3": "Copy and paste your ClientID and Client Secret above"
       },
       "how_to": {

+ 10 - 0
resource/locales/ja/translation.json

@@ -425,6 +425,8 @@
     "change_setting": "この設定を途中で変更すると、これまでにアップロードしたファイル等へのアクセスができなくなりますのでご注意下さい。",
     "region": "リージョン",
     "bucket name": "バケット名",
+    "custom endpoint": "カスタムエンドポイント",
+    "custom_endpoint_change": "MinIOなど、S3互換APIを持つ他のオブジェクトストレージサービスを使用する場合のみ、そのエンドポイントのURLを入力してください。空欄の場合は、Amazon S3を使用します。",
     "Plugin settings": "プラグイン設定",
     "Enable plugin loading": "プラグインの読み込みを有効にします。",
     "Load plugins": "プラグインを読み込む",
@@ -481,6 +483,9 @@
     "Use env var if empty": "空の場合、環境変数 <code>%s</code> を利用します",
     "Use default if both are empty": "どちらの値も空の場合、デフォルト値 <code>%s</code> を利用します",
     "missing mandatory configs": "以下の必須項目の値がデータベースと環境変数のどちらにも設定されていません",
+    "Local": {
+      "name": "ID/Password"
+    },
     "ldap": {
       "server_url_detail": "LDAP URLを <code>ldap://host:port/DN</code> または <code>ldaps://host:port/DN</code> の形式で入力してください。",
       "bind_mode": "Bind モード",
@@ -521,6 +526,11 @@
       "Use env var if empty": "データベース側の値が空の場合、環境変数 <code>%s</code> の値を利用します",
       "note for the only env option": "現在SAML認証のON/OFFの設定値及びハイライトされている設定値は環境変数の値のみを使用するようになっています<br>この設定を変更する場合は環境変数 <code>%s</code> の値をfalseに変更もしくは削除してください"
     },
+    "Basic": {
+      "name": "Basic 認証",
+      "desc_1": "Authorization ヘッダに格納されている <code>username</code> でログインします。",
+      "desc_2": "ユーザーが存在しなかった場合は自動生成します。"
+    },
     "OAuth": {
       "register": "%sに登録",
       "change_redirect_url": "承認済みのリダイレクトURLに、 <code>%s</code> を入力",

+ 2 - 0
src/client/js/app.jsx

@@ -31,6 +31,7 @@ import RecentCreated from './components/RecentCreated/RecentCreated';
 import StaffCredit from './components/StaffCredit/StaffCredit';
 import MyDraftList from './components/MyDraftList/MyDraftList';
 import UserPictureList from './components/User/UserPictureList';
+import TableOfContents from './components/TableOfContents';
 
 import CustomCssEditor from './components/Admin/CustomCssEditor';
 import CustomScriptEditor from './components/Admin/CustomScriptEditor';
@@ -106,6 +107,7 @@ if (pageContainer.state.pageId != null) {
     'page-comments-list': <PageComments />,
     'page-attachment':  <PageAttachment />,
     'page-comment-write':  <CommentEditorLazyRenderer />,
+    'revision-toc': <TableOfContents />,
     'like-button': <LikeButton pageId={pageContainer.state.pageId} isLiked={pageContainer.state.isLiked} />,
     'seen-user-list': <UserPictureList userIds={pageContainer.state.seenUserIds} />,
     'liker-list': <UserPictureList userIds={pageContainer.state.likerUserIds} />,

+ 2 - 2
src/client/js/components/PageComments.jsx

@@ -7,13 +7,13 @@ import { withTranslation } from 'react-i18next';
 
 import AppContainer from '../services/AppContainer';
 import CommentContainer from '../services/CommentContainer';
+import PageContainer from '../services/PageContainer';
 
 import { createSubscribedElement } from './UnstatedUtils';
-import CommentEditor from './PageComment/CommentEditor';
 
+import CommentEditor from './PageComment/CommentEditor';
 import Comment from './PageComment/Comment';
 import DeleteCommentModal from './PageComment/DeleteCommentModal';
-import PageContainer from '../services/PageContainer';
 
 
 /**

+ 3 - 3
src/client/js/components/StaffCredit/StaffCredit.jsx

@@ -1,5 +1,5 @@
 import React from 'react';
-import { HotKeys } from 'react-hotkeys';
+import { GlobalHotKeys } from 'react-hotkeys';
 
 import loggerFactory from '@alias/logger';
 
@@ -112,9 +112,9 @@ export default class StaffCredit extends React.Component {
     const keyMap = { check: ['up', 'down', 'right', 'left', 'b', 'a'] };
     const handlers = { check: (event) => { return this.check(event) } };
     return (
-      <HotKeys focused attach={window} keyMap={keyMap} handlers={handlers}>
+      <GlobalHotKeys keyMap={keyMap} handlers={handlers}>
         {this.renderContributors()}
-      </HotKeys>
+      </GlobalHotKeys>
     );
   }
 

+ 119 - 0
src/client/js/components/TableOfContents.jsx

@@ -0,0 +1,119 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import { withTranslation } from 'react-i18next';
+
+import { debounce } from 'throttle-debounce';
+
+import AppContainer from '../services/AppContainer';
+import PageContainer from '../services/PageContainer';
+
+import { createSubscribedElement } from './UnstatedUtils';
+
+// get these value with
+//   document.querySelector('.revision-toc').getBoundingClientRect().top
+const DEFAULT_REVISION_TOC_TOP_FOR_GROWI_LAYOUT = 190;
+const DEFAULT_REVISION_TOC_TOP_FOR_KIBELA_LAYOUT = 105;
+
+/**
+ * @author Yuki Takei <yuki@weseek.co.jp>
+ *
+ * @export
+ * @class TableOfContents
+ * @extends {React.Component}
+ */
+class TableOfContents extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    this.resetScrollbarDebounced = debounce(100, this.resetScrollbar);
+  }
+
+  componentDidUpdate() {
+    const { layoutType } = this.props.appContainer.config;
+    if (layoutType === 'crowi') {
+      return;
+    }
+
+    let defaultRevisionTocTop = DEFAULT_REVISION_TOC_TOP_FOR_GROWI_LAYOUT;
+    if (layoutType === 'kibela') {
+      defaultRevisionTocTop = DEFAULT_REVISION_TOC_TOP_FOR_KIBELA_LAYOUT;
+    }
+
+    // initialize
+    this.resetScrollbar(defaultRevisionTocTop);
+
+    /*
+     * set event listener
+     */
+    // resize
+    window.addEventListener('resize', (event) => {
+      this.resetScrollbarDebounced(defaultRevisionTocTop);
+    });
+    // affix on
+    $('#revision-toc').on('affixed.bs.affix', () => {
+      this.resetScrollbar(this.getCurrentRevisionTocTop());
+    });
+    // affix off
+    $('#revision-toc').on('affixed-top.bs.affix', () => {
+      this.resetScrollbar(defaultRevisionTocTop);
+    });
+  }
+
+  getCurrentRevisionTocTop() {
+    // calculate absolute top of '#revision-toc' element
+    const revisionTocElem = document.querySelector('.revision-toc');
+    return revisionTocElem.getBoundingClientRect().top;
+  }
+
+  resetScrollbar(revisionTocTop) {
+    const tocContentElem = document.querySelector('.revision-toc .markdownIt-TOC');
+
+    // window height - revisionTocTop - .system-version height
+    const viewHeight = window.innerHeight - revisionTocTop - 20;
+
+    const tocContentHeight = tocContentElem.getBoundingClientRect().height + 15; // add margin
+
+    if (viewHeight < tocContentHeight) {
+      $('#revision-toc-content').slimScroll({
+        railVisible: true,
+        position: 'right',
+        height: viewHeight,
+      });
+    }
+    else {
+      $('#revision-toc-content').slimScroll({ destroy: true });
+    }
+  }
+
+  render() {
+    const { tocHtml } = this.props.pageContainer.state;
+
+    return (
+      <div
+        id="revision-toc-content"
+        className="revision-toc-content"
+        // eslint-disable-next-line react/no-danger
+        dangerouslySetInnerHTML={{
+          __html: tocHtml,
+        }}
+      />
+    );
+  }
+
+}
+
+/**
+ * Wrapper component for using unstated
+ */
+const TableOfContentsWrapper = (props) => {
+  return createSubscribedElement(TableOfContents, props, [AppContainer, PageContainer]);
+};
+
+TableOfContents.propTypes = {
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
+};
+
+export default withTranslation()(TableOfContentsWrapper);

+ 0 - 65
src/client/js/legacy/crowi.js

@@ -5,8 +5,6 @@ import ReactDOM from 'react-dom';
 
 import { Provider } from 'unstated';
 
-import { debounce } from 'throttle-debounce';
-
 import { pathUtils } from 'growi-commons';
 
 import GrowiRenderer from '../util/GrowiRenderer';
@@ -26,14 +24,6 @@ if (!window) {
 }
 window.Crowi = Crowi;
 
-/**
- * render Table Of Contents
- * @param {string} tocHtml
- */
-Crowi.renderTocContent = (tocHtml) => {
-  $('#revision-toc-content').html(tocHtml);
-};
-
 /**
  * set 'data-caret-line' attribute that will be processed when 'shown.bs.tab' event fired
  * @param {number} line
@@ -157,60 +147,6 @@ Crowi.initAffix = () => {
   }
 };
 
-Crowi.initSlimScrollForRevisionToc = () => {
-  const revisionTocElem = document.querySelector('.growi .revision-toc');
-  const tocContentElem = document.querySelector('.growi .revision-toc .markdownIt-TOC');
-
-  // growi layout only
-  if (revisionTocElem == null || tocContentElem == null) {
-    return;
-  }
-
-  function getCurrentRevisionTocTop() {
-    // calculate absolute top of '#revision-toc' element
-    return revisionTocElem.getBoundingClientRect().top;
-  }
-
-  function resetScrollbar(revisionTocTop) {
-    // window height - revisionTocTop - .system-version height
-    let h = window.innerHeight - revisionTocTop - 20;
-
-    const tocContentHeight = tocContentElem.getBoundingClientRect().height + 15; // add margin
-
-    h = Math.min(h, tocContentHeight);
-
-    $('#revision-toc-content').slimScroll({
-      railVisible: true,
-      position: 'right',
-      height: h,
-    });
-  }
-
-  const resetScrollbarDebounced = debounce(100, resetScrollbar);
-
-  // initialize
-  const revisionTocTop = getCurrentRevisionTocTop();
-  resetScrollbar(revisionTocTop);
-
-  /*
-   * set event listener
-   */
-  // resize
-  window.addEventListener('resize', (event) => {
-    resetScrollbarDebounced(getCurrentRevisionTocTop());
-  });
-  // affix on
-  $('#revision-toc').on('affixed.bs.affix', () => {
-    resetScrollbar(getCurrentRevisionTocTop());
-  });
-  // affix off
-  $('#revision-toc').on('affixed-top.bs.affix', () => {
-    // calculate sum of height (.navbar-header + .bg-title) + margin-top of .main
-    const sum = 138;
-    resetScrollbar(sum);
-  });
-};
-
 Crowi.initClassesByOS = function() {
   // add classes to cmd-key by OS
   const platform = navigator.platform.toLowerCase();
@@ -769,7 +705,6 @@ window.addEventListener('load', (e) => {
 
   Crowi.highlightSelectedSection(window.location.hash);
   Crowi.modifyScrollTop();
-  Crowi.initSlimScrollForRevisionToc();
   Crowi.initAffix();
   Crowi.initClassesByOS();
 });

+ 7 - 0
src/client/js/services/PageContainer.js

@@ -36,6 +36,7 @@ export default class PageContainer extends Container {
       revisionId,
       revisionCreatedAt: +mainContent.getAttribute('data-page-revision-created'),
       path: mainContent.getAttribute('data-path'),
+      tocHtml: '',
       isLiked: false,
       seenUserIds: [],
       likerUserIds: [],
@@ -55,6 +56,7 @@ export default class PageContainer extends Container {
     this.initStateMarkdown();
     this.initStateOthers();
 
+    this.setTocHtml = this.setTocHtml.bind(this);
     this.save = this.save.bind(this);
     this.addWebSocketEventHandlers = this.addWebSocketEventHandlers.bind(this);
     this.addWebSocketEventHandlers();
@@ -110,6 +112,11 @@ export default class PageContainer extends Container {
     });
   }
 
+  setTocHtml(tocHtml) {
+    if (this.state.tocHtml !== tocHtml) {
+      this.setState({ tocHtml });
+    }
+  }
 
   /**
    * save success handler

+ 2 - 2
src/client/js/util/GrowiRenderer.js

@@ -74,11 +74,11 @@ export default class GrowiRenderer {
     // add configurers according to mode
     switch (mode) {
       case 'page': {
-        const renderToc = appContainer.getCrowiForJquery().renderTocContent;
+        const pageContainer = appContainer.getContainer('PageContainer');
 
         this.markdownItConfigurers = this.markdownItConfigurers.concat([
           new FooternoteConfigurer(appContainer),
-          new TocAndAnchorConfigurer(appContainer, renderToc),
+          new TocAndAnchorConfigurer(appContainer, pageContainer.setTocHtml),
           new HeaderLineNumberConfigurer(appContainer),
           new HeaderWithEditLinkConfigurer(appContainer),
           new TableWithHandsontableButtonConfigurer(appContainer),

+ 4 - 4
src/client/js/util/markdown-it/toc-and-anchor.js

@@ -1,8 +1,8 @@
 export default class TocAndAnchorConfigurer {
 
-  constructor(crowi, renderToc) {
+  constructor(crowi, setHtml) {
     this.crowi = crowi;
-    this.renderToc = renderToc;
+    this.setHtml = setHtml;
   }
 
   configure(md) {
@@ -15,10 +15,10 @@ export default class TocAndAnchorConfigurer {
     });
 
     // set toc render function
-    if (this.renderToc != null) {
+    if (this.setHtml != null) {
       md.set({
         tocCallback: (tocMarkdown, tocArray, tocHtml) => {
-          this.renderToc(tocHtml);
+          this.setHtml(tocHtml);
         },
       });
     }

+ 10 - 8
src/client/styles/scss/_login.scss

@@ -94,13 +94,7 @@
     }
   }
 
-  .collapse-oauth {
-    overflow: hidden;
-    &:not(.in) {
-      height: 0;
-      padding: 0 !important;
-    }
-
+  .external-auth {
     form {
       flex: 1;
       @media (min-width: 350px) {
@@ -112,11 +106,19 @@
     }
   }
 
+  .collapse-external-auth {
+    overflow: hidden;
+    &:not(.in) {
+      height: 0;
+      padding: 0 !important;
+    }
+  }
+
   // button style
   .btn-login.fcbtn,
   .btn-register.fcbtn,
   .btn-login-oauth.fcbtn,
-  .btn-collapse-oauth {
+  .btn-collapse-external-auth {
     color: white;
     background-color: rgba(lighten(black, 20%), 0.4);
     border: none;

+ 1 - 1
src/server/crowi/index.js

@@ -286,8 +286,8 @@ Crowi.prototype.setupPassport = async function() {
   }
   this.passportService.setupSerializer();
   // setup strategies
-  this.passportService.setupLocalStrategy();
   try {
+    this.passportService.setupLocalStrategy();
     this.passportService.setupLdapStrategy();
     this.passportService.setupGoogleStrategy();
     this.passportService.setupGitHubStrategy();

+ 1 - 0
src/server/form/admin/aws.js

@@ -4,6 +4,7 @@ const field = form.field;
 
 module.exports = form(
   field('settingForm[aws:region]', 'リージョン').trim().is(/^[a-z]+-[a-z]+-\d+$/, 'リージョンには、AWSリージョン名を入力してください。 例: ap-northeast-1'),
+  field('settingForm[aws:customEndpoint]', 'カスタムエンドポイント').trim().is(/^(https?:\/\/[^/]+|)$/, 'カスタムエンドポイントは、http(s)://で始まるURLを指定してください。また、末尾の/は不要です。'),
   field('settingForm[aws:bucket]', 'バケット名').trim(),
   field('settingForm[aws:accessKeyId]', 'Access Key Id').trim().is(/^[\da-zA-Z]+$/),
   field('settingForm[aws:secretAccessKey]', 'Secret Access Key').trim(),

+ 0 - 4
src/server/form/admin/securityGeneral.js

@@ -1,13 +1,9 @@
 const form = require('express-form');
 
 const field = form.field;
-const stringToArray = require('../../util/formUtil').stringToArrayFilter;
-const normalizeCRLF = require('../../util/formUtil').normalizeCRLFFilter;
 
 module.exports = form(
   field('settingForm[security:restrictGuestMode]'),
-  field('settingForm[security:registrationMode]').required(),
-  field('settingForm[security:registrationWhiteList]').custom(normalizeCRLF).custom(stringToArray),
   field('settingForm[security:list-policy:hideRestrictedByOwner]').trim().toBooleanStrict(),
   field('settingForm[security:list-policy:hideRestrictedByGroup]').trim().toBooleanStrict(),
   field('settingForm[security:pageCompleteDeletionAuthority]'),

+ 1 - 2
src/server/form/admin/securityPassportBasic.js

@@ -4,6 +4,5 @@ const field = form.field;
 
 module.exports = form(
   field('settingForm[security:passport-basic:isEnabled]').trim().toBooleanStrict().required(),
-  field('settingForm[security:passport-basic:id]').trim(),
-  field('settingForm[security:passport-basic:password]').trim(),
+  field('settingForm[security:passport-basic:isSameUsernameTreatedAsIdenticalUser]').trim().toBooleanStrict(),
 );

+ 11 - 0
src/server/form/admin/securityPassportLocal.js

@@ -0,0 +1,11 @@
+const form = require('express-form');
+
+const field = form.field;
+const stringToArray = require('../../util/formUtil').stringToArrayFilter;
+const normalizeCRLF = require('../../util/formUtil').normalizeCRLFFilter;
+
+module.exports = form(
+  field('settingForm[security:passport-local:isEnabled]').trim().toBooleanStrict().required(),
+  field('settingForm[security:registrationMode]').required(),
+  field('settingForm[security:registrationWhiteList]').custom(normalizeCRLF).custom(stringToArray),
+);

+ 1 - 0
src/server/form/index.js

@@ -19,6 +19,7 @@ module.exports = {
     importerQiita: require('./admin/importerQiita'),
     plugin: require('./admin/plugin'),
     securityGeneral: require('./admin/securityGeneral'),
+    securityPassportLocal: require('./admin/securityPassportLocal'),
     securityPassportLdap: require('./admin/securityPassportLdap'),
     securityPassportSaml: require('./admin/securityPassportSaml'),
     securityPassportBasic: require('./admin/securityPassportBasic'),

+ 2 - 0
src/server/models/config.js

@@ -49,6 +49,7 @@ module.exports = function(crowi) {
       'security:list-policy:hideRestrictedByGroup' : false,
       'security:pageCompleteDeletionAuthority' : undefined,
 
+      'security:passport-local:isEnabled' : true,
       'security:passport-ldap:isEnabled' : false,
       'security:passport-ldap:serverUrl' : undefined,
       'security:passport-ldap:isUserBind' : undefined,
@@ -74,6 +75,7 @@ module.exports = function(crowi) {
       'aws:region'          : 'ap-northeast-1',
       'aws:accessKeyId'     : undefined,
       'aws:secretAccessKey' : undefined,
+      'aws:customEndpoint'  : undefined,
 
       'mail:from'         : undefined,
       'mail:smtpHost'     : undefined,

+ 19 - 14
src/server/models/page.js

@@ -5,12 +5,15 @@
 
 const debug = require('debug')('growi:models:page');
 const nodePath = require('path');
+const urljoin = require('url-join');
 const mongoose = require('mongoose');
 const uniqueValidator = require('mongoose-unique-validator');
 
-const ObjectId = mongoose.Schema.Types.ObjectId;
-const escapeStringRegexp = require('escape-string-regexp');
+const { pathUtils } = require('growi-commons');
 const templateChecker = require('@commons/util/template-checker');
+const escapeStringRegexp = require('escape-string-regexp');
+
+const ObjectId = mongoose.Schema.Types.ObjectId;
 
 /*
  * define schema
@@ -842,17 +845,19 @@ module.exports = function(crowi) {
   /**
    * find all templates applicable to the new page
    */
-  pageSchema.statics.findTemplate = function(path) {
+  pageSchema.statics.findTemplate = async function(path) {
     const templatePath = nodePath.posix.dirname(path);
     const pathList = generatePathsOnTree(path, []);
-    const regexpList = pathList.map((path) => { return new RegExp(`^${escapeStringRegexp(path)}/_{1,2}template$`) });
+    const regexpList = pathList.map((path) => {
+      const pathWithTrailingSlash = pathUtils.addTrailingSlash(path);
+      return new RegExp(`^${escapeStringRegexp(pathWithTrailingSlash)}_{1,2}template$`);
+    });
 
-    return this
-      .find({ path: { $in: regexpList } })
+    const templatePages = await this.find({ path: { $in: regexpList } })
       .populate({ path: 'revision', model: 'Revision' })
-      .then((templates) => {
-        return fetchTemplate(templates, templatePath);
-      });
+      .exec();
+
+    return fetchTemplate(templatePages, templatePath);
   };
 
   const generatePathsOnTree = (path, pathList) => {
@@ -868,11 +873,11 @@ module.exports = function(crowi) {
   };
 
   const assignTemplateByType = (templates, path, type) => {
-    for (let i = 0; i < templates.length; i++) {
-      if (templates[i].path === `${path}/${type}template`) {
-        return templates[i];
-      }
-    }
+    const targetTemplatePath = urljoin(path, `${type}template`);
+
+    return templates.find((template) => {
+      return (template.path === targetTemplatePath);
+    });
   };
 
   const assignDecendantsTemplate = (decendantsTemplates, path) => {

+ 8 - 11
src/server/models/user.js

@@ -438,17 +438,14 @@ module.exports = function(crowi) {
   };
 
   userSchema.statics.findUsersWithPagination = async function(options) {
-    const sort = options.sort || { status: 1, username: 1, createdAt: 1 };
-
-    // eslint-disable-next-line no-return-await
-    return await this.paginate({ status: { $ne: STATUS_DELETED } }, { page: options.page || 1, limit: options.limit || PAGE_ITEMS }, (err, result) => {
-      if (err) {
-        debug('Error on pagination:', err);
-        throw new Error(err);
-      }
-
-      return result;
-    }, { sortBy: sort });
+    const defaultOptions = {
+      sort: { status: 1, username: 1, createdAt: 1 },
+      page: 1,
+      limit: PAGE_ITEMS,
+    };
+    const mergedOptions = Object.assign(defaultOptions, options);
+
+    return this.paginate({ status: { $ne: STATUS_DELETED } }, mergedOptions);
   };
 
   userSchema.statics.findUsersByPartOfEmail = function(emailPart, options) {

+ 49 - 15
src/server/routes/admin.js

@@ -418,7 +418,12 @@ module.exports = function(crowi, app) {
 
     const page = parseInt(req.query.page) || 1;
 
-    const result = await User.findUsersWithPagination({ page });
+    const result = await User.findUsersWithPagination({
+      page,
+      select: User.USER_PUBLIC_FIELDS,
+      populate: User.IMAGE_POPULATION,
+    });
+
     const pager = createPager(result.total, result.limit, result.page, result.pages, MAX_PAGE_LIST);
 
     return res.render('admin/users', {
@@ -913,7 +918,7 @@ module.exports = function(crowi, app) {
     }
   };
 
-  actions.api.securityPassportLdapSetting = function(req, res) {
+  actions.api.securityPassportLocalSetting = async function(req, res) {
     const form = req.form.settingForm;
 
     if (!req.form.isValid) {
@@ -921,19 +926,48 @@ module.exports = function(crowi, app) {
     }
 
     debug('form content', form);
-    return configManager.updateConfigsInTheSameNamespace('crowi', form)
-      .then(() => {
-        // reset strategy
-        crowi.passportService.resetLdapStrategy();
-        // setup strategy
-        if (configManager.getConfig('crowi', 'security:passport-ldap:isEnabled')) {
-          crowi.passportService.setupLdapStrategy(true);
-        }
-        return;
-      })
-      .then(() => {
-        res.json({ status: true });
-      });
+
+    try {
+      await configManager.updateConfigsInTheSameNamespace('crowi', form);
+      // reset strategy
+      crowi.passportService.resetLocalStrategy();
+      // setup strategy
+      if (configManager.getConfig('crowi', 'security:passport-local:isEnabled')) {
+        crowi.passportService.setupLocalStrategy(true);
+      }
+    }
+    catch (err) {
+      logger.error(err);
+      return res.json({ status: false, message: err.message });
+    }
+
+    return res.json({ status: true });
+  };
+
+  actions.api.securityPassportLdapSetting = async function(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);
+
+    try {
+      await configManager.updateConfigsInTheSameNamespace('crowi', form);
+      // reset strategy
+      crowi.passportService.resetLdapStrategy();
+      // setup strategy
+      if (configManager.getConfig('crowi', 'security:passport-ldap:isEnabled')) {
+        crowi.passportService.setupLdapStrategy(true);
+      }
+    }
+    catch (err) {
+      logger.error(err);
+      return res.json({ status: false, message: err.message });
+    }
+
+    return res.json({ status: true });
   };
 
   actions.api.securityPassportSamlSetting = async(req, res) => {

+ 1 - 0
src/server/routes/index.js

@@ -58,6 +58,7 @@ module.exports = function(crowi, app) {
   // security admin
   app.get('/admin/security'                     , loginRequired() , adminRequired , admin.security.index);
   app.post('/_api/admin/security/general'       , loginRequired() , adminRequired , form.admin.securityGeneral, admin.api.securitySetting);
+  app.post('/_api/admin/security/passport-local', loginRequired() , adminRequired , csrf, form.admin.securityPassportLocal, admin.api.securityPassportLocalSetting);
   app.post('/_api/admin/security/passport-ldap' , loginRequired() , adminRequired , csrf, form.admin.securityPassportLdap, admin.api.securityPassportLdapSetting);
   app.post('/_api/admin/security/passport-saml' , loginRequired() , adminRequired , csrf, form.admin.securityPassportSaml, admin.api.securityPassportSamlSetting);
   app.post('/_api/admin/security/passport-basic' , loginRequired() , adminRequired , csrf, form.admin.securityPassportBasic, admin.api.securityPassportBasicSetting);

+ 7 - 4
src/server/routes/login-passport.js

@@ -199,6 +199,12 @@ module.exports = function(crowi, app) {
    * @param {*} next
    */
   const loginWithLocal = (req, res, next) => {
+    if (!passportService.isLocalStrategySetup) {
+      debug('LocalStrategy has not been set up');
+      req.flash('warningMessage', 'LocalStrategy has not been set up');
+      return next();
+    }
+
     if (!req.form.isValid) {
       return res.render('login', {
       });
@@ -476,10 +482,7 @@ module.exports = function(crowi, app) {
       userId = await promisifiedPassportAuthentication(strategyName, req, res);
     }
     catch (err) {
-      // display prompt in browser
-      res.setHeader('WWW-Authenticate', 'Basic realm="Users"');
-      res.sendStatus(401).end();
-      return;
+      return loginFailure(req, res);
     }
 
     const userInfo = {

+ 1 - 4
src/server/routes/login.js

@@ -99,10 +99,7 @@ module.exports = function(crowi, app) {
   };
 
   actions.register = function(req, res) {
-    // redirect to '/' if both of these are true:
-    //  1. user has logged in
-    //  2. req.user is not username/email string (which is set by basic-auth-connect)
-    if (req.user != null && req.user instanceof Object) {
+    if (req.user != null) {
       return res.redirect('/');
     }
 

+ 12 - 0
src/server/service/config-loader.js

@@ -142,6 +142,18 @@ const ENV_VAR_NAME_TO_CONFIG_INFO = {
     type:    TYPES.STRING,
     default: undefined,
   },
+  LOCAL_STRATEGY_ENABLED: {
+    ns:      'crowi',
+    key:     'security:passport-local:isEnabled',
+    type:    TYPES.BOOLEAN,
+    default: true,
+  },
+  LOCAL_STRATEGY_USES_ONLY_ENV_VARS_FOR_SOME_OPTIONS: {
+    ns:      'crowi',
+    key:     'security:passport-local:useOnlyEnvVarsForSomeOptions',
+    type:    TYPES.BOOLEAN,
+    default: false,
+  },
   SAML_USES_ONLY_ENV_VARS_FOR_SOME_OPTIONS: {
     ns:      'crowi',
     key:     'security:passport-saml:useOnlyEnvVarsForSomeOptions',

+ 27 - 20
src/server/service/config-manager.js

@@ -1,6 +1,10 @@
 const logger = require('@alias/logger')('growi:service:ConfigManager');
 const ConfigLoader = require('../service/config-loader');
 
+const KEYS_FOR_LOCAL_STRATEGY_USE_ONLY_ENV_OPTION = [
+  'security:passport-local:isEnabled',
+];
+
 const KEYS_FOR_SAML_USE_ONLY_ENV_OPTION = [
   'security:passport-saml:isEnabled',
   'security:passport-saml:entryPoint',
@@ -50,11 +54,12 @@ class ConfigManager {
   getConfig(namespace, key) {
     let value;
 
-    if (this.searchOnlyFromEnvVarConfigs('crowi', 'security:passport-saml:useOnlyEnvVarsForSomeOptions')) {
-      value = this.searchInSAMLUseOnlyEnvMode(namespace, key);
+    if (this.shouldSearchedFromEnvVarsOnly(namespace, key)) {
+      value = this.searchOnlyFromEnvVarConfigs(namespace, key);
+    }
+    else {
+      value = this.defaultSearch(namespace, key);
     }
-
-    value = this.defaultSearch(namespace, key);
 
     logger.debug(key, value);
     return value;
@@ -174,6 +179,24 @@ class ConfigManager {
     this.reloadConfigKeys();
   }
 
+  /**
+   * return whether the specified namespace/key should be retrieved only from env vars
+   */
+  shouldSearchedFromEnvVarsOnly(namespace, key) {
+    return (namespace === 'crowi' && (
+      // local strategy
+      (
+        KEYS_FOR_LOCAL_STRATEGY_USE_ONLY_ENV_OPTION.includes(key)
+        && this.defaultSearch('crowi', 'security:passport-local:useOnlyEnvVarsForSomeOptions')
+      )
+      // saml strategy
+      || (
+        KEYS_FOR_SAML_USE_ONLY_ENV_OPTION.includes(key)
+        && this.defaultSearch('crowi', 'security:passport-saml:useOnlyEnvVarsForSomeOptions')
+      )
+    ));
+  }
+
   /*
    * All of the methods below are private APIs.
    */
@@ -215,22 +238,6 @@ class ConfigManager {
     }
   }
 
-  /**
-   * For the configs specified by KEYS_FOR_SAML_USE_ONLY_ENV_OPTION,
-   * this searches only from configs loaded from the environment variables.
-   * For the other configs, this searches as the same way to defaultSearch.
-   */
-  /* eslint-disable no-else-return */
-  searchInSAMLUseOnlyEnvMode(namespace, key) {
-    if (namespace === 'crowi' && KEYS_FOR_SAML_USE_ONLY_ENV_OPTION.includes(key)) {
-      return this.searchOnlyFromEnvVarConfigs(namespace, key);
-    }
-    else {
-      return this.defaultSearch(namespace, key);
-    }
-  }
-  /* eslint-enable no-else-return */
-
   /**
    * search a specified config from configs loaded from the database
    */

+ 13 - 8
src/server/service/file-uploader/aws.js

@@ -1,6 +1,5 @@
 const logger = require('@alias/logger')('growi:service:fileUploaderAws');
 
-const axios = require('axios');
 const urljoin = require('url-join');
 const aws = require('aws-sdk');
 
@@ -15,6 +14,7 @@ module.exports = function(crowi) {
       secretAccessKey: configManager.getConfig('crowi', 'aws:secretAccessKey'),
       region: configManager.getConfig('crowi', 'aws:region'),
       bucket: configManager.getConfig('crowi', 'aws:bucket'),
+      customEndpoint: configManager.getConfig('crowi', 'aws:customEndpoint'),
     };
   }
 
@@ -29,9 +29,11 @@ module.exports = function(crowi) {
       accessKeyId: awsConfig.accessKeyId,
       secretAccessKey: awsConfig.secretAccessKey,
       region: awsConfig.region,
+      s3ForcePathStyle: awsConfig.customEndpoint ? true : undefined,
     });
 
-    return new aws.S3();
+    // undefined & null & '' => default endpoint (genuine S3)
+    return new aws.S3({ endpoint: awsConfig.customEndpoint || undefined });
   }
 
   function getFilePathOnStorage(attachment) {
@@ -89,14 +91,17 @@ module.exports = function(crowi) {
    * @return {stream.Readable} readable stream
    */
   lib.findDeliveryFile = async function(attachment) {
-    // construct url
+    const s3 = S3Factory(this.getIsUploadable());
     const awsConfig = getAwsConfig();
-    const baseUrl = `https://${awsConfig.bucket}.s3.amazonaws.com`;
-    const url = urljoin(baseUrl, getFilePathOnStorage(attachment));
+    const filePath = getFilePathOnStorage(attachment);
 
-    let response;
+    let stream;
     try {
-      response = await axios.get(url, { responseType: 'stream' });
+      const params = {
+        Bucket: awsConfig.bucket,
+        Key: filePath,
+      };
+      stream = s3.getObject(params).createReadStream();
     }
     catch (err) {
       logger.error(err);
@@ -104,7 +109,7 @@ module.exports = function(crowi) {
     }
 
     // return stream.Readable
-    return response.data;
+    return stream;
   };
 
   /**

+ 3 - 1
src/server/service/file-uploader/uploader.js

@@ -13,7 +13,9 @@ class Uploader {
     if (method === 'aws' && (
       !this.configManager.getConfig('crowi', 'aws:accessKeyId')
         || !this.configManager.getConfig('crowi', 'aws:secretAccessKey')
-        || !this.configManager.getConfig('crowi', 'aws:region')
+        || (
+          !this.configManager.getConfig('crowi', 'aws:region')
+            && !this.configManager.getConfig('crowi', 'aws:customEndpoint'))
         || !this.configManager.getConfig('crowi', 'aws:bucket'))) {
       return false;
     }

+ 12 - 6
src/server/service/passport.js

@@ -105,6 +105,15 @@ class PassportService {
       throw new Error('LocalStrategy has already been set up');
     }
 
+    const { configManager } = this.crowi;
+
+    const isEnabled = configManager.getConfig('crowi', 'security:passport-local:isEnabled');
+
+    // when disabled
+    if (!isEnabled) {
+      return;
+    }
+
     debug('LocalStrategy: setting up..');
 
     const User = this.crowi.model('User');
@@ -621,15 +630,12 @@ class PassportService {
 
     debug('BasicStrategy: setting up..');
 
-    const configId = configManager.getConfig('crowi', 'security:passport-basic:id');
-    const configPassword = configManager.getConfig('crowi', 'security:passport-basic:password');
-
     passport.use(new BasicStrategy(
       (userId, password, done) => {
-        if (userId !== configId || password !== configPassword) {
-          return done(null, false, { message: 'Incorrect credentials.' });
+        if (userId != null) {
+          return done(null, userId);
         }
-        return done(null, userId);
+        return done(null, false, { message: 'Incorrect credentials.' });
       },
     ));
 

+ 1 - 4
src/server/util/i18nUserSettingDetector.js

@@ -2,10 +2,7 @@ module.exports = {
   name: 'userSettingDetector',
 
   lookup(req, res, options) {
-    // return null if
-    //  1. user doesn't logged in
-    //  2. req.user is username/email string to login which is set by basic-auth-connect
-    if (req.user == null || !(req.user instanceof Object)) {
+    if (req.user == null) {
       return null;
     }
     return req.user.lang || null;

+ 1 - 4
src/server/util/middlewares.js

@@ -170,8 +170,6 @@ module.exports = (crowi, app) => {
   };
 
   middlewares.adminRequired = function(req, res, next) {
-    // check the user logged in
-    //  make sure that req.user isn't username/email string to login which is set by basic-auth-connect
     if (req.user != null && (req.user instanceof Object) && '_id' in req.user) {
       if (req.user.admin) {
         next();
@@ -202,7 +200,6 @@ module.exports = (crowi, app) => {
       const User = crowi.model('User');
 
       // check the user logged in
-      //  make sure that req.user isn't username/email string to login which is set by basic-auth-connect
       if (req.user != null && (req.user instanceof Object) && '_id' in req.user) {
         if (req.user.status === User.STATUS_ACTIVE) {
           // Active の人だけ先に進める
@@ -277,7 +274,7 @@ module.exports = (crowi, app) => {
 
   middlewares.awsEnabled = function() {
     return function(req, res, next) {
-      if (configManager.getConfig('crowi', 'aws:region') !== ''
+      if ((configManager.getConfig('crowi', 'aws:region') !== '' || this.configManager.getConfig('crowi', 'aws:customEndpoint') !== '')
           && configManager.getConfig('crowi', 'aws:bucket') !== ''
           && configManager.getConfig('crowi', 'aws:accessKeyId') !== ''
           && configManager.getConfig('crowi', 'aws:secretAccessKey') !== '') {

+ 4 - 11
src/server/util/swigFunctions.js

@@ -1,6 +1,7 @@
 module.exports = function(crowi, app, req, locals) {
   const debug = require('debug')('growi:lib:swigFunctions');
   const stringWidth = require('string-width');
+  const { pathUtils } = require('growi-commons');
   const Page = crowi.model('Page');
   const User = crowi.model('User');
   const {
@@ -62,12 +63,14 @@ module.exports = function(crowi, app, req, locals) {
   locals.getConfigFromEnvVars = configManager.getConfigFromEnvVars.bind(configManager);
 
   /**
-   * pass service class to swig
+   * pass service/utils instances to swig
    */
   locals.appService = appService;
   locals.aclService = aclService;
   locals.fileUploadService = fileUploadService;
   locals.customizeService = customizeService;
+  locals.passportService = passportService;
+  locals.pathUtils = pathUtils;
 
   locals.noCdn = function() {
     return cdnResourcesService.noCdn();
@@ -94,16 +97,6 @@ module.exports = function(crowi, app, req, locals) {
     return cdnResourcesService.getHighlightJsStyleTag(styleName);
   };
 
-  /**
-   * return true if enabled and strategy has been setup successfully
-   */
-  locals.isLdapSetup = function() {
-    return (
-      configManager.getConfig('crowi', 'security:passport-ldap:isEnabled')
-      && passportService.isLdapStrategySetup
-    );
-  };
-
   /**
    * return true if enabled but strategy has some problem
    */

+ 13 - 0
src/server/views/admin/app.html

@@ -252,6 +252,19 @@
           </div>
         </div>
 
+        <div class="form-group">
+          <label for="settingForm[aws:customEndpoint]" class="col-xs-3 control-label">{{ t('app_setting.custom endpoint') }}</label>
+          <div class="col-xs-6">
+            <input class="form-control"
+                   id="settingForm[aws:customEndpoint]"
+                   type="text"
+                   name="settingForm[aws:customEndpoint]"
+                   placeholder="例: http://localhost:9000"
+                   value="{{ getConfig('crowi', 'aws:customEndpoint') | default('') }}">
+                   <p class="help-block">{{ t("app_setting.custom_endpoint_change") }}</p>
+          </div>
+        </div>
+
         <div class="form-group">
           <label for="settingForm[aws:bucket]" class="col-xs-3 control-label">{{ t('app_setting.bucket name') }}</label>
           <div class="col-xs-6">

+ 1 - 1
src/server/views/admin/external-accounts.html

@@ -100,7 +100,7 @@
                   <i class="icon-settings"></i> <span class="caret"></span>
                 </button>
                 <ul class="dropdown-menu" role="menu">
-                  <li class="dropdown-header">{{ t('user_management.Edit_menu') }}</li>
+                  <li class="dropdown-header">{{ t('user_management.edit_menu') }}</li>
                   <form id="form_remove_{{ loop.index }}" action="/admin/users/external-accounts/{{ account._id.toString() }}/remove" method="post">
                     <input type="hidden" name="_csrf" value="{{ csrf() }}">
                   </form>

+ 22 - 36
src/server/views/admin/security.html

@@ -59,27 +59,6 @@
             </div>
           </div>
 
-          <div class="form-group">
-            <label for="settingForm[security:registrationMode]" class="col-xs-3 control-label">{{ t('Register limitation') }}</label>
-            <div class="col-xs-6">
-              <select class="form-control selectpicker" name="settingForm[security:registrationMode]" value="{{ getConfig('crowi', 'security:registrationMode') }}">
-                {% for modeValue, modeLabel in consts.registrationMode %}
-                <option value="{{ t(modeValue) }}" {% if modeValue == getConfig('crowi', 'security:registrationMode') %}selected{% endif %} >{{ t(modeLabel) }}</option>
-                {% endfor %}
-              </select>
-              <p class="help-block small">{{ t('security_setting.Register limitation desc') }}</p>
-            </div>
-          </div>
-
-          <div class="form-group">
-            <label for="settingForm[security:registrationWhiteList]" class="col-xs-3 control-label">{{ t('The whitelist of registration permission E-mail address') }}</label>
-            <div class="col-xs-8">
-              <textarea class="form-control" type="textarea" name="settingForm[security:registrationWhiteList]" placeholder="{{ t('security_setting.example') }}: @growi.org">{{ getConfig('crowi', 'security:registrationWhiteList') | join('&#13') | raw }}</textarea>
-              <p class="help-block small">{{ t("security_setting.restrict_emails") }}{{ t("security_setting.for_instance") }}<code>@growi.org</code>{{ t("security_setting.only_those") }}<br>
-              {{ t("security_setting.insert_single") }}</p>
-            </div>
-          </div>
-
           <div class="form-group">
             {% set configName = 'settingForm[security:list-policy:hideRestrictedByOwner]' %}
             {% set configValue = getConfig('crowi', 'security:list-policy:hideRestrictedByOwner') %}
@@ -170,25 +149,28 @@
         <div class="passport-settings">
           <ul class="nav nav-tabs" role="tablist">
             <li class="active">
+              <a href="#passport-local" data-toggle="tab" role="tab"><i class="fa fa-users"></i> ID/Pass</a>
+            </li>
+            <li>
               <a href="#passport-ldap" data-toggle="tab" role="tab"><i class="fa fa-sitemap"></i> LDAP</a>
             </li>
             <li>
               <a href="#passport-saml" data-toggle="tab" role="tab"><i class="fa fa-key"></i> SAML</a>
             </li>
             <li>
-              <a href="#passport-google-oauth" data-toggle="tab" role="tab"><i class="fa fa-google"></i> Google</a>
+              <a href="#passport-oidc" data-toggle="tab" role="tab"><i class="fa fa-openid"></i> OIDC</a>
             </li>
             <li>
-              <a href="#passport-github" data-toggle="tab" role="tab"><i class="fa fa-github"></i> GitHub</a>
+              <a href="#passport-basic" data-toggle="tab" role="tab"><i class="fa fa-lock"></i> Basic</a>
             </li>
             <li>
-              <a href="#passport-twitter" data-toggle="tab" role="tab"><i class="fa fa-twitter"></i> Twitter</a>
+              <a href="#passport-google-oauth" data-toggle="tab" role="tab"><i class="fa fa-google"></i> Google</a>
             </li>
             <li>
-              <a href="#passport-oidc" data-toggle="tab" role="tab"><i class="fa fa-openid"></i> OIDC</a>
+              <a href="#passport-github" data-toggle="tab" role="tab"><i class="fa fa-github"></i> GitHub</a>
             </li>
             <li>
-              <a href="#passport-basic" data-toggle="tab" role="tab"><i class="fa fa-sign-in"></i> Basic</a>
+              <a href="#passport-twitter" data-toggle="tab" role="tab"><i class="fa fa-twitter"></i> Twitter</a>
             </li>
             <li class="tbd">
               <a href="#passport-facebook" data-toggle="tab" role="tab"><i class="fa fa-facebook"></i> (TBD) Facebook</a>
@@ -196,7 +178,11 @@
           </ul>
 
           <div class="tab-content p-t-10">
-            <div id="passport-ldap" class="tab-pane active" role="tabpanel" >
+            <div id="passport-local" class="tab-pane active" role="tabpanel" >
+              {% include './widget/passport/local.html' %}
+            </div>
+
+            <div id="passport-ldap" class="tab-pane" role="tabpanel" >
               {% include './widget/passport/ldap.html' with { settingForm: settingForm } %}
             </div>
 
@@ -204,6 +190,14 @@
               {% include './widget/passport/saml.html' %}
             </div>
 
+            <div id="passport-oidc" class="tab-pane" role="tabpanel">
+              {% include './widget/passport/oidc.html' %}
+            </div>
+
+            <div id="passport-basic" class="tab-pane" role="tabpanel">
+              {% include './widget/passport/basic.html' %}
+            </div>
+
             <div id="passport-google-oauth" class="tab-pane" role="tabpanel">
               {% include './widget/passport/google-oauth.html' %}
             </div>
@@ -216,18 +210,10 @@
               {% include './widget/passport/twitter.html' %}
             </div>
 
-            <div id="passport-oidc" class="tab-pane" role="tabpanel">
-              {% include './widget/passport/oidc.html' %}
-            </div>
-
             <div id="passport-github" class="tab-pane" role="tabpanel">
               {% include './widget/passport/github.html' %}
             </div>
 
-            <div id="passport-basic" class="tab-pane" role="tabpanel">
-              {% include './widget/passport/basic.html' %}
-            </div>
-
           </div><!-- /.tab-content -->
         </div>
 
@@ -236,7 +222,7 @@
   </div>
 
   <script>
-    $('#generalSetting, #samlSetting, #googleSetting, #mechanismSetting, #githubSetting, #twitterSetting, #oidcSetting').each(function() {
+    $('#generalSetting, #localSetting, #samlSetting, #basicSetting, #googleSetting, #githubSetting, #twitterSetting, #oidcSetting').each(function() {
       $(this).submit(function()
       {
         function showMessage(formId, msg, status) {

+ 23 - 12
src/server/views/admin/widget/passport/basic.html

@@ -1,12 +1,12 @@
 <form action="/_api/admin/security/passport-basic" method="post" class="form-horizontal passportStrategy" id="basicSetting" role="form"
     {% if isRestartingServerNeeded %}style="opacity: 0.4;"{% endif %}>
-  <legend class="alert-anchor">{{ t("security_setting.Basic authentication") }} {{ t("security_setting.configuration") }}</legend>
+  <legend class="alert-anchor">{{ t("security_setting.Basic.name") }} {{ t("security_setting.configuration") }}</legend>
 
   {% set nameForIsbasicEnabled = "settingForm[security:passport-basic:isEnabled]" %}
-  {% set isbasicEnabled = settingForm['security:passport-basic:isEnabled'] %}
+  {% set isbasicEnabled = getConfig('crowi', 'security:passport-basic:isEnabled') %}
 
   <div class="form-group">
-    <label for="{{nameForIsbasicEnabled}}" class="col-xs-3 control-label">{{ t("security_setting.Basic authentication") }}</label>
+    <label for="{{nameForIsbasicEnabled}}" class="col-xs-3 control-label">{{ t("security_setting.Basic.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 isbasicEnabled %}active{% endif %}" data-active-class="primary">
@@ -18,21 +18,32 @@
               {% if !isbasicEnabled %}checked{% endif %}> OFF
         </label>
       </div>
+      <p class="help-block">
+        <small>
+          {{ t("security_setting.Basic.desc_1") }}<br>
+          {{ t("security_setting.Basic.desc_2") }}
+        </small>
+      </p>
     </div>
   </div>
+
+
   <fieldset id="passport-basic-hide-when-disabled" {%if !isbasicEnabled %}style="display: none;"{% endif %}>
 
     <div class="form-group">
-      <label for="settingForm[security:passport-basic:id]" class="col-xs-3 control-label">ID</label>
-      <div class="col-xs-6">
-        <input class="form-control" type="text" name="settingForm[security:passport-basic:id]" value="{{ settingForm['security:passport-basic:id'] || '' }}">
-      </div>
+    <div class="col-xs-6 col-xs-offset-3">
+      <div class="checkbox checkbox-info">
+        <input type="checkbox" id="bindByUserName-basic" name="settingForm[security:passport-basic:isSameUsernameTreatedAsIdenticalUser]" value="1"
+            {% if getConfig('crowi', 'security:passport-basic:isSameUsernameTreatedAsIdenticalUser') %}checked{% endif %} />
+        <label for="bindByUserName-basic">
+          {{ 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 class="form-group">
-      <label for="settingForm[security:passport-basic:password]" class="col-xs-3 control-label">{{ t("Password") }}</label>
-      <div class="col-xs-6">
-        <input class="form-control" type="text" name="settingForm[security:passport-basic:password]" value="{{ settingForm['security:passport-basic:password'] || '' }}">
       </div>
     </div>
 

+ 16 - 14
src/server/views/admin/widget/passport/github.html

@@ -4,7 +4,7 @@
   {% set nameForIsGitHubEnabled = "settingForm[security:passport-github:isEnabled]" %}
   {% set isGitHubEnabled = getConfig('crowi', 'security:passport-github:isEnabled') %}
   {% set siteUrl = getConfig('crowi', 'app:siteUrl') || '[INVALID]' %}
-  {% set callbackUrl = siteUrl + '/passport/github/callback' %}
+  {% set callbackUrl = pathUtils.removeTrailingSlash(siteUrl) + '/passport/github/callback' %}
 
   <div class="form-group">
     <label for="{{nameForIsGitHubEnabled}}" class="col-xs-3 control-label">{{ t("security_setting.OAuth.GitHub.name") }}</label>
@@ -21,6 +21,21 @@
       </div>
     </div>
   </div>
+
+
+  <div class="form-group">
+    <label class="col-xs-3 control-label">{{ t("security_setting.callback_URL") }}</label>
+    <div class="col-xs-6">
+        <input class="form-control" type="text" value="{{ callbackUrl }}" readonly>
+      <p class="help-block small">{{ t("security_setting.desc_of_callback_URL", 'OAuth') }}</p>
+      {% if !getConfig('crowi', 'app:siteUrl') %}
+      <div class="alert alert-danger">
+        <i class="icon-exclamation"></i> {{ t("security_setting.alert_siteUrl_is_not_set", '<a href="/admin/app">' + t('App settings') + '<i class="icon-login"></i></a>') }}
+      </div>
+      {% endif %}
+    </div>
+  </div>
+
   <fieldset id="passport-github-hide-when-disabled" {%if !isGitHubEnabled %}style="display: none;"{% endif %}>
 
     <div class="form-group">
@@ -47,19 +62,6 @@
       </div>
     </div>
 
-    <div class="form-group">
-      <label class="col-xs-3 control-label">{{ t("security_setting.callback_URL") }}</label>
-      <div class="col-xs-6">
-          <input class="form-control" type="text" value="{{ callbackUrl }}" readonly>
-        <p class="help-block small">{{ t("security_setting.desc_of_callback_URL", 'OAuth') }}</p>
-        {% if !getConfig('crowi', 'app:siteUrl') %}
-        <div class="alert alert-danger">
-          <i class="icon-exclamation"></i> {{ t("security_setting.alert_siteUrl_is_not_set", '<a href="/admin/app">' + t('App settings') + '<i class="icon-login"></i></a>') }}
-        </div>
-        {% endif %}
-      </div>
-    </div>
-
     <div class="form-group">
       <div class="col-xs-6 col-xs-offset-3">
         <div class="checkbox checkbox-info">

+ 15 - 14
src/server/views/admin/widget/passport/google-oauth.html

@@ -4,7 +4,7 @@
   {% set nameForIsGoogleEnabled = "settingForm[security:passport-google:isEnabled]" %}
   {% set isGoogleEnabled = getConfig('crowi', 'security:passport-google:isEnabled') | default('') %}
   {% set siteUrl = getConfig('crowi', 'app:siteUrl') || '[INVALID]' %}
-  {% set callbackUrl = siteUrl + '/passport/google/callback' %}
+  {% set callbackUrl = pathUtils.removeTrailingSlash(siteUrl) + '/passport/google/callback' %}
 
   <div class="form-group">
     <label for="{{nameForIsGoogleEnabled}}" class="col-xs-3 control-label">{{ t("security_setting.OAuth.Google.name") }}</label>
@@ -21,6 +21,20 @@
       </div>
     </div>
   </div>
+
+  <div class="form-group">
+    <label class="col-xs-3 control-label">{{ t("security_setting.callback_URL") }}</label>
+    <div class="col-xs-6">
+        <input class="form-control" type="text" value="{{ callbackUrl }}" readonly>
+      <p class="help-block small">{{ t("security_setting.desc_of_callback_URL", 'OAuth') }}</p>
+      {% if !getConfig('crowi', 'app:siteUrl') %}
+      <div class="alert alert-danger">
+        <i class="icon-exclamation"></i> {{ t("security_setting.alert_siteUrl_is_not_set", '<a href="/admin/app">' + t('App settings') + '<i class="icon-login"></i></a>') }}
+      </div>
+      {% endif %}
+    </div>
+  </div>
+
   <fieldset id="passport-google-hide-when-disabled" {%if !isGoogleEnabled %}style="display: none;"{% endif %}>
 
     <div class="form-group">
@@ -47,19 +61,6 @@
       </div>
     </div>
 
-    <div class="form-group">
-      <label class="col-xs-3 control-label">{{ t("security_setting.callback_URL") }}</label>
-      <div class="col-xs-6">
-          <input class="form-control" type="text" value="{{ callbackUrl }}" readonly>
-        <p class="help-block small">{{ t("security_setting.desc_of_callback_URL", 'OAuth') }}</p>
-        {% if !getConfig('crowi', 'app:siteUrl') %}
-        <div class="alert alert-danger">
-          <i class="icon-exclamation"></i> {{ t("security_setting.alert_siteUrl_is_not_set", '<a href="/admin/app">' + t('App settings') + '<i class="icon-login"></i></a>') }}
-        </div>
-        {% endif %}
-      </div>
-    </div>
-
     <div class="form-group">
       <div class="col-xs-6 col-xs-offset-3">
         <div class="checkbox checkbox-info">

+ 84 - 0
src/server/views/admin/widget/passport/local.html

@@ -0,0 +1,84 @@
+<form action="/_api/admin/security/passport-local" method="post" class="form-horizontal passportStrategy" id="localSetting" role="form"
+    {% if isRestartingServerNeeded %}style="opacity: 0.4;"{% endif %}>
+  <legend class="alert-anchor">{{ t("security_setting.Local.name") }} {{ t("security_setting.configuration") }}</legend>
+
+  {% set nameForIsLocalEnabled = "settingForm[security:passport-local:isEnabled]" %}
+  {% set isLocalEnabled = getConfig('crowi', 'security:passport-local:isEnabled') %}
+  {% set useOnlyEnvVars = getConfig('crowi', 'security:passport-local:useOnlyEnvVarsForSomeOptions') %}
+
+  {% if useOnlyEnvVars %}
+    <p class="alert alert-info">
+      {{ t("security_setting.Local.note for the only env option", "LOCAL_STRATEGY_USES_ONLY_ENV_VARS_FOR_SOME_OPTIONS") }}
+    </p>
+  {% endif %}
+
+  <div class="form-group">
+    <label for="{{nameForIsLocalEnabled}}" class="col-xs-3 control-label">{{ t("security_setting.Local.name") }}</label>
+    <div class="col-xs-6">
+      <div class="btn-group btn-toggle {% if useOnlyEnvVars %}btn-group-disabled{% endif %}" data-toggle="buttons">
+        <label class="btn btn-default btn-rounded btn-outline {% if isLocalEnabled %}active{% endif %}" data-active-class="primary">
+          <input name="{{nameForIsLocalEnabled}}"
+                 value="true"
+                 type="radio"
+                 {% if true === isLocalEnabled %}checked{% endif %}
+                 {% if useOnlyEnvVars %}readonly{% endif %}> ON
+        </label>
+        <label class="btn btn-default btn-rounded btn-outline {% if !isLocalEnabled %}active{% endif %}" data-active-class="default">
+          <input name="{{nameForIsLocalEnabled}}"
+                 value="false"
+                 type="radio"
+                 {% if !isLocalEnabled %}checked{% endif %}
+                 {% if useOnlyEnvVars %}readonly{% endif %}> OFF
+        </label>
+      </div>
+    </div>
+  </div>
+
+
+  <fieldset id="passport-local-hide-when-disabled" {%if !isLocalEnabled %}style="display: none;"{% endif %}>
+
+    <div class="form-group">
+      <label for="settingForm[security:registrationMode]" class="col-xs-3 control-label">{{ t('Register limitation') }}</label>
+      <div class="col-xs-9 col-lg-6">
+        <select class="form-control selectpicker" name="settingForm[security:registrationMode]" value="{{ getConfig('crowi', 'security:registrationMode') }}">
+          {% for modeValue, modeLabel in consts.registrationMode %}
+          <option value="{{ t(modeValue) }}" {% if modeValue == getConfig('crowi', 'security:registrationMode') %}selected{% endif %} >{{ t(modeLabel) }}</option>
+          {% endfor %}
+        </select>
+        <p class="help-block small">{{ t('security_setting.Register limitation desc') }}</p>
+      </div>
+    </div>
+
+    <div class="form-group">
+      <label for="settingForm[security:registrationWhiteList]" class="col-xs-3 control-label">{{ t('The whitelist of registration permission E-mail address') }}</label>
+      <div class="col-xs-9 col-lg-6">
+        <textarea class="form-control" type="textarea" name="settingForm[security:registrationWhiteList]" placeholder="{{ t('security_setting.example') }}: @growi.org">{{ getConfig('crowi', 'security:registrationWhiteList') | join('&#13') | raw }}</textarea>
+        <p class="help-block small">{{ t("security_setting.restrict_emails") }}{{ t("security_setting.for_instance") }}<code>@growi.org</code>{{ t("security_setting.only_those") }}<br>
+        {{ t("security_setting.insert_single") }}</p>
+      </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>
+
+<script>
+  $('input[name="settingForm[security:passport-local:isEnabled]"]').change(function() {
+    const isEnabled = ($(this).val() === "true");
+
+    if (isEnabled) {
+      $('#passport-local-hide-when-disabled').show(400);
+    }
+    else {
+      $('#passport-local-hide-when-disabled').hide(400);
+    }
+  });
+</script>
+

+ 15 - 1
src/server/views/admin/widget/passport/oidc.html

@@ -4,7 +4,7 @@
   {% set nameForIsOIDCEnabled = "settingForm[security:passport-oidc:isEnabled]" %}
   {% set isOidcEnabled = getConfig('crowi', 'security:passport-oidc:isEnabled') %}
   {% set siteUrl = getConfig('crowi', 'app:siteUrl') || '[INVALID]' %}
-  {% set callbackUrl = siteUrl + '/passport/oidc/callback' %}
+  {% set callbackUrl = pathUtils.removeTrailingSlash(siteUrl) + '/passport/oidc/callback' %}
 
   <div class="form-group">
     <label for="{{nameForIsOIDCEnabled}}" class="col-xs-3 control-label">{{ t("security_setting.OAuth.OIDC.name") }}</label>
@@ -21,6 +21,20 @@
       </div>
     </div>
   </div>
+
+  <div class="form-group">
+    <label class="col-xs-3 control-label">{{ t("security_setting.callback_URL") }}</label>
+    <div class="col-xs-6">
+      <input class="form-control" type="text" value="{{ callbackUrl }}" readonly>
+      <p class="help-block small">{{ t("security_setting.desc_of_callback_URL", 'OAuth') }}</p>
+      {% if !getConfig('crowi', 'app:siteUrl') %}
+      <div class="alert alert-danger">
+        <i class="icon-exclamation"></i> {{ t("security_setting.alert_siteUrl_is_not_set", '<a href="/admin/app">' + t('App settings') + '<i class="icon-login"></i></a>') }}
+      </div>
+      {% endif %}
+    </div>
+  </div>
+
   <fieldset id="passport-oidc-hide-when-disabled" {%if !isOidcEnabled %}style="display: none;"{% endif %}>
 
     <div class="form-group">

+ 1 - 1
src/server/views/admin/widget/passport/saml.html

@@ -5,7 +5,7 @@
   {% set isSamlEnabled  = getConfig('crowi', 'security:passport-saml:isEnabled') %}
   {% set useOnlyEnvVars = getConfig('crowi', 'security:passport-saml:useOnlyEnvVarsForSomeOptions') %}
   {% set siteUrl = getConfig('crowi', 'app:siteUrl') || '[INVALID]' %}
-  {% set callbackUrl = siteUrl + '/passport/saml/callback' %}
+  {% set callbackUrl = pathUtils.removeTrailingSlash(siteUrl) + '/passport/saml/callback' %}
 
   {% if useOnlyEnvVars %}
     <p class="alert alert-info">

+ 14 - 16
src/server/views/admin/widget/passport/twitter.html

@@ -4,7 +4,7 @@
   {% set nameForIsTwitterEnabled = "settingForm[security:passport-twitter:isEnabled]" %}
   {% set isTwitterEnabled = getConfig('crowi', 'security:passport-twitter:isEnabled') %}
   {% set siteUrl = getConfig('crowi', 'app:siteUrl') || '[INVALID]' %}
-  {% set callbackUrl = siteUrl + '/passport/twitter/callback' %}
+  {% set callbackUrl = pathUtils.removeTrailingSlash(siteUrl) + '/passport/twitter/callback' %}
 
   <div class="form-group">
     <label for="{{nameForIsTwitterEnabled}}" class="col-xs-3 control-label">{{ t("security_setting.OAuth.Twitter.name") }}</label>
@@ -21,9 +21,21 @@
       </div>
     </div>
   </div>
-  <fieldset id="passport-twitter-hide-when-disabled" {%if !isTwitterEnabled %}style="display: none;"{% endif %}>
 
+  <div class="form-group">
+    <label class="col-xs-3 control-label">{{ t("security_setting.callback_URL") }}</label>
+    <div class="col-xs-6">
+      <input class="form-control" type="text" value="{{ callbackUrl }}" readonly>
+      <p class="help-block small">{{ t("security_setting.desc_of_callback_URL", 'OAuth') }}</p>
+      {% if !getConfig('crowi', 'app:siteUrl') %}
+      <div class="alert alert-danger">
+        <i class="icon-exclamation"></i> {{ t("security_setting.alert_siteUrl_is_not_set", '<a href="/admin/app">' + t('App settings') + '<i class="icon-login"></i></a>') }}
+      </div>
+      {% endif %}
+    </div>
+  </div>
 
+  <fieldset id="passport-twitter-hide-when-disabled" {%if !isTwitterEnabled %}style="display: none;"{% endif %}>
 
     <div class="form-group">
       <label for="settingForm[security:passport-twitter:consumerKey]" class="col-xs-3 control-label">{{ t("security_setting.clientID") }}</label>
@@ -49,20 +61,6 @@
       </div>
     </div>
 
-    <div class="form-group">
-      <label class="col-xs-3 control-label">{{ t("security_setting.callback_URL") }}</label>
-      <div class="col-xs-6">
-          <input class="form-control" type="text" value="{{ callbackUrl }}" readonly>
-        <p class="help-block small">{{ t("security_setting.desc_of_callback_URL", 'OAuth') }}</p>
-        {% if !getConfig('crowi', 'app:siteUrl') %}
-        <div class="alert alert-danger">
-          <i class="icon-exclamation"></i> {{ t("security_setting.alert_siteUrl_is_not_set", '<a href="/admin/app">' + t('App settings') + '<i class="icon-login"></i></a>') }}
-        </div>
-        {% endif %}
-      </div>
-    </div>
-
-
     <div class="form-group">
       <div class="col-xs-6 col-xs-offset-3">
         <div class="checkbox checkbox-info">

+ 33 - 17
src/server/views/login.html

@@ -100,14 +100,21 @@
       </div>
     </div>
 
+
+    {% set isLocalOrLdapStrategiesEnabled = passportService.isLocalStrategySetup || passportService.isLdapStrategySetup %}
+    {% set isExternalAuthCollapsible = isLocalOrLdapStrategiesEnabled %}
+    {% set isRegistrationEnabled = passportService.isLocalStrategySetup && getConfig('crowi', 'security:registrationMode') != 'Closed' %}
+
     <div class="login-dialog p-b-10 col-sm-offset-4 col-sm-4 flipper {% if req.query.register or req.body.registerForm or isRegistering %}to-flip{% endif %}" id="login-dialog">
 
       <div class="front">
+
+        {% if isLocalOrLdapStrategiesEnabled %}
         <form role="form" action="/login" method="post">
           <div class="input-group">
             <span class="input-group-addon"><i class="icon-user"></i></span>
             <input type="text" class="form-control" placeholder="Username or E-mail" name="loginForm[username]">
-            {% if isLdapSetup() %}
+            {% if passportService.isLdapStrategySetup %}
             <span class="input-group-addon">
               <small class="text-success">
                 <i class="icon-fw icon-check"></i> LDAP
@@ -129,6 +136,7 @@
             </button>
           </div>
         </form>
+        {% endif %}
 
         {% if (
           getConfig('crowi', 'security:passport-google:isEnabled') ||
@@ -140,7 +148,7 @@
           getConfig('crowi', 'security:passport-basic:isEnabled')
         ) %}
         <hr class="mb-1">
-        <div class="collapse collapse-oauth collapse-anchor">
+        <div id="external-auth" class="external-auth {% if isExternalAuthCollapsible %}collapse collapse-external-auth collapse-anchor{% endif %}">
           <div class="spacer"></div>
           <div class="d-flex flex-row justify-content-between flex-wrap">
             {% if getConfig('crowi', 'security:passport-google:isEnabled') %}
@@ -206,10 +214,10 @@
             <form role="form" action="/passport/basic" 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="basic">
-                <span class="btn-label"><i class="fa fa-key"></i></span>
+                <span class="btn-label"><i class="fa fa-lock"></i></span>
                 <span class="btn-label-text">{{ t('Sign in') }}</span>
               </button>
-              <div class="small text-right">with HTTP Basic</div>
+              <div class="small text-right">with Basic Auth</div>
             </form>
             {% endif %}
           </div>{# ./d-flex flex-row flex-wrap #}
@@ -217,7 +225,8 @@
         </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">
+          <button class="collapse-anchor btn btn-xs btn-collapse-external-auth mb-3"
+              data-toggle="{% if isExternalAuthCollapsible %}collapse{% endif %}" data-target="#external-auth" aria-expanded="false" aria-controls="external-auth">
             External Auth
           </button>
         </div>
@@ -225,21 +234,26 @@
         <hr>
         {% endif %}
 
-
+        {% if isExternalAuthCollapsible %}
         <script>
-          $(".collapse-anchor").hover(
-            function() {
-              $('.collapse-oauth').collapse('show');
-            },
-            function() {
-              $('.collapse-oauth').collapse('hide');
-            }
-          );
+          const isMobile = /iphone|ipad|android/.test(window.navigator.userAgent.toLowerCase());
+
+          if (!isMobile) {
+            $(".collapse-anchor").hover(
+              function() {
+                $('.collapse-external-auth').collapse('show');
+              },
+              function() {
+                $('.collapse-external-auth').collapse('hide');
+              }
+            );
+          }
         </script>
+        {% endif %}
 
         <div class="row">
           <div class="col-xs-12 text-right">
-            {% if getConfig('crowi', 'security:registrationMode') != 'Closed' %}
+            {% if isRegistrationEnabled %}
             <a href="#register" id="register" class="link-switch">
               <i class="ti-check-box"></i> {{ t('Sign up is here') }}
             </a>
@@ -248,10 +262,11 @@
             {% endif %}
           </div>
         </div>
+
       </div>
 
 
-      {% if getConfig('crowi', 'security:registrationMode') != 'Closed' %}
+      {% if isRegistrationEnabled %}
       <div class="back">
         {% if getConfig('crowi', 'security:registrationMode') == 'Restricted' %}
         <p class="alert alert-warning">
@@ -313,8 +328,9 @@
             </a>
           </div>
         </div>
+
       </div>
-      {% endif %} {# if registrationMode == Closed #}
+      {% endif %} {# if isRegistrationEnabled id false #}
 
       <a href="https://growi.org" class="link-growi-org">
         <span class="growi">GROWI</span>.<span class="org">ORG

+ 1 - 1
src/test/setup.js

@@ -4,7 +4,7 @@ const mongoose = require('mongoose');
 
 mongoose.Promise = global.Promise;
 
-jest.setTimeout(15000); // default 5000
+jest.setTimeout(30000); // default 5000
 
 beforeAll(async(done) => {
   await mongoose.connect(mongoUri, { useNewUrlParser: true });

+ 50 - 61
yarn.lock

@@ -1826,10 +1826,6 @@ base@^0.11.1:
     mixin-deep "^1.2.0"
     pascalcase "^0.1.1"
 
-basic-auth-connect@~1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/basic-auth-connect/-/basic-auth-connect-1.0.0.tgz#fdb0b43962ca7b40456a7c2bb48fe173da2d2122"
-
 basic-auth@~2.0.0:
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/basic-auth/-/basic-auth-2.0.0.tgz#015db3f353e02e56377755f962742e8981e7bbba"
@@ -2943,6 +2939,11 @@ core-js-pure@3.1.4:
   resolved "https://registry.yarnpkg.com/core-js-pure/-/core-js-pure-3.1.4.tgz#5fa17dc77002a169a3566cc48dc774d2e13e3769"
   integrity sha512-uJ4Z7iPNwiu1foygbcZYJsJs1jiXrTTCvxfLDXNhI/I+NHbSIEyr548y4fcsCEyWY0XgfAG/qqaunJ1SThHenA==
 
+core-js@=2.6.9, core-js@^2.6.5:
+  version "2.6.9"
+  resolved "https://registry.yarnpkg.com/core-js/-/core-js-2.6.9.tgz#6b4b214620c834152e179323727fc19741b084f2"
+  integrity sha512-HOpZf6eXmnl7la+cUdMnLvUxKNqLUzJvgIziQ0DiF3JwSImNphIqdGqzj6hIKyX04MmV0poclQ7+wjWvxQyR2A==
+
 core-js@^1.0.0:
   version "1.2.7"
   resolved "https://registry.yarnpkg.com/core-js/-/core-js-1.2.7.tgz#652294c14651db28fa93bd2d5ff2983a4f08c636"
@@ -2956,11 +2957,6 @@ core-js@^2.5.7:
   resolved "https://registry.yarnpkg.com/core-js/-/core-js-2.6.5.tgz#44bc8d249e7fb2ff5d00e0341a7ffb94fbf67895"
   integrity sha512-klh/kDpwX8hryYL14M9w/xei6vrv6sE8gTHDG7/T/+SEovB/G4ejwcfE/CBzO6Edsu+OETZMZ3wcX/EjUkrl5A==
 
-core-js@^2.6.5, core-js@^2.6.9:
-  version "2.6.9"
-  resolved "https://registry.yarnpkg.com/core-js/-/core-js-2.6.9.tgz#6b4b214620c834152e179323727fc19741b084f2"
-  integrity sha512-HOpZf6eXmnl7la+cUdMnLvUxKNqLUzJvgIziQ0DiF3JwSImNphIqdGqzj6hIKyX04MmV0poclQ7+wjWvxQyR2A==
-
 core-util-is@1.0.2, core-util-is@~1.0.0:
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.2.tgz#b5fd54220aa2bc5ab57aab7140c940754503c1a7"
@@ -3271,9 +3267,10 @@ cssstyle@^1.0.0:
   dependencies:
     cssom "0.3.x"
 
-csv-to-markdown-table@^0.5.0:
-  version "0.5.0"
-  resolved "https://registry.yarnpkg.com/csv-to-markdown-table/-/csv-to-markdown-table-0.5.0.tgz#df7b5fd2d7d433319cec2fc01f3213c945de99f6"
+csv-to-markdown-table@^1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/csv-to-markdown-table/-/csv-to-markdown-table-1.0.1.tgz#43da1b0c0c483faa10a23921abc5e47a48e0daba"
+  integrity sha512-sw7oHNTBvmvztdDp5ZdIA3FPOy7fVol08hPgdSfVky4D1bcIoKwSiUeB/3G99mSaHnZh7wgCHcT7wAmyiyiaQA==
 
 currently-unhandled@^0.4.1:
   version "0.4.1"
@@ -4343,13 +4340,13 @@ express-session@^1.16.1:
     safe-buffer "5.1.2"
     uid-safe "~2.1.5"
 
-express-validator@^5.3.1:
-  version "5.3.1"
-  resolved "https://registry.yarnpkg.com/express-validator/-/express-validator-5.3.1.tgz#6f42c6d52554441b0360c40ccfb555b1770affe2"
-  integrity sha512-g8xkipBF6VxHbO1+ksC7nxUU7+pWif0+OZXjZTybKJ/V0aTVhuCoHbyhIPgSYVldwQLocGExPtB2pE0DqK4jsw==
+express-validator@^6.1.1:
+  version "6.1.1"
+  resolved "https://registry.yarnpkg.com/express-validator/-/express-validator-6.1.1.tgz#2ac81c3a11ce670da6f85a39af6f726a587ee2e7"
+  integrity sha512-AF6YOhdDiCU7tUOO/OHp2W++I3qpYX7EInMmEEcRGOjs+qoubwgc5s6Wo3OQgxwsWRGCxXlrF73SIDEmY4y3wg==
   dependencies:
-    lodash "^4.17.10"
-    validator "^10.4.0"
+    lodash "^4.17.11"
+    validator "^11.0.0"
 
 express-webpack-assets@^0.1.0:
   version "0.1.0"
@@ -7050,12 +7047,7 @@ lodash.has@^4.0, lodash.has@^4.5.2:
   version "4.5.2"
   resolved "https://registry.yarnpkg.com/lodash.has/-/lodash.has-4.5.2.tgz#d19f4dc1095058cccbe2b0cdf4ee0fe4aa37c862"
 
-lodash.isboolean@^3.0.3:
-  version "3.0.3"
-  resolved "https://registry.yarnpkg.com/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz#6c2e171db2a257cd96802fd43b01b20d5f5870f6"
-  integrity sha1-bC4XHbKiV82WgC/UOwGyDV9YcPY=
-
-lodash.isequal@^4.0.0, lodash.isequal@^4.5.0:
+lodash.isequal@^4.0.0:
   version "4.5.0"
   resolved "https://registry.yarnpkg.com/lodash.isequal/-/lodash.isequal-4.5.0.tgz#415c4478f2bcc30120c22ce10ed3226f7d3e18e0"
   integrity sha1-QVxEePK8wwEgwizhDtMib30+GOA=
@@ -7064,11 +7056,6 @@ lodash.isfinite@^3.3.2:
   version "3.3.2"
   resolved "https://registry.yarnpkg.com/lodash.isfinite/-/lodash.isfinite-3.3.2.tgz#fb89b65a9a80281833f0b7478b3a5104f898ebb3"
 
-lodash.isobject@^3.0.2:
-  version "3.0.2"
-  resolved "https://registry.yarnpkg.com/lodash.isobject/-/lodash.isobject-3.0.2.tgz#3c8fb8d5b5bf4bf90ae06e14f2a530a4ed935e1d"
-  integrity sha1-PI+41bW/S/kK4G4U8qUwpO2TXh0=
-
 lodash.memoize@^4.1.2:
   version "4.1.2"
   resolved "https://registry.yarnpkg.com/lodash.memoize/-/lodash.memoize-4.1.2.tgz#bcc6c49a42a2840ed997f323eada5ecd182e0bfe"
@@ -7286,15 +7273,16 @@ markdown-it-toc-and-anchor-with-slugid@^1.1.4:
     clone "^2.1.0"
     uslug "^1.0.4"
 
-markdown-it@^8.4.0:
-  version "8.4.0"
-  resolved "https://registry.yarnpkg.com/markdown-it/-/markdown-it-8.4.0.tgz#e2400881bf171f7018ed1bd9da441dac8af6306d"
+markdown-it@^9.0.1:
+  version "9.0.1"
+  resolved "https://registry.yarnpkg.com/markdown-it/-/markdown-it-9.0.1.tgz#aafe363c43718720b6575fd10625cde6e4ff2d47"
+  integrity sha512-XC9dMBHg28Xi7y5dPuLjM61upIGPJG8AiHNHYqIaXER2KNnn7eKnM5/sF0ImNnyoV224Ogn9b1Pck8VH4k0bxw==
   dependencies:
     argparse "^1.0.7"
     entities "~1.1.1"
     linkify-it "^2.0.0"
     mdurl "^1.0.1"
-    uc.micro "^1.0.3"
+    uc.micro "^1.0.5"
 
 markdown-table@^1.1.0:
   version "1.1.2"
@@ -7548,10 +7536,10 @@ mimic-response@^1.0.0:
   resolved "https://registry.yarnpkg.com/mimic-response/-/mimic-response-1.0.1.tgz#4923538878eef42063cb8a3e3b0798781487ab1b"
   integrity sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==
 
-mini-css-extract-plugin@^0.7.0:
-  version "0.7.0"
-  resolved "https://registry.yarnpkg.com/mini-css-extract-plugin/-/mini-css-extract-plugin-0.7.0.tgz#5ba8290fbb4179a43dd27cca444ba150bee743a0"
-  integrity sha512-RQIw6+7utTYn8DBGsf/LpRgZCJMpZt+kuawJ/fju0KiOL6nAaTBNmCJwS7HtwSCXfS47gCkmtBFS7HdsquhdxQ==
+mini-css-extract-plugin@^0.8.0:
+  version "0.8.0"
+  resolved "https://registry.yarnpkg.com/mini-css-extract-plugin/-/mini-css-extract-plugin-0.8.0.tgz#81d41ec4fe58c713a96ad7c723cdb2d0bd4d70e1"
+  integrity sha512-MNpRGbNA52q6U92i0qbVpQNsgk7LExy41MdAlG84FeytfDOtRIf/mCHdEgG8rpTKOaNKiqUnZdlptF469hxqOw==
   dependencies:
     loader-utils "^1.1.0"
     normalize-url "1.9.1"
@@ -7782,11 +7770,6 @@ morgan@^1.9.0:
     on-finished "~2.3.0"
     on-headers "~1.0.1"
 
-mousetrap@^1.5.2:
-  version "1.6.3"
-  resolved "https://registry.yarnpkg.com/mousetrap/-/mousetrap-1.6.3.tgz#80fee49665fd478bccf072c9d46bdf1bfed3558a"
-  integrity sha512-bd+nzwhhs9ifsUrC2tWaSgm24/oo2c83zaRyZQF06hYA6sANfsXHtnZ19AbbbDXCDzeH5nZBSQ4NvCjgD62tJA==
-
 move-concurrently@^1.0.1:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/move-concurrently/-/move-concurrently-1.0.1.tgz#be2c005fda32e0b29af1f05d7c4b33214c701f92"
@@ -9509,14 +9492,6 @@ prop-types@^15.5.10, prop-types@^15.5.8:
     loose-envify "^1.3.1"
     object-assign "^4.1.1"
 
-prop-types@^15.6.0, prop-types@^15.7.2:
-  version "15.7.2"
-  resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.7.2.tgz#52c41e75b8c87e72b9d9360e0206b99dcbffa6c5"
-  dependencies:
-    loose-envify "^1.4.0"
-    object-assign "^4.1.1"
-    react-is "^16.8.1"
-
 prop-types@^15.6.1:
   version "15.6.1"
   resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.6.1.tgz#36644453564255ddda391191fb3a125cbdf654ca"
@@ -9525,6 +9500,14 @@ prop-types@^15.6.1:
     loose-envify "^1.3.1"
     object-assign "^4.1.1"
 
+prop-types@^15.7.2:
+  version "15.7.2"
+  resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.7.2.tgz#52c41e75b8c87e72b9d9360e0206b99dcbffa6c5"
+  dependencies:
+    loose-envify "^1.4.0"
+    object-assign "^4.1.1"
+    react-is "^16.8.1"
+
 proxy-addr@~2.0.2:
   version "2.0.2"
   resolved "https://registry.yarnpkg.com/proxy-addr/-/proxy-addr-2.0.2.tgz#6571504f47bb988ec8180253f85dd7e14952bdec"
@@ -9790,16 +9773,12 @@ react-frame-component@^4.0.0:
   version "4.0.0"
   resolved "https://registry.yarnpkg.com/react-frame-component/-/react-frame-component-4.0.0.tgz#57d51cdb2da3b204cc34577349f9f5bb84a76aac"
 
-react-hotkeys@^1.1.4:
-  version "1.1.4"
-  resolved "https://registry.yarnpkg.com/react-hotkeys/-/react-hotkeys-1.1.4.tgz#a0712aa2e0c03a759fd7885808598497a4dace72"
-  integrity sha1-oHEqouDAOnWf14hYCFmEl6TaznI=
+react-hotkeys@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/react-hotkeys/-/react-hotkeys-2.0.0.tgz#a7719c7340cbba888b0e9184f806a9ec0ac2c53f"
+  integrity sha512-3n3OU8vLX/pfcJrR3xJ1zlww6KS1kEJt0Whxc4FiGV+MJrQ1mYSYI3qS/11d2MJDFm8IhOXMTFQirfu6AVOF6Q==
   dependencies:
-    lodash.isboolean "^3.0.3"
-    lodash.isequal "^4.5.0"
-    lodash.isobject "^3.0.2"
-    mousetrap "^1.5.2"
-    prop-types "^15.6.0"
+    prop-types "^15.6.1"
 
 react-i18next@^10.6.1:
   version "10.6.1"
@@ -11940,10 +11919,15 @@ uberproto@^1.1.0:
   version "1.2.0"
   resolved "https://registry.yarnpkg.com/uberproto/-/uberproto-1.2.0.tgz#61d4eab024f909c4e6ea52be867c4894a4beeb76"
 
-uc.micro@^1.0.1, uc.micro@^1.0.3:
+uc.micro@^1.0.1:
   version "1.0.5"
   resolved "https://registry.yarnpkg.com/uc.micro/-/uc.micro-1.0.5.tgz#0c65f15f815aa08b560a61ce8b4db7ffc3f45376"
 
+uc.micro@^1.0.5:
+  version "1.0.6"
+  resolved "https://registry.yarnpkg.com/uc.micro/-/uc.micro-1.0.6.tgz#9c411a802a409a91fc6cf74081baba34b24499ac"
+  integrity sha512-8Y75pvTYkLJW2hWQHXxoqRgV7qb9B+9vFEtidML+7koHUFapnVJAZ6cKs+Qjz5Aw3aZWHMC6u0wJE3At+nSGwA==
+
 uglify-js@2.6.0:
   version "2.6.0"
   resolved "https://registry.yarnpkg.com/uglify-js/-/uglify-js-2.6.0.tgz#25eaa1cc3550e39410ceefafd1cfbb6b6d15f001"
@@ -12273,11 +12257,16 @@ validator@>=11.0.0:
   resolved "https://registry.yarnpkg.com/validator/-/validator-11.0.0.tgz#fb10128bfb1fd14ce4ed36b79fc94289eae70667"
   integrity sha512-+wnGLYqaKV2++nUv60uGzUJyJQwYVOin6pn1tgEiFCeCQO60yeu3Og9/yPccbBX574kxIcEJicogkzx6s6eyag==
 
-validator@^10.0.0, validator@^10.4.0:
+validator@^10.0.0:
   version "10.11.0"
   resolved "https://registry.yarnpkg.com/validator/-/validator-10.11.0.tgz#003108ea6e9a9874d31ccc9e5006856ccd76b228"
   integrity sha512-X/p3UZerAIsbBfN/IwahhYaBbY68EN/UQBWHtsbXGT5bfrH/p4NQzUCG1kF/rtKaNpnJ7jAu6NGTdSNtyNIXMw==
 
+validator@^11.0.0:
+  version "11.1.0"
+  resolved "https://registry.yarnpkg.com/validator/-/validator-11.1.0.tgz#ac18cac42e0aa5902b603d7a5d9b7827e2346ac4"
+  integrity sha512-qiQ5ktdO7CD6C/5/mYV4jku/7qnqzjrxb3C/Q5wR3vGGinHTgJZN/TdFT3ZX4vXhX2R1PXx42fB1cn5W+uJ4lg==
+
 validator@^2.1.0:
   version "2.1.0"
   resolved "https://registry.yarnpkg.com/validator/-/validator-2.1.0.tgz#63276570def208adcf1c032c1f4e6a17d2bd8d8b"