Browse Source

Merge remote-tracking branch 'origin/master' into dev/4.0.x

# Conflicts:
#	CHANGES.md
#	package.json
#	src/client/js/components/Admin/ElasticsearchManagement/StatusTable.jsx
#	src/client/js/legacy/crowi.js
#	src/server/routes/apiv3/index.js
#	src/server/views/widget/page_alerts.html
Yuki Takei 6 years ago
parent
commit
058c77b5d1

+ 27 - 2
CHANGES.md

@@ -5,17 +5,34 @@
 * Support: Upgrade libs
     * bootstrap
 
-## v3.7.6
+## 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 shouldn't be editable
+* Fix: Trash page is no longer editable
 * Fix: Rendering Timeline on /trash
 
 ## v3.7.4
@@ -54,6 +71,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\.7\`, .\+\]\)\(.\+\/blob\/v\).\+\(\/docker\/Dockerfile.\+\)$/\1${RELEASE_VERSION}\2\3${RELEASE_VERSION}\4/" README.md
-sed -i -e "s/^\([*] \[\`\)[^\`]\+\(\`, \`3\.7-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 - 4
docker/README.md

@@ -10,10 +10,10 @@ GROWI Official docker image
 Supported tags and respective Dockerfile links
 ------------------------------------------------
 
-* [`3.7.0`, `3.7`, `3`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v3.7.0/docker/Dockerfile)
-* [`3.7.0-nocdn`, `3.7-nocdn`, `3-nocdn`, `latest-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v3.7.0/docker/Dockerfile)
-* [`3.6.10`, `3.6` (Dockerfile)](https://github.com/weseek/growi/blob/v3.6.10/docker/Dockerfile)
-* [`3.6.10-nocdn`, `3.6-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v3.6.10/docker/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?

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

@@ -284,6 +284,9 @@
     "recursively": "Delete children of under <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",
@@ -630,6 +633,7 @@
     "connection_status_label_unconfigured": "UNCONFIGURED",
     "connection_status_label_connected": "CONNECTED",
     "connection_status_label_disconnected": "DISCONNECTED",
+    "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",

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

@@ -282,6 +282,9 @@
     "recursively": "<code>%s</code> 配下のページも削除します",
     "completely": "ゴミ箱を経由せず、完全に削除します"
   },
+  "modal_empty":{
+    "empty_the_trash": "ゴミ箱を空にする"
+  },
   "modal_duplicate": {
     "label": {
       "Duplicate page": "ページを複製する",
@@ -619,6 +622,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": "リビルド中 または 破損しています",

+ 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-md-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-md-3 col-form-label text-left text-md-right">{ t('full_text_search_management.reconnect') }</label>
           <div className="col-md-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,
 };
 

+ 50 - 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="badge badge-pill badge-default">――</span>;
+  }
+
+  renderConnectionStatusLabels() {
+    const { t } = this.props;
+    const {
+      isErrorOccuredOnSearchService,
+      isConnected, isConfigured,
+    } = this.props;
+
+    const errorOccuredLabel = isErrorOccuredOnSearchService
+      ? <span className="badge badge-pill badge-danger ml-2">{ t('full_text_search_management.connection_status_label_erroroccured') }</span>
+      : null;
+
+    let connectionStatusLabel = null;
+    if (!isConfigured) {
+      connectionStatusLabel = <span className="badge badge-pill badge-default">{ t('full_text_search_management.connection_status_label_unconfigured') }</span>;
+    }
+    else {
+      connectionStatusLabel = isConnected
+        ? <span className="badge badge-pill badge-success">{ t('full_text_search_management.connection_status_label_connected') }</span>
+        : <span className="badge badge-pill badge-danger">{ t('full_text_search_management.connection_status_label_disconnected') }</span>;
+    }
+
+    return (
+      <>
+        {connectionStatusLabel}{errorOccuredLabel}
+      </>
+    );
+  }
+
+  renderIndicesStatusLabel() {
+    const { t, isNormalized } = this.props;
+
+    return isNormalized
+      ? <span className="badge badge-pill badge-info">{ t('full_text_search_management.indices_status_label_normalized') }</span>
+      : <span className="badge badge-pill badge-warning">{ t('full_text_search_management.indices_status_label_unnormalized') }</span>;
+  }
+
   renderIndexInfoPanel(indexName, body = {}, aliases = []) {
     const collapseId = `collapse-${indexName}`;
 
@@ -94,40 +134,24 @@ class StatusTable extends React.PureComponent {
 
   render() {
     const { t } = this.props;
-    const { isConfigured, isConnected, isNormalized } = this.props;
-
-
-    let connectionStatusLabel = <span className="badge badge-pill badge-secondary">――</span>;
-    if (isConfigured != null && !isConfigured) {
-      connectionStatusLabel = <span className="badge badge-pill badge-secondary">{t('full_text_search_management.connection_status_label_unconfigured')}</span>;
-    }
-    else if (isConnected != null) {
-      connectionStatusLabel = isConnected
-        ? <span className="badge badge-pill badge-success">{ t('full_text_search_management.connection_status_label_connected') }</span>
-        : <span className="badge badge-pill badge-danger">{ t('full_text_search_management.connection_status_label_disconnected') }</span>;
-    }
-
-    let indicesStatusLabel = <span className="badge badge-pill badge-secondary">――</span>;
-    if (isNormalized != null) {
-      indicesStatusLabel = isNormalized
-        ? <span className="badge badge-pill badge-info">{ t('full_text_search_management.indices_status_label_normalized') }</span>
-        : <span className="badge badge-pill badge-warning">{ t('full_text_search_management.indices_status_label_unnormalized') }</span>;
-    }
+    const {
+      isInitialized,
+    } = this.props;
 
     return (
       <table className="table table-bordered">
         <tbody>
           <tr>
             <th className="w-25">{t('full_text_search_management.connection_status')}</th>
-            <td className="w-75">{connectionStatusLabel}</td>
+            <td className="w-75">{ isInitialized ? this.renderConnectionStatusLabels() : this.renderPreInitializedLabel() }</td>
           </tr>
           <tr>
             <th className="w-25">{t('full_text_search_management.indices_status')}</th>
-            <td className="w-75">{indicesStatusLabel}</td>
+            <td className="w-75">{ isInitialized ? this.renderIndicesStatusLabel() : this.renderPreInitializedLabel() }</td>
           </tr>
           <tr>
             <th className="w-25">{t('full_text_search_management.indices_summary')}</th>
-            <td className="p-4 w-75">{this.renderIndexInfoPanels()}</td>
+            <td className="p-4 w-75">{ isInitialized && this.renderIndexInfoPanels() }</td>
           </tr>
         </tbody>
       </table>
@@ -146,8 +170,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,

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

@@ -336,6 +336,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();

+ 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));

+ 1 - 3
src/server/routes/apiv3/index.js

@@ -37,9 +37,7 @@ module.exports = (crowi) => {
 
   router.use('/search', require('./search')(crowi));
 
-  router.use('/page', require('./page')(crowi));
-
-  router.use('/bookmarks', require('./bookmarks')(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;
+};

+ 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;
     }
   }

+ 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">
+        <div class="modal-title">
+          <i class="icon-fw icon-trash"></i>  {{ t('modal_empty.empty_the_trash') }}
+        </div>
+        <button type="button" class="close" data-dismiss="modal" aria-hidden="true">&times;</button>
+      </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 -->

+ 5 - 1
src/server/views/widget/page_alerts.html

@@ -88,11 +88,15 @@
         <br>Deleted by <img src="{{ page.lastUpdateUser|picture }}" class="picture picture-sm rounded-circle"> {{ page.lastUpdateUser.name }} at {{ page.updatedAt|datetz('Y-m-d H:i:s') }}
         {% endif %}
       </div>
+      {% if user and user.admin and req.path == '/trash' and pages.length > 0 %}
+      <div>
+        <button href="#" class="btn btn-danger rounded-pill btn-sm" data-target="#emptyTrash" data-toggle="modal"><i class="icon-trash" aria-hidden="true"></i>{{ t('modal_empty.empty_the_trash') }}</button>
+      </div>
+      {% endif %}
       {% if page.isDeleted() and user %}
       <div>
         <button href="#" class="btn btn-outline-secondary rounded-pill btn-sm mr-2" data-target="#putBackPage" data-toggle="modal"><i class="icon-action-undo" aria-hidden="true"></i> {{ t('Put Back') }}</button>
         <button href="#" class="btn btn-danger rounded-pill btn-sm mr-2" {% 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>
-      {# /.float-right #}
       </div>
       {% 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' %}