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

Merge branch 'reactify/personal-settings' into feat/crop-and-upload-profile-image

# Conflicts:
#	yarn.lock
itizawa 6 лет назад
Родитель
Сommit
542b6dcc6e
86 измененных файлов с 2354 добавлено и 1255 удалено
  1. 22 2
      CHANGES.md
  2. 6 6
      package.json
  3. 3 1
      resource/locales/en-US/admin/admin.json
  4. 8 6
      resource/locales/en-US/translation.json
  5. 3 1
      resource/locales/ja/admin/admin.json
  6. 8 6
      resource/locales/ja/translation.json
  7. 2 0
      src/client/js/admin.jsx
  8. 18 0
      src/client/js/app.jsx
  9. 2 0
      src/client/js/bootstrap.jsx
  10. 59 0
      src/client/js/components/Admin/Common/AdminNavigation.jsx
  11. 15 0
      src/client/js/components/Admin/Customize/CustomizeFunctionSetting.jsx
  12. 1 1
      src/client/js/components/Admin/MarkdownSetting/XssForm.jsx
  13. 2 2
      src/client/js/components/Admin/Users/RemoveAdminButton.jsx
  14. 2 2
      src/client/js/components/Admin/Users/StatusSuspendedButton.jsx
  15. 1 1
      src/client/js/components/BookmarkButton.jsx
  16. 1 1
      src/client/js/components/LikeButton.jsx
  17. 107 0
      src/client/js/components/Me/ApiSettings.jsx
  18. 160 0
      src/client/js/components/Me/BasicInfoSettings.jsx
  19. 75 0
      src/client/js/components/Me/ExternalAccountLinkedMe.jsx
  20. 41 0
      src/client/js/components/Me/ExternalAccountRow.jsx
  21. 147 0
      src/client/js/components/Me/PasswordSettings.jsx
  22. 61 0
      src/client/js/components/Me/PersonalSettings.jsx
  23. 118 0
      src/client/js/components/Me/ProfileImageSettings.jsx
  24. 37 0
      src/client/js/components/Me/UserSettings.jsx
  25. 60 0
      src/client/js/components/Navbar/PersonalDropdown.jsx
  26. 1 1
      src/client/js/components/Page/RevisionBody.jsx
  27. 49 41
      src/client/js/components/Page/RevisionRenderer.jsx
  28. 1 1
      src/client/js/components/PageAttachment.jsx
  29. 56 151
      src/client/js/components/PageComment/Comment.jsx
  30. 24 0
      src/client/js/components/PageComment/CommentControl.jsx
  31. 1 3
      src/client/js/components/PageComment/CommentEditor.jsx
  32. 2 3
      src/client/js/components/PageComment/CommentEditorLazyRenderer.jsx
  33. 124 0
      src/client/js/components/PageComment/ReplayComments.jsx
  34. 8 2
      src/client/js/components/PageComments.jsx
  35. 5 44
      src/client/js/components/PageEditor.jsx
  36. 75 2
      src/client/js/components/PageEditor/Preview.jsx
  37. 6 2
      src/client/js/components/PageHistory/RevisionDiff.jsx
  38. 1 1
      src/client/js/components/RecentCreated/RecentCreated.jsx
  39. 11 0
      src/client/js/services/AdminCustomizeContainer.js
  40. 20 10
      src/client/js/services/AppContainer.js
  41. 168 0
      src/client/js/services/PersonalContainer.js
  42. 2 3
      src/client/styles/agile-admin/inverse/colors/spring.scss
  43. 8 1
      src/client/styles/scss/_comment.scss
  44. 0 5
      src/client/styles/scss/_comment_growi.scss
  45. 0 5
      src/client/styles/scss/_comment_kibela.scss
  46. 0 5
      src/client/styles/scss/_layout_crowi_sidebar.scss
  47. 1 4
      src/client/styles/scss/_page.scss
  48. 5 3
      src/linter-checker/test.js
  49. 5 3
      src/linter-checker/test.scss
  50. 0 6
      src/server/form/index.js
  51. 0 7
      src/server/form/me/apiToken.js
  52. 0 7
      src/server/form/me/imagetype.js
  53. 0 9
      src/server/form/me/password.js
  54. 0 10
      src/server/form/me/user.js
  55. 3 1
      src/server/middleware/access-token-parser.js
  56. 3 0
      src/server/models/config.js
  57. 9 25
      src/server/models/user.js
  58. 6 0
      src/server/routes/apiv3/customize-setting.js
  59. 6 9
      src/server/routes/apiv3/index.js
  60. 296 0
      src/server/routes/apiv3/personal-setting.js
  61. 0 7
      src/server/routes/index.js
  62. 3 1
      src/server/routes/login.js
  63. 1 160
      src/server/routes/me.js
  64. 3 22
      src/server/views/admin/app.html
  65. 3 26
      src/server/views/admin/customize.html
  66. 3 12
      src/server/views/admin/export.html
  67. 3 30
      src/server/views/admin/external-accounts.html
  68. 5 23
      src/server/views/admin/global-notification-detail.html
  69. 3 32
      src/server/views/admin/importer.html
  70. 3 18
      src/server/views/admin/index.html
  71. 3 9
      src/server/views/admin/markdown.html
  72. 3 7
      src/server/views/admin/notification.html
  73. 2 8
      src/server/views/admin/search.html
  74. 2 5
      src/server/views/admin/security.html
  75. 7 11
      src/server/views/admin/user-group-detail.html
  76. 3 7
      src/server/views/admin/user-groups.html
  77. 2 18
      src/server/views/admin/users.html
  78. 0 16
      src/server/views/admin/widget/menu.html
  79. 0 16
      src/server/views/admin/widget/theme-colorbox.html
  80. 1 1
      src/server/views/installer.html
  81. 4 0
      src/server/views/layout/admin.html
  82. 8 17
      src/server/views/layout/layout.html
  83. 0 87
      src/server/views/me/api_token.html
  84. 1 1
      src/server/views/me/index.html
  85. 0 100
      src/server/views/me/password.html
  86. 436 228
      yarn.lock

+ 22 - 2
CHANGES.md

@@ -1,8 +1,28 @@
 # CHANGES
 
-## 3.6.7-RC
+## v3.6.9-RC
 
-* Imprv: Show error toastr when saving page is failed because of empty document
+*
+
+## v3.6.8
+
+* Improvement: Show page history side-by-side
+* Improvement: Optimize markdown rendering
+* Improvement: Reactify admin pages (Navigation)
+* Fix: Reply comments collapsed are broken
+* Support: Update libs
+    * cross-env
+    * mkdirp
+    * diff2html
+    * jest
+    * stylelint
+
+## v3.6.7
+
+* Feature: Anchor link for comments
+* Improvement: Show error toastr when saving page is failed because of empty document
+* Fix: Admin Customise couldn't restore stored config value
+    * Introduced by 3.6.2
 * Fix: Admin Customise missed preview functions
     * Introduced by 3.6.2
 * Fix: AWS doesn't work

+ 6 - 6
package.json

@@ -1,6 +1,6 @@
 {
   "name": "growi",
-  "version": "3.6.7-RC",
+  "version": "3.6.9-RC",
   "description": "Team collaboration software using markdown",
   "tags": [
     "wiki",
@@ -85,7 +85,7 @@
     "connect-mongo": "^3.0.0",
     "connect-redis": "^3.3.0",
     "cookie-parser": "^1.4.3",
-    "cross-env": "^6.0.3",
+    "cross-env": "^7.0.0",
     "csrf": "^3.1.0",
     "date-fns": "^2.0.0",
     "diff": "^4.0.1",
@@ -112,7 +112,7 @@
     "md5": "^2.2.1",
     "method-override": "^3.0.0",
     "migrate-mongo": "^7.0.1",
-    "mkdirp": "~0.5.1",
+    "mkdirp": "^1.0.3",
     "module-alias": "^2.0.6",
     "mongoose": "5.4.4",
     "mongoose-gridfs": "^1.2.2",
@@ -173,7 +173,7 @@
     "core-js": "=2.6.9",
     "css-loader": "^3.0.0",
     "csv-to-markdown-table": "^1.0.1",
-    "diff2html": "^2.3.3",
+    "diff2html": "^3.1.2",
     "eazy-logger": "^3.0.2",
     "eslint": "^6.0.1",
     "eslint-config-weseek": "^1.0.3",
@@ -185,7 +185,7 @@
     "hard-source-webpack-plugin": "^0.13.1",
     "i18next-browser-languagedetector": "^4.0.1",
     "imports-loader": "^0.8.0",
-    "jest": "^24.8.0",
+    "jest": "^25.1.0",
     "jquery-slimscroll": "^1.3.8",
     "jquery-ui": "^1.12.1",
     "jquery.cookie": "~1.4.1",
@@ -231,7 +231,7 @@
     "simple-load-script": "^1.0.2",
     "socket.io-client": "^2.0.3",
     "style-loader": "^1.0.0",
-    "stylelint": "^12.0.1",
+    "stylelint": "^13.2.0",
     "stylelint-config-recess-order": "^2.0.1",
     "swagger-jsdoc": "^3.4.0",
     "swagger2openapi": "^5.3.1",

+ 3 - 1
resource/locales/en-US/admin/admin.json

@@ -126,7 +126,9 @@
       "recent_created__n_draft_num_desc": "Number of Recently Created Pages & Drafts Displayed",
       "recently_created_n_draft_num_desc": "Number of recently created pages and drafts displayed on user page",
       "stale_notification": "Display Notification on Stale Pages",
-      "stale_notification_desc": "Displays the notification to pages more than 1 year since the last update."
+      "stale_notification_desc": "Displays the notification to pages more than 1 year since the last update.",
+      "show_all_reply_comments": "Show all reply comments",
+      "show_all_reply_comments_desc": "When the setting value is off, comments other than the latest two are omitted."
     },
     "code_highlight": "Code Highlight",
     "nocdn_desc": "This function is disabled when the environment variable <code>NO_CDN=true</code>.<br>Github style has been forcibly applied.",

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

@@ -161,12 +161,14 @@
   },
   "Password": "Password",
   "Password Settings": "Password Settings",
-  "Set new Password": "Set new Password",
-  "Update Password": "Update Password",
-  "Current password": "Current password",
-  "New password": "New password",
-  "Re-enter new password": "Re-enter new password",
-  "Password is not set": "Password is not set",
+    "personal_settings": {
+    "set_new_password": "Set new Password",
+    "update_password": "Update Password",
+      "current_password": "Current password",
+      "new_password": "New password",
+      "new_password_confirm": "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",

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

@@ -126,7 +126,9 @@
       "recent_created__n_draft_num_desc": "最近作成したページと下書きの表示数",
       "recently_created_n_draft_num_desc": "ホーム画面の Recently Created での、1ページの表示数を設定します。",
       "stale_notification": "古いページに通知を表示する",
-      "stale_notification_desc": "最後の更新から1年を超えるページへの通知を表示します。"
+      "stale_notification_desc": "最後の更新から1年を超えるページへの通知を表示します。",
+      "show_all_reply_comments": "返信コメントを全て表示する",
+      "show_all_reply_comments_desc": "OFFの場合、最新2件のコメント以外が省略されます。"
     },
     "code_highlight": "コードハイライト",
     "nocdn_desc": "この機能は、環境変数 <code>NO_CDN=true</code> の時は無効化されます。<br>GitHub スタイルが適用されています。",

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

@@ -160,12 +160,14 @@
   },
   "Password": "パスワード",
   "Password Settings": "パスワード設定",
-  "Set new Password": "パスワードを新規に設定",
-  "Update Password": "パスワードを更新",
-  "Current password": "現在のパスワード",
-  "New password": "新しいパスワード",
-  "Re-enter new password": "(確認用)",
-  "Password is not set": "パスワードが設定されていません",
+  "personal_settings":{
+    "set_new_password": "パスワードを新規に設定",
+    "update_password": "パスワードを更新",
+    "current_password": "現在のパスワード",
+    "new_password": "新しいパスワード",
+    "new_password_confirm": "(確認用)",
+    "password_is_not_set": "パスワードが設定されていません"
+  },
   "security_settings": "セキュリティ設定",
   "API Settings": "API設定",
   "API Token Settings": "API Token設定",

+ 2 - 0
src/client/js/admin.jsx

@@ -18,6 +18,7 @@ import Customize from './components/Admin/Customize/Customize';
 import ImportDataPage from './components/Admin/ImportDataPage';
 import ExportArchiveDataPage from './components/Admin/ExportArchiveDataPage';
 import FullTextSearchManagement from './components/Admin/FullTextSearchManagement';
+import AdminNavigation from './components/Admin/Common/AdminNavigation';
 
 import AdminHomeContainer from './services/AdminHomeContainer';
 import AdminCustomizeContainer from './services/AdminCustomizeContainer';
@@ -79,6 +80,7 @@ Object.assign(componentMappings, {
   'admin-user-group-detail': <UserGroupDetailPage />,
   'admin-full-text-search-management': <FullTextSearchManagement />,
   'admin-user-group-page': <UserGroupPage />,
+  'admin-navigation': <AdminNavigation />,
 });
 
 

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

@@ -31,10 +31,12 @@ import MyDraftList from './components/MyDraftList/MyDraftList';
 import UserPictureList from './components/User/UserPictureList';
 import TableOfContents from './components/TableOfContents';
 
+import PersonalSettings from './components/Me/PersonalSettings';
 import PageContainer from './services/PageContainer';
 import CommentContainer from './services/CommentContainer';
 import EditorContainer from './services/EditorContainer';
 import TagContainer from './services/TagContainer';
+import PersonalContainer from './services/PersonalContainer';
 
 import ProfileImageUploader from './components/Me/ProfileImageUploader';
 
@@ -121,6 +123,22 @@ Object.keys(componentMappings).forEach((key) => {
   }
 });
 
+const personalSettingsElem = document.getElementById('personal-setting');
+if (personalSettingsElem != null) {
+  const personalContainer = new PersonalContainer(appContainer);
+  ReactDOM.render(
+    <Provider inject={[injectableContainers, personalContainer]}>
+      <I18nextProvider i18n={i18n}>
+        <PersonalSettings
+          crowi={appContainer}
+        />
+      </I18nextProvider>
+    </Provider>,
+    personalSettingsElem,
+  );
+}
+
+
 // うわーもうー (commented by Crowi team -- 2018.03.23 Yuki Takei)
 $('a[data-toggle="tab"][href="#revision-history"]').on('show.bs.tab', () => {
   ReactDOM.render(

+ 2 - 0
src/client/js/bootstrap.jsx

@@ -4,6 +4,7 @@ import loggerFactory from '@alias/logger';
 import Xss from '@commons/service/xss';
 
 import HeaderSearchBox from './components/HeaderSearchBox';
+import PersonalDropdown from './components/Navbar/PersonalDropdown';
 import StaffCredit from './components/StaffCredit/StaffCredit';
 
 import AppContainer from './services/AppContainer';
@@ -37,6 +38,7 @@ appContainer.injectToWindow();
 const componentMappings = {
   'search-top': <HeaderSearchBox />,
   'search-sidebar': <HeaderSearchBox crowi={appContainer} />,
+  'personal-dropdown': <PersonalDropdown />,
 
   'staff-credit': <StaffCredit />,
 };

+ 59 - 0
src/client/js/components/Admin/Common/AdminNavigation.jsx

@@ -0,0 +1,59 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+import urljoin from 'url-join';
+
+const AdminNavigation = (props) => {
+  const { t } = props;
+  const pathname = window.location.pathname;
+
+  const isActiveMenu = (path) => {
+    return (pathname.startsWith(urljoin('/admin', path)));
+  };
+
+  return (
+    <ul className="nav nav-pills nav-stacked">
+      <li className={`${pathname === '/admin' && 'active'}`}>
+        <a href="/admin"><i className="icon-fw icon-home"></i> { t('Management Wiki Home') }</a>
+      </li>
+      <li className={`${isActiveMenu('/app') && 'active'}`}>
+        <a href="/admin/app"><i className="icon-fw icon-settings"></i> { t('App Settings') }</a>
+      </li>
+      <li className={`${isActiveMenu('/security') && 'active'}`}>
+        <a href="/admin/security"><i className="icon-fw icon-shield"></i> { t('security_settings') }</a>
+      </li>
+      <li className={`${isActiveMenu('/markdown') && 'active'}`}>
+        <a href="/admin/markdown"><i className="icon-fw icon-note"></i> { t('Markdown Settings') }</a>
+      </li>
+      <li className={`${isActiveMenu('/customize') && 'active'}`}>
+        <a href="/admin/customize"><i className="icon-fw icon-wrench"></i> { t('Customize') }</a>
+      </li>
+      <li className={`${isActiveMenu('/importer') && 'active'}`}>
+        <a href="/admin/importer"><i className="icon-fw icon-cloud-upload"></i> { t('Import Data') }</a>
+      </li>
+      <li className={`${isActiveMenu('/export') && 'active'}`}>
+        <a href="/admin/export"><i className="icon-fw icon-cloud-download"></i> { t('Export Archive Data') }</a>
+      </li>
+      <li className={`${(isActiveMenu('/notification') || isActiveMenu('/global-notification')) && 'active'}`}>
+        <a href="/admin/notification"><i className="icon-fw icon-bell"></i> { t('Notification Settings') }</a>
+      </li>
+      <li className={`${(isActiveMenu('/users')) && 'active'}`}>
+        <a href="/admin/users"><i className="icon-fw icon-user"></i> { t('User_Management') }</a>
+      </li>
+      <li className={`${isActiveMenu('/user-group') && 'active'}`}>
+        <a href="/admin/user-groups"><i className="icon-fw icon-people"></i> { t('UserGroup Management') }</a>
+      </li>
+      <li className={`${isActiveMenu('/search') && 'active'}`}>
+        <a href="/admin/search"><i className="icon-fw icon-magnifier"></i> { t('Full Text Search Management') }</a>
+      </li>
+    </ul>
+  );
+};
+
+
+AdminNavigation.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+
+};
+
+export default withTranslation()(AdminNavigation);

+ 15 - 0
src/client/js/components/Admin/Customize/CustomizeFunctionSetting.jsx

@@ -133,6 +133,21 @@ class CustomizeBehaviorSetting extends React.Component {
           </div>
         </div>
 
+        <div className="form-group row">
+          <div className="col-xs-offset-3 col-xs-6 text-left">
+            <CustomizeFunctionOption
+              optionId="isAllReplyShown"
+              label={t('admin:customize_setting.function_options.show_all_reply_comments')}
+              isChecked={adminCustomizeContainer.state.isAllReplyShown || false}
+              onChecked={() => { adminCustomizeContainer.switchIsAllReplyShown() }}
+            >
+              <p className="help-block">
+                {t('admin:customize_setting.function_options.show_all_reply_comments_desc')}
+              </p>
+            </CustomizeFunctionOption>
+          </div>
+        </div>
+
         <AdminUpdateButtonRow onClick={this.onClickSubmit} disabled={adminCustomizeContainer.state.retrieveError != null} />
       </React.Fragment>
     );

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

@@ -27,7 +27,7 @@ class XssForm extends React.Component {
 
     try {
       await this.props.adminMarkDownContainer.updateXssSetting();
-      toastSuccess(t('toaster.update_successed', { target: t('admin:markdown_setting.xss_desc') }));
+      toastSuccess(t('toaster.update_successed', { target: t('admin:markdown_setting.xss_header') }));
     }
     catch (err) {
       toastError(err);

+ 2 - 2
src/client/js/components/Admin/Users/RemoveAdminButton.jsx

@@ -51,11 +51,11 @@ class RemoveAdminButton extends React.Component {
 
   render() {
     const { user } = this.props;
-    const me = this.props.appContainer.me;
+    const { currentUsername } = this.props.appContainer;
 
     return (
       <Fragment>
-        {user.username !== me ? this.renderRemoveAdminBtn()
+        {user.username !== currentUsername ? this.renderRemoveAdminBtn()
           : this.renderRemoveAdminAlert()}
       </Fragment>
     );

+ 2 - 2
src/client/js/components/Admin/Users/StatusSuspendedButton.jsx

@@ -50,11 +50,11 @@ class StatusSuspendedButton extends React.Component {
 
   render() {
     const { user } = this.props;
-    const me = this.props.appContainer.me;
+    const { currentUsername } = this.props.appContainer;
 
     return (
       <Fragment>
-        {user.username !== me ? this.renderSuspendedBtn()
+        {user.username !== currentUsername ? this.renderSuspendedBtn()
           : this.renderSuspendedAlert()}
       </Fragment>
     );

+ 1 - 1
src/client/js/components/BookmarkButton.jsx

@@ -56,7 +56,7 @@ export default class BookmarkButton extends React.Component {
   }
 
   isUserLoggedIn() {
-    return this.props.crowi.me != null;
+    return this.props.crowi.currentUserId != null;
   }
 
   render() {

+ 1 - 1
src/client/js/components/LikeButton.jsx

@@ -37,7 +37,7 @@ class LikeButton extends React.Component {
   }
 
   isUserLoggedIn() {
-    return this.props.appContainer.me != null;
+    return this.props.appContainer.currentUserId != null;
   }
 
   render() {

+ 107 - 0
src/client/js/components/Me/ApiSettings.jsx

@@ -0,0 +1,107 @@
+
+import React from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+
+import { toastSuccess, toastError } from '../../util/apiNotification';
+import { createSubscribedElement } from '../UnstatedUtils';
+
+import AppContainer from '../../services/AppContainer';
+import PersonalContainer from '../../services/PersonalContainer';
+
+
+class ApiSettings extends React.Component {
+
+  constructor(appContainer) {
+    super();
+
+    this.onClickSubmit = this.onClickSubmit.bind(this);
+  }
+
+  async onClickSubmit() {
+    const { t, appContainer, personalContainer } = this.props;
+
+    try {
+      await appContainer.apiv3Put('/personal-setting/api-token');
+
+      await personalContainer.retrievePersonalData();
+      toastSuccess(t('toaster.update_successed', { target: t('personal_settings.update_password') }));
+    }
+    catch (err) {
+      toastError(err);
+    }
+
+  }
+
+  render() {
+    const { t, personalContainer } = this.props;
+    return (
+      <React.Fragment>
+
+        <div className="mb-5 container-fluid">
+          <h2 className="border-bottom">{ t('API Token Settings') }</h2>
+        </div>
+
+        <div className="row mb-3">
+          <label htmlFor="apiToken" className="col-xs-3 text-right">{t('Current API Token')}</label>
+          <div className="col-xs-6">
+            {personalContainer.state.apiToken != null
+            ? (
+              <input
+                className="form-control"
+                type="text"
+                name="apiToken"
+                value={personalContainer.state.apiToken}
+                readOnly
+              />
+            )
+            : (
+              <p>
+                { t('page_me_apitoken.notice.apitoken_issued') }
+              </p>
+            )}
+          </div>
+        </div>
+
+
+        <div className="row">
+          <div className="col-xs-offset-3 col-xs-6">
+
+            <p className="alert alert-warning">
+              { t('page_me_apitoken.notice.update_token1') }<br />
+              { t('page_me_apitoken.notice.update_token2') }
+            </p>
+
+          </div>
+        </div>
+
+        <div className="row my-3">
+          <div className="col-xs-offset-4 col-xs-5">
+            <button
+              type="button"
+              className="btn btn-primary"
+              onClick={this.onClickSubmit}
+            >
+              {t('Update API Token')}
+            </button>
+          </div>
+        </div>
+
+      </React.Fragment>
+
+    );
+  }
+
+}
+
+const ApiSettingsWrapper = (props) => {
+  return createSubscribedElement(ApiSettings, props, [AppContainer, PersonalContainer]);
+};
+
+ApiSettings.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  personalContainer: PropTypes.instanceOf(PersonalContainer).isRequired,
+};
+
+export default withTranslation()(ApiSettingsWrapper);

+ 160 - 0
src/client/js/components/Me/BasicInfoSettings.jsx

@@ -0,0 +1,160 @@
+
+import React, { Fragment } from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+
+import { toastSuccess, toastError } from '../../util/apiNotification';
+import { createSubscribedElement } from '../UnstatedUtils';
+
+import AppContainer from '../../services/AppContainer';
+import PersonalContainer from '../../services/PersonalContainer';
+
+class BasicInfoSettings extends React.Component {
+
+  constructor(appContainer) {
+    super();
+
+    this.onClickSubmit = this.onClickSubmit.bind(this);
+  }
+
+  async componentDidMount() {
+    try {
+      await this.props.personalContainer.retrievePersonalData();
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }
+
+  async onClickSubmit() {
+    const { t, personalContainer } = this.props;
+
+    try {
+      await personalContainer.updateBasicInfo();
+      toastSuccess(t('toaster.update_successed', { target: t('Basic Info') }));
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }
+
+  render() {
+    const { t, personalContainer } = this.props;
+    const { registrationWhiteList } = personalContainer.state;
+
+    return (
+      <Fragment>
+
+        <div className="row mb-3">
+          <label htmlFor="userForm[name]" className="col-sm-2 text-right">{t('Name')}</label>
+          <div className="col-sm-4 text-left">
+            <input
+              className="form-control"
+              type="text"
+              name="userForm[name]"
+              defaultValue={personalContainer.state.name}
+              onChange={(e) => { personalContainer.changeName(e.target.value) }}
+            />
+          </div>
+        </div>
+
+        <div className="row mb-3">
+          <label htmlFor="userForm[email]" className="col-sm-2 text-right">{t('Email')}</label>
+          <div className="col-sm-4 text-left">
+            <input
+              className="form-control"
+              type="text"
+              name="userForm[email]"
+              defaultValue={personalContainer.state.email}
+              onChange={(e) => { personalContainer.changeEmail(e.target.value) }}
+            />
+          </div>
+          {registrationWhiteList.length !== 0 && (
+            <div className="col-sm-offset-2 col-sm-10">
+              <div className="help-block">
+                {t('page_register.form_help.email')}
+                <ul>
+                  {registrationWhiteList.map(data => <li key={data}><code>{data}</code></li>)}
+                </ul>
+              </div>
+            </div>
+          )}
+        </div>
+
+        <div className="row mb-3">
+          <label className="col-xs-2 text-right">{t('Disclose E-mail')}</label>
+          <div className="col-xs-6">
+            <div className="radio radio-primary radio-inline">
+              <input
+                type="radio"
+                id="radioEmailShow"
+                name="userForm[isEmailPublished]"
+                checked={personalContainer.state.isEmailPublished}
+                onChange={() => { personalContainer.changeIsEmailPublished(true) }}
+              />
+              <label htmlFor="radioEmailShow">{t('Show')}</label>
+            </div>
+            <div className="radio radio-primary radio-inline">
+              <input
+                type="radio"
+                id="radioEmailHide"
+                name="userForm[isEmailPublished]"
+                checked={!personalContainer.state.isEmailPublished}
+                onChange={() => { personalContainer.changeIsEmailPublished(false) }}
+              />
+              <label htmlFor="radioEmailHide">{t('Hide')}</label>
+            </div>
+          </div>
+        </div>
+
+        <div className="row mb-3">
+          <label className="col-xs-2 text-right">{t('Language')}</label>
+          <div className="col-xs-6">
+            <div className="radio radio-primary radio-inline">
+              <input
+                type="radio"
+                id="radioLangEn"
+                name="userForm[lang]"
+                checked={personalContainer.state.lang === 'en-US'}
+                onChange={() => { personalContainer.changeLang('en-US') }}
+              />
+              <label htmlFor="radioLangEn">{t('English')}</label>
+            </div>
+            <div className="radio radio-primary radio-inline">
+              <input
+                type="radio"
+                id="radioLangJa"
+                name="userForm[lang]"
+                checked={personalContainer.state.lang === 'ja'}
+                onChange={() => { personalContainer.changeLang('ja') }}
+              />
+              <label htmlFor="radioLangJa">{t('Japanese')}</label>
+            </div>
+          </div>
+        </div>
+
+        <div className="row my-3">
+          <div className="col-xs-offset-4 col-xs-5">
+            <button type="button" className="btn btn-primary" onClick={this.onClickSubmit} disabled={personalContainer.state.retrieveError != null}>
+              {t('Update')}
+            </button>
+          </div>
+        </div>
+
+      </Fragment>
+    );
+  }
+
+}
+
+const BasicInfoSettingsWrapper = (props) => {
+  return createSubscribedElement(BasicInfoSettings, props, [AppContainer, PersonalContainer]);
+};
+
+BasicInfoSettings.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  personalContainer: PropTypes.instanceOf(PersonalContainer).isRequired,
+};
+
+export default withTranslation()(BasicInfoSettingsWrapper);

+ 75 - 0
src/client/js/components/Me/ExternalAccountLinkedMe.jsx

@@ -0,0 +1,75 @@
+
+import React, { Fragment } from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+
+import { createSubscribedElement } from '../UnstatedUtils';
+import { toastError } from '../../util/apiNotification';
+
+import AppContainer from '../../services/AppContainer';
+import PersonalContainer from '../../services/PersonalContainer';
+import ExternalAccountRow from './ExternalAccountRow';
+
+class ExternalAccountLinkedMe extends React.Component {
+
+  async componentDidMount() {
+    try {
+      await this.props.personalContainer.retrieveExternalAccounts();
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }
+
+  render() {
+    const { t, personalContainer } = this.props;
+    const { externalAccounts } = personalContainer.state;
+
+    return (
+      <Fragment>
+        <div className="container-fluid">
+          <h2 className="border-bottom">
+            <button type="button" className="btn btn-default btn-sm pull-right">
+              <i className="icon-plus" aria-hidden="true" />
+            Add
+            </button>
+            { t('External Accounts') }
+          </h2>
+        </div>
+
+        <div className="row">
+          <div className="col-md-12">
+            <table className="table table-bordered table-user-list">
+              <thead>
+                <tr>
+                  <th width="120px">Authentication Provider</th>
+                  <th>
+                    <code>accountId</code>
+                  </th>
+                  <th width="200px">{ t('Created') }</th>
+                  <th width="150px">{ t('Admin') }</th>
+                </tr>
+              </thead>
+              <tbody>
+                {externalAccounts !== 0 && externalAccounts.map(account => <ExternalAccountRow account={account} key={account._id} />)}
+              </tbody>
+            </table>
+          </div>
+        </div>
+      </Fragment>
+    );
+  }
+
+}
+
+const ExternalAccountLinkedMeWrapper = (props) => {
+  return createSubscribedElement(ExternalAccountLinkedMe, props, [AppContainer, PersonalContainer]);
+};
+
+ExternalAccountLinkedMe.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  personalContainer: PropTypes.instanceOf(PersonalContainer).isRequired,
+};
+
+export default withTranslation()(ExternalAccountLinkedMeWrapper);

+ 41 - 0
src/client/js/components/Me/ExternalAccountRow.jsx

@@ -0,0 +1,41 @@
+
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import { withTranslation } from 'react-i18next';
+import dateFnsFormat from 'date-fns/format';
+
+class ExternalAccountRow extends React.PureComponent {
+
+  render() {
+    const { t, account } = this.props;
+
+    return (
+      <tr>
+        <td>{ account.providerType }</td>
+        <td>
+          <strong>{ account.accountId }</strong>
+        </td>
+        <td>{dateFnsFormat(new Date(account.createdAt), 'yyyy-MM-dd')}</td>
+        <td className="text-center">
+          <button
+            type="button"
+            className="btn btn-default btn-sm btn-danger"
+          >
+            <i className="ti-unlink"></i>
+            { t('Diassociate') }
+          </button>
+        </td>
+      </tr>
+    );
+  }
+
+}
+
+ExternalAccountRow.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+
+  account: PropTypes.object.isRequired,
+};
+
+export default withTranslation()(ExternalAccountRow);

+ 147 - 0
src/client/js/components/Me/PasswordSettings.jsx

@@ -0,0 +1,147 @@
+
+import React from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+
+
+import { toastSuccess, toastError } from '../../util/apiNotification';
+import { createSubscribedElement } from '../UnstatedUtils';
+
+import AppContainer from '../../services/AppContainer';
+import PersonalContainer from '../../services/PersonalContainer';
+
+
+class PasswordSettings extends React.Component {
+
+  constructor(appContainer) {
+    super();
+
+    this.appContainer = appContainer;
+
+    this.state = {
+      retrieveError: null,
+      oldPassword: '',
+      newPassword: '',
+      newPasswordConfirm: '',
+    };
+
+    this.onClickSubmit = this.onClickSubmit.bind(this);
+    this.onChangeOldPassword = this.onChangeOldPassword.bind(this);
+
+  }
+
+  async onClickSubmit() {
+    const { t, appContainer, personalContainer } = this.props;
+    const { oldPassword, newPassword, newPasswordConfirm } = this.state;
+
+    try {
+      await appContainer.apiv3Put('/personal-setting/password', {
+        oldPassword, newPassword, newPasswordConfirm,
+      });
+      this.setState({ oldPassword: '', newPassword: '', newPasswordConfirm: '' });
+      await personalContainer.retrievePersonalData();
+      toastSuccess(t('toaster.update_successed', { target: t('personal_settings.update_password') }));
+    }
+    catch (err) {
+      toastError(err);
+    }
+
+  }
+
+  onChangeOldPassword(oldPassword) {
+    this.setState({ oldPassword });
+  }
+
+  onChangeNewPassword(newPassword) {
+    this.setState({ newPassword });
+  }
+
+  onChangeNewPasswordConfirm(newPasswordConfirm) {
+    this.setState({ newPasswordConfirm });
+  }
+
+  render() {
+    const { t, personalContainer } = this.props;
+    const { newPassword, newPasswordConfirm } = this.state;
+    const isIncorrectConfirmPassword = (newPassword !== newPasswordConfirm);
+
+    return (
+      <React.Fragment>
+        {(!personalContainer.state.isPasswordSet) && <div className="alert alert-warning m-t-10">{ t('Password is not set') }</div>}
+        <div className="mb-5 container-fluid">
+          {(personalContainer.state.isPasswordSet)
+            ? <h2 className="border-bottom">{t('personal_settings.update_password')}</h2>
+          : <h2 className="border-bottom">{t('personal_settings.set_new_password')}</h2>}
+        </div>
+        {(personalContainer.state.isPasswordSet)
+        && (
+          <div className="row mb-3">
+            <label htmlFor="oldPassword" className="col-xs-3 text-right">{ t('personal_settings.current_password') }</label>
+            <div className="col-xs-6">
+              <input
+                className="form-control"
+                type="password"
+                name="oldPassword"
+                value={this.state.oldPassword}
+                onChange={(e) => { this.onChangeOldPassword(e.target.value) }}
+              />
+            </div>
+          </div>
+        )}
+        <div className="row mb-3">
+          <label htmlFor="newPassword" className="col-xs-3 text-right">{t('personal_settings.new_password') }</label>
+          <div className="col-xs-6">
+            <input
+              className="form-control"
+              type="password"
+              name="newPassword"
+              value={this.state.newPassword}
+              onChange={(e) => { this.onChangeNewPassword(e.target.value) }}
+            />
+          </div>
+        </div>
+        <div className={`row mb-3 ${isIncorrectConfirmPassword && 'has-error'}`}>
+          <label htmlFor="newPasswordConfirm" className="col-xs-3 text-right">{t('personal_settings.new_password_confirm') }</label>
+          <div className="col-xs-6">
+            <input
+              className="form-control col-xs-4"
+              type="password"
+              name="newPasswordConfirm"
+              value={this.state.newPasswordConfirm}
+              onChange={(e) => { this.onChangeNewPasswordConfirm(e.target.value) }}
+            />
+
+            <p className="help-block">{t('page_register.form_help.password') }</p>
+          </div>
+        </div>
+
+        <div className="row my-3">
+          <div className="col-xs-offset-4 col-xs-5">
+            <button
+              type="button"
+              className="btn btn-primary"
+              onClick={this.onClickSubmit}
+              disabled={this.state.retrieveError != null || isIncorrectConfirmPassword}
+            >
+              {t('Update')}
+            </button>
+          </div>
+        </div>
+      </React.Fragment>
+    );
+  }
+
+}
+
+
+const PasswordSettingsWrapper = (props) => {
+  return createSubscribedElement(PasswordSettings, props, [AppContainer, PersonalContainer]);
+};
+
+PasswordSettings.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  personalContainer: PropTypes.instanceOf(PersonalContainer).isRequired,
+};
+
+export default withTranslation()(PasswordSettingsWrapper);

+ 61 - 0
src/client/js/components/Me/PersonalSettings.jsx

@@ -0,0 +1,61 @@
+
+import React, { Fragment } from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+
+import UserSettings from './UserSettings';
+import PasswordSettings from './PasswordSettings';
+import ExternalAccountLinkedMe from './ExternalAccountLinkedMe';
+import ApiSettings from './ApiSettings';
+
+class PersonalSettings extends React.Component {
+
+  render() {
+    const { t } = this.props;
+
+    return (
+      <Fragment>
+        {/* TODO GW-226 adapt BS4 */}
+        <div className="m-t-10">
+          <div className="personal-settings">
+            <ul className="nav nav-tabs" role="tablist">
+              <li className="active">
+                <a href="#user-settings" data-toggle="tab" role="tab"><i className="icon-user"></i> { t('User Information') }</a>
+              </li>
+              <li>
+                <a href="#external-accounts" data-toggle="tab" role="tab"><i className="icon-share-alt"></i> { t('External Accounts') }</a>
+              </li>
+              <li>
+                <a href="#password-settings" data-toggle="tab" role="tab"><i className="icon-lock"></i> { t('Password Settings') }</a>
+              </li>
+              <li>
+                <a href="#apiToken" data-toggle="tab" role="tab"><i className="icon-paper-plane"></i> { t('API Settings') }</a>
+              </li>
+            </ul>
+            <div className="tab-content p-t-10">
+              <div id="user-settings" className="tab-pane active" role="tabpanel">
+                <UserSettings />
+              </div>
+              <div id="external-accounts" className="tab-pane" role="tabpanel">
+                <ExternalAccountLinkedMe />
+              </div>
+              <div id="password-settings" className="tab-pane" role="tabpanel">
+                <PasswordSettings />
+              </div>
+              <div id="apiToken" className="tab-pane" role="tabpanel">
+                <ApiSettings />
+              </div>
+            </div>
+          </div>
+        </div>
+      </Fragment>
+    );
+  }
+
+}
+
+PersonalSettings.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+};
+
+export default withTranslation()(PersonalSettings);

+ 118 - 0
src/client/js/components/Me/ProfileImageSettings.jsx

@@ -0,0 +1,118 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+import md5 from 'md5';
+
+import { toastSuccess, toastError } from '../../util/apiNotification';
+import { createSubscribedElement } from '../UnstatedUtils';
+
+import AppContainer from '../../services/AppContainer';
+import PersonalContainer from '../../services/PersonalContainer';
+
+class ProfileImageSettings extends React.Component {
+
+  constructor(appContainer) {
+    super();
+
+    this.onClickSubmit = this.onClickSubmit.bind(this);
+  }
+
+  async onClickSubmit() {
+    const { t, personalContainer } = this.props;
+
+    try {
+      await personalContainer.updateProfileImage();
+      toastSuccess(t('toaster.update_successed', { target: t('Set Profile Image') }));
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }
+
+  generateGravatarSrc() {
+    const email = this.props.personalContainer.state.email || '';
+    const hash = md5(email.trim().toLowerCase());
+    return `https://gravatar.com/avatar/${hash}`;
+  }
+
+  render() {
+    const { t, personalContainer } = this.props;
+
+    return (
+      <React.Fragment>
+        <div className="row">
+          <div className="col-md-2 col-sm-offset-1 col-sm-4">
+            <h4>
+              <div className="radio radio-primary">
+                <input
+                  type="radio"
+                  id="radioGravatar"
+                  form="formImageType"
+                  name="imagetypeForm[isGravatarEnabled]"
+                  checked={personalContainer.state.isGravatarEnabled}
+                  onChange={() => { personalContainer.changeIsGravatarEnabled(true) }}
+                />
+                <label htmlFor="radioGravatar">
+                  <img src="https://gravatar.com/avatar/00000000000000000000000000000000?s=24" /> Gravatar
+                </label>
+                <a href="https://gravatar.com/">
+                  <small><i className="icon-arrow-right-circle" aria-hidden="true"></i></small>
+                </a>
+              </div>
+            </h4>
+
+            <img src={this.generateGravatarSrc()} width="64" />
+          </div>
+
+          <div className="col-md-4 col-sm-7">
+            <h4>
+              <div className="radio radio-primary">
+                <input
+                  type="radio"
+                  id="radioUploadPicture"
+                  form="formImageType"
+                  name="imagetypeForm[isGravatarEnabled]"
+                  checked={!personalContainer.state.isGravatarEnabled}
+                  onChange={() => { personalContainer.changeIsGravatarEnabled(false) }}
+                />
+                <label htmlFor="radioUploadPicture">
+                  { t('Upload Image') }
+                </label>
+              </div>
+            </h4>
+            <div className="form-group">
+              <div id="pictureUploadFormMessage"></div>
+              <label className="col-sm-4 control-label">
+                { t('Current Image') }
+              </label>
+              {/* TDOO GW-1198 uproad profile image */}
+            </div>
+          </div>
+        </div>
+
+        <div className="row my-3">
+          <div className="col-xs-offset-4 col-xs-5">
+            <button type="button" className="btn btn-primary" onClick={this.onClickSubmit} disabled={personalContainer.state.retrieveError != null}>
+              {t('Update')}
+            </button>
+          </div>
+        </div>
+
+      </React.Fragment>
+    );
+  }
+
+}
+
+
+const ProfileImageSettingsWrapper = (props) => {
+  return createSubscribedElement(ProfileImageSettings, props, [AppContainer, PersonalContainer]);
+};
+
+ProfileImageSettings.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  personalContainer: PropTypes.instanceOf(PersonalContainer).isRequired,
+};
+
+export default withTranslation()(ProfileImageSettingsWrapper);

+ 37 - 0
src/client/js/components/Me/UserSettings.jsx

@@ -0,0 +1,37 @@
+
+import React, { Fragment } from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+
+import BasicInfoSettings from './BasicInfoSettings';
+import ProfileImageSettings from './ProfileImageSettings';
+
+class UserSettings extends React.Component {
+
+  render() {
+    const { t } = this.props;
+
+    return (
+      <Fragment>
+        <div className="mb-5 container-fluid">
+          <h2 className="border-bottom">{t('Basic Info')}</h2>
+          <BasicInfoSettings />
+        </div>
+
+        <div className="mb-5 container-fluid">
+          <h2 className="border-bottom">{t('Set Profile Image')}</h2>
+          <ProfileImageSettings />
+        </div>
+
+      </Fragment>
+    );
+  }
+
+}
+
+
+UserSettings.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+};
+
+export default withTranslation()(UserSettings);

+ 60 - 0
src/client/js/components/Navbar/PersonalDropdown.jsx

@@ -0,0 +1,60 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import { withTranslation } from 'react-i18next';
+
+import { createSubscribedElement } from '../UnstatedUtils';
+import AppContainer from '../../services/AppContainer';
+
+import UserPicture from '../User/UserPicture';
+
+const PersonalDropdown = (props) => {
+
+  const { t, appContainer } = props;
+  const user = appContainer.currentUser || {};
+
+  const logoutHandler = () => {
+    const { interceptorManager } = appContainer;
+
+    const context = {
+      user,
+      currentPagePath: decodeURIComponent(window.location.pathname),
+    };
+    interceptorManager.process('logout', context);
+
+    window.location.href = '/logout';
+  };
+
+  return (
+    <>
+      <a className="dropdown-toggle waves-effect waves-light" data-toggle="dropdown">
+        <UserPicture user={user} withoutLink />&nbsp;{user.name}
+      </a>
+      <ul className="dropdown-menu dropdown-menu-right">
+        <li><a href={`/user/${user.username}`}><i className="icon-fw icon-home"></i>{ t('Home') }</a></li>
+        <li><a href="/me"><i className="icon-fw icon-wrench"></i>{ t('User Settings') }</a></li>
+        <li role="separator" className="divider"></li>
+        <li><a href={`/user/${user.username}#user-draft-list`}><i className="icon-fw icon-docs"></i>{ t('List Drafts') }</a></li>
+        <li><a href="/trash"><i className="icon-fw icon-trash"></i>{ t('Deleted Pages') }</a></li>
+        <li role="separator" className="divider"></li>
+        <li><a role="button" onClick={logoutHandler}><i className="icon-fw icon-power"></i>{ t('Sign out') }</a></li>
+      </ul>
+    </>
+  );
+
+};
+
+/**
+ * Wrapper component for using unstated
+ */
+const PersonalDropdownWrapper = (props) => {
+  return createSubscribedElement(PersonalDropdown, props, [AppContainer]);
+};
+
+
+PersonalDropdown.propTypes = {
+  t: PropTypes.func.isRequired, //  i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+};
+
+export default withTranslation()(PersonalDropdownWrapper);

+ 1 - 1
src/client/js/components/Page/RevisionBody.jsx

@@ -3,7 +3,7 @@ import PropTypes from 'prop-types';
 
 import { debounce } from 'throttle-debounce';
 
-export default class RevisionBody extends React.Component {
+export default class RevisionBody extends React.PureComponent {
 
   constructor(props) {
     super(props);

+ 49 - 41
src/client/js/components/Page/RevisionRenderer.jsx

@@ -8,7 +8,7 @@ import GrowiRenderer from '../../util/GrowiRenderer';
 
 import RevisionBody from './RevisionBody';
 
-class RevisionRenderer extends React.Component {
+class RevisionRenderer extends React.PureComponent {
 
   constructor(props) {
     super(props);
@@ -21,12 +21,32 @@ class RevisionRenderer extends React.Component {
     this.getHighlightedBody = this.getHighlightedBody.bind(this);
   }
 
-  componentWillMount() {
-    this.renderHtml(this.props.markdown, this.props.highlightKeywords);
+  initCurrentRenderingContext() {
+    this.currentRenderingContext = {
+      markdown: this.props.markdown,
+      currentPagePath: this.props.pageContainer.state.path,
+    };
+  }
+
+  componentDidMount() {
+    this.initCurrentRenderingContext();
+    this.renderHtml();
   }
 
-  componentWillReceiveProps(nextProps) {
-    this.renderHtml(nextProps.markdown, this.props.highlightKeywords);
+  componentDidUpdate(prevProps) {
+    const { markdown: prevMarkdown, highlightKeywords: prevHighlightKeywords } = prevProps;
+    const { markdown, highlightKeywords } = this.props;
+
+    // render only when props.markdown is updated
+    if (markdown !== prevMarkdown || highlightKeywords !== prevHighlightKeywords) {
+      this.initCurrentRenderingContext();
+      this.renderHtml();
+      return;
+    }
+
+    const { interceptorManager } = this.props.appContainer;
+
+    interceptorManager.process('postRenderHtml', this.currentRenderingContext);
   }
 
   /**
@@ -51,42 +71,30 @@ class RevisionRenderer extends React.Component {
     return returnBody;
   }
 
-  renderHtml(markdown) {
-    const { pageContainer } = this.props;
-
-    const context = {
-      markdown,
-      currentPagePath: pageContainer.state.path,
-    };
-
-    const growiRenderer = this.props.growiRenderer;
-    const interceptorManager = this.props.appContainer.interceptorManager;
-    interceptorManager.process('preRender', context)
-      .then(() => { return interceptorManager.process('prePreProcess', context) })
-      .then(() => {
-        context.markdown = growiRenderer.preProcess(context.markdown);
-      })
-      .then(() => { return interceptorManager.process('postPreProcess', context) })
-      .then(() => {
-        context.parsedHTML = growiRenderer.process(context.markdown);
-      })
-      .then(() => { return interceptorManager.process('prePostProcess', context) })
-      .then(() => {
-        context.parsedHTML = growiRenderer.postProcess(context.parsedHTML);
-
-        // highlight
-        if (this.props.highlightKeywords != null) {
-          context.parsedHTML = this.getHighlightedBody(context.parsedHTML, this.props.highlightKeywords);
-        }
-      })
-      .then(() => { return interceptorManager.process('postPostProcess', context) })
-      .then(() => { return interceptorManager.process('preRenderHtml', context) })
-      .then(() => {
-        this.setState({ html: context.parsedHTML });
-      })
-      // process interceptors for post rendering
-      .then(() => { return interceptorManager.process('postRenderHtml', context) });
-
+  async renderHtml() {
+    const {
+      appContainer, growiRenderer,
+      highlightKeywords,
+    } = this.props;
+
+    const { interceptorManager } = appContainer;
+    const context = this.currentRenderingContext;
+
+    await interceptorManager.process('preRender', context);
+    await interceptorManager.process('prePreProcess', context);
+    context.markdown = growiRenderer.preProcess(context.markdown);
+    await interceptorManager.process('postPreProcess', context);
+    context.parsedHTML = growiRenderer.process(context.markdown);
+    await interceptorManager.process('prePostProcess', context);
+    context.parsedHTML = growiRenderer.postProcess(context.parsedHTML);
+
+    if (this.props.highlightKeywords != null) {
+      context.parsedHTML = this.getHighlightedBody(context.parsedHTML, highlightKeywords);
+    }
+    await interceptorManager.process('postPostProcess', context);
+    await interceptorManager.process('preRenderHtml', context);
+
+    this.setState({ html: context.parsedHTML });
   }
 
   render() {

+ 1 - 1
src/client/js/components/PageAttachment.jsx

@@ -89,7 +89,7 @@ class PageAttachment extends React.Component {
   }
 
   isUserLoggedIn() {
-    return this.props.appContainer.me != null;
+    return this.props.appContainer.currentUser != null;
   }
 
   render() {

+ 56 - 151
src/client/js/components/PageComment/Comment.jsx

@@ -3,10 +3,8 @@ import PropTypes from 'prop-types';
 
 import { format, formatDistanceStrict } from 'date-fns';
 
-import Button from 'react-bootstrap/es/Button';
 import Tooltip from 'react-bootstrap/es/Tooltip';
 import OverlayTrigger from 'react-bootstrap/es/OverlayTrigger';
-import Collapse from 'react-bootstrap/es/Collapse';
 
 import AppContainer from '../../services/AppContainer';
 import PageContainer from '../../services/PageContainer';
@@ -16,6 +14,7 @@ import RevisionBody from '../Page/RevisionBody';
 import UserPicture from '../User/UserPicture';
 import Username from '../User/Username';
 import CommentEditor from './CommentEditor';
+import CommentControl from './CommentControl';
 
 /**
  *
@@ -25,15 +24,14 @@ import CommentEditor from './CommentEditor';
  * @class Comment
  * @extends {React.Component}
  */
-class Comment extends React.Component {
+class Comment extends React.PureComponent {
 
   constructor(props) {
     super(props);
 
     this.state = {
       html: '',
-      isOlderRepliesShown: false,
-      showReEditorIds: new Set(),
+      isReEdit: false,
     };
 
     this.growiRenderer = this.props.appContainer.getRenderer('comment');
@@ -42,23 +40,39 @@ class Comment extends React.Component {
     this.isCurrentRevision = this.isCurrentRevision.bind(this);
     this.getRootClassName = this.getRootClassName.bind(this);
     this.getRevisionLabelClassName = this.getRevisionLabelClassName.bind(this);
+    this.editBtnClickedHandler = this.editBtnClickedHandler.bind(this);
     this.deleteBtnClickedHandler = this.deleteBtnClickedHandler.bind(this);
     this.renderText = this.renderText.bind(this);
     this.renderHtml = this.renderHtml.bind(this);
     this.commentButtonClickedHandler = this.commentButtonClickedHandler.bind(this);
   }
 
-  componentWillMount() {
-    this.renderHtml(this.props.comment.comment);
+
+  initCurrentRenderingContext() {
+    this.currentRenderingContext = {
+      markdown: this.props.comment.comment,
+    };
   }
 
-  componentWillReceiveProps(nextProps) {
-    this.renderHtml(nextProps.comment.comment);
+  componentDidMount() {
+    this.initCurrentRenderingContext();
+    this.renderHtml();
   }
 
-  // not used
-  setMarkdown(markdown) {
-    this.renderHtml(markdown);
+  componentDidUpdate(prevProps) {
+    const { comment: prevComment } = prevProps;
+    const { comment } = this.props;
+
+    // render only when props.markdown is updated
+    if (comment !== prevComment) {
+      this.initCurrentRenderingContext();
+      this.renderHtml();
+      return;
+    }
+
+    const { interceptorManager } = this.props.appContainer;
+
+    interceptorManager.process('postRenderCommentHtml', this.currentRenderingContext);
   }
 
   checkPermissionToControlComment() {
@@ -66,7 +80,7 @@ class Comment extends React.Component {
   }
 
   isCurrentUserEqualsToAuthor() {
-    return this.props.comment.creator.username === this.props.appContainer.me;
+    return this.props.comment.creator.username === this.props.appContainer.currentUsername;
   }
 
   isCurrentRevision() {
@@ -99,18 +113,12 @@ class Comment extends React.Component {
       this.isCurrentRevision() ? 'label-primary' : 'label-default'}`;
   }
 
-  editBtnClickedHandler(commentId) {
-    const ids = this.state.showReEditorIds.add(commentId);
-    this.setState({ showReEditorIds: ids });
+  editBtnClickedHandler() {
+    this.setState({ isReEdit: !this.state.isReEdit });
   }
 
-  commentButtonClickedHandler(commentId) {
-    this.setState((prevState) => {
-      prevState.showReEditorIds.delete(commentId);
-      return {
-        showReEditorIds: prevState.showReEditorIds,
-      };
-    });
+  commentButtonClickedHandler() {
+    this.editBtnClickedHandler();
   }
 
   deleteBtnClickedHandler() {
@@ -134,120 +142,23 @@ class Comment extends React.Component {
     );
   }
 
-  toggleOlderReplies() {
-    this.setState((prevState) => {
-      return {
-        showOlderReplies: !prevState.showOlderReplies,
-      };
-    });
-  }
-
-  renderHtml(markdown) {
-    const context = {
-      markdown,
-    };
-
-    const growiRenderer = this.props.growiRenderer;
-    const interceptorManager = this.props.appContainer.interceptorManager;
-    interceptorManager.process('preRenderComment', context)
-      .then(() => { return interceptorManager.process('prePreProcess', context) })
-      .then(() => {
-        context.markdown = growiRenderer.preProcess(context.markdown);
-      })
-      .then(() => { return interceptorManager.process('postPreProcess', context) })
-      .then(() => {
-        const parsedHTML = growiRenderer.process(context.markdown);
-        context.parsedHTML = parsedHTML;
-      })
-      .then(() => { return interceptorManager.process('prePostProcess', context) })
-      .then(() => {
-        context.parsedHTML = growiRenderer.postProcess(context.parsedHTML);
-      })
-      .then(() => { return interceptorManager.process('postPostProcess', context) })
-      .then(() => { return interceptorManager.process('preRenderCommentHtml', context) })
-      .then(() => {
-        this.setState({ html: context.parsedHTML });
-      })
-      // process interceptors for post rendering
-      .then(() => { return interceptorManager.process('postRenderCommentHtml', context) });
-
-  }
-
-  renderReply(reply) {
-    return (
-      <div key={reply._id} className="page-comment-reply">
-        <CommentWrapper
-          comment={reply}
-          deleteBtnClicked={this.props.deleteBtnClicked}
-          growiRenderer={this.props.growiRenderer}
-        />
-      </div>
-    );
-  }
-
-  renderReplies() {
-    const layoutType = this.props.appContainer.getConfig().layoutType;
-    const isBaloonStyle = layoutType.match(/crowi-plus|growi|kibela/);
-
-    let replyList = this.props.replyList;
-    if (!isBaloonStyle) {
-      replyList = replyList.slice().reverse();
-    }
-
-    const areThereHiddenReplies = replyList.length > 2;
-
-    const { isOlderRepliesShown } = this.state;
-    const toggleButtonIconName = isOlderRepliesShown ? 'icon-arrow-up' : 'icon-options-vertical';
-    const toggleButtonIcon = <i className={`icon-fw ${toggleButtonIconName}`}></i>;
-    const toggleButtonLabel = isOlderRepliesShown ? '' : 'more';
-    const toggleButton = (
-      <Button
-        bsStyle="link"
-        className="page-comments-list-toggle-older"
-        onClick={() => { this.setState({ isOlderRepliesShown: !isOlderRepliesShown }) }}
-      >
-        {toggleButtonIcon} {toggleButtonLabel}
-      </Button>
-    );
-
-    const shownReplies = replyList.slice(replyList.length - 2, replyList.length);
-    const hiddenReplies = replyList.slice(0, replyList.length - 2);
-
-    const hiddenElements = hiddenReplies.map((reply) => {
-      return this.renderReply(reply);
-    });
-
-    const shownElements = shownReplies.map((reply) => {
-      return this.renderReply(reply);
-    });
-
-    return (
-      <React.Fragment>
-        { areThereHiddenReplies && (
-          <div className="page-comments-hidden-replies">
-            <Collapse in={this.state.isOlderRepliesShown}>
-              <div>{hiddenElements}</div>
-            </Collapse>
-            <div className="text-center">{toggleButton}</div>
-          </div>
-        ) }
+  async renderHtml() {
 
-        {shownElements}
-      </React.Fragment>
-    );
-  }
+    const { growiRenderer, appContainer } = this.props;
+    const { interceptorManager } = appContainer;
+    const context = this.currentRenderingContext;
 
-  renderCommentControl(comment) {
-    return (
-      <div className="page-comment-control">
-        <button type="button" className="btn btn-link p-2" onClick={() => { this.editBtnClickedHandler(comment._id) }}>
-          <i className="ti-pencil"></i>
-        </button>
-        <button type="button" className="btn btn-link p-2 mr-2" onClick={this.deleteBtnClickedHandler}>
-          <i className="ti-close"></i>
-        </button>
-      </div>
-    );
+    await interceptorManager.process('preRenderComment', context);
+    await interceptorManager.process('prePreProcess', context);
+    context.markdown = await growiRenderer.preProcess(context.markdown);
+    await interceptorManager.process('postPreProcess', context);
+    context.parsedHTML = await growiRenderer.process(context.markdown);
+    await interceptorManager.process('prePostProcess', context);
+    context.parsedHTML = await growiRenderer.postProcess(context.parsedHTML);
+    await interceptorManager.process('postPostProcess', context);
+    await interceptorManager.process('preRenderCommentHtml', context);
+    this.setState({ html: context.parsedHTML });
+    await interceptorManager.process('postRenderCommentHtml', context);
   }
 
   render() {
@@ -259,8 +170,6 @@ class Comment extends React.Component {
     const updatedAt = new Date(comment.updatedAt);
     const isEdited = createdAt < updatedAt;
 
-    const showReEditor = this.state.showReEditorIds.has(commentId);
-
     const rootClassName = this.getRootClassName(comment);
     const commentDate = formatDistanceStrict(createdAt, new Date());
     const commentBody = isMarkdown ? this.renderRevisionBody() : this.renderText(comment.comment);
@@ -284,7 +193,7 @@ class Comment extends React.Component {
     return (
       <React.Fragment>
 
-        {showReEditor ? (
+        {this.state.isReEdit ? (
           <CommentEditor
             growiRenderer={this.growiRenderer}
             currentCommentId={commentId}
@@ -305,19 +214,19 @@ class Comment extends React.Component {
                 <OverlayTrigger overlay={commentDateTooltip} placement="bottom">
                   <span><a href={`#${commentId}`}>{commentDate}</a></span>
                 </OverlayTrigger>
-                { isEdited && (
-                  <OverlayTrigger overlay={editedDateTooltip} placement="bottom">
-                    <span>&nbsp;(edited)</span>
-                  </OverlayTrigger>
-                ) }
+                {isEdited && (
+                <OverlayTrigger overlay={editedDateTooltip} placement="bottom">
+                  <span>&nbsp;(edited)</span>
+                </OverlayTrigger>
+                  )}
                 <span className="ml-2"><a className={revisionLavelClassName} href={revHref}>{revFirst8Letters}</a></span>
               </div>
-              { this.checkPermissionToControlComment() && this.renderCommentControl(comment) }
+              {this.checkPermissionToControlComment()
+                  && <CommentControl onClickDeleteBtn={this.deleteBtnClickedHandler} onClickEditBtn={this.editBtnClickedHandler} />}
             </div>
           </div>
-        )
-      }
-        {this.renderReplies()}
+          )
+        }
 
       </React.Fragment>
     );
@@ -339,10 +248,6 @@ Comment.propTypes = {
   comment: PropTypes.object.isRequired,
   growiRenderer: PropTypes.object.isRequired,
   deleteBtnClicked: PropTypes.func.isRequired,
-  replyList: PropTypes.array,
-};
-Comment.defaultProps = {
-  replyList: [],
 };
 
 export default CommentWrapper;

+ 24 - 0
src/client/js/components/PageComment/CommentControl.jsx

@@ -0,0 +1,24 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+
+const CommentControl = (props) => {
+  return (
+    <div className="page-comment-control">
+      <button type="button" className="btn btn-link p-2" onClick={props.onClickEditBtn}>
+        <i className="ti-pencil"></i>
+      </button>
+      <button type="button" className="btn btn-link p-2 mr-2" onClick={props.onClickDeleteBtn}>
+        <i className="ti-close"></i>
+      </button>
+    </div>
+  );
+};
+
+CommentControl.propTypes = {
+
+  onClickEditBtn: PropTypes.func.isRequired,
+  onClickDeleteBtn: PropTypes.func.isRequired,
+};
+
+export default CommentControl;

+ 1 - 3
src/client/js/components/PageComment/CommentEditor.jsx

@@ -212,8 +212,6 @@ class CommentEditor extends React.Component {
 
   render() {
     const { appContainer, commentContainer } = this.props;
-    const username = appContainer.me;
-    const user = appContainer.findUser(username);
     const commentPreview = this.state.isMarkdown ? this.getCommentHtml() : null;
     const emojiStrategy = appContainer.getEmojiStrategy();
 
@@ -236,7 +234,7 @@ class CommentEditor extends React.Component {
         <div className="comment-form">
           { isBaloonStyle && (
             <div className="comment-form-user">
-              <UserPicture user={user} />
+              <UserPicture user={appContainer.currentUser} />
             </div>
           ) }
           <div className="comment-form-main">

+ 2 - 3
src/client/js/components/PageComment/CommentEditorLazyRenderer.jsx

@@ -27,9 +27,8 @@ class CommentEditorLazyRenderer extends React.Component {
 
   render() {
     const { appContainer } = this.props;
-    const username = appContainer.me;
-    const isLoggedIn = username != null;
-    const user = appContainer.findUser(username);
+    const user = appContainer.currentUser;
+    const isLoggedIn = user != null;
 
     const layoutType = this.props.appContainer.getConfig().layoutType;
     const isBaloonStyle = layoutType.match(/crowi-plus|growi|kibela/);

+ 124 - 0
src/client/js/components/PageComment/ReplayComments.jsx

@@ -0,0 +1,124 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import Button from 'react-bootstrap/es/Button';
+import Collapse from 'react-bootstrap/es/Collapse';
+
+import AppContainer from '../../services/AppContainer';
+import PageContainer from '../../services/PageContainer';
+
+import Comment from './Comment';
+
+import { createSubscribedElement } from '../UnstatedUtils';
+
+class ReplayComments extends React.PureComponent {
+
+  constructor() {
+    super();
+
+    this.state = {
+      isOlderRepliesShown: false,
+    };
+
+    this.toggleIsOlderRepliesShown = this.toggleIsOlderRepliesShown.bind(this);
+  }
+
+  toggleIsOlderRepliesShown() {
+    this.setState({ isOlderRepliesShown: !this.state.isOlderRepliesShown });
+  }
+
+  renderReply(reply) {
+    return (
+      <div key={reply._id} className="page-comment-reply">
+        <Comment
+          comment={reply}
+          deleteBtnClicked={this.props.deleteBtnClicked}
+          growiRenderer={this.props.growiRenderer}
+        />
+      </div>
+    );
+  }
+
+  render() {
+
+    const layoutType = this.props.appContainer.getConfig().layoutType;
+    const isBaloonStyle = layoutType.match(/crowi-plus|growi|kibela/);
+
+    const isAllReplyShown = this.props.appContainer.getConfig().isAllReplyShown || false;
+
+    let replyList = this.props.replyList;
+    if (!isBaloonStyle) {
+      replyList = replyList.slice().reverse();
+    }
+
+    if (isAllReplyShown) {
+      return (
+        <React.Fragment>
+          {replyList.map((reply) => {
+            return this.renderReply(reply);
+          })}
+        </React.Fragment>
+      );
+    }
+
+    const areThereHiddenReplies = (replyList.length > 2);
+
+    const { isOlderRepliesShown } = this.state;
+    const toggleButtonIconName = isOlderRepliesShown ? 'icon-arrow-up' : 'icon-options-vertical';
+    const toggleButtonIcon = <i className={`icon-fw ${toggleButtonIconName}`}></i>;
+    const toggleButtonLabel = isOlderRepliesShown ? '' : 'more';
+
+    const shownReplies = replyList.slice(replyList.length - 2, replyList.length);
+    const hiddenReplies = replyList.slice(0, replyList.length - 2);
+
+    const hiddenElements = hiddenReplies.map((reply) => {
+      return this.renderReply(reply);
+    });
+
+    const shownElements = shownReplies.map((reply) => {
+      return this.renderReply(reply);
+    });
+
+    return (
+      <React.Fragment>
+        {areThereHiddenReplies && (
+          <div className="page-comments-hidden-replies">
+            <Collapse in={this.state.isOlderRepliesShown}>
+              <div>{hiddenElements}</div>
+            </Collapse>
+            <div className="text-center">
+              <Button
+                bsStyle="link"
+                className="page-comments-list-toggle-older"
+                onClick={this.toggleIsOlderRepliesShown}
+              >
+                {toggleButtonIcon} {toggleButtonLabel}
+              </Button>
+            </div>
+          </div>
+        )}
+        {shownElements}
+
+      </React.Fragment>
+    );
+  }
+
+}
+
+/**
+ * Wrapper component for using unstated
+ */
+const ReplayCommentsWrapper = (props) => {
+  return createSubscribedElement(ReplayComments, props, [AppContainer, PageContainer]);
+};
+
+ReplayComments.propTypes = {
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
+
+  growiRenderer: PropTypes.object.isRequired,
+  deleteBtnClicked: PropTypes.func.isRequired,
+  replyList: PropTypes.array,
+};
+
+export default ReplayCommentsWrapper;

+ 8 - 2
src/client/js/components/PageComments.jsx

@@ -14,6 +14,7 @@ import { createSubscribedElement } from './UnstatedUtils';
 import CommentEditor from './PageComment/CommentEditor';
 import Comment from './PageComment/Comment';
 import DeleteCommentModal from './PageComment/DeleteCommentModal';
+import ReplayComments from './PageComment/ReplayComments';
 
 
 /**
@@ -127,7 +128,7 @@ class PageComments extends React.Component {
   renderThread(comment, replies) {
     const commentId = comment._id;
     const showEditor = this.state.showEditorIds.has(commentId);
-    const isLoggedIn = this.props.appContainer.me != null;
+    const isLoggedIn = this.props.appContainer.currentUser != null;
 
     let rootClassNames = 'page-comment-thread';
     if (replies.length === 0) {
@@ -138,11 +139,16 @@ class PageComments extends React.Component {
       <div key={commentId} className={`mb-5 ${rootClassNames}`}>
         <Comment
           comment={comment}
-          editBtnClicked={this.confirmToEditComment}
           deleteBtnClicked={this.confirmToDeleteComment}
           growiRenderer={this.growiRenderer}
+        />
+        {replies.length !== 0 && (
+        <ReplayComments
           replyList={replies}
+          deleteBtnClicked={this.confirmToDeleteComment}
+          growiRenderer={this.growiRenderer}
         />
+        )}
         { !showEditor && isLoggedIn && (
           <div className="text-right">
             <Button

+ 5 - 44
src/client/js/components/PageEditor.jsx

@@ -44,9 +44,6 @@ class PageEditor extends React.Component {
     this.saveDraft = this.saveDraft.bind(this);
     this.clearDraft = this.clearDraft.bind(this);
 
-    // get renderer
-    this.growiRenderer = this.props.appContainer.getRenderer('editor');
-
     // for scrolling
     this.lastScrolledDateWithCursor = null;
     this.isOriginOfScrollSyncEditor = false;
@@ -56,15 +53,14 @@ class PageEditor extends React.Component {
     this.scrollPreviewByEditorLineWithThrottle = throttle(20, this.scrollPreviewByEditorLine);
     this.scrollPreviewByCursorMovingWithThrottle = throttle(20, this.scrollPreviewByCursorMoving);
     this.scrollEditorByPreviewScrollWithThrottle = throttle(20, this.scrollEditorByPreviewScroll);
-    this.renderPreviewWithDebounce = debounce(50, throttle(100, this.renderPreview));
+    this.setMarkdownStateWithDebounce = debounce(50, throttle(100, (value) => {
+      this.setState({ markdown: value });
+    }));
     this.saveDraftWithDebounce = debounce(800, this.saveDraft);
   }
 
   componentWillMount() {
     this.props.appContainer.registerComponentInstance('PageEditor', this);
-
-    // initial rendering
-    this.renderPreview(this.state.markdown);
   }
 
   getMarkdown() {
@@ -93,7 +89,7 @@ class PageEditor extends React.Component {
    * @param {string} value
    */
   onMarkdownChanged(value) {
-    this.renderPreviewWithDebounce(value);
+    this.setMarkdownStateWithDebounce(value);
     this.saveDraftWithDebounce();
   }
 
@@ -285,41 +281,6 @@ class PageEditor extends React.Component {
     this.props.editorContainer.clearDraft(this.props.pageContainer.state.path);
   }
 
-  renderPreview(value) {
-    this.setState({ markdown: value });
-
-    // render html
-    const context = {
-      markdown: this.state.markdown,
-      currentPagePath: decodeURIComponent(window.location.pathname),
-    };
-
-    const growiRenderer = this.growiRenderer;
-    const interceptorManager = this.props.appContainer.interceptorManager;
-    interceptorManager.process('preRenderPreview', context)
-      .then(() => { return interceptorManager.process('prePreProcess', context) })
-      .then(() => {
-        context.markdown = growiRenderer.preProcess(context.markdown);
-      })
-      .then(() => { return interceptorManager.process('postPreProcess', context) })
-      .then(() => {
-        const parsedHTML = growiRenderer.process(context.markdown);
-        context.parsedHTML = parsedHTML;
-      })
-      .then(() => { return interceptorManager.process('prePostProcess', context) })
-      .then(() => {
-        context.parsedHTML = growiRenderer.postProcess(context.parsedHTML);
-      })
-      .then(() => { return interceptorManager.process('postPostProcess', context) })
-      .then(() => { return interceptorManager.process('preRenderPreviewHtml', context) })
-      .then(() => {
-        this.setState({ html: context.parsedHTML });
-      })
-      // process interceptors for post rendering
-      .then(() => { return interceptorManager.process('postRenderPreviewHtml', context) });
-
-  }
-
   render() {
     const config = this.props.appContainer.getConfig();
     const noCdn = envUtils.toBoolean(config.env.NO_CDN);
@@ -345,7 +306,7 @@ class PageEditor extends React.Component {
         </div>
         <div className="col-md-6 hidden-sm hidden-xs page-editor-preview-container">
           <Preview
-            html={this.state.html}
+            markdown={this.state.markdown}
             // eslint-disable-next-line no-return-assign
             inputRef={(el) => { return this.previewElement = el }}
             isMathJaxEnabled={this.state.isMathJaxEnabled}

+ 75 - 2
src/client/js/components/PageEditor/Preview.jsx

@@ -2,15 +2,76 @@ import React from 'react';
 import PropTypes from 'prop-types';
 
 import { Subscribe } from 'unstated';
+import { createSubscribedElement } from '../UnstatedUtils';
 
 import RevisionBody from '../Page/RevisionBody';
 
+import AppContainer from '../../services/AppContainer';
 import EditorContainer from '../../services/EditorContainer';
 
 /**
  * Wrapper component for Page/RevisionBody
  */
-export default class Preview extends React.PureComponent {
+class Preview extends React.PureComponent {
+
+  constructor(props) {
+    super(props);
+
+    this.state = {
+      html: '',
+    };
+
+    // get renderer
+    this.growiRenderer = props.appContainer.getRenderer('editor');
+  }
+
+  componentDidMount() {
+    this.initCurrentRenderingContext();
+    this.renderPreview();
+  }
+
+  componentDidUpdate(prevProps) {
+    const { markdown: prevMarkdown } = prevProps;
+    const { markdown } = this.props;
+
+    // render only when props.markdown is updated
+    if (markdown !== prevMarkdown) {
+      this.initCurrentRenderingContext();
+      this.renderPreview();
+      return;
+    }
+
+    const { interceptorManager } = this.props.appContainer;
+
+    interceptorManager.process('postRenderPreviewHtml', this.currentRenderingContext);
+  }
+
+  initCurrentRenderingContext() {
+    this.currentRenderingContext = {
+      markdown: this.props.markdown,
+      currentPagePath: decodeURIComponent(window.location.pathname),
+    };
+  }
+
+  async renderPreview() {
+    const { appContainer } = this.props;
+    const { growiRenderer } = this;
+
+    const { interceptorManager } = appContainer;
+    const context = this.currentRenderingContext;
+
+    await interceptorManager.process('preRenderPreview', context);
+    await interceptorManager.process('prePreProcess', context);
+    context.markdown = growiRenderer.preProcess(context.markdown);
+    await interceptorManager.process('postPreProcess', context);
+    context.parsedHTML = growiRenderer.process(context.markdown);
+    await interceptorManager.process('prePostProcess', context);
+    context.parsedHTML = growiRenderer.postProcess(context.parsedHTML);
+    await interceptorManager.process('postPostProcess', context);
+    await interceptorManager.process('preRenderPreviewHtml', context);
+
+    this.setState({ html: context.parsedHTML });
+  }
 
   render() {
     return (
@@ -31,6 +92,7 @@ export default class Preview extends React.PureComponent {
           >
             <RevisionBody
               {...this.props}
+              html={this.state.html}
               renderMathJaxInRealtime={editorContainer.state.previewOptions.renderMathJaxInRealtime}
             />
           </div>
@@ -41,10 +103,21 @@ export default class Preview extends React.PureComponent {
 
 }
 
+/**
+ * Wrapper component for using unstated
+ */
+const PreviewWrapper = (props) => {
+  return createSubscribedElement(Preview, props, [AppContainer]);
+};
+
 Preview.propTypes = {
-  html: PropTypes.string,
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+
+  markdown: PropTypes.string,
   inputRef: PropTypes.func.isRequired, // for getting div element
   isMathJaxEnabled: PropTypes.bool,
   renderMathJaxOnInit: PropTypes.bool,
   onScroll: PropTypes.func,
 };
+
+export default PreviewWrapper;

+ 6 - 2
src/client/js/components/PageHistory/RevisionDiff.jsx

@@ -2,7 +2,7 @@ import React from 'react';
 import PropTypes from 'prop-types';
 
 import { createPatch } from 'diff';
-import { Diff2Html } from 'diff2html';
+import { html } from 'diff2html';
 
 export default class RevisionDiff extends React.Component {
 
@@ -29,8 +29,12 @@ export default class RevisionDiff extends React.Component {
         previousText,
         currentRevision.body,
       );
+      const option = {
+        drawFileList: false,
+        outputFormat: 'side-by-side',
+      };
 
-      diffViewHTML = Diff2Html.getPrettyHtml(patch);
+      diffViewHTML = html(patch, option);
     }
 
     const diffView = { __html: diffViewHTML };

+ 1 - 1
src/client/js/components/RecentCreated/RecentCreated.jsx

@@ -37,7 +37,7 @@ class RecentCreated extends React.Component {
     const { appContainer, pageContainer } = this.props;
     const { pageId } = pageContainer.state;
 
-    const userId = appContainer.me;
+    const userId = appContainer.currentUserId;
     const limit = appContainer.getConfig().recentCreatedLimit;
     const offset = (selectPageNumber - 1) * limit;
 

+ 11 - 0
src/client/js/services/AdminCustomizeContainer.js

@@ -28,6 +28,7 @@ export default class AdminCustomizeContainer extends Container {
       isEnabledAttachTitleHeader: false,
       currentRecentCreatedLimit: 10,
       isEnabledStaleNotification: false,
+      isAllReplyShown: false,
       currentHighlightJsStyleId: '',
       isHighlightJsStyleBorderEnabled: false,
       currentCustomizeTitle: '',
@@ -76,6 +77,7 @@ export default class AdminCustomizeContainer extends Container {
         isEnabledAttachTitleHeader: customizeParams.isEnabledAttachTitleHeader,
         currentRecentCreatedLimit: customizeParams.recentCreatedLimit,
         isEnabledStaleNotification: customizeParams.isEnabledStaleNotification,
+        isAllReplyShown: customizeParams.isAllReplyShown,
         currentHighlightJsStyleId: customizeParams.styleName,
         isHighlightJsStyleBorderEnabled: customizeParams.styleBorder,
         currentCustomizeTitle: customizeParams.customizeTitle,
@@ -159,6 +161,13 @@ export default class AdminCustomizeContainer extends Container {
     this.setState({ isEnabledStaleNotification:  !this.state.isEnabledStaleNotification });
   }
 
+  /**
+   * Switch isAllReplyShown
+   */
+  switchIsAllReplyShown() {
+    this.setState({ isAllReplyShown: !this.state.isAllReplyShown });
+  }
+
   /**
    * Switch highlightJsStyle
    */
@@ -289,6 +298,7 @@ export default class AdminCustomizeContainer extends Container {
         isEnabledAttachTitleHeader: this.state.isEnabledAttachTitleHeader,
         recentCreatedLimit: this.state.currentRecentCreatedLimit,
         isEnabledStaleNotification: this.state.isEnabledStaleNotification,
+        isAllReplyShown: this.state.isAllReplyShown,
       });
       const { customizedParams } = response.data;
       this.setState({
@@ -297,6 +307,7 @@ export default class AdminCustomizeContainer extends Container {
         isEnabledAttachTitleHeader: customizedParams.isEnabledAttachTitleHeader,
         recentCreatedLimit: customizedParams.currentRecentCreatedLimit,
         isEnabledStaleNotification: customizedParams.isEnabledStaleNotification,
+        isAllReplyShown: customizedParams.isAllReplyShown,
       });
     }
     catch (err) {

+ 20 - 10
src/client/js/services/AppContainer.js

@@ -31,13 +31,17 @@ export default class AppContainer extends Container {
 
     const body = document.querySelector('body');
 
-    this.me = body.dataset.currentUsername || null; // will be initialized with null when data is empty string
     this.isAdmin = body.dataset.isAdmin === 'true';
     this.csrfToken = body.dataset.csrftoken;
     this.isPluginEnabled = body.dataset.pluginEnabled === 'true';
     this.isLoggedin = document.querySelector('.main-container.nologin') == null;
 
-    this.config = JSON.parse(document.getElementById('crowi-context-hydrate').textContent || '{}');
+    this.config = JSON.parse(document.getElementById('growi-context-hydrate').textContent || '{}');
+
+    const currentUserElem = document.getElementById('growi-current-user');
+    if (currentUserElem != null) {
+      this.currentUser = JSON.parse(currentUserElem.textContent);
+    }
 
     const userAgent = window.navigator.userAgent.toLowerCase();
     this.isMobile = /iphone|ipad|android/.test(userAgent);
@@ -107,6 +111,20 @@ export default class AppContainer extends Container {
     window.crowiPlugin = window.growiPlugin;
   }
 
+  get currentUserId() {
+    if (this.currentUser == null) {
+      return null;
+    }
+    return this.currentUser._id;
+  }
+
+  get currentUsername() {
+    if (this.currentUser == null) {
+      return null;
+    }
+    return this.currentUser.username;
+  }
+
   /**
    * @return {Object} window.Crowi (js/legacy/crowi.js)
    */
@@ -271,14 +289,6 @@ export default class AppContainer extends Container {
     return users;
   }
 
-  findUser(username) {
-    if (this.userByName && this.userByName[username]) {
-      return this.userByName[username];
-    }
-
-    return null;
-  }
-
   launchHandsontableModal(componentKind, beginLineNumber, endLineNumber) {
     let targetComponent;
     switch (componentKind) {

+ 168 - 0
src/client/js/services/PersonalContainer.js

@@ -0,0 +1,168 @@
+import { Container } from 'unstated';
+
+import loggerFactory from '@alias/logger';
+
+// eslint-disable-next-line no-unused-vars
+const logger = loggerFactory('growi:services:PersonalContainer');
+
+/**
+ * Service container for personal settings page (PersonalSettings.jsx)
+ * @extends {Container} unstated Container
+ */
+export default class PersonalContainer extends Container {
+
+  constructor(appContainer) {
+    super();
+
+    this.appContainer = appContainer;
+
+    this.state = {
+      retrieveError: null,
+      name: '',
+      email: '',
+      registrationWhiteList: this.appContainer.getConfig().registrationWhiteList,
+      isEmailPublished: false,
+      lang: 'en-US',
+      isGravatarEnabled: false,
+      externalAccounts: [],
+      isPasswordSet: false,
+      apiToken: '',
+    };
+
+  }
+
+  /**
+   * Workaround for the mangling in production build to break constructor.name
+   */
+  static getClassName() {
+    return 'PersonalContainer';
+  }
+
+  /**
+   * retrieve personal data
+   */
+  async retrievePersonalData() {
+    try {
+      const response = await this.appContainer.apiv3.get('/personal-setting/');
+      const { currentUser } = response.data;
+
+      this.setState({
+        name: currentUser.name,
+        email: currentUser.email,
+        isEmailPublished: currentUser.isEmailPublished,
+        lang: currentUser.lang,
+        isGravatarEnabled: currentUser.isGravatarEnabled,
+        isPasswordSet: (currentUser.password != null),
+        apiToken: currentUser.apiToken,
+      });
+    }
+    catch (err) {
+      this.setState({ retrieveError: err });
+      logger.error(err);
+      throw new Error('Failed to fetch personal data');
+    }
+  }
+
+  /**
+   * retrieve external accounts that linked me
+   */
+  async retrieveExternalAccounts() {
+    try {
+      const response = await this.appContainer.apiv3.get('/personal-setting/external-accounts');
+      const { externalAccounts } = response.data;
+
+      this.setState({ externalAccounts });
+    }
+    catch (err) {
+      this.setState({ retrieveError: err });
+      logger.error(err);
+      throw new Error('Failed to fetch external accounts');
+    }
+  }
+
+  /**
+   * Change name
+   */
+  changeName(inputValue) {
+    this.setState({ name: inputValue });
+  }
+
+  /**
+   * Change email
+   */
+  changeEmail(inputValue) {
+    this.setState({ email: inputValue });
+  }
+
+  /**
+   * Change isEmailPublished
+   */
+  changeIsEmailPublished(boolean) {
+    this.setState({ isEmailPublished: boolean });
+  }
+
+  /**
+   * Change lang
+   */
+  changeLang(lang) {
+    this.setState({ lang });
+  }
+
+  /**
+   * Change isGravatarEnabled
+   */
+  changeIsGravatarEnabled(boolean) {
+    this.setState({ isGravatarEnabled: boolean });
+  }
+
+  /**
+   * Update basic info
+   * @memberOf PersonalContainer
+   * @return {Array} basic info
+   */
+  async updateBasicInfo() {
+    try {
+      const response = await this.appContainer.apiv3.put('/personal-setting/', {
+        name: this.state.name,
+        email: this.state.email,
+        isEmailPublished: this.state.isEmailPublished,
+        lang: this.state.lang,
+      });
+      const { updatedUser } = response.data;
+
+      this.setState({
+        name: updatedUser.name,
+        email: updatedUser.email,
+        isEmailPublished: updatedUser.isEmailPublished,
+        lang: updatedUser.lang,
+      });
+    }
+    catch (err) {
+      this.setState({ retrieveError: err });
+      logger.error(err);
+      throw new Error('Failed to update personal data');
+    }
+  }
+
+  /**
+   * Update profile image
+   * @memberOf PersonalContainer
+   */
+  async updateProfileImage() {
+    try {
+      const response = await this.appContainer.apiv3.put('/personal-setting/image-type', {
+        isGravatarEnabled: this.state.isGravatarEnabled,
+      });
+      const { userData } = response.data;
+      this.setState({
+        isGravatarEnabled: userData.isGravatarEnabled,
+      });
+    }
+    catch (err) {
+      this.setState({ retrieveError: err });
+      logger.error(err);
+      throw new Error('Failed to update profile image');
+    }
+  }
+
+}

+ 2 - 3
src/client/styles/agile-admin/inverse/colors/spring.scss

@@ -20,10 +20,9 @@ $background-color: rgba(171, 224, 174, 0.4);
 $third-main-color: antiquewhite;
 $textcolor: dimgray;
 $primary: $themecolor;
-
 $logo-mark-fill: lighten(desaturate($topbar, 10%), 15%);
-$wikilinktext: lighten($themecolor, 20%);
-$wikilinktext-hover: lighten($wikilinktext, 20%);
+$wikilinktext: $subthemecolor;
+$wikilinktext-hover: gba(171, 224, 174, 0.9);
 
 @import 'apply-colors';
 @import 'apply-colors-light';

+ 8 - 1
src/client/styles/scss/_comment.scss

@@ -1,11 +1,17 @@
 .main-container {
   .page-comment-main {
+    pointer-events: auto;
+
     // delete button
     .page-comment-control {
       position: absolute;
       top: 0;
       right: 0;
-      display: none; // default hidden
+      visibility: hidden;
+    }
+
+    &:hover > .page-comment-control {
+      visibility: visible;
     }
   }
 
@@ -24,6 +30,7 @@
 .page-comment {
   padding-top: 50px;
   margin-top: -50px;
+  pointer-events: none;
 }
 
 .main-container {

+ 0 - 5
src/client/styles/scss/_comment_growi.scss

@@ -103,11 +103,6 @@
     border-left: none;
   }
 
-  // show when hover
-  .page-comment-main:hover > .page-comment-control {
-    display: block;
-  }
-
   // display cheatsheet for comment form only
   .comment-form {
     .editor-cheatsheet {

+ 0 - 5
src/client/styles/scss/_comment_kibela.scss

@@ -105,11 +105,6 @@
     border-left: none;
   }
 
-  // show when hover
-  .page-comment-main:hover > .page-comment-control {
-    display: block;
-  }
-
   // display cheatsheet for comment form only
   .comment-form {
     .editor-cheatsheet {

+ 0 - 5
src/client/styles/scss/_layout_crowi_sidebar.scss

@@ -144,11 +144,6 @@
               display: none; // default hidden
             }
           }
-
-          // show controls when hover
-          .page-comment-main:hover > .page-comment-control {
-            display: block;
-          }
         }
       }
     }

+ 1 - 4
src/client/styles/scss/_page.scss

@@ -1,5 +1,5 @@
 // import diff2html styles
-@import '~diff2html/dist/diff2html.css';
+@import '~diff2html/bundles/css/diff2html.min.css';
 
 .main-container {
   // padding controll of .header-wrap and .content-main are moved to _layout and _form
@@ -8,7 +8,6 @@
    * header
    */
   header {
-
     // the container of h1
     div.title-container {
       padding-right: 5px;
@@ -39,7 +38,6 @@
 
     // change button opacity
     &:hover {
-
       .btn.btn-copy,
       .btn-copy-link,
       .btn.btn-edit,
@@ -116,7 +114,6 @@
 .main-container .main .content-main .revision-history {
   .revision-history-list {
     .revision-history-outer {
-
       // add border-top except of first element
       &:not(:first-of-type) {
         border-top: 1px solid $border;

+ 5 - 3
src/linter-checker/test.js

@@ -1,13 +1,15 @@
 /*
  * VSCode の Eslint 設定チェック方法
  *
- * 1. VSCode で以下のエラーが表示されていることを確認
+ * 1. .eslilntignore ファイル中の `/src/linter-checker/**` 行を消す
+ * 
+ * 2. VSCode で以下のエラーが表示されていることを確認
  *   - constructor で eslint(space-before-blocks)
  *   - ファイル末尾の ";" で eslint(eol-last)
  *
- * 2. VSCode で上書き保存
+ * 3. VSCode で上書き保存
  *
- * 3. 以下のように整形され、全てのエラーが消えていることを確認
+ * 4. 以下のように整形され、全てのエラーが消えていることを確認
  *   - "constructor() {" のように間にスペースが入る
  *   - ファイル末尾に空行が入る
  *

+ 5 - 3
src/linter-checker/test.scss

@@ -1,13 +1,15 @@
 /*
  * VSCode の Stylelint 設定チェック方法
  *
- * 1. VSCode で以下のエラーが表示されていることを確認
+ * 1. .stylelintrc.json ファイル中の `src/linter-checker/test.scss` 行を削除
+ * 
+ * 2. VSCode で以下のエラーが表示されていることを確認
  *   - color で stylelint(order/properties-order)
  *   - ul で stylelint(selector-combinator-space-after)
  *
- * 2. VSCode で上書き保存
+ * 3. VSCode で上書き保存
  *
- * 3. 以下のように整形され、全てのエラーが消えていることを確認
+ * 4. 以下のように整形され、全てのエラーが消えていることを確認
  *   - color が background の上の行にくる
  *   - ul と li の間にスペースが入る
  *

+ 0 - 6
src/server/form/index.js

@@ -4,12 +4,6 @@ module.exports = {
   invited: require('./invited'),
   revision: require('./revision'),
   comment: require('./comment'),
-  me: {
-    user: require('./me/user'),
-    password: require('./me/password'),
-    imagetype: require('./me/imagetype'),
-    apiToken: require('./me/apiToken'),
-  },
   admin: {
     securityGeneral: require('./admin/securityGeneral'),
     securityPassportLocal: require('./admin/securityPassportLocal'),

+ 0 - 7
src/server/form/me/apiToken.js

@@ -1,7 +0,0 @@
-const form = require('express-form');
-
-const field = form.field;
-
-module.exports = form(
-  field('apiTokenForm.confirm').required(),
-);

+ 0 - 7
src/server/form/me/imagetype.js

@@ -1,7 +0,0 @@
-const form = require('express-form');
-
-const field = form.field;
-
-module.exports = form(
-  field('imagetypeForm.isGravatarEnabled').required(),
-);

+ 0 - 9
src/server/form/me/password.js

@@ -1,9 +0,0 @@
-const form = require('express-form');
-
-const field = form.field;
-
-module.exports = form(
-  field('mePassword.oldPassword'),
-  field('mePassword.newPassword').required().is(/^[\x20-\x7F]{6,}$/),
-  field('mePassword.newPasswordConfirm').required(),
-);

+ 0 - 10
src/server/form/me/user.js

@@ -1,10 +0,0 @@
-const form = require('express-form');
-
-const field = form.field;
-
-module.exports = form(
-  field('userForm.name').trim().required(),
-  field('userForm.email').trim().isEmail().required(),
-  field('userForm.lang').required(),
-  field('userForm.isEmailPublished').trim().toBooleanStrict().required(),
-);

+ 3 - 1
src/server/middleware/access-token-parser.js

@@ -16,7 +16,9 @@ module.exports = (crowi) => {
     logger.debug('accessToken is', accessToken);
 
     const user = await User.findUserByApiToken(accessToken);
-    req.user = user;
+    // transforming attributes
+    // see User model
+    req.user = user.toObject();
     req.skipCsrfVerify = true;
 
     logger.debug('Access token parsed: skipCsrfVerify');

+ 3 - 0
src/server/models/config.js

@@ -109,6 +109,7 @@ module.exports = function(crowi) {
       'customize:isEnabledAttachTitleHeader' : false,
       'customize:showRecentCreatedNumber' : 10,
       'customize:isEnabledStaleNotification': false,
+      'customize:isAllReplyShown': false,
 
       'importer:esa:team_name': undefined,
       'importer:esa:access_token': undefined,
@@ -179,6 +180,7 @@ module.exports = function(crowi) {
         image: crowi.fileUploadService.getIsUploadable(),
         file: crowi.fileUploadService.getFileUploadEnabled(),
       },
+      registrationWhiteList: crowi.configManager.getConfig('crowi', 'security:registrationWhiteList'),
       behaviorType: crowi.configManager.getConfig('crowi', 'customize:behavior'),
       layoutType: crowi.configManager.getConfig('crowi', 'customize:layout'),
       themeType: crowi.configManager.getConfig('crowi', 'customize:theme'),
@@ -188,6 +190,7 @@ module.exports = function(crowi) {
       pageBreakCustomSeparator: crowi.configManager.getConfig('markdown', 'markdown:presentation:pageBreakCustomSeparator'),
       isEnabledXssPrevention: crowi.configManager.getConfig('markdown', 'markdown:xss:isEnabledPrevention'),
       isEnabledTimeline: crowi.configManager.getConfig('crowi', 'customize:isEnabledTimeline'),
+      isAllReplyShown: crowi.configManager.getConfig('crowi', 'customize:isAllReplyShown'),
       xssOption: crowi.configManager.getConfig('markdown', 'markdown:xss:option'),
       tagWhiteList: crowi.xssService.getTagWhiteList(),
       attrWhiteList: crowi.xssService.getAttrWhiteList(),

+ 9 - 25
src/server/models/user.js

@@ -188,25 +188,16 @@ module.exports = function(crowi) {
     });
   };
 
-  userSchema.methods.updateIsGravatarEnabled = function(isGravatarEnabled, callback) {
+  userSchema.methods.updateIsGravatarEnabled = async function(isGravatarEnabled) {
     this.isGravatarEnabled = isGravatarEnabled;
-    this.save((err, userData) => {
-      return callback(err, userData);
-    });
-  };
-
-  userSchema.methods.updateIsEmailPublished = function(isEmailPublished, callback) {
-    this.isEmailPublished = isEmailPublished;
-    this.save((err, userData) => {
-      return callback(err, userData);
-    });
+    const userData = await this.save();
+    return userData;
   };
 
-  userSchema.methods.updatePassword = function(password, callback) {
+  userSchema.methods.updatePassword = async function(password) {
     this.setPassword(password);
-    this.save((err, userData) => {
-      return callback(err, userData);
-    });
+    const userData = await this.save();
+    return userData;
   };
 
   userSchema.methods.canDeleteCompletely = function(creatorId) {
@@ -224,19 +215,12 @@ module.exports = function(crowi) {
     return false;
   };
 
-  userSchema.methods.updateApiToken = function(callback) {
+  userSchema.methods.updateApiToken = async function() {
     const self = this;
 
     self.apiToken = generateApiToken(this);
-    return new Promise(((resolve, reject) => {
-      self.save((err, userData) => {
-        if (err) {
-          return reject(err);
-        }
-
-        return resolve(userData);
-      });
-    }));
+    const userData = await self.save();
+    return userData;
   };
 
   userSchema.methods.updateImage = async function(attachment) {

+ 6 - 0
src/server/routes/apiv3/customize-setting.js

@@ -49,6 +49,8 @@ const ErrorV3 = require('../../models/vo/error-apiv3');
  *            type: number
  *          isEnabledStaleNotification:
  *            type: boolean
+ *          isAllReplyShown:
+ *            type: boolean
  *      CustomizeHighlight:
  *        description: CustomizeHighlight
  *        type: object
@@ -112,6 +114,7 @@ module.exports = (crowi) => {
       body('isEnabledAttachTitleHeader').isBoolean(),
       body('recentCreatedLimit').isInt().isInt({ min: 1, max: 1000 }),
       body('isEnabledStaleNotification').isBoolean(),
+      body('isAllReplyShown').isBoolean(),
     ],
     customizeTitle: [
       body('customizeTitle').isString(),
@@ -164,6 +167,7 @@ module.exports = (crowi) => {
       isEnabledAttachTitleHeader: await crowi.configManager.getConfig('crowi', 'customize:isEnabledAttachTitleHeader'),
       recentCreatedLimit: await crowi.configManager.getConfig('crowi', 'customize:showRecentCreatedNumber'),
       isEnabledStaleNotification: await crowi.configManager.getConfig('crowi', 'customize:isEnabledStaleNotification'),
+      isAllReplyShown: await crowi.configManager.getConfig('crowi', 'customize:isAllReplyShown'),
       styleName: await crowi.configManager.getConfig('crowi', 'customize:highlightJsStyle'),
       styleBorder: await crowi.configManager.getConfig('crowi', 'customize:highlightJsStyleBorder'),
       customizeTitle: await crowi.configManager.getConfig('crowi', 'customize:title'),
@@ -329,6 +333,7 @@ module.exports = (crowi) => {
       'customize:isEnabledAttachTitleHeader': req.body.isEnabledAttachTitleHeader,
       'customize:showRecentCreatedNumber': req.body.recentCreatedLimit,
       'customize:isEnabledStaleNotification': req.body.isEnabledStaleNotification,
+      'customize:isAllReplyShown': req.body.isAllReplyShown,
     };
 
     try {
@@ -339,6 +344,7 @@ module.exports = (crowi) => {
         isEnabledAttachTitleHeader: await crowi.configManager.getConfig('crowi', 'customize:isEnabledAttachTitleHeader'),
         recentCreatedLimit: await crowi.configManager.getConfig('crowi', 'customize:showRecentCreatedNumber'),
         isEnabledStaleNotification: await crowi.configManager.getConfig('crowi', 'customize:isEnabledStaleNotification'),
+        isAllReplyShown: await crowi.configManager.getConfig('crowi', 'customize:isAllReplyShown'),
       };
       return res.apiv3({ customizedParams });
     }

+ 6 - 9
src/server/routes/apiv3/index.js

@@ -13,31 +13,28 @@ module.exports = (crowi) => {
 
   router.use('/healthcheck', require('./healthcheck')(crowi));
 
+  // admin
   router.use('/admin-home', require('./admin-home')(crowi));
-
   router.use('/markdown-setting', require('./markdown-setting')(crowi));
-
   router.use('/app-settings', require('./app-settings')(crowi));
-
   router.use('/customize-setting', require('./customize-setting')(crowi));
 
   router.use('/notification-setting', require('./notification-setting')(crowi));
 
   router.use('/users', require('./users')(crowi));
-
   router.use('/user-groups', require('./user-group')(crowi));
+  router.use('/export', require('./export')(crowi));
+  router.use('/import', require('./import')(crowi));
+  router.use('/search', require('./search')(crowi));
+
+  router.use('/personal-setting', require('./personal-setting')(crowi));
 
   router.use('/user-group-relations', require('./user-group-relation')(crowi));
 
   router.use('/mongo', require('./mongo')(crowi));
 
-  router.use('/export', require('./export')(crowi));
-
-  router.use('/import', require('./import')(crowi));
-
   router.use('/statistics', require('./statistics')(crowi));
 
-  router.use('/search', require('./search')(crowi));
 
   return router;
 };

+ 296 - 0
src/server/routes/apiv3/personal-setting.js

@@ -0,0 +1,296 @@
+/* eslint-disable no-unused-vars */
+const loggerFactory = require('@alias/logger');
+
+const logger = loggerFactory('growi:routes:apiv3:personal-setting');
+
+const express = require('express');
+
+const router = express.Router();
+
+const { body } = require('express-validator/check');
+const ErrorV3 = require('../../models/vo/error-apiv3');
+
+/**
+ * @swagger
+ *  tags:
+ *    name: PsersonalSetting
+ */
+
+/**
+ * @swagger
+ *
+ *  components:
+ *    schemas:
+ *      PersonalSettings:
+ *        description: personal settings
+ *        type: object
+ *        properties:
+ *          name:
+ *            type: string
+ *          email:
+ *            type: string
+ *          lang:
+ *            type: string
+ *          isEmailPublished:
+ *            type: boolean
+ *      Passwords:
+ *        description: passwords for update
+ *        type: object
+ *        properties:
+ *          oldPassword:
+ *            type: string
+ *          newPassword:
+ *            type: string
+ *          newPasswordConfirm:
+ *            type: string
+ */
+module.exports = (crowi) => {
+  const accessTokenParser = require('../../middleware/access-token-parser')(crowi);
+  const loginRequiredStrictly = require('../../middleware/login-required')(crowi);
+  const csrf = require('../../middleware/csrf')(crowi);
+
+  const { User, ExternalAccount } = crowi.models;
+
+
+  const { ApiV3FormValidator } = crowi.middlewares;
+
+  const validator = {
+    personal: [
+      body('name').isString().not().isEmpty(),
+      body('email').isEmail(),
+      body('lang').isString().isIn(['en-US', 'ja']),
+      body('isEmailPublished').isBoolean(),
+    ],
+    imageType: [
+      body('isGravatarEnabled').isBoolean(),
+    ],
+    password: [
+      body('oldPassword').isString(),
+      body('newPassword').isString().not().isEmpty()
+        .isLength({ min: 6 })
+        .withMessage('password must be at least 6 characters long'),
+      body('newPasswordConfirm').isString().not().isEmpty()
+        .custom((value, { req }) => {
+          return (value === req.body.newPassword);
+        }),
+    ],
+  };
+
+  /**
+   * @swagger
+   *
+   *    /personal-setting:
+   *      get:
+   *        tags: [PersonalSetting]
+   *        operationId: getPersonalSetting
+   *        summary: /personal-setting
+   *        description: Get personal parameters
+   *        responses:
+   *          200:
+   *            description: params of personal
+   *            content:
+   *              application/json:
+   *                schema:
+   *                  properties:
+   *                    currentUser:
+   *                      type: object
+   *                      description: personal params
+   */
+  router.get('/', accessTokenParser, loginRequiredStrictly, async(req, res) => {
+    const currentUser = await User.findUserByUsername(req.user.username);
+    return res.apiv3({ currentUser });
+  });
+
+  /**
+   * @swagger
+   *
+   *    /personal-setting:
+   *      put:
+   *        tags: [PersonalSetting]
+   *        operationId: updatePersonalSetting
+   *        summary: /personal-setting
+   *        description: Update personal setting
+   *        requestBody:
+   *          required: true
+   *          content:
+   *            application/json:
+   *              schema:
+   *                $ref: '#/components/schemas/PersonalSettings'
+   *        responses:
+   *          200:
+   *            description: params of personal
+   *            content:
+   *              application/json:
+   *                schema:
+   *                  properties:
+   *                    currentUser:
+   *                      type: object
+   *                      description: personal params
+   */
+  router.put('/', accessTokenParser, loginRequiredStrictly, csrf, validator.personal, ApiV3FormValidator, async(req, res) => {
+
+    try {
+      const user = await User.findOne({ _id: req.user.id });
+      user.name = req.body.name;
+      user.email = req.body.email;
+      user.lang = req.body.lang;
+      user.isEmailPublished = req.body.isEmailPublished;
+
+      const updatedUser = await user.save();
+      req.i18n.changeLanguage(req.body.lang);
+      return res.apiv3({ updatedUser });
+    }
+    catch (err) {
+      logger.error(err);
+      return res.apiv3Err('update-personal-settings-failed');
+    }
+
+  });
+
+  /**
+   * @swagger
+   *
+   *    /personal-setting/image-type:
+   *      put:
+   *        tags: [PersonalSetting]
+   *        operationId: putUserImageType
+   *        summary: /personal-setting/image-type
+   *        description: Update user image type
+   *        responses:
+   *          200:
+   *            description: succeded to update user image type
+   *            content:
+   *              application/json:
+   *                schema:
+   *                  properties:
+   *                    userData:
+   *                      type: object
+   *                      description: user data
+   */
+  router.put('/image-type', accessTokenParser, loginRequiredStrictly, csrf, validator.imageType, ApiV3FormValidator, async(req, res) => {
+    const { isGravatarEnabled } = req.body;
+
+    try {
+      const userData = await req.user.updateIsGravatarEnabled(isGravatarEnabled);
+      return res.apiv3({ userData });
+    }
+    catch (err) {
+      logger.error(err);
+      return res.apiv3Err('update-personal-settings-failed');
+    }
+  });
+
+  /**
+   * @swagger
+   *
+   *    /personal-setting/external-accounts:
+   *      get:
+   *        tags: [PersonalSetting]
+   *        operationId: getExternalAccounts
+   *        summary: /personal-setting/external-accounts
+   *        description: Get external accounts that linked current user
+   *        responses:
+   *          200:
+   *            description: external accounts
+   *            content:
+   *              application/json:
+   *                schema:
+   *                  properties:
+   *                    externalAccounts:
+   *                      type: object
+   *                      description: array of external accounts
+   */
+  router.get('/external-accounts', accessTokenParser, loginRequiredStrictly, async(req, res) => {
+    const userData = req.user;
+
+    try {
+      const externalAccounts = await ExternalAccount.find({ user: userData });
+      return res.apiv3({ externalAccounts });
+    }
+    catch (err) {
+      logger.error(err);
+      return res.apiv3Err('get-external-accounts-failed');
+    }
+
+  });
+
+  /**
+   * @swagger
+   *
+   *    /personal-setting/password:
+   *      put:
+   *        tags: [PersonalSetting]
+   *        operationId: putUserPassword
+   *        summary: /personal-setting/password
+   *        description: Update user password
+   *        requestBody:
+   *          required: true
+   *          content:
+   *            application/json:
+   *              schema:
+   *                $ref: '#/components/schemas/Passwords'
+   *        responses:
+   *          200:
+   *            description: user password
+   *            content:
+   *              application/json:
+   *                schema:
+   *                  properties:
+   *                    userData:
+   *                      type: object
+   *                      description: user data updated
+   */
+  router.put('/password', accessTokenParser, loginRequiredStrictly, csrf, validator.password, ApiV3FormValidator, async(req, res) => {
+    const { body, user } = req;
+    const { oldPassword, newPassword } = body;
+
+    if (user.isPasswordSet() && !user.isPasswordValid(oldPassword)) {
+      return res.apiv3Err('wrong-current-password', 400);
+    }
+    try {
+      const userData = await user.updatePassword(newPassword);
+      return res.apiv3({ userData });
+    }
+    catch (err) {
+      logger.error(err);
+      return res.apiv3Err('update-password-failed');
+    }
+
+  });
+
+  /**
+   * @swagger
+   *
+   *    /personal-setting/api-token:
+   *      put:
+   *        tags: [PersonalSetting]
+   *        operationId: putUserApiToken
+   *        summary: /personal-setting/api-token
+   *        description: Update user api token
+   *        responses:
+   *          200:
+   *            description: succeded to update user api token
+   *            content:
+   *              application/json:
+   *                schema:
+   *                  properties:
+   *                    userData:
+   *                      type: object
+   *                      description: user data
+   */
+  router.put('/api-token', loginRequiredStrictly, csrf, async(req, res) => {
+    const { user } = req;
+
+    try {
+      const userData = await user.updateApiToken();
+      return res.apiv3({ userData });
+    }
+    catch (err) {
+      logger.error(err);
+      return res.apiv3Err('update-api-token-failed');
+    }
+
+  });
+
+  return router;
+};

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

@@ -128,18 +128,11 @@ module.exports = function(crowi, app) {
   app.get('/admin/export/:fileName'             , loginRequiredStrictly , adminRequired ,admin.export.download);
 
   app.get('/me'                       , loginRequiredStrictly , me.index);
-  app.get('/me/password'              , loginRequiredStrictly , me.password);
-  app.get('/me/apiToken'              , loginRequiredStrictly , me.apiToken);
-  app.post('/me'                      , loginRequiredStrictly , csrf , form.me.user , me.index);
   // external-accounts
   app.get('/me/external-accounts'                         , loginRequiredStrictly , me.externalAccounts.list);
   app.post('/me/external-accounts/disassociate'           , loginRequiredStrictly , me.externalAccounts.disassociate);
   app.post('/me/external-accounts/associateLdap'          , loginRequiredStrictly , form.login , me.externalAccounts.associateLdap);
 
-  app.post('/me/password'             , form.me.password          , loginRequiredStrictly , me.password);
-  app.post('/me/imagetype'            , form.me.imagetype         , loginRequiredStrictly , me.imagetype);
-  app.post('/me/apiToken'             , form.me.apiToken          , loginRequiredStrictly , me.apiToken);
-
   app.get('/:id([0-9a-z]{24})'       , loginRequired , page.redirector);
   app.get('/_r/:id([0-9a-z]{24})'    , loginRequired , page.redirector); // alias
   app.get('/attachment/:pageId/:fileName'  , loginRequired, attachment.api.obsoletedGetForMongoDB); // DEPRECATED: remains for backward compatibility for v3.3.x or below

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

@@ -15,7 +15,9 @@ module.exports = function(crowi, app) {
   const actions = {};
 
   const loginSuccess = function(req, res, userData) {
-    req.user = req.session.user = userData;
+    // transforming attributes
+    // see User model
+    req.user = req.session.user = userData.toObject();
 
     // update lastLoginAt
     userData.updateLastLoginAt(new Date(), (err, uData) => {

+ 1 - 160
src/server/routes/me.js

@@ -52,7 +52,6 @@ module.exports = function(crowi, app) {
   const debug = require('debug')('growi:routes:me');
   const logger = require('@alias/logger')('growi:routes:me');
   const models = crowi.models;
-  const User = models.User;
   const UserGroupRelation = models.UserGroupRelation;
   const ExternalAccount = models.ExternalAccount;
   const ApiResponse = require('../util/apiResponse');
@@ -104,90 +103,7 @@ module.exports = function(crowi, app) {
   };
 
   actions.index = function(req, res) {
-    const userForm = req.body.userForm;
-    const userData = req.user;
-
-    if (req.method === 'POST' && req.form.isValid) {
-      const name = userForm.name;
-      const email = userForm.email;
-      const lang = userForm.lang;
-      const isEmailPublished = userForm.isEmailPublished;
-
-      /*
-       * disabled because the system no longer allows undefined email -- 2017.10.06 Yuki Takei
-       *
-      if (!User.isEmailValid(email)) {
-        req.form.errors.push('You can\'t update to that email address');
-        return res.render('me/index', {});
-      }
-      */
-
-      User.findOneAndUpdate(
-        /* eslint-disable object-curly-newline */
-        { email: userData.email }, //                   query
-        { name, email, lang, isEmailPublished }, //     updating data
-        { runValidators: true, context: 'query' }, //   for validation
-        // see https://www.npmjs.com/package/mongoose-unique-validator#find--updates -- 2017.09.24 Yuki Takei
-        /* eslint-enable object-curly-newline */
-        (err) => {
-          if (err) {
-            Object.keys(err.errors).forEach((e) => {
-              req.form.errors.push(err.errors[e].message);
-            });
-
-            return res.render('me/index', {});
-          }
-          req.i18n.changeLanguage(lang);
-          req.flash('successMessage', req.t('Updated'));
-          return res.redirect('/me');
-        },
-      );
-    }
-    else { // method GET
-      /*
-       * disabled because the system no longer allows undefined email -- 2017.10.06 Yuki Takei
-       *
-      /// そのうちこのコードはいらなくなるはず
-      if (!userData.isEmailSet()) {
-        req.flash('warningMessage', 'メールアドレスが設定されている必要があります');
-      }
-      */
-
-      return res.render('me/index', {
-      });
-    }
-  };
-
-  actions.imagetype = function(req, res) {
-    if (req.method !== 'POST') {
-      // do nothing
-      return;
-    }
-    if (!req.form.isValid) {
-      req.flash('errorMessage', req.form.errors.join('\n'));
-      return;
-    }
-
-    const imagetypeForm = req.body.imagetypeForm;
-    const userData = req.user;
-
-    const isGravatarEnabled = imagetypeForm.isGravatarEnabled;
-
-    userData.updateIsGravatarEnabled(isGravatarEnabled, (err, userData) => {
-      if (err) {
-        /* eslint-disable no-restricted-syntax, no-prototype-builtins */
-        for (const e in err.errors) {
-          if (err.errors.hasOwnProperty(e)) {
-            req.form.errors.push(err.errors[e].message);
-          }
-        }
-        /* eslint-enable no-restricted-syntax, no-prototype-builtins */
-        return res.render('me/index', {});
-      }
-
-      req.flash('successMessage', req.t('Updated'));
-      return res.redirect('/me');
-    });
+    return res.render('me/index');
   };
 
   actions.externalAccounts = {};
@@ -306,81 +222,6 @@ module.exports = function(crowi, app) {
     })(req, res, () => {});
   };
 
-  actions.password = function(req, res) {
-    const passwordForm = req.body.mePassword;
-    const userData = req.user;
-
-    /*
-      * disabled because the system no longer allows undefined email -- 2017.10.06 Yuki Takei
-      *
-    // パスワードを設定する前に、emailが設定されている必要がある (schemaを途中で変更したため、最初の方の人は登録されていないかもしれないため)
-    // そのうちこのコードはいらなくなるはず
-    if (!userData.isEmailSet()) {
-      return res.redirect('/me');
-    }
-    */
-
-    if (req.method === 'POST' && req.form.isValid) {
-      const newPassword = passwordForm.newPassword;
-      const newPasswordConfirm = passwordForm.newPasswordConfirm;
-      const oldPassword = passwordForm.oldPassword;
-
-      if (userData.isPasswordSet() && !userData.isPasswordValid(oldPassword)) {
-        req.form.errors.push('Wrong current password');
-        return res.render('me/password', {
-        });
-      }
-
-      // check password confirm
-      if (newPassword !== newPasswordConfirm) {
-        req.form.errors.push('Failed to verify passwords');
-      }
-      else {
-        userData.updatePassword(newPassword, (err, userData) => {
-          if (err) {
-            /* eslint-disable no-restricted-syntax, no-prototype-builtins */
-            for (const e in err.errors) {
-              if (err.errors.hasOwnProperty(e)) {
-                req.form.errors.push(err.errors[e].message);
-              }
-            }
-            return res.render('me/password', {});
-          }
-          /* eslint-enable no-restricted-syntax, no-prototype-builtins */
-
-          req.flash('successMessage', 'Password updated');
-          return res.redirect('/me/password');
-        });
-      }
-    }
-    else { // method GET
-      return res.render('me/password', {
-      });
-    }
-  };
-
-  actions.apiToken = function(req, res) {
-    const userData = req.user;
-
-    if (req.method === 'POST' && req.form.isValid) {
-      userData.updateApiToken()
-        .then((userData) => {
-          req.flash('successMessage', 'API Token updated');
-          return res.redirect('/me/apiToken');
-        })
-        .catch((err) => {
-        // req.flash('successMessage',);
-          req.form.errors.push('Failed to update API Token');
-          return res.render('me/api_token', {
-          });
-        });
-    }
-    else {
-      return res.render('me/api_token', {
-      });
-    }
-  };
-
   actions.updates = function(req, res) {
     res.render('me/update', {
     });

+ 3 - 22
src/server/views/admin/app.html

@@ -14,28 +14,9 @@
 {% endblock %}
 
 {% block content_main %}
-<div class="content-main">
-  {% set smessage = req.flash('successMessage') %}
-  {% if smessage.length %}
-  <div class="alert alert-success">
-    {{ smessage }}
-  </div>
-  {% endif %}
-
-  {% set emessage = req.flash('errorMessage') %}
-  {% if emessage.length %}
-  <div class="alert alert-danger">
-    {{ emessage }}
-  </div>
-  {% endif %}
-  <div class="row">
-    <div class="col-md-3">
-      {% include './widget/menu.html' with {current: 'app'} %}
-    </div>
-
-    <div class="col-md-9" id="admin-app"></div>
-  </div>
-
+<div class="content-main row">
+  {% parent %}
+  <div class="col-md-9" id="admin-app"></div>
 </div>
 {% endblock content_main %}
 

+ 3 - 26
src/server/views/admin/customize.html

@@ -21,32 +21,9 @@
 {% endblock %}
 
 {% block content_main %}
-<div class="content-main admin-customize">
-  {% set smessage = req.flash('successMessage') %}
-  {% if smessage.length %}
-  <div class="alert alert-success">
-    {{ smessage }}
-  </div>
-  {% endif %}
-  {% set emessage = req.flash('errorMessage') %}
-  {% if emessage.length %}
-  <div class="alert alert-danger">
-    {{ emessage }}
-  </div>
-  {% endif %}
-
-  <div id="grw-hljs-container-for-demo">
-    {{ cdnHighlightJsStyleTag(getConfig('crowi', 'customize:highlightJsStyle')) }}
-  </div>
-
-  <div class="row">
-    <div class="col-md-3">
-      {% include './widget/menu.html' with {current: 'customize'} %}
-    </div>
-    <div class="col-md-9">
-      <div id="admin-customize"></div>
-    </div>
-  </div>
+<div class="content-main admin-customize row">
+  {% parent %}
+  <div class="col-md-9" id="admin-customize"></div>
 </div>
 {% endblock content_main %}
 

+ 3 - 12
src/server/views/admin/export.html

@@ -11,18 +11,9 @@
 {% endblock %}
 
 {% block content_main %}
-<div class="content-main admin-export">
-
-  <div class="row">
-    <div class="col-md-3">
-      {% include './widget/menu.html' with {current: 'export'} %}
-    </div>
-    <div
-      id="admin-export-page"
-      class="col-md-9"
-    >
-    </div>
-  </div>
+<div class="content-main admin-export row">
+  {% parent %}
+  <div id="admin-export-page" class="col-md-9"></div>
 </div>
 
 {% endblock content_main %}

+ 3 - 30
src/server/views/admin/external-accounts.html

@@ -11,36 +11,9 @@
 {% endblock %}
 
 {% block content_main %}
-<div class="content-main">
-  {% set wmessage = req.flash('warningMessage') %}
-  {% if wmessage.length %}
-  <div class="alert alert-warning">
-    {{ wmessage }}
-  </div>
-  {% endif %}
-
-  {% set smessage = req.flash('successMessage') %}
-  {% if smessage.length %}
-  <div class="alert alert-success">
-    {{ smessage }}
-  </div>
-  {% endif %}
-
-  {% set emessage = req.flash('errorMessage') %}
-  {% if emessage.length %}
-  <div class="alert alert-danger">
-    {{ emessage }}
-  </div>
-  {% endif %}
-
-  <div class="row">
-    <div class="col-md-3">
-      {% include './widget/menu.html' with {current: 'external-account'} %}
-    </div>
-
-    <div class="col-md-9" id="admin-external-account-setting">
-    </div>
-  </div>
+<div class="content-main row">
+  {% parent %}
+  <div class="col-md-9" id="admin-external-account-setting"></div>
 </div>
 {% endblock content_main %}
 

+ 5 - 23
src/server/views/admin/global-notification-detail.html

@@ -11,29 +11,11 @@
 {% endblock %}
 
 {% block content_main %}
-<div class="content-main">
-  {% set smessage = req.flash('successMessage') %}
-  {% if smessage.length %}
-  <div class="alert alert-success">
-    {{ smessage }}
-  </div>
-  {% endif %}
-
-  {% set emessage = req.flash('errorMessage') %}
-  {% if emessage.length %}
-  <div class="alert alert-danger">
-    {{ emessage }}
-  </div>
-  {% endif %}
-
-  <div class="row">
-    <div class="col-md-3">
-      {% include './widget/menu.html' with {current: 'notification'} %}
-    </div>
-    <div class="col-md-9" id="admin-global-notification-setting"
-      data-global-notification="{{ globalNotification|json }}">
-    </div>
-  </div>
+<div class="content-main row">
+  {% parent %}
+  <div class="col-md-9" id="admin-global-notification-setting"
+      data-global-notification="{{ globalNotification|json }}"></div>
+</div>
 
   {% endblock content_main %}
 

+ 3 - 32
src/server/views/admin/importer.html

@@ -11,38 +11,9 @@
 {% endblock %}
 
 {% block content_main %}
-<div class="content-main admin-importer">
-
-  <div class="row">
-    <div class="col-md-3">
-      {% include './widget/menu.html' with {current: 'importer'} %}
-    </div>
-    <div class="col-lg-7 col-md-9">
-
-      <!-- Flash message for success -->
-      {% set smessage = req.flash('successMessage') %}
-      {% if smessage.length %}
-      <div class="alert alert-success">
-        {% for e in smessage %}
-        {{ e }}<br>
-        {% endfor %}
-      </div>
-      {% endif %}
-
-      <!-- Flash message for error -->
-      {% set emessage = req.flash('errorMessage') %}
-      {% if emessage.length %}
-      <div class="alert alert-danger">
-        {% for e in emessage %}
-        {{ e }}<br>
-        {% endfor %}
-      </div>
-      {% endif %}
-
-      <div id="admin-importer"></div>
-
-    </div>
-  </div>
+<div class="content-main admin-importer row">
+  {% parent %}
+  <div class="col-md-9" id="admin-importer"></div>
 </div>
 
 {% endblock content_main %}

+ 3 - 18
src/server/views/admin/index.html

@@ -11,24 +11,9 @@
 {% endblock %}
 
 {% block content_main %}
-<div class="content-main">
-
-  {% set emessage = req.flash('errorMessage') %}
-  {% if emessage.length %}
-  <div class="alert alert-danger">
-    {{ emessage }}
-  </div>
-  {% endif %}
-
-  <div class="row">
-    <div class="col-md-3">
-      {% include './widget/menu.html' %}
-    </div>
-    <div class="col-md-9">
-      <div id="admin-home"></div>
-    </div>
-  </div>
-
+<div class="content-main row">
+  {% parent %}
+  <div class="col-md-9" id="admin-home"></div>
 </div>
 {% endblock content_main %}
 

+ 3 - 9
src/server/views/admin/markdown.html

@@ -12,15 +12,9 @@
 {% endblock %}
 
 {% block content_main %}
-<div class="content-main">
-  <div class="row">
-    <div class="col-md-3">
-      {% include './widget/menu.html' with {current: 'markdown'} %}
-    </div>
-
-    <div class="col-md-9" id="admin-markdown-setting"></div>
-  </div>
-
+<div class="content-main row">
+  {% parent %}
+  <div class="col-md-9" id="admin-markdown-setting"></div>
 </div>
 {% endblock content_main %}
 

+ 3 - 7
src/server/views/admin/notification.html

@@ -11,13 +11,9 @@
 {% endblock %}
 
 {% block content_main %}
-<div class="content-main admin-notification">
-  <div class="row">
-    <div class="col-md-3">
-      {% include './widget/menu.html' with {current: 'notification'} %}
-    </div>
-    <div class="col-md-9" id="admin-notification-setting"></div>
-  </div>
+<div class="content-main admin-notification row">
+  {% parent %}
+  <div class="col-md-9" id="admin-notification-setting"></div>
 </div>
 {% endblock content_main %}
 

+ 2 - 8
src/server/views/admin/search.html

@@ -11,12 +11,8 @@
 {% endblock %}
 
 {% block content_main %}
-<div class="content-main">
-
-  <div class="row">
-    <div class="col-md-3">
-      {% include './widget/menu.html' with {current: 'search'} %}
-    </div>
+<div class="content-main row">
+  {% parent %}
     <div
       class="col-md-9"
       id ="admin-full-text-search-management"
@@ -25,8 +21,6 @@
       <!-- {% include '../widget/pager.html' with {path: "/admin/search", pager: pager} %} -->
       <!-- Reactify Paginator end -->
     </div>
-  </div>
-
 </div>
 
 

+ 2 - 5
src/server/views/admin/security.html

@@ -11,11 +11,8 @@
 {% endblock %}
 
 {% block content_main %}
-<div class="content-main admin-security">
-  <div class="row">
-    <div class="col-md-3">
-      {% include './widget/menu.html' with {current: 'security'} %}
-    </div>
+<div class="content-main admin-security row">
+  {% parent %}
     <div class="col-md-9">
 
       {% set smessage = req.flash('successMessage') %}

+ 7 - 11
src/server/views/admin/user-group-detail.html

@@ -11,17 +11,13 @@
 {% endblock %}
 
 {% block content_main %}
-<div class="content-main">
-  <div class="row">
-    <div class="col-md-3">
-      {% include './widget/menu.html' with {current: 'user-group'} %}
-    </div>
-    <div
-      id="admin-user-group-detail"
-      class="col-md-9"
-      data-user-group="{{ userGroup|json }}"
-    >
-    </div>
+<div class="content-main row">
+  {% parent %}
+  <div
+    id="admin-user-group-detail"
+    class="col-md-9"
+    data-user-group="{{ userGroup|json }}"
+  >
   </div>
 </div>
 {% endblock content_main %}

+ 3 - 7
src/server/views/admin/user-groups.html

@@ -11,13 +11,9 @@
 {% endblock %}
 
 {% block content_main %}
-<div class="content-main">
-  <div class="row">
-    <div class="col-md-3">
-      {% include './widget/menu.html' with {current: 'user-group'} %}
-    </div>
-    <div id ="admin-user-group-page" class="col-md-9"></div>
-  </div>
+<div class="content-main row">
+  {% parent %}
+  <div id ="admin-user-group-page" class="col-md-9"></div>
 </div>
 {% endblock content_main %}
 

+ 2 - 18
src/server/views/admin/users.html

@@ -11,24 +11,8 @@
 {% endblock %}
 
 {% block content_main %}
-<div class="content-main">
-    {% set smessage = req.flash('successMessage') %}
-  {% if smessage.length %}
-  <div class="alert alert-success">
-    {{ smessage }}
-  </div>
-  {% endif %}
-
-  {% set emessage = req.flash('errorMessage') %}
-  {% if emessage.length %}
-  <div class="alert alert-danger">
-    {{ emessage }}
-  </div>
-  {% endif %}
-
-  <div class="col-md-3">
-    {% include './widget/menu.html' with {current: 'user'} %}
-  </div>
+<div class="content-main row">
+  {% parent %}
   <div
     class="col-md-9"
     id ="admin-user-page"

+ 0 - 16
src/server/views/admin/widget/menu.html

@@ -1,16 +0,0 @@
-{% if not current %}
-  {% set current = 'index' %}
-{% 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 == '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-upload"></i> {{ t('Import Data') }}</a></li>
-  <li class="{% if current == 'export'%}active{% endif %}"><a href="/admin/export"><i class="icon-fw icon-cloud-download"></i> {{ t('Export Archive 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>
-</ul>

+ 0 - 16
src/server/views/admin/widget/theme-colorbox.html

@@ -1,16 +0,0 @@
-<div id="theme-option-{{name}}" class="theme-option-container d-flex flex-column align-items-center {% if name === settingForm['customize:theme'] %}active{% endif %}">
-  <a class="m-0 {{name}} theme-button"
-    id="{{name}}"
-    {% if 'kibela' !== settingForm['customize:layout'] %}onclick="selectTheme('{{name}}')"{% endif %}
-    data-theme="{{ webpack_asset('styles/theme-' + name + '.css') }}">
-
-    <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
-      <g>
-        <path d="M -1 -1 L65 -1 L65 65 L-1 65 L-1 -1 Z" fill="{{bg}}"></path>
-        <path d="M -1 -1 L65 -1 L65 15 L-1 15 L-1 -1 Z" fill="{{topbar}}"></path>
-        <path d="M 44 15 L65 15 L65 65 L44 65 L44 15 Z" fill="{{theme}}"></path>
-      </g>
-    </svg>
-  </a>
-  <span class="theme-option-name"><b>{{name}}</b></span>
-</div>

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

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

+ 4 - 0
src/server/views/layout/admin.html

@@ -8,6 +8,10 @@
 <script src="{{ webpack_asset('js/admin.js') }}" defer></script>
 {% endblock %}
 
+{% block content_main %}
+  <div class="col-md-3" id="admin-navigation"></div>
+{% endblock content_main %}
+
 {# disable custom script in admin page #}
 {% block custom_script %}
 {% endblock %}

+ 8 - 17
src/server/views/layout/layout.html

@@ -63,7 +63,6 @@
 <body
   class="main-container content-wrapper {% block html_base_css %}{% endblock %}
       {% if !getConfig('crowi', 'customize:layout') || 'crowi' === getConfig('crowi', 'customize:layout') %}crowi{% elseif !getConfig('crowi', 'customize:layout') || 'kibela' === getConfig('crowi', 'customize:layout') %}kibela{% else %}growi{% endif %}"
-  data-me="{{ user._id.toString() }}"
   data-is-admin="{{ user.admin }}"
   data-plugin-enabled="{{ getConfig('crowi', 'plugin:isEnabledPlugins') }}"
   {% block html_base_attr %}{% endblock %}
@@ -141,21 +140,7 @@
             <i class="icon-question"></i><span>{{ t('Help') }}</span><span class="text-muted small"><i class="icon-share-alt"></i></span>
           </a>
         </li>
-        <li class="dropdown">
-          <a class="dropdown-toggle waves-effect waves-light" data-toggle="dropdown">
-            <img src="{{ user|picture }}" class="picture img-circle" width="25" /> <span class="user-name">{{ user.name }}</span>
-          </a>
-          <ul class="dropdown-menu dropdown-menu-right">
-            <li><a href="/user/{{ user.username }}"><i class="icon-fw icon-home"></i>{{ t('Home') }}</a></li>
-            <li><a href="/me"><i class="icon-fw icon-wrench"></i>{{ t('User Settings') }}</a></li>
-            <li role="separator" class="divider"></li>
-            <li><a href="/user/{{ user.username }}#user-draft-list"><i class="icon-fw icon-docs"></i>{{ t('List Drafts') }}</a></li>
-            <li><a href="/trash"><i class="icon-fw icon-trash"></i>{{ t('Deleted Pages') }}</a></li>
-            <li role="separator" class="divider"></li>
-            <li><a href="/logout"><i class="icon-fw icon-power"></i>{{ t('Sign out') }}</a></li>
-          </ul>
-          <!-- /.dropdown-messages -->
-        </li>
+        <li id="personal-dropdown" class="dropdown"></li>
         {% else %}
         <li id="login-user"><a href="/login">Login</a></li>
         {% endif %}
@@ -207,10 +192,16 @@
 </body>
 {% endblock %}
 
-<script type="application/json" id="crowi-context-hydrate">
+<script type="application/json" id="growi-context-hydrate">
 {{ local_config|json|safe|preventXss }}
 </script>
 
+{% if user != null %}
+  <script type="application/json" id="growi-current-user">
+  {{ user|json|safe|preventXss }}
+  </script>
+{% endif %}
+
 {% block custom_script %}
 <script>
   {{ customizeService.getCustomScript() }}

+ 0 - 87
src/server/views/me/api_token.html

@@ -1,87 +0,0 @@
-{% extends '../layout-growi/base/layout.html' %}
-
-
-{% block html_title %}{{ customizeService.generateCustomTitle(t('API Settings')) }}{% endblock %}
-
-
-{% block content_header %}
-<div class="header-wrap">
-  <header id="page-header">
-  <h1 id="admin-title" class="title">{{ t('API Settings') }}</h1>
-  </header>
-</div>
-{% endblock %}
-
-{% block content_main %}
-<div class="content-main">
-
-  <ul class="nav nav-tabs">
-    <li><a href="/me"><i class="icon-user"></i> {{ t('User Information') }}</a></li>
-    <li><a href="/me/external-accounts"><i class="icon-share-alt"></i> {{ t('External Accounts') }}</a></li>
-    <li><a href="/me/password"><i class="icon-lock"></i> {{ t('Password Settings') }}</a></li>
-    <li class="active"><a href="/me/apiToken"><i class="icon-paper-plane"></i> {{ t('API Settings') }}</a></li>
-  </ul>
-
-  <div class="tab-content">
-
-  {% set message = req.flash('successMessage') %}
-  {% if message.length %}
-  <div class="alert alert-success m-t-10">
-    {{ message }}
-  </div>
-  {% endif %}
-
-  {% if req.form.errors.length > 0 %}
-  <div class="alert alert-danger m-t-10">
-    <ul>
-    {% for error in req.form.errors %}
-      <li>{{ error }}</li>
-    {% endfor %}
-    </ul>
-  </div>
-  {% endif %}
-
-  <div class="form-box m-t-20">
-
-    <form action="/me/apiToken" method="post" class="form-horizontal" role="form">
-    <fieldset>
-      <legend>{{ t('API Token Settings') }}</legend>
-      <div class="form-group {% if not user.password %}has-error{% endif %}">
-        <label for="" class="col-xs-3 control-label">{{ t('Current API Token') }}</label>
-        <div class="col-xs-6">
-          {% if user.apiToken %}
-            <input class="form-control" type="text" value="{{ user.apiToken }}">
-          {% else %}
-          <p class="form-control-static">
-            {{ t('page_me_apitoken.notice.apitoken_issued') }}
-          </p>
-          {% endif %}
-        </div>
-      </div>
-
-      <div class="form-group">
-        <div class="col-xs-offset-3 col-xs-9">
-
-          <p class="alert alert-warning">
-            {{ t('page_me_apitoken.notice.update_token1') }}<br>
-            {{ t('page_me_apitoken.notice.update_token2') }}
-          </p>
-
-          <button type="submit" value="1" name="apiTokenForm[confirm]" class="btn btn-primary">{{ t('Update API Token') }}</button>
-        </div>
-      </div>
-
-    </fieldset>
-    </form>
-  </div>
-
-
-  </div>
-</div>
-{% endblock content_main %}
-
-{% block content_footer %}
-{% endblock %}
-
-{% block layout_footer %}
-{% endblock %}

+ 1 - 1
src/server/views/me/index.html

@@ -11,7 +11,7 @@
 {% endblock %}
 
 {% block content_main %}
-<div class="content-main">
+<div class="content-main" id="personal-setting">
 
   <ul class="nav nav-tabs">
     <li class="active"><a href="/me"><i class="icon-user"></i> {{ t('User Information') }}</a></li>

+ 0 - 100
src/server/views/me/password.html

@@ -1,100 +0,0 @@
-{% extends '../layout-growi/base/layout.html' %}
-
-{% block html_title %}{{ customizeService.generateCustomTitle(t('Password Settings')) }}{% endblock %}
-
-{% block content_header %}
-<div class="header-wrap">
-  <header id="page-header">
-    <h1 id="mypage-title" class="title">{{ t('Password Settings') }}</h1>
-  </header>
-</div>
-{% endblock %}
-
-{% block content_main %}
-<div class="content-main">
-
-  <ul class="nav nav-tabs">
-    <li><a href="/me"><i class="icon-user"></i> {{ t('User Information') }}</a></li>
-    <li><a href="/me/external-accounts"><i class="icon-share-alt"></i> {{ t('External Accounts') }}</a></li>
-    <li class="active"><a href="/me/password"><i class="icon-lock"></i> {{ t('Password Settings') }}</a></li>
-    <li><a href="/me/apiToken"><i class="icon-paper-plane"></i> {{ t('API Settings') }}</a></li>
-  </ul>
-
-  <div class="tab-content">
-
-  {% if not user.password %}
-  <div class="alert alert-warning m-t-10">
-    {{ t('Password is not set') }}
-  </div>
-  {% endif %}
-
-  {% set message = req.flash('successMessage') %}
-  {% if message.length %}
-  <div class="alert alert-success m-t-10">
-    {{ message }}
-  </div>
-  {% endif %}
-
-  {% if req.form.errors.length > 0 %}
-  <div class="alert alert-danger m-t-10">
-    <ul>
-    {% for error in req.form.errors %}
-      <li>{{ error }}</li>
-    {% endfor %}
-    </ul>
-  </div>
-  {% endif %}
-
-  <div id="form-box" class="m-t-20">
-
-    <form action="/me/password" method="post" class="form-horizontal" role="form">
-    <fieldset>
-      {% if user.password %}
-      <legend>{{ t('Update Password') }}</legend>
-      {% else %}
-      <legend>{{ t('Set new Password') }}</legend>
-      {% endif %}
-      {% if user.password %}
-      <div class="form-group">
-        <label for="mePassword[oldPassword]" class="col-xs-3 control-label">{{ t('Current password') }}</label>
-        <div class="col-xs-6">
-          <input class="form-control" type="password" name="mePassword[oldPassword]">
-        </div>
-      </div>
-      {% endif %}
-      <div class="form-group {% if not user.password %}has-error{% endif %}">
-        <label for="mePassword[newPassword]" class="col-xs-3 control-label">{{ t('New password') }}</label>
-        <div class="col-xs-6">
-          <input class="form-control" type="password" name="mePassword[newPassword]" required>
-        </div>
-      </div>
-      <div class="form-group">
-        <label for="mePassword[newPasswordConfirm]" class="col-xs-3 control-label">{{ t('Re-enter new password') }}</label>
-        <div class="col-xs-6">
-          <input class="form-control col-xs-4" type="password" name="mePassword[newPasswordConfirm]" required>
-
-          <p class="help-block">{{ t('page_register.form_help.password') }}</p>
-        </div>
-      </div>
-
-
-      <div class="form-group">
-        <div class="text-center">
-          <button type="submit" class="btn btn-primary">{{ t('Update') }}</button>
-        </div>
-      </div>
-
-    </fieldset>
-    </form>
-  </div>
-
-
-  </div>
-</div>
-{% endblock content_main %}
-
-{% block content_footer %}
-{% endblock %}
-
-{% block layout_footer %}
-{% endblock %}

Разница между файлами не показана из-за своего большого размера
+ 436 - 228
yarn.lock


Некоторые файлы не были показаны из-за большого количества измененных файлов