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

Merge branch 'support/apply-bootstrap4' into support/apply-bootstrap4-admin-user

# Conflicts:
#	src/client/js/components/Admin/ImportData/GrowiArchive/ImportCollectionConfigurationModal.jsx
WESEEK Kaito 6 лет назад
Родитель
Сommit
4dd7cee482
35 измененных файлов с 871 добавлено и 548 удалено
  1. 3 2
      .github/workflows/build.yml
  2. 1 0
      .github/workflows/release.yml
  3. 9 1
      CHANGES.md
  4. 1 0
      package.json
  5. 5 0
      src/client/js/app.jsx
  6. 71 0
      src/client/js/components/Admin/AdminHome/AdminHome.jsx
  7. 53 0
      src/client/js/components/Admin/AdminHome/InstalledPluginTable.jsx
  8. 53 0
      src/client/js/components/Admin/AdminHome/SystemInfomationTable.jsx
  9. 9 9
      src/client/js/components/Admin/App/AppSettingsPage.jsx
  10. 1 1
      src/client/js/components/Admin/ImportData/GrowiArchive/ImportCollectionConfigurationModal.jsx
  11. 21 16
      src/client/js/components/Admin/MarkdownSetting/LineBreakForm.jsx
  12. 13 6
      src/client/js/components/Admin/MarkdownSetting/MarkDownSetting.jsx
  13. 78 60
      src/client/js/components/Admin/MarkdownSetting/PresentationForm.jsx
  14. 72 77
      src/client/js/components/Admin/MarkdownSetting/XssForm.jsx
  15. 13 36
      src/client/js/components/PageEditor/CodeMirrorEditor.jsx
  16. 89 0
      src/client/js/components/PageEditor/EditorIcon.jsx
  17. 1 0
      src/client/js/components/PageEditor/HandsontableModal.jsx
  18. 61 0
      src/client/js/services/AdminHomeContainer.js
  19. 176 0
      src/client/styles/agile-admin/inverse/colors/_apply-colors-dark.scss
  20. 0 4
      src/client/styles/agile-admin/inverse/colors/blue-night.scss
  21. 0 4
      src/client/styles/agile-admin/inverse/colors/default-dark.scss
  22. 0 4
      src/client/styles/agile-admin/inverse/colors/future.scss
  23. 0 4
      src/client/styles/agile-admin/inverse/colors/halloween.scss
  24. 2 1
      src/client/styles/scss/_handsontable.scss
  25. 8 2
      src/server/models/config.js
  26. 13 9
      src/server/plugins/plugin-utils.js
  27. 1 5
      src/server/routes/admin.js
  28. 77 0
      src/server/routes/apiv3/admin-home.js
  29. 2 2
      src/server/routes/apiv3/customize-setting.js
  30. 2 0
      src/server/routes/apiv3/index.js
  31. 1 1
      src/server/routes/apiv3/markdown-setting.js
  32. 10 0
      src/server/routes/login-passport.js
  33. 1 47
      src/server/views/admin/index.html
  34. 1 248
      src/server/views/admin/markdown.html
  35. 23 9
      yarn.lock

+ 3 - 2
.github/workflows/build.yml

@@ -2,8 +2,9 @@ name: Release Docker Images
 
 on:
   push:
-    tags:
-      - v3.*.*
+    branches:
+      - tmp/release-**
+
 
 jobs:
 

+ 1 - 0
.github/workflows/release.yml

@@ -41,5 +41,6 @@ jobs:
       uses: Roang-zero1/github-create-release-action@master
       with:
         created_tag: v${{ env.RELEASE_VERSION }}
+        changelog_file: CHANGES.md
       env:
         GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

+ 9 - 1
CHANGES.md

@@ -1,9 +1,17 @@
 # CHANGES
 
-## v3.6.4-RC
+## v3.6.5-RC
+
+*
+
+## v3.6.4
 
 * Feature: Alert for stale page
+* Improvement: Reactify admin pages (Home)
 * Improvement: Reactify admin pages (App)
+* Improvement: Accessibility for editor icons of dark themes
+* Improvement: Accessibility for importing table data pane
+* Improvement: Resolve username and email when logging in with Google OAuth
 
 ## v3.6.3
 

+ 1 - 0
package.json

@@ -125,6 +125,7 @@
     "nodemailer-ses-transport": "~1.5.0",
     "npm-run-all": "^4.1.2",
     "openid-client": "=2.5.0",
+    "package-installed-version-sync": "^2.1.0",
     "passport": "^0.4.0",
     "passport-github": "^1.1.0",
     "passport-google-oauth20": "^2.0.0",

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

@@ -34,6 +34,7 @@ import MyDraftList from './components/MyDraftList/MyDraftList';
 import UserPictureList from './components/User/UserPictureList';
 import TableOfContents from './components/TableOfContents';
 
+import AdminHome from './components/Admin/AdminHome/AdminHome';
 import UserGroupDetailPage from './components/Admin/UserGroupDetail/UserGroupDetailPage';
 import MarkdownSetting from './components/Admin/MarkdownSetting/MarkDownSetting';
 import UserManagement from './components/Admin/UserManagement';
@@ -50,6 +51,7 @@ import PageContainer from './services/PageContainer';
 import CommentContainer from './services/CommentContainer';
 import EditorContainer from './services/EditorContainer';
 import TagContainer from './services/TagContainer';
+import AdminHomeContainer from './services/AdminHomeContainer';
 import AdminCustomizeContainer from './services/AdminCustomizeContainer';
 import UserGroupDetailContainer from './services/UserGroupDetailContainer';
 import AdminUsersContainer from './services/AdminUsersContainer';
@@ -156,11 +158,13 @@ Object.keys(componentMappings).forEach((key) => {
 });
 
 // create unstated container instance for admin
+const adminHomeContainer = new AdminHomeContainer(appContainer);
 const adminCustomizeContainer = new AdminCustomizeContainer(appContainer);
 const adminUsersContainer = new AdminUsersContainer(appContainer);
 const adminExternalAccountsContainer = new AdminExternalAccountsContainer(appContainer);
 const adminMarkDownContainer = new AdminMarkDownContainer(appContainer);
 const adminContainers = {
+  'admin-home': adminHomeContainer,
   'admin-customize': adminCustomizeContainer,
   'admin-user-page': adminUsersContainer,
   'admin-external-account-setting': adminExternalAccountsContainer,
@@ -188,6 +192,7 @@ if (adminAppElem != null) {
  *  value: React Element
  */
 const adminComponentMappings = {
+  'admin-home': <AdminHome />,
   'admin-customize': <Customize />,
   'admin-user-page': <UserManagement />,
   'admin-external-account-setting': <ManageExternalAccount />,

+ 71 - 0
src/client/js/components/Admin/AdminHome/AdminHome.jsx

@@ -0,0 +1,71 @@
+import React, { Fragment } from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+import loggerFactory from '@alias/logger';
+
+import { toastError } from '../../../util/apiNotification';
+
+import { createSubscribedElement } from '../../UnstatedUtils';
+import AppContainer from '../../../services/AppContainer';
+import AdminHomeContainer from '../../../services/AdminHomeContainer';
+import SystemInfomationTable from './SystemInfomationTable';
+import InstalledPluginTable from './InstalledPluginTable';
+
+const logger = loggerFactory('growi:admin');
+
+class AdminHome extends React.Component {
+
+  async componentDidMount() {
+    const { adminHomeContainer } = this.props;
+
+    try {
+      await adminHomeContainer.retrieveAdminHomeData();
+    }
+    catch (err) {
+      toastError(err);
+      adminHomeContainer.setState({ retrieveError: err });
+      logger.error(err);
+    }
+  }
+
+  render() {
+    const { t } = this.props;
+
+    return (
+      <Fragment>
+        <p>
+          {t('admin_top.wiki_administrator')}
+          <br></br>
+          {t('admin_top.assign_administrator')}
+        </p>
+
+        <div className="row mb-5">
+          <div className="col-md-12">
+            <h2 className="admin-setting-header">{t('admin_top.System Information')}</h2>
+            <SystemInfomationTable />
+          </div>
+        </div>
+
+        <div className="row mb-5">
+          <div className="col-md-12">
+            <h2 className="admin-setting-header">{t('admin_top.List of installed plugins')}</h2>
+            <InstalledPluginTable />
+          </div>
+        </div>
+      </Fragment>
+    );
+  }
+
+}
+
+const AdminHomeWrapper = (props) => {
+  return createSubscribedElement(AdminHome, props, [AppContainer, AdminHomeContainer]);
+};
+
+AdminHome.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  adminHomeContainer: PropTypes.instanceOf(AdminHomeContainer).isRequired,
+};
+
+export default withTranslation()(AdminHomeWrapper);

+ 53 - 0
src/client/js/components/Admin/AdminHome/InstalledPluginTable.jsx

@@ -0,0 +1,53 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+
+import { createSubscribedElement } from '../../UnstatedUtils';
+import AppContainer from '../../../services/AppContainer';
+import AdminHomeContainer from '../../../services/AdminHomeContainer';
+
+class InstalledPluginTable extends React.Component {
+
+  render() {
+    const { t, adminHomeContainer } = this.props;
+
+    return (
+      <table className="table table-bordered">
+        <thead>
+          <tr>
+            <th className="text-center">{ t('admin_top.Package name') }</th>
+            <th className="text-center">{ t('admin_top.Specified version') }</th>
+            <th className="text-center">{ t('admin_top.Installed version') }</th>
+          </tr>
+        </thead>
+        <tbody>
+          { adminHomeContainer.state.installedPlugins.map((plugin) => {
+            return (
+              <tr key={plugin.name}>
+                <td>{ plugin.name }</td>
+                <td className="text-center">{ plugin.requiredVersion }</td>
+                <td className="text-center">{ plugin.installedVersion }</td>
+              </tr>
+            );
+          }) }
+        </tbody>
+      </table>
+    );
+  }
+
+}
+
+InstalledPluginTable.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  adminHomeContainer: PropTypes.instanceOf(AdminHomeContainer).isRequired,
+};
+
+/**
+ * Wrapper component for using unstated
+ */
+const InstalledPluginTableWrapper = (props) => {
+  return createSubscribedElement(InstalledPluginTable, props, [AppContainer, AdminHomeContainer]);
+};
+
+export default withTranslation()(InstalledPluginTableWrapper);

+ 53 - 0
src/client/js/components/Admin/AdminHome/SystemInfomationTable.jsx

@@ -0,0 +1,53 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+
+import { createSubscribedElement } from '../../UnstatedUtils';
+import AppContainer from '../../../services/AppContainer';
+import AdminHomeContainer from '../../../services/AdminHomeContainer';
+
+class SystemInformationTable extends React.Component {
+
+  render() {
+    const { adminHomeContainer } = this.props;
+
+    return (
+      <table className="table table-bordered">
+        <tbody>
+          <tr>
+            <th className="col-sm-4">GROWI</th>
+            <td>{ adminHomeContainer.state.growiVersion }</td>
+          </tr>
+          <tr>
+            <th>node.js</th>
+            <td>{ adminHomeContainer.state.nodeVersion }</td>
+          </tr>
+          <tr>
+            <th>npm</th>
+            <td>{ adminHomeContainer.state.npmVersion }</td>
+          </tr>
+          <tr>
+            <th>yarn</th>
+            <td>{ adminHomeContainer.state.yarnVersion }</td>
+          </tr>
+        </tbody>
+      </table>
+    );
+  }
+
+}
+
+SystemInformationTable.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  adminHomeContainer: PropTypes.instanceOf(AdminHomeContainer).isRequired,
+};
+
+/**
+ * Wrapper component for using unstated
+ */
+const SystemInformationTableWrapper = (props) => {
+  return createSubscribedElement(SystemInformationTable, props, [AppContainer, AdminHomeContainer]);
+};
+
+export default withTranslation()(SystemInformationTableWrapper);

+ 9 - 9
src/client/js/components/Admin/App/AppSettingsPage.jsx

@@ -39,35 +39,35 @@ class AppSettingsPage extends React.Component {
       <Fragment>
         <div className="row">
           <div className="col-md-12">
-            <h2>{t('App Settings')}</h2>
+            <h2 className="admin-setting-header">{t('App Settings')}</h2>
             <AppSetting />
           </div>
         </div>
 
-        <div className="row">
+        <div className="row mt-5">
           <div className="col-md-12">
-            <h2>{t('Site URL settings')}</h2>
+            <h2 className="admin-setting-header">{t('Site URL settings')}</h2>
             <SiteUrlSetting />
           </div>
         </div>
 
-        <div className="row">
+        <div className="row mt-5">
           <div className="col-md-12">
-            <h2>{t('app_setting.Mail settings')}</h2>
+            <h2 className="admin-setting-header">{t('app_setting.Mail settings')}</h2>
             <MailSetting />
           </div>
         </div>
 
-        <div className="row">
+        <div className="row mt-5">
           <div className="col-md-12">
-            <h2>{t('app_setting.AWS settings')}</h2>
+            <h2 className="admin-setting-header">{t('app_setting.AWS settings')}</h2>
             <AwsSetting />
           </div>
         </div>
 
-        <div className="row">
+        <div className="row mt-5">
           <div className="col-md-12">
-            <h2>{t('app_setting.Plugin settings')}</h2>
+            <h2 className="admin-setting-header">{t('app_setting.Plugin settings')}</h2>
             <PluginSetting />
           </div>
         </div>

+ 1 - 1
src/client/js/components/Admin/ImportData/GrowiArchive/ImportCollectionConfigurationModal.jsx

@@ -3,7 +3,7 @@
 import React from 'react';
 import PropTypes from 'prop-types';
 import { withTranslation } from 'react-i18next';
-/* import Modal from 'react-bootstrap/es/Modal'; */
+import { Modal } from 'reactstrap';
 
 import GrowiArchiveImportOption from '@commons/models/admin/growi-archive-import-option';
 

+ 21 - 16
src/client/js/components/Admin/MarkdownSetting/LineBreakForm.jsx

@@ -1,5 +1,7 @@
 /* eslint-disable react/no-danger */
 import React from 'react';
+import { Button } from 'reactstrap';
+
 import PropTypes from 'prop-types';
 import { withTranslation } from 'react-i18next';
 import loggerFactory from '@alias/logger';
@@ -7,6 +9,7 @@ import loggerFactory from '@alias/logger';
 import { createSubscribedElement } from '../../UnstatedUtils';
 import { toastSuccess, toastError } from '../../../util/apiNotification';
 
+
 import AppContainer from '../../../services/AppContainer';
 import AdminMarkDownContainer from '../../../services/AdminMarkDownContainer';
 
@@ -41,17 +44,18 @@ class LineBreakForm extends React.Component {
     const helpLineBreak = { __html: t('markdown_setting.Enable Line Break desc') };
 
     return (
-      <div className="form-group row">
-        <div className="col-xs-offset-4 col-xs-6 text-left">
-          <div className="checkbox checkbox-success">
+      <div className="form-group text-left my-3">
+        <div className="col-8 offset-4">
+          <div className="custom-control custom-switch checkbox-success">
             <input
               type="checkbox"
+              className="custom-control-input"
               id="isEnabledLinebreaks"
               checked={isEnabledLinebreaks}
               onChange={() => { adminMarkDownContainer.setState({ isEnabledLinebreaks: !isEnabledLinebreaks }) }}
             />
-            <label htmlFor="isEnabledLinebreaks">
-              {t('markdown_setting.Enable Line Break')}
+            <label className="custom-control-label" htmlFor="isEnabledLinebreaks">
+              { t('markdown_setting.Enable Line Break') }
             </label>
           </div>
           <p className="help-block" dangerouslySetInnerHTML={helpLineBreak} />
@@ -67,17 +71,18 @@ class LineBreakForm extends React.Component {
     const helpLineBreakInComment = { __html: t('markdown_setting.Enable Line Break for comment desc') };
 
     return (
-      <div className="form-group row">
-        <div className="col-xs-offset-4 col-xs-6 text-left">
-          <div className="checkbox checkbox-success">
+      <div className="form-group text-left my-3">
+        <div className="col-8 offset-4">
+          <div className="custom-control custom-switch checkbox-success">
             <input
               type="checkbox"
+              className="custom-control-input"
               id="isEnabledLinebreaksInComments"
               checked={isEnabledLinebreaksInComments}
               onChange={() => { adminMarkDownContainer.setState({ isEnabledLinebreaksInComments: !isEnabledLinebreaksInComments }) }}
             />
-            <label htmlFor="isEnabledLinebreaksInComments">
-              {t('markdown_setting.Enable Line Break for comment')}
+            <label className="custom-control-label" htmlFor="isEnabledLinebreaksInComments">
+              { t('markdown_setting.Enable Line Break for comment') }
             </label>
           </div>
           <p className="help-block" dangerouslySetInnerHTML={helpLineBreakInComment} />
@@ -91,20 +96,20 @@ class LineBreakForm extends React.Component {
 
     return (
       <React.Fragment>
-        <fieldset className="row">
+        <fieldset className="col-12">
           {this.renderLineBreakOption()}
           {this.renderLineBreakInCommentOption()}
         </fieldset>
-        <div className="form-group my-3">
-          <div className="col-xs-offset-4 col-xs-5">
-            <button
+        <div className="form-group col-12 m-3">
+          <div className="offset-4 col-8">
+            <Button
               type="submit"
-              className="btn btn-primary"
+              color="primary"
               onClick={this.onClickSubmit}
               disabled={adminMarkDownContainer.state.retrieveError != null}
             >
               {t('Update')}
-            </button>
+            </Button>
           </div>
         </div>
       </React.Fragment>

+ 13 - 6
src/client/js/components/Admin/MarkdownSetting/MarkDownSetting.jsx

@@ -1,4 +1,5 @@
 import React from 'react';
+import { Card, CardBody } from 'reactstrap';
 import PropTypes from 'prop-types';
 import { withTranslation } from 'react-i18next';
 
@@ -38,22 +39,28 @@ class MarkdownSetting extends React.Component {
       <React.Fragment>
         {/* Line Break Setting */}
         <div className="row mb-5">
-          <h2 className="border-bottom">{ t('markdown_setting.line_break_setting') }</h2>
-          <p className="well">{ t('markdown_setting.line_break_setting_desc') }</p>
+          <h2 className="border-bottom col-12">{ t('markdown_setting.line_break_setting') }</h2>
+          <Card className="card-well col-12">
+            <CardBody className="px-2 py-3">{ t('markdown_setting.line_break_setting_desc') }</CardBody>
+          </Card>
           <LineBreakForm />
         </div>
 
         {/* Presentation Setting */}
         <div className="row mb-5">
-          <h2 className="border-bottom">{ t('markdown_setting.presentation_setting') }</h2>
-          <p className="well">{ t('markdown_setting.presentation_setting_desc') }</p>
+          <h2 className="border-bottom col-12">{ t('markdown_setting.presentation_setting') }</h2>
+          <Card className="card-well col-12">
+            <CardBody className="px-2 py-3">{ t('markdown_setting.presentation_setting_desc') }</CardBody>
+          </Card>
           <PresentationForm />
         </div>
 
         {/* XSS Setting */}
         <div className="row mb-5">
-          <h2 className="border-bottom">{ t('markdown_setting.XSS_setting') }</h2>
-          <p className="well">{ t('markdown_setting.XSS_setting_desc') }</p>
+          <h2 className="border-bottom col-12">{ t('markdown_setting.XSS_setting') }</h2>
+          <Card className="card-well col-12">
+            <CardBody className="px-2 py-3">{ t('markdown_setting.XSS_setting_desc') }</CardBody>
+          </Card>
           <XssForm />
         </div>
       </React.Fragment>

+ 78 - 60
src/client/js/components/Admin/MarkdownSetting/PresentationForm.jsx

@@ -1,4 +1,6 @@
 import React from 'react';
+import { Button } from 'reactstrap';
+
 import PropTypes from 'prop-types';
 import { withTranslation } from 'react-i18next';
 import loggerFactory from '@alias/logger';
@@ -38,71 +40,87 @@ class PresentationForm extends React.Component {
     const { pageBreakSeparator, pageBreakCustomSeparator } = adminMarkDownContainer.state;
 
     return (
-      <fieldset className="form-group row my-2">
-
-        <label className="col-xs-3 control-label text-right">
-          { t('markdown_setting.Page break setting') }
-        </label>
-
-        <div className="col-xs-3 radio radio-primary">
-          <input
-            type="radio"
-            id="pageBreakOption1"
-            checked={pageBreakSeparator === 1}
-            onChange={() => adminMarkDownContainer.switchPageBreakSeparator(1)}
-          />
-          <label htmlFor="pageBreakOption1">
-            <p className="font-weight-bold">{ t('markdown_setting.Preset one separator') }</p>
-            <div className="mt-3">
-              { t('markdown_setting.Preset one separator desc') }
-              <pre><code>{ t('markdown_setting.Preset one separator value') }</code></pre>
+      <React.Fragment>
+        <fieldset className="form-group col-12 my-2">
+
+          <p className="col-12 col-sm-3 control-label font-weight-bold text-left mt-3">
+            { t('markdown_setting.Page break setting') }
+          </p>
+
+          <div className="form-group form-check-inline col-12 my-3">
+            <div className="col-4 align-self-start">
+              <div className="custom-control custom-radio">
+                <input
+                  type="radio"
+                  className="custom-control-input"
+                  id="pageBreakOption1"
+                  checked={pageBreakSeparator === 1}
+                  onChange={() => adminMarkDownContainer.switchPageBreakSeparator(1)}
+                />
+                <label className="custom-control-label" htmlFor="pageBreakOption1">
+                  <p className="font-weight-bold">{ t('markdown_setting.Preset one separator') }</p>
+                  <div className="mt-3">
+                    { t('markdown_setting.Preset one separator desc') }
+                    <pre><code>{ t('markdown_setting.Preset one separator value') }</code></pre>
+                  </div>
+                </label>
+              </div>
             </div>
-          </label>
-        </div>
-
-        <div className="col-xs-3 radio radio-primary mt-3">
-          <input
-            type="radio"
-            id="pageBreakOption2"
-            checked={pageBreakSeparator === 2}
-            onChange={() => adminMarkDownContainer.switchPageBreakSeparator(2)}
-          />
-          <label htmlFor="pageBreakOption2">
-            <p className="font-weight-bold">{ t('markdown_setting.Preset two separator') }</p>
-            <div className="mt-3">
-              { t('markdown_setting.Preset two separator desc') }
-              <pre><code>{ t('markdown_setting.Preset two separator value') }</code></pre>
+            <div className="col-4 align-self-start">
+              <div className="custom-control custom-radio">
+                <input
+                  type="radio"
+                  className="custom-control-input"
+                  id="pageBreakOption2"
+                  checked={pageBreakSeparator === 2}
+                  onChange={() => adminMarkDownContainer.switchPageBreakSeparator(2)}
+                />
+                <label className="custom-control-label" htmlFor="pageBreakOption2">
+                  <p className="font-weight-bold">{ t('markdown_setting.Preset two separator') }</p>
+                  <div className="mt-3">
+                    { t('markdown_setting.Preset two separator desc') }
+                    <pre><code>{ t('markdown_setting.Preset two separator value') }</code></pre>
+                  </div>
+                </label>
+              </div>
             </div>
-          </label>
-        </div>
-
-        <div className="col-xs-3 radio radio-primary mt-3">
-          <input
-            type="radio"
-            id="pageBreakOption3"
-            checked={pageBreakSeparator === 3}
-            onChange={() => adminMarkDownContainer.switchPageBreakSeparator(3)}
-          />
-          <label htmlFor="pageBreakOption3">
-            <p className="font-weight-bold">{ t('markdown_setting.Custom separator') }</p>
-            <div className="mt-3">
-              { t('markdown_setting.Custom separator desc') }
-              <input
-                className="form-control"
-                defaultValue={pageBreakCustomSeparator}
-                onChange={(e) => { adminMarkDownContainer.setPageBreakCustomSeparator(e.target.value) }}
-              />
+            <div className="col-4 align-self-start">
+              <div className="custom-control custom-radio">
+                <input
+                  type="radio"
+                  id="pageBreakOption3"
+                  className="custom-control-input"
+                  checked={pageBreakSeparator === 3}
+                  onChange={() => adminMarkDownContainer.switchPageBreakSeparator(3)}
+                />
+                <label className="custom-control-label" htmlFor="pageBreakOption3">
+                  <p className="font-weight-bold">{ t('markdown_setting.Custom separator') }</p>
+                  <div className="mt-3">
+                    { t('markdown_setting.Custom separator desc') }
+                    <input
+                      className="form-control"
+                      value={pageBreakCustomSeparator}
+                      onChange={(e) => { adminMarkDownContainer.setPageBreakCustomSeparator(e.target.value) }}
+                    />
+                  </div>
+                </label>
+              </div>
             </div>
-          </label>
-        </div>
-
-        <div className="form-group my-3">
-          <div className="col-xs-offset-4 col-xs-5">
-            <div className="btn btn-primary" onClick={this.onClickSubmit} disabled={adminMarkDownContainer.state.retrieveError != null}>{ t('Update') }</div>
+          </div>
+        </fieldset>
+        <div className="form-group col-12 m-3">
+          <div className="offset-4 col-8">
+            <Button
+              type="submit"
+              color="primary"
+              onClick={this.onClickSubmit}
+              disabled={adminMarkDownContainer.state.retrieveError != null}
+            >
+              { t('Update') }
+            </Button>
           </div>
         </div>
-
-      </fieldset>
+      </React.Fragment>
     );
   }
 

+ 72 - 77
src/client/js/components/Admin/MarkdownSetting/XssForm.jsx

@@ -1,11 +1,12 @@
 import React from 'react';
+import { Button } from 'reactstrap';
+
 import PropTypes from 'prop-types';
 import { withTranslation } from 'react-i18next';
 import loggerFactory from '@alias/logger';
 
 import { createSubscribedElement } from '../../UnstatedUtils';
 import { toastSuccess, toastError } from '../../../util/apiNotification';
-import { tags, attrs } from '../../../../../lib/service/xss/recommended-whitelist';
 
 import AppContainer from '../../../services/AppContainer';
 import AdminMarkDownContainer from '../../../services/AdminMarkDownContainer';
@@ -40,76 +41,60 @@ class XssForm extends React.Component {
     const { xssOption } = adminMarkDownContainer.state;
 
     return (
-      <fieldset className="row col-xs-12 my-3">
-        <div className="col-xs-4 radio radio-primary">
-          <input
-            type="radio"
-            id="xssOption1"
-            name="XssOption"
-            checked={xssOption === 1}
-            onChange={() => { adminMarkDownContainer.setState({ xssOption: 1 }) }}
-          />
-          <label htmlFor="xssOption1">
-            <p className="font-weight-bold">{t('markdown_setting.Ignore all tags')}</p>
-            <div className="mt-4">
-              {t('markdown_setting.Ignore all tags desc')}
-            </div>
-          </label>
+      <div className="form-group form-check-inline col-12 my-3">
+        <div className="col-4 align-self-start">
+          <div className="custom-control custom-radio ">
+            <input
+              type="radio"
+              className="custom-control-input"
+              id="xssOption1"
+              name="XssOption"
+              checked={xssOption === 1}
+              onChange={() => { adminMarkDownContainer.setState({ xssOption: 1 }) }}
+            />
+            <label className="custom-control-label" htmlFor="xssOption1">
+              <p className="font-weight-bold">{ t('markdown_setting.Ignore all tags') }</p>
+              <div className="mt-4">
+                { t('markdown_setting.Ignore all tags desc') }
+              </div>
+            </label>
+          </div>
         </div>
 
-        <div className="col-xs-4 radio radio-primary">
-          <input
-            type="radio"
-            id="xssOption2"
-            name="XssOption"
-            checked={xssOption === 2}
-            onChange={() => { adminMarkDownContainer.setState({ xssOption: 2 }) }}
-          />
-          <label htmlFor="xssOption2">
-            <p className="font-weight-bold">{t('markdown_setting.Recommended setting')}</p>
-            <div className="m-t-15">
-              <div className="d-flex justify-content-between">
-                {t('markdown_setting.Tag names')}
-              </div>
-              <textarea
-                className="form-control xss-list"
-                name="recommendedTags"
-                rows="6"
-                cols="40"
-                readOnly
-                defaultValue={tags}
-              />
-            </div>
-            <div className="m-t-15">
-              <div className="d-flex justify-content-between">
-                {t('markdown_setting.Tag attributes')}
-              </div>
-              <textarea
-                className="form-control xss-list"
-                name="recommendedAttrs"
-                rows="6"
-                cols="40"
-                readOnly
-                defaultValue={attrs}
-              />
-            </div>
-          </label>
+        <div className="col-4 align-self-start">
+          <div className="custom-control custom-radio">
+            <input
+              type="radio"
+              className="custom-control-input"
+              id="xssOption2"
+              name="XssOption"
+              checked={xssOption === 2}
+              onChange={() => { adminMarkDownContainer.setState({ xssOption: 2 }) }}
+            />
+            <label className="custom-control-label" htmlFor="xssOption2">
+              <p className="font-weight-bold">{ t('markdown_setting.Recommended setting') }</p>
+              <WhiteListInput customizable={false} />
+            </label>
+          </div>
         </div>
 
-        <div className="col-xs-4 radio radio-primary">
-          <input
-            type="radio"
-            id="xssOption3"
-            name="XssOption"
-            checked={xssOption === 3}
-            onChange={() => { adminMarkDownContainer.setState({ xssOption: 3 }) }}
-          />
-          <label htmlFor="xssOption3">
-            <p className="font-weight-bold">{t('markdown_setting.Custom Whitelist')}</p>
-            <WhiteListInput />
-          </label>
+        <div className="col-4 align-self-start">
+          <div className="custom-control custom-radio">
+            <input
+              type="radio"
+              className="custom-control-input"
+              id="xssOption3"
+              name="XssOption"
+              checked={xssOption === 3}
+              onChange={() => { adminMarkDownContainer.setState({ xssOption: 3 }) }}
+            />
+            <label className="custom-control-label" htmlFor="xssOption3">
+              <p className="font-weight-bold">{ t('markdown_setting.Custom Whitelist') }</p>
+              <WhiteListInput customizable />
+            </label>
+          </div>
         </div>
-      </fieldset>
+      </div>
     );
   }
 
@@ -119,31 +104,41 @@ class XssForm extends React.Component {
 
     return (
       <React.Fragment>
-        <form className="row">
+        <fieldset className="col-12">
           <div className="form-group">
-            <div className="col-xs-offset-4 col-xs-4 text-left">
-              <div className="checkbox checkbox-success">
+            <div className="col-8 offset-4 my-3">
+              <div className="custom-control custom-switch checkbox-success">
                 <input
                   type="checkbox"
+                  className="custom-control-input"
                   id="XssEnable"
-                  className="form-check-input"
                   name="isEnabledXss"
                   checked={isEnabledXss}
                   onChange={adminMarkDownContainer.switchEnableXss}
                 />
-                <label htmlFor="XssEnable">
-                  {t('markdown_setting.Enable XSS prevention')}
+                <label className="custom-control-label" htmlFor="XssEnable">
+                  { t('markdown_setting.Enable XSS prevention') }
                 </label>
               </div>
             </div>
+          </div>
+
+          <div className="col-12">
             {isEnabledXss && this.xssOptions()}
           </div>
-          <div className="form-group my-3">
-            <div className="col-xs-offset-4 col-xs-5">
-              <div className="btn btn-primary" onClick={this.onClickSubmit} disabled={adminMarkDownContainer.state.retrieveError != null}> {t('Update')}</div>
-            </div>
+        </fieldset>
+        <div className="form-group col-12 m-3">
+          <div className="offset-4 col-8">
+            <Button
+              type="submit"
+              color="primary"
+              onClick={this.onClickSubmit}
+              disabled={adminMarkDownContainer.state.retrieveError != null}
+            >
+              {t('Update')}
+            </Button>
           </div>
-        </form>
+        </div>
       </React.Fragment>
     );
   }

+ 13 - 36
src/client/js/components/PageEditor/CodeMirrorEditor.jsx

@@ -18,6 +18,7 @@ import PreventMarkdownListInterceptor from './PreventMarkdownListInterceptor';
 import MarkdownTableInterceptor from './MarkdownTableInterceptor';
 import mtu from './MarkdownTableUtil';
 import HandsontableModal from './HandsontableModal';
+import EditorIcon from './EditorIcon';
 
 const loadScript = require('simple-load-script');
 const loadCssSync = require('load-css-file');
@@ -655,9 +656,7 @@ export default class CodeMirrorEditor extends AbstractEditor {
         title="Bold"
         onClick={this.createReplaceSelectionHandler('**', '**')}
       >
-        <svg xmlns="http://www.w3.org/2000/svg" height="13" viewBox="0 0 10.9 14">
-          <path d="M0 0h5.6c3 0 4.7 1.1 4.7 3.4a3.1 3.1 0 0 1-2.5 3.1 3.7 3.7 0 0 1 3.1 3.5c0 2.9-1.4 4-4.2 4H0zm5.2 6.5c2.7 0 2.6-1.4 2.6-3.1S7.9.7 5.6.7H2.3v5.8zm-2.9 6.6h3.4c2.1 0 2.7-1.1 2.7-3.1s0-2.8-3.2-2.8H2.3z" />
-        </svg>
+        <EditorIcon icon="Bold" />
       </Button>,
       <Button
         key="nav-item-italic"
@@ -665,9 +664,7 @@ export default class CodeMirrorEditor extends AbstractEditor {
         title="Italic"
         onClick={this.createReplaceSelectionHandler('*', '*')}
       >
-        <svg xmlns="http://www.w3.org/2000/svg" height="13" viewBox="0 0 8.6 13.9">
-          <path d="M8.1 0a.6.6 0 0 1 .5.6c0 .3-.2.6-.7.6H6.2L3.8 12.8h1.8c.2 0 .4.3.4.5a.7.7 0 0 1-.7.6H.5c-.3 0-.5-.4-.5-.6s.4-.6.7-.6h1.7L4.9 1.2H3.1a.5.5 0 0 1-.5-.5c0-.3.1-.7.8-.7z" />
-        </svg>
+        <EditorIcon icon="Italic" />
       </Button>,
       <Button
         key="nav-item-strikethrough"
@@ -675,9 +672,7 @@ export default class CodeMirrorEditor extends AbstractEditor {
         title="Strikethrough"
         onClick={this.createReplaceSelectionHandler('~~', '~~')}
       >
-        <svg xmlns="http://www.w3.org/2000/svg" height="13" viewBox="0 0 19.5 14">
-          <path d="M5.8 6.2H9C7.2 5.7 6.3 5 6.3 3.8a2.2 2.2 0 0 1 .9-1.9 4.3 4.3 0 0 1 2.5-.7 4.3 4.3 0 0 1 2.5.7 3.1 3.1 0 0 1 1.1 1.6.7.7 0 0 0 .6.4h.3a.7.7 0 0 0 .4-.8A3.6 3.6 0 0 0 13.1 1a6.7 6.7 0 0 0-6-.5 3.1 3.1 0 0 0-1.7 1.3 3.6 3.6 0 0 0-.6 2 2.9 2.9 0 0 0 1 2.3zm7 2.5a2 2 0 0 1 .6 1.4 2.4 2.4 0 0 1-1 1.9 3.7 3.7 0 0 1-2.5.7 4.6 4.6 0 0 1-3-.8 3.7 3.7 0 0 1-1.2-2 .6.6 0 0 0-.6-.5h-.2a.7.7 0 0 0-.5.8 4.1 4.1 0 0 0 1.5 2.5A6 6 0 0 0 9.8 14a7.5 7.5 0 0 0 2.6-.5 4.9 4.9 0 0 0 1.8-1.4 4.3 4.3 0 0 0 .6-2.2 5 5 0 0 0-.2-1.2zM.4 7.9a.7.7 0 0 1-.4-.5.4.4 0 0 1 .4-.4h18.8a.4.4 0 0 1 .3.6c0 .1-.1.2-.2.3z" />
-        </svg>
+        <EditorIcon icon="Strikethrough" />
       </Button>,
       <Button
         key="nav-item-header"
@@ -685,9 +680,7 @@ export default class CodeMirrorEditor extends AbstractEditor {
         title="Heading"
         onClick={this.makeHeaderHandler}
       >
-        <svg xmlns="http://www.w3.org/2000/svg" height="13" viewBox="0 0 13.7 14">
-          <path d="M10.2 0h2.9a.6.6 0 0 1 .6.6.6.6 0 0 1-.6.6h-.8v11.6h.8a.6.6 0 0 1 .6.6.6.6 0 0 1-.6.6h-2.9a.6.6 0 0 1-.6-.6.6.6 0 0 1 .6-.6h.8V7.2H2.7v5.6h.8a.6.6 0 0 1 .6.6.6.6 0 0 1-.6.6H.6a.6.6 0 0 1-.6-.6.6.6 0 0 1 .6-.6h.7V1.2H.6A.6.6 0 0 1 0 .6.6.6 0 0 1 .6 0h2.9a.6.6 0 0 1 .6.6.6.6 0 0 1-.6.6h-.8v4.9H11V1.2h-.8a.6.6 0 0 1-.6-.6.6.6 0 0 1 .6-.6z" />
-        </svg>
+        <EditorIcon icon="Heading" />
       </Button>,
       <Button
         key="nav-item-code"
@@ -695,9 +688,7 @@ export default class CodeMirrorEditor extends AbstractEditor {
         title="Inline Code"
         onClick={this.createReplaceSelectionHandler('`', '`')}
       >
-        <svg xmlns="http://www.w3.org/2000/svg" height="13" viewBox="0 0 18.1 14">
-          <path d="M17.8 7.9l-4 3.8a.5.5 0 0 1-.8 0 .5.5 0 0 1 0-.8L16.8 7 13 3.2a.6.6 0 0 1 0-.9.5.5 0 0 1 .8 0l4 3.8a1.3 1.3 0 0 1 0 1.8zM5.2 2.3a.7.7 0 0 1 0 .9L1.3 7l3.9 3.9a.6.6 0 0 1 0 .8.6.6 0 0 1-.9 0L.4 7.9a1.3 1.3 0 0 1 0-1.8l3.9-3.8a.6.6 0 0 1 .9 0zM11.5.8L7.8 13.6a.6.6 0 0 1-.7.4.6.6 0 0 1-.5-.8L10.3.4a.7.7 0 0 1 .8-.4.6.6 0 0 1 .4.8z" />
-        </svg>
+        <EditorIcon icon="InlineCode" />
       </Button>,
       <Button
         key="nav-item-quote"
@@ -705,9 +696,7 @@ export default class CodeMirrorEditor extends AbstractEditor {
         title="Quote"
         onClick={this.createAddPrefixToEachLinesHandler('> ')}
       >
-        <svg xmlns="http://www.w3.org/2000/svg" height="13" viewBox="0 0 17 12">
-          <path d="M5 0H2a2 2 0 0 0-2 2v3a2 2 0 0 0 2 2h3a1.7 1.7 0 0 0 1-.3V10a.9.9 0 0 1-1 1H3v1h2a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2zm0 6H2a.9.9 0 0 1-1-1V2a.9.9 0 0 1 1-1h3a.9.9 0 0 1 1 1v3a.9.9 0 0 1-1 1zm10-6h-3a2 2 0 0 0-2 2v3a2 2 0 0 0 2 2h3a1.7 1.7 0 0 0 1-.3V10a.9.9 0 0 1-1 1h-2v1h2a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2zm0 6h-3a.9.9 0 0 1-1-1V2a.9.9 0 0 1 1-1h3a.9.9 0 0 1 1 1v3a.9.9 0 0 1-1 1z" />
-        </svg>
+        <EditorIcon icon="Quote" />
       </Button>,
       <Button
         key="nav-item-ul"
@@ -715,9 +704,7 @@ export default class CodeMirrorEditor extends AbstractEditor {
         title="List"
         onClick={this.createAddPrefixToEachLinesHandler('- ')}
       >
-        <svg xmlns="http://www.w3.org/2000/svg" height="13" viewBox="0 0 21.6 13.5">
-          <path d="M6.4 1.5h14.5a.7.7 0 0 0 .7-.7.7.7 0 0 0-.7-.7H6.4a.8.8 0 0 0-.8.7.8.8 0 0 0 .8.7zm0 6h14.5a.7.7 0 0 0 .7-.7.7.7 0 0 0-.7-.7H6.4a.8.8 0 0 0-.8.7.8.8 0 0 0 .8.7zm0 6h14.5a.7.7 0 0 0 .7-.7.7.7 0 0 0-.7-.7H6.4a.8.8 0 0 0-.8.7.8.8 0 0 0 .8.7zM.9 1.5h1a.8.8 0 0 0 .9-.7.8.8 0 0 0-.9-.8h-1a.8.8 0 0 0-.9.7.8.8 0 0 0 .9.8zm0 6h1a.8.8 0 0 0 .9-.7.8.8 0 0 0-.9-.8h-1a.8.8 0 0 0-.9.7.8.8 0 0 0 .9.8zm0 6h1a.8.8 0 0 0 .9-.7.8.8 0 0 0-.9-.7h-1a.8.8 0 0 0-.9.7.8.8 0 0 0 .9.7z" />
-        </svg>
+        <EditorIcon icon="List" />
       </Button>,
       <Button
         key="nav-item-ol"
@@ -725,9 +712,7 @@ export default class CodeMirrorEditor extends AbstractEditor {
         title="Numbered List"
         onClick={this.createAddPrefixToEachLinesHandler('1. ')}
       >
-        <svg xmlns="http://www.w3.org/2000/svg" height="13" viewBox="0 0 23.7 16">
-          <path d="M23.7 2a.8.8 0 0 1-.8.8H6.6a.8.8 0 0 1-.7-.8.7.7 0 0 1 .7-.7h16.3a.7.7 0 0 1 .8.7zM6.6 8.7h16.3a.7.7 0 0 0 .8-.7.8.8 0 0 0-.8-.8H6.6a.8.8 0 0 0-.7.8.7.7 0 0 0 .7.7zm0 5.9h16.3a.7.7 0 0 0 .8-.7.7.7 0 0 0-.8-.7H6.6a.7.7 0 0 0-.7.7.7.7 0 0 0 .7.7zM1.5.5V4h.6V0h-.5L.7.5v.4l.8-.4zM.9 9.6l.3-.3c.9-.9 1.4-1.5 1.4-2.2a1.2 1.2 0 0 0-1.3-1.2h-.1a1.4 1.4 0 0 0-1.2.6l.3.4a1.2 1.2 0 0 1 .9-.5.6.6 0 0 1 .8.6v.2c0 .6-.4 1.1-1.5 2.1l-.4.4v.3h2.6v-.4zm.9 4.1a1 1 0 0 0 .7-.9 1 1 0 0 0-1.1-1 2 2 0 0 0-1.1.3v.4l.8-.2c.5 0 .8.2.8.6s-.5.7-.9.7H.7v.4H1c.6 0 1.1.2 1.1.8a.8.8 0 0 1-.9.8l-.9-.3-.2.4a2 2 0 0 0 1.1.3c1 0 1.5-.6 1.5-1.2a1.2 1.2 0 0 0-.9-1.1z" />
-        </svg>
+        <EditorIcon icon="NumberedList" />
       </Button>,
       <Button
         key="nav-item-checkbox"
@@ -735,9 +720,7 @@ export default class CodeMirrorEditor extends AbstractEditor {
         title="Check List"
         onClick={this.createAddPrefixToEachLinesHandler('- [ ] ')}
       >
-        <svg xmlns="http://www.w3.org/2000/svg" height="13" viewBox="0 0 14.4 16">
-          <path d="M13.9 5.5a.5.5 0 0 1 .5.5v9a1.1 1.1 0 0 1-1.1 1H1a1.1 1.1 0 0 1-1-1V2.6a1.1 1.1 0 0 1 1-1h7.1a.5.5 0 0 1 .5.5.5.5 0 0 1-.5.5H1V15h12.3V6a.6.6 0 0 1 .6-.5zM3.6 8.3a.5.5 0 0 0 0 .7l2.5 2.5a.8.8 0 0 0 1.1 0h.1l7-10.7c.1-.2.1-.6-.2-.7a.5.5 0 0 0-.7.1L6.6 10.6 4.3 8.3a.5.5 0 0 0-.7 0z" />
-        </svg>
+        <EditorIcon icon="CheckList" />
       </Button>,
       <Button
         key="nav-item-link"
@@ -745,9 +728,7 @@ export default class CodeMirrorEditor extends AbstractEditor {
         title="Link"
         onClick={this.createReplaceSelectionHandler('[', ']()')}
       >
-        <svg xmlns="http://www.w3.org/2000/svg" height="13" viewBox="0 0 16 16">
-          <path d="M4.6 11.4l.4.2h.2v-.2l6.1-6.1a.4.4 0 0 0 .1-.3.5.5 0 0 0-.5-.5h-.3l-6 6.2a.6.6 0 0 0-.1.4c0 .1 0 .3.1.3zm2.8-1a2 2 0 0 1 0 1.1 4.1 4.1 0 0 1-.5.9l-2.1 1.9a1.9 1.9 0 0 1-1.5.7 2 2 0 0 1-1.6-.7 1.9 1.9 0 0 1-.7-1.5 2 2 0 0 1 .7-1.6l1.9-2.1a2 2 0 0 1 2.2-.5l.8-.8a3.2 3.2 0 0 0-1.4-.3 3.3 3.3 0 0 0-2.3.9L1 10.5A3.2 3.2 0 0 0 .9 15H1a2.9 2.9 0 0 0 2.3 1 3.2 3.2 0 0 0 2.3-1l2-1.9a4.6 4.6 0 0 0 .9-1.7 2.9 2.9 0 0 0-.3-1.8zM15 1a2.5 2.5 0 0 0-1-.8 3.1 3.1 0 0 0-3.5.8L8.4 2.9a3.1 3.1 0 0 0-.9 1.8 3.2 3.2 0 0 0 .3 1.9l.8-.8a2 2 0 0 1 0-1.1 2.2 2.2 0 0 1 .5-1.1l2.1-1.9.3-.3.4-.2.4-.2h.5a1.9 1.9 0 0 1 1.5.7 2 2 0 0 1 .7 1.6 1.9 1.9 0 0 1-.7 1.5l-2 2.1-.7.4a1.5 1.5 0 0 1-.9.2h-.4l-.8.8H12l1-.7 2-2.1A3 3 0 0 0 15 1z" />
-        </svg>
+        <EditorIcon icon="Link" />
       </Button>,
       <Button
         key="nav-item-image"
@@ -755,9 +736,7 @@ export default class CodeMirrorEditor extends AbstractEditor {
         title="Image"
         onClick={this.createReplaceSelectionHandler('![', ']()')}
       >
-        <svg xmlns="http://www.w3.org/2000/svg" height="13" viewBox="0 0 19 16">
-          <path d="M17.8 0H1.2A1.2 1.2 0 0 0 0 1.2v13.6A1.2 1.2 0 0 0 1.2 16h16.6a1.2 1.2 0 0 0 1.2-1.2V1.2a1.4 1.4 0 0 0-.2-.6.8.8 0 0 0-.4-.4zm0 14.8H1.2v-3.5l4.7-4.6 5 4.9.3.2.5-.2 2.1-1.9 3.9 4h.1v1.1zm0-2.8l-3.5-3.5-.4-.2h-.4l-2.2 2-4.9-4.8-.4-.2c-.2 0-.4 0-.5.2L1.2 9.7V1.2h16.6V12zm-4.2-6.1h.6a1.1 1.1 0 0 0 .6-1.1 1.2 1.2 0 0 0-1.2-1.1 1.3 1.3 0 0 0-1.2 1.2 1.2 1.2 0 0 0 .4.8 1.1 1.1 0 0 0 .8.3z" />
-        </svg>
+        <EditorIcon icon="Image" />
       </Button>,
       <Button
         key="nav-item-table"
@@ -765,9 +744,7 @@ export default class CodeMirrorEditor extends AbstractEditor {
         title="Table"
         onClick={this.showHandsonTableHandler}
       >
-        <svg xmlns="http://www.w3.org/2000/svg" height="13" viewBox="0 0 20.3 16">
-          <path d="M19.1 16H1.2A1.2 1.2 0 0 1 0 14.8V1.2A1.2 1.2 0 0 1 1.2 0h17.9a1.2 1.2 0 0 1 1.2 1.2v13.6a1.2 1.2 0 0 1-1.2 1.2zm-5.2-4.3v3.2h5.3v-3.2zm-6.4 0v3.2h5.3v-3.2zm-6.4 0v3.2h5.3v-3.2zm12.8-4.2v3.2h5.3V7.5zm-6.4 0v3.2h5.3V7.5zm-6.4 0v3.2h5.3V7.5zm12.8-4.3v3.2h5.3V3.2zm-6.4 0v3.2h5.3V3.2zm-6.4 0v3.2h5.3V3.2z" />
-        </svg>
+        <EditorIcon icon="Table" />
       </Button>,
       /* eslint-able max-len */
     ];

+ 89 - 0
src/client/js/components/PageEditor/EditorIcon.jsx

@@ -0,0 +1,89 @@
+/* eslint-disable max-len */
+import React from 'react';
+import PropTypes from 'prop-types';
+
+const EditorIcon = (props) => {
+
+  switch (props.icon) {
+    case 'Bold':
+      return (
+        <svg xmlns="http://www.w3.org/2000/svg" height="13" viewBox="0 0 10.9 14">
+          <path d="M0 0h5.6c3 0 4.7 1.1 4.7 3.4a3.1 3.1 0 0 1-2.5 3.1 3.7 3.7 0 0 1 3.1 3.5c0 2.9-1.4 4-4.2 4H0zm5.2 6.5c2.7 0 2.6-1.4 2.6-3.1S7.9.7 5.6.7H2.3v5.8zm-2.9 6.6h3.4c2.1 0 2.7-1.1 2.7-3.1s0-2.8-3.2-2.8H2.3z" />
+        </svg>
+      );
+    case 'Italic':
+      return (
+        <svg xmlns="http://www.w3.org/2000/svg" height="13" viewBox="0 0 8.6 13.9">
+          <path d="M8.1 0a.6.6 0 0 1 .5.6c0 .3-.2.6-.7.6H6.2L3.8 12.8h1.8c.2 0 .4.3.4.5a.7.7 0 0 1-.7.6H.5c-.3 0-.5-.4-.5-.6s.4-.6.7-.6h1.7L4.9 1.2H3.1a.5.5 0 0 1-.5-.5c0-.3.1-.7.8-.7z" />
+        </svg>
+      );
+    case 'Strikethrough':
+      return (
+        <svg xmlns="http://www.w3.org/2000/svg" height="13" viewBox="0 0 19.5 14">
+          <path d="M5.8 6.2H9C7.2 5.7 6.3 5 6.3 3.8a2.2 2.2 0 0 1 .9-1.9 4.3 4.3 0 0 1 2.5-.7 4.3 4.3 0 0 1 2.5.7 3.1 3.1 0 0 1 1.1 1.6.7.7 0 0 0 .6.4h.3a.7.7 0 0 0 .4-.8A3.6 3.6 0 0 0 13.1 1a6.7 6.7 0 0 0-6-.5 3.1 3.1 0 0 0-1.7 1.3 3.6 3.6 0 0 0-.6 2 2.9 2.9 0 0 0 1 2.3zm7 2.5a2 2 0 0 1 .6 1.4 2.4 2.4 0 0 1-1 1.9 3.7 3.7 0 0 1-2.5.7 4.6 4.6 0 0 1-3-.8 3.7 3.7 0 0 1-1.2-2 .6.6 0 0 0-.6-.5h-.2a.7.7 0 0 0-.5.8 4.1 4.1 0 0 0 1.5 2.5A6 6 0 0 0 9.8 14a7.5 7.5 0 0 0 2.6-.5 4.9 4.9 0 0 0 1.8-1.4 4.3 4.3 0 0 0 .6-2.2 5 5 0 0 0-.2-1.2zM.4 7.9a.7.7 0 0 1-.4-.5.4.4 0 0 1 .4-.4h18.8a.4.4 0 0 1 .3.6c0 .1-.1.2-.2.3z" />
+        </svg>
+      );
+    case 'Heading':
+      return (
+        <svg xmlns="http://www.w3.org/2000/svg" height="13" viewBox="0 0 13.7 14">
+          <path d="M10.2 0h2.9a.6.6 0 0 1 .6.6.6.6 0 0 1-.6.6h-.8v11.6h.8a.6.6 0 0 1 .6.6.6.6 0 0 1-.6.6h-2.9a.6.6 0 0 1-.6-.6.6.6 0 0 1 .6-.6h.8V7.2H2.7v5.6h.8a.6.6 0 0 1 .6.6.6.6 0 0 1-.6.6H.6a.6.6 0 0 1-.6-.6.6.6 0 0 1 .6-.6h.7V1.2H.6A.6.6 0 0 1 0 .6.6.6 0 0 1 .6 0h2.9a.6.6 0 0 1 .6.6.6.6 0 0 1-.6.6h-.8v4.9H11V1.2h-.8a.6.6 0 0 1-.6-.6.6.6 0 0 1 .6-.6z" />
+        </svg>
+      );
+    case 'InlineCode':
+      return (
+        <svg xmlns="http://www.w3.org/2000/svg" height="13" viewBox="0 0 18.1 14">
+          <path d="M17.8 7.9l-4 3.8a.5.5 0 0 1-.8 0 .5.5 0 0 1 0-.8L16.8 7 13 3.2a.6.6 0 0 1 0-.9.5.5 0 0 1 .8 0l4 3.8a1.3 1.3 0 0 1 0 1.8zM5.2 2.3a.7.7 0 0 1 0 .9L1.3 7l3.9 3.9a.6.6 0 0 1 0 .8.6.6 0 0 1-.9 0L.4 7.9a1.3 1.3 0 0 1 0-1.8l3.9-3.8a.6.6 0 0 1 .9 0zM11.5.8L7.8 13.6a.6.6 0 0 1-.7.4.6.6 0 0 1-.5-.8L10.3.4a.7.7 0 0 1 .8-.4.6.6 0 0 1 .4.8z" />
+        </svg>
+      );
+    case 'Quote':
+      return (
+        <svg xmlns="http://www.w3.org/2000/svg" height="13" viewBox="0 0 17 12">
+          <path d="M5 0H2a2 2 0 0 0-2 2v3a2 2 0 0 0 2 2h3a1.7 1.7 0 0 0 1-.3V10a.9.9 0 0 1-1 1H3v1h2a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2zm0 6H2a.9.9 0 0 1-1-1V2a.9.9 0 0 1 1-1h3a.9.9 0 0 1 1 1v3a.9.9 0 0 1-1 1zm10-6h-3a2 2 0 0 0-2 2v3a2 2 0 0 0 2 2h3a1.7 1.7 0 0 0 1-.3V10a.9.9 0 0 1-1 1h-2v1h2a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2zm0 6h-3a.9.9 0 0 1-1-1V2a.9.9 0 0 1 1-1h3a.9.9 0 0 1 1 1v3a.9.9 0 0 1-1 1z" />
+        </svg>
+      );
+    case 'List':
+      return (
+        <svg xmlns="http://www.w3.org/2000/svg" height="13" viewBox="0 0 21.6 13.5">
+          <path d="M6.4 1.5h14.5a.7.7 0 0 0 .7-.7.7.7 0 0 0-.7-.7H6.4a.8.8 0 0 0-.8.7.8.8 0 0 0 .8.7zm0 6h14.5a.7.7 0 0 0 .7-.7.7.7 0 0 0-.7-.7H6.4a.8.8 0 0 0-.8.7.8.8 0 0 0 .8.7zm0 6h14.5a.7.7 0 0 0 .7-.7.7.7 0 0 0-.7-.7H6.4a.8.8 0 0 0-.8.7.8.8 0 0 0 .8.7zM.9 1.5h1a.8.8 0 0 0 .9-.7.8.8 0 0 0-.9-.8h-1a.8.8 0 0 0-.9.7.8.8 0 0 0 .9.8zm0 6h1a.8.8 0 0 0 .9-.7.8.8 0 0 0-.9-.8h-1a.8.8 0 0 0-.9.7.8.8 0 0 0 .9.8zm0 6h1a.8.8 0 0 0 .9-.7.8.8 0 0 0-.9-.7h-1a.8.8 0 0 0-.9.7.8.8 0 0 0 .9.7z" />
+        </svg>
+      );
+    case 'NumberedList':
+      return (
+        <svg xmlns="http://www.w3.org/2000/svg" height="13" viewBox="0 0 23.7 16">
+          <path d="M23.7 2a.8.8 0 0 1-.8.8H6.6a.8.8 0 0 1-.7-.8.7.7 0 0 1 .7-.7h16.3a.7.7 0 0 1 .8.7zM6.6 8.7h16.3a.7.7 0 0 0 .8-.7.8.8 0 0 0-.8-.8H6.6a.8.8 0 0 0-.7.8.7.7 0 0 0 .7.7zm0 5.9h16.3a.7.7 0 0 0 .8-.7.7.7 0 0 0-.8-.7H6.6a.7.7 0 0 0-.7.7.7.7 0 0 0 .7.7zM1.5.5V4h.6V0h-.5L.7.5v.4l.8-.4zM.9 9.6l.3-.3c.9-.9 1.4-1.5 1.4-2.2a1.2 1.2 0 0 0-1.3-1.2h-.1a1.4 1.4 0 0 0-1.2.6l.3.4a1.2 1.2 0 0 1 .9-.5.6.6 0 0 1 .8.6v.2c0 .6-.4 1.1-1.5 2.1l-.4.4v.3h2.6v-.4zm.9 4.1a1 1 0 0 0 .7-.9 1 1 0 0 0-1.1-1 2 2 0 0 0-1.1.3v.4l.8-.2c.5 0 .8.2.8.6s-.5.7-.9.7H.7v.4H1c.6 0 1.1.2 1.1.8a.8.8 0 0 1-.9.8l-.9-.3-.2.4a2 2 0 0 0 1.1.3c1 0 1.5-.6 1.5-1.2a1.2 1.2 0 0 0-.9-1.1z" />
+        </svg>
+      );
+    case 'CheckList':
+      return (
+        <svg xmlns="http://www.w3.org/2000/svg" height="13" viewBox="0 0 14.4 16">
+          <path d="M13.9 5.5a.5.5 0 0 1 .5.5v9a1.1 1.1 0 0 1-1.1 1H1a1.1 1.1 0 0 1-1-1V2.6a1.1 1.1 0 0 1 1-1h7.1a.5.5 0 0 1 .5.5.5.5 0 0 1-.5.5H1V15h12.3V6a.6.6 0 0 1 .6-.5zM3.6 8.3a.5.5 0 0 0 0 .7l2.5 2.5a.8.8 0 0 0 1.1 0h.1l7-10.7c.1-.2.1-.6-.2-.7a.5.5 0 0 0-.7.1L6.6 10.6 4.3 8.3a.5.5 0 0 0-.7 0z" />
+        </svg>
+      );
+    case 'Link':
+      return (
+        <svg xmlns="http://www.w3.org/2000/svg" height="13" viewBox="0 0 16 16">
+          <path d="M4.6 11.4l.4.2h.2v-.2l6.1-6.1a.4.4 0 0 0 .1-.3.5.5 0 0 0-.5-.5h-.3l-6 6.2a.6.6 0 0 0-.1.4c0 .1 0 .3.1.3zm2.8-1a2 2 0 0 1 0 1.1 4.1 4.1 0 0 1-.5.9l-2.1 1.9a1.9 1.9 0 0 1-1.5.7 2 2 0 0 1-1.6-.7 1.9 1.9 0 0 1-.7-1.5 2 2 0 0 1 .7-1.6l1.9-2.1a2 2 0 0 1 2.2-.5l.8-.8a3.2 3.2 0 0 0-1.4-.3 3.3 3.3 0 0 0-2.3.9L1 10.5A3.2 3.2 0 0 0 .9 15H1a2.9 2.9 0 0 0 2.3 1 3.2 3.2 0 0 0 2.3-1l2-1.9a4.6 4.6 0 0 0 .9-1.7 2.9 2.9 0 0 0-.3-1.8zM15 1a2.5 2.5 0 0 0-1-.8 3.1 3.1 0 0 0-3.5.8L8.4 2.9a3.1 3.1 0 0 0-.9 1.8 3.2 3.2 0 0 0 .3 1.9l.8-.8a2 2 0 0 1 0-1.1 2.2 2.2 0 0 1 .5-1.1l2.1-1.9.3-.3.4-.2.4-.2h.5a1.9 1.9 0 0 1 1.5.7 2 2 0 0 1 .7 1.6 1.9 1.9 0 0 1-.7 1.5l-2 2.1-.7.4a1.5 1.5 0 0 1-.9.2h-.4l-.8.8H12l1-.7 2-2.1A3 3 0 0 0 15 1z" />
+        </svg>
+      );
+    case 'Image':
+      return (
+        <svg xmlns="http://www.w3.org/2000/svg" height="13" viewBox="0 0 19 16">
+          <path d="M17.8 0H1.2A1.2 1.2 0 0 0 0 1.2v13.6A1.2 1.2 0 0 0 1.2 16h16.6a1.2 1.2 0 0 0 1.2-1.2V1.2a1.4 1.4 0 0 0-.2-.6.8.8 0 0 0-.4-.4zm0 14.8H1.2v-3.5l4.7-4.6 5 4.9.3.2.5-.2 2.1-1.9 3.9 4h.1v1.1zm0-2.8l-3.5-3.5-.4-.2h-.4l-2.2 2-4.9-4.8-.4-.2c-.2 0-.4 0-.5.2L1.2 9.7V1.2h16.6V12zm-4.2-6.1h.6a1.1 1.1 0 0 0 .6-1.1 1.2 1.2 0 0 0-1.2-1.1 1.3 1.3 0 0 0-1.2 1.2 1.2 1.2 0 0 0 .4.8 1.1 1.1 0 0 0 .8.3z" />
+        </svg>
+      );
+    case 'Table':
+      return (
+        <svg xmlns="http://www.w3.org/2000/svg" height="13" viewBox="0 0 20.3 16">
+          <path d="M19.1 16H1.2A1.2 1.2 0 0 1 0 14.8V1.2A1.2 1.2 0 0 1 1.2 0h17.9a1.2 1.2 0 0 1 1.2 1.2v13.6a1.2 1.2 0 0 1-1.2 1.2zm-5.2-4.3v3.2h5.3v-3.2zm-6.4 0v3.2h5.3v-3.2zm-6.4 0v3.2h5.3v-3.2zm12.8-4.2v3.2h5.3V7.5zm-6.4 0v3.2h5.3V7.5zm-6.4 0v3.2h5.3V7.5zm12.8-4.3v3.2h5.3V3.2zm-6.4 0v3.2h5.3V3.2zm-6.4 0v3.2h5.3V3.2z" />
+        </svg>
+      );
+  }
+
+
+};
+
+EditorIcon.propTypes = {
+  icon: PropTypes.string.isRequired,
+};
+
+export default EditorIcon;

+ 1 - 0
src/client/js/components/PageEditor/HandsontableModal.jsx

@@ -423,6 +423,7 @@ export default class HandsontableModal extends React.PureComponent {
 
     const dialogClassName = dialogClassNames.join(' ');
 
+    // eslint-disable-next-line no-unused-vars
     const buttons = (
       <span>
         {/* change order because of `float: right` by '.close' class */}

+ 61 - 0
src/client/js/services/AdminHomeContainer.js

@@ -0,0 +1,61 @@
+import { Container } from 'unstated';
+
+import loggerFactory from '@alias/logger';
+
+import { toastError } from '../util/apiNotification';
+
+// eslint-disable-next-line no-unused-vars
+const logger = loggerFactory('growi:services:AdminHomeContainer');
+
+/**
+ * Service container for admin home page (AdminHome.jsx)
+ * @extends {Container} unstated Container
+ */
+export default class AdminHomeContainer extends Container {
+
+  constructor(appContainer) {
+    super();
+
+    this.appContainer = appContainer;
+
+    this.state = {
+      retrieveError: null,
+      growiVersion: '',
+      nodeVersion: '',
+      npmVersion: '',
+      yarnVersion: '',
+      installedPlugins: [],
+    };
+
+  }
+
+  /**
+   * Workaround for the mangling in production build to break constructor.name
+   */
+  static getClassName() {
+    return 'AdminHomeContainer';
+  }
+
+  /**
+   * retrieve admin home data
+   */
+  async retrieveAdminHomeData() {
+    try {
+      const response = await this.appContainer.apiv3.get('/admin-home/');
+      const { adminHomeParams } = response.data;
+
+      this.setState({
+        growiVersion: adminHomeParams.growiVersion,
+        nodeVersion: adminHomeParams.nodeVersion,
+        npmVersion: adminHomeParams.npmVersion,
+        yarnVersion: adminHomeParams.yarnVersion,
+        installedPlugins: adminHomeParams.installedPlugins,
+      });
+    }
+    catch (err) {
+      logger.error(err);
+      toastError(new Error('Failed to fetch data'));
+    }
+  }
+
+}

+ 176 - 0
src/client/styles/agile-admin/inverse/colors/_apply-colors-dark.scss

@@ -0,0 +1,176 @@
+.top-left-part {
+  .logo-mark, .logo-text {
+    fill: white;
+  }
+}
+
+
+/*
+ * Button
+ */
+.btn-default {
+  &:hover, &:focus,
+  &.active, &.active:hover, &.active:focus {
+    color: white;
+    background-color: lighten($bodycolor, 5%);
+  }
+}
+
+/*
+  * Form
+  */
+input.form-control, textarea.form-control {
+  color: lighten($bodytext, 30%);
+  background-color: darken($bodycolor, 5%);
+  border: 1px solid darken($border, 30%);
+}
+.form-control[disabled], .form-control[readonly] {
+  color: lighten($bodytext, 10%);
+  background-color: lighten($bodycolor, 5%);
+}
+.input-group .input-group-addon {
+  color: $dark;
+  background-color: rgba($topbar, 0.4);
+  border: 1px solid darken($border, 30%);
+  border-right: none;
+}
+
+/*
+ * Dropdown
+ */
+.dropdown-menu {
+  background-color: $bodycolor;
+  > li > a {
+    color: $bodytext;
+  }
+
+  .divider {
+    background-color: $border;
+  }
+}
+
+.modal {
+  .modal-header {
+    .close {
+      color: white;
+    }
+  }
+}
+
+/*
+ * Panel
+ */
+.panel {
+  &, &.panel-white, &.panel-default {
+    .panel-heading, .panel-body {
+      color: $light;
+    }
+  }
+}
+
+/*
+ * Table
+ */
+ .table > thead > tr > th, .table > tbody > tr > th, .table > tfoot > tr > th,
+ .table > thead > tr > td, .table > tbody > tr > td, .table > tfoot > tr > td ,
+ .table > thead > tr > th, .table-bordered{
+     border-top: 1px solid $border;
+ }
+
+ .table-bordered > thead > tr > th,
+ .table-bordered > tbody > tr > th,
+ .table-bordered > tfoot > tr > th,
+ .table-bordered > thead > tr > td,
+ .table-bordered > tbody > tr > td,
+ .table-bordered > tfoot > tr > td {
+   border: 1px solid $border;
+ }
+ .table > thead > tr > th {
+     border-bottom: 1px solid $border;
+ }
+
+ .table-bordered {
+     border: 1px solid $border;
+ }
+
+
+/*
+ * GROWI header
+ */
+.main-container header.affix {
+  .logo-mark {
+    fill: white;
+  }
+}
+
+/*
+ * GROWI page list
+ */
+.page-list {
+  .page-list-ul {
+    > li {
+      > a strong {
+        color: lighten($bodytext, 25%);
+      }
+      > span.page-list-meta {
+        color: $bodytext;
+      }
+    }
+  }
+}
+
+/*
+ * GROWI search page
+ */
+.search-page {
+  .input-group-btn {
+    .btn-default {
+      border: 1px solid darken($border, 30%); // fit to input.form-control
+    }
+  }
+}
+
+/*
+ * GROWI on-edit
+ */
+.page-editor-footer {
+  #slack-mark-black {
+    display: none;
+  }
+}
+
+legend {
+  border-color: lighten($border, 10%);
+}
+.wiki {
+  h1 {
+    border-color: lighten($border, 10%);
+  }
+  h2 {
+    border-color: $border;
+  }
+}
+.editor-container .navbar-editor svg {
+  fill: $bodytext;
+}
+
+/*
+ * GROWI admin page #themeOptions
+ */
+.admin-page {
+  #themeOptions {
+    .theme-option-container.active a {
+      background-color: darken($themecolor,15%);
+      border-color: darken($themecolor,15%);
+    }
+  }
+}
+
+/*
+ * GROWI comment form
+ */
+.comment-form {
+  #slack-mark-black {
+    display: none;
+  }
+}

+ 0 - 4
src/client/styles/agile-admin/inverse/colors/blue-night.scss

@@ -49,7 +49,3 @@ $bgcolor-inline-code: #0a121b;
     }
   }
 }
-
-.editor-container .navbar-editor svg {
-  fill: $bodytext;
-}

+ 0 - 4
src/client/styles/agile-admin/inverse/colors/default-dark.scss

@@ -25,9 +25,5 @@ $active-navbar-border: darken($border, 3%);
 $btn-default-bgcolor: darken($basecolor, 10%);
 $bgcolor-inline-code: darken($bgcolor-global, 5%);
 
-.editor-container .navbar-editor svg {
-  fill: $bodytext;
-}
-
 @import 'apply-colors';
 @import 'apply-colors-dark';

+ 0 - 4
src/client/styles/agile-admin/inverse/colors/future.scss

@@ -35,7 +35,3 @@ body:not(.on-edit) {
     border-bottom: 1px solid rgb(131, 228, 215);
   }
 }
-
-.editor-container .navbar-editor svg {
-  fill: $bodytext;
-}

+ 0 - 4
src/client/styles/agile-admin/inverse/colors/halloween.scss

@@ -73,10 +73,6 @@ $bgcolor-inline-code: #0a121b;
   background-color: rgba(0, 0, 0, 0.3);
 }
 
-.editor-container .navbar-editor svg {
-  fill: $bodytext;
-}
-
 /*
  * Tabs
  */

+ 2 - 1
src/client/styles/scss/_handsontable.scss

@@ -42,7 +42,8 @@
   }
 
   .data-import-form {
-    background-color: #f8f8f8;
+    color: $headingtext;
+    background-color: $navbar-default-bg;
 
     .btn + .btn {
       margin-left: 5px;

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

@@ -84,8 +84,14 @@ module.exports = function(crowi) {
       'mail:smtpUser'     : undefined,
       'mail:smtpPassword' : undefined,
 
-      'google:clientId'     : undefined,
-      'google:clientSecret' : undefined,
+      'security:passport-google:clientId'     : undefined,
+      'security:passport-google:clientSecret' : undefined,
+
+      'security:passport-github:clientId': undefined,
+      'security:passport-github:clientSecret': undefined,
+
+      'security:passport-twitter:clientId': undefined,
+      'security:passport-twitter:clientSecret': undefined,
 
       'plugin:isEnabledPlugins' : true,
 

+ 13 - 9
src/server/plugins/plugin-utils.js

@@ -1,6 +1,7 @@
 const path = require('path');
 const fs = require('graceful-fs');
 const logger = require('@alias/logger')('growi:plugins:plugin-utils');
+const packageInstalledVersionSync = require('package-installed-version-sync');
 
 const PluginUtilsV2 = require('./plugin-utils-v2');
 
@@ -48,8 +49,8 @@ class PluginUtils {
    *
    * @returns array of objects
    *   [
-   *     { name: 'growi-plugin-...', version: '1.0.0' },
-   *     { name: 'growi-plugin-...', version: '1.0.0' },
+   *     { name: 'growi-plugin-...', requiredVersion: '^1.0.0', installedVersion: '1.0.0' },
+   *     { name: 'growi-plugin-...', requiredVersion: '^1.0.0', installedVersion: '1.0.0' },
    *     ...
    *   ]
    *
@@ -68,14 +69,17 @@ class PluginUtils {
     const json = JSON.parse(content);
     const deps = json.dependencies || {};
 
-    const objs = {};
-    Object.keys(deps).forEach((name) => {
-      if (/^(crowi|growi)-plugin-/.test(name)) {
-        objs[name] = deps[name];
-      }
+    const pluginNames = Object.keys(deps).filter((name) => {
+      return /^(crowi|growi)-plugin-/.test(name);
     });
 
-    return objs;
+    return pluginNames.map((name) => {
+      return {
+        name,
+        requiredVersion: deps[name],
+        installedVersion: packageInstalledVersionSync(name),
+      };
+    });
   }
 
   /**
@@ -87,7 +91,7 @@ class PluginUtils {
    */
   listPluginNames(rootDir) {
     const plugins = this.listPlugins(rootDir);
-    return Object.keys(plugins);
+    return plugins.map((plugin) => { return plugin.name });
   }
 
 }

+ 1 - 5
src/server/routes/admin.js

@@ -20,12 +20,10 @@ module.exports = function(crowi, app) {
   } = crowi;
 
   const recommendedWhitelist = require('@commons/service/xss/recommended-whitelist');
-  const PluginUtils = require('../plugins/plugin-utils');
   const ApiResponse = require('../util/apiResponse');
   const importer = require('../util/importer')(crowi);
 
   const searchEvent = crowi.event('search');
-  const pluginUtils = new PluginUtils();
 
   const MAX_PAGE_LIST = 50;
   const actions = {};
@@ -99,9 +97,7 @@ module.exports = function(crowi, app) {
   });
 
   actions.index = function(req, res) {
-    return res.render('admin/index', {
-      plugins: pluginUtils.listPlugins(crowi.rootDir),
-    });
+    return res.render('admin/index');
   };
 
   // app.get('/admin/app'                  , admin.app.index);

+ 77 - 0
src/server/routes/apiv3/admin-home.js

@@ -0,0 +1,77 @@
+const express = require('express');
+const PluginUtils = require('../../plugins/plugin-utils');
+
+const pluginUtils = new PluginUtils();
+
+const router = express.Router();
+
+/**
+ * @swagger
+ *  tags:
+ *    name: adminHome
+ */
+
+/**
+ * @swagger
+ *
+ *  components:
+ *    schemas:
+ *      SystemInformationParams:
+ *        type: object
+ *        properties:
+ *          growiVersion:
+ *            type: string
+ *            description: version of growi
+ *          nodeVersion:
+ *            type: string
+ *            description: version of node
+ *          npmVersion:
+ *            type: string
+ *            description: version of npm
+ *          yarnVersion:
+ *            type: string
+ *            description: version of yarn
+ *      InstalledPluginsParams:
+ *        type: object
+ *        properties:
+ *          installedPlugins:
+ *            type: object
+ *            description: installed plugins
+ */
+
+module.exports = (crowi) => {
+  const loginRequiredStrictly = require('../../middleware/login-required')(crowi);
+  const adminRequired = require('../../middleware/admin-required')(crowi);
+
+  /**
+   * @swagger
+   *
+   *    /admin-home/:
+   *      get:
+   *        tags: [adminHome]
+   *        description: Get adminHome parameters
+   *        responses:
+   *          200:
+   *            description: params of adminHome
+   *            content:
+   *              application/json:
+   *                schema:
+   *                  properties:
+   *                    adminHomeParams:
+   *                      type: object
+   *                      description: adminHome params
+   */
+  router.get('/', loginRequiredStrictly, adminRequired, async(req, res) => {
+    const adminHomeParams = {
+      growiVersion: crowi.version,
+      nodeVersion: crowi.runtimeVersions.versions.node ? crowi.runtimeVersions.versions.node.version.version : '-',
+      npmVersion: crowi.runtimeVersions.versions.npm ? crowi.runtimeVersions.versions.npm.version.version : '-',
+      yarnVersion: crowi.runtimeVersions.versions.yarn ? crowi.runtimeVersions.versions.yarn.version.version : '-',
+      installedPlugins: pluginUtils.listPlugins(crowi.rootDir),
+    };
+
+    return res.apiv3({ adminHomeParams });
+  });
+
+  return router;
+};

+ 2 - 2
src/server/routes/apiv3/customize-setting.js

@@ -116,7 +116,7 @@ module.exports = (crowi) => {
     ],
     highlight: [
       body('highlightJsStyle').isString().isIn([
-        'github', 'github-gist', 'atom-one-light', 'xcode', 'vs', 'atom-one-dark', 'hybrid', 'monokai', 'tororrow-night', 'vs2015',
+        'github', 'github-gist', 'atom-one-light', 'xcode', 'vs', 'atom-one-dark', 'hybrid', 'monokai', 'tomorrow-night', 'vs2015',
       ]),
       body('highlightJsStyleBorder').isBoolean(),
     ],
@@ -136,7 +136,7 @@ module.exports = (crowi) => {
    *        tags: [CustomizeSetting, apiv3]
    *        operationId: getCustomizeSetting
    *        summary: /_api/v3/customize-setting
-   *        description: Get customize paramators
+   *        description: Get customize parameters
    *        responses:
    *          200:
    *            description: params of customize

+ 2 - 0
src/server/routes/apiv3/index.js

@@ -13,6 +13,8 @@ module.exports = (crowi) => {
 
   router.use('/healthcheck', require('./healthcheck')(crowi));
 
+  router.use('/admin-home', require('./admin-home')(crowi));
+
   router.use('/markdown-setting', require('./markdown-setting')(crowi));
 
   router.use('/app-settings', require('./app-settings')(crowi));

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

@@ -97,7 +97,7 @@ module.exports = (crowi) => {
    *        tags: [MarkDownSetting, apiv3]
    *        operationId: getMarkdownSetting
    *        summary: /_api/v3/markdown-setting
-   *        description: Get markdown paramators
+   *        description: Get markdown parameters
    *        responses:
    *          200:
    *            description: params of markdown

+ 10 - 0
src/server/routes/login-passport.js

@@ -258,6 +258,16 @@ module.exports = function(crowi, app) {
       username: response.displayName,
       name: `${response.name.givenName} ${response.name.familyName}`,
     };
+
+    // Emails are not empty if it exists
+    // See https://github.com/passport/express-4.x-facebook-example/blob/dfce5495d0313174a1b5039bab2c2dcda7e0eb61/views/profile.ejs
+    // Both Facebook and Google use OAuth 2.0, the code is similar
+    // See https://github.com/jaredhanson/passport-google-oauth2/blob/723e8f3e8e711275f89e0163e2c77cfebae33f25/README.md#examples
+    if (response.emails != null) {
+      userInfo.email = response.emails[0].value;
+      userInfo.username = userInfo.email.slice(0, userInfo.email.indexOf('@'));
+    }
+
     const externalAccount = await getOrCreateUser(req, res, userInfo, providerId);
     if (!externalAccount) {
       return loginFailure(req, res, next);

+ 1 - 47
src/server/views/admin/index.html

@@ -25,53 +25,7 @@
       {% include './widget/menu.html' %}
     </div>
     <div class="col-md-9">
-      <p> {{ t("admin_top.wiki_administrator") }}<br>
-      {{ t("admin_top.assign_administrator") }}
-      </p>
-
-      <legend>
-        <h2>{{ t('admin_top.System Information') }}</h2>
-      </legend>
-      <table class="table table-bordered">
-        <tr>
-          <th class="col-sm-4">GROWI</th>
-          <td>{{ growiVersion() }}</td>
-        </tr>
-        <tr>
-          <th>node.js</th>
-          <td>{{ nodeVersion() }}</td>
-        </tr>
-        <tr>
-          <th>npm</th>
-          <td>{{ npmVersion() }}</td>
-        </tr>
-        <tr>
-          <th>yarn</th>
-          <td>{{ yarnVersion() }}</td>
-        </tr>
-      </table>
-
-      <legend>
-        <h2>{{ t('admin_top.List of installed plugins') }}</h2>
-      </legend>
-      <table class="table table-bordered">
-        <th class="text-center">
-          {{ t('admin_top.Package name') }}
-        </th>
-        <th class="text-center">
-          {{ t('admin_top.Specified version') }}
-        </th>
-        <th class="text-center">
-          {{ t('admin_top.Installed version') }}
-        </th>
-        {% for pluginName in Object.keys(plugins) %}
-        <tr>
-          <td>{{ pluginName }}</td>
-          <td class="text-center">{{ plugins[pluginName] }}</td>
-          <td class="text-center"><span class="tbd">(TBD)</span></td>
-        </tr>
-        {% endfor %}
-      </table>
+      <div id="admin-home"></div>
     </div>
   </div>
 

+ 1 - 248
src/server/views/admin/markdown.html

@@ -17,257 +17,10 @@
     <div class="col-md-3">
       {% include './widget/menu.html' with {current: 'markdown'} %}
     </div>
-    <!-- TODO reactify admin -->
-    <div class="col-md-9">
-      {% set smessage = req.flash('successMessage') %}
-      {% if smessage.length %}
-      <div class="alert alert-success">
-        {% for e in smessage %}
-          {{ e }}<br>
-        {% endfor %}
-      </div>
-      {% endif %}
-
-      {% set emessage = req.flash('errorMessage') %}
-      {% if emessage.length %}
-      <div class="alert alert-danger">
-        {% for e in emessage %}
-        {{ e }}<br>
-        {% endfor %}
-      </div>
-      {% endif %}
-
-      <form action="/admin/markdown/lineBreaksSetting" method="post" class="form-horizontal" id="markdownSettingForm" role="form">
-        <fieldset>
-          <legend>{{ t('markdown_setting.line_break_setting') }}</legend>
-          <p class="well">{{ t("markdown_setting.line_break_setting_desc") }}</p>
-
-          <div class="form-group">
-            <label for="markdownSetting[markdown:isEnabledLinebreaks]" class="col-xs-4 control-label">
-              {{ t('markdown_setting.Enable Line Break') }}
-            </label>
-            <div class="col-xs-5">
-              <div class="btn-group btn-toggle" data-toggle="buttons">
-                <label class="btn btn-default btn-rounded btn-outline {% if markdownSetting['markdown:isEnabledLinebreaks'] %}active{% endif %}" data-active-class="primary">
-                  <input name="markdownSetting[markdown:isEnabledLinebreaks]" value="true" type="radio"
-                      {% if true === markdownSetting['markdown:isEnabledLinebreaks'] %}checked{% endif %}> ON
-                </label>
-                <label class="btn btn-default btn-rounded btn-outline {% if !markdownSetting['markdown:isEnabledLinebreaks'] %}active{% endif %}" data-active-class="default">
-                  <input name="markdownSetting[markdown:isEnabledLinebreaks]" value="false" type="radio"
-                      {% if !markdownSetting['markdown:isEnabledLinebreaks'] %}checked{% endif %}> OFF
-                </label>
-              </div>
-              <p class="help-block">{{ t("markdown_setting.Enable Line Break desc") }}</p>
-            </div>
-          </div>
-
-          <div class="form-group">
-            <label for="markdownSetting[markdown:isEnabledLinebreaksInComments]" class="col-xs-4 control-label">
-              {{ t("markdown_setting.Enable Line Break for comment") }}
-            </label>
-            <div class="col-xs-5">
-              <div class="btn-group btn-toggle" data-toggle="buttons">
-                <label class="btn btn-default btn-rounded btn-outline {% if markdownSetting['markdown:isEnabledLinebreaksInComments'] %}active{% endif %}" data-active-class="primary">
-                  <input name="markdownSetting[markdown:isEnabledLinebreaksInComments]" value="true" type="radio"
-                      {% if true === markdownSetting['markdown:isEnabledLinebreaksInComments'] %}checked{% endif %}> ON
-                </label>
-                <label class="btn btn-default btn-rounded btn-outline {% if !markdownSetting['markdown:isEnabledLinebreaksInComments'] %}active{% endif %}" data-active-class="default">
-                  <input name="markdownSetting[markdown:isEnabledLinebreaksInComments]" value="false" type="radio"
-                      {% if !markdownSetting['markdown:isEnabledLinebreaksInComments'] %}checked{% endif %}> OFF
-                </label>
-              </div>
-              <p class="help-block">{{ t("markdown_setting.Enable Line Break for comment desc") }}</p>
-            </div>
-          </div>
-
-          <div class="form-group my-3">
-            <div class="col-xs-offset-4 col-xs-5">
-              <input type="hidden" name="_csrf" value="{{ csrf() }}">
-              <button type="submit" class="btn btn-primary">{{ t("Update") }}</button>
-            </div>
-          </div>
-        </fieldset>
-      </form>
-
-      <form action="/admin/markdown/presentationSetting" method="post" class="form-horizontal" id="markdownSettingForm" role="form">
-        <legend>{{ t('markdown_setting.presentation_setting') }}</legend>
-        <p class="well">{{ t("markdown_setting.presentation_setting_desc") }}</p>
-
-        <fieldset class="form-group row my-2">
-          {% set nameForPageBreakOption = "markdownSetting[markdown:presentation:pageBreakSeparator]" %}
-          {% set pageBreakSeparator = markdownSetting['markdown:presentation:pageBreakSeparator'] %}
-
-          <label class="col-xs-3 control-label">
-            {{ t('markdown_setting.Page break setting') }}
-          </label>
-
-          <div class="col-xs-3 radio radio-primary">
-            <input type="radio" id="pageBreakOption1" name="{{nameForPageBreakOption}}" value="1" {% if pageBreakSeparator === 1 %}checked{% endif %}>
-            <label for="pageBreakOption1">
-              <p class="font-weight-bold">{{ t('markdown_setting.Preset one separator') }}</p>
-              <p class="mt-3">
-                {{ t('markdown_setting.Preset one separator desc') }}
-                <pre><code>{{ t('markdown_setting.Preset one separator value') }}</code></pre>
-              </p>
-            </label>
-          </div>
-
-          <div class="col-xs-3 radio radio-primary">
-            <input type="radio" id="pageBreakOption2" name="{{nameForPageBreakOption}}" value="2" {% if pageBreakSeparator === 2 %}checked{% endif %}>
-            <label for="pageBreakOption2">
-              <p class="font-weight-bold">{{ t('markdown_setting.Preset two separator') }}</p>
-              <p class="mt-3">
-                {{ t('markdown_setting.Preset two separator desc') }}
-                <pre><code>{{ t('markdown_setting.Preset two separator value') }}</code></pre>
-              </p>
-            </label>
-          </div>
-
-          <div class="col-xs-3 radio radio-primary">
-            <input type="radio" id="pageBreakOption3" name="{{nameForPageBreakOption}}" value="3" {% if pageBreakSeparator === 3 %}checked{% endif %}>
-            <label for="pageBreakOption3">
-              <p class="font-weight-bold">{{ t('markdown_setting.Custom separator') }}</p>
-              <p class="mt-3">
-                {{ t('markdown_setting.Custom separator desc') }}
-                <div>
-                  <input class="form-control" name="markdownSetting[markdown:presentation:pageBreakCustomSeparator]" value="{{markdownSetting['markdown:presentation:pageBreakCustomSeparator']|default('') }}">
-                </div>
-              </p>
-            </label>
-          </div>
-
-        </fieldset>
-
-        <div class="form-group my-3">
-          <div class="col-xs-offset-4 col-xs-5">
-            <input type="hidden" name="_csrf" value="{{ csrf() }}">
-            <button type="submit" class="btn btn-primary">{{ t("Update") }}</button>
-          </div>
-        </div>
-      </form>
-
-      <form action="/admin/markdown/xss-setting" method="post" class="form-horizontal" id="markdownSettingForm" role="form">
-        {% set nameForIsXssEnabled = "markdownSetting[markdown:xss:isEnabledPrevention]" %}
-        {% set isXssEnabled = markdownSetting['markdown:xss:isEnabledPrevention'] %}
-
-        <legend>{{ t('markdown_setting.XSS_setting') }}</legend>
-        <p class="well">{{ t("markdown_setting.XSS_setting_desc") }}</p>
-
-        <fieldset class="row">
-          <div class="form-group">
-            <label for="markdownSetting[markdown:isEnabledLinebreaks]" class="col-xs-4 control-label">
-              {{ t('markdown_setting.Enable XSS prevention') }}
-            </label>
-            <div class="col-xs-5">
-              <div class="btn-group btn-toggle" data-toggle="buttons">
-                <label class="btn btn-default btn-rounded btn-outline {% if isXssEnabled %}active{% endif %}" data-active-class="primary">
-                  <input name="{{nameForIsXssEnabled}}" value="true" type="radio"
-                      {% if isXssEnabled %}checked{% endif %}> ON
-                </label>
-                <label class="btn btn-default btn-rounded btn-outline {% if !isXssEnabled %}active{% endif %}" data-active-class="default">
-                  <input name="{{nameForIsXssEnabled}}" value="false" type="radio"
-                      {% if !isXssEnabled %}checked{% endif %}> OFF
-                </label>
-              </div>
-            </div>
-          </div>
-        </fieldset>
-
-        <fieldset class="form-group row my-3" id="xss-hide-when-disabled" {% if !isXssEnabled %}style="display: none;"{% endif %}>
-          {% set nameForXssOption = "markdownSetting[markdown:xss:option]" %}
-          {% set xssOption = markdownSetting['markdown:xss:option'] %}
-
-          <div class="col-xs-4 radio radio-primary">
-            <input type="radio" id="xssOption1" name="{{nameForXssOption}}" value="1" {% if xssOption === 1 %}checked{% endif %}>
-            <label for="xssOption1">
-              <p class="font-weight-bold">{{ t('markdown_setting.Ignore all tags') }}</p>
-              <div class="mt-4">
-                  {{ t('markdown_setting.Ignore all tags desc') }}
-              </div>
-            </label>
-          </div>
-
-          <div class="col-xs-4 radio radio-primary">
-            <input type="radio" id="xssOption2" name="{{nameForXssOption}}" value="2" {% if xssOption === 2 %}checked{% endif %}>
-            <label for="xssOption2">
-              <p class="font-weight-bold">{{ t('markdown_setting.Recommended setting') }}</p>
-              <div class="mt-4">
-                {{ t('markdown_setting.Tag names') }}
-                <textarea class="form-control xss-list" name="recommendedTags" rows="6" cols="40" readonly>{{ recommendedWhitelist.tags }}</textarea>
-              </div>
-              <div class="mt-4">
-                {{ t('markdown_setting.Tag attributes') }}
-                <textarea class="form-control xss-list" name="recommendedAttrs" rows="6" cols="40" readonly>{{ recommendedWhitelist.attrs }}</textarea>
-              </div>
-            </label>
-          </div>
-
-          <div class="col-xs-4 radio radio-primary">
-            <input type="radio" id="xssOption3" name="{{nameForXssOption}}" value="3" {% if xssOption === 3 %}checked{% endif %}>
-            <label for="xssOption3">
-              <p class="font-weight-bold">{{ t('markdown_setting.Custom Whitelist') }}</p>
-              <div class="mt-4">
-                <div class="d-flex justify-content-between">
-                  {{ t('markdown_setting.Tag names') }}
-                  <p id="btn-import-tags" class="btn btn-xs btn-primary">
-                    {{ t('markdown_setting.import_recommended', 'tags') }}
-                  </p>
-                </div>
-                <textarea class="form-control xss-list" type="text" name="markdownSetting[markdown:xss:tagWhiteList]" rows="6" cols="40" placeholder="e.g. iframe, script, video...">{{ markdownSetting['markdown:xss:tagWhiteList'] }}</textarea>
-              </div>
-              <div class="mt-4">
-                <div class="d-flex justify-content-between">
-                  {{ t('markdown_setting.Tag attributes') }}
-                  <p id="btn-import-attrs" class="btn btn-xs btn-primary">
-                    {{ t('markdown_setting.import_recommended', 'attributes') }}
-                  </p>
-                </div>
-                <textarea class="form-control xss-list" name="markdownSetting[markdown:xss:attrWhiteList]" rows="6" cols="40" placeholder="e.g. src, id, name...">{{ markdownSetting['markdown:xss:attrWhiteList'] }}</textarea>
-              </div>
-            </label>
-          </div>
-
-        </fieldset>
-
-        <div class="form-group row">
-          <div class="col-xs-12 d-flex justify-content-center">
-            <input type="hidden" name="_csrf" value="{{ csrf() }}">
-            <button type="submit" class="btn btn-primary">{{ t("Update") }}</button>
-          </div>
-        </div>
-
-      </form>
-    </div>
+    <div class="col-md-9" id="admin-markdown-setting"></div>
   </div>
-
 </div>
 
-<script>
-  // give a space between items in textarea(',' => ', ')
-  for (var i = 0; i < $('textarea.xss-list').length; i++) {
-    $($('textarea.xss-list')[i]).val($($('textarea.xss-list')[i]).val().replace(/,/g, ', '));
-  };
-
-  $('input[name="markdownSetting[markdown:xss:isEnabledPrevention]"]').change(function() {
-    if ($(this).val() === 'true') {
-      $('#xss-hide-when-disabled').slideDown();
-    }
-    else {
-      $('#xss-hide-when-disabled').slideUp();
-    }
-  });
-
-  $('#btn-import-tags').on('click', () => {
-    var $tagWhiteList = $('textarea[name="markdownSetting[markdown:xss:tagWhiteList]"]');
-    var $recommendedTagList = $('textarea[name="recommendedTags"]');
-    $tagWhiteList.val($recommendedTagList.val());
-  });
-  $('#btn-import-attrs').on('click', () => {
-    var $attrWhiteList = $('textarea[name="markdownSetting[markdown:xss:attrWhiteList]"]');
-    var $recommendedAttrList = $('textarea[name="recommendedAttrs"]');
-    $attrWhiteList.val($recommendedAttrList.val());
-  });
-</script>
 {% endblock content_main %}
 
 {% block content_footer %}

+ 23 - 9
yarn.lock

@@ -1541,6 +1541,11 @@
   resolved "https://registry.yarnpkg.com/@xtuc/long/-/long-4.2.2.tgz#d291c6a4e97989b5c61d9acf396ae4fe133a718d"
   integrity sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==
 
+"@yarnpkg/lockfile@^1.1.0":
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/@yarnpkg/lockfile/-/lockfile-1.1.0.tgz#e77a97fbd345b76d83245edcd17d393b1b41fb31"
+  integrity sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ==
+
 JSONStream@^1.3.5:
   version "1.3.5"
   resolved "https://registry.yarnpkg.com/JSONStream/-/JSONStream-1.3.5.tgz#3208c1f08d3a4d99261ab64f92302bc15e111ca0"
@@ -5304,6 +5309,14 @@ find-cache-dir@^3.0.0:
     make-dir "^3.0.0"
     pkg-dir "^4.1.0"
 
+find-up@4.1.0, find-up@^4.0.0:
+  version "4.1.0"
+  resolved "https://registry.yarnpkg.com/find-up/-/find-up-4.1.0.tgz#97afe7d6cdc0bc5928584b7c8d7b16e8a9aa5d19"
+  integrity sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==
+  dependencies:
+    locate-path "^5.0.0"
+    path-exists "^4.0.0"
+
 find-up@^1.0.0:
   version "1.1.2"
   resolved "https://registry.yarnpkg.com/find-up/-/find-up-1.1.2.tgz#6b2e9822b1a2ce0a60ab64d610eccad53cb24d0f"
@@ -5323,14 +5336,6 @@ find-up@^3.0.0:
   dependencies:
     locate-path "^3.0.0"
 
-find-up@^4.0.0:
-  version "4.1.0"
-  resolved "https://registry.yarnpkg.com/find-up/-/find-up-4.1.0.tgz#97afe7d6cdc0bc5928584b7c8d7b16e8a9aa5d19"
-  integrity sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==
-  dependencies:
-    locate-path "^5.0.0"
-    path-exists "^4.0.0"
-
 findup-sync@3.0.0:
   version "3.0.0"
   resolved "https://registry.yarnpkg.com/findup-sync/-/findup-sync-3.0.0.tgz#17b108f9ee512dfb7a5c7f3c8b27ea9e1a9c08d1"
@@ -9594,6 +9599,15 @@ p-try@^2.0.0:
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/p-try/-/p-try-2.0.0.tgz#85080bb87c64688fa47996fe8f7dfbe8211760b1"
 
+package-installed-version-sync@^2.1.0:
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/package-installed-version-sync/-/package-installed-version-sync-2.1.0.tgz#db8d2cbee32bc91a36e100da9bda6743f956ac93"
+  integrity sha512-rhREjEXIJ0IurYS23PGmlL1T+6/wJL9Oev2WYztN+MYze6xpsFxUL3DaixlZglpHoYCPxu3tdCUO/AMoIVrCVg==
+  dependencies:
+    "@yarnpkg/lockfile" "^1.1.0"
+    find-up "4.1.0"
+    semver "^6.2.0"
+
 pako@1.0.3:
   version "1.0.3"
   resolved "https://registry.yarnpkg.com/pako/-/pako-1.0.3.tgz#5f515b0c6722e1982920ae8005eacb0b7ca73ccf"
@@ -11697,7 +11711,7 @@ semver@^6.1.1:
   resolved "https://registry.yarnpkg.com/semver/-/semver-6.1.2.tgz#079960381376a3db62eb2edc8a3bfb10c7cfe318"
   integrity sha512-z4PqiCpomGtWj8633oeAdXm1Kn1W++3T8epkZYnwiVgIYIJ0QHszhInYSJTYxebByQH7KVCEAn8R9duzZW2PhQ==
 
-semver@^6.3.0:
+semver@^6.2.0, semver@^6.3.0:
   version "6.3.0"
   resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.0.tgz#ee0a64c8af5e8ceea67687b133761e1becbd1d3d"
   integrity sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==