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

Merge branch 'master' into imprv/apply-unstated

Yuki Takei 6 лет назад
Родитель
Сommit
13f0c413f3

+ 11 - 1
CHANGES.md

@@ -1,9 +1,19 @@
 # CHANGES
 
-## 3.4.7-RC
+## 3.4.8-RC
 
+* Support: Upgrade libs
+    * mini-css-extract-plugin
+    * null-loader
+
+## 3.4.7
+
+* Improvement: Handle private pages on group deletion
 * Fix: Searching with `tag:xxx` syntax doesn't work
+* Fix: Check CSRF when updating user data
+* Fix: `createdAt` field initialization
 * I18n: Import data page
+* I18n: Group Management page
 
 ## 3.4.6
 

+ 3 - 3
package.json

@@ -1,6 +1,6 @@
 {
   "name": "growi",
-  "version": "3.4.7-RC",
+  "version": "3.4.8-RC",
   "description": "Team collaboration software using markdown",
   "tags": [
     "wiki",
@@ -179,14 +179,14 @@
     "markdown-it-toc-and-anchor-with-slugid": "^1.1.4",
     "markdown-table": "^1.1.1",
     "metismenu": "^3.0.3",
-    "mini-css-extract-plugin": "^0.6.0",
+    "mini-css-extract-plugin": "^0.7.0",
     "mocha": "^6.0.1",
     "morgan": "^1.9.0",
     "node-dev": "^4.0.0",
     "node-sass": "^4.11.0",
     "nodelist-foreach-polyfill": "^1.2.0",
     "normalize-path": "^3.0.0",
-    "null-loader": "^1.0.0",
+    "null-loader": "^2.0.0",
     "on-headers": "^1.0.1",
     "optimize-css-assets-webpack-plugin": "^5.0.0",
     "penpal": "^4.0.0",

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

@@ -51,8 +51,6 @@
   "Share Link": "Share Link",
   "Markdown Link": "Markdown Link",
 
-  "Management Wiki": "Management Wiki",
-
   "Create/Edit Template": "Create/Edit Template Page",
 
   "Unportalize": "Unportalize",
@@ -98,6 +96,22 @@
 
   "Table of Contents": "Table of Contents",
 
+  "Management Wiki Home": "Management Wiki Home",
+  "App Settings": "App Settings",
+  "Site URL settings": "Site URL settings",
+  "Markdown Settings": "Markdown Settings",
+  "Customize": "Customize",
+  "Notification Settings": "Notification Settings",
+  "User Management": "User Management",
+  "External Account management": "External Account management",
+  "UserGroup Management": "UserGroup Management",
+  "Full Text Search Management": "Full Text Search Management",
+  "Import Data": "Import Data",
+  "Basic settings": "Basic settings",
+  "Basic authentication": "Basic authentication",
+  "Guest users access": "Guest users access",
+  "Register limitation": "Register limitation",
+  "The contents entered here will be shown in the header etc": "The contents entered here will be shown in the header etc",
   "Public": "Public",
   "Anyone with the link": "Anyone with the link",
   "Specified users only": "Specified users only",
@@ -105,6 +119,11 @@
   "Only inside the group": "Only inside the group",
   "Reselect the group": "Reselect the group",
   "Shareable link": "Shareable link",
+  "The whitelist of registration permission E-mail address": "The whitelist of registration permission E-mail address",
+  "Selecting authentication mechanism": "Selecting authentication mechanism",
+  "Add tags for this page": "Add tags for this page",
+  "Edit tags for this page": "Edit tags for this page",
+  "You have no tag, You can set tags on pages": "You have no tag, You can set tags on pages",
 
   "Show latest": "Show latest",
   "Load latest": "Load latest",
@@ -172,13 +191,13 @@
   "Re-enter new password": "Re-enter new password",
   "Password is not set": "Password is not set",
 
+  "Security Settings": "Security Settings",
+
   "API Settings": "API Settings",
   "API Token Settings": "API Token Settings",
   "Current API Token": "Current API Token",
   "Update API Token": "Update API Token",
 
-  "Security settings": "Security settings",
-
   "header_search_box": {
     "label": {
       "This tree": "This tree"
@@ -188,6 +207,14 @@
     }
   },
 
+  "copy_to_clipboard": {
+    "Copy to clipboard": "Copy to clipboard",
+    "Page path": "Page path",
+    "Parmanent link": "Parmanent link",
+    "Page path and parmanent link": "Page path and parmanent link",
+    "Markdown link": "Markdown link"
+  },
+
   "search_help": {
     "title": "Searching Help",
     "and": {
@@ -206,6 +233,12 @@
     },
     "exclude_prefix": {
       "desc": "Exclude the pages that the title start with {{path}}"
+    },
+    "tag": {
+      "desc": "Search for pages with {{tag}} tag"
+    },
+    "exclude_tag": {
+      "desc": "Exclude pages with {{tag}} tag"
     }
   },
   "search": {
@@ -401,6 +434,7 @@
     "Disable": "Disable",
     "Use env var if empty": "If the value in the database is empty, the value of the environment variable <code>%s</code> is used."
   },
+
   "security_setting": {
 		"Basic authentication": "Basic authentication",
 		"Security settings": "Security settings",
@@ -670,6 +704,25 @@
     "current users": "Current users:"
   },
 
+  "user_group_management": {
+    "group_list": "List of Group",
+    "create_group": "Create New Group",
+    "group_example": "e.g. : Group1",
+    "created_group": "Group was created",
+    "add_user": "Add a user to the created group",
+    "deny_create_group": "You can't create a new group with the current settings",
+    "is_loading_data": "Loading data...",
+    "choose_action": "Choose an action for private pages",
+    "delete_group": "Delete Group",
+    "group_name": "Group Name",
+    "group_and_pages_not_retrievable": "Once deleted, the deleted group and its private pages cannot be retrieved",
+    "publish_pages": "Publish All",
+    "delete_pages": "Delete All",
+    "transfer_pages": "Transfer to another group",
+    "select_group": "Select a group",
+    "no_groups": "No groups to select"
+  },
+
   "importer_management": {
     "import_from": "Import from %s",
     "esa_settings": {

+ 40 - 16
resource/locales/ja/translation.json

@@ -95,16 +95,17 @@
   "Create under": "ページを以下に作成",
 
   "Table of Contents": "目次",
+
   "Management Wiki Home": "Wiki管理トップ",
-  "App settings": "アプリ設定",
+  "App Settings": "アプリ設定",
   "Site URL settings": "サイトURL設定",
-  "Markdown settings": "マークダウン設定",
+  "Markdown Settings": "マークダウン設定",
   "Customize": "カスタマイズ",
-  "Notification settings": "通知設定",
-  "User management": "ユーザー管理",
+  "Notification Settings": "通知設定",
+  "User Management": "ユーザー管理",
   "External Account management": "外部アカウント管理",
-  "UserGroup management": "グループ管理",
-  "Full Text Search management": "全文検索管理",
+  "UserGroup Management": "グループ管理",
+  "Full Text Search Management": "全文検索管理",
   "Import Data": "データインポート",
   "Basic settings": "基本設定",
   "Basic authentication": "Basic認証",
@@ -124,7 +125,6 @@
   "Edit tags for this page": "タグを編集する",
   "You have no tag, You can set tags on pages": "使用中のタグがありません",
 
-
   "Show latest": "最新のページを表示",
   "Load latest": "最新版を読み込む",
   "edited this page": "さんがこのページを編集しました。",
@@ -191,7 +191,7 @@
   "Re-enter new password": "(確認用)",
   "Password is not set": "パスワードが設定されていません",
 
-  "Security settings": "セキュリティ設定",
+  "Security Settings": "セキュリティ設定",
 
   "API Settings": "API設定",
   "API Token Settings": "API Token設定",
@@ -318,17 +318,20 @@
 
   "modal_shortcuts": {
     "global": {
-        "Open/Close shortcut help": "ショートカットヘルプの表示/非表示",
-        "Edit Page": "ページ編集",
-        "Create Page": "ページ作成"
+      "title": "グローバルショートカット",
+      "Open/Close shortcut help": "ショートカットヘルプの表示/非表示",
+      "Edit Page": "ページ編集",
+      "Create Page": "ページ作成"
     },
     "editor": {
-        "Indent": "インデント",
-        "Outdent": "左インデント",
-        "Save Page": "保存",
-        "Delete Line": "行削除"
+      "titile": "エディターショートカット",
+      "Indent": "インデント",
+      "Outdent": "左インデント",
+      "Save Page": "保存",
+      "Delete Line": "行削除"
     },
     "commentform": {
+      "title": "コメントフォームショートカット",
       "Post": "投稿"
     }
   },
@@ -467,6 +470,7 @@
     "facebook_auth2": "Facebook OAuth 認証",
     "twitter_auth2": "Twitter OAuth 認証",
     "github_auth2": "GitHub OAuth 認証",
+    "crowi_auth": "Crowi Classic OAuth 認証",
     "require_server_restart": "サーバーを再起動してください。",
     "server_on_passport_auth": "Passport 認証機構でサーバーが稼働しています。",
     "server_on_crowi_auth": "Crowi Classic 認証機構でサーバーが稼働しています。",
@@ -585,6 +589,7 @@
       "security:passport-saml:attrMapLastName": "名"
     }
   },
+
   "markdown_setting": {
     "line_break_setting": "Line Break設定",
     "line_break_setting_desc": "Line Breakの設定を変更できます。",
@@ -667,7 +672,7 @@
   },
 
   "user_management": {
-    "User management": "ユーザー管理",
+    "User Management": "ユーザー管理",
     "invite_users": "新規ユーザーの招待",
     "emails": "メールアドレス (複数行入力で複数人招待可能)",
     "invite_thru_email": "招待をメールで送信",
@@ -699,6 +704,25 @@
     "current users": "現在のユーザー数:"
   },
 
+  "user_group_management": {
+    "group_list": "グループ一覧",
+    "create_group": "新規グループの作成",
+    "group_example": "例: Group1",
+    "created_group": "グループを作成しました",
+    "add_user": "グループへのユーザー追加",
+    "deny_create_group": "現在の設定では新規グループの作成はできません。",
+    "is_loading_data": "データを取得中です...",
+    "choose_action": "削除するグループの限定公開ページの処理を選択してください",
+    "delete_group": "グループの削除",
+    "group_name": "グループ名",
+    "group_and_pages_not_retrievable": "グループ及び限定公開のページの削除を行うと元に戻すことはできませんのでご注意ください。",
+    "publish_pages": "全て公開する",
+    "delete_pages": "全て削除する",
+    "transfer_pages": "全て他のグループに移譲する",
+    "select_group": "グループを選択してください",
+    "no_groups": "グループがありません"
+  },
+
   "importer_management": {
     "import_from": "%s からインポート",
     "esa_settings": {

+ 12 - 0
src/client/js/app.js

@@ -45,6 +45,7 @@ import CustomCssEditor from './components/Admin/CustomCssEditor';
 import CustomScriptEditor from './components/Admin/CustomScriptEditor';
 import CustomHeaderEditor from './components/Admin/CustomHeaderEditor';
 import AdminRebuildSearch from './components/Admin/AdminRebuildSearch';
+import GroupDeleteModal from './components/GroupDeleteModal/GroupDeleteModal';
 
 import PageContainer from './services/PageContainer';
 import CommentContainer from './components/PageComment/CommentContainer';
@@ -595,6 +596,17 @@ if (adminRebuildSearchElem != null) {
     adminRebuildSearchElem,
   );
 }
+const adminGrantSelectorElem = document.getElementById('admin-delete-user-group-modal');
+if (adminGrantSelectorElem != null) {
+  ReactDOM.render(
+    <I18nextProvider i18n={i18n}>
+      <GroupDeleteModal
+        crowi={crowi}
+      />
+    </I18nextProvider>,
+    adminGrantSelectorElem,
+  );
+}
 
 // notification from websocket
 function updatePageStatusAlert(page, user) {

+ 260 - 0
src/client/js/components/GroupDeleteModal/GroupDeleteModal.jsx

@@ -0,0 +1,260 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+
+import * as toastr from 'toastr';
+
+/**
+ * Delete User Group Select component
+ *
+ * @export
+ * @class GrantSelector
+ * @extends {React.Component}
+ */
+class GroupDeleteModal extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    const { t } = this.props;
+
+    // actionName master constants
+    this.actionForPages = {
+      public: 'public',
+      delete: 'delete',
+      transfer: 'transfer',
+    };
+
+    this.availableOptions = [
+      {
+        id: 1, actionForPages: this.actionForPages.public, iconClass: 'icon-people', styleClass: '', label: t('user_group_management.publish_pages'),
+      },
+      {
+        id: 2, actionForPages: this.actionForPages.delete, iconClass: 'icon-trash', styleClass: 'text-danger', label: t('user_group_management.delete_pages'),
+      },
+      {
+        id: 3, actionForPages: this.actionForPages.transfer, iconClass: 'icon-options', styleClass: '', label: t('user_group_management.transfer_pages'),
+      },
+    ];
+
+    this.initialState = {
+      deleteGroupId: '',
+      deleteGroupName: '',
+      groups: [],
+      actionName: '',
+      selectedGroupId: '',
+      isFetching: false,
+    };
+
+    this.state = this.initialState;
+
+    // logger
+    this.logger = require('@alias/logger')('growi:GroupDeleteModal:GroupDeleteModal');
+
+    // retrieve xss library from window
+    this.xss = window.xss;
+
+    this.getGroupName = this.getGroupName.bind(this);
+    this.changeActionHandler = this.changeActionHandler.bind(this);
+    this.changeGroupHandler = this.changeGroupHandler.bind(this);
+    this.renderPageActionSelector = this.renderPageActionSelector.bind(this);
+    this.renderGroupSelector = this.renderGroupSelector.bind(this);
+    this.validateForm = this.validateForm.bind(this);
+  }
+
+  componentDidMount() {
+    // bootstrap and this jQuery opens/hides the modal.
+    // let React handle it in the future.
+    $('#admin-delete-user-group-modal').on('show.bs.modal', async(button) => {
+      this.setState({ isFetching: true });
+
+      const groups = await this.fetchAllGroups();
+
+      const data = $(button.relatedTarget);
+      const deleteGroupId = data.data('user-group-id');
+      const deleteGroupName = data.data('user-group-name');
+
+      this.setState({
+        groups,
+        deleteGroupId,
+        deleteGroupName,
+        isFetching: false,
+      });
+    });
+
+    $('#admin-delete-user-group-modal').on('hide.bs.modal', (button) => {
+      this.setState(this.initialState);
+    });
+  }
+
+  getGroupName(group) {
+    return this.xss.process(group.name);
+  }
+
+  async fetchAllGroups() {
+    let groups = [];
+
+    try {
+      const res = await this.props.crowi.apiGet('/admin/user-groups');
+      if (res.ok) {
+        groups = res.userGroups;
+      }
+      else {
+        throw new Error('Unable to fetch groups from server');
+      }
+    }
+    catch (err) {
+      this.handleError(err);
+    }
+
+    return groups;
+  }
+
+  handleError(err) {
+    this.logger.error(err);
+    toastr.error(err, 'Error occured', {
+      closeButton: true,
+      progressBar: true,
+      newestOnTop: false,
+      showDuration: '100',
+      hideDuration: '100',
+      timeOut: '3000',
+    });
+  }
+
+  changeActionHandler(e) {
+    const actionName = e.target.value;
+    this.setState({ actionName });
+  }
+
+  changeGroupHandler(e) {
+    const selectedGroupId = e.target.value;
+    this.setState({ selectedGroupId });
+  }
+
+  renderPageActionSelector() {
+    const { t } = this.props;
+
+    const optoins = this.availableOptions.map((opt) => {
+      const dataContent = `<i class="icon icon-fw ${opt.iconClass} ${opt.styleClass}"></i> <span class="action-name ${opt.styleClass}">${t(opt.label)}</span>`;
+      return <option key={opt.id} value={opt.actionForPages} data-content={dataContent}>{t(opt.label)}</option>;
+    });
+
+    return (
+      <select
+        name="actionName"
+        className="form-control"
+        placeholder="select"
+        value={this.state.actionName}
+        onChange={this.changeActionHandler}
+      >
+        <option value="" disabled>{t('user_group_management.choose_action')}</option>
+        {optoins}
+      </select>
+    );
+  }
+
+  renderGroupSelector() {
+    const { t } = this.props;
+
+    const groups = this.state.groups.filter((group) => {
+      return group._id !== this.state.deleteGroupId;
+    });
+
+    const options = groups.map((group) => {
+      const dataContent = `<i class="icon icon-fw icon-organization"></i> ${this.getGroupName(group)}`;
+      return <option key={group._id} value={group._id} data-content={dataContent}>{this.getGroupName(group)}</option>;
+    });
+
+    const defaultOptionText = groups.length === 0 ? t('user_group_management.no_groups') : t('user_group_management.select_group');
+
+    return (
+      <select
+        name="selectedGroupId"
+        className={`form-control ${this.state.actionName === this.actionForPages.transfer ? '' : 'd-none'}`}
+        value={this.state.selectedGroupId}
+        onChange={this.changeGroupHandler}
+      >
+        <option value="" disabled>{defaultOptionText}</option>
+        {options}
+      </select>
+    );
+  }
+
+  validateForm() {
+    let isValid = true;
+
+    if (this.state.actionName === '') {
+      isValid = false;
+    }
+    else if (this.state.actionName === this.actionForPages.transfer) {
+      isValid = this.state.selectedGroupId !== '';
+    }
+
+    return isValid;
+  }
+
+  render() {
+    const { t } = this.props;
+
+    return (
+      <div className="modal-dialog">
+        <div className="modal-content">
+          <div className="modal-header bg-danger">
+            <button type="button" className="close" data-dismiss="modal" aria-hidden="true">&times;</button>
+            <div className="modal-title">
+              <i className="icon icon-fire"></i> {t('user_group_management.delete_group')}
+            </div>
+          </div>
+
+          <div className="modal-body">
+            <div>
+              <span className="font-weight-bold">{t('user_group_management.group_name')}</span> : &quot;{this.state.deleteGroupName}&quot;
+            </div>
+            {this.state.isFetching
+              ? (
+                <div className="mt-5">
+                  {t('user_group_management.is_loading_data')}
+                </div>
+              )
+              : (
+                <div className="text-danger mt-5">
+                  {t('user_group_management.group_and_pages_not_retrievable')}
+                </div>
+              )
+            }
+          </div>
+
+          {this.state.isFetching
+            ? (
+              null
+            )
+            : (
+              <div className="modal-footer">
+                <form action="/admin/user-group.remove" method="post" id="admin-user-groups-delete" className="d-flex justify-content-between">
+                  <div className="d-flex">
+                    {this.renderPageActionSelector()}
+                    {this.renderGroupSelector()}
+                  </div>
+                  <input type="hidden" id="deleteGroupId" name="deleteGroupId" value={this.state.deleteGroupId} onChange={() => {}} />
+                  <input type="hidden" name="_csrf" defaultValue={this.props.crowi.csrfToken} />
+                  <button type="submit" value="" className="btn btn-sm btn-danger" disabled={!this.validateForm()}>
+                    <i className="icon icon-fire"></i> {t('Delete')}
+                  </button>
+                </form>
+              </div>
+            )
+          }
+        </div>
+      </div>
+    );
+  }
+
+}
+
+GroupDeleteModal.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  crowi: PropTypes.object.isRequired,
+};
+
+export default withTranslation()(GroupDeleteModal);

+ 11 - 9
src/client/js/components/PageList/Draft.jsx

@@ -73,21 +73,23 @@ class Draft extends React.Component {
   }
 
   renderAccordionTitle(isExist) {
+    const iconClass = this.state.isOpen ? 'caret-opened' : '';
+
     if (isExist) {
       return (
         <Fragment>
-          <span>{this.props.path}</span>
-          <span className="mx-2">({this.props.t('page exists')})</span>
+          <i className={`caret ${iconClass}`}></i>
+          <span className="mx-2">{this.props.path}</span>
+          <span>({this.props.t('page exists')})</span>
         </Fragment>
       );
     }
 
     return (
       <Fragment>
-        <a href={`${this.props.path}#edit`} target="_blank" rel="noopener noreferrer">{this.props.path}</a>
-        <span className="mx-2">
-          <span className="label-draft label label-default">draft</span>
-        </span>
+        <i className={`caret ${iconClass}`}></i>
+        <a className="mx-2" href={`${this.props.path}#edit`} target="_blank" rel="noopener noreferrer">{this.props.path}</a>
+        <span className="label-draft label label-default">draft</span>
       </Fragment>
     );
   }
@@ -97,13 +99,13 @@ class Draft extends React.Component {
     const id = this.props.path.replace('/', '-');
 
     return (
-      <div className="timeline-body">
-        <div className="panel panel-timeline">
+      <div className="draft-list-item">
+        <div className="panel">
           <div className="panel-heading d-flex justify-content-between">
             <div className="panel-title" onClick={this.toggleContent} data-target={`#${id}`}>
               {this.renderAccordionTitle(this.props.isExist)}
             </div>
-            <div>
+            <div className="icon-container">
               {this.props.isExist
                 ? null
                 : (

+ 0 - 9
src/client/js/legacy/crowi-admin.js

@@ -61,15 +61,6 @@ $(() => {
     return false;
   });
 
-  $('#admin-delete-user-group-modal').on('show.bs.modal', (button) => {
-    const data = $(button.relatedTarget);
-    const userGroupId = data.data('user-group-id');
-    const userGroupName = data.data('user-group-name');
-
-    $('#admin-delete-user-group-name').text(userGroupName);
-    $('#admin-user-groups-delete input[name=user_group_id]').val(userGroupId);
-  });
-
   $('form#user-group-relation-create').on('submit', function(e) {
     $.post('/admin/user-group-relation/create', $(this).serialize(), (res) => {
       $('#admin-add-user-group-relation-modal').modal('hide');

+ 32 - 0
src/client/styles/scss/_draft.scss

@@ -0,0 +1,32 @@
+.draft-list-item {
+  .panel {
+    border: 1px solid #ccc;
+
+    .panel-heading {
+      background-color: #ccc;
+
+      .caret {
+        transition: 0.4s;
+        transform: rotate(-90deg);
+
+        &.caret-opened {
+          transform: rotate(0deg);
+        }
+      }
+
+      .icon-container {
+        i {
+          opacity: 0;
+        }
+      }
+
+      &:hover {
+        .icon-container {
+          i {
+            opacity: 1;
+          }
+        }
+      }
+    }
+  }
+}

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

@@ -40,6 +40,7 @@
 @import 'handsontable';
 @import 'wiki';
 @import 'tag';
+@import 'draft';
 
 /*
  * for Guest User Mode

+ 1 - 1
src/server/form/admin/userGroupCreate.js

@@ -3,5 +3,5 @@ const form = require('express-form');
 const field = form.field;
 
 module.exports = form(
-  field('createGroupForm[userGroupName]', '新規グループ名').trim().required(),
+  field('createGroupForm[userGroupName]', 'Group name').trim().required(),
 );

+ 1 - 1
src/server/models/attachment.js

@@ -28,7 +28,7 @@ module.exports = function(crowi) {
     originalName: { type: String },
     fileFormat: { type: String, required: true },
     fileSize: { type: Number, default: 0 },
-    createdAt: { type: Date, default: Date.now() },
+    createdAt: { type: Date, default: Date.now },
   });
 
   attachmentSchema.virtual('filePathProxied').get(function() {

+ 1 - 1
src/server/models/bookmark.js

@@ -13,7 +13,7 @@ module.exports = function(crowi) {
   bookmarkSchema = new mongoose.Schema({
     page: { type: ObjectId, ref: 'Page', index: true },
     user: { type: ObjectId, ref: 'User', index: true },
-    createdAt: { type: Date, default: Date.now() },
+    createdAt: { type: Date, default: Date.now },
   });
   bookmarkSchema.index({ page: 1, user: 1 }, { unique: true });
 

+ 48 - 2
src/server/models/page.js

@@ -57,8 +57,8 @@ const pageSchema = new mongoose.Schema({
   pageIdOnHackmd: String,
   revisionHackmdSynced: { type: ObjectId, ref: 'Revision' }, // the revision that is synced to HackMD
   hasDraftOnHackmd: { type: Boolean }, // set true if revision and revisionHackmdSynced are same but HackMD document has modified
-  createdAt: { type: Date, default: Date.now() },
-  updatedAt: { type: Date, default: Date.now() },
+  createdAt: { type: Date, default: Date.now },
+  updatedAt: { type: Date, default: Date.now },
 }, {
   toJSON: { getters: true },
   toObject: { getters: true },
@@ -1311,6 +1311,52 @@ module.exports = function(crowi) {
     return pageData;
   };
 
+  pageSchema.statics.handlePrivatePagesForDeletedGroup = async function(deletedGroup, action, selectedGroupId) {
+    const Page = mongoose.model('Page');
+
+    const pages = await this.find({ grantedGroup: deletedGroup });
+
+    switch (action) {
+      case 'public':
+        await Promise.all(pages.map((page) => {
+          return Page.publicizePage(page);
+        }));
+        break;
+      case 'delete':
+        await Promise.all(pages.map((page) => {
+          return Page.completelyDeletePage(page);
+        }));
+        break;
+      case 'transfer':
+        await Promise.all(pages.map((page) => {
+          return Page.transferPageToGroup(page, selectedGroupId);
+        }));
+        break;
+      default:
+        throw new Error('Unknown action for private pages');
+    }
+  };
+
+  pageSchema.statics.publicizePage = async function(page) {
+    page.grantedGroup = null;
+    page.grant = GRANT_PUBLIC;
+    await page.save();
+  };
+
+  pageSchema.statics.transferPageToGroup = async function(page, selectedGroupId) {
+    const UserGroup = mongoose.model('UserGroup');
+
+    // check page existence
+    const isExist = await UserGroup.count({ _id: selectedGroupId }) > 0;
+    if (isExist) {
+      page.grantedGroup = selectedGroupId;
+      await page.save();
+    }
+    else {
+      throw new Error('Cannot find the group to which private pages belong to. _id: ', selectedGroupId);
+    }
+  };
+
   /**
    * associate GROWI page and HackMD page
    * @param {Page} pageData

+ 15 - 22
src/server/models/user-group.js

@@ -90,31 +90,24 @@ class UserGroup {
   }
 
   // グループの完全削除
-  static removeCompletelyById(id) {
+  static async removeCompletelyById(deleteGroupId, action, selectedGroupId) {
     const PageGroupRelation = mongoose.model('PageGroupRelation');
     const UserGroupRelation = mongoose.model('UserGroupRelation');
+    const Page = mongoose.model('Page');
 
-    let removed;
-    return this.findById(id)
-      .then((userGroupData) => {
-        if (userGroupData == null) {
-          throw new Error('UserGroup data is not exists. id:', id);
-        }
-        return userGroupData.remove();
-      })
-      .then((removedUserGroupData) => {
-        removed = removedUserGroupData;
-      })
-      // remove relations
-      .then(() => {
-        return Promise.all([
-          UserGroupRelation.removeAllByUserGroup(removed),
-          PageGroupRelation.removeAllByUserGroup(removed),
-        ]);
-      })
-      .then(() => {
-        return removed;
-      });
+    const groupToDelete = await this.findById(deleteGroupId);
+    if (groupToDelete == null) {
+      throw new Error('UserGroup data is not exists. id:', deleteGroupId);
+    }
+    const deletedGroup = await groupToDelete.remove();
+
+    await Promise.all([
+      UserGroupRelation.removeAllByUserGroup(deletedGroup),
+      PageGroupRelation.removeAllByUserGroup(deletedGroup),
+      Page.handlePrivatePagesForDeletedGroup(deletedGroup, action, selectedGroupId),
+    ]);
+
+    return deletedGroup;
   }
 
   // グループ生成(名前が要る)

+ 23 - 12
src/server/routes/admin.js

@@ -780,19 +780,19 @@ module.exports = function(crowi, app) {
 
 
   // app.post('/_api/admin/user-group/delete' , admin.userGroup.removeCompletely);
-  actions.userGroup.removeCompletely = function(req, res) {
-    const id = req.body.user_group_id;
+  actions.userGroup.removeCompletely = async(req, res) => {
+    const { deleteGroupId, actionName, selectedGroupId } = req.body;
 
-    UserGroup.removeCompletelyById(id)
-      .then(() => {
-        req.flash('successMessage', '削除しました');
-        return res.redirect('/admin/user-groups');
-      })
-      .catch((err) => {
-        debug('Error while removing userGroup.', err, id);
-        req.flash('errorMessage', '完全な削除に失敗しました。');
-        return res.redirect('/admin/user-groups');
-      });
+    try {
+      await UserGroup.removeCompletelyById(deleteGroupId, actionName, selectedGroupId);
+      req.flash('successMessage', '削除しました');
+    }
+    catch (err) {
+      debug('Error while removing userGroup.', err, deleteGroupId);
+      req.flash('errorMessage', '完全な削除に失敗しました。');
+    }
+
+    return res.redirect('/admin/user-groups');
   };
 
   actions.userGroupRelation = {};
@@ -1327,6 +1327,17 @@ module.exports = function(crowi, app) {
     return res.json(ApiResponse.success());
   };
 
+  actions.api.userGroups = async(req, res) => {
+    try {
+      const userGroups = await UserGroup.find();
+      return res.json(ApiResponse.success({ userGroups }));
+    }
+    catch (err) {
+      logger.error('Error', err);
+      return res.json(ApiResponse.error('Error'));
+    }
+  };
+
   /**
    * save settings, update config cache, and response json
    *

+ 1 - 0
src/server/routes/index.js

@@ -143,6 +143,7 @@ module.exports = function(crowi, app) {
   app.post('/admin/user-group/create'      , form.admin.userGroupCreate, loginRequired(crowi, app), middleware.adminRequired(), csrf, admin.userGroup.create);
   app.post('/admin/user-group/:userGroupId/update', loginRequired(crowi, app), middleware.adminRequired(), csrf, admin.userGroup.update);
   app.post('/admin/user-group.remove' , loginRequired(crowi, app), middleware.adminRequired(), csrf, admin.userGroup.removeCompletely);
+  app.get('/_api/admin/user-groups', loginRequired(crowi, app), middleware.adminRequired(), admin.api.userGroups);
 
   // user-group-relations admin
   app.post('/admin/user-group-relation/create', loginRequired(crowi, app), middleware.adminRequired(), csrf, admin.userGroupRelation.create);

+ 14 - 1
src/server/routes/login-passport.js

@@ -4,6 +4,7 @@ module.exports = function(crowi, app) {
   const debug = require('debug')('growi:routes:login-passport');
   const logger = require('@alias/logger')('growi:routes:login-passport');
   const passport = require('passport');
+  const { URL } = require('url');
   const ExternalAccount = crowi.model('ExternalAccount');
   const passportService = crowi.passportService;
 
@@ -24,7 +25,19 @@ module.exports = function(crowi, app) {
     const jumpTo = req.session.jumpTo;
     if (jumpTo) {
       req.session.jumpTo = null;
-      return res.redirect(jumpTo);
+
+      // prevention from open redirect
+      try {
+        const redirectUrl = new URL(jumpTo, `${req.protocol}://${req.get('host')}`);
+        if (redirectUrl.hostname === req.hostname) {
+          return res.redirect(redirectUrl);
+        }
+        logger.warn('Requested redirect URL is invalid, redirect to root page');
+      }
+      catch (err) {
+        logger.warn('Requested redirect URL is invalid, redirect to root page', err);
+        return res.redirect('/');
+      }
     }
 
     return res.redirect('/');

+ 13 - 1
src/server/routes/login.js

@@ -37,7 +37,19 @@ module.exports = function(crowi, app) {
     const jumpTo = req.session.jumpTo;
     if (jumpTo) {
       req.session.jumpTo = null;
-      return res.redirect(jumpTo);
+
+      // prevention from open redirect
+      try {
+        const redirectUrl = new URL(jumpTo, `${req.protocol}://${req.get('host')}`);
+        if (redirectUrl.hostname === req.hostname) {
+          return res.redirect(redirectUrl);
+        }
+        logger.warn('Requested redirect URL is invalid, redirect to root page');
+      }
+      catch (err) {
+        logger.warn('Requested redirect URL is invalid, redirect to root page', err);
+        return res.redirect('/');
+      }
     }
 
     return res.redirect('/');

+ 15 - 48
src/server/views/admin/user-groups.html

@@ -1,11 +1,11 @@
 {% extends '../layout/admin.html' %}
 
-{% block html_title %}{{ customTitle(t('UserGroup management')) }}{% endblock %}
+{% block html_title %}{{ customTitle(t('UserGroup Management')) }}{% endblock %}
 
 {% block content_header %}
 <div class="header-wrap">
   <header id="page-header">
-    <h1 id="admin-title" class="title">{{ t('UserGroup management') }}</h1>
+    <h1 id="admin-title" class="title">{{ t('UserGroup Management') }}</h1>
   </header>
 </div>
 {% endblock %}
@@ -34,18 +34,18 @@
     <div class="col-md-9">
       <p>
         {% if isAclEnabled %}
-          <button  data-toggle="collapse" class="btn btn-default" href="#createGroupForm">新規グループの作成</button>
+          <button  data-toggle="collapse" class="btn btn-default" href="#createGroupForm">{{ t('user_group_management.create_group') }}</button>
         {% else %}
-          現在の設定では新規グループの作成はできません。
+          {{ t('user_group_management.deny_create_group')}}
         {% endif %}
       </p>
       <form role="form" action="/admin/user-group/create" method="post">
         <div id="createGroupForm" class="collapse">
           <div class="form-group">
-            <label for="createGroupForm[userGroupName]">グループ名</label>
-            <textarea class="form-control" name="createGroupForm[userGroupName]" placeholder="例: Group1"></textarea>
+            <label for="createGroupForm[userGroupName]">{{ t('user_group_management.group_name') }}</label>
+            <textarea class="form-control" name="createGroupForm[userGroupName]" placeholder="{{t('user_group_management.group_example')}}"></textarea>
           </div>
-          <button type="submit" class="btn btn-primary">作成する</button>
+          <button type="submit" class="btn btn-primary">{{ t('Create') }}</button>
         </div>
         <input type="hidden" name="_csrf" value="{{ csrf() }}">
       </form>
@@ -58,12 +58,12 @@
 
             <div class="modal-header">
               <button type="button" class="close" data-dismiss="modal" aria-hidden="true">&times;</button>
-              <h4 class="modal-title">グループを作成しました</h4>
+              <h4 class="modal-title">{{ t('user_group_management.created_group') }}</h4>
             </div>
 
             <div class="modal-body">
               <p>
-                作成したグループにユーザを追加してください
+                {{ t('user_group_management.add_user') }}
               </p>
 
               <pre>{{ createdUserGroup.name }}</pre>
@@ -74,49 +74,16 @@
       </div><!-- /.modal -->
       {% endif %}
 
-      <div class="modal fade" id="admin-delete-user-group-modal">
-        <div class="modal-dialog">
-          <div class="modal-content">
-            <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 icon-fire"></i> グループの削除
-              </div>
-            </div>
-
-            <div class="modal-body">
-              <dl>
-                <dt>グループ名</dt>
-                <dd><span id="admin-delete-user-group-name"></span></dd>
-              </dl>
-              <span class="text-danger">
-                グループの削除を行うと元に戻すことはできませんのでご注意ください。
-              </span>
-            </div>
-            <div class="modal-footer">
-              <form action="/admin/user-group.remove" method="post" id="admin-user-groups-delete" class="text-right">
-                <input type="hidden" name="user_group_id" value="">
-                <input type="hidden" name="_csrf" value="{{ csrf() }}">
-                <button type="submit" value="" class="btn btn-sm btn-danger">
-                  <i class="icon icon-fire"></i> 削除
-                </button>
-              </form>
-            </div>
-
-          </div>
-          <!-- /.modal-content -->
-        </div>
-        <!-- /.modal-dialog -->
-      </div>
+      <div class="modal fade" id="admin-delete-user-group-modal"></div>
 
-      <h2>グループ一覧</h2>
+      <h2>{{ t('user_group_management.group_list') }}</h2>
 
       <table class="table table-bordered table-user-list">
         <thead>
           <tr>
             <th>{{ t('Name') }}</th>
-            <th>ユーザ一覧</th>
-            <th width="100px">作成日</th>
+            <th>{{ t('User') }}</th>
+            <th width="100px">{{ t('Created') }}</th>
             <th width="70px"></th>
           </tr>
         </thead>
@@ -144,7 +111,7 @@
                 <ul class="dropdown-menu" role="menu">
                   <li>
                     <a href="{{ sGroupDetailPageUrl }}">
-                      <i class="icon-fw icon-note"></i> 編集
+                      <i class="icon-fw icon-note"></i> {{ t('Edit') }}
                     </a>
                   </li>
 
@@ -154,7 +121,7 @@
                         data-user-group-name="{{ sGroup.name.toString() | encodeHTML }}"
                         data-target="#admin-delete-user-group-modal"
                         data-toggle="modal">
-                      <i class="icon-fw icon-fire text-danger"></i> 削除する
+                      <i class="icon-fw icon-fire text-danger"></i> {{ t('Delete') }}
                     </a>
                   </li>
 

+ 7 - 7
src/server/views/admin/widget/menu.html

@@ -3,13 +3,13 @@
 {% endif  %}
 <ul class="nav nav-pills nav-stacked">
   <li class="{% if current == 'index'%}active{% endif %}"><a href="/admin"><i class="icon-fw icon-home"></i> {{ t('Management Wiki Home') }}</a></li>
-  <li class="{% if current == 'app'%}active{% endif %}"><a href="/admin/app"><i class="icon-fw icon-settings"></i> {{ t('App settings') }}</a></li>
-  <li class="{% if current == 'security'%}active{% endif %}"><a href="/admin/security"><i class="icon-fw icon-shield"></i> {{ t('Security settings') }}</a></li>
-  <li class="{% if current == 'markdown'%}active{% endif %}"><a href="/admin/markdown"><i class="icon-fw icon-note"></i> {{ t('Markdown settings') }}</a></li>
+  <li class="{% if current == 'app'%}active{% endif %}"><a href="/admin/app"><i class="icon-fw icon-settings"></i> {{ t('App Settings') }}</a></li>
+  <li class="{% if current == 'security'%}active{% endif %}"><a href="/admin/security"><i class="icon-fw icon-shield"></i> {{ t('Security Settings') }}</a></li>
+  <li class="{% if current == 'markdown'%}active{% endif %}"><a href="/admin/markdown"><i class="icon-fw icon-note"></i> {{ t('Markdown Settings') }}</a></li>
   <li class="{% if current == 'customize'%}active{% endif %}"><a href="/admin/customize"><i class="icon-fw icon-wrench"></i> {{ t('Customize') }}</a></li>
   <li class="{% if current == 'importer'%}active{% endif %}"><a href="/admin/importer"><i class="icon-fw icon-cloud-download"></i> {{ t('Import Data') }}</a></li>
-  <li class="{% if current == 'notification'%}active{% endif %}"><a href="/admin/notification"><i class="icon-fw icon-bell"></i> {{ t('Notification settings') }}</a></li>
-  <li class="{% if current == 'user' || current == 'external-account' %}active{% endif %}"><a href="/admin/users"><i class="icon-fw icon-user"></i> {{ t('User management') }}</a></li>
-  <li class="{% if current == 'user-group'%}active{% endif %}"><a href="/admin/user-groups"><i class="icon-fw icon-people"></i> {{ t('UserGroup management') }}</a></li>
-  <li class="{% if current == 'search'%}active{% endif %}"><a href="/admin/search"><i class="icon-fw icon-magnifier"></i> {{ t('Full Text Search management') }}</a></li>
+  <li class="{% if current == 'notification'%}active{% endif %}"><a href="/admin/notification"><i class="icon-fw icon-bell"></i> {{ t('Notification Settings') }}</a></li>
+  <li class="{% if current == 'user' || current == 'external-account' %}active{% endif %}"><a href="/admin/users"><i class="icon-fw icon-user"></i> {{ t('User Management') }}</a></li>
+  <li class="{% if current == 'user-group'%}active{% endif %}"><a href="/admin/user-groups"><i class="icon-fw icon-people"></i> {{ t('UserGroup Management') }}</a></li>
+  <li class="{% if current == 'search'%}active{% endif %}"><a href="/admin/search"><i class="icon-fw icon-magnifier"></i> {{ t('Full Text Search Management') }}</a></li>
 </ul>

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

@@ -247,7 +247,7 @@
 {% endblock %}
 
 <script type="application/json" id="crowi-context-hydrate">
-{{ local_config|json|safe }}
+{{ local_config|json|safe|preventXss }}
 </script>
 
 {% block custom_script %}

+ 27 - 26
yarn.lock

@@ -6562,12 +6562,13 @@ mimic-fn@^1.0.0:
   version "1.1.0"
   resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-1.1.0.tgz#e667783d92e89dbd342818b5230b9d62a672ad18"
 
-mini-css-extract-plugin@^0.6.0:
-  version "0.6.0"
-  resolved "https://registry.yarnpkg.com/mini-css-extract-plugin/-/mini-css-extract-plugin-0.6.0.tgz#a3f13372d6fcde912f3ee4cd039665704801e3b9"
+mini-css-extract-plugin@^0.7.0:
+  version "0.7.0"
+  resolved "https://registry.yarnpkg.com/mini-css-extract-plugin/-/mini-css-extract-plugin-0.7.0.tgz#5ba8290fbb4179a43dd27cca444ba150bee743a0"
+  integrity sha512-RQIw6+7utTYn8DBGsf/LpRgZCJMpZt+kuawJ/fju0KiOL6nAaTBNmCJwS7HtwSCXfS47gCkmtBFS7HdsquhdxQ==
   dependencies:
     loader-utils "^1.1.0"
-    normalize-url "^2.0.1"
+    normalize-url "1.9.1"
     schema-utils "^1.0.0"
     webpack-sources "^1.1.0"
 
@@ -7155,13 +7156,15 @@ normalize-selector@^0.2.0:
   version "0.2.0"
   resolved "https://registry.yarnpkg.com/normalize-selector/-/normalize-selector-0.2.0.tgz#d0b145eb691189c63a78d201dc4fdb1293ef0c03"
 
-normalize-url@^2.0.1:
-  version "2.0.1"
-  resolved "https://registry.yarnpkg.com/normalize-url/-/normalize-url-2.0.1.tgz#835a9da1551fa26f70e92329069a23aa6574d7e6"
+normalize-url@1.9.1:
+  version "1.9.1"
+  resolved "https://registry.yarnpkg.com/normalize-url/-/normalize-url-1.9.1.tgz#2cc0d66b31ea23036458436e3620d85954c66c3c"
+  integrity sha1-LMDWazHqIwNkWENuNiDYWVTGbDw=
   dependencies:
-    prepend-http "^2.0.0"
-    query-string "^5.0.1"
-    sort-keys "^2.0.0"
+    object-assign "^4.0.1"
+    prepend-http "^1.0.0"
+    query-string "^4.1.0"
+    sort-keys "^1.0.0"
 
 normalize-url@^3.0.0:
   version "3.2.0"
@@ -7213,10 +7216,10 @@ nth-check@^1.0.1:
   dependencies:
     boolbase "~1.0.0"
 
-null-loader@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/null-loader/-/null-loader-1.0.0.tgz#90e85798e50e9dd1d568495a44e74829dec26744"
-  integrity sha512-mYLDjDVTkjTlFoidxRhzO75rdcwfVXfw5G5zpj8sXnBkHtKJxMk4hTcRR4i5SOhDB6EvcQuYriy6IV23eq6uog==
+null-loader@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/null-loader/-/null-loader-2.0.0.tgz#1c99da3f0d2c0996b51e9eada3a898a5d57472aa"
+  integrity sha512-PhEeA3v/tAacxC5dNO1i2yXzGVWWrZ9jTx+TMEJ716amvnBXzvrxIwy9HW7MyJsHe8ACQzpiQgbrAjDRMA7gcg==
   dependencies:
     loader-utils "^1.2.3"
     schema-utils "^1.0.0"
@@ -8273,13 +8276,10 @@ prelude-ls@~1.1.2:
   version "1.1.2"
   resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.1.2.tgz#21932a549f5e52ffd9a827f570e04be62a97da54"
 
-prepend-http@^1.0.1:
+prepend-http@^1.0.0, prepend-http@^1.0.1:
   version "1.0.4"
   resolved "https://registry.yarnpkg.com/prepend-http/-/prepend-http-1.0.4.tgz#d4f4562b0ce3696e41ac52d0e002e57a635dc6dc"
-
-prepend-http@^2.0.0:
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/prepend-http/-/prepend-http-2.0.0.tgz#e92434bfa5ea8c19f41cdfd401d741a3c819d897"
+  integrity sha1-1PRWKwzjaW5BrFLQ4ALlemNdxtw=
 
 preserve@^0.2.0:
   version "0.2.0"
@@ -8471,11 +8471,11 @@ qs@^6.5.2, qs@~6.5.2:
   version "6.5.2"
   resolved "https://registry.yarnpkg.com/qs/-/qs-6.5.2.tgz#cb3ae806e8740444584ef154ce8ee98d403f3e36"
 
-query-string@^5.0.1:
-  version "5.1.1"
-  resolved "https://registry.yarnpkg.com/query-string/-/query-string-5.1.1.tgz#a78c012b71c17e05f2e3fa2319dd330682efb3cb"
+query-string@^4.1.0:
+  version "4.3.4"
+  resolved "https://registry.yarnpkg.com/query-string/-/query-string-4.3.4.tgz#bbb693b9ca915c232515b228b1a02b609043dbeb"
+  integrity sha1-u7aTucqRXCMlFbIosaArYJBD2+s=
   dependencies:
-    decode-uri-component "^0.2.0"
     object-assign "^4.1.0"
     strict-uri-encode "^1.0.0"
 
@@ -9758,9 +9758,10 @@ socket.io@^2.0.3:
     socket.io-client "2.0.4"
     socket.io-parser "~3.1.1"
 
-sort-keys@^2.0.0:
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/sort-keys/-/sort-keys-2.0.0.tgz#658535584861ec97d730d6cf41822e1f56684128"
+sort-keys@^1.0.0:
+  version "1.1.2"
+  resolved "https://registry.yarnpkg.com/sort-keys/-/sort-keys-1.1.2.tgz#441b6d4d346798f1b4e49e8920adfba0e543f9ad"
+  integrity sha1-RBttTTRnmPG05J6JIK37oOVD+a0=
   dependencies:
     is-plain-obj "^1.0.0"