Przeglądaj źródła

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

# Conflicts:
#	src/client/js/components/Admin/Security/SecurityManagement.jsx
WESEEK Kaito 6 lat temu
rodzic
commit
966de6dc6a

+ 95 - 0
.github/workflows/test.yml

@@ -0,0 +1,95 @@
+name: Node CI
+
+# on: [push]
+on:
+  push:
+    branches:
+      - support/github-actions
+
+jobs:
+
+  resolve-dependencies:
+    runs-on: ubuntu-latest
+
+    strategy:
+      matrix:
+        node-version: [10.x, 12.x]
+
+    steps:
+    - uses: actions/checkout@v1
+    - name: Use Node.js ${{ matrix.node-version }}
+      uses: actions/setup-node@v1
+      with:
+        node-version: ${{ matrix.node-version }}
+    - name: install dependencies
+      run: |
+        yarn
+    - name: install plugins
+      run: |
+        yarn add growi-plugin-lsx growi-plugin-pukiwiki-like-linker growi-plugin-attachment-refs
+        yarn add -D react-images react-motion
+    - name: print dependencies
+      run: |
+        echo -n "node " && node -v
+        echo -n "npm " && npm -v
+        yarn list --depth=0
+
+
+  lint:
+    runs-on: ubuntu-latest
+    needs: resolve-dependencies
+
+    steps:
+    - uses: actions/checkout@v1
+    - name: yarn lint
+      run: |
+        yarn lint
+
+
+  test:
+    runs-on: ubuntu-latest
+    needs: resolve-dependencies
+
+    steps:
+    - name: Launch MongoDB
+      uses: wbari/start-mongoDB@v0.2
+      with:
+        mongoDBVersion: 3.6
+
+    - name: yarn test
+      run: |
+        yarn test
+      env:
+        MONGO_URI: mongodb://localhost:27017/growi_test
+
+
+  build-dev:
+    runs-on: ubuntu-latest
+    needs: resolve-dependencies
+
+    steps:
+    - name: yarn build:dev
+      run: |
+        yarn build:dev
+
+
+  build-prod:
+    runs-on: ubuntu-latest
+    needs: resolve-dependencies
+
+    steps:
+    - name: Launch MongoDB
+      uses: wbari/start-mongoDB@v0.2
+      with:
+        mongoDBVersion: 3.6
+    - name: yarn build:prod:analyze
+      run: |
+        yarn build:prod:analyze
+    - name: shrink dependencies for production
+      run: |
+        yarn install --production
+    - name: yarn server:prod:ci
+      run: |
+        yarn server:prod:ci
+      env:
+        MONGO_URI: mongodb://localhost:27017/growi

+ 59 - 22
docker/Dockerfile

@@ -5,51 +5,85 @@ ARG flavor=default
 
 
 ##
-## setupper-default
+## deps-resolver
 ##
-FROM node:12-slim AS setupper-default
+FROM node:12-slim AS deps-resolver
 LABEL maintainer Yuki Takei <yuki@weseek.co.jp>
 
-RUN mkdir -p ${appDir}
-RUN mv .* ${appDir}/
+ENV appDir /opt/growi
 
+COPY ./package.json ${appDir}/
+COPY ./yarn.lock ${appDir}/
 WORKDIR ${appDir}
 
 # setup
 RUN yarn config set network-timeout 300000
-RUN yarn
+RUN --mount=type=cache,target=/usr/local/share/.cache/yarn \
+  yarn
 # install official plugins
-RUN yarn add growi-plugin-lsx growi-plugin-pukiwiki-like-linker growi-plugin-attachment-refs
+RUN --mount=type=cache,target=/usr/local/share/.cache/yarn \
+  yarn add growi-plugin-lsx growi-plugin-pukiwiki-like-linker growi-plugin-attachment-refs
 # install peerDependencies
-RUN yarn add -D react-images react-motion
+RUN --mount=type=cache,target=/usr/local/share/.cache/yarn \
+  yarn add -D react-images react-motion
+
+
+
+##
+## deps-resolver-prod
+##
+FROM deps-resolver AS deps-resolver-prod
+
+# shrink dependencies for production
+RUN --mount=type=cache,target=/usr/local/share/.cache/yarn \
+  yarn install --production
+
+
+
+##
+## prebuilder-default
+##
+FROM node:12-slim AS prebuilder-default
+LABEL maintainer Yuki Takei <yuki@weseek.co.jp>
+
+ENV appDir /opt/growi
+
+COPY . ${appDir}
 
 
 
 ##
-## setupper-nocdn
+## prebuilder-nocdn
 ##
-FROM setupper-default AS setupper-nocdn
+FROM prebuilder-default AS prebuilder-nocdn
 
 # replace env.prod.js for NO_CDN
-COPY nocdn/env.prod.js config
+COPY docker/nocdn/env.prod.js ${appDir}/config/
+
+
+
+##
+## prebuilder (alias)
+##
+FROM prebuilder-${flavor} AS prebuilder
 
 
 
 ##
 ## builder
 ##
-FROM setupper-${flavor} AS builder
+FROM deps-resolver AS builder
 
 ENV appDir /opt/growi
 
+COPY --from=prebuilder ${appDir} ${appDir}
+
 # build
 RUN yarn build:prod
-# shrink dependencies for production
-RUN yarn install --production
 
-# remove unnecessary files
+# remove except artifacts
 WORKDIR /tmp
-RUN --mount=target=. sh bin/remove-unnecessary-files.sh
+RUN --mount=target=. sh docker/bin/remove-except-artifacts.sh
 WORKDIR ${appDir}
 
 
@@ -63,18 +97,21 @@ LABEL maintainer Yuki Takei <yuki@weseek.co.jp>
 ENV appDir /opt/growi
 
 # install tini
-RUN apk add --no-cache tini
+RUN --mount=type=cache,target=/var/cache/apk \
+  apk add tini su-exec
 
-COPY --from=builder ${appDir} ${appDir}
+COPY docker/docker-entrypoint.sh /
+RUN chmod 700 /docker-entrypoint.sh
+
+COPY --from=deps-resolver-prod --chown=node:node \
+  ${appDir}/node_modules ${appDir}/node_modules
+COPY --from=builder --chown=node:node \
+  ${appDir} ${appDir}
 
-# create symlink for FILE_UPLOAD=local
-WORKDIR /tmp
-RUN --mount=target=. sh bin/symlink-for-uploading-to-local.sh
 WORKDIR ${appDir}
 
-USER node
 VOLUME /data
 EXPOSE 3000
 
-ENTRYPOINT ["/sbin/tini", "-e", "143", "--"]
+ENTRYPOINT ["/sbin/tini", "-e", "143", "--", "/docker-entrypoint.sh"]
 CMD ["yarn", "server:prod"]

+ 14 - 0
docker/Dockerfile.dockerignore

@@ -0,0 +1,14 @@
+.git
+.github
+.vscode
+node_modules
+src/linter-checker
+src/test
+.editorconfig
+.eslint*
+.gitignore
+.prettier*
+.stylelint*
+app.json
+Procfile
+wercker.yml

+ 10 - 0
docker/bin/remove-except-artifacts.sh

@@ -0,0 +1,10 @@
+#!/bin/sh
+
+set -e
+
+rm -rf \
+  ${appDir}/bin \
+  ${appDir}/docker \
+  ${appDir}/node_modules \
+  ${appDir}/src/client \
+  ${appDir}/babel.config.js \

+ 0 - 21
docker/bin/remove-unnecessary-files.sh

@@ -1,21 +0,0 @@
-#!/bin/sh
-
-set -e
-
-rm -rf \
-  ${appDir}/.github \
-  ${appDir}/.vscode \
-  ${appDir}/bin \
-  ${appDir}/docker \
-  ${appDir}/src/client \
-  ${appDir}/src/linter-checker \
-  ${appDir}/src/test \
-  ${appDir}/.editorconfig \
-  ${appDir}/.eslint* \
-  ${appDir}/.gitignore \
-  ${appDir}/.prettier* \
-  ${appDir}/.stylelint* \
-  ${appDir}/app.json \
-  ${appDir}/babel.config.js \
-  ${appDir}/Procfile \
-  ${appDir}/wercker.yml

+ 6 - 1
docker/bin/symlink-for-uploading-to-local.sh → docker/docker-entrypoint.sh

@@ -2,8 +2,13 @@
 
 set -e
 
-# Corresponds to `FILE_UPLOAD=local`
+# Support `FILE_UPLOAD=local`
 mkdir -p /data/uploads
 if [ ! -e "$appDir/public/uploads" ]; then
   ln -s /data/uploads $appDir/public/uploads
 fi
+
+chown node:node /data/uploads
+chown -h node:node $appDir/public/uploads
+
+su-exec node $@

+ 10 - 6
resource/locales/en-US/translation.json

@@ -540,6 +540,7 @@
       "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> ."
     },
     "Basic": {
+      "enable_basic":"enable Basic",
       "name": "Basic Authentication",
       "desc_1": "Login with <code>username</code> in Authorization header.",
       "desc_2": "User will be automatically generated if not exist."
@@ -549,28 +550,31 @@
       "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": {
+        "enable_google":"enable Google OAuth",
         "name": "Google OAuth",
-        "register_1": "Access <a href=\"%s\" target=\"_blank\">%s</a>",
+        "register_1": "Access {{link}}",
         "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>",
+        "register_4": "Register your OAuth App with one of Authorized redirect URIs as <code>{{url}}</code>",
         "register_5": "Copy and paste your ClientID and Client Secret above"
       },
       "Facebook": {
         "name": "Facebook OAuth"
       },
       "Twitter": {
+        "enable_twitter": "enable Twitter OAuth",
         "name": "Twitter OAuth",
-        "register_1": "Access <a href=\"%s\" target=\"_blank\">%s</a>",
+        "register_1": "Access {{link}}",
         "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>",
+        "register_4": "Register your OAuth App with one of Authorized redirect URIs as <code>{{url}}</code>",
         "register_5": "Copy and paste your ClientID and Client Secret above"
       },
       "GitHub": {
+        "enable_github":"enable GitHub OAuth",
         "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>",
+        "register_1": "Access {{link}}",
+        "register_2": "Register your OAuth App with \"Authorization callback URL\" as <code>{{url}}</code>",
         "register_3": "Copy and paste your ClientID and Client Secret above"
       },
       "OIDC": {

+ 9 - 5
resource/locales/ja/translation.json

@@ -535,6 +535,7 @@
       "note for the only env option": "現在SAML認証のON/OFFの設定値及びハイライトされている設定値は環境変数の値のみを使用するようになっています<br>この設定を変更する場合は環境変数 <code>{{env}}</code> の値をfalseに変更もしくは削除してください"
     },
     "Basic": {
+      "enable_basic":"Basic を有効にする",
       "name": "Basic 認証",
       "desc_1": "Authorization ヘッダに格納されている <code>username</code> でログインします。",
       "desc_2": "ユーザーが存在しなかった場合は自動生成します。"
@@ -544,28 +545,31 @@
       "register": "%sに登録",
       "change_redirect_url": "承認済みのリダイレクトURLに、 <code>%s</code> を入力",
       "Google": {
+        "enable_google":"Google OAuth を有効にする",
         "name": "Google OAuth",
-        "register_1": "<a href=\"%s\" target=\"_blank\">%s</a>へアクセス",
+        "register_1": "{{link}}へアクセス",
         "register_2": "プロジェクトがない場合はプロジェクトを作成",
         "register_3": "認証情報を作成 &rightarrow; OAuthクライアントID &rightarrow; ウェブアプリケーションを選択",
-        "register_4": "承認済みのリダイレクトURIを<code>%s</code>としてGrowiを登録",
+        "register_4": "承認済みのリダイレクトURIを<code>{{url}}</code>としてGrowiを登録",
         "register_5": "上記フォームにクライアントIDとクライアントシークレットを入力"
       },
       "Facebook": {
         "name": "Facebook OAuth"
       },
       "Twitter": {
+        "enable_twitter": "Twitter OAuth を有効にする",
         "name": "Twitter OAuth",
-        "register_1": "<a href=\"%s\" target=\"_blank\">%s</a>へアクセス",
+        "register_1": "{{link}} へアクセス",
         "register_2": "Twitterにサインイン",
         "register_3": "Create New Appをクリック &rightarrow; Application Detailsの各項目を入力",
         "register_4": "Create your Twitter Applicationで作成",
         "register_5": "上記フォームにクライアントIDとクライアントシークレットを入力"
       },
       "GitHub": {
+        "enable_github":"GitHub OAuth を有効にする",
         "name": "GitHub OAuth",
-        "register_1": "<a href=\"%s\" target=\"_blank\">%s</a>へアクセス",
-        "register_2": "\"Authorization callback URL\"を<code>%s</code>としてGrowiを登録",
+        "register_1": "{{link}} へアクセス",
+        "register_2": "\"Authorization callback URL\"を<code>{{url}}</code>としてGrowiを登録",
         "register_3": "上記フォームにクライアントIDとクライアントシークレットを入力"
       },
       "OIDC": {

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

@@ -62,6 +62,10 @@ import MarkDownSettingContainer from './services/MarkDownSettingContainer';
 import AdminExternalAccountsContainer from './services/AdminExternalAccountsContainer';
 import AdminSamlSecurityContainer from './services/AdminSamlSecurityContainer';
 import AdminOidcSecurityContainer from './services/AdminOidcSecurityContainer';
+import AdminBasicSecurityContainer from './services/AdminBasicSecurityContainer';
+import AdminGoogleSecurityContainer from './services/AdminGoogleSecurityContainer';
+import AdminGithubSecurityContainer from './services/AdminGithubSecurityConatainer';
+import AdminTwitterSecurityContainer from './services/AdminTwitterSecurityContainer';
 
 const logger = loggerFactory('growi:app');
 
@@ -221,7 +225,14 @@ if (adminSecuritySettingElem != null) {
   const adminLdapSecurityContainer = new AdminLdapSecurityContainer(appContainer);
   const adminSamlSecurityContainer = new AdminSamlSecurityContainer(appContainer);
   const adminOidcSecurityContainer = new AdminOidcSecurityContainer(appContainer);
-  const adminSecurityContainers = [adminGeneralSecurityContainer, adminLdapSecurityContainer, adminSamlSecurityContainer, adminOidcSecurityContainer];
+  const adminBasicSecurityContainer = new AdminBasicSecurityContainer(appContainer);
+  const adminGoogleSecurityContainer = new AdminGoogleSecurityContainer(appContainer);
+  const adminGithubSecurityContainer = new AdminGithubSecurityContainer(appContainer);
+  const adminTwitterSecurityContainer = new AdminTwitterSecurityContainer(appContainer);
+  const adminSecurityContainers = [
+    adminGeneralSecurityContainer, adminLdapSecurityContainer, adminSamlSecurityContainer, adminOidcSecurityContainer, adminBasicSecurityContainer,
+    adminGoogleSecurityContainer, adminGithubSecurityContainer, adminTwitterSecurityContainer,
+  ];
   ReactDOM.render(
     <Provider inject={[injectableContainers, adminSecurityContainers]}>
       <I18nextProvider i18n={i18n}>

+ 88 - 0
src/client/js/components/Admin/Security/BasicSecuritySetting.jsx

@@ -0,0 +1,88 @@
+/* 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 AdminBasicSecurityContainer from '../../../services/AdminBasicSecurityContainer';
+
+class BasicSecurityManagement extends React.Component {
+
+  render() {
+    const { t, adminGeneralSecurityContainer, adminBasicSecurityContainer } = this.props;
+
+    return (
+      <React.Fragment>
+
+        <h2 className="alert-anchor border-bottom">
+          { t('security_setting.Basic.name') } { t('security_setting.configuration') }
+        </h2>
+
+        <div className="row mb-5">
+          <strong className="col-xs-3 text-right">{ t('security_setting.Basic.name') }</strong>
+          <div className="col-xs-6 text-left">
+            <div className="checkbox checkbox-success">
+              <input
+                id="isBasicEnabled"
+                type="checkbox"
+                checked={adminGeneralSecurityContainer.state.isBasicEnabled}
+                onChange={() => { adminGeneralSecurityContainer.switchIsBasicEnabled() }}
+              />
+              <label htmlFor="isBasicEnabled">
+                { t('security_setting.Basic.enable_basic') }
+              </label>
+            </div>
+            <p className="help-block">
+              <small>
+                <span dangerouslySetInnerHTML={{ __html: t('security_setting.Basic.desc_1') }} /><br />
+                { t('security_setting.Basic.desc_2')}
+              </small>
+            </p>
+          </div>
+        </div>
+
+        {adminGeneralSecurityContainer.state.isBasicEnabled && (
+        <React.Fragment>
+          <div className="row mb-5">
+            <div className="col-xs-offset-3 col-xs-6 text-left">
+              <div className="checkbox checkbox-success">
+                <input
+                  id="bindByEmail-basic"
+                  type="checkbox"
+                  checked={adminBasicSecurityContainer.state.isSameUsernameTreatedAsIdenticalUser}
+                  onChange={() => { adminBasicSecurityContainer.switchIsSameUsernameTreatedAsIdenticalUser() }}
+                />
+                <label
+                  htmlFor="bindByEmail-basic"
+                  dangerouslySetInnerHTML={{ __html: t('security_setting.Treat username matching as identical', 'username') }}
+                />
+              </div>
+              <p className="help-block">
+                <small dangerouslySetInnerHTML={{ __html: t('security_setting.Treat username matching as identical_warn', 'username') }} />
+              </p>
+            </div>
+          </div>
+        </React.Fragment>
+        )}
+
+      </React.Fragment>
+    );
+  }
+
+}
+
+BasicSecurityManagement.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  adminGeneralSecurityContainer: PropTypes.instanceOf(AdminGeneralSecurityContainer).isRequired,
+  adminBasicSecurityContainer: PropTypes.instanceOf(AdminBasicSecurityContainer).isRequired,
+};
+
+const OidcSecurityManagementWrapper = (props) => {
+  return createSubscribedElement(BasicSecurityManagement, props, [AppContainer, AdminGeneralSecurityContainer, AdminBasicSecurityContainer]);
+};
+
+export default withTranslation()(OidcSecurityManagementWrapper);

+ 41 - 0
src/client/js/components/Admin/Security/FacebookSecuritySetting.jsx

@@ -0,0 +1,41 @@
+/* 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';
+
+class FacebookSecurityManagement extends React.Component {
+
+  render() {
+    const { t } = this.props;
+    return (
+      <React.Fragment>
+
+        <h2 className="alert-anchor border-bottom">
+          Facebook OAuth { t('security_setting.configuration') }
+        </h2>
+
+        <p className="well">(TBD)</p>
+
+      </React.Fragment>
+    );
+  }
+
+}
+
+
+FacebookSecurityManagement.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  adminGeneralSecurityContainer: PropTypes.instanceOf(AdminGeneralSecurityContainer).isRequired,
+};
+
+const TwitterSecurityManagementWrapper = (props) => {
+  return createSubscribedElement(FacebookSecurityManagement, props, [AppContainer, AdminGeneralSecurityContainer]);
+};
+
+export default withTranslation()(TwitterSecurityManagementWrapper);

+ 157 - 0
src/client/js/components/Admin/Security/GithubSecuritySetting.jsx

@@ -0,0 +1,157 @@
+/* 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 AdminGithubSecurityContainer from '../../../services/AdminGithubSecurityConatainer';
+
+class GithubSecurityManagement extends React.Component {
+
+  render() {
+    const { t, adminGeneralSecurityContainer, adminGithubSecurityContainer } = this.props;
+    return (
+
+      <React.Fragment>
+
+        <h2 className="alert-anchor border-bottom">
+          { t('security_setting.OAuth.GitHub.name') } { t('security_setting.configuration') }
+        </h2>
+
+        <div className="row mb-5">
+          <strong className="col-xs-3 text-right">{ t('security_setting.OAuth.GitHub.name') }</strong>
+          <div className="col-xs-6 text-left">
+            <div className="checkbox checkbox-success">
+              <input
+                id="isGithubEnabled"
+                type="checkbox"
+                checked={adminGeneralSecurityContainer.state.isGithubOAuthEnabled}
+                onChange={() => { adminGeneralSecurityContainer.switchIsGithubOAuthEnabled() }}
+              />
+              <label htmlFor="isGithubEnabled">
+                { t('security_setting.OAuth.GitHub.enable_github') }
+              </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={adminGithubSecurityContainer.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.isGithubOAuthEnabled && (
+          <React.Fragment>
+
+            <div className="row mb-5">
+              <label htmlFor="githubClientId" className="col-xs-3 text-right">{ t('security_setting.clientID') }</label>
+              <div className="col-xs-6">
+                <input
+                  className="form-control"
+                  type="text"
+                  name="githubClientId"
+                  value={adminGithubSecurityContainer.state.githubClientId}
+                  onChange={e => adminGithubSecurityContainer.changeGithubClientId(e.target.value)}
+                />
+                <p className="help-block">
+                  <small dangerouslySetInnerHTML={{ __html: t('security_setting.Use env var if empty', { env: 'OAUTH_GITHUB_CLIENT_ID' }) }} />
+                </p>
+              </div>
+            </div>
+
+            <div className="row mb-5">
+              <label htmlFor="githubClientSecret" className="col-xs-3 text-right">{ t('security_setting.client_secret') }</label>
+              <div className="col-xs-6">
+                <input
+                  className="form-control"
+                  type="text"
+                  name="githubClientSecret"
+                  value={adminGithubSecurityContainer.state.githubClientSecret}
+                  onChange={e => adminGithubSecurityContainer.changeGithubClientSecret(e.target.value)}
+                />
+                <p className="help-block">
+                  <small dangerouslySetInnerHTML={{ __html: t('security_setting.Use env var if empty', { env: 'OAUTH_GITHUB_CLIENT_SECRET' }) }} />
+                </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="bindByUserNameGithub"
+                    type="checkbox"
+                    checked={adminGithubSecurityContainer.state.isSameUsernameTreatedAsIdenticalUser}
+                    onChange={() => { adminGithubSecurityContainer.switchIsSameUsernameTreatedAsIdenticalUser() }}
+                  />
+                  <label
+                    htmlFor="bindByUserNameGithub"
+                    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="#collapseHelpForGithubOauth" data-toggle="collapse"> { t('security_setting.OAuth.how_to.github') }</a>
+          </h4>
+          <ol id="collapseHelpForGithubOauth" className="collapse">
+            {/* eslint-disable-next-line max-len */}
+            <li dangerouslySetInnerHTML={{ __html:  t('security_setting.OAuth.GitHub.register_1', { link: '<a href="https://github.com/settings/developers" target=_blank>GitHub Developer Settings</a>' }) }} />
+            <li dangerouslySetInnerHTML={{ __html:  t('security_setting.OAuth.GitHub.register_2', { url: adminGithubSecurityContainer.state.callbackUrl }) }} />
+            <li dangerouslySetInnerHTML={{ __html:  t('security_setting.OAuth.GitHub.register_3') }} />
+          </ol>
+        </div>
+
+      </React.Fragment>
+
+
+    );
+  }
+
+}
+
+
+GithubSecurityManagement.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  adminGeneralSecurityContainer: PropTypes.instanceOf(AdminGeneralSecurityContainer).isRequired,
+  adminGithubSecurityContainer: PropTypes.instanceOf(AdminGithubSecurityContainer).isRequired,
+};
+
+const GithubSecurityManagementWrapper = (props) => {
+  return createSubscribedElement(GithubSecurityManagement, props, [AppContainer, AdminGeneralSecurityContainer, AdminGithubSecurityContainer]);
+};
+
+export default withTranslation()(GithubSecurityManagementWrapper);

+ 159 - 0
src/client/js/components/Admin/Security/GoogleSecuritySetting.jsx

@@ -0,0 +1,159 @@
+/* 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 AdminGoogleSecurityContainer from '../../../services/AdminGoogleSecurityContainer';
+
+class GoogleSecurityManagement extends React.Component {
+
+  render() {
+    const { t, adminGeneralSecurityContainer, adminGoogleSecurityContainer } = this.props;
+    return (
+
+      <React.Fragment>
+
+        <h2 className="alert-anchor border-bottom">
+          { t('security_setting.OAuth.Google.name') } { t('security_setting.configuration') }
+        </h2>
+
+        <div className="row mb-5">
+          <strong className="col-xs-3 text-right">{ t('security_setting.OAuth.Google.name') }</strong>
+          <div className="col-xs-6 text-left">
+            <div className="checkbox checkbox-success">
+              <input
+                id="isGoogleEnabled"
+                type="checkbox"
+                checked={adminGeneralSecurityContainer.state.isGoogleOAuthEnabled}
+                onChange={() => { adminGeneralSecurityContainer.switchIsGoogleOAuthEnabled() }}
+              />
+              <label htmlFor="isGoogleEnabled">
+                { t('security_setting.OAuth.Google.enable_google') }
+              </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={adminGoogleSecurityContainer.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.isGoogleOAuthEnabled && (
+          <React.Fragment>
+
+            <div className="row mb-5">
+              <label htmlFor="googleClientId" className="col-xs-3 text-right">{ t('security_setting.clientID') }</label>
+              <div className="col-xs-6">
+                <input
+                  className="form-control"
+                  type="text"
+                  name="googleClientId"
+                  value={adminGoogleSecurityContainer.state.googleClientId}
+                  onChange={e => adminGoogleSecurityContainer.changeGoogleClientId(e.target.value)}
+                />
+                <p className="help-block">
+                  <small dangerouslySetInnerHTML={{ __html: t('security_setting.Use env var if empty', { env: 'OAUTH_GOOGLE_CLIENT_ID' }) }} />
+                </p>
+              </div>
+            </div>
+
+            <div className="row mb-5">
+              <label htmlFor="googleClientSecret" className="col-xs-3 text-right">{ t('security_setting.client_secret') }</label>
+              <div className="col-xs-6">
+                <input
+                  className="form-control"
+                  type="text"
+                  name="googleClientSecret"
+                  value={adminGoogleSecurityContainer.state.googleClientSecret}
+                  onChange={e => adminGoogleSecurityContainer.changeGoogleClientSecret(e.target.value)}
+                />
+                <p className="help-block">
+                  <small dangerouslySetInnerHTML={{ __html: t('security_setting.Use env var if empty', { env: 'OAUTH_GOOGLE_CLIENT_SECRET' }) }} />
+                </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="bindByUserNameGoogle"
+                    type="checkbox"
+                    checked={adminGoogleSecurityContainer.state.isSameUsernameTreatedAsIdenticalUser}
+                    onChange={() => { adminGoogleSecurityContainer.switchIsSameUsernameTreatedAsIdenticalUser() }}
+                  />
+                  <label
+                    htmlFor="bindByUserNameGoogle"
+                    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="#collapseHelpForGoogleOauth" data-toggle="collapse"> { t('security_setting.OAuth.how_to.google') }</a>
+          </h4>
+          <ol id="collapseHelpForGoogleOauth" className="collapse">
+            {/* eslint-disable-next-line max-len */}
+            <li dangerouslySetInnerHTML={{ __html:  t('security_setting.OAuth.Google.register_1', { link: '<a href="https://console.cloud.google.com/apis/credentials" target=_blank>Google Cloud Platform API Manager</a>' }) }} />
+            <li dangerouslySetInnerHTML={{ __html:  t('security_setting.OAuth.Google.register_2') }} />
+            <li dangerouslySetInnerHTML={{ __html:  t('security_setting.OAuth.Google.register_3') }} />
+            <li dangerouslySetInnerHTML={{ __html:  t('security_setting.OAuth.Google.register_4', { url: adminGoogleSecurityContainer.state.callbackUrl }) }} />
+            <li dangerouslySetInnerHTML={{ __html:  t('security_setting.OAuth.Google.register_5') }} />
+          </ol>
+        </div>
+
+      </React.Fragment>
+
+
+    );
+  }
+
+}
+
+
+GoogleSecurityManagement.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  adminGeneralSecurityContainer: PropTypes.instanceOf(AdminGeneralSecurityContainer).isRequired,
+  adminGoogleSecurityContainer: PropTypes.instanceOf(AdminGoogleSecurityContainer).isRequired,
+};
+
+const GoogleSecurityManagementWrapper = (props) => {
+  return createSubscribedElement(GoogleSecurityManagement, props, [AppContainer, AdminGeneralSecurityContainer, AdminGoogleSecurityContainer]);
+};
+
+export default withTranslation()(GoogleSecurityManagementWrapper);

+ 10 - 5
src/client/js/components/Admin/Security/SecurityManagement.jsx

@@ -10,6 +10,11 @@ import LocalSecuritySetting from './LocalSecuritySetting';
 import SamlSecuritySetting from './SamlSecuritySetting';
 import OidcSecuritySetting from './OidcSecuritySetting';
 import SecuritySetting from './SecuritySetting';
+import BasicSecuritySetting from './BasicSecuritySetting';
+import GoogleSecuritySetting from './GoogleSecuritySetting';
+import GithubSecuritySetting from './GithubSecuritySetting';
+import TwitterSecuritySetting from './TwitterSecuritySetting';
+import FacebookSecuritySetting from './FacebookSecuritySetting';
 
 class SecurityManagement extends React.Component {
 
@@ -81,19 +86,19 @@ class SecurityManagement extends React.Component {
                 <OidcSecuritySetting />
               </div>
               <div id="passport-basic" className="tab-pane" role="tabpanel">
-                {/* TODO GW-546 reactify basic.html */}
+                <BasicSecuritySetting />
               </div>
               <div id="passport-google-oauth" className="tab-pane" role="tabpanel">
-                {/* TODO GW-547 reactify google-oauth.html */}
+                <GoogleSecuritySetting />
               </div>
               <div id="passport-github" className="tab-pane" role="tabpanel">
-                {/* TODO GW-548 reactify github.html */}
+                <GithubSecuritySetting />
               </div>
               <div id="passport-twitter" className="tab-pane" role="tabpanel">
-                {/* TODO GW-549 reactify twitter.html */}
+                <TwitterSecuritySetting />
               </div>
               <div id="passport-facebook" className="tab-pane" role="tabpanel">
-                {/* TODO GW-550 reactify facebook.html */}
+                <FacebookSecuritySetting />
               </div>
             </div>
           </div>

+ 160 - 0
src/client/js/components/Admin/Security/TwitterSecuritySetting.jsx

@@ -0,0 +1,160 @@
+/* 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 AdminTwitterSecurityContainer from '../../../services/AdminTwitterSecurityContainer';
+
+class TwitterSecurityManagement extends React.Component {
+
+  render() {
+    const { t, adminGeneralSecurityContainer, adminTwitterSecurityContainer } = this.props;
+    return (
+
+      <React.Fragment>
+
+        <h2 className="alert-anchor border-bottom">
+          { t('security_setting.OAuth.Twitter.name') } { t('security_setting.configuration') }
+        </h2>
+
+        <div className="row mb-5">
+          <strong className="col-xs-3 text-right">{ t('security_setting.OAuth.Twitter.name') }</strong>
+          <div className="col-xs-6 text-left">
+            <div className="checkbox checkbox-success">
+              <input
+                id="isTwitterEnabled"
+                type="checkbox"
+                checked={adminGeneralSecurityContainer.state.isTwitterOAuthEnabled}
+                onChange={() => { adminGeneralSecurityContainer.switchIsTwitterOAuthEnabled() }}
+              />
+              <label htmlFor="isTwitterEnabled">
+                { t('security_setting.OAuth.Twitter.enable_twitter') }
+              </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={adminTwitterSecurityContainer.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.isTwitterOAuthEnabled && (
+          <React.Fragment>
+
+            <div className="row mb-5">
+              <label htmlFor="TwitterConsumerId" className="col-xs-3 text-right">{ t('security_setting.clientID') }</label>
+              <div className="col-xs-6">
+                <input
+                  className="form-control"
+                  type="text"
+                  name="TwitterConsumerId"
+                  value={adminTwitterSecurityContainer.state.TwitterConsumerId}
+                  onChange={e => adminTwitterSecurityContainer.changeTwitterConsumerId(e.target.value)}
+                />
+                <p className="help-block">
+                  <small dangerouslySetInnerHTML={{ __html: t('security_setting.Use env var if empty', { env: 'OAUTH_TWITTER_CONSUMER_KEY' }) }} />
+                </p>
+              </div>
+            </div>
+
+            <div className="row mb-5">
+              <label htmlFor="TwitterConsumerSecret" className="col-xs-3 text-right">{ t('security_setting.client_secret') }</label>
+              <div className="col-xs-6">
+                <input
+                  className="form-control"
+                  type="text"
+                  name="TwitterConsumerSecret"
+                  value={adminTwitterSecurityContainer.state.TwitterConsumerSecret}
+                  onChange={e => adminTwitterSecurityContainer.changeTwitterConsumerSecret(e.target.value)}
+                />
+                <p className="help-block">
+                  <small dangerouslySetInnerHTML={{ __html: t('security_setting.Use env var if empty', { env: 'OAUTH_TWITTER_CONSUMER_SECRET' }) }} />
+                </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="bindByUserNameTwitter"
+                    type="checkbox"
+                    checked={adminTwitterSecurityContainer.state.isSameUsernameTreatedAsIdenticalUser}
+                    onChange={() => { adminTwitterSecurityContainer.switchIsSameUsernameTreatedAsIdenticalUser() }}
+                  />
+                  <label
+                    htmlFor="bindByUserNameTwitter"
+                    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="#collapseHelpForTwitterOauth" data-toggle="collapse"> { t('security_setting.OAuth.how_to.twitter') }</a>
+          </h4>
+          <ol id="collapseHelpForTwitterOauth" className="collapse">
+            {/* eslint-disable-next-line max-len */}
+            <li dangerouslySetInnerHTML={{ __html:  t('security_setting.OAuth.Twitter.register_1', { link: '<a href="https://apps.twitter.com/" target=_blank>Twitter Application Management</a>' }) }} />
+            <li dangerouslySetInnerHTML={{ __html:  t('security_setting.OAuth.Twitter.register_2') }} />
+            <li dangerouslySetInnerHTML={{ __html:  t('security_setting.OAuth.Twitter.register_3') }} />
+            {/* eslint-disable-next-line max-len */}
+            <li dangerouslySetInnerHTML={{ __html:  t('security_setting.OAuth.Twitter.register_4', { url: adminTwitterSecurityContainer.state.callbackUrl }) }} />
+            <li dangerouslySetInnerHTML={{ __html:  t('security_setting.OAuth.Twitter.register_5') }} />
+          </ol>
+        </div>
+
+      </React.Fragment>
+
+
+    );
+  }
+
+}
+
+
+TwitterSecurityManagement.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  adminGeneralSecurityContainer: PropTypes.instanceOf(AdminGeneralSecurityContainer).isRequired,
+  adminTwitterSecurityContainer: PropTypes.instanceOf(AdminTwitterSecurityContainer).isRequired,
+};
+
+const TwitterSecurityManagementWrapper = (props) => {
+  return createSubscribedElement(TwitterSecurityManagement, props, [AppContainer, AdminGeneralSecurityContainer, AdminTwitterSecurityContainer]);
+};
+
+export default withTranslation()(TwitterSecurityManagementWrapper);

