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

Merge branch 'master' into support/fix-i18n

Ryuichi Paul Egoshi 5 лет назад
Родитель
Сommit
05aa7aeeb6
42 измененных файлов с 459 добавлено и 143 удалено
  1. 5 0
      .github/workflows/build-rc.yml
  2. 5 0
      .github/workflows/build.yml
  3. 8 4
      .github/workflows/ci.yml
  4. 35 1
      CHANGES.md
  5. 2 2
      bin/github-actions/update-readme.sh
  6. 4 6
      docker/README.md
  7. 3 3
      package.json
  8. 7 2
      resource/locales/en-US/admin/admin.json
  9. 7 1
      resource/locales/en-US/translation.json
  10. 6 1
      resource/locales/ja/admin/admin.json
  11. 6 0
      resource/locales/ja/translation.json
  12. 5 3
      src/client/js/app.jsx
  13. 25 10
      src/client/js/components/Admin/ElasticsearchManagement/ElasticsearchManagement.jsx
  14. 4 5
      src/client/js/components/Admin/ElasticsearchManagement/ReconnectControls.jsx
  15. 54 23
      src/client/js/components/Admin/ElasticsearchManagement/StatusTable.jsx
  16. 16 12
      src/client/js/components/Admin/ImportData/GrowiArchive/ImportForm.jsx
  17. 2 3
      src/client/js/components/Admin/Security/LdapAuthTestModal.jsx
  18. 8 1
      src/client/js/components/Admin/UserManagement.jsx
  19. 15 15
      src/client/js/components/Admin/Users/PasswordResetModal.jsx
  20. 2 2
      src/client/js/components/HeaderSearchBox.jsx
  21. 25 1
      src/client/js/legacy/crowi.js
  22. 4 1
      src/client/js/services/AdminUsersContainer.js
  23. 6 0
      src/client/styles/scss/_modal.scss
  24. 1 0
      src/client/styles/scss/style-app.scss
  25. 11 5
      src/migrations/20191102223901-drop-pages-indices.js
  26. 3 2
      src/server/crowi/express-init.js
  27. 1 1
      src/server/routes/apiv3/healthcheck.js
  28. 2 0
      src/server/routes/apiv3/index.js
  29. 48 0
      src/server/routes/apiv3/pages.js
  30. 13 14
      src/server/service/import.js
  31. 19 0
      src/server/service/search-delegator/elasticsearch.js
  32. 25 6
      src/server/service/search.js
  33. 1 1
      src/server/views/layout-growi/page_list.html
  34. 4 0
      src/server/views/layout-growi/user_page.html
  35. 1 1
      src/server/views/layout-kibela/page_list.html
  36. 4 0
      src/server/views/layout-kibela/user_page.html
  37. 47 0
      src/server/views/modal/empty_trash.html
  38. 2 0
      src/server/views/modal/rename.html
  39. 7 2
      src/server/views/widget/page_alerts.html
  40. 1 1
      src/server/views/widget/page_content.html
  41. 1 0
      src/server/views/widget/page_modals.html
  42. 14 14
      yarn.lock

+ 5 - 0
.github/workflows/build-rc.yml

@@ -52,3 +52,8 @@ jobs:
         target: weseek/growi
         semver: ${{ env.SEMVER }}
         publish: true
+
+    - name: Check whether workspace is clean
+      run: |
+        STATUS=`git status --porcelain`
+        if [ -z "$STATUS" ]; then exit 0; else exit 1; fi

+ 5 - 0
.github/workflows/build.yml

@@ -66,6 +66,11 @@ jobs:
         additional-tags: 'latest'
         publish: true
 
+    - name: Check whether workspace is clean
+      run: |
+        STATUS=`git status --porcelain`
+        if [ -z "$STATUS" ]; then exit 0; else exit 1; fi
+
   publish-desc:
 
     runs-on: ubuntu-latest

+ 8 - 4
.github/workflows/ci.yml

@@ -53,12 +53,13 @@ jobs:
         yarn lint
 
     - name: Slack Notification
-      uses: homoluctus/slatify@master
+      uses: weseek/ghaction-slack-notification@master
       if: failure()
       with:
         type: ${{ job.status }}
         job_name: '*test (${{ matrix.node-version }})*'
         channel: '#ci'
+        isCompactMode: true
         url: ${{ secrets.SLACK_WEBHOOK_URL }}
 
 
@@ -113,12 +114,13 @@ jobs:
         MONGO_URI: mongodb://localhost:27017/growi_test
 
     - name: Slack Notification
-      uses: homoluctus/slatify@master
+      uses: weseek/ghaction-slack-notification@master
       if: failure()
       with:
         type: ${{ job.status }}
         job_name: '*test (${{ matrix.node-version }})*'
         channel: '#ci'
+        isCompactMode: true
         url: ${{ secrets.SLACK_WEBHOOK_URL }}
 
 
@@ -183,12 +185,13 @@ jobs:
         yarn build:dev
 
     - name: Slack Notification
-      uses: homoluctus/slatify@master
+      uses: weseek/ghaction-slack-notification@master
       if: failure()
       with:
         type: ${{ job.status }}
         job_name: '*build-dev (${{ matrix.node-version }})*'
         channel: '#ci'
+        isCompactMode: true
         url: ${{ secrets.SLACK_WEBHOOK_URL }}
 
 
@@ -268,10 +271,11 @@ jobs:
         path: report
 
     - name: Slack Notification
-      uses: homoluctus/slatify@master
+      uses: weseek/ghaction-slack-notification@master
       if: failure()
       with:
         type: ${{ job.status }}
         job_name: '*build-prod (${{ matrix.node-version }})*'
         channel: '#ci'
+        isCompactMode: true
         url: ${{ secrets.SLACK_WEBHOOK_URL }}

+ 35 - 1
CHANGES.md

@@ -1,9 +1,35 @@
 # CHANGES
 
-## v3.7.5
+## v3.8.1-RC
 
 *
 
+## v3.8.0
+
+### BREAKING CHANGES
+
+- Now Elasticsearch requires the privilege `cluster:monitor/health` instead of `cluster:monitor/nodes/info`
+
+Upgrading Guide: <https://docs.growi.org/en/admin-guide/upgrading/38x.html>
+
+### Updates
+
+* Improvement: Change the health check method for Elasticsearch
+
+## v3.7.6
+
+* Feature: Empty trash pages
+* Improvement: Behavior of Reconnect to Elasticsearch button
+
+## v3.7.5
+
+* Fix: Draw.io diagrams rendered twice
+* Fix: Behavior of password reset modal is strange
+* Fix: Import GROWI Archive doesn't restore some data correctly
+* Fix: Attachments list on root page and users top pages
+* Fix: Trash page is no longer editable
+* Fix: Rendering Timeline on /trash
+
 ## v3.7.4
 
 * Fix: Broken by displaying user image
@@ -40,6 +66,14 @@
 
 ## v3.7.0
 
+### BREAKING CHANGES
+
+None.
+
+Upgrading Guide: <https://docs.growi.org/en/admin-guide/upgrading/37x.html>
+
+### Updates
+
 * Feature: [Draw.io](https://www.draw.io/) Integration
 * Feature: SAML Attribute-based Login Control
 * Improvement: Reactify admin pages (Security)

+ 2 - 2
bin/github-actions/update-readme.sh

@@ -2,5 +2,5 @@
 
 cd docker
 
-sed -i -e "s/^\([*] \[\`\)[^\`]\+\(\`, \`3\.6\`, .\+\]\)\(.\+\/blob\/v\).\+\(\/docker\/Dockerfile.\+\)$/\1${RELEASE_VERSION}\2\3${RELEASE_VERSION}\4/" README.md
-sed -i -e "s/^\([*] \[\`\)[^\`]\+\(\`, \`3\.6-nocdn\`, .\+\]\)\(.\+\/blob\/v\).\+\(\/docker\/Dockerfile.\+\)$/\1${RELEASE_VERSION}-nocdn\2\3${RELEASE_VERSION}\4/" README.md
+sed -i -e "s/^\([*] \[\`\)[^\`]\+\(\`, \`3\.8\`, .\+\]\)\(.\+\/blob\/v\).\+\(\/docker\/Dockerfile.\+\)$/\1${RELEASE_VERSION}\2\3${RELEASE_VERSION}\4/" README.md
+sed -i -e "s/^\([*] \[\`\)[^\`]\+\(\`, \`3\.8-nocdn\`, .\+\]\)\(.\+\/blob\/v\).\+\(\/docker\/Dockerfile.\+\)$/\1${RELEASE_VERSION}-nocdn\2\3${RELEASE_VERSION}\4/" README.md

+ 4 - 6
docker/README.md

@@ -10,12 +10,10 @@ GROWI Official docker image
 Supported tags and respective Dockerfile links
 ------------------------------------------------
 
-* [`3.6.0`, `3.6`, `3`, `latest`, (Dockerfile)](https://github.com/weseek/growi/blob/v3.6.0/docker/Dockerfile)
-* [`3.6.0-nocdn`, `3.6-nocdn`, `3-nocdn`, `latest-nocdn`, (Dockerfile)](https://github.com/weseek/growi/blob/v3.6.0/docker/Dockerfile)
-* [`3.5.25`, `3.5`, `3`, (Dockerfile)](https://github.com/weseek/growi-docker/blob/v3.5.25/Dockerfile)
-* [`3.5.25-nocdn`, `3.5-nocdn`, `3-nocdn` (Dockerfile)](https://github.com/weseek/growi-docker/blob/v3.5.25/nocdn/Dockerfile)
-* [`3.4.7`, `3.4`, `3`, (Dockerfile)](https://github.com/weseek/growi-docker/blob/v3.4.7/Dockerfile)
-* [`3.4.7-nocdn`, `3.4-nocdn`, `3-nocdn` (Dockerfile)](https://github.com/weseek/growi-docker/blob/v3.4.7/nocdn/Dockerfile)
+* [`3.8.0`, `3.8`, `3`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v3.8.0/docker/Dockerfile)
+* [`3.8.0-nocdn`, `3.8-nocdn`, `3-nocdn`, `latest-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v3.8.0/docker/Dockerfile)
+* [`3.7.6`, `3.7` (Dockerfile)](https://github.com/weseek/growi/blob/v3.7.6/docker/Dockerfile)
+* [`3.7.6-nocdn`, `3.7-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v3.7.6/docker/Dockerfile)
 
 
 What is GROWI?

+ 3 - 3
package.json

@@ -1,6 +1,6 @@
 {
   "name": "growi",
-  "version": "3.7.4-RC",
+  "version": "3.8.1-RC",
   "description": "Team collaboration software using markdown",
   "tags": [
     "wiki",
@@ -194,8 +194,8 @@
     "load-css-file": "^1.0.0",
     "lodash-webpack-plugin": "^0.11.5",
     "markdown-it": "^10.0.0",
-    "markdown-it-blockdiag": "^1.0.3",
-    "markdown-it-drawio-viewer": "^1.1.3",
+    "markdown-it-blockdiag": "^1.1.1",
+    "markdown-it-drawio-viewer": "^1.2.0",
     "markdown-it-emoji": "^1.4.0",
     "markdown-it-footnote": "^3.0.1",
     "markdown-it-mathjax": "^2.0.0",

+ 7 - 2
resource/locales/en-US/admin/admin.json

@@ -105,7 +105,7 @@
     },
     "behavior": "Behavior",
     "behavior_desc": {
-      "growi_text1": "Both of <code>/page</code> and <code>/page/</code> shows the same page",
+      "growi_text1": "Both of <code>/page</code> and <code>/page/</code> shows the same page.",
       "growi_text2": "<code>/nonexistent_page</code> shows editing form",
       "growi_text3": "All pages show the list of child pages <b>if using GROWI Enhanced Layout</b>",
       "crowi_text1": "<code>/page</code> shows the page",
@@ -151,7 +151,12 @@
     "import_from": "Import from {{from}}",
     "import_growi_archive": "Import GROWI archive",
     "growi_settings": {
-      "overwrite_documents": "Imported documents will overwrite existing documents",
+      "description_of_import_mode": {
+        "about": "When you import data with the same name as an existing one, choose from the following three modes below.",
+        "insert": "Insert: Skip importing the data.",
+        "upsert": "Upsert: Overwrite and update the existing data with imported data.",
+        "flash_and_insert": "Flash and Insert: After deleting the existing data completely, import the data"
+      },
       "growi_archive_file": "GROWI Archive File",
       "uploaded_data": "Uploaded Data",
       "extracted_file": "Extracted File",

+ 7 - 1
resource/locales/en-US/translation.json

@@ -182,9 +182,11 @@
   "Update API Token": "Update API token",
   "header_search_box": {
     "label": {
+      "All pages": "All pages",
       "This tree": "This tree"
     },
     "item_label": {
+      "All pages": "All pages",
       "This tree": "Only children of this tree"
     }
   },
@@ -275,6 +277,9 @@
     "recursively": "Delete children of <code>%s</code> recursively.",
     "completely": "Delete completely instead of putting it into trash."
   },
+  "modal_empty":{
+    "empty_the_trash": "Empty The Trash"
+  },
   "modal_duplicate": {
     "label": {
       "Duplicate page": "Duplicate page",
@@ -621,7 +626,8 @@
     "connection_status_label_unconfigured": "UNCONFIGURED",
     "connection_status_label_connected": "CONNECTED",
     "connection_status_label_disconnected": "DISCONNECTED",
-    "indices_status": "Indices status",
+    "connection_status_label_erroroccured": "ERROR OCCURED ON SEARCH SERVICE",
+    "indices_status": "Indices Status",
     "indices_status_label_normalized": "NORMALIZED",
     "indices_status_label_unnormalized": "REBUILDING or BROKEN",
     "indices_summary": "Indices summary",

+ 6 - 1
resource/locales/ja/admin/admin.json

@@ -169,7 +169,12 @@
     "import_from": "{{from}} からインポート",
     "import_growi_archive": "GROWI アーカイブをインポート",
     "growi_settings": {
-      "overwrite_documents": "インポートされたドキュメントは既存のドキュメントを上書きします",
+      "description_of_import_mode": {
+        "about": "既存のデータと同名であるデータをインポートする際の挙動は以下の3つのモードから選べます。",
+        "insert": "Insert: 当該データのインポートをスキップします。",
+        "upsert": "Upsert: 既存のデータをインポートデータで上書き更新します。",
+        "flash_and_insert": "Flash and Insert: 既存のデータを完全に削除した後、インポートを行います。"
+      },
       "growi_archive_file": "GROWI アーカイブファイル",
       "uploaded_data": "アップロードされたデータ",
       "extracted_file": "展開されたファイル",

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

@@ -181,9 +181,11 @@
   "Update API Token": "API Tokenを更新",
   "header_search_box": {
     "label": {
+      "All pages": "全てのページ",
       "This tree": "この階層"
     },
     "item_label": {
+      "All pages": "全てのページ",
       "This tree": "この階層下の子ページのみ"
     }
   },
@@ -273,6 +275,9 @@
     "recursively": "<code>%s</code> 配下のページも削除します",
     "completely": "ゴミ箱を経由せず、完全に削除します"
   },
+  "modal_empty":{
+    "empty_the_trash": "ゴミ箱を空にする"
+  },
   "modal_duplicate": {
     "label": {
       "Duplicate page": "ページを複製する",
@@ -610,6 +615,7 @@
     "connection_status_label_unconfigured": "設定されていません",
     "connection_status_label_connected": "接続されています",
     "connection_status_label_disconnected": "切断されています",
+    "connection_status_label_erroroccured": "SearchService でエラーが発生しています",
     "indices_status": "インデックスの状態",
     "indices_status_label_normalized": "正規化されています",
     "indices_status_label_unnormalized": "リビルド中 または 破損しています",

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

@@ -75,8 +75,8 @@ Object.assign(componentMappings, {
   'page-status-alert': <PageStatusAlert />,
   'save-page-controls': <SavePageControls />,
 
-  'user-created-list': <RecentCreated />,
-  'user-draft-list': <MyDraftList />,
+  'page-timeline': <PageTimeline />,
+
   'personal-setting': <PersonalSettings crowi={personalContainer} />,
 });
 
@@ -86,7 +86,6 @@ if (pageContainer.state.pageId != null) {
     'page-editor-with-hackmd': <PageEditorByHackmd />,
     'page-comments-list': <PageComments />,
     'page-attachment': <PageAttachment />,
-    'page-timeline': <PageTimeline />,
     'page-comment-write': <CommentEditorLazyRenderer />,
     'revision-toc': <TableOfContents />,
     'like-button': <LikeButton pageId={pageContainer.state.pageId} isLiked={pageContainer.state.isLiked} />,
@@ -96,6 +95,9 @@ if (pageContainer.state.pageId != null) {
     'bookmark-button-lg': <BookmarkButton pageId={pageContainer.state.pageId} crowi={appContainer} size="lg" />,
     'rename-page-name-input': <PagePathAutoComplete crowi={appContainer} initializedPath={pageContainer.state.path} />,
     'duplicate-page-name-input': <PagePathAutoComplete crowi={appContainer} initializedPath={pageContainer.state.path} />,
+
+    'user-created-list': <RecentCreated />,
+    'user-draft-list': <MyDraftList />,
   });
 }
 if (pageContainer.state.path != null) {

+ 25 - 10
src/client/js/components/Admin/ElasticsearchManagement/ElasticsearchManagement.jsx

@@ -18,8 +18,11 @@ class ElasticsearchManagement extends React.Component {
     super(props);
 
     this.state = {
-      isConfigured: null,
-      isConnected: null,
+      isInitialized: false,
+
+      isConnected: false,
+      isConfigured: false,
+      isReconnectingProcessing: false,
       isRebuildingProcessing: false,
       isRebuildingCompleted: false,
 
@@ -69,8 +72,8 @@ class ElasticsearchManagement extends React.Component {
       const { info } = await appContainer.apiv3Get('/search/indices');
 
       this.setState({
-        isConfigured: true,
         isConnected: true,
+        isConfigured: true,
 
         indicesData: info.indices,
         aliasesData: info.aliases,
@@ -89,21 +92,26 @@ class ElasticsearchManagement extends React.Component {
 
       toastError(errors);
     }
+    finally {
+      this.setState({ isInitialized: true });
+    }
   }
 
   async reconnect() {
     const { appContainer } = this.props;
 
+    this.setState({ isReconnectingProcessing: true });
+
     try {
       await appContainer.apiv3Post('/search/connection');
-      toastSuccess('Reconnecting to Elasticsearch has succeeded');
     }
     catch (e) {
       toastError(e);
       return;
     }
 
-    await this.retrieveIndicesStatus();
+    // reload
+    window.location.reload();
   }
 
   async normalizeIndices() {
@@ -138,19 +146,26 @@ class ElasticsearchManagement extends React.Component {
   }
 
   render() {
-    const { t } = this.props;
+    const { t, appContainer } = this.props;
     const {
-      isConfigured, isConnected, isRebuildingProcessing, isRebuildingCompleted,
+      isInitialized,
+      isConnected, isConfigured, isReconnectingProcessing, isRebuildingProcessing, isRebuildingCompleted,
       isNormalized, indicesData, aliasesData,
     } = this.state;
 
+    const isErrorOccuredOnSearchService = !appContainer.config.isSearchServiceReachable;
+
+    const isReconnectBtnEnabled = !isReconnectingProcessing && (!isInitialized || !isConnected || isErrorOccuredOnSearchService);
+
     return (
       <>
         <div className="row">
           <div className="col-xs-12">
             <StatusTable
-              isConfigured={isConfigured}
+              isInitialized={isInitialized}
+              isErrorOccuredOnSearchService={isErrorOccuredOnSearchService}
               isConnected={isConnected}
+              isConfigured={isConfigured}
               isNormalized={isNormalized}
               indicesData={indicesData}
               aliasesData={aliasesData}
@@ -165,8 +180,8 @@ class ElasticsearchManagement extends React.Component {
           <label className="col-xs-3 control-label">{ t('full_text_search_management.reconnect') }</label>
           <div className="col-xs-6">
             <ReconnectControls
-              isConfigured={isConfigured}
-              isConnected={isConnected}
+              isEnabled={isReconnectBtnEnabled}
+              isProcessing={isReconnectingProcessing}
               onReconnectingRequested={this.reconnect}
             />
           </div>

+ 4 - 5
src/client/js/components/Admin/ElasticsearchManagement/ReconnectControls.jsx

@@ -7,9 +7,7 @@ import { createSubscribedElement } from '../../UnstatedUtils';
 class ReconnectControls extends React.PureComponent {
 
   render() {
-    const { t, isConfigured, isConnected } = this.props;
-
-    const isEnabled = (isConfigured == null || isConfigured === true) && isConnected === false;
+    const { t, isEnabled, isProcessing } = this.props;
 
     return (
       <>
@@ -19,6 +17,7 @@ class ReconnectControls extends React.PureComponent {
           onClick={() => { this.props.onReconnectingRequested() }}
           disabled={!isEnabled}
         >
+          { isProcessing && <i className="fa fa-spinner fa-pulse mr-2"></i> }
           { t('full_text_search_management.reconnect_button') }
         </button>
 
@@ -41,8 +40,8 @@ const ReconnectControlsWrapper = (props) => {
 ReconnectControls.propTypes = {
   t: PropTypes.func.isRequired, // i18next
 
-  isConfigured: PropTypes.bool,
-  isConnected: PropTypes.bool,
+  isEnabled: PropTypes.bool,
+  isProcessing: PropTypes.bool,
   onReconnectingRequested: PropTypes.func.isRequired,
 };
 

+ 54 - 23
src/client/js/components/Admin/ElasticsearchManagement/StatusTable.jsx

@@ -6,6 +6,46 @@ import { createSubscribedElement } from '../../UnstatedUtils';
 
 class StatusTable extends React.PureComponent {
 
+  renderPreInitializedLabel() {
+    return <span className="label label-default">――</span>;
+  }
+
+  renderConnectionStatusLabels() {
+    const { t } = this.props;
+    const {
+      isErrorOccuredOnSearchService,
+      isConnected, isConfigured,
+    } = this.props;
+
+    const errorOccuredLabel = isErrorOccuredOnSearchService
+      ? <span className="label label-danger ml-2">{ t('full_text_search_management.connection_status_label_erroroccured') }</span>
+      : null;
+
+    let connectionStatusLabel = null;
+    if (!isConfigured) {
+      connectionStatusLabel = <span className="label label-default">{ t('full_text_search_management.connection_status_label_unconfigured') }</span>;
+    }
+    else {
+      connectionStatusLabel = isConnected
+        ? <span className="label label-success">{ t('full_text_search_management.connection_status_label_connected') }</span>
+        : <span className="label label-danger">{ t('full_text_search_management.connection_status_label_disconnected') }</span>;
+    }
+
+    return (
+      <>
+        {connectionStatusLabel}{errorOccuredLabel}
+      </>
+    );
+  }
+
+  renderIndicesStatusLabel() {
+    const { t, isNormalized } = this.props;
+
+    return isNormalized
+      ? <span className="label label-info">{ t('full_text_search_management.indices_status_label_normalized') }</span>
+      : <span className="label label-warning">{ t('full_text_search_management.indices_status_label_unnormalized') }</span>;
+  }
+
   renderIndexInfoPanel(indexName, body = {}, aliases = []) {
     const collapseId = `collapse-${indexName}`;
 
@@ -95,41 +135,29 @@ class StatusTable extends React.PureComponent {
 
   render() {
     const { t } = this.props;
-    const { isConfigured, isConnected, isNormalized } = this.props;
-
-
-    let connectionStatusLabel = <span className="label label-default">――</span>;
-    if (isConfigured != null && !isConfigured) {
-      connectionStatusLabel = <span className="label label-default">{ t('full_text_search_management.connection_status_label_unconfigured') }</span>;
-    }
-    else if (isConnected != null) {
-      connectionStatusLabel = isConnected
-        ? <span className="label label-success">{ t('full_text_search_management.connection_status_label_connected') }</span>
-        : <span className="label label-danger">{ t('full_text_search_management.connection_status_label_disconnected') }</span>;
-    }
-
-    let indicesStatusLabel = <span className="label label-default">――</span>;
-    if (isNormalized != null) {
-      indicesStatusLabel = isNormalized
-        ? <span className="label label-info">{ t('full_text_search_management.indices_status_label_normalized') }</span>
-        : <span className="label label-warning">{ t('full_text_search_management.indices_status_label_unnormalized') }</span>;
-    }
+    const {
+      isInitialized,
+    } = this.props;
 
     return (
       <table className="table table-bordered">
         <tbody>
           <tr>
             <th>{ t('full_text_search_management.connection_status') }</th>
-            <td>{connectionStatusLabel}</td>
+            <td>
+              { isInitialized ? this.renderConnectionStatusLabels() : this.renderPreInitializedLabel() }
+            </td>
           </tr>
           <tr>
             <th>{ t('full_text_search_management.indices_status') }</th>
-            <td>{indicesStatusLabel}</td>
+            <td>
+              { isInitialized ? this.renderIndicesStatusLabel() : this.renderPreInitializedLabel() }
+            </td>
           </tr>
           <tr>
             <th className="col-sm-4">{ t('full_text_search_management.indices_summary') }</th>
             <td className="p-4">
-              { this.renderIndexInfoPanels() }
+              { isInitialized && this.renderIndexInfoPanels() }
             </td>
           </tr>
         </tbody>
@@ -149,8 +177,11 @@ const StatusTableWrapper = (props) => {
 StatusTable.propTypes = {
   t: PropTypes.func.isRequired, // i18next
 
-  isConfigured: PropTypes.bool,
+  isInitialized: PropTypes.bool,
+  isErrorOccuredOnSearchService: PropTypes.bool,
+
   isConnected: PropTypes.bool,
+  isConfigured: PropTypes.bool,
   isNormalized: PropTypes.bool,
   indicesData: PropTypes.object,
   aliasesData: PropTypes.object,

+ 16 - 12
src/client/js/components/Admin/ImportData/GrowiArchive/ImportForm.jsx

@@ -333,7 +333,7 @@ class ImportForm extends React.Component {
     );
   }
 
-  renderGroups(groupList, groupName, errors, { wellContent } = {}) {
+  renderGroups(groupList, groupName, errors) {
     const collectionNames = groupList.filter((collectionName) => {
       return this.allCollectionNames.includes(collectionName);
     });
@@ -344,14 +344,7 @@ class ImportForm extends React.Component {
 
     return (
       <div className="mt-4">
-        <legend>{groupName} collections</legend>
-        {wellContent != null && (
-          <div className="well well-sm small">
-            <ul>
-              <li>{wellContent}</li>
-            </ul>
-          </div>
-        )}
+        <legend>{groupName} Collections</legend>
         {this.renderImportItems(collectionNames)}
         {this.renderWarnForGroups(errors, `warnFor${groupName}`)}
       </div>
@@ -463,10 +456,21 @@ class ImportForm extends React.Component {
             </button>
           </div>
         </form>
+        
+        <div className="well well-sm small my-4">
+          <ul>
+            <li>{t('admin:importer_management.growi_settings.description_of_import_mode.about')}</li>
+            <ul>
+              <li>{t('admin:importer_management.growi_settings.description_of_import_mode.insert')}</li>
+              <li>{t('admin:importer_management.growi_settings.description_of_import_mode.upsert')}</li>
+              <li>{t('admin:importer_management.growi_settings.description_of_import_mode.flash_and_insert')}</li>
+            </ul>
+          </ul>
+        </div>
 
-        {this.renderGroups(GROUPS_PAGE, 'MongoDB page', warnForPageGroups, { wellContent: t('admin:importer_management.growi_settings.overwrite_documents') })}
-        {this.renderGroups(GROUPS_USER, 'MongoDB user', warnForUserGroups)}
-        {this.renderGroups(GROUPS_CONFIG, 'MongoDB config', warnForConfigGroups)}
+        {this.renderGroups(GROUPS_PAGE, 'Page', warnForPageGroups)}
+        {this.renderGroups(GROUPS_USER, 'User', warnForUserGroups)}
+        {this.renderGroups(GROUPS_CONFIG, 'Config', warnForConfigGroups)}
         {this.renderOthers()}
 
         <div className="mt-4 text-center">

+ 2 - 3
src/client/js/components/Admin/Security/LdapAuthTestModal.jsx

@@ -43,8 +43,8 @@ class LdapAuthTestModal extends React.Component {
 
     return (
       <Modal show={this.props.isOpen} onHide={this.props.onClose}>
-        <Modal.Header className="modal-header" closeButton>
-          <Modal.Title>
+        <Modal.Header className="bg-info modal-header" closeButton>
+          <Modal.Title className="text-white">
             Test LDAP Account
           </Modal.Title>
         </Modal.Header>
@@ -56,7 +56,6 @@ class LdapAuthTestModal extends React.Component {
             onChangePassword={this.onChangePassword}
           />
         </Modal.Body>
-        <Modal.Footer />
       </Modal>
     );
   }

+ 8 - 1
src/client/js/components/Admin/UserManagement.jsx

@@ -121,7 +121,14 @@ class UserManagement extends React.Component {
 
     return (
       <Fragment>
-        {adminUsersContainer.state.userForPasswordResetModal && <PasswordResetModal />}
+        {adminUsersContainer.state.userForPasswordResetModal != null
+        && (
+        <PasswordResetModal
+          isOpen={adminUsersContainer.state.isPasswordResetModalShown}
+          onClose={adminUsersContainer.hidePasswordResetModal}
+          userForPasswordResetModal={adminUsersContainer.state.userForPasswordResetModal}
+        />
+        )}
         <p>
           <InviteUserControl />
           <a className="btn btn-default btn-outline ml-2" href="/admin/users/external-accounts">

+ 15 - 15
src/client/js/components/Admin/Users/PasswordResetModal.jsx

@@ -7,7 +7,6 @@ import Modal from 'react-bootstrap/es/Modal';
 import { toastError } from '../../../util/apiNotification';
 import { createSubscribedElement } from '../../UnstatedUtils';
 import AppContainer from '../../../services/AppContainer';
-import AdminUsersContainer from '../../../services/AdminUsersContainer';
 
 class PasswordResetModal extends React.Component {
 
@@ -23,10 +22,9 @@ class PasswordResetModal extends React.Component {
   }
 
   async resetPassword() {
-    const { appContainer, adminUsersContainer } = this.props;
-    const user = adminUsersContainer.state.userForPasswordResetModal;
+    const { appContainer, userForPasswordResetModal } = this.props;
 
-    const res = await appContainer.apiPost('/admin/users.resetPassword', { user_id: user._id });
+    const res = await appContainer.apiPost('/admin/users.resetPassword', { user_id: userForPasswordResetModal._id });
     if (res.ok) {
       this.setState({ temporaryPassword: res.newPassword, isPasswordResetDone: true });
     }
@@ -36,14 +34,13 @@ class PasswordResetModal extends React.Component {
   }
 
   renderModalBodyBeforeReset() {
-    const { t, adminUsersContainer } = this.props;
-    const user = adminUsersContainer.state.userForPasswordResetModal;
+    const { t, userForPasswordResetModal } = this.props;
 
     return (
       <div>
         <p className="alert alert-danger">{t('admin:user_management.reset_password_modal.password_reset_message')}</p>
         <p>
-          {t('admin:user_management.reset_password_modal.target_user')}: <code>{user.email}</code>
+          {t('admin:user_management.reset_password_modal.target_user')}: <code>{userForPasswordResetModal.email}</code>
         </p>
         <p>
           {t('admin:user_management.reset_password_modal.new_password')}: <code>{this.state.temporaryPassword}</code>
@@ -53,8 +50,7 @@ class PasswordResetModal extends React.Component {
   }
 
   returnModalBodyAfterReset() {
-    const { t, adminUsersContainer } = this.props;
-    const user = adminUsersContainer.state.userForPasswordResetModal;
+    const { t, userForPasswordResetModal } = this.props;
 
     return (
       <div>
@@ -63,7 +59,7 @@ class PasswordResetModal extends React.Component {
           <span className="text-danger">{t('admin:user_management.reset_password_modal.send_new_password')}</span>
         </p>
         <p>
-          {t('admin:user_management.reset_password_modal.target_user')}: <code>{user.email}</code>
+          {t('admin:user_management.reset_password_modal.target_user')}: <code>{userForPasswordResetModal.email}</code>
         </p>
         <button type="submit" className="btn btn-primary" onClick={this.resetPassword}>
           {t('admin:user_management.reset_password')}
@@ -75,17 +71,17 @@ class PasswordResetModal extends React.Component {
   returnModalFooter() {
     return (
       <div>
-        <button type="submit" className="btn btn-primary" onClick={this.props.adminUsersContainer.hidePasswordResetModal}>OK</button>
+        <button type="submit" className="btn btn-primary" onClick={this.props.onClose}>OK</button>
       </div>
     );
   }
 
 
   render() {
-    const { t, adminUsersContainer } = this.props;
+    const { t } = this.props;
 
     return (
-      <Modal show={adminUsersContainer.state.isPasswordResetModalShown} onHide={adminUsersContainer.hidePasswordResetModal}>
+      <Modal show={this.props.isOpen} onHide={this.props.onClose}>
         <Modal.Header className="modal-header" closeButton>
           <Modal.Title>
             {t('admin:user_management.reset_password')}
@@ -107,13 +103,17 @@ class PasswordResetModal extends React.Component {
  * Wrapper component for using unstated
  */
 const PasswordResetModalWrapper = (props) => {
-  return createSubscribedElement(PasswordResetModal, props, [AppContainer, AdminUsersContainer]);
+  return createSubscribedElement(PasswordResetModal, props, [AppContainer]);
 };
 
 PasswordResetModal.propTypes = {
   t: PropTypes.func.isRequired, // i18next
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  adminUsersContainer: PropTypes.instanceOf(AdminUsersContainer).isRequired,
+
+  isOpen: PropTypes.bool.isRequired,
+  onClose: PropTypes.func.isRequired,
+  userForPasswordResetModal: PropTypes.object,
+
 };
 
 export default withTranslation()(PasswordResetModalWrapper);

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

@@ -66,7 +66,7 @@ class HeaderSearchBox extends React.Component {
     const { t, appContainer } = this.props;
     const scopeLabel = this.state.isScopeChildren
       ? t('header_search_box.label.This tree')
-      : 'All pages';
+      : t('header_search_box.label.All pages');
 
     const config = appContainer.getConfig();
     const isReachable = config.isSearchServiceReachable;
@@ -76,7 +76,7 @@ class HeaderSearchBox extends React.Component {
         <InputGroup>
           <InputGroup.Button className="btn-group-dropdown-scope">
             <DropdownButton id="dbScope" title={scopeLabel}>
-              <MenuItem onClick={this.onClickAllPages}>All pages</MenuItem>
+              <MenuItem onClick={this.onClickAllPages}>{ t('header_search_box.item_label.All pages') }</MenuItem>
               <MenuItem onClick={this.onClickChildren}>{ t('header_search_box.item_label.This tree') }</MenuItem>
             </DropdownButton>
           </InputGroup.Button>

+ 25 - 1
src/client/js/legacy/crowi.js

@@ -135,7 +135,7 @@ Crowi.initAffix = () => {
       $elm.removeData('affix').removeClass('affix affix-top affix-bottom');
       return false;
     });
-    $affixContentContainer.css({ 'min-height': containerHeight });
+    $affixContentContainer.css({ minHeight: containerHeight });
   }
 };
 
@@ -359,6 +359,30 @@ $(() => {
     return false;
   });
 
+  // empty trash
+  $('#emptyTrash').on('shown.bs.modal', (e) => {
+    $('#emptyTrash .msg').hide();
+  });
+  $('#empty-trash-form').submit((e) => {
+    // create name-value map
+    const nameValueMap = {};
+    $('#empty-trash-form').serializeArray().forEach((obj) => {
+      nameValueMap[obj.name] = obj.value;
+    });
+    $.ajax({
+      type: 'DELETE',
+      url: '/_api/v3/pages/empty-trash',
+      data: nameValueMap,
+      dataType: 'json',
+    }).done((res) => {
+      window.location.href = '/trash';
+    }).fail((jqXHR, textStatus, errorThrown) => {
+      $('#emptyTrash .msg').hide();
+      $('#emptyTrash .msg-unknown').show();
+    });
+
+    return false;
+  });
   // delete
   $('#deletePage').on('shown.bs.modal', (e) => {
     $('#deletePage .msg').hide();

+ 4 - 1
src/client/js/services/AdminUsersContainer.js

@@ -183,7 +183,10 @@ export default class AdminUsersContainer extends Container {
    * @memberOf AdminUsersContainer
    */
   async hidePasswordResetModal() {
-    await this.setState({ isPasswordResetModalShown: false });
+    await this.setState({
+      isPasswordResetModalShown: false,
+      userForPasswordResetModal: null,
+    });
   }
 
   /**

+ 6 - 0
src/client/styles/scss/_modal.scss

@@ -0,0 +1,6 @@
+.modal-body {
+  // Adjust the height by subtracting the footer and the footer
+  // show https://stackoverflow.com/questions/24166568/set-bootstrap-modal-body-height-by-percentage/26078942
+  max-height: calc(100% - 120px);
+  overflow-y: auto;
+}

+ 1 - 0
src/client/styles/scss/style-app.scss

@@ -45,6 +45,7 @@
 @import 'tag';
 @import 'staff_credit';
 @import 'draft';
+@import 'modal';
 
 /*
  * for Guest User Mode

+ 11 - 5
src/migrations/20191102223901-drop-pages-indices.js

@@ -4,7 +4,14 @@ const logger = require('@alias/logger')('growi:migrate:drop-pages-indices');
 const mongoose = require('mongoose');
 const config = require('@root/config/migrate');
 
-async function dropIndexIfExists(collection, indexName) {
+async function dropIndexIfExists(db, collectionName, indexName) {
+  // check existence of the collection
+  const items = await db.listCollections({ name: collectionName }, { nameOnly: true }).toArray();
+  if (items.length === 0) {
+    return;
+  }
+
+  const collection = await db.collection(collectionName);
   if (await collection.indexExists(indexName)) {
     await collection.dropIndex(indexName);
   }
@@ -15,10 +22,9 @@ module.exports = {
     logger.info('Apply migration');
     mongoose.connect(config.mongoUri, config.mongodb.options);
 
-    const collection = db.collection('pages');
-    await dropIndexIfExists(collection, 'lastUpdateUser_1');
-    await dropIndexIfExists(collection, 'liker_1');
-    await dropIndexIfExists(collection, 'seenUsers_1');
+    await dropIndexIfExists(db, 'pages', 'lastUpdateUser_1');
+    await dropIndexIfExists(db, 'pages', 'liker_1');
+    await dropIndexIfExists(db, 'pages', 'seenUsers_1');
 
     logger.info('Migration has successfully applied');
   },

+ 3 - 2
src/server/crowi/express-init.js

@@ -97,15 +97,16 @@ module.exports = function(crowi, app) {
   app.use(cookieParser());
 
   // configure express-session
+  const sessionMiddleware = expressSession(crowi.sessionConfig);
   app.use((req, res, next) => {
-    // test whether the route is listed in avoidSessionTroutes
+    // test whether the route is listed in avoidSessionRoutes
     for (const regex of avoidSessionRoutes) {
       if (regex.test(req.path)) {
         return next();
       }
     }
 
-    expressSession(crowi.sessionConfig)(req, res, next);
+    sessionMiddleware(req, res, next);
   });
 
   // passport

+ 1 - 1
src/server/routes/apiv3/healthcheck.js

@@ -119,7 +119,7 @@ module.exports = (crowi) => {
     const { searchService } = crowi;
     if (searchService.isConfigured) {
       try {
-        info.searchInfo = await searchService.getInfo();
+        info.searchInfo = await searchService.getInfoForHealth();
       }
       catch (err) {
         errors.push(new ErrorV3(`The Search Service is not connectable - ${err.message}`, 'healthcheck-search-unhealthy', err.stack));

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

@@ -39,5 +39,7 @@ module.exports = (crowi) => {
 
   router.use('/search', require('./search')(crowi));
 
+  router.use('/pages', require('./pages')(crowi));
+
   return router;
 };

+ 48 - 0
src/server/routes/apiv3/pages.js

@@ -0,0 +1,48 @@
+const loggerFactory = require('@alias/logger');
+
+const logger = loggerFactory('growi:routes:apiv3:pages'); // eslint-disable-line no-unused-vars
+
+const express = require('express');
+
+
+const router = express.Router();
+
+/**
+ * @swagger
+ *  tags:
+ *    name: Pages
+ */
+module.exports = (crowi) => {
+  const loginRequired = require('../../middleware/login-required')(crowi);
+  const adminRequired = require('../../middleware/admin-required')(crowi);
+  const csrf = require('../../middleware/csrf')(crowi);
+
+  const Page = crowi.model('Page');
+
+  /**
+  * @swagger
+  *
+  *    /pages/empty-trash:
+  *      delete:
+  *        tags: [Pages]
+  *        description: empty trash
+  *        responses:
+  *          200:
+  *            description: Succeeded to remove all trash pages
+  */
+  router.delete('/empty-trash', loginRequired, adminRequired, csrf, async(req, res) => {
+    try {
+      const pages = await Page.deleteMany({
+        path: { $in: /^\/trash/ },
+      });
+      return res.apiv3({ pages });
+    }
+    catch (err) {
+      res.code = 'unknown';
+      logger.error('Failed to delete trash pages', err);
+      return res.apiv3Err(err, 500);
+    }
+  });
+
+  return router;
+};

+ 13 - 14
src/server/service/import.js

@@ -97,28 +97,27 @@ class ImportService {
    * @param {any} value value from imported document
    * @param {{ document: object, schema: object, propertyName: string }}
    * @return {any} new value for the document
+   *
+   * @see https://mongoosejs.com/docs/api/schematype.html#schematype_SchemaType-cast
    */
   keepOriginal(value, { document, schema, propertyName }) {
-    let _value = value;
+    // Model
+    if (schema != null && schema.path(propertyName) != null) {
+      const schemaType = schema.path(propertyName);
+      return schemaType.cast(value);
+    }
 
     // _id
     if (propertyName === '_id' && ObjectId.isValid(value)) {
-      _value = ObjectId(value);
-    }
-    // Date
-    else if (isIsoDate(value)) {
-      _value = parseISO(value);
+      return ObjectId(value);
     }
 
-    // Model
-    if (schema != null) {
-      // ObjectID
-      if (schema[propertyName] != null && schema[propertyName].instance === 'ObjectID' && ObjectId.isValid(value)) {
-        _value = ObjectId(value);
-      }
+    // Date
+    if (isIsoDate(value)) {
+      return parseISO(value);
     }
 
-    return _value;
+    return value;
   }
 
   /**
@@ -409,7 +408,7 @@ class ImportService {
    */
   convertDocuments(collectionName, document, overwriteParams) {
     const Model = this.growiBridgeService.getModelFromCollectionName(collectionName);
-    const schema = (Model != null) ? Model.schema.paths : null;
+    const schema = (Model != null) ? Model.schema : null;
     const convertMap = this.convertMap[collectionName];
 
     const _document = {};

+ 19 - 0
src/server/service/search-delegator/elasticsearch.js

@@ -97,6 +97,13 @@ class ElasticsearchDelegator {
     return this.normalizeIndices();
   }
 
+  /**
+   * return Nodes Info
+   * `cluster:monitor/nodes/info` privilege is required on ES
+   * @return {object} `{ esVersion, esNodeInfos }`
+   *
+   * @see https://www.elastic.co/guide/en/elasticsearch/reference/6.6/cluster-nodes-info.html
+   */
   async getInfo() {
     const info = await this.client.nodes.info();
     if (!info._nodes || !info.nodes) {
@@ -126,6 +133,18 @@ class ElasticsearchDelegator {
     return { esVersion, esNodeInfos };
   }
 
+  /**
+   * return Cluster Health
+   * `cluster:monitor/health` privilege is required on ES
+   * @return {object} `{ esClusterHealth }`
+   *
+   * @see https://www.elastic.co/guide/en/elasticsearch/reference/6.6/cluster-health.html
+   */
+  async getInfoForHealth() {
+    const esClusterHealth = await this.client.cluster.health();
+    return { esClusterHealth };
+  }
+
   /**
    * Return information for Admin Full Text Search Management page
    */

+ 25 - 6
src/server/service/search.js

@@ -7,7 +7,8 @@ class SearchService {
     this.crowi = crowi;
     this.configManager = crowi.configManager;
 
-    this.isErrorOccured = null;
+    this.isErrorOccuredOnHealthcheck = null;
+    this.isErrorOccuredOnSearching = null;
 
     try {
       this.delegator = this.initDelegator();
@@ -27,7 +28,7 @@ class SearchService {
   }
 
   get isReachable() {
-    return this.isConfigured && !this.isErrorOccured;
+    return this.isConfigured && !this.isErrorOccuredOnHealthcheck && !this.isErrorOccuredOnSearching;
   }
 
   get isSearchboxEnabled() {
@@ -72,7 +73,8 @@ class SearchService {
 
   async initClient() {
     // reset error flag
-    this.isErrorOccured = false;
+    this.isErrorOccuredOnHealthcheck = false;
+    this.isErrorOccuredOnSearching = false;
 
     return this.delegator.initClient();
   }
@@ -82,8 +84,23 @@ class SearchService {
       return await this.delegator.getInfo();
     }
     catch (err) {
-      // switch error flag, `isReachable` to be `false`
-      this.isErrorOccured = true;
+      logger.error(err);
+      throw err;
+    }
+  }
+
+  async getInfoForHealth() {
+    try {
+      const result = await this.delegator.getInfoForHealth();
+
+      this.isErrorOccuredOnHealthcheck = false;
+      return result;
+    }
+    catch (err) {
+      logger.error(err);
+
+      // switch error flag, `isErrorOccuredOnHealthcheck` to be `false`
+      this.isErrorOccuredOnHealthcheck = true;
       throw err;
     }
   }
@@ -105,8 +122,10 @@ class SearchService {
       return await this.delegator.searchKeyword(keyword, user, userGroups, searchOpts);
     }
     catch (err) {
+      logger.error(err);
+
       // switch error flag, `isReachable` to be `false`
-      this.isErrorOccured = true;
+      this.isErrorOccuredOnSearching = true;
       throw err;
     }
   }

+ 1 - 1
src/server/views/layout-growi/page_list.html

@@ -42,7 +42,7 @@
 {% endblock %}
 
 
-{% block content_footer %}
+{% block content_main_after %}
   {% if page %}
     {% include '../widget/page_attachments.html' %}
   {% endif %}

+ 4 - 0
src/server/views/layout-growi/user_page.html

@@ -71,4 +71,8 @@
 
 {% block content_main_after %}
   {% include 'widget/comments.html' %}
+
+  {% if page %}
+    {% include '../widget/page_attachments.html' %}
+  {% endif %}
 {% endblock %}

+ 1 - 1
src/server/views/layout-kibela/page_list.html

@@ -43,7 +43,7 @@
 {% endblock %}
 
 
-{% block content_footer %}
+{% block content_main_after %}
   {% if page %}
     {% include '../widget/page_attachments.html' %}
   {% endif%}

+ 4 - 0
src/server/views/layout-kibela/user_page.html

@@ -64,4 +64,8 @@
 
 {% block content_main_after %}
   {% include 'widget/comments.html' %}
+
+  {% if page %}
+    {% include '../widget/page_attachments.html' %}
+  {% endif %}
 {% endblock %}

+ 47 - 0
src/server/views/modal/empty_trash.html

@@ -0,0 +1,47 @@
+<div class="modal" id="emptyTrash">
+  <div class="modal-dialog">
+    <div class="modal-content">
+
+    <form role="form" id="empty-trash-form" onsubmit="return false;">
+
+      <div class="modal-header bg-danger">
+        <button type="button" class="close" data-dismiss="modal" aria-hidden="true">&times;</button>
+        <div class="modal-title">
+          <i class="icon-fw icon-trash"></i>  {{ t('modal_empty.empty_the_trash') }}
+        </div>
+      </div>
+      <div class="modal-body">
+        <ul>
+          {% for data in pages %}
+            {% if pagePropertyName %}
+              {% set page = data[pagePropertyName] %}
+            {% else %}
+              {% set page = data %}
+            {% endif %}
+            <li>
+              <img src="{{ page.lastUpdateUser|picture }}" class="picture img-circle">
+              <a href="{{ page.path }}"
+                class="page-list-link"
+                data-path="{{ page.path }}">{{ decodeURIComponent(page.path) }}
+              </a>
+            </li>
+          {% endfor %}
+        </ul>
+      </div>
+      <div class="modal-footer">
+        <div class="d-flex justify-content-end">
+          {% include '../widget/modal/page-api-error-messages.html' %}
+          <div>
+            <input type="hidden" name="_csrf" value="{{ csrf() }}">
+            <button type="submit" class="m-l-10 btn btn-danger delete-button">
+              <i class="icon-trash" aria-hidden="true"></i>
+              Empty
+            </button>
+          </div>
+        </div>
+      </div><!-- /.modal-footer -->
+
+    </form>
+    </div><!-- /.modal-content -->
+  </div><!-- /.modal-dialog -->
+</div><!-- /.modal -->

+ 2 - 0
src/server/views/modal/rename.html

@@ -27,6 +27,7 @@
 
           <hr>
 
+          {% if page.grant != 2 %}
           <div class="checkbox checkbox-warning">
             <input name="recursively" id="cbRenameRecursively" value="1" type="checkbox" checked>
             <label for="cbRenameRecursively">
@@ -34,6 +35,7 @@
               <p class="help-block mt-0">{{ t('modal_rename.help.recursive', page.path) }}</p>
             </label>
           </div>
+          {% endif %}
 
           <div class="checkbox checkbox-success">
             <input name="create_redirect" id="cbRenameRedirect" value="1" type="checkbox">

+ 7 - 2
src/server/views/widget/page_alerts.html

@@ -88,16 +88,21 @@
         <br>Deleted by <img src="{{ page.lastUpdateUser|picture }}" class="picture picture-sm img-circle"> {{ page.lastUpdateUser.name }} at {{ page.updatedAt|datetz('Y-m-d H:i:s') }}
         {% endif %}
       </div>
-      {% if page.isDeleted() and user %}
       <ul class="list-inline">
+        {% if user and user.admin and req.path == '/trash' and pages.length > 0 %}
+        <li>
+          <button href="#" class="btn btn-danger btn-rounded btn-sm" data-target="#emptyTrash" data-toggle="modal"><i class="icon-trash" aria-hidden="true"></i>{{ t('modal_empty.empty_the_trash') }}</button>
+        </li>
+        {% endif %}
+        {% if page.isDeleted() and user %}
         <li>
           <button href="#" class="btn btn-default btn-rounded btn-sm" data-target="#putBackPage" data-toggle="modal"><i class="icon-action-undo" aria-hidden="true"></i> {{ t('Put Back') }}</button>
         </li>
         <li>
             <button href="#" class="btn btn-danger btn-rounded btn-sm" {% if !user.canDeleteCompletely(page.creator._id) %} disabled="disabled" {% endif %} data-target="#deletePage" data-toggle="modal"><i class="icon-fire" aria-hidden="true"></i> {{ t('Delete Completely') }}</button>
         </li>
+        {% endif %}
       </ul>{# /.pull-right #}
-      {% endif %}
     </div>
     {% endif %}
   </div>

+ 1 - 1
src/server/views/widget/page_content.html

@@ -45,7 +45,7 @@
       </div>
     {% endif %}
 
-    {% if not page.isDeleted() %}
+    {% if !isTrashPage() %}
       {# edit form #}
       <div class="tab-pane" id="edit">
         <div id="page-editor">{% if pageForm.body %}{{ pageForm.body }}{% endif %}</div>

+ 1 - 0
src/server/views/widget/page_modals.html

@@ -1,4 +1,5 @@
 {% include '../modal/rename.html' %}
+{% include '../modal/empty_trash.html' %}
 {% include '../modal/delete.html' %}
 {% include '../modal/create_template.html' %}
 {% include '../modal/duplicate.html' %}

+ 14 - 14
yarn.lock

@@ -1277,10 +1277,10 @@
     "@types/yargs" "^15.0.0"
     chalk "^3.0.0"
 
-"@kaishuu0123/markdown-it-fence@0.1.4", "@kaishuu0123/markdown-it-fence@^0.1.4":
-  version "0.1.4"
-  resolved "https://registry.yarnpkg.com/@kaishuu0123/markdown-it-fence/-/markdown-it-fence-0.1.4.tgz#759cf0dd80cca23a08e70b9cbb33c999cb23f3c3"
-  integrity sha512-u00GhVLpTeIbeflMKCozzaCAEmuwGngryomtbsYoyRwdYLfrH2nJHZa41+gwKLXsrq7Ii3N+Rei5GHaqHT3j5A==
+"@kaishuu0123/markdown-it-fence@^0.2.0":
+  version "0.2.0"
+  resolved "https://registry.yarnpkg.com/@kaishuu0123/markdown-it-fence/-/markdown-it-fence-0.2.0.tgz#f46722bfce4ab7eb3e051def5090dcae1bd6e36b"
+  integrity sha512-mdqKA+bXfJPl7gAg9tis8fGlea2oppBM068YbMDSXKWM6H18nVSZLrVKPHXpPWBgSv1ceeKkoWj8K1ntpIHlrw==
 
 "@lykmapipo/common@>=0.21.0":
   version "0.21.0"
@@ -8338,23 +8338,23 @@ markdown-escapes@^1.0.0:
   resolved "https://registry.yarnpkg.com/markdown-escapes/-/markdown-escapes-1.0.3.tgz#6155e10416efaafab665d466ce598216375195f5"
   integrity sha512-XUi5HJhhV5R74k8/0H2oCbCiYf/u4cO/rX8tnGkRvrqhsr5BRNU6Mg0yt/8UIx1iIS8220BNJsDb7XnILhLepw==
 
-markdown-it-blockdiag@^1.0.3:
-  version "1.0.3"
-  resolved "https://registry.yarnpkg.com/markdown-it-blockdiag/-/markdown-it-blockdiag-1.0.3.tgz#7c2be967d21a17f559da5860b6179b34f29192a3"
-  integrity sha512-2y4C5L6V3twWhDdQLl3zzYvBiumKcS7D1CD8/OTN2Y57G1gE2ot86vTPrfljaf2Q8q6J1UgcG40zTjDGcHgHrg==
+markdown-it-blockdiag@^1.1.1:
+  version "1.1.1"
+  resolved "https://registry.yarnpkg.com/markdown-it-blockdiag/-/markdown-it-blockdiag-1.1.1.tgz#f89d9a56c4ef693f4cec88fc62655fce23d15115"
+  integrity sha512-e8IvRZE7hS0eQqNMyJ8l3EI89swTUVvIkQ8MlmLA0DacumMauHCAfKgzxcZ2UMSj2wQPCmUUM9L9iAOAFcAByw==
   dependencies:
-    "@kaishuu0123/markdown-it-fence" "0.1.4"
+    "@kaishuu0123/markdown-it-fence" "^0.2.0"
     pako "^1.0.6"
     paths "^0.1.1"
     url-join "^4.0.0"
     utf8-bytes "0.0.1"
 
-markdown-it-drawio-viewer@^1.1.3:
-  version "1.1.3"
-  resolved "https://registry.yarnpkg.com/markdown-it-drawio-viewer/-/markdown-it-drawio-viewer-1.1.3.tgz#e5f7fa9a1d200a8711e2aeb691551add413ffe33"
-  integrity sha512-FELyH+ko9sOAC6hHQqBYi4tI1Qv8HTzVPXsYuwTFy0NF5gY1A9oriN051rHn4UtyAAXEUIMquNNYYkxC9bhMVw==
+markdown-it-drawio-viewer@^1.2.0:
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/markdown-it-drawio-viewer/-/markdown-it-drawio-viewer-1.2.0.tgz#d47648c039f12e4c5ca706ed4d0f5dc19400c9a2"
+  integrity sha512-Hu9jxqKLVfFhk2T8J4ayaVbuoW2RSugRrXIsREMW7MMWFDciBgs9C8ADKaTav7JITY5fp7q6KJU7pqP/5dMRnA==
   dependencies:
-    "@kaishuu0123/markdown-it-fence" "^0.1.4"
+    "@kaishuu0123/markdown-it-fence" "^0.2.0"
     xmldoc "^1.1.2"
 
 markdown-it-emoji@^1.4.0: