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

Merge branch 'reactify-admin/security' into reactify-security-reflect-api

# Conflicts:
#	src/client/js/components/Admin/Security/SecurityManagement.jsx
#	src/client/js/services/AdminGeneralSecurityContainer.js
WESEEK Kaito 6 лет назад
Родитель
Сommit
0dd0632697

+ 4 - 2
CHANGES.md

@@ -20,11 +20,13 @@ Upgrading Guide: https://docs.growi.org/en/admin-guide/upgrading/36x.html
 * Support: Upgrade libs
     * growi-commons
 
-
-
 ## 3.5.23
 
+* Fix: Global Notification failed to send e-mail
+* Fix: Pagination is not working for trash list
 * Fix: Healthcheck API with `?connectToMiddlewares` returns error
+* Support: Upgrade libs
+    * growi-commons
 
 ## 3.5.22
 

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

@@ -438,7 +438,7 @@
     "Load plugins": "Load plugins",
     "Enable": "Enable",
     "Disable": "Disable",
-    "Use env var if empty": "If the value in the database is empty, the value of the environment variable <code>%s</code> is used."
+    "Use env var if empty": "If the value in the database is empty, the value of the environment variable <code>{{env}}</code> is used."
   },
 
   "security_setting": {
@@ -490,8 +490,8 @@
     "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>.",
     "Treat email matching as identical": "Automatically bind external accounts newly logged in to local accounts when <code>%s</code> match",
     "Treat email matching as identical_warn": "WARNING: Be aware of security because the system treats the same user as a match of <code>%s</code>.",
-    "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.",
+    "Use env var if empty": "Use env var <code>{{env}}</code> if empty",
+    "Use default if both are empty": "If both ​​are empty, the default value <code>{{target}}</code> is used.",
     "missing mandatory configs": "The following mandatory items are not set in either database nor environment variables.",
     "Local": {
       "name": "ID/Password",
@@ -534,7 +534,7 @@
       "enable_saml":"enable SAML",
       "id_detail": "Specification of the name of attribute which can identify the user in SAML Identity Provider",
       "username_detail": "Specification of mappings for <code>username</code> when creating new users",
-      "mapping_detail": "Specification of mappings for %s when creating new users",
+      "mapping_detail": "Specification of mappings for {{target}} when creating new users",
       "cert_detail": "PEM-encoded X.509 signing certificate to validate the response from IdP",
       "Use env var if empty": "If the value in the database is empty, the value of the environment variable <code>{{env}}</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>{{env}}</code> ."
@@ -545,6 +545,7 @@
       "desc_2": "User will be automatically generated if not exist."
     },
     "OAuth": {
+      "enable_oidc": "enable OIDC",
       "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": {

+ 14 - 6
resource/locales/ja/translation.json

@@ -437,7 +437,7 @@
     "Load plugins": "プラグインを読み込む",
     "Enable": "有効",
     "Disable": "無効",
-    "Use env var if empty": "データベース側の値が空の場合、環境変数 <code>%s</code> の値を利用します"
+    "Use env var if empty": "データベース側の値が空の場合、環境変数 <code>{{env}}</code> の値を利用します"
    },
 
   "security_setting": {
@@ -483,10 +483,10 @@
     "optional": "オプション",
     "Treat username matching as identical": "新規ログイン時、<code>username</code> が一致したローカルアカウントが存在した場合は自動的に紐付ける",
     "Treat username matching as identical_warn": "警告: <code>username</code> の一致を以て同一ユーザーであるとみなすので、セキュリティに注意してください",
-    "Treat email matching as identical": "新規ログイン時、<code>%s</code> が一致したローカルアカウントが存在した場合は自動的に紐付ける",
-    "Treat email matching as identical_warn": "警告: <code>%s</code> の一致を以て同一ユーザーであるとみなすので、セキュリティに注意してください",
-    "Use env var if empty": "空の場合、環境変数 <code>%s</code> を利用します",
-    "Use default if both are empty": "どちらの値も空の場合、デフォルト値 <code>%s</code> を利用します",
+    "Treat email matching as identical": "新規ログイン時、<code>email</code> が一致したローカルアカウントが存在した場合は自動的に紐付ける",
+    "Treat email matching as identical_warn": "警告: <code>email</code> の一致を以て同一ユーザーであるとみなすので、セキュリティに注意してください",
+    "Use env var if empty": "空の場合、環境変数 <code>{{env}}</code> を利用します",
+    "Use default if both are empty": "どちらの値も空の場合、デフォルト値 <code>{{target}}</code> を利用します",
     "missing mandatory configs": "以下の必須項目の値がデータベースと環境変数のどちらにも設定されていません",
     "Local": {
       "name": "ID/Password",
@@ -529,7 +529,7 @@
       "enable_saml": "SAML を有効にする",
       "id_detail": "SAML Identity プロバイダ内で一意に識別可能な値を格納している属性",
       "username_detail": "新規ユーザーのアカウント名(<code>username</code>)に関連付ける属性",
-      "mapping_detail": "新規ユーザーの%sに関連付ける属性",
+      "mapping_detail": "新規ユーザーの{{target}}に関連付ける属性",
       "cert_detail": "IdP からのレスポンスの validation を行うためのPEMエンコードされた X.509 証明書",
       "Use env var if empty": "データベース側の値が空の場合、環境変数 <code>{{env}}</code> の値を利用します",
       "note for the only env option": "現在SAML認証のON/OFFの設定値及びハイライトされている設定値は環境変数の値のみを使用するようになっています<br>この設定を変更する場合は環境変数 <code>{{env}}</code> の値をfalseに変更もしくは削除してください"
@@ -540,6 +540,7 @@
       "desc_2": "ユーザーが存在しなかった場合は自動生成します。"
     },
     "OAuth": {
+      "enable_oidc": "OIDC を有効にする",
       "register": "%sに登録",
       "change_redirect_url": "承認済みのリダイレクトURLに、 <code>%s</code> を入力",
       "Google": {
@@ -567,6 +568,13 @@
         "register_2": "\"Authorization callback URL\"を<code>%s</code>としてGrowiを登録",
         "register_3": "上記フォームにクライアントIDとクライアントシークレットを入力"
       },
+      "OIDC": {
+        "name": "OpenID Connect",
+        "id_detail": "OIDC claims で一意に識別可能な値を格納している属性",
+        "username_detail": "新規ユーザーのアカウント名(<code>username</code>)に関連付ける属性",
+        "name_detail": "新規ユーザー名(<code>name</code>)に関連付ける属性",
+        "mapping_detail": "新規ユーザーの{{target}}に関連付ける属性"
+      },
       "how_to": {
         "google": "Google OAuth の設定方法",
         "github": "GitHub OAuth の設定方法",

+ 3 - 1
src/client/js/app.jsx

@@ -61,6 +61,7 @@ import WebsocketContainer from './services/WebsocketContainer';
 import MarkDownSettingContainer from './services/MarkDownSettingContainer';
 import AdminExternalAccountsContainer from './services/AdminExternalAccountsContainer';
 import AdminSamlSecurityContainer from './services/AdminSamlSecurityContainer';
+import AdminOidcSecurityContainer from './services/AdminOidcSecurityContainer';
 
 const logger = loggerFactory('growi:app');
 
@@ -219,7 +220,8 @@ if (adminSecuritySettingElem != null) {
   const adminGeneralSecurityContainer = new AdminGeneralSecurityContainer(appContainer);
   const adminLdapSecurityContainer = new AdminLdapSecurityContainer(appContainer);
   const adminSamlSecurityContainer = new AdminSamlSecurityContainer(appContainer);
-  const adminSecurityContainers = [adminGeneralSecurityContainer, adminLdapSecurityContainer, adminSamlSecurityContainer];
+  const adminOidcSecurityContainer = new AdminOidcSecurityContainer(appContainer);
+  const adminSecurityContainers = [adminGeneralSecurityContainer, adminLdapSecurityContainer, adminSamlSecurityContainer, adminOidcSecurityContainer];
   ReactDOM.render(
     <Provider inject={[injectableContainers, adminSecurityContainers]}>
       <I18nextProvider i18n={i18n}>

+ 293 - 0
src/client/js/components/Admin/Security/OidcSecuritySetting.jsx

@@ -0,0 +1,293 @@
+/* eslint-disable react/no-danger */
+import React from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+
+import { createSubscribedElement } from '../../UnstatedUtils';
+
+import AppContainer from '../../../services/AppContainer';
+import AdminGeneralSecurityContainer from '../../../services/AdminGeneralSecurityContainer';
+import AdminOidcSecurityContainer from '../../../services/AdminOidcSecurityContainer';
+
+
+class OidcSecurityManagement extends React.Component {
+
+  render() {
+    const { t, adminGeneralSecurityContainer, adminOidcSecurityContainer } = this.props;
+
+    return (
+
+      <React.Fragment>
+
+        <h2 className="alert-anchor border-bottom">
+          { t('security_setting.OAuth.OIDC.name') } { t('security_setting.configuration') }
+        </h2>
+
+        <div className="row mb-5">
+          <strong className="col-xs-3 text-right">{ t('security_setting.OAuth.OIDC.name') }</strong>
+          <div className="col-xs-6 text-left">
+            <div className="checkbox checkbox-success">
+              <input
+                id="isOidcEnabled"
+                type="checkbox"
+                checked={adminGeneralSecurityContainer.state.isOidcEnabled}
+                onChange={() => { adminGeneralSecurityContainer.switchIsOidcEnabled() }}
+              />
+              <label htmlFor="isOidcEnabled">
+                { t('security_setting.OAuth.enable_oidc') }
+              </label>
+            </div>
+          </div>
+        </div>
+
+        <div className="row mb-5">
+          <label className="col-xs-3 text-right">{ t('security_setting.callback_URL') }</label>
+          <div className="col-xs-6">
+            <input
+              className="form-control"
+              type="text"
+              value={adminOidcSecurityContainer.state.callbackUrl}
+              readOnly
+            />
+            <p className="help-block small">{ t('security_setting.desc_of_callback_URL', { AuthName: 'OAuth' }) }</p>
+            {!adminGeneralSecurityContainer.state.appSiteUrl && (
+            <div className="alert alert-danger">
+              <i
+                className="icon-exclamation"
+                // eslint-disable-next-line max-len
+                dangerouslySetInnerHTML={{ __html: t('security_setting.alert_siteUrl_is_not_set', { link: `<a href="/admin/app">${t('App settings')}<i class="icon-login"></i></a>` }) }}
+              />
+            </div>
+            )}
+          </div>
+        </div>
+
+        {adminGeneralSecurityContainer.state.isOidcEnabled && (
+        <React.Fragment>
+
+          <div className="row mb-5">
+            <label htmlFor="oidcProviderName" className="col-xs-3 text-right">{ t('security_setting.providerName') }</label>
+            <div className="col-xs-6">
+              <input
+                className="form-control"
+                type="text"
+                name="oidcProviderName"
+                value={adminOidcSecurityContainer.state.oidcProviderName}
+                onChange={e => adminOidcSecurityContainer.changeOidcProviderName(e.target.value)}
+              />
+            </div>
+          </div>
+
+          <div className="row mb-5">
+            <label htmlFor="oidcIssuerHost" className="col-xs-3 text-right">{ t('security_setting.issuerHost') }</label>
+            <div className="col-xs-6">
+              <input
+                className="form-control"
+                type="text"
+                name="oidcIssuerHost"
+                value={adminOidcSecurityContainer.state.oidcIssuerHost}
+                onChange={e => adminOidcSecurityContainer.changeOidcIssuerHost(e.target.value)}
+              />
+              <p className="help-block">
+                <small dangerouslySetInnerHTML={{ __html: t('security_setting.Use env var if empty', { env: 'OAUTH_OIDC_ISSUER_HOST' }) }} />
+              </p>
+            </div>
+          </div>
+
+          <div className="row mb-5">
+            <label htmlFor="oidcClientId" className="col-xs-3 text-right">{ t('security_setting.clientID') }</label>
+            <div className="col-xs-6">
+              <input
+                className="form-control"
+                type="text"
+                name="oidcClientId"
+                value={adminOidcSecurityContainer.state.oidcClientId}
+                onChange={e => adminOidcSecurityContainer.changeOidcClientId(e.target.value)}
+              />
+              <p className="help-block">
+                <small dangerouslySetInnerHTML={{ __html: t('security_setting.Use env var if empty', { env: 'OAUTH_OIDC_CLIENT_ID' }) }} />
+              </p>
+            </div>
+          </div>
+
+          <div className="row mb-5">
+            <label htmlFor="oidcClientSecret" className="col-xs-3 text-right">{ t('security_setting.client_secret') }</label>
+            <div className="col-xs-6">
+              <input
+                className="form-control"
+                type="text"
+                name="oidcClientSecret"
+                value={adminOidcSecurityContainer.state.oidcClientSecret}
+                onChange={e => adminOidcSecurityContainer.changeOidcClientSecret(e.target.value)}
+              />
+              <p className="help-block">
+                <small dangerouslySetInnerHTML={{ __html: t('security_setting.Use env var if empty', { env: 'OAUTH_OIDC_CLIENT_SECRET' }) }} />
+              </p>
+            </div>
+          </div>
+
+          <h3 className="alert-anchor border-bottom">
+              Attribute Mapping ({ t('security_setting.optional') })
+          </h3>
+
+          <div className="row mb-5">
+            <label htmlFor="oidcAttrMapId" className="col-xs-3 text-right">Identifier</label>
+            <div className="col-xs-6">
+              <input
+                className="form-control"
+                type="text"
+                name="oidcAttrMapId"
+                value={adminOidcSecurityContainer.state.oidcAttrMapId}
+                onChange={e => adminOidcSecurityContainer.changeOidcAttrMapId(e.target.value)}
+              />
+              <p className="help-block">
+                <small dangerouslySetInnerHTML={{ __html: t('security_setting.OAuth.OIDC.id_detail') }} />
+              </p>
+            </div>
+          </div>
+
+          <div className="row mb-5">
+            <label htmlFor="oidcAttrMapUserName" className="col-xs-3 text-right">{ t('username') }</label>
+            <div className="col-xs-6">
+              <input
+                className="form-control"
+                type="text"
+                name="oidcAttrMapUserName"
+                value={adminOidcSecurityContainer.state.oidcAttrMapUserName}
+                onChange={e => adminOidcSecurityContainer.changeOidcAttrMapUserName(e.target.value)}
+              />
+              <p className="help-block">
+                <small dangerouslySetInnerHTML={{ __html: t('security_setting.OAuth.OIDC.username_detail') }} />
+              </p>
+            </div>
+          </div>
+
+          <div className="row mb-5">
+            <label htmlFor="oidcAttrMapName" className="col-xs-3 text-right">{ t('Name') }</label>
+            <div className="col-xs-6">
+              <input
+                className="form-control"
+                type="text"
+                name="oidcAttrMapName"
+                value={adminOidcSecurityContainer.state.oidcAttrMapName}
+                onChange={e => adminOidcSecurityContainer.changeOidcAttrMapName(e.target.value)}
+              />
+              <p className="help-block">
+                <small dangerouslySetInnerHTML={{ __html: t('security_setting.OAuth.OIDC.name_detail') }} />
+              </p>
+            </div>
+          </div>
+
+          <div className="row mb-5">
+            <label htmlFor="oidcAttrMapEmail" className="col-xs-3 text-right">{ t('Email') }</label>
+            <div className="col-xs-6">
+              <input
+                className="form-control"
+                type="text"
+                name="oidcAttrMapEmail"
+                value={adminOidcSecurityContainer.state.oidcAttrMapEmail}
+                onChange={e => adminOidcSecurityContainer.changeOidcAttrMapEmail(e.target.value)}
+              />
+              <p className="help-block">
+                <small dangerouslySetInnerHTML={{ __html: t('security_setting.OAuth.OIDC.mapping_detail', { target: t('Email') }) }} />
+              </p>
+            </div>
+          </div>
+
+          <div className="row mb-5">
+            <label className="col-xs-3 text-right">{ t('security_setting.callback_URL') }</label>
+            <div className="col-xs-6">
+              <input
+                className="form-control"
+                type="text"
+                value={adminOidcSecurityContainer.state.callbackUrl}
+                readOnly
+              />
+              <p className="help-block small">{ t('security_setting.desc_of_callback_URL', { AuthName: 'OAuth' }) }</p>
+              {!adminGeneralSecurityContainer.state.appSiteUrl && (
+              <div className="alert alert-danger">
+                <i
+                  className="icon-exclamation"
+                // eslint-disable-next-line max-len
+                  dangerouslySetInnerHTML={{ __html: t('security_setting.alert_siteUrl_is_not_set', { link: `<a href="/admin/app">${t('App settings')}<i class="icon-login"></i></a>` }) }}
+                />
+              </div>
+            )}
+            </div>
+          </div>
+
+          <div className="row mb-3">
+            <div className="col-xs-offset-3 col-xs-6 text-left">
+              <div className="checkbox checkbox-success">
+                <input
+                  id="bindByUserName-oidc"
+                  type="checkbox"
+                  checked={adminOidcSecurityContainer.state.isSameUsernameTreatedAsIdenticalUser}
+                  onChange={() => { adminOidcSecurityContainer.switchIsSameUsernameTreatedAsIdenticalUser() }}
+                />
+                <label
+                  htmlFor="bindByUserName-oidc"
+                  dangerouslySetInnerHTML={{ __html: t('security_setting.Treat username matching as identical') }}
+                />
+              </div>
+              <p className="help-block">
+                <small dangerouslySetInnerHTML={{ __html: t('security_setting.Treat username matching as identical_warn') }} />
+              </p>
+            </div>
+          </div>
+
+          <div className="row mb-5">
+            <div className="col-xs-offset-3 col-xs-6 text-left">
+              <div className="checkbox checkbox-success">
+                <input
+                  id="bindByEmail-oidc"
+                  type="checkbox"
+                  checked={adminOidcSecurityContainer.state.isSameEmailTreatedAsIdenticalUser}
+                  onChange={() => { adminOidcSecurityContainer.switchIsSameEmailTreatedAsIdenticalUser() }}
+                />
+                <label
+                  htmlFor="bindByEmail-oidc"
+                  dangerouslySetInnerHTML={{ __html: t('security_setting.Treat email matching as identical') }}
+                />
+              </div>
+              <p className="help-block">
+                <small dangerouslySetInnerHTML={{ __html: t('security_setting.Treat email matching as identical_warn') }} />
+              </p>
+            </div>
+          </div>
+
+        </React.Fragment>
+        )}
+
+        <hr />
+
+        <div style={{ minHeight: '300px' }}>
+          <h4>
+            <i className="icon-question" aria-hidden="true"></i>
+            <a href="#collapseHelpForOidcOauth" data-toggle="collapse">{ t('security_setting.OAuth.how_to.oidc') }</a>
+          </h4>
+          <ol id="collapseHelpForOidcOauth" className="collapse">
+            <li>{ t('security_setting.OAuth.OIDC.register_1') }</li>
+            <li>{ t('security_setting.OAuth.OIDC.register_2') }</li>
+            <li>{ t('security_setting.OAuth.OIDC.register_3') }</li>
+          </ol>
+        </div>
+
+      </React.Fragment>
+    );
+  }
+
+}
+
+OidcSecurityManagement.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  adminGeneralSecurityContainer: PropTypes.instanceOf(AdminGeneralSecurityContainer).isRequired,
+  adminOidcSecurityContainer: PropTypes.instanceOf(AdminOidcSecurityContainer).isRequired,
+};
+
+const OidcSecurityManagementWrapper = (props) => {
+  return createSubscribedElement(OidcSecurityManagement, props, [AppContainer, AdminGeneralSecurityContainer, AdminOidcSecurityContainer]);
+};
+
+export default withTranslation()(OidcSecurityManagementWrapper);

+ 206 - 1
src/client/js/components/Admin/Security/SamlSecuritySetting.jsx

@@ -19,6 +19,10 @@ class SamlSecurityManagement extends React.Component {
     return (
       <React.Fragment>
 
+        <h2 className="alert-anchor border-bottom">
+          { t('security_setting.SAML.name') } { t('security_setting.configuration') }
+        </h2>
+
         {useOnlyEnvVars && (
         <p
           className="alert alert-info"
@@ -189,7 +193,208 @@ pWVdnzS1VCO8fKsJ7YYIr+JmHvseph3kFUOI5RqkCcMZlKUv83aUThsTHw==
               </tbody>
             </table>
 
-            {/* TODO GW-635 Attribute Mapping */}
+            <h3 className="alert-anchor border-bottom">
+              Attribute Mapping
+            </h3>
+
+            <table className={`table settings-table ${adminSamlSecurityContainer.state.useOnlyEnvVars && 'use-only-env-vars'}`}>
+              <colgroup>
+                <col className="item-name" />
+                <col className="from-db" />
+                <col className="from-env-vars" />
+              </colgroup>
+              <thead>
+                <tr><th></th><th>Database</th><th>Environment variables</th></tr>
+              </thead>
+              <tbody>
+                <tr>
+                  <th>{ t('security_setting.form_item_name.attrMapId') }</th>
+                  <td>
+                    <input
+                      className="form-control"
+                      type="text"
+                      readOnly={useOnlyEnvVars}
+                      value={adminSamlSecurityContainer.state.samlDbAttrMapId}
+                      onChange={e => adminSamlSecurityContainer.changeSamlDbAttrMapId(e.target.value)}
+                    />
+                    <p className="help-block">
+                      <small>
+                        { t('security_setting.SAML.id_detail') }
+                      </small>
+                    </p>
+                  </td>
+                  <td>
+                    <input
+                      className="form-control"
+                      type="text"
+                      value={adminSamlSecurityContainer.state.samlEnvVarAttrMapId}
+                      readOnly
+                    />
+                    <p className="help-block">
+                      <small dangerouslySetInnerHTML={{ __html: t('security_setting.SAML.Use env var if empty', { env: 'SAML_ATTR_MAPPING_ID' }) }} />
+                    </p>
+                  </td>
+                </tr>
+                <tr>
+                  <th>{ t('security_setting.form_item_name.attrMapUsername') }</th>
+                  <td>
+                    <input
+                      className="form-control"
+                      type="text"
+                      readOnly={useOnlyEnvVars}
+                      value={adminSamlSecurityContainer.state.samlDbAttrMapUserName}
+                      onChange={e => adminSamlSecurityContainer.changeSamlDbAttrMapUserName(e.target.value)}
+                    />
+                    <p className="help-block">
+                      <small dangerouslySetInnerHTML={{ __html: t('security_setting.SAML.username_detail') }} />
+                    </p>
+                  </td>
+                  <td>
+                    <input
+                      className="form-control"
+                      type="text"
+                      value={adminSamlSecurityContainer.state.samlEnvVarAttrMapUserName}
+                      readOnly
+                    />
+                    <p className="help-block">
+                      <small dangerouslySetInnerHTML={{ __html: t('security_setting.SAML.Use env var if empty', { env: 'SAML_ATTR_MAPPING_USERNAME' }) }} />
+                    </p>
+                  </td>
+                </tr>
+                <tr>
+                  <th>{ t('security_setting.form_item_name.attrMapMail') }</th>
+                  <td>
+                    <input
+                      className="form-control"
+                      type="text"
+                      readOnly={useOnlyEnvVars}
+                      value={adminSamlSecurityContainer.state.samlDbAttrMapMail}
+                      onChange={e => adminSamlSecurityContainer.changeSamlDbAttrMapMail(e.target.value)}
+                    />
+                    <p className="help-block">
+                      <small dangerouslySetInnerHTML={{ __html: t('security_setting.SAML.mapping_detail', { target: 'Email' }) }} />
+                    </p>
+                  </td>
+                  <td>
+                    <input
+                      className="form-control"
+                      type="text"
+                      value={adminSamlSecurityContainer.state.samlEnvVarAttrMapMail}
+                      readOnly
+                    />
+                    <p className="help-block">
+                      <small dangerouslySetInnerHTML={{ __html: t('security_setting.SAML.Use env var if empty', { env: 'SAML_ATTR_MAPPING_MAIL' }) }} />
+                    </p>
+                  </td>
+                </tr>
+                <tr>
+                  <th>{ t('security_setting.form_item_name.attrMapFirstName') }</th>
+                  <td>
+                    <input
+                      className="form-control"
+                      type="text"
+                      readOnly={useOnlyEnvVars}
+                      value={adminSamlSecurityContainer.state.samlDbAttrMapFirstName}
+                      onChange={e => adminSamlSecurityContainer.changeSamlDbAttrMapFirstName(e.target.value)}
+                    />
+                    <p className="help-block">
+                      {/* eslint-disable-next-line max-len */}
+                      <small dangerouslySetInnerHTML={{ __html: t('security_setting.SAML.mapping_detail', { target: t('security_setting.form_item_name.attrMapFirstName') }) }} />
+                    </p>
+                  </td>
+                  <td>
+                    <input
+                      className="form-control"
+                      type="text"
+                      value={adminSamlSecurityContainer.state.samlEnvVarAttrMapFirstName}
+                      readOnly
+                    />
+                    <p className="help-block">
+                      <small>
+                        <span dangerouslySetInnerHTML={{ __html: t('security_setting.SAML.Use env var if empty', { env: 'SAML_ATTR_MAPPING_FIRST_NAME' }) }} />
+                        <br />
+                        <span dangerouslySetInnerHTML={{ __html: t('security_setting.Use default if both are empty', { target: 'firstName' }) }} />
+                      </small>
+                    </p>
+                  </td>
+                </tr>
+                <tr>
+                  <th>{ t('security_setting.form_item_name.attrMapLastName') }</th>
+                  <td>
+                    <input
+                      className="form-control"
+                      type="text"
+                      readOnly={useOnlyEnvVars}
+                      value={adminSamlSecurityContainer.state.samlDbAttrMapLastName}
+                      onChange={e => adminSamlSecurityContainer.changeSamlDbAttrMapLastName(e.target.value)}
+                    />
+                    <p className="help-block">
+                      {/* eslint-disable-next-line max-len */}
+                      <small dangerouslySetInnerHTML={{ __html: t('security_setting.SAML.mapping_detail', { target: t('security_setting.form_item_name.attrMapLastName') }) }} />
+                    </p>
+                  </td>
+                  <td>
+                    <input
+                      className="form-control"
+                      type="text"
+                      value={adminSamlSecurityContainer.state.samlEnvVarAttrMapLastName}
+                      readOnly
+                    />
+                    <p className="help-block">
+                      <small>
+                        <span dangerouslySetInnerHTML={{ __html: t('security_setting.SAML.Use env var if empty', { env: 'SAML_ATTR_MAPPING_LAST_NAME' }) }} />
+                        <br />
+                        <span dangerouslySetInnerHTML={{ __html: t('security_setting.Use default if both are empty', { target: 'lastName' }) }} />
+                      </small>
+                    </p>
+                  </td>
+                </tr>
+              </tbody>
+            </table>
+
+            <h3 className="alert-anchor border-bottom">
+              Attribute Mapping Options
+            </h3>
+
+            <div className="row mb-5">
+              <div className="col-xs-offset-3 col-xs-6 text-left">
+                <div className="checkbox checkbox-success">
+                  <input
+                    id="bindByUserName-SAML"
+                    type="checkbox"
+                    checked={adminSamlSecurityContainer.state.isSameUsernameTreatedAsIdenticalUser}
+                    onChange={() => { adminSamlSecurityContainer.switchIsSameUsernameTreatedAsIdenticalUser() }}
+                  />
+                  <label
+                    htmlFor="bindByUserName-SAML"
+                    dangerouslySetInnerHTML={{ __html: t('security_setting.Treat username matching as identical') }}
+                  />
+                </div>
+                <p className="help-block">
+                  <small dangerouslySetInnerHTML={{ __html: t('security_setting.Treat username matching as identical_warn') }} />
+                </p>
+              </div>
+            </div>
+
+            <div className="row mb-5">
+              <div className="col-xs-offset-3 col-xs-6 text-left">
+                <div className="checkbox checkbox-success">
+                  <input
+                    id="bindByEmail-SAML"
+                    type="checkbox"
+                    checked={adminSamlSecurityContainer.state.isSameEmailTreatedAsIdenticalUser}
+                    onChange={() => { adminSamlSecurityContainer.switchIsSameEmailTreatedAsIdenticalUser() }}
+                  />
+                  <label
+                    htmlFor="bindByEmail-SAML"
+                    dangerouslySetInnerHTML={{ __html: t('security_setting.Treat email matching as identical') }}
+                  />
+                </div>
+                <p className="help-block">
+                  <small dangerouslySetInnerHTML={{ __html: t('security_setting.Treat email matching as identical_warn') }} />
+                </p>
+              </div>
+            </div>
 
           </React.Fragment>
 

+ 2 - 1
src/client/js/components/Admin/Security/SecurityManagement.jsx

@@ -8,6 +8,7 @@ import AppContainer from '../../../services/AppContainer';
 import LdapSecuritySetting from './LdapSecuritySetting';
 import LocalSecuritySetting from './LocalSecuritySetting';
 import SamlSecuritySetting from './SamlSecuritySetting';
+import OidcSecuritySetting from './OidcSecuritySetting';
 import SecuritySetting from './SecuritySetting';
 
 class SecurityManagement extends React.Component {
@@ -77,7 +78,7 @@ class SecurityManagement extends React.Component {
                 <SamlSecuritySetting />
               </div>
               <div id="passport-oidc" className="tab-pane" role="tabpanel">
-                {/* TODO GW-545 reactify oidc.html */}
+                <OidcSecuritySetting />
               </div>
               <div id="passport-basic" className="tab-pane" role="tabpanel">
                 {/* TODO GW-546 reactify basic.html */}

+ 8 - 0
src/client/js/services/AdminGeneralSecurityContainer.js

@@ -24,11 +24,13 @@ export default class AdminGeneralSecurityContainer extends Container {
       isHideRestrictedByOwner: true,
       isHideRestrictedByGroup: true,
       useOnlyEnvVarsForSomeOptions: true,
+      appSiteUrl: '',
       isLocalEnabled: true,
       registrationMode: 'open',
       registrationWhiteList: '',
       isLdapEnabled: true,
       isSamlEnabled: true,
+      isOidcEnabled: true,
     };
 
     this.init();
@@ -82,6 +84,11 @@ export default class AdminGeneralSecurityContainer extends Container {
   }
 
   /**
+   * Switch Oidc enabled
+   */
+  switchIsOidcEnabled() {
+    this.setState({ isOidcEnabled: !this.state.isOidcEnabled });
+  }
    * Change restrictGuestMode
    */
   changeRestrictGuestMode(restrictGuestModeLabel) {
@@ -149,4 +156,5 @@ export default class AdminGeneralSecurityContainer extends Container {
     return securitySettingParams;
   }
 
+
 }

+ 1 - 1
src/client/js/services/AdminLdapSecurityContainer.js

@@ -3,7 +3,7 @@ import { Container } from 'unstated';
 import loggerFactory from '@alias/logger';
 
 // eslint-disable-next-line no-unused-vars
-const logger = loggerFactory('growi:security:AdminLdapSecurityLdapContainer');
+const logger = loggerFactory('growi:security:AdminLdapSecurityContainer');
 
 /**
  * Service container for admin security page (SecurityLdapSetting.jsx)

+ 119 - 0
src/client/js/services/AdminOidcSecurityContainer.js

@@ -0,0 +1,119 @@
+import { Container } from 'unstated';
+
+import loggerFactory from '@alias/logger';
+
+// eslint-disable-next-line no-unused-vars
+const logger = loggerFactory('growi:security:AdminOidcSecurityContainer');
+
+/**
+ * Service container for admin security page (OidcSecurityManagement.jsx)
+ * @extends {Container} unstated Container
+ */
+export default class AdminOidcSecurityContainer extends Container {
+
+  constructor(appContainer) {
+    super();
+
+    this.appContainer = appContainer;
+
+    this.state = {
+      // TODO GW-583 set value
+      callbackUrl: '',
+      oidcProviderName: '',
+      oidcIssuerHost: '',
+      oidcClientId: '',
+      oidcClientSecret: '',
+      oidcAttrMapId: '',
+      oidcAttrMapUserName: '',
+      oidcAttrMapName: '',
+      oidcAttrMapEmail: '',
+      isSameUsernameTreatedAsIdenticalUser: true,
+      isSameEmailTreatedAsIdenticalUser: true,
+    };
+
+    this.init();
+
+  }
+
+  init() {
+    // TODO GW-583 fetch config value with api
+  }
+
+  /**
+   * Workaround for the mangling in production build to break constructor.name
+   */
+  static getClassName() {
+    return 'AdminOidcSecurityContainer';
+  }
+
+  /**
+   * Change oidcProviderName
+   */
+  changeOidcProviderName(inputValue) {
+    this.setState({ oidcProviderName: inputValue });
+  }
+
+  /**
+   * Change oidcIssuerHost
+   */
+  changeOidcIssuerHost(inputValue) {
+    this.setState({ oidcIssuerHost: inputValue });
+  }
+
+  /**
+   * Change oidcClientId
+   */
+  changeOidcClientId(inputValue) {
+    this.setState({ oidcClientId: inputValue });
+  }
+
+  /**
+   * Change oidcClientSecret
+   */
+  changeOidcClientSecret(inputValue) {
+    this.setState({ oidcClientSecret: inputValue });
+  }
+
+  /**
+   * Change oidcAttrMapId
+   */
+  changeOidcAttrMapId(inputValue) {
+    this.setState({ oidcAttrMapId: inputValue });
+  }
+
+  /**
+   * Change oidcAttrMapUserName
+   */
+  changeOidcAttrMapUserName(inputValue) {
+    this.setState({ oidcAttrMapUserName: inputValue });
+  }
+
+  /**
+   * Change oidcAttrMapName
+   */
+  changeOidcAttrMapName(inputValue) {
+    this.setState({ oidcAttrMapName: inputValue });
+  }
+
+  /**
+   * Change oidcAttrMapEmail
+   */
+  changeOidcAttrMapEmail(inputValue) {
+    this.setState({ oidcAttrMapEmail: inputValue });
+  }
+
+  /**
+   * Switch sameUsernameTreatedAsIdenticalUser
+   */
+  switchIsSameUsernameTreatedAsIdenticalUser() {
+    this.setState({ isSameUsernameTreatedAsIdenticalUser: !this.state.isSameUsernameTreatedAsIdenticalUser });
+  }
+
+  /**
+   * Switch sameEmailTreatedAsIdenticalUser
+   */
+  switchIsSameEmailTreatedAsIdenticalUser() {
+    this.setState({ isSameEmailTreatedAsIdenticalUser: !this.state.isSameEmailTreatedAsIdenticalUser });
+  }
+
+}

+ 64 - 3
src/client/js/services/AdminSamlSecurityContainer.js

@@ -28,6 +28,18 @@ export default class AdminSamlSecurityContainer extends Container {
       samlEnvVarIssuer: '',
       samlDbCert: '',
       samlEnvVarCert: '',
+      samlDbAttrMapId: '',
+      samlEnvVarAttrMapId: '',
+      samlDbAttrMapUserName: '',
+      samlEnvVarAttrMapUserName: '',
+      samlDbAttrMapMail: '',
+      samlEnvVarAttrMapMail: '',
+      samlDbAttrMapFirstName: '',
+      samlEnvVarAttrMapFirstName: '',
+      samlDbAttrMapLastName: '',
+      samlEnvVarAttrMapLastName: '',
+      isSameUsernameTreatedAsIdenticalUser: false,
+      isSameEmailTreatedAsIdenticalUser: false,
     };
 
     this.init();
@@ -47,24 +59,73 @@ export default class AdminSamlSecurityContainer extends Container {
   }
 
   /**
-   * Change saml db entry point
+   * Change samlDbEntryPoint
    */
   changeSamlDbEntryPoint(inputValue) {
     this.setState({ samlDbEntryPoint: inputValue });
   }
 
   /**
-   * Change saml db issuer
+   * Change samlDbIssuer
    */
   changeSamlDbIssuer(inputValue) {
     this.setState({ samlDbIssuer: inputValue });
   }
 
   /**
-   * Change saml db Cert
+   * Change samlDbCert
    */
   changeSamlDbCert(inputValue) {
     this.setState({ samlDbCert: inputValue });
   }
 
+  /**
+   * Change samlDbAttrMapId
+   */
+  changeSamlDbAttrMapId(inputValue) {
+    this.setState({ samlDbAttrMapId: inputValue });
+  }
+
+  /**
+   * Change samlDbAttrMapUserName
+   */
+  changeSamlDbAttrMapUserName(inputValue) {
+    this.setState({ samlDbAttrMapUserName: inputValue });
+  }
+
+  /**
+   * Change samlDbAttrMapMail
+   */
+  changeSamlDbAttrMapMail(inputValue) {
+    this.setState({ samlDbAttrMapMail: inputValue });
+  }
+
+  /**
+   * Change samlDbAttrMapFirstName
+   */
+  changeSamlDbAttrMapFirstName(inputValue) {
+    this.setState({ samlDbAttrMapFirstName: inputValue });
+  }
+
+  /**
+   * Change samlDbAttrMapLastName
+   */
+  changeSamlDbAttrMapLastName(inputValue) {
+    this.setState({ samlDbAttrMapLastName: inputValue });
+  }
+
+  /**
+   * Switch isSameUsernameTreatedAsIdenticalUser
+   */
+  switchIsSameUsernameTreatedAsIdenticalUser() {
+    this.setState({ isSameUsernameTreatedAsIdenticalUser: !this.state.isSameUsernameTreatedAsIdenticalUser });
+  }
+
+  /**
+   * Switch isSameEmailTreatedAsIdenticalUser
+   */
+  switchIsSameEmailTreatedAsIdenticalUser() {
+    this.setState({ isSameEmailTreatedAsIdenticalUser: !this.state.isSameEmailTreatedAsIdenticalUser });
+  }
+
 }

+ 3 - 3
src/server/routes/apiv3/markdown-setting.js

@@ -114,7 +114,7 @@ module.exports = (crowi) => {
    *                schema:
    *                  properties:
    *                    status:
-   *                      $ref: '#/components/schemas/lineBreakParams'
+   *                      $ref: '#/components/schemas/LineBreakParams'
    */
   router.put('/lineBreak', loginRequiredStrictly, adminRequired, csrf, validator.lineBreak, ApiV3FormValidator, async(req, res) => {
 
@@ -167,7 +167,7 @@ module.exports = (crowi) => {
    *                schema:
    *                  properties:
    *                    status:
-   *                      $ref: '#/components/schemas/presentationParams'
+   *                      $ref: '#/components/schemas/PresentationParams'
    */
   router.put('/presentation', loginRequiredStrictly, adminRequired, csrf, validator.presentationSetting, ApiV3FormValidator, async(req, res) => {
     if (req.body.pageBreakSeparator === 3 && req.body.pageBreakCustomSeparator === '') {
@@ -235,7 +235,7 @@ module.exports = (crowi) => {
    *                schema:
    *                  properties:
    *                    status:
-   *                      $ref: '#/components/schemas/xssParams'
+   *                      $ref: '#/components/schemas/XssParams'
    */
   router.put('/xss', loginRequiredStrictly, adminRequired, csrf, validator.xssSetting, ApiV3FormValidator, async(req, res) => {
     if (req.body.isEnabledXss && req.body.xssOption == null) {

+ 7 - 10
src/server/routes/page.js

@@ -72,9 +72,6 @@ module.exports = function(crowi, app) {
   }
 
   function generatePager(offset, limit, totalCount) {
-    let next = null;
-
-
     let prev = null;
 
     if (offset > 0) {
@@ -84,12 +81,10 @@ module.exports = function(crowi, app) {
       }
     }
 
-    if (totalCount < limit) {
+    let next = offset + limit;
+    if (totalCount < next) {
       next = null;
     }
-    else {
-      next = offset + limit;
-    }
 
     return {
       prev,
@@ -164,7 +159,7 @@ module.exports = function(crowi, app) {
 
     const queryOptions = {
       offset,
-      limit: limit + 1,
+      limit,
       includeTrashed: path.startsWith('/trash/'),
       isRegExpEscapedFromPath,
     };
@@ -469,13 +464,15 @@ module.exports = function(crowi, app) {
   };
 
   actions.deletedPageListShow = async function(req, res) {
-    const path = `/trash${getPathFromRequest(req)}`;
+    // normalizePath makes '/trash/' -> '/trash'
+    const path = pathUtils.normalizePath(`/trash${getPathFromRequest(req)}`);
+
     const limit = 50;
     const offset = parseInt(req.query.offset) || 0;
 
     const queryOptions = {
       offset,
-      limit: limit + 1,
+      limit,
       includeTrashed: true,
     };
 

+ 2 - 2
src/server/service/global-notification/global-notification-mail.js

@@ -11,7 +11,6 @@ class GlobalNotificationMailService {
     this.mailer = crowi.getMailer();
     this.type = crowi.model('GlobalNotificationSetting').TYPE.MAIL;
     this.event = crowi.model('GlobalNotificationSetting').EVENT;
-    this.defaultLang = crowi.configManager.getConfig('crowi', 'app:globalLang');
   }
 
   /**
@@ -48,12 +47,13 @@ class GlobalNotificationMailService {
    * @return  {{ subject: string, template: string, vars: object }}
    */
   generateOption(event, path, triggeredBy, { comment, oldPath }) {
+    const defaultLang = this.crowi.configManager.getConfig('crowi', 'app:globalLang');
     // validate for all events
     if (event == null || path == null || triggeredBy == null) {
       throw new Error(`invalid vars supplied to GlobalNotificationMailService.generateOption for event ${event}`);
     }
 
-    const template = nodePath.join(this.crowi.localeDir, `${this.defaultLang}/notifications/${event}.txt`);
+    const template = nodePath.join(this.crowi.localeDir, `${defaultLang}/notifications/${event}.txt`);
     let subject;
     let vars = {
       appTitle: this.crowi.appService.getAppTitle(),

+ 1 - 1
src/server/util/swigFunctions.js

@@ -156,7 +156,7 @@ module.exports = function(crowi, req, locals) {
 
   locals.isTrashPage = function() {
     const path = req.path || '';
-    if (path.match(/^\/trash\/.*/)) {
+    if (path.match(/^\/trash(\/.*)?$/)) {
       return true;
     }