+ 47 - 0
src/client/js/services/AdminBasicSecurityContainer.js

@@ -0,0 +1,47 @@
+import { Container } from 'unstated';
+
+import loggerFactory from '@alias/logger';
+
+// eslint-disable-next-line no-unused-vars
+const logger = loggerFactory('growi:security:AdminBasicSecurityContainer');
+
+/**
+ * Service container for admin security page (BasicSecuritySetting.jsx)
+ * @extends {Container} unstated Container
+ */
+export default class AdminBasicSecurityContainer extends Container {
+
+  constructor(appContainer) {
+    super();
+
+    this.appContainer = appContainer;
+
+    this.state = {
+      // TODO GW-583 set value
+      isSameUsernameTreatedAsIdenticalUser: 'hoge',
+    };
+
+    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 'AdminBasicSecurityContainer';
+  }
+
+  /**
+   * Switch isSameUsernameTreatedAsIdenticalUser
+   */
+  switchIsSameUsernameTreatedAsIdenticalUser() {
+    this.setState({ isSameUsernameTreatedAsIdenticalUser: !this.state.isSameUsernameTreatedAsIdenticalUser });
+  }
+
+}

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

@@ -32,6 +32,10 @@ export default class AdminGeneralSecurityContainer extends Container {
       isLdapEnabled: true,
       isSamlEnabled: true,
       isOidcEnabled: true,
+      isBasicEnabled: true,
+      isGoogleOAuthEnabled: true,
+      isGithubOAuthEnabled: true,
+      isTwitterOAuthEnabled: true,
     };
 
     this.init();
@@ -159,4 +163,33 @@ export default class AdminGeneralSecurityContainer extends Container {
     this.setState({ isOidcEnabled: !this.state.isOidcEnabled });
   }
 
+  /**
+   * Switch Basic enabled
+   */
+  switchIsBasicEnabled() {
+    this.setState({ isBasicEnabled: !this.state.isBasicEnabled });
+  }
+
+  /**
+   * Switch GoogleOAuth enabled
+   */
+  switchIsGoogleOAuthEnabled() {
+    this.setState({ isGoogleOAuthEnabled: !this.state.isGoogleOAuthEnabled });
+  }
+
+  /**
+   * Switch GithubOAuth enabled
+   */
+  switchIsGithubOAuthEnabled() {
+    this.setState({ isGithubOAuthEnabled: !this.state.isGithubOAuthEnabled });
+  }
+
+  /**
+   * Switch TwitterOAuth enabled
+   */
+  switchIsTwitterOAuthEnabled() {
+    this.setState({ isTwitterOAuthEnabled: !this.state.isTwitterOAuthEnabled });
+  }
+
+
 }

+ 63 - 0
src/client/js/services/AdminGithubSecurityConatainer.js

@@ -0,0 +1,63 @@
+import { Container } from 'unstated';
+
+import loggerFactory from '@alias/logger';
+
+// eslint-disable-next-line no-unused-vars
+const logger = loggerFactory('growi:security:AdminGithubSecurityContainer');
+
+/**
+ * Service container for admin security page (GithubSecurityManagement.jsx)
+ * @extends {Container} unstated Container
+ */
+export default class AdminGithubSecurityContainer extends Container {
+
+  constructor(appContainer) {
+    super();
+
+    this.appContainer = appContainer;
+
+    this.state = {
+      // TODO GW-583 set value
+      appSiteUrl: '',
+      githubClientId: '',
+      githubClientSecret: '',
+      isSameUsernameTreatedAsIdenticalUser: 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 'AdminGithubSecurityContainer';
+  }
+
+  /**
+   * Change githubClientId
+   */
+  changeGithubClientId(value) {
+    this.setState({ githubClientId: value });
+  }
+
+  /**
+   * Change githubClientSecret
+   */
+  changeGithubClientSecret(value) {
+    this.setState({ githubClientSecret: value });
+  }
+
+  /**
+   * Switch isSameUsernameTreatedAsIdenticalUser
+   */
+  switchIsSameUsernameTreatedAsIdenticalUser() {
+    this.setState({ isSameUsernameTreatedAsIdenticalUser: !this.state.isSameUsernameTreatedAsIdenticalUser });
+  }
+
+}

+ 63 - 0
src/client/js/services/AdminGoogleSecurityContainer.js

@@ -0,0 +1,63 @@
+import { Container } from 'unstated';
+
+import loggerFactory from '@alias/logger';
+
+// eslint-disable-next-line no-unused-vars
+const logger = loggerFactory('growi:security:AdminGoogleSecurityContainer');
+
+/**
+ * Service container for admin security page (GoogleSecurityManagement.jsx)
+ * @extends {Container} unstated Container
+ */
+export default class AdminGoogleSecurityContainer extends Container {
+
+  constructor(appContainer) {
+    super();
+
+    this.appContainer = appContainer;
+
+    this.state = {
+      // TODO GW-583 set value
+      appSiteUrl: '',
+      googleClientId: '',
+      googleClientSecret: '',
+      isSameUsernameTreatedAsIdenticalUser: 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 'AdminGoogleSecurityContainer';
+  }
+
+  /**
+   * Change googleClientId
+   */
+  changeGoogleClientId(value) {
+    this.setState({ googleClientId: value });
+  }
+
+  /**
+   * Change googleClientSecret
+   */
+  changeGoogleClientSecret(value) {
+    this.setState({ googleClientSecret: value });
+  }
+
+  /**
+   * Switch isSameUsernameTreatedAsIdenticalUser
+   */
+  switchIsSameUsernameTreatedAsIdenticalUser() {
+    this.setState({ isSameUsernameTreatedAsIdenticalUser: !this.state.isSameUsernameTreatedAsIdenticalUser });
+  }
+
+}

+ 63 - 0
src/client/js/services/AdminTwitterSecurityContainer.js

@@ -0,0 +1,63 @@
+import { Container } from 'unstated';
+
+import loggerFactory from '@alias/logger';
+
+// eslint-disable-next-line no-unused-vars
+const logger = loggerFactory('growi:security:AdminTwitterSecurityContainer');
+
+/**
+ * Service container for admin security page (TwitterSecurityManagement.jsx)
+ * @extends {Container} unstated Container
+ */
+export default class AdminTwitterSecurityContainer extends Container {
+
+  constructor(appContainer) {
+    super();
+
+    this.appContainer = appContainer;
+
+    this.state = {
+      // TODO GW-583 set value
+      appSiteUrl: '',
+      TwitterConsumerId: '',
+      TwitterConsumerSecret: '',
+      isSameUsernameTreatedAsIdenticalUser: 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 'AdminTwitterSecurityContainer';
+  }
+
+  /**
+   * Change TwitterConsumerId
+   */
+  changeTwitterConsumerId(value) {
+    this.setState({ TwitterConsumerId: value });
+  }
+
+  /**
+   * Change TwitterConsumerSecret
+   */
+  changeTwitterConsumerSecret(value) {
+    this.setState({ TwitterConsumerSecret: value });
+  }
+
+  /**
+   * Switch isSameUsernameTreatedAsIdenticalUser
+   */
+  switchIsSameUsernameTreatedAsIdenticalUser() {
+    this.setState({ isSameUsernameTreatedAsIdenticalUser: !this.state.isSameUsernameTreatedAsIdenticalUser });
+  }
+
+}

+ 41 - 0
src/migrations/20191126173016-adjust-pages-path.js

@@ -0,0 +1,41 @@
+require('module-alias/register');
+const logger = require('@alias/logger')('growi:migrate:adjust-pages-path');
+
+const mongoose = require('mongoose');
+const config = require('@root/config/migrate');
+
+const pathUtils = require('growi-commons').pathUtils;
+
+module.exports = {
+  async up(db) {
+    logger.info('Apply migration');
+    mongoose.connect(config.mongoUri, config.mongodb.options);
+
+    const Page = require('@server/models/page')();
+
+    // retrieve target data
+    const pages = await Page.find({ path: /^(?!\/)/ });
+
+
+    // create requests for bulkWrite
+    const requests = pages.map((page) => {
+      const adjustedPath = pathUtils.addHeadingSlash(page.path);
+      return {
+        updateOne: {
+          filter: { _id: page._id },
+          update: { $set: { path: adjustedPath } },
+        },
+      };
+    });
+
+    if (requests.length > 0) {
+      await db.collection('pages').bulkWrite(requests);
+    }
+
+    logger.info('Migration has successfully applied');
+  },
+
+  down(db) {
+    // do not rollback
+  },
+};

+ 12 - 3
src/server/routes/page.js

@@ -573,7 +573,7 @@ module.exports = function(crowi, app) {
    */
   api.create = async function(req, res) {
     const body = req.body.body || null;
-    const pagePath = req.body.path || null;
+    let pagePath = req.body.path || null;
     const grant = req.body.grant || null;
     const grantUserGroupId = req.body.grantUserGroupId || null;
     const overwriteScopesOfDescendants = req.body.overwriteScopesOfDescendants || null;
@@ -586,6 +586,9 @@ module.exports = function(crowi, app) {
       return res.json(ApiResponse.error('Parameters body and path are required.'));
     }
 
+    // check whether path starts slash
+    pagePath = pathUtils.addHeadingSlash(pagePath);
+
     // check page existence
     const isExist = await Page.count({ path: pagePath }) > 0;
     if (isExist) {
@@ -1064,7 +1067,7 @@ module.exports = function(crowi, app) {
   api.rename = async function(req, res) {
     const pageId = req.body.page_id;
     const previousRevision = req.body.revision_id || null;
-    const newPagePath = pathUtils.normalizePath(req.body.new_path);
+    let newPagePath = pathUtils.normalizePath(req.body.new_path);
     const options = {
       createRedirectPage: (req.body.create_redirect != null),
       updateMetadata: (req.body.remain_metadata == null),
@@ -1076,6 +1079,9 @@ module.exports = function(crowi, app) {
       return res.json(ApiResponse.error(`Could not use the path '${newPagePath})'`, 'invalid_path'));
     }
 
+    // check whether path starts slash
+    newPagePath = pathUtils.addHeadingSlash(newPagePath);
+
     const isExist = await Page.count({ path: newPagePath }) > 0;
     if (isExist) {
       // if page found, cannot cannot rename to that path
@@ -1130,7 +1136,7 @@ module.exports = function(crowi, app) {
    */
   api.duplicate = async function(req, res) {
     const pageId = req.body.page_id;
-    const newPagePath = pathUtils.normalizePath(req.body.new_path);
+    let newPagePath = pathUtils.normalizePath(req.body.new_path);
 
     const page = await Page.findByIdAndViewer(pageId, req.user);
 
@@ -1138,6 +1144,9 @@ module.exports = function(crowi, app) {
       return res.json(ApiResponse.error(`Page '${pageId}' is not found or forbidden`, 'notfound_or_forbidden'));
     }
 
+    // check whether path starts slash
+    newPagePath = pathUtils.addHeadingSlash(newPagePath);
+
     await page.populateDataToShowRevision();
     const originTags = await page.findRelatedTagsById();