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

Merge branch 'support/apply-bootstrap4' into imprv/fix-subnavigation

# Conflicts:
#	src/client/js/components/Navbar/GrowiSubNavigation.jsx
ryohek 6 лет назад
Родитель
Сommit
ec6a0932db
100 измененных файлов с 2941 добавлено и 643 удалено
  1. 18 1
      CHANGES.md
  2. 1 0
      config/env.dev.js
  3. 3 1
      package.json
  4. 5 1
      resource/locales/en-US/admin/admin.json
  5. 29 7
      resource/locales/en-US/translation.json
  6. 5 1
      resource/locales/ja/admin/admin.json
  7. 29 7
      resource/locales/ja/translation.json
  8. 5 6
      src/client/js/app.jsx
  9. 12 1
      src/client/js/components/Admin/AdminHome/AdminHome.jsx
  10. 32 0
      src/client/js/components/Admin/AdminHome/EnvVarsTable.jsx
  11. 13 11
      src/client/js/components/Admin/Common/AdminNavigation.jsx
  12. 144 0
      src/client/js/components/Admin/Security/LdapAuthTest.jsx
  13. 8 96
      src/client/js/components/Admin/Security/LdapAuthTestModal.jsx
  14. 1 1
      src/client/js/components/Admin/UserGroupDetail/UserGroupUserTable.jsx
  15. 173 0
      src/client/js/components/Admin/UserManagement.jsx
  16. 32 0
      src/client/js/components/Admin/Users/SortIcons.jsx
  17. 9 4
      src/client/js/components/Admin/Users/UserInviteModal.jsx
  18. 99 10
      src/client/js/components/Admin/Users/UserTable.jsx
  19. 1 1
      src/client/js/components/BookmarkButton.jsx
  20. 1 1
      src/client/js/components/HeaderSearchBox.jsx
  21. 20 11
      src/client/js/components/InstallerForm.jsx
  22. 22 26
      src/client/js/components/LikeButton.jsx
  23. 107 0
      src/client/js/components/Me/ApiSettings.jsx
  24. 146 0
      src/client/js/components/Me/AssociateModal.jsx
  25. 164 0
      src/client/js/components/Me/BasicInfoSettings.jsx
  26. 90 0
      src/client/js/components/Me/DisassociateModal.jsx
  27. 136 0
      src/client/js/components/Me/ExternalAccountLinkedMe.jsx
  28. 40 0
      src/client/js/components/Me/ExternalAccountRow.jsx
  29. 130 0
      src/client/js/components/Me/ImageCropModal.jsx
  30. 145 0
      src/client/js/components/Me/PasswordSettings.jsx
  31. 61 0
      src/client/js/components/Me/PersonalSettings.jsx
  32. 198 0
      src/client/js/components/Me/ProfileImageSettings.jsx
  33. 37 0
      src/client/js/components/Me/UserSettings.jsx
  34. 6 6
      src/client/js/components/Navbar/GrowiSubNavigation.jsx
  35. 3 1
      src/client/js/components/Navbar/PersonalDropdown.jsx
  36. 7 4
      src/client/js/components/Page.jsx
  37. 5 5
      src/client/js/components/Page/CopyDropdown.jsx
  38. 1 1
      src/client/js/components/Page/TagEditor.jsx
  39. 2 2
      src/client/js/components/PageAttachment/Attachment.jsx
  40. 1 1
      src/client/js/components/PageAttachment/DeleteAttachmentModal.jsx
  41. 5 3
      src/client/js/components/PageComment/Comment.jsx
  42. 2 2
      src/client/js/components/PageComment/CommentEditor.jsx
  43. 3 3
      src/client/js/components/PageComment/DeleteCommentModal.jsx
  44. 1 1
      src/client/js/components/PageComment/ReplayComments.jsx
  45. 1 1
      src/client/js/components/PageComments.jsx
  46. 8 5
      src/client/js/components/PageEditor/CodeMirrorEditor.jsx
  47. 28 11
      src/client/js/components/PageEditor/DrawioModal.jsx
  48. 3 3
      src/client/js/components/PageEditor/OptionsSelector.jsx
  49. 24 20
      src/client/js/components/PageEditorByHackmd.jsx
  50. 9 14
      src/client/js/components/PageList/Page.jsx
  51. 4 2
      src/client/js/components/PageStatusAlert.jsx
  52. 1 1
      src/client/js/components/SavePageControls/GrantSelector.jsx
  53. 1 1
      src/client/js/components/SearchPage/DeletePageListModal.jsx
  54. 10 8
      src/client/js/components/SearchPage/SearchPageForm.jsx
  55. 47 44
      src/client/js/components/SearchPage/SearchResult.jsx
  56. 2 1
      src/client/js/components/SearchPage/SearchResultList.jsx
  57. 1 1
      src/client/js/components/SlackNotification.jsx
  58. 9 1
      src/client/js/components/StaffCredit/Contributor.js
  59. 3 5
      src/client/js/components/User/UserPicture.jsx
  60. 1 0
      src/client/js/services/AdminGeneralSecurityContainer.js
  61. 1 0
      src/client/js/services/AdminHomeContainer.js
  62. 89 2
      src/client/js/services/AdminUsersContainer.js
  63. 1 5
      src/client/js/services/PageContainer.js
  64. 248 0
      src/client/js/services/PersonalContainer.js
  65. 4 4
      src/client/js/util/interceptor/drawio-interceptor.js
  66. 1 1
      src/client/styles/agile-admin/inverse/sidebar-nav.scss
  67. 14 5
      src/client/styles/scss/_comment_growi.scss
  68. 0 8
      src/client/styles/scss/_create-template.scss
  69. 5 3
      src/client/styles/scss/_layout.scss
  70. 90 15
      src/client/styles/scss/_layout_kibela.scss
  71. 104 54
      src/client/styles/scss/_login.scss
  72. 0 7
      src/client/styles/scss/_navbar.scss
  73. 15 54
      src/client/styles/scss/_navbar_kibela.scss
  74. 7 2
      src/client/styles/scss/_on-edit.scss
  75. 2 2
      src/client/styles/scss/_override-bootstrap-variables.scss
  76. 13 3
      src/client/styles/scss/_override-bootstrap.scss
  77. 0 8
      src/client/styles/scss/_page.scss
  78. 16 11
      src/client/styles/scss/_page_list.scss
  79. 17 20
      src/client/styles/scss/_search.scss
  80. 6 0
      src/client/styles/scss/_user.scss
  81. 54 13
      src/client/styles/scss/atoms/_buttons.scss
  82. 0 1
      src/client/styles/scss/style-app.scss
  83. 16 10
      src/client/styles/scss/theme/_apply-colors.scss
  84. 39 0
      src/client/styles/scss/theme/_layout_kibela_variable.scss
  85. 9 5
      src/client/styles/scss/theme/_reboot-bootstrap-colors.scss
  86. 12 0
      src/client/styles/scss/theme/default.scss
  87. 1 0
      src/client/styles/scss/theme/kibela.scss
  88. 12 0
      src/lib/util/isSecurityEnv.js
  89. 30 0
      src/migrations/2020040216038-remove-deleteduser-from-relationgroup.js
  90. 0 6
      src/server/form/index.js
  91. 0 7
      src/server/form/me/apiToken.js
  92. 0 7
      src/server/form/me/imagetype.js
  93. 0 9
      src/server/form/me/password.js
  94. 0 10
      src/server/form/me/user.js
  95. 6 4
      src/server/models/bookmark.js
  96. 2 0
      src/server/models/config.js
  97. 6 1
      src/server/models/user-group-relation.js
  98. 9 25
      src/server/models/user.js
  99. 2 0
      src/server/routes/apiv3/admin-home.js
  100. 6 8
      src/server/routes/apiv3/index.js

+ 18 - 1
CHANGES.md

@@ -5,9 +5,26 @@
 * Support: Upgrade libs
     * bootstrap
 
-## v3.7.1-RC
+## v3.7.3-RC
+
+*
+
+## v3.7.2
+
+* Feature: User Management Filtering/Sort
+* Feature: Show env vars on Admin pages
+* Fix: Attachment row z-index
+* I18n: HackMD integration alert
+
+## v3.7.1
 
 * Improvement: Add an option that make it possible to choose what to send notifications
+* Improvement: Add the env var `DRAWIO_URI`
+* Improvement: Accessibility for 'spring' theme
+* Improvement: Editor scroll sync behaves strangely when using draw.io blocks
+* Fix: Coudn't upload file on Comment Editor
+    * Introduced by 3.5.8
+* I18n: HackMD integration
 
 ## v3.7.0
 

+ 1 - 0
config/env.dev.js

@@ -6,6 +6,7 @@ module.exports = {
   // NO_CDN: true,
   ELASTICSEARCH_URI: 'http://localhost:9200/growi',
   HACKMD_URI: 'http://localhost:3010',
+  // DRAWIO_URI: 'http://localhost:8080/?offline=1&https=0',
   PLUGIN_NAMES_TOBE_LOADED: [
     // 'growi-plugin-lsx',
     // 'growi-plugin-pukiwiki-like-linker',

+ 3 - 1
package.json

@@ -1,6 +1,6 @@
 {
   "name": "growi",
-  "version": "3.7.1-RC",
+  "version": "3.7.3-RC",
   "description": "Team collaboration software using markdown",
   "tags": [
     "wiki",
@@ -75,6 +75,7 @@
     "archiver": "^3.1.1",
     "array.prototype.flatmap": "^1.2.2",
     "async": "^3.0.1",
+    "async-canvas-to-blob": "^1.0.3",
     "aws-sdk": "^2.88.0",
     "axios": "^0.19.0",
     "body-parser": "^1.18.2",
@@ -134,6 +135,7 @@
     "passport-local": "^1.0.0",
     "passport-saml": "^1.0.0",
     "passport-twitter": "^1.0.4",
+    "react-image-crop": "^8.3.0",
     "rimraf": "^3.0.0",
     "slack-node": "^0.1.8",
     "socket.io": "^2.0.3",

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

@@ -7,7 +7,10 @@
     "list_of_installed_plugins": "List of installed plugins",
     "package_name": "Package name",
     "specified_version": "Specified version",
-    "installed_version": "Installed version"
+    "installed_version": "Installed version",
+    "list_of_env_vars":"List of environment variables",
+    "env_var_priority": "For environment variables other than security, the value of the database is obtained preferentially.",
+    "about_security": "Check <a href='/admin/security'>Securtiy Management</a> for security environment variables."
   },
   "app_setting": {
     "site_name": "Site name",
@@ -221,6 +224,7 @@
   },
   "user_management": {
     "invite_users": "Invite New Users",
+    "click_twice_same_checkbox": "You should check at least one checkbox.",
     "invite_modal": {
       "emails": "Emails",
       "invite_thru_email": "Send Invitation Email",

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

@@ -123,6 +123,7 @@
   "List Drafts": "Drafts",
   "Deleted Pages": "Deleted Pages",
   "Sign out": "Logout",
+  "Disassociate": "Disassociate",
   "form_validation": {
     "error_message": "Some values ​​are incorrect",
     "required": "%s is required",
@@ -164,12 +165,16 @@
   },
   "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": {
+    "disassociate_external_account": "Disassociate External Account",
+    "disassociate_external_account_desc": "Are you sure to disassociate the <strong>{{providerType}}</strong> account <strong>{{accountId}}</strong>?",
+    "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",
@@ -320,7 +325,7 @@
   "template": {
     "modal_label": {
       "Create/Edit Template Page": "Create/Edit Template Page",
-      "Create template under": "Create template page under:<br /><code><small>%s</small></code>"
+      "Create template under": "Create template page under:<br /><code>%s</code>"
     },
     "option_label": {
       "create/edit": "Create/Edit Template page..",
@@ -366,6 +371,23 @@
     "insert_image": "inserts an image",
     "open_sandbox": "Open Sandbox"
   },
+  "hackmd": {
+    "not_set_up": "HackMD is not set up.",
+    "start_to_edit": "Start to edit with HackMD",
+    "clone_page_content": "Click to clone page content and start to edit.",
+    "unsaved_draft": "HackMD has unsaved draft.",
+    "draft_outdated": "DRAFT MAY BE OUTDATED",
+    "based_on_revision": "The current draft on HackMD is based on",
+    "view_outdated_draft": "View the outdated draft on HackMD",
+    "resume_to_edit": "Resume to edit with HackMD",
+    "discard_changes": "Discard changes of HackMD",
+    "integration_failed": "HackMD Integration failed",
+    "fail_to_connect": "GROWI client failed to connect to GROWI agent for HackMD.",
+    "check_configuration": "Check your configuration following <a href='https://docs.growi.org/guide/admin-cookbook/integrate-with-hackmd.html'>the manual</a>.",
+    "not_initialized": "HackmdEditor component has not initialized",
+    "someone_editing": "Someone editing this page on HackMD",
+    "this_page_has_draft": "This page has a draft on HackMD"
+  },
   "security_setting": {
     "Security settings": "Security settings",
     "Guest Users Access": "Guest Users Access",

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

@@ -7,7 +7,10 @@
     "list_of_installed_plugins": "インストールされているプラグイン一覧",
     "package_name": "パッケージ名",
     "specified_version": "指定バージョン",
-    "installed_version": "インストールされているバージョン"
+    "installed_version": "インストールされているバージョン",
+    "list_of_env_vars":"サーバー側で設定されている環境変数一覧",
+    "env_var_priority":"セキュリティに関する環境変数を除き、データベースの値が優先的に取得されます。",
+    "about_security":"セキュリティに関する環境変数は <a href='/admin/security'>セキュリティ設定画面</a> からご確認ください。"
   },
   "app_setting": {
     "site_name": "サイト名",
@@ -221,6 +224,7 @@
   },
   "user_management": {
     "invite_users": "新規ユーザーの招待",
+    "click_twice_same_checkbox": "少なくとも一つはチェックしてください。",
     "invite_modal": {
       "emails": "メールアドレス (複数行入力で複数人招待可能)",
       "invite_thru_email": "招待をメールで送信",

+ 29 - 7
resource/locales/ja/translation.json

@@ -122,6 +122,7 @@
   "List Drafts": "下書き一覧",
   "Deleted Pages": "削除済みページ",
   "Sign out": "ログアウト",
+  "Disassociate": "連携解除",
   "form_validation": {
     "error_message": "いくつかの値が設定されていません",
     "required": "%sに値を入力してください",
@@ -163,12 +164,16 @@
   },
   "Password": "パスワード",
   "Password Settings": "パスワード設定",
-  "Set new Password": "パスワードを新規に設定",
-  "Update Password": "パスワードを更新",
-  "Current password": "現在のパスワード",
-  "New password": "新しいパスワード",
-  "Re-enter new password": "(確認用)",
-  "Password is not set": "パスワードが設定されていません",
+  "personal_settings":{
+    "disassociate_external_account": "External Account の連携解除",
+    "disassociate_external_account_desc": "<strong>{{providerType}}</strong> プロバイダーの <strong>{{accountId}}</strong> アカウントを連携解除します",
+    "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設定",
@@ -318,7 +323,7 @@
   "template": {
     "modal_label": {
       "Create/Edit Template Page": "テンプレートページの作成/編集",
-      "Create template under": "<code><small>%s</small></code><br />にテンプレートページを作成"
+      "Create template under": "<code>%s</code><br />にテンプレートページを作成"
     },
     "option_label": {
       "select": "テンプレートタイプを選択してください",
@@ -364,6 +369,23 @@
     "insert_image": "で画像を挿入できます",
     "open_sandbox": "Sandbox を開く"
   },
+  "hackmd":{
+    "not_set_up": "HackMD はセットアップされていません",
+    "start_to_edit": "HackMD を開始する",
+    "clone_page_content": "ページを複製して編集を開始します",
+    "unsaved_draft": "HackMD のドラフトが保存されていません",
+    "draft_outdated": "ドラフトは古くなっている可能性があります",
+    "based_on_revision": "現在のドラフトは次の revision に基づいています",
+    "view_outdated_draft": "HackMD で古いドラフトを表示する",
+    "resume_to_edit": "HackMD で編集を再開する",
+    "discard_changes": "HackMD の変更を破棄する",
+    "integration_failed": "HackMD の統合に失敗しました",
+    "fail_to_connect": "GROWI クライアントが HackMD の GROWI agent に接続できませんでした。",
+    "check_configuration": "<a href='https://docs.growi.org/guide/admin-cookbook/integrate-with-hackmd.html'>こちらのマニュアル</a>から設定を確認してください",
+    "not_initialized": "HackMD コンポーネントは初期化されていません",
+    "someone_editing": "このページは、HackMD で編集されています。",
+    "this_page_has_draft": "このページは、HackMD のドラフトがあります。"
+  },
   "security_setting": {
     "Guest Users Access": "ゲストユーザーのアクセス",
     "Fixed by env var": "環境変数 <code>{{forcewikimode}}={{wikimode}}</code> により固定されています。",

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

@@ -23,19 +23,19 @@ import PageAttachment from './components/PageAttachment';
 import PageStatusAlert from './components/PageStatusAlert';
 import RevisionPath from './components/Page/RevisionPath';
 import TagLabels from './components/Page/TagLabels';
-import BookmarkButton from './components/BookmarkButton';
-import LikeButton from './components/LikeButton';
 import PagePathAutoComplete from './components/PagePathAutoComplete';
 import RecentCreated from './components/RecentCreated/RecentCreated';
 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 GrowiSubNavigation from './components/Navbar/GrowiSubNavigation';
+import PersonalContainer from './services/PersonalContainer';
 
 import { appContainer, componentMappings } from './bootstrap';
 
@@ -49,8 +49,9 @@ const pageContainer = new PageContainer(appContainer);
 const commentContainer = new CommentContainer(appContainer);
 const editorContainer = new EditorContainer(appContainer, defaultEditorOptions, defaultPreviewOptions);
 const tagContainer = new TagContainer(appContainer);
+const personalContainer = new PersonalContainer(appContainer);
 const injectableContainers = [
-  appContainer, websocketContainer, pageContainer, commentContainer, editorContainer, tagContainer,
+  appContainer, websocketContainer, pageContainer, commentContainer, editorContainer, tagContainer, personalContainer,
 ];
 
 logger.info('unstated containers have been initialized');
@@ -75,6 +76,7 @@ Object.assign(componentMappings, {
 
   'user-created-list': <RecentCreated />,
   'user-draft-list': <MyDraftList />,
+  'personal-setting': <PersonalSettings crowi={personalContainer} />,
 });
 
 // additional definitions if data exists
@@ -86,11 +88,8 @@ if (pageContainer.state.pageId != null) {
     'page-timeline': <PageTimeline />,
     'page-comment-write': <CommentEditorLazyRenderer />,
     'revision-toc': <TableOfContents />,
-    'like-button': <LikeButton pageId={pageContainer.state.pageId} isLiked={pageContainer.state.isLiked} />,
     'seen-user-list': <UserPictureList userIds={pageContainer.state.seenUserIds} />,
     'liker-list': <UserPictureList userIds={pageContainer.state.likerUserIds} />,
-    'bookmark-button': <BookmarkButton pageId={pageContainer.state.pageId} crowi={appContainer} />,
-    'bookmark-button-lg': <BookmarkButton pageId={pageContainer.state.pageId} crowi={appContainer} size="lg" />,
     'rename-page-name-input': <PagePathAutoComplete crowi={appContainer} initializedPath={pageContainer.state.path} />,
     'duplicate-page-name-input': <PagePathAutoComplete crowi={appContainer} initializedPath={pageContainer.state.path} />,
   });

+ 12 - 1
src/client/js/components/Admin/AdminHome/AdminHome.jsx

@@ -10,6 +10,7 @@ import AppContainer from '../../../services/AppContainer';
 import AdminHomeContainer from '../../../services/AdminHomeContainer';
 import SystemInfomationTable from './SystemInfomationTable';
 import InstalledPluginTable from './InstalledPluginTable';
+import EnvVarsTable from './EnvVarsTable';
 
 const logger = loggerFactory('growi:admin');
 
@@ -29,7 +30,7 @@ class AdminHome extends React.Component {
   }
 
   render() {
-    const { t } = this.props;
+    const { t, adminHomeContainer } = this.props;
 
     return (
       <Fragment>
@@ -52,6 +53,16 @@ class AdminHome extends React.Component {
             <InstalledPluginTable />
           </div>
         </div>
+
+        <div className="row mb-5">
+          <div className="col-md-12">
+            <h2 className="admin-setting-header">{t('admin:admin_top.list_of_env_vars')}</h2>
+            <p>{t('admin:admin_top.env_var_priority')}</p>
+            {/* eslint-disable-next-line react/no-danger */}
+            <p dangerouslySetInnerHTML={{ __html: t('admin:admin_top.about_security') }} />
+            {adminHomeContainer.state.envVars && <EnvVarsTable envVars={adminHomeContainer.state.envVars} />}
+          </div>
+        </div>
       </Fragment>
     );
   }

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

@@ -0,0 +1,32 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+const EnvVarsTable = (props) => {
+  const envVarRows = [];
+
+  for (const [key, value] of Object.entries(props.envVars)) {
+    if (value != null) {
+      envVarRows.push(
+        <tr key={key}>
+          <th className="col-sm-4">{key}</th>
+          <td>{value.toString()}</td>
+        </tr>,
+      );
+    }
+  }
+
+  return (
+    <table className="table table-bordered">
+      <tbody>
+        {envVarRows}
+      </tbody>
+    </table>
+  );
+
+};
+
+EnvVarsTable.propTypes = {
+  envVars: PropTypes.object.isRequired,
+};
+
+export default EnvVarsTable;

+ 13 - 11
src/client/js/components/Admin/Common/AdminNavigation.jsx

@@ -13,40 +13,42 @@ const AdminNavigation = (props) => {
 
   return (
     <div className="list-group admin-navigation">
-      <a href="/admin" className={`list-group-item list-group-item-action border-0 ${pathname === '/admin' && 'active'}`}>
+      <a href="/admin" className={`list-group-item list-group-item-action border-0 round-corner ${pathname === '/admin' && 'active'}`}>
         <i className="icon-fw icon-home"></i> {t('Management Wiki Home')}
       </a>
-      <a href="/admin/app" className={`list-group-item list-group-item-action border-0 ${isActiveMenu('/app') && 'active'}`}>
+      <a href="/admin/app" className={`list-group-item list-group-item-action border-0 round-corner ${isActiveMenu('/app') && 'active'}`}>
         <i className="icon-fw icon-settings"></i> {t('App Settings')}
       </a>
-      <a href="/admin/security" className={`list-group-item list-group-item-action border-0  ${isActiveMenu('/security') && 'active'}`}>
+      <a href="/admin/security" className={`list-group-item list-group-item-action border-0 round-corner  ${isActiveMenu('/security') && 'active'}`}>
         <i className="icon-fw icon-shield"></i> {t('security_settings')}
       </a>
-      <a href="/admin/markdown" className={`list-group-item list-group-item-action border-0 ${isActiveMenu('/markdown') && 'active'}`}>
+      <a href="/admin/markdown" className={`list-group-item list-group-item-action border-0 round-corner ${isActiveMenu('/markdown') && 'active'}`}>
         <i className="icon-fw icon-note"></i> {t('Markdown Settings')}
       </a>
-      <a href="/admin/customize" className={`list-group-item list-group-item-action border-0 ${isActiveMenu('/customize') && 'active'}`}>
+      <a href="/admin/customize" className={`list-group-item list-group-item-action border-0 round-corner ${isActiveMenu('/customize') && 'active'}`}>
         <i className="icon-fw icon-wrench"></i> {t('Customize')}
       </a>
-      <a href="/admin/importer" className={`list-group-item list-group-item-action border-0 ${isActiveMenu('/importer') && 'active'}`}>
+      <a href="/admin/importer" className={`list-group-item list-group-item-action border-0 round-corner ${isActiveMenu('/importer') && 'active'}`}>
         <i className="icon-fw icon-cloud-upload"></i> {t('Import Data')}
       </a>
-      <a href="/admin/export" className={`list-group-item list-group-item-action border-0 ${isActiveMenu('/export') && 'active'}`}>
+      <a href="/admin/export" className={`list-group-item list-group-item-action border-0 round-corner ${isActiveMenu('/export') && 'active'}`}>
         <i className="icon-fw icon-cloud-download"></i> {t('Export Archive Data')}
       </a>
       <a
         href="/admin/notification"
-        className={`list-group-item list-group-item-action  border-0 ${(isActiveMenu('/notification') || isActiveMenu('/global-notification')) && 'active'}`}
+        className={
+          `list-group-item list-group-item-action border-0 round-corner ${(isActiveMenu('/notification') || isActiveMenu('/global-notification')) && 'active'}`
+        }
       >
         <i className="icon-fw icon-bell"></i> {t('Notification Settings')}
       </a>
-      <a href="/admin/users" className={`list-group-item list-group-item-action border-0 ${(isActiveMenu('/users')) && 'active'}`}>
+      <a href="/admin/users" className={`list-group-item list-group-item-action border-0 round-corner ${(isActiveMenu('/users')) && 'active'}`}>
         <i className="icon-fw icon-user"></i> {t('User_Management')}
       </a>
-      <a href="/admin/user-groups" className={`list-group-item list-group-item-action border-0 ${isActiveMenu('/user-group') && 'active'}`}>
+      <a href="/admin/user-groups" className={`list-group-item list-group-item-action border-0 round-corner ${isActiveMenu('/user-group') && 'active'}`}>
         <i className="icon-fw icon-people"></i> {t('UserGroup Management')}
       </a>
-      <a href="/admin/search" className={`list-group-item list-group-item-action border-0 ${isActiveMenu('/search') && 'active'}`}>
+      <a href="/admin/search" className={`list-group-item list-group-item-action border-0 round-corner ${isActiveMenu('/search') && 'active'}`}>
         <i className="icon-fw icon-magnifier"></i> {t('Full Text Search Management')}
       </a>
     </div>

+ 144 - 0
src/client/js/components/Admin/Security/LdapAuthTest.jsx

@@ -0,0 +1,144 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+import loggerFactory from '@alias/logger';
+
+import { createSubscribedElement } from '../../UnstatedUtils';
+import { toastSuccess, toastError } from '../../../util/apiNotification';
+
+import AppContainer from '../../../services/AppContainer';
+import AdminLdapSecurityContainer from '../../../services/AdminLdapSecurityContainer';
+
+const logger = loggerFactory('growi:security:AdminLdapSecurityContainer');
+
+class LdapAuthTest extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    this.state = {
+      logs: '',
+      errorMessage: null,
+      successMessage: null,
+    };
+
+    this.addLogs = this.addLogs.bind(this);
+    this.testLdapCredentials = this.testLdapCredentials.bind(this);
+  }
+
+  /**
+   * add logs
+   */
+  addLogs(log) {
+    const newLog = `${new Date()} - ${log}\n\n`;
+    this.setState({
+      logs: `${newLog}${this.state.logs}`,
+    });
+  }
+
+  /**
+   * Test ldap auth
+   */
+  async testLdapCredentials() {
+    try {
+      const response = await this.props.appContainer.apiPost('/login/testLdap', {
+        loginForm: {
+          username: this.props.username,
+          password: this.props.password,
+        },
+      });
+
+      // add logs
+      if (response.err) {
+        toastError(response.err);
+        this.addLogs(response.err);
+      }
+
+      if (response.status === 'warning') {
+        this.addLogs(response.message);
+        this.setState({ errorMessage: response.message, successMessage: null });
+      }
+
+      if (response.status === 'success') {
+        toastSuccess(response.message);
+        this.setState({ successMessage: response.message, errorMessage: null });
+      }
+
+      if (response.ldapConfiguration) {
+        const prettified = JSON.stringify(response.ldapConfiguration.server, undefined, 4);
+        this.addLogs(`LDAP Configuration : ${prettified}`);
+      }
+      if (response.ldapAccountInfo) {
+        const prettified = JSON.stringify(response.ldapAccountInfo, undefined, 4);
+        this.addLogs(`Retrieved LDAP Account : ${prettified}`);
+      }
+
+    }
+    // Catch server communication error
+    catch (err) {
+      toastError(err);
+      logger.error(err);
+    }
+  }
+
+  render() {
+    const { t } = this.props;
+
+    return (
+      <React.Fragment>
+        {this.state.successMessage != null && <div className="alert alert-success">{this.state.successMessage}</div>}
+        {this.state.errorMessage != null && <div className="alert alert-warning">{this.state.errorMessage}</div>}
+        <div className="row p-3">
+          <label htmlFor="username" className="col-xs-3 text-right">{t('username')}</label>
+          <div className="col-xs-6">
+            <input
+              className="form-control"
+              name="username"
+              value={this.props.username}
+              onChange={(e) => { this.props.onChangeUsername(e.target.value) }}
+            />
+          </div>
+        </div>
+        <div className="row p-3">
+          <label htmlFor="password" className="col-xs-3 text-right">{t('Password')}</label>
+          <div className="col-xs-6">
+            <input
+              className="form-control"
+              type="password"
+              name="password"
+              value={this.props.password}
+              onChange={(e) => { this.props.onChangePassword(e.target.value) }}
+            />
+          </div>
+        </div>
+        <div>
+          <h5>Logs</h5>
+          <textarea id="taLogs" className="col-xs-12" rows="4" value={this.state.logs} readOnly />
+        </div>
+
+        <button type="button" className="btn btn-default mt-3 col-xs-offset-5 col-xs-2" onClick={this.testLdapCredentials}>Test</button>
+
+      </React.Fragment>
+
+    );
+  }
+
+}
+
+
+LdapAuthTest.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  adminLdapSecurityContainer: PropTypes.instanceOf(AdminLdapSecurityContainer).isRequired,
+
+  username: PropTypes.string.isRequired,
+  password: PropTypes.string.isRequired,
+  onChangeUsername: PropTypes.func.isRequired,
+  onChangePassword: PropTypes.func.isRequired,
+};
+
+const LdapAuthTestWrapper = (props) => {
+  return createSubscribedElement(LdapAuthTest, props, [AppContainer, AdminLdapSecurityContainer]);
+};
+
+export default withTranslation()(LdapAuthTestWrapper);

+ 8 - 96
src/client/js/components/Admin/Security/LdapAuthTestModal.jsx

@@ -1,7 +1,6 @@
 import React from 'react';
 import PropTypes from 'prop-types';
 import { withTranslation } from 'react-i18next';
-import loggerFactory from '@alias/logger';
 
 import {
   Modal,
@@ -11,12 +10,11 @@ import {
 } from 'reactstrap';
 
 import { createSubscribedElement } from '../../UnstatedUtils';
-import { toastSuccess, toastError } from '../../../util/apiNotification';
 
 import AppContainer from '../../../services/AppContainer';
 import AdminLdapSecurityContainer from '../../../services/AdminLdapSecurityContainer';
+import LdapAuthTest from './LdapAuthTest';
 
-const logger = loggerFactory('growi:security:AdminLdapSecurityContainer');
 
 class LdapAuthTestModal extends React.Component {
 
@@ -26,15 +24,10 @@ class LdapAuthTestModal extends React.Component {
     this.state = {
       username: '',
       password: '',
-      logs: '',
-      errorMessage: null,
-      successMessage: null,
     };
 
     this.onChangeUsername = this.onChangeUsername.bind(this);
     this.onChangePassword = this.onChangePassword.bind(this);
-    this.addLogs = this.addLogs.bind(this);
-    this.testLdapCredentials = this.testLdapCredentials.bind(this);
   }
 
   /**
@@ -51,63 +44,7 @@ class LdapAuthTestModal extends React.Component {
     this.setState({ password });
   }
 
-  /**
-   * add logs
-   */
-  addLogs(log) {
-    const newLog = `${new Date()} - ${log}\n\n`;
-    this.setState({
-      logs: `${newLog}${this.state.logs}`,
-    });
-  }
-
-  /**
-   * Test ldap auth
-   */
-  async testLdapCredentials() {
-    try {
-      const response = await this.props.appContainer.apiPost('/login/testLdap', {
-        loginForm: {
-          username: this.state.username,
-          password: this.state.password,
-        },
-      });
-
-      // add logs
-      if (response.err) {
-        toastError(response.err);
-        this.addLogs(response.err);
-      }
-
-      if (response.status === 'warning') {
-        this.addLogs(response.message);
-        this.setState({ errorMessage: response.message, successMessage: null });
-      }
-
-      if (response.status === 'success') {
-        toastSuccess(response.message);
-        this.setState({ successMessage: response.message, errorMessage: null });
-      }
-
-      if (response.ldapConfiguration) {
-        const prettified = JSON.stringify(response.ldapConfiguration.server, undefined, 4);
-        this.addLogs(`LDAP Configuration : ${prettified}`);
-      }
-      if (response.ldapAccountInfo) {
-        const prettified = JSON.stringify(response.ldapAccountInfo, undefined, 4);
-        this.addLogs(`Retrieved LDAP Account : ${prettified}`);
-      }
-
-    }
-    // Catch server communication error
-    catch (err) {
-      toastError(err);
-      logger.error(err);
-    }
-  }
-
   render() {
-    const { t } = this.props;
 
     return (
       <Modal isOpen={this.props.isOpen} toggle={this.props.onClose}>
@@ -115,39 +52,14 @@ class LdapAuthTestModal extends React.Component {
           Test LDAP Account
         </ModalHeader>
         <ModalBody>
-          {this.state.successMessage != null && <div className="alert alert-success">{this.state.successMessage}</div>}
-          {this.state.errorMessage != null && <div className="alert alert-warning">{this.state.errorMessage}</div>}
-          <div className="row p-3">
-            <label htmlFor="username" className="col-3 text-right">{t('username')}</label>
-            <div className="col-6">
-              <input
-                className="form-control"
-                name="username"
-                value={this.state.username}
-                onChange={(e) => { this.onChangeUsername(e.target.value) }}
-              />
-            </div>
-          </div>
-          <div className="row p-3">
-            <label htmlFor="password" className="col-3 text-right">{t('Password')}</label>
-            <div className="col-6">
-              <input
-                className="form-control"
-                type="password"
-                name="password"
-                value={this.state.password}
-                onChange={(e) => { this.onChangePassword(e.target.value) }}
-              />
-            </div>
-          </div>
-          <div>
-            <h5>Logs</h5>
-            <textarea id="taLogs" className="col-12" rows="4" value={this.state.logs} readOnly />
-          </div>
+          <LdapAuthTest
+            username={this.state.username}
+            password={this.state.password}
+            onChangeUsername={this.onChangeUsername}
+            onChangePassword={this.onChangePassword}
+          />
         </ModalBody>
-        <ModalFooter>
-          <button type="button" className="btn btn-light mt-3 offset-5" onClick={this.testLdapCredentials}>Test</button>
-        </ModalFooter>
+        <ModalFooter />
       </Modal>
     );
   }

+ 1 - 1
src/client/js/components/Admin/UserGroupDetail/UserGroupUserTable.jsx

@@ -54,7 +54,7 @@ class UserGroupUserTable extends React.Component {
             return (
               <tr key={sRelation._id}>
                 <td>
-                  <UserPicture user={relatedUser} className="picture img-circle" />
+                  <UserPicture user={relatedUser} className="picture rounded-circle" />
                 </td>
                 <td>
                   <strong>{relatedUser.username}</strong>

+ 173 - 0
src/client/js/components/Admin/UserManagement.jsx

@@ -20,7 +20,12 @@ class UserManagement extends React.Component {
   constructor(props) {
     super();
 
+    this.state = {
+      isNotifyCommentShow: false,
+    };
+
     this.handlePage = this.handlePage.bind(this);
+    this.handleChangeSearchText = this.handleChangeSearchText.bind(this);
   }
 
   componentWillMount() {
@@ -36,6 +41,56 @@ class UserManagement extends React.Component {
     }
   }
 
+  /**
+   * For checking same check box twice
+   * @param {string} statusType
+   */
+  async handleClick(statusType) {
+    const { adminUsersContainer } = this.props;
+    if (!this.validateToggleStatus(statusType)) {
+      return this.setState({ isNotifyCommentShow: true });
+    }
+
+    if (this.state.isNotifyCommentShow) {
+      await this.setState({ isNotifyCommentShow: false });
+    }
+    adminUsersContainer.handleClick(statusType);
+  }
+
+  /**
+   * Workaround user status check box
+   * @param {string} statusType
+   */
+  validateToggleStatus(statusType) {
+    if (this.props.adminUsersContainer.isSelected(statusType)) {
+      return this.props.adminUsersContainer.state.selectedStatusList.size > 1;
+    }
+    return true;
+  }
+
+  /**
+   * Reset button
+   */
+  resetButtonClickHandler() {
+    const { adminUsersContainer } = this.props;
+    try {
+      adminUsersContainer.resetAllChanges();
+      this.searchUserElement.value = '';
+      this.state.isNotifyCommentShow = false;
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }
+
+  /**
+   * Workaround increamental search
+   * @param {string} event
+   */
+  handleChangeSearchText(event) {
+    this.props.adminUsersContainer.handleChangeSearchText(event.target.value);
+  }
+
   render() {
     const { t, adminUsersContainer } = this.props;
 
@@ -50,6 +105,20 @@ class UserManagement extends React.Component {
       </div>
     );
 
+    const clearButton = (
+      adminUsersContainer.state.searchText.length > 0
+        ? (
+          <i
+            className="icon-close search-clear"
+            onClick={() => {
+              adminUsersContainer.clearSearchText();
+              this.searchUserElement.value = '';
+            }}
+          />
+        )
+        : ''
+    );
+
     return (
       <Fragment>
         {adminUsersContainer.state.userForPasswordResetModal && <PasswordResetModal />}
@@ -63,6 +132,110 @@ class UserManagement extends React.Component {
 
         <h2>{t('User_Management')}</h2>
 
+        <div className="border-top border-bottom">
+
+          <div className="d-flex justify-content-start align-items-center my-2">
+            <div>
+              <i className="icon-magnifier mr-1"></i>
+              <span className="search-typeahead">
+                <input
+                  type="text"
+                  ref={(searchUserElement) => { this.searchUserElement = searchUserElement }}
+                  onChange={this.handleChangeSearchText}
+                />
+                { clearButton }
+              </span>
+            </div>
+
+            <div className="mx-5 form-inline">
+              <div className="custom-control custom-checkbox custom-checkbox-primary mr-2">
+                <input
+                  className="custom-control-input"
+                  type="checkbox"
+                  id="c1"
+                  checked={adminUsersContainer.isSelected('all')}
+                  onClick={() => { this.handleClick('all') }}
+                />
+                <label className="custom-control-label" htmlFor="c1">
+                  <span className="badge badge-primary d-inline-block vt mt-1">All</span>
+                </label>
+              </div>
+
+              <div className="custom-control custom-checkbox custom-checkbox-info mr-2">
+                <input
+                  className="custom-control-input"
+                  type="checkbox"
+                  id="c2"
+                  checked={adminUsersContainer.isSelected('registered')}
+                  onClick={() => { this.handleClick('registered') }}
+                />
+                <label className="custom-control-label" htmlFor="c2">
+                  <span className="badge badge-info d-inline-block vt mt-1">Approval Pending</span>
+                </label>
+              </div>
+
+              <div className="custom-control custom-checkbox custom-checkbox-success mr-2">
+                <input
+                  className="custom-control-input"
+                  type="checkbox"
+                  id="c3"
+                  checked={adminUsersContainer.isSelected('active')}
+                  onClick={() => { this.handleClick('active') }}
+                />
+                <label className="custom-control-label" htmlFor="c3">
+                  <span className="badge badge-success d-inline-block vt mt-1">Active</span>
+                </label>
+              </div>
+
+              <div className="custom-control custom-checkbox custom-checkbox-warning mr-2">
+                <input
+                  className="custom-control-input"
+                  type="checkbox"
+                  id="c4"
+                  checked={adminUsersContainer.isSelected('suspended')}
+                  onClick={() => { this.handleClick('suspended') }}
+                />
+                <label className="custom-control-label" htmlFor="c4">
+                  <span className="badge badge-warning d-inline-block vt mt-1">Suspended</span>
+                </label>
+              </div>
+
+              <div className="custom-control custom-checkbox custom-checkbox-info">
+                <input
+                  className="custom-control-input"
+                  type="checkbox"
+                  id="c5"
+                  checked={adminUsersContainer.isSelected('invited')}
+                  onClick={() => { this.handleClick('invited') }}
+                />
+                <label className="custom-control-label" htmlFor="c5">
+                  <span className="badge badge-info d-inline-block vt mt-1">Invited</span>
+                </label>
+              </div>
+            </div>
+
+            <div>
+              <button
+                type="button"
+                className="btn btn-outline-secondary btn-sm"
+                onClick={() => { this.resetButtonClickHandler() }}
+              >
+                <span
+                  className="icon-refresh mr-1"
+                >
+                </span>
+                Reset
+              </button>
+            </div>
+
+            <div className="ml-5">
+              {this.state.isNotifyCommentShow && <span className="text-warning small">{t('admin:user_management.click_twice_same_checkbox')}</span>}
+            </div>
+
+          </div>
+        </div>
+
+
         {pager}
         <UserTable />
         {pager}

+ 32 - 0
src/client/js/components/Admin/Users/SortIcons.jsx

@@ -0,0 +1,32 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+
+const SortIcons = (props) => {
+
+  const { isSelected, isAsc } = props;
+
+  return (
+    <div className="d-flex flex-column text-center">
+      <a
+        className={`fa ${isSelected && isAsc ? 'fa-chevron-up' : 'fa-angle-up'}`}
+        aria-hidden="true"
+        onClick={() => props.onClick('asc')}
+      />
+      <a
+        className={`fa ${isSelected && !isAsc ? 'fa-chevron-down' : 'fa-angle-down'}`}
+        aria-hidden="true"
+        onClick={() => props.onClick('desc')}
+      />
+    </div>
+  );
+};
+
+SortIcons.propTypes = {
+  onClick: PropTypes.func.isRequired,
+  isSelected: PropTypes.bool.isRequired,
+  isAsc: PropTypes.bool.isRequired,
+};
+
+
+export default withTranslation()(SortIcons);

+ 9 - 4
src/client/js/components/Admin/Users/UserInviteModal.jsx

@@ -78,19 +78,24 @@ class UserInviteModal extends React.Component {
 
     return (
       <>
-        <div className="ccustom-control custom-switch custom-checkbox-info text-left" onChange={this.handleCheckBox} style={{ flex: 0.95 }}>
+        <div className="col text-left custom-control custom-checkbox custom-checkbox-info text-left" onChange={this.handleCheckBox}>
           <input type="checkbox" id="sendEmail" className="custom-control-input" name="sendEmail" defaultChecked={this.state.sendEmail} />
           <label className="custom-control-label" htmlFor="sendEmail">
             {t('admin:user_management.invite_modal.invite_thru_email')}
           </label>
         </div>
         <div>
-          <button type="button" className="fcbtn btn btn-xs btn-outline-secondary" onClick={this.onToggleModal}>
+          <button
+            type="button"
+            className="btn btn-outline-danger rounded-pill mr-2"
+            onClick={this.onToggleModal}
+          >
             Cancel
           </button>
+
           <button
             type="button"
-            className="fcbtn btn btn-primary btn-1b"
+            className="btn btn-outline-primary rounded-pill"
             onClick={this.handleSubmit}
             disabled={!this.validEmail()}
           >
@@ -111,7 +116,7 @@ class UserInviteModal extends React.Component {
         </label>
         <button
           type="button"
-          className="fcbtn btn btn-primary"
+          className="btn btn-outline-primary"
           onClick={this.onToggleModal}
         >
           Close

+ 99 - 10
src/client/js/components/Admin/Users/UserTable.jsx

@@ -9,6 +9,7 @@ import UserMenu from './UserMenu';
 import { createSubscribedElement } from '../../UnstatedUtils';
 import AppContainer from '../../../services/AppContainer';
 import AdminUsersContainer from '../../../services/AdminUsersContainer';
+import SortIcons from './SortIcons';
 
 class UserTable extends React.Component {
 
@@ -74,21 +75,108 @@ class UserTable extends React.Component {
     }
   }
 
+  sortIconsClickedHandler(sort, sortOrder) {
+    const isAsc = sortOrder === 'asc';
+
+    const { adminUsersContainer } = this.props;
+    adminUsersContainer.sort(sort, isAsc);
+  }
+
   render() {
     const { t, adminUsersContainer } = this.props;
 
+    const isCurrentSortOrderAsc = adminUsersContainer.state.sortOrder === 'asc';
+
     return (
       <Fragment>
         <table className="table table-default table-bordered table-user-list">
           <thead>
             <tr>
               <th width="100px">#</th>
-              <th>{t('status')}</th>
-              <th><code>username</code></th>
-              <th>{t('Name')}</th>
-              <th>{t('Email')}</th>
-              <th width="100px">{t('Created')}</th>
-              <th width="150px">{t('Last_Login')}</th>
+              <th>
+                <div className="d-flex align-items-center">
+                  <div className="mr-3">
+                    {t('status')}
+                  </div>
+                  <SortIcons
+                    isSelected={adminUsersContainer.state.sort === 'status'}
+                    isAsc={isCurrentSortOrderAsc}
+                    onClick={(sortOrder) => {
+                      this.sortIconsClickedHandler('status', sortOrder);
+                    }}
+                  />
+                </div>
+              </th>
+              <th>
+                <div className="d-flex align-items-center">
+                  <div className="mr-3">
+                    <code>username</code>
+                  </div>
+                  <SortIcons
+                    isSelected={adminUsersContainer.state.sort === 'username'}
+                    isAsc={isCurrentSortOrderAsc}
+                    onClick={(sortOrder) => {
+                      this.sortIconsClickedHandler('username', sortOrder);
+                    }}
+                  />
+                </div>
+              </th>
+              <th>
+                <div className="d-flex align-items-center">
+                  <div className="mr-3">
+                    {t('Name')}
+                  </div>
+                  <SortIcons
+                    isSelected={adminUsersContainer.state.sort === 'name'}
+                    isAsc={isCurrentSortOrderAsc}
+                    onClick={(sortOrder) => {
+                      this.sortIconsClickedHandler('name', sortOrder);
+                    }}
+                  />
+                </div>
+              </th>
+              <th>
+                <div className="d-flex align-items-center">
+                  <div className="mr-3">
+                    {t('Email')}
+                  </div>
+                  <SortIcons
+                    isSelected={adminUsersContainer.state.sort === 'email'}
+                    isAsc={isCurrentSortOrderAsc}
+                    onClick={(sortOrder) => {
+                      this.sortIconsClickedHandler('email', sortOrder);
+                    }}
+                  />
+                </div>
+              </th>
+              <th width="100px">
+                <div className="d-flex align-items-center">
+                  <div className="mr-3">
+                    {t('Created')}
+                  </div>
+                  <SortIcons
+                    isSelected={adminUsersContainer.state.sort === 'createdAt'}
+                    isAsc={isCurrentSortOrderAsc}
+                    onClick={(sortOrder) => {
+                      this.sortIconsClickedHandler('createdAt', sortOrder);
+                    }}
+                  />
+                </div>
+              </th>
+              <th width="150px">
+                <div className="d-flex align-items-center">
+                  <div className="mr-3">
+                    {t('Last_Login')}
+                  </div>
+                  <SortIcons
+                    isSelected={adminUsersContainer.state.sort === 'lastLoginAt'}
+                    isAsc={isCurrentSortOrderAsc}
+                    onClick={(sortOrder) => {
+                      this.sortIconsClickedHandler('lastLoginAt', sortOrder);
+                    }}
+                  />
+                </div>
+              </th>
               <th width="70px"></th>
             </tr>
           </thead>
@@ -97,7 +185,7 @@ class UserTable extends React.Component {
               return (
                 <tr key={user._id}>
                   <td>
-                    <UserPicture user={user} className="picture img-circle" />
+                    <UserPicture user={user} className="picture rounded-circle" />
                   </td>
                   <td>{this.getUserStatusLabel(user.status)} {this.getUserAdminLabel(user.admin)}</td>
                   <td>
@@ -123,9 +211,6 @@ class UserTable extends React.Component {
 
 }
 
-const UserTableWrapper = (props) => {
-  return createSubscribedElement(UserTable, props, [AppContainer, AdminUsersContainer]);
-};
 
 UserTable.propTypes = {
   t: PropTypes.func.isRequired, // i18next
@@ -134,4 +219,8 @@ UserTable.propTypes = {
 
 };
 
+const UserTableWrapper = (props) => {
+  return createSubscribedElement(UserTable, props, [AppContainer, AdminUsersContainer]);
+};
+
 export default withTranslation()(UserTableWrapper);

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

@@ -78,7 +78,7 @@ export default class BookmarkButton extends React.Component {
         href="#"
         title="Bookmark"
         onClick={this.handleClick}
-        className={`btn btn-circle btn-outline-warning border-0 ${addedClassName}`}
+        className={`btn btn-circle btn-outline-warning btn-bookmark border-0 ${addedClassName}`}
       >
         <i className="icon-star"></i>
       </button>

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

@@ -85,7 +85,7 @@ class HeaderSearchBox extends React.Component {
             placeholder="Search ..."
           />
           <div className="btn-group-submit-search">
-            <span className="btn-link" onClick={this.search}>
+            <span className="btn-link text-decoration-none" onClick={this.search}>
               <i className="icon-magnifier"></i>
             </span>
           </div>

+ 20 - 11
src/client/js/components/InstallerForm.jsx

@@ -90,8 +90,10 @@ class InstallerForm extends React.Component {
               </div>
             </div>
 
-            <div className={`input-group${hasErrorClass}`}>
-              <span className="input-group-addon"><i className="icon-user" /></span>
+            <div className={`input-group mb-3${hasErrorClass}`}>
+              <div className="input-group-prepend">
+                <span className="input-group-text"><i className="icon-user" /></span>
+              </div>
               <input
                 type="text"
                 className="form-control"
@@ -104,8 +106,10 @@ class InstallerForm extends React.Component {
             </div>
             <p className="form-text">{ unavailableUserId }</p>
 
-            <div className="input-group">
-              <span className="input-group-addon"><i className="icon-tag" /></span>
+            <div className="input-group mb-3">
+              <div className="input-group-prepend">
+                <span className="input-group-text"><i className="icon-tag" /></span>
+              </div>
               <input
                 type="text"
                 className="form-control"
@@ -116,8 +120,10 @@ class InstallerForm extends React.Component {
               />
             </div>
 
-            <div className="input-group">
-              <span className="input-group-addon"><i className="icon-envelope" /></span>
+            <div className="input-group mb-3">
+              <div className="input-group-prepend">
+                <span className="input-group-text"><i className="icon-envelope" /></span>
+              </div>
               <input
                 type="email"
                 className="form-control"
@@ -128,8 +134,10 @@ class InstallerForm extends React.Component {
               />
             </div>
 
-            <div className="input-group">
-              <span className="input-group-addon"><i className="icon-lock" /></span>
+            <div className="input-group mb-3">
+              <div className="input-group-prepend">
+                <span className="input-group-text"><i className="icon-lock" /></span>
+              </div>
               <input
                 type="password"
                 className="form-control"
@@ -142,9 +150,10 @@ class InstallerForm extends React.Component {
             <input type="hidden" name="_csrf" value={this.props.csrf} />
 
             <div className="input-group mt-4 mb-3 d-flex justify-content-center">
-              <button type="submit" className="fcbtn btn btn-success btn-1b btn-register">
-                <span className="btn-label"><i className="icon-user-follow" /></span>
-                <span className="btn-label-text">{ this.props.t('Create') }</span>
+              <button type="submit" className="btn-fill btn btn-register px-0 py-2" id="register">
+                <div className="eff"></div>
+                <span className="btn-label p-3"><i className="icon-user-follow" /></span>
+                <span className="btn-label-text p-3">{ this.props.t('Create') }</span>
               </button>
             </div>
 

+ 22 - 26
src/client/js/components/LikeButton.jsx

@@ -1,6 +1,7 @@
 import React from 'react';
 import PropTypes from 'prop-types';
 
+import { toastError } from '../util/apiNotification';
 import { createSubscribedElement } from './UnstatedUtils';
 import AppContainer from '../services/AppContainer';
 
@@ -10,29 +11,33 @@ class LikeButton extends React.Component {
     super(props);
 
     this.state = {
-      isLiked: !!props.isLiked,
+      isLiked: props.isLiked,
     };
 
     this.handleClick = this.handleClick.bind(this);
   }
 
-  handleClick(event) {
-    event.preventDefault();
-
-    const { appContainer } = this.props;
-    const pageId = this.props.pageId;
-
-    if (!this.state.isLiked) {
-      appContainer.apiPost('/likes.add', { page_id: pageId })
-        .then((res) => {
-          this.setState({ isLiked: true });
-        });
+  async handleClick() {
+    const { appContainer, pageId } = this.props;
+    const { isLiked } = this.state;
+
+    if (!isLiked) {
+      try {
+        await appContainer.apiPost('/likes.add', { page_id: pageId });
+        this.setState({ isLiked: true });
+      }
+      catch (err) {
+        toastError(err);
+      }
     }
     else {
-      appContainer.apiPost('/likes.remove', { page_id: pageId })
-        .then((res) => {
-          this.setState({ isLiked: false });
-        });
+      try {
+        await appContainer.apiPost('/likes.remove', { page_id: pageId });
+        this.setState({ isLiked: false });
+      }
+      catch (err) {
+        toastError(err);
+      }
     }
   }
 
@@ -46,20 +51,11 @@ class LikeButton extends React.Component {
       return <div></div>;
     }
 
-    const btnSizeClassName = this.props.size ? `btn-${this.props.size}` : 'btn-md';
-    const addedClassNames = [
-      this.state.isLiked ? 'active' : '',
-      btnSizeClassName,
-    ];
-    const addedClassName = addedClassNames.join(' ');
-
     return (
       <button
         type="button"
-        href="#"
-        title="Like"
         onClick={this.handleClick}
-        className={`btn btn-circle btn-outline-info border-0 ${addedClassName}`}
+        className={`btn btn-circle btn-outline-info btn-like border-0 ${this.state.isLiked ? 'active' : ''}`}
       >
         <i className="icon-like"></i>
       </button>

+ 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-3 text-right">{t('Current API Token')}</label>
+          <div className="col-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="offset-3 col-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="offset-4 col-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);

+ 146 - 0
src/client/js/components/Me/AssociateModal.jsx

@@ -0,0 +1,146 @@
+
+import React from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+
+import {
+  Modal,
+  ModalHeader,
+  ModalBody,
+  ModalFooter,
+} from 'reactstrap';
+import { toastSuccess, toastError } from '../../util/apiNotification';
+import { createSubscribedElement } from '../UnstatedUtils';
+
+import AppContainer from '../../services/AppContainer';
+import PersonalContainer from '../../services/PersonalContainer';
+
+import LdapAuthTest from '../Admin/Security/LdapAuthTest';
+
+class AssociateModal extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    this.state = {
+      username: '',
+      password: '',
+    };
+
+    this.onChangeUsername = this.onChangeUsername.bind(this);
+    this.onChangePassword = this.onChangePassword.bind(this);
+    this.onClickAddBtn = this.onClickAddBtn.bind(this);
+  }
+
+  /**
+   * Change username
+   */
+  onChangeUsername(username) {
+    this.setState({ username });
+  }
+
+  /**
+   * Change password
+   */
+  onChangePassword(password) {
+    this.setState({ password });
+  }
+
+  async onClickAddBtn() {
+    const { t, personalContainer } = this.props;
+    const { username, password } = this.state;
+
+    try {
+      await personalContainer.associateLdapAccount({ username, password });
+      this.props.onClose();
+      toastSuccess(t('security_setting.updated_general_security_setting'));
+    }
+    catch (err) {
+      toastError(err);
+    }
+    try {
+      await personalContainer.retrieveExternalAccounts();
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }
+
+  render() {
+    const { t } = this.props;
+
+    return (
+      <Modal isOpen={this.props.isOpen} toggle={this.props.onClose} className="mw-100 m-4">
+        <ModalHeader className="bg-info" toggle={this.props.onClose}>
+          { t('Create External Account') }
+        </ModalHeader>
+        <ModalBody>
+          <ul className="nav nav-tabs passport-settings mb-2" role="tablist">
+            <li className="nav-item active">
+              <a href="#passport-ldap" className="nav-link active" data-toggle="tab" role="tab">
+                <i className="fa fa-sitemap"></i> LDAP
+              </a>
+            </li>
+            <li className="nav-item">
+              <a href="#github-tbd" className="nav-link" data-toggle="tab" role="tab">
+                <i className="fa fa-github"></i> (TBD) GitHub
+              </a>
+            </li>
+            <li className="nav-item">
+              <a href="#google-tbd" className="nav-link" data-toggle="tab" role="tab">
+                <i className="fa fa-google"></i> (TBD) Google OAuth
+              </a>
+            </li>
+            <li className="nav-item">
+              <a href="#facebook-tbd" className="nav-link" data-toggle="tab" role="tab">
+                <i className="fa fa-facebook"></i> (TBD) Facebook
+              </a>
+            </li>
+            <li className="nav-item">
+              <a href="#twitter-tbd" className="nav-link" data-toggle="tab" role="tab">
+                <i className="fa fa-twitter"></i> (TBD) Twitter
+              </a>
+            </li>
+          </ul>
+          <div className="tab-content">
+            <div id="passport-ldap" className="tab-pane active">
+              <LdapAuthTest
+                username={this.state.username}
+                password={this.state.password}
+                onChangeUsername={this.onChangeUsername}
+                onChangePassword={this.onChangePassword}
+              />
+            </div>
+            <div id="github-tbd" className="tab-pane" role="tabpanel">TBD</div>
+            <div id="google-tbd" className="tab-pane" role="tabpanel">TBD</div>
+            <div id="facebook-tbd" className="tab-pane" role="tabpanel">TBD</div>
+            <div id="twitter-tbd" className="tab-pane" role="tabpanel">TBD</div>
+          </div>
+        </ModalBody>
+        <ModalFooter className="border-top-0">
+          <button type="button" className="btn btn-info mt-3" onClick={this.onClickAddBtn}>
+            <i className="fa fa-plus-circle" aria-hidden="true"></i>
+            {t('add')}
+          </button>
+        </ModalFooter>
+      </Modal>
+    );
+  }
+
+}
+
+const AssociateModalWrapper = (props) => {
+  return createSubscribedElement(AssociateModal, props, [AppContainer, PersonalContainer]);
+};
+
+AssociateModal.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  personalContainer: PropTypes.instanceOf(PersonalContainer).isRequired,
+
+  isOpen: PropTypes.bool.isRequired,
+  onClose: PropTypes.func.isRequired,
+};
+
+
+export default withTranslation()(AssociateModalWrapper);

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

@@ -0,0 +1,164 @@
+
+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 form-group 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 form-group 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-sm-2 text-right">{t('Disclose E-mail')}</label>
+          <div className="col-6">
+            <div className="custom-control custom-radio custom-control-inline">
+              <input
+                type="radio"
+                id="radioEmailShow"
+                className="custom-control-input"
+                name="userForm[isEmailPublished]"
+                checked={personalContainer.state.isEmailPublished}
+                onChange={() => { personalContainer.changeIsEmailPublished(true) }}
+              />
+              <label className="custom-control-label" htmlFor="radioEmailShow">{t('Show')}</label>
+            </div>
+            <div className="custom-control custom-radio custom-control-inline">
+              <input
+                type="radio"
+                id="radioEmailHide"
+                className="custom-control-input"
+                name="userForm[isEmailPublished]"
+                checked={!personalContainer.state.isEmailPublished}
+                onChange={() => { personalContainer.changeIsEmailPublished(false) }}
+              />
+              <label className="custom-control-label" htmlFor="radioEmailHide">{t('Hide')}</label>
+            </div>
+          </div>
+        </div>
+
+        <div className="row mb-3">
+          <label className="col-sm-2 col-form-label text-right">{t('Language')}</label>
+          <div className="col-6">
+            <div className="custom-control custom-radio custom-control-inline">
+              <input
+                type="radio"
+                id="radioLangEn"
+                className="custom-control-input"
+                name="userForm[lang]"
+                checked={personalContainer.state.lang === 'en-US'}
+                onChange={() => { personalContainer.changeLang('en-US') }}
+              />
+              <label className="custom-control-label" htmlFor="radioLangEn">{t('English')}</label>
+            </div>
+            <div className="custom-control custom-radio custom-control-inline">
+              <input
+                type="radio"
+                id="radioLangJa"
+                className="custom-control-input"
+                name="userForm[lang]"
+                checked={personalContainer.state.lang === 'ja'}
+                onChange={() => { personalContainer.changeLang('ja') }}
+              />
+              <label className="custom-control-label" htmlFor="radioLangJa">{t('Japanese')}</label>
+            </div>
+          </div>
+        </div>
+
+        <div className="row my-3">
+          <div className="offset-4 col-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);

+ 90 - 0
src/client/js/components/Me/DisassociateModal.jsx

@@ -0,0 +1,90 @@
+
+import React from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+
+import {
+  Modal,
+  ModalHeader,
+  ModalBody,
+  ModalFooter,
+} from 'reactstrap';
+import { toastSuccess, toastError } from '../../util/apiNotification';
+import { createSubscribedElement } from '../UnstatedUtils';
+
+import AppContainer from '../../services/AppContainer';
+import PersonalContainer from '../../services/PersonalContainer';
+
+class DisassociateModal extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    this.onClickDisassociateBtn = this.onClickDisassociateBtn.bind(this);
+  }
+
+  async onClickDisassociateBtn() {
+    const { t, personalContainer } = this.props;
+    const { providerType, accountId } = this.props.accountForDisassociate;
+
+    try {
+      await personalContainer.disassociateLdapAccount({ providerType, accountId });
+      this.props.onClose();
+      toastSuccess(t('security_setting.updated_general_security_setting'));
+    }
+    catch (err) {
+      toastError(err);
+    }
+    try {
+      await personalContainer.retrieveExternalAccounts();
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }
+
+  render() {
+    const { t, accountForDisassociate } = this.props;
+    const { providerType, accountId } = accountForDisassociate;
+
+    return (
+      <Modal isOpen={this.props.isOpen} toggle={this.props.onClose}>
+        <ModalHeader className="bg-info" toggle={this.props.onClose}>
+          {t('personal_settings.disassociate_external_account')}
+        </ModalHeader>
+        <ModalBody>
+          {/* eslint-disable-next-line react/no-danger */}
+          <p dangerouslySetInnerHTML={{ __html: t('personal_settings.disassociate_external_account_desc', { providerType, accountId }) }} />
+        </ModalBody>
+        <ModalFooter>
+          <button type="button" className="btn btn-sm btn-default" onClick={this.props.onClose}>
+            { t('Cancel') }
+          </button>
+          <button type="button" className="btn btn-sm btn-danger" onClick={this.onClickDisassociateBtn}>
+            <i className="ti-unlink"></i>
+            { t('Disassociate') }
+          </button>
+        </ModalFooter>
+      </Modal>
+    );
+  }
+
+}
+
+const DisassociateModalWrapper = (props) => {
+  return createSubscribedElement(DisassociateModal, props, [AppContainer, PersonalContainer]);
+};
+
+DisassociateModal.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  personalContainer: PropTypes.instanceOf(PersonalContainer).isRequired,
+
+  isOpen: PropTypes.bool.isRequired,
+  onClose: PropTypes.func.isRequired,
+  accountForDisassociate: PropTypes.object.isRequired,
+
+};
+
+
+export default withTranslation()(DisassociateModalWrapper);

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

@@ -0,0 +1,136 @@
+
+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';
+import AssociateModal from './AssociateModal';
+import DisassociateModal from './DisassociateModal';
+
+class ExternalAccountLinkedMe extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    this.state = {
+      isAssociateModalOpen: false,
+      isDisassociateModalOpen: false,
+      accountForDisassociate: null,
+    };
+
+    this.openAssociateModal = this.openAssociateModal.bind(this);
+    this.closeAssociateModal = this.closeAssociateModal.bind(this);
+    this.openDisassociateModal = this.openDisassociateModal.bind(this);
+    this.closeDisassociateModal = this.closeDisassociateModal.bind(this);
+  }
+
+  async componentDidMount() {
+    try {
+      await this.props.personalContainer.retrieveExternalAccounts();
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }
+
+  openAssociateModal() {
+    this.setState({ isAssociateModalOpen: true });
+  }
+
+  closeAssociateModal() {
+    this.setState({ isAssociateModalOpen: false });
+  }
+
+  /**
+   * open disassociate modal, and props account
+   * @param {object} account
+   */
+  openDisassociateModal(account) {
+    this.setState({
+      isDisassociateModalOpen: true,
+      accountForDisassociate: account,
+    });
+  }
+
+  closeDisassociateModal() {
+    this.setState({ isDisassociateModalOpen: false });
+  }
+
+  render() {
+    const { t, personalContainer } = this.props;
+    const { externalAccounts } = personalContainer.state;
+
+    return (
+      <Fragment>
+        <div className="container-fluid p-0 my-4">
+          <h2 className="border-bottom">
+            <button type="button" className="btn btn-light btn-sm pull-right" onClick={this.openAssociateModal}>
+              <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}
+                    openDisassociateModal={this.openDisassociateModal}
+                  />
+                ))}
+              </tbody>
+            </table>
+          </div>
+        </div>
+
+        <AssociateModal
+          isOpen={this.state.isAssociateModalOpen}
+          onClose={this.closeAssociateModal}
+        />
+
+        {this.state.accountForDisassociate != null
+        && (
+        <DisassociateModal
+          isOpen={this.state.isDisassociateModalOpen}
+          onClose={this.closeDisassociateModal}
+          accountForDisassociate={this.state.accountForDisassociate}
+        />
+        )}
+
+      </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);

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

@@ -0,0 +1,40 @@
+
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import { withTranslation } from 'react-i18next';
+import dateFnsFormat from 'date-fns/format';
+
+const ExternalAccountRow = (props) => {
+  const { t, account } = 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"
+          onClick={() => props.openDisassociateModal(account)}
+        >
+          <i className="ti-unlink"></i>
+          { t('Disassociate') }
+        </button>
+      </td>
+    </tr>
+  );
+};
+
+
+ExternalAccountRow.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+
+  account: PropTypes.object.isRequired,
+  openDisassociateModal: PropTypes.func.isRequired,
+};
+
+export default withTranslation()(ExternalAccountRow);

+ 130 - 0
src/client/js/components/Me/ImageCropModal.jsx

@@ -0,0 +1,130 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import loggerFactory from '@alias/logger';
+import canvasToBlob from 'async-canvas-to-blob';
+
+import {
+  Modal,
+  ModalHeader,
+  ModalBody,
+  ModalFooter,
+} from 'reactstrap';
+import { withTranslation } from 'react-i18next';
+import ReactCrop from 'react-image-crop';
+import AppContainer from '../../services/AppContainer';
+import { createSubscribedElement } from '../UnstatedUtils';
+import 'react-image-crop/dist/ReactCrop.css';
+import { toastError } from '../../util/apiNotification';
+
+const logger = loggerFactory('growi:ImageCropModal');
+
+class ImageCropModal extends React.Component {
+
+  // demo: https://codesandbox.io/s/72py4jlll6
+  constructor(props) {
+    super();
+    this.state = {
+      crop: null,
+      imageRef: null,
+    };
+    this.onImageLoaded = this.onImageLoaded.bind(this);
+    this.onCropChange = this.onCropChange.bind(this);
+    this.getCroppedImg = this.getCroppedImg.bind(this);
+    this.crop = this.crop.bind(this);
+    this.reset = this.reset.bind(this);
+    this.imageRef = null;
+  }
+
+  onImageLoaded(image) {
+    this.setState({ imageRef: image }, () => this.reset());
+    return false; // Return false when setting crop state in here.
+  }
+
+  onCropChange(crop) {
+    this.setState({ crop });
+  }
+
+  async getCroppedImg(image, crop, fileName) {
+    const canvas = document.createElement('canvas');
+    const scaleX = image.naturalWidth / image.width;
+    const scaleY = image.naturalHeight / image.height;
+    canvas.width = crop.width;
+    canvas.height = crop.height;
+    const ctx = canvas.getContext('2d');
+    ctx.drawImage(image, crop.x * scaleX, crop.y * scaleY, crop.width * scaleX, crop.height * scaleY, 0, 0, crop.width, crop.height);
+    try {
+      const blob = await canvasToBlob(canvas);
+      return blob;
+    }
+    catch (err) {
+      logger.error(err);
+      toastError(new Error('Failed to draw image'));
+    }
+  }
+
+  async crop() {
+    // crop immages
+    if (this.state.imageRef && this.state.crop.width && this.state.crop.height) {
+      const croppedImage = await this.getCroppedImg(this.state.imageRef, this.state.crop, '/images/icons/user');
+      this.props.onCropCompleted(croppedImage);
+    }
+  }
+
+  reset() {
+    const size = Math.min(this.state.imageRef.width, this.state.imageRef.height);
+    this.setState({
+      crop: {
+        aspect: 1,
+        unit: 'px',
+        x: this.state.imageRef.width / 2 - size / 2,
+        y: this.state.imageRef.height / 2 - size / 2,
+        width: size,
+        height: size,
+      },
+    });
+  }
+
+  render() {
+    return (
+      <Modal isOpen={this.props.show} toggle={this.props.onModalClose}>
+        <ModalHeader tag="h4" toggle={this.props.onModalClose}>
+          Image Crop
+        </ModalHeader>
+        <ModalBody className="my-4">
+          <ReactCrop circularCrop src={this.props.src} crop={this.state.crop} onImageLoaded={this.onImageLoaded} onChange={this.onCropChange} />
+        </ModalBody>
+        <ModalFooter>
+          <div className="d-flex justify-content-between">
+            <button type="button" className="btn btn-sm bg-danger" onClick={this.reset}>
+              Reset
+            </button>
+            <div className="d-flex">
+              <button type="button" className="btn btn-sm bg-light" onClick={this.props.onModalClose}>
+                Cancel
+              </button>
+              <button type="button" className="btn btn-sm bg-primary" onClick={this.crop}>
+                Crop
+              </button>
+            </div>
+          </div>
+        </ModalFooter>
+      </Modal>
+    );
+  }
+
+}
+
+/**
+ * Wrapper component for using unstated
+ */
+const ProfileImageFormWrapper = (props) => {
+  return createSubscribedElement(ImageCropModal, props, [AppContainer]);
+};
+ImageCropModal.propTypes = {
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  show: PropTypes.bool.isRequired,
+  src: PropTypes.string,
+  onModalClose: PropTypes.func.isRequired,
+  onCropCompleted: PropTypes.func.isRequired,
+};
+export default withTranslation()(ProfileImageFormWrapper);

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

@@ -0,0 +1,145 @@
+
+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-3 text-right">{ t('personal_settings.current_password') }</label>
+            <div className="col-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-3 text-right">{t('personal_settings.new_password') }</label>
+          <div className="col-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-3 text-right">{t('personal_settings.new_password_confirm') }</label>
+          <div className="col-6">
+            <input
+              className="form-control"
+              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="my-3 text-center">
+          <button
+            type="button"
+            className="btn btn-primary"
+            onClick={this.onClickSubmit}
+            disabled={this.state.retrieveError != null || isIncorrectConfirmPassword}
+          >
+            {t('Update')}
+          </button>
+        </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="nav-item">
+                <a className="nav-link active" href="#user-settings" data-toggle="tab" role="tab"><i className="icon-user"></i> { t('User Information') }</a>
+              </li>
+              <li className="nav-item">
+                <a className="nav-link" href="#external-accounts" data-toggle="tab" role="tab"><i className="icon-share-alt"></i> { t('External Accounts') }</a>
+              </li>
+              <li className="nav-item">
+                <a className="nav-link" href="#password-settings" data-toggle="tab" role="tab"><i className="icon-lock"></i> { t('Password Settings') }</a>
+              </li>
+              <li className="nav-item">
+                <a className="nav-link" 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);

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

@@ -0,0 +1,198 @@
+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';
+
+import ImageCropModal from './ImageCropModal';
+
+class ProfileImageSettings extends React.Component {
+
+  constructor(appContainer) {
+    super();
+
+    this.state = {
+      show: false,
+      src: null,
+    };
+
+    this.imageRef = null;
+    this.onSelectFile = this.onSelectFile.bind(this);
+    this.onClickDeleteBtn = this.onClickDeleteBtn.bind(this);
+    this.hideModal = this.hideModal.bind(this);
+    this.cancelModal = this.cancelModal.bind(this);
+    this.onCropCompleted = this.onCropCompleted.bind(this);
+    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}`;
+  }
+
+  onSelectFile(e) {
+    if (e.target.files && e.target.files.length > 0) {
+      const reader = new FileReader();
+      reader.addEventListener('load', () => this.setState({ src: reader.result }));
+      reader.readAsDataURL(e.target.files[0]);
+      this.setState({ show: true });
+    }
+  }
+
+  /**
+   * @param {object} croppedImage cropped profile image for upload
+   */
+  async onCropCompleted(croppedImage) {
+    const { t, personalContainer } = this.props;
+    try {
+      await personalContainer.uploadAttachment(croppedImage);
+      toastSuccess(t('toaster.update_successed', { target: t('Current Image') }));
+    }
+    catch (err) {
+      toastError(err);
+    }
+    this.hideModal();
+  }
+
+  async onClickDeleteBtn() {
+    const { t, personalContainer } = this.props;
+    try {
+      await personalContainer.deleteProfileImage();
+      toastSuccess(t('toaster.update_successed', { target: t('Current Image') }));
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }
+
+  showModal() {
+    this.setState({ show: true });
+  }
+
+  hideModal() {
+    this.setState({ show: false });
+  }
+
+  cancelModal() {
+    this.hideModal();
+  }
+
+  render() {
+    const { t, personalContainer } = this.props;
+    const { uploadedPictureSrc, isGravatarEnabled, isUploadedPicture } = personalContainer.state;
+
+    return (
+      <React.Fragment>
+        <div className="row">
+          <div className="col-md-2 offset-1 col-sm-4">
+            <h4>
+              <div className="custom-control custom-radio radio-primary">
+                <input
+                  type="radio"
+                  id="radioGravatar"
+                  className="custom-control-input"
+                  form="formImageType"
+                  name="imagetypeForm[isGravatarEnabled]"
+                  checked={isGravatarEnabled}
+                  onChange={() => { personalContainer.changeIsGravatarEnabled(true) }}
+                />
+                <label className="custom-control-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="custom-control custom-radio radio-primary">
+                <input
+                  type="radio"
+                  id="radioUploadPicture"
+                  className="custom-control-input"
+                  form="formImageType"
+                  name="imagetypeForm[isGravatarEnabled]"
+                  checked={!isGravatarEnabled}
+                  onChange={() => { personalContainer.changeIsGravatarEnabled(false) }}
+                />
+                <label className="custom-control-label" htmlFor="radioUploadPicture">
+                  { t('Upload Image') }
+                </label>
+              </div>
+            </h4>
+            <div className="row mb-3">
+              <label className="col-sm-4 control-label">
+                { t('Current Image') }
+              </label>
+              <div className="col-sm-8">
+                {uploadedPictureSrc && (<p><img src={uploadedPictureSrc} className="picture picture-lg rounded-circle" id="settingUserPicture" /></p>)}
+                {isUploadedPicture && <button type="button" className="btn btn-danger" onClick={this.onClickDeleteBtn}>{ t('Delete Image') }</button>}
+              </div>
+            </div>
+            <div className="row">
+              <label className="col-sm-4 control-label">
+                {t('Upload new image')}
+              </label>
+              <div className="col-sm-8">
+                <input type="file" onChange={this.onSelectFile} name="profileImage" accept="image/*" />
+              </div>
+            </div>
+          </div>
+        </div>
+
+        <ImageCropModal
+          show={this.state.show}
+          src={this.state.src}
+          onModalClose={this.cancelModal}
+          onCropCompleted={this.onCropCompleted}
+        />
+
+        <div className="row my-3">
+          <div className="offset-4 col-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);

+ 6 - 6
src/client/js/components/Navbar/GrowiSubNavigation.jsx

@@ -18,7 +18,7 @@ const GrowiSubNavigation = (props) => {
   const isPageForbidden = document.querySelector('#grw-subnav').getAttribute('data-is-forbidden-page');
   const { appContainer, pageContainer } = props;
   const {
-    path, createdAt, creator, updatedAt, revisionAuthor, isCompactMode,
+    pageId, path, createdAt, creator, updatedAt, revisionAuthor, isCompactMode,
   } = pageContainer.state;
   const compactClassName = isCompactMode ? 'fixed-top grw-compact-subnavbar px-3' : null;
 
@@ -28,7 +28,7 @@ const GrowiSubNavigation = (props) => {
       <div className="d-flex align-items-center">
         <div className="title-container mr-auto">
           <h1>
-            <RevisionPath behaviorType={appContainer.config.behaviorType} pageId={pageContainer.state.pageId} pagePath={pageContainer.state.path} />
+            <RevisionPath behaviorType={appContainer.config.behaviorType} pageId={pageId} pagePath={pageContainer.state.path} />
           </h1>
         </div>
       </div>
@@ -41,17 +41,17 @@ const GrowiSubNavigation = (props) => {
       {/* Page Path */}
       <div className="title-container mr-auto">
         <h1>
-          <RevisionPath behaviorType={appContainer.config.behaviorType} pageId={pageContainer.state.pageId} pagePath={pageContainer.state.path} />
+          <RevisionPath behaviorType={appContainer.config.behaviorType} pageId={pageId} pagePath={pageContainer.state.path} />
         </h1>
         <TagLabels />
       </div>
 
       {/* Header Button */}
-      <div className="ml-1">
-        <LikeButton pageId={pageContainer.state.pageId} isLiked={pageContainer.state.isLiked} />
+      <div className="mr-2">
+        <LikeButton pageId={pageId} />
       </div>
       <div>
-        <BookmarkButton pageId={pageContainer.state.pageId} crowi={appContainer} />
+        <BookmarkButton pageId={pageId} crowi={appContainer} />
       </div>
 
       {/* Page Authors */}

+ 3 - 1
src/client/js/components/Navbar/PersonalDropdown.jsx

@@ -56,7 +56,9 @@ const PersonalDropdown = (props) => {
   return (
     <>
       {/* Button */}
-      <a className="nav-link dropdown-toggle waves-effect waves-light" data-toggle="dropdown">
+      {/* remove .dropdown-toggle for hide caret */}
+      {/* See https://stackoverflow.com/a/44577512/13183572 */}
+      <a className="nav-link waves-effect waves-light" data-toggle="dropdown">
         <UserPicture user={user} withoutLink />&nbsp;{user.name}
       </a>
 

+ 7 - 4
src/client/js/components/Page.jsx

@@ -29,6 +29,9 @@ class Page extends React.Component {
 
     this.growiRenderer = this.props.appContainer.getRenderer('page');
 
+    this.handsontableModal = React.createRef();
+    this.drawioModal = React.createRef();
+
     this.saveHandlerForHandsontableModal = this.saveHandlerForHandsontableModal.bind(this);
     this.saveHandlerForDrawioModal = this.saveHandlerForDrawioModal.bind(this);
   }
@@ -46,7 +49,7 @@ class Page extends React.Component {
     const markdown = this.props.pageContainer.state.markdown;
     const tableLines = markdown.split(/\r\n|\r|\n/).slice(beginLineNumber - 1, endLineNumber).join('\n');
     this.setState({ currentTargetTableArea: { beginLineNumber, endLineNumber } });
-    this.handsontableModal.show(MarkdownTable.fromMarkdownString(tableLines));
+    this.handsontableModal.current.show(MarkdownTable.fromMarkdownString(tableLines));
   }
 
   /**
@@ -59,7 +62,7 @@ class Page extends React.Component {
     const drawioMarkdownArray = markdown.split(/\r\n|\r|\n/).slice(beginLineNumber, endLineNumber);
     const drawioData = drawioMarkdownArray.slice(1, drawioMarkdownArray.length - 1).join('\n').trim();
     this.setState({ currentTargetDrawioArea: { beginLineNumber, endLineNumber } });
-    this.drawioModal.show(drawioData);
+    this.drawioModal.current.show(drawioData);
   }
 
   async saveHandlerForHandsontableModal(markdownTable) {
@@ -129,8 +132,8 @@ class Page extends React.Component {
     return (
       <div className={isMobile ? 'page-mobile' : ''}>
         <RevisionRenderer growiRenderer={this.growiRenderer} markdown={markdown} />
-        <HandsontableModal ref={(c) => { this.handsontableModal = c }} onSave={this.saveHandlerForHandsontableModal} />
-        <DrawioModal ref={(c) => { this.drawioModal = c }} onSave={this.saveHandlerForDrawioModal} />
+        <HandsontableModal ref={this.handsontableModal} onSave={this.saveHandlerForHandsontableModal} />
+        <DrawioModal ref={this.drawioModal} onSave={this.saveHandlerForDrawioModal} />
       </div>
     );
   }

+ 5 - 5
src/client/js/components/Page/CopyDropdown.jsx

@@ -71,7 +71,7 @@ export default class CopyDropdown extends React.Component {
 
             {/* Page path */}
             <CopyToClipboard text={this.props.pagePath} onCopy={this.showToolTip}>
-              <DropdownItem>
+              <DropdownItem tag="a">
                 <div className="d-inline-flex flex-column">
                   <h6 className="mt-1 mb-2"><strong>{ t('copy_to_clipboard.Page path') }</strong></h6>
                   <span className="small">{safePagePath}</span>
@@ -81,7 +81,7 @@ export default class CopyDropdown extends React.Component {
             {/* Parmanent Link */}
             { this.props.pageId && (
               <CopyToClipboard text={url} onCopy={this.showToolTip}>
-                <DropdownItem>
+                <DropdownItem tag="a">
                   <div className="d-inline-flex flex-column">
                     <h6 className="mt-1 mb-2"><strong>{ t('copy_to_clipboard.Parmanent link') }</strong></h6>
                     <span className="small">{url}</span>
@@ -92,10 +92,10 @@ export default class CopyDropdown extends React.Component {
             {/* Page path + Parmanent Link */}
             { this.props.pageId && (
               <CopyToClipboard text={`${this.props.pagePath}\n${url}`} onCopy={this.showToolTip}>
-                <DropdownItem>
+                <DropdownItem tag="a">
                   <div className="d-inline-flex flex-column">
                     <h6 className="mt-1 mb-2"><strong>{ t('copy_to_clipboard.Page path and parmanent link') }</strong></h6>
-                    <span className="small mb-1">{safePagePath}</span><br></br>
+                    <span className="small mb-3">{safePagePath}</span>
                     <span className="small">{url}</span>
                   </div>
                 </DropdownItem>
@@ -104,7 +104,7 @@ export default class CopyDropdown extends React.Component {
             {/* Markdown Link */}
             { this.props.pageId && (
               <CopyToClipboard text={`[${this.props.pagePath}](${url})`} onCopy={this.showToolTip}>
-                <DropdownItem>
+                <DropdownItem tag="a">
                   <div className="d-inline-flex flex-column">
                     <h6 className="mt-1 mb-2"><strong>{ t('copy_to_clipboard.Markdown link') }</strong></h6>
                     <span className="small">{`[${safePagePath}](${url})`}</span>

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

@@ -54,7 +54,7 @@ export default class TagEditor extends React.Component {
           <TagsInput tags={this.state.tags} onTagsUpdated={this.onTagsUpdatedByTagsInput} />
         </ModalBody>
         <ModalFooter>
-          <Button variant="primary" onClick={this.handleSubmit}>
+          <Button color="primary" onClick={this.handleSubmit}>
             Done
           </Button>
         </ModalFooter>

+ 2 - 2
src/client/js/components/PageAttachment/Attachment.jsx

@@ -31,10 +31,10 @@ export default class Attachment extends React.Component {
 
     let fileInUse = '';
     if (this.props.inUse) {
-      fileInUse = <span className="attachment-in-use label label-info">In Use</span>;
+      fileInUse = <span className="attachment-in-use badge badge-pill badge-info">In Use</span>;
     }
 
-    const fileType = <span className="attachment-filetype label label-default">{attachment.fileFormat}</span>;
+    const fileType = <span className="attachment-filetype badge badge-pill badge-secondary">{attachment.fileFormat}</span>;
 
     const btnDownload = (this.props.isUserLoggedIn)
       ? (

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

@@ -72,7 +72,7 @@ export default class DeleteAttachmentModal extends React.Component {
     const renderAttachment = this.renderByFileFormat(attachment);
 
     return (
-      <Modal {...props} className="attachment-delete-modal" bsSize="large" aria-labelledby="contained-modal-title-lg">
+      <Modal {...props} className="attachment-delete-modal" bssize="large" aria-labelledby="contained-modal-title-lg">
         <ModalHeader tag="h4" toggle={this.props.toggle}>
           <span id="contained-modal-title-lg">Delete attachment?</span>
         </ModalHeader>

+ 5 - 3
src/client/js/components/PageComment/Comment.jsx

@@ -87,7 +87,7 @@ class Comment extends React.PureComponent {
   }
 
   getRootClassName(comment) {
-    let className = 'page-comment';
+    let className = 'page-comment flex-column';
 
     const { revisionId, revisionCreatedAt } = this.props.pageContainer.state;
     if (comment.revision === revisionId) {
@@ -197,8 +197,10 @@ class Comment extends React.PureComponent {
           />
         ) : (
           <div id={commentId} className={rootClassName}>
-            <UserPicture user={creator} />
-            <div className="page-comment-main">
+            <div className="page-comment-writer">
+              <UserPicture user={creator} />
+            </div>
+            <div className="page-comment-main ml-0">
               <div className="page-comment-creator">
                 <Username user={creator} />
               </div>

+ 2 - 2
src/client/js/components/PageComment/CommentEditor.jsx

@@ -224,7 +224,7 @@ class CommentEditor extends React.Component {
 
     const errorMessage = <span className="text-danger text-right mr-2">{this.state.errorMessage}</span>;
     const cancelButton = (
-      <Button outline color="danger" size="xs" className="fcbtn rounded-pill" onClick={this.toggleEditor}>
+      <Button outline color="danger" size="xs" className="btn btn-outline-danger rounded-pill" onClick={this.toggleEditor}>
         Cancel
       </Button>
     );
@@ -232,7 +232,7 @@ class CommentEditor extends React.Component {
       <Button
         outline
         color="primary"
-        className="fcbtn rounded-pill btn-1b"
+        className="btn btn-outline-primary rounded-pill"
         onClick={this.postHandler}
       >
         Comment

+ 3 - 3
src/client/js/components/PageComment/DeleteCommentModal.jsx

@@ -45,12 +45,12 @@ export default class DeleteCommentModal extends React.Component {
         </ModalHeader>
         <ModalBody>
           <UserPicture user={comment.creator} size="xs" /> <strong><Username user={comment.creator}></Username></strong> wrote on {commentDate}:
-          <p className="well well-sm comment-body mt-2">{commentBody}</p>
+          <p className="card well comment-body mt-2 p-2">{commentBody}</p>
         </ModalBody>
         <ModalFooter>
           <span className="text-danger">{this.props.errorMessage}</span>&nbsp;
-          <Button onClick={this.props.cancel} bsClass="btn btn-sm">Cancel</Button>
-          <Button onClick={this.props.confirmedToDelete} bsClass="btn btn-sm btn-danger">
+          <Button onClick={this.props.cancel}>Cancel</Button>
+          <Button color="danger" onClick={this.props.confirmedToDelete}>
             <i className="icon icon-fire"></i>
             Delete
           </Button>

+ 1 - 1
src/client/js/components/PageComment/ReplayComments.jsx

@@ -28,7 +28,7 @@ class ReplayComments extends React.PureComponent {
 
   renderReply(reply) {
     return (
-      <div key={reply._id} className="page-comment-reply">
+      <div key={reply._id} className="page-comment-reply ml-4 ml-sm-5 mr-3">
         <Comment
           comment={reply}
           deleteBtnClicked={this.props.deleteBtnClicked}

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

@@ -165,7 +165,7 @@ class PageComments extends React.Component {
           </div>
         )}
         { showEditor && isLoggedIn && (
-          <div className="page-comment-reply-form">
+          <div className="page-comment-reply-form ml-4 ml-sm-5 mr-3">
             <CommentEditor
               growiRenderer={this.growiRenderer}
               replyTo={commentId}

+ 8 - 5
src/client/js/components/PageEditor/CodeMirrorEditor.jsx

@@ -71,6 +71,9 @@ export default class CodeMirrorEditor extends AbstractEditor {
       additionalClassSet: new Set(),
     };
 
+    this.handsontableModal = React.createRef();
+    this.drawioModal = React.createRef();
+
     this.init();
 
     this.getCodeMirror = this.getCodeMirror.bind(this);
@@ -647,11 +650,11 @@ export default class CodeMirrorEditor extends AbstractEditor {
   }
 
   showHandsonTableHandler() {
-    this.handsontableModal.show(mtu.getMarkdownTable(this.getCodeMirror()));
+    this.handsontableModal.current.show(mtu.getMarkdownTable(this.getCodeMirror()));
   }
 
   showDrawioHandler() {
-    this.drawioIFrame.show(mdu.getMarkdownDrawioMxfile(this.getCodeMirror()));
+    this.drawioModal.current.show(mdu.getMarkdownDrawioMxfile(this.getCodeMirror()));
   }
 
   getNavbarItems() {
@@ -769,7 +772,7 @@ export default class CodeMirrorEditor extends AbstractEditor {
       <Button
         key="nav-item-drawio"
         color={buttonColor}
-        bsSize="small"
+        bssize="small"
         title="draw.io"
         onClick={this.showDrawioHandler}
       >
@@ -850,11 +853,11 @@ export default class CodeMirrorEditor extends AbstractEditor {
         { this.renderCheatsheetOverlay() }
 
         <HandsontableModal
-          ref={(c) => { this.handsontableModal = c }}
+          ref={this.handsontableModal}
           onSave={(table) => { return mtu.replaceFocusedMarkdownTableWithEditor(this.getCodeMirror(), table) }}
         />
         <DrawioModal
-          ref={(c) => { this.drawioIFrame = c }}
+          ref={this.drawioModal}
           onSave={(drawioData) => { return mdu.replaceFocusedDrawioWithEditor(this.getCodeMirror(), drawioData) }}
         />
 

+ 28 - 11
src/client/js/components/PageEditor/DrawioModal.jsx

@@ -7,7 +7,11 @@ import {
   ModalBody,
 } from 'reactstrap';
 
-export default class DrawioModal extends React.PureComponent {
+import { createSubscribedElement } from '../UnstatedUtils';
+import AppContainer from '../../services/AppContainer';
+import EditorContainer from '../../services/EditorContainer';
+
+class DrawioModal extends React.PureComponent {
 
   constructor(props) {
     super(props);
@@ -17,15 +21,12 @@ export default class DrawioModal extends React.PureComponent {
       drawioMxFile: '',
     };
 
-    this.drawioIFrame = React.createRef();
-
     this.headerColor = '#334455';
     this.fontFamily = "Lato, -apple-system, BlinkMacSystemFont, 'Hiragino Kaku Gothic ProN', Meiryo, sans-serif";
 
     this.init = this.init.bind(this);
     this.cancel = this.cancel.bind(this);
     this.receiveFromDrawio = this.receiveFromDrawio.bind(this);
-    this.drawioURL = this.drawioURL.bind(this);
   }
 
   init(drawioMxFile) {
@@ -93,7 +94,10 @@ export default class DrawioModal extends React.PureComponent {
         const parser = new DOMParser();
         const dom = parser.parseFromString(event.data, 'text/xml');
         const value = dom.getElementsByTagName('diagram')[0].innerHTML;
-        this.props.onSave(value);
+
+        if (this.props.onSave != null) {
+          this.props.onSave(value);
+        }
       }
 
       window.removeEventListener('message', this.receiveFromDrawio);
@@ -112,8 +116,11 @@ export default class DrawioModal extends React.PureComponent {
     // NOTHING DONE. (Receive unknown iframe message.)
   }
 
-  drawioURL() {
-    const url = new URL('https://www.draw.io/');
+  get drawioURL() {
+    const { config } = this.props.appContainer;
+
+    const drawioUri = config.env.DRAWIO_URI || 'https://www.draw.io/';
+    const url = new URL(drawioUri);
 
     // refs: https://desk.draw.io/support/solutions/articles/16000042546-what-url-parameters-are-supported-
     url.searchParams.append('spin', 1);
@@ -127,8 +134,7 @@ export default class DrawioModal extends React.PureComponent {
 
   render() {
     return (
-      // <Modal show={this.state.show} onHide={this.cancel} bsSize="large" dialogClassName={dialogClassName} keyboard={false}>
-      <Modal isOpen={this.state.show} toggle={this.cancel} className="drawio-modal" bsSize="large" keyboard={false}>
+      <Modal isOpen={this.state.show} toggle={this.cancel} className="drawio-modal" bssize="large" keyboard={false}>
         <ModalBody className="p-0">
           {/* Loading spinner */}
           <div className="w-100 h-100 position-absolute d-flex">
@@ -140,8 +146,7 @@ export default class DrawioModal extends React.PureComponent {
           <div className="w-100 h-100 position-absolute d-flex">
             { this.state.show && (
               <iframe
-                ref={(c) => { this.drawioIFrame = c }}
-                src={this.drawioURL()}
+                src={this.drawioURL}
                 className="border-0 flex-grow-1"
               >
               </iframe>
@@ -154,6 +159,18 @@ export default class DrawioModal extends React.PureComponent {
 
 }
 
+/**
+ * Wrapper component for using unstated
+ */
+const DrawioModalWrapper = React.forwardRef((props, ref) => {
+  return createSubscribedElement(DrawioModal, Object.assign({ ref }, props), [AppContainer, EditorContainer]);
+});
+
 DrawioModal.propTypes = {
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  editorContainer: PropTypes.instanceOf(EditorContainer).isRequired,
+
   onSave: PropTypes.func,
 };
+
+export default DrawioModalWrapper;

+ 3 - 3
src/client/js/components/PageEditor/OptionsSelector.jsx

@@ -112,7 +112,7 @@ class OptionsSelector extends React.Component {
       <div className="my-0 form-group">
         <label>Theme:</label>
         <div className="btn-group btn-group-sm dropup">
-          <button className="btn btn-secondary dropdown-toggle" type="button" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
+          <button className="btn btn-light dropdown-toggle" type="button" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
             {selectedTheme}
           </button>
           <div className="dropdown-menu" aria-labelledby="dropdownMenuLink">
@@ -139,7 +139,7 @@ class OptionsSelector extends React.Component {
       <div className="my-0 form-group">
         <label>Keymap:</label>
         <div className="btn-group btn-group-sm dropup">
-          <button className="btn btn-secondary dropdown-toggle" type="button" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
+          <button className="btn btn-light dropdown-toggle" type="button" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
             {selectedKeymapMode}
           </button>
           <div className="dropdown-menu" aria-labelledby="dropdownMenuLink">
@@ -162,7 +162,7 @@ class OptionsSelector extends React.Component {
           toggle={this.onToggleConfigurationDropdown}
         >
 
-          <DropdownToggle caret>
+          <DropdownToggle color="light" caret>
             <i className="icon-settings"></i>
           </DropdownToggle>
 

+ 24 - 20
src/client/js/components/PageEditorByHackmd.jsx

@@ -2,6 +2,8 @@ import React from 'react';
 import PropTypes from 'prop-types';
 import loggerFactory from '@alias/logger';
 
+import { withTranslation } from 'react-i18next';
+
 import AppContainer from '../services/AppContainer';
 import PageContainer from '../services/PageContainer';
 import EditorContainer from '../services/EditorContainer';
@@ -42,8 +44,9 @@ class PageEditorByHackmd extends React.Component {
    * @return {Promise<string>}
    */
   getMarkdown() {
+    const { t } = this.props;
     if (!this.state.isInitialized) {
-      return Promise.reject(new Error('HackmdEditor component has not initialized'));
+      return Promise.reject(new Error(t('hackmd.not_initialized')));
     }
 
     return this.hackmdEditor.getValue();
@@ -212,20 +215,20 @@ class PageEditorByHackmd extends React.Component {
   }
 
   penpalErrorOccuredHandler(error) {
-    const { pageContainer } = this.props;
+    const { pageContainer, t } = this.props;
 
     pageContainer.showErrorToastr(error);
 
     this.setState({
       hasError: true,
-      errorMessage: 'GROWI client failed to connect to GROWI agent for HackMD.',
+      errorMessage: t('hackmd.fail_to_connect'),
       errorReason: error.toString(),
     });
   }
 
   renderPreInitContent() {
     const hackmdUri = this.getHackmdUri();
-    const { pageContainer } = this.props;
+    const { pageContainer, t } = this.props;
     const {
       revisionId, revisionIdHackmdSynced, remoteRevisionId,
     } = pageContainer.state;
@@ -239,7 +242,7 @@ class PageEditorByHackmd extends React.Component {
     if (hackmdUri == null) {
       content = (
         <div>
-          <p className="text-center hackmd-status-label"><i className="fa fa-file-text"></i> HackMD is not set up.</p>
+          <p className="text-center hackmd-status-label"><i className="fa fa-file-text"></i> { t('hackmd.not_set_up')}</p>
         </div>
       );
     }
@@ -252,14 +255,14 @@ class PageEditorByHackmd extends React.Component {
       content = (
         <div>
           <p className="text-center hackmd-status-label"><i className="fa fa-file-text"></i> HackMD is READY!</p>
-          <p className="text-center"><strong>HackMD has unsaved draft.</strong></p>
+          <p className="text-center"><strong>{t('hackmd.unsaved_draft')}</strong></p>
 
           { isHackmdDocumentOutdated && (
             <div className="card border-warning">
-              <div className="card-header bg-warning"><i className="icon-fw icon-info"></i> DRAFT MAY BE OUTDATED</div>
+              <div className="card-header bg-warning"><i className="icon-fw icon-info"></i> {t('hackmd.draft_outdated')}</div>
               <div className="card-body text-center">
-                The current draft on HackMD is based on&nbsp;
-                <a href={`?revision=${revisionIdHackmdSynced}`}><span className="label label-default">{revisionIdHackmdSynced.substr(-8)}</span></a>.
+                {t('hackmd.based_on_revision')}&nbsp;
+                <a href={`?revision=${revisionIdHackmdSynced}`}><span className="label label-default">{revisionIdHackmdSynced.substr(-8)}</span></a>
 
                 <div className="text-center mt-3">
                   <button
@@ -268,7 +271,7 @@ class PageEditorByHackmd extends React.Component {
                     disabled={this.state.isInitializing}
                     onClick={() => { return this.resumeToEdit() }}
                   >
-                    View the outdated draft on HackMD
+                    {t('hackmd.view_outdated_draft')}
                   </button>
                 </div>
               </div>
@@ -284,7 +287,7 @@ class PageEditorByHackmd extends React.Component {
                 onClick={() => { return this.resumeToEdit() }}
               >
                 <span className="btn-label"><i className="icon-control-end"></i></span>
-                <span className="btn-text">Resume to edit with HackMD</span>
+                <span className="btn-text">{t('hackmd.resume_to_edit')}</span>
               </button>
             </div>
           ) }
@@ -296,7 +299,7 @@ class PageEditorByHackmd extends React.Component {
               onClick={() => { return this.discardChanges() }}
             >
               <span className="btn-label"><i className="icon-control-start"></i></span>
-              <span className="btn-text">Discard changes of HackMD</span>
+              <span className="btn-text">{t('hackmd.discard_changes')}</span>
             </button>
           </div>
 
@@ -320,10 +323,10 @@ class PageEditorByHackmd extends React.Component {
               onClick={() => { return this.startToEdit() }}
             >
               <span className="btn-label"><i className="icon-paper-plane"></i></span>
-              Start to edit with HackMD
+              {t('hackmd.start_to_edit')}
             </button>
           </div>
-          <p className="text-center">Click to clone page content and start to edit.</p>
+          <p className="text-center">{t('hackmd.clone_page_content')}</p>
         </div>
       );
     }
@@ -337,7 +340,7 @@ class PageEditorByHackmd extends React.Component {
 
   render() {
     const hackmdUri = this.getHackmdUri();
-    const { pageContainer } = this.props;
+    const { pageContainer, t } = this.props;
     const {
       markdown, pageIdOnHackmd,
     } = pageContainer.state;
@@ -374,14 +377,13 @@ class PageEditorByHackmd extends React.Component {
         { this.state.hasError && (
           <div className="hackmd-error position-absolute d-flex flex-column justify-content-center align-items-center">
             <div className="white-box text-center">
-              <h2 className="text-warning"><i className="icon-fw icon-exclamation"></i> HackMD Integration failed</h2>
+              <h2 className="text-warning"><i className="icon-fw icon-exclamation"></i> {t('hackmd.integration_failed')}</h2>
               <h4>{this.state.errorMessage}</h4>
               <p className="well well-sm text-danger">
                 {this.state.errorReason}
               </p>
-              <p>
-                Check your configuration following <a href="https://docs.growi.org/guide/admin-cookbook/integrate-with-hackmd.html">the manual</a>.
-              </p>
+              {/* eslint-disable-next-line react/no-danger */}
+              <p dangerouslySetInnerHTML={{ __html: t('hackmd.check_configuration') }} />
             </div>
           </div>
         ) }
@@ -400,9 +402,11 @@ const PageEditorByHackmdWrapper = (props) => {
 };
 
 PageEditorByHackmd.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
   editorContainer: PropTypes.instanceOf(EditorContainer).isRequired,
 };
 
-export default PageEditorByHackmdWrapper;
+export default withTranslation()(PageEditorByHackmdWrapper);

+ 9 - 14
src/client/js/components/PageList/Page.jsx

@@ -14,25 +14,20 @@ export default class Page extends React.Component {
       link = page.path;
     }
 
-    const styleFlex = {
-      flex: 1,
-    };
-
     const hasChildren = this.props.children != null;
 
     return (
-      <li className="page-list-li d-flex align-items-center">
-        <UserPicture user={page.lastUpdateUser} />
-        <a className="page-list-link" href={link}>
+      <li className="nav-item page-list-li w-100">
+        <a className="nav-link page-list-link d-flex align-items-center" href={link}>
+          <UserPicture user={page.lastUpdateUser} />
           <PagePath page={page} excludePathString={this.props.excludePathString} />
+          <PageListMeta page={page} />
+          { hasChildren && (
+            <React.Fragment>
+              {this.props.children}
+            </React.Fragment>
+          )}
         </a>
-        <PageListMeta page={page} />
-        { hasChildren && (
-          <React.Fragment>
-            <a style={styleFlex} href={link}>&nbsp;</a>
-            {this.props.children}
-          </React.Fragment>
-        ) }
       </li>
     );
   }

+ 4 - 2
src/client/js/components/PageStatusAlert.jsx

@@ -39,10 +39,11 @@ class PageStatusAlert extends React.Component {
   }
 
   renderSomeoneEditingAlert() {
+    const { t } = this.props;
     return (
       <div className="alert-hackmd-someone-editing alert alert-success fixed-bottom p-3 mb-0">
         <i className="icon-fw icon-people"></i>
-        Someone editing this page on HackMD
+        {t('hackmd.someone_editing')}
         &nbsp;
         <i className="fa fa-angle-double-right"></i>
         &nbsp;
@@ -54,10 +55,11 @@ class PageStatusAlert extends React.Component {
   }
 
   renderDraftExistsAlert(isRealtime) {
+    const { t } = this.props;
     return (
       <div className="alert-hackmd-draft-exists alert alert-success fixed-bottom p-3 mb-0">
         <i className="icon-fw icon-pencil"></i>
-        This page has a draft on HackMD
+        {t('hackmd.this_page_has_draft')}
         &nbsp;
         <i className="fa fa-angle-double-right"></i>
         &nbsp;

+ 1 - 1
src/client/js/components/SavePageControls/GrantSelector.jsx

@@ -166,7 +166,7 @@ class GrantSelector extends React.Component {
     return (
       <div className="form-group grw-grant-selector mb-0">
         <UncontrolledDropdown direction="up" size="sm">
-          <DropdownToggle caret className="d-flex justify-content-between align-items-center" disabled={this.props.disabled}>
+          <DropdownToggle color="light" caret className="d-flex justify-content-between align-items-center" disabled={this.props.disabled}>
             {dropdownToggleLabelElm}
           </DropdownToggle>
           <DropdownMenu>

+ 1 - 1
src/client/js/components/SearchPage/DeletePageListModal.jsx

@@ -41,7 +41,7 @@ export default class DeletePageListModal extends React.Component {
           <div className="d-flex justify-content-between">
             <span className="text-danger">{this.props.errorMessage}</span>
             <span className="d-flex align-items-center">
-              <div className="custom-control custom-checkbox">
+              <div className="custom-control custom-checkbox custom-checkbox-danger">
                 <input type="checkbox" className="custom-control-input" id="customCheck-delete-completely" />
                 <label
                   className="custom-control-label text-danger"

+ 10 - 8
src/client/js/components/SearchPage/SearchPageForm.jsx

@@ -32,15 +32,17 @@ class SearchPageForm extends React.Component {
 
   render() {
     return (
-      <div className="input-group mb-3">
-        <SearchForm
-          t={this.props.t}
-          onSubmit={this.search}
-          keyword={this.state.searchedKeyword}
-          onInputChange={this.onInputChange}
-        />
+      <div className="input-group mb-3 d-flex">
+        <div className="flex-fill">
+          <SearchForm
+            t={this.props.t}
+            onSubmit={this.search}
+            keyword={this.state.searchedKeyword}
+            onInputChange={this.onInputChange}
+          />
+        </div>
         <div className="input-group-append">
-          <button className="btn btn-outline-secondary" type="button" id="button-addon2" onClick={this.search}>
+          <button className="btn btn-light" type="button" id="button-addon2" onClick={this.search}>
             <i className="icon-magnifier"></i>
           </button>
         </div>

+ 47 - 44
src/client/js/components/SearchPage/SearchResult.jsx

@@ -202,12 +202,12 @@ class SearchResult extends React.Component {
     if (this.state.deletionMode) {
       deletionModeButtons = (
         <div className="btn-group">
-          <button type="button" className="btn btn-rounded btn-default btn-xs" onClick={() => { return this.handleDeletionModeChange() }}>
+          <button type="button" className="btn btn-rounded btn-light btn-sm" onClick={() => { return this.handleDeletionModeChange() }}>
             <i className="icon-ban" /> Cancel
           </button>
           <button
             type="button"
-            className="btn btn-rounded btn-danger btn-xs"
+            className="btn btn-rounded btn-danger btn-sm"
             onClick={() => { return this.showDeleteConfirmModal() }}
             disabled={this.state.selectedPages.size === 0}
           >
@@ -216,22 +216,22 @@ class SearchResult extends React.Component {
         </div>
       );
       allSelectCheck = (
-        <div>
-          <label>
-            <input
-              type="checkbox"
-              onChange={() => { return this.handleAllSelect() }}
-              checked={this.isAllSelected()}
-            />
-            &nbsp;Check All
-          </label>
+        <div className="custom-control custom-checkbox custom-checkbox-danger">
+          <input
+            id="all-select-check"
+            className="custom-control-input"
+            type="checkbox"
+            onChange={() => { return this.handleAllSelect() }}
+            checked={this.isAllSelected()}
+          />
+          <label className="custom-control-label" htmlFor="all-select-check">&nbsp;Check All</label>
         </div>
       );
     }
     else {
       deletionModeButtons = (
         <div className="btn-group">
-          <button type="button" className="btn btn-default btn-rounded btn-xs" onClick={() => { return this.handleDeletionModeChange() }}>
+          <button type="button" className="btn btn-light rounded-pill btn-sm" onClick={() => { return this.handleDeletionModeChange() }}>
             <i className="ti-check-box" /> DeletionMode
           </button>
         </div>
@@ -239,26 +239,33 @@ class SearchResult extends React.Component {
     }
 
     const listView = this.props.pages.map((page) => {
-      const pageId = `#${page._id}`;
+      // Add prefix 'id_' in pageId, because scrollspy of bootstrap doesn't work when the first letter of id attr of target component is numeral.
+      const pageId = `#id_${page._id}`;
       return (
         <Page
           page={page}
           linkTo={pageId}
           key={page._id}
         >
-          { this.state.deletionMode
-            && (
-              <input
-                type="checkbox"
-                className="search-result-list-delete-checkbox"
-                value={pageId}
-                checked={this.state.selectedPages.has(page)}
-                onChange={() => { return this.toggleCheckbox(page) }}
-              />
-            )
+          <div className="ml-auto d-flex">
+            { this.state.deletionMode
+              && (
+                <div className="custom-control custom-checkbox custom-checkbox-danger">
+                  <input
+                    type="checkbox"
+                    id={`page-delete-check-${page._id}`}
+                    className="custom-control-input search-result-list-delete-checkbox"
+                    value={pageId}
+                    checked={this.state.selectedPages.has(page)}
+                    onChange={() => { return this.toggleCheckbox(page) }}
+                  />
+                  <label className="custom-control-label" htmlFor={`page-delete-check-${page._id}`}></label>
+                </div>
+              )
             }
-          <div className="page-list-option">
-            <a href={page.path}><i className="icon-login" /></a>
+            <div className="page-list-option">
+              <a href={page.path}><i className="icon-login" /></a>
+            </div>
           </div>
         </Page>
       );
@@ -271,28 +278,25 @@ class SearchResult extends React.Component {
     return (
       <div className="content-main">
         <div className="search-result row" id="search-result">
-          <div className="col-md-4 hidden-xs hidden-sm page-list search-result-list" id="search-result-list">
-            <nav data-spy="affix" data-offset-top="50">
-              <div className="float-right">
-                {deletionModeButtons}
-                {allSelectCheck}
+          <div className="col-lg-4 d-none d-lg-block page-list search-result-list" id="search-result-list">
+            <nav>
+              <div className="d-flex align-items-start justify-content-between mt-1">
+                <div className="search-result-meta">
+                  <i className="icon-magnifier" /> Found {this.props.searchResultMeta.total} pages with &quot;{this.props.searchingKeyword}&quot;
+                </div>
+                <div className="text-nowrap">
+                  {deletionModeButtons}
+                  {allSelectCheck}
+                </div>
               </div>
-              <div className="search-result-meta">
-                <i className="icon-magnifier" /> Found {this.props.searchResultMeta.total} pages with &quot;{this.props.searchingKeyword}&quot;
-              </div>
-              <div className="clearfix"></div>
+
               <div className="page-list">
-                <ul className="page-list-ul page-list-ul-flat nav">
-                  {listView}
-                </ul>
+                <ul className="page-list-ul page-list-ul-flat nav nav-pills">{listView}</ul>
               </div>
             </nav>
           </div>
-          <div className="col-md-8 search-result-content" id="search-result-content">
-            <SearchResultList
-              pages={this.props.pages}
-              searchingKeyword={this.props.searchingKeyword}
-            />
+          <div className="col-lg-8 search-result-content" id="search-result-content">
+            <SearchResultList pages={this.props.pages} searchingKeyword={this.props.searchingKeyword} />
           </div>
         </div>
         <DeletePageListModal
@@ -303,8 +307,7 @@ class SearchResult extends React.Component {
           confirmedToDelete={this.deleteSelectedPages}
           toggleDeleteCompletely={this.toggleDeleteCompletely}
         />
-
-      </div>// content-main
+      </div> // content-main
     );
   }
 

+ 2 - 1
src/client/js/components/SearchPage/SearchResultList.jsx

@@ -18,7 +18,8 @@ class SearchResultList extends React.Component {
       const showTags = (page.tags != null) && (page.tags.length > 0);
 
       return (
-        <div id={page._id} key={page._id} className="search-result-page mb-5">
+        // Add prefix 'id_' in id attr, because scrollspy of bootstrap doesn't work when the first letter of id of target component is numeral.
+        <div id={`id_${page._id}`} key={page._id} className="search-result-page mb-5">
           <h2>
             <a href={page.path}>{page.path}</a>
             { showTags && (

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

@@ -36,7 +36,7 @@ export default class SlackNotification extends React.Component {
   render() {
     return (
       <div className="input-group input-group-sm input-group-slack extended-setting">
-        <label className="input-group-addon">
+        <label className="input-group-addon bg-light">
           <img id="slack-mark-white" alt="slack-mark" src="/images/icons/slack/mark-monochrome_white.svg" width="18" height="18" />
           <img id="slack-mark-black" alt="slack-mark" src="/images/icons/slack/mark-monochrome_black.svg" width="18" height="18" />
 

+ 9 - 1
src/client/js/components/StaffCredit/Contributor.js

@@ -9,6 +9,7 @@ const contributors = [
           { position: 'Founder', name: 'yuki-takei' },
           { position: 'Soncho 1st', name: 'mizozobu' },
           { position: 'Soncho 2nd', name: 'yusuketk' },
+          { position: 'Paladin', name: 'itizawa' },
         ],
       },
       {
@@ -19,7 +20,6 @@ const contributors = [
           { name: 'TatsuyaIse' },
           { name: 'shinoka7' },
           { name: 'SeiyaTashiro' },
-          { name: 'itizawa' },
           { name: 'TsuyoshiSuzukief' },
           { name: 'Yuchan4342' },
           { name: 'ryu-sato' },
@@ -28,6 +28,14 @@ const contributors = [
           { name: 'kaishuu0123' },
           { name: 'kouki-o' },
           { name: 'Angola' },
+          { name: 'Yohei-Shiina' },
+          { name: 'shukmos' },
+          { name: 'sooouh' },
+          { name: 'ryouhek' },
+          { name: 'ryuichi-e' },
+          { name: 'N1koge' },
+          { name: 'Ertai87' },
+          { name: 'kaoritokashiki' },
         ],
       },
     ],

+ 3 - 5
src/client/js/components/User/UserPicture.jsx

@@ -8,21 +8,19 @@ const DEFAULT_IMAGE = '/images/icons/user.svg';
 export default class UserPicture extends React.Component {
 
   getUserPicture(user) {
-    let pictPath;
-
     // gravatar
     if (user.isGravatarEnabled === true) {
-      pictPath = this.generateGravatarSrc(user);
+      return this.generateGravatarSrc(user);
     }
     // uploaded image
     if (user.image != null) {
-      pictPath = user.image;
+      return user.image;
     }
     if (user.imageAttachment != null) {
       return user.imageAttachment.filePathProxied;
     }
 
-    return pictPath || DEFAULT_IMAGE;
+    return DEFAULT_IMAGE;
   }
 
   generateGravatarSrc(user) {

+ 1 - 0
src/client/js/services/AdminGeneralSecurityContainer.js

@@ -39,6 +39,7 @@ export default class AdminGeneralSecurityContainer extends Container {
     const response = await this.appContainer.apiv3.get('/security-setting/');
     const { generalSetting, generalAuth } = response.data.securityParams;
     this.setState({
+      currentRestrictGuestMode: generalSetting.restrictGuestMode,
       currentPageCompleteDeletionAuthority: generalSetting.pageCompleteDeletionAuthority,
       isShowRestrictedByOwner: !generalSetting.hideRestrictedByOwner,
       isShowRestrictedByGroup: !generalSetting.hideRestrictedByGroup,

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

@@ -50,6 +50,7 @@ export default class AdminHomeContainer extends Container {
         npmVersion: adminHomeParams.npmVersion,
         yarnVersion: adminHomeParams.yarnVersion,
         installedPlugins: adminHomeParams.installedPlugins,
+        envVars: adminHomeParams.envVars,
       });
     }
     catch (err) {

+ 89 - 2
src/client/js/services/AdminUsersContainer.js

@@ -1,6 +1,6 @@
 import { Container } from 'unstated';
-
 import loggerFactory from '@alias/logger';
+import { debounce } from 'throttle-debounce';
 
 // eslint-disable-next-line no-unused-vars
 const logger = loggerFactory('growi:services:AdminUserGroupDetailContainer');
@@ -18,17 +18,23 @@ export default class AdminUsersContainer extends Container {
 
     this.state = {
       users: [],
+      sort: 'id',
+      sortOrder: 'asc',
       isPasswordResetModalShown: false,
       isUserInviteModalShown: false,
       userForPasswordResetModal: null,
       totalUsers: 0,
       activePage: 1,
       pagingLimit: Infinity,
+      selectedStatusList: new Set(['all']),
+      searchText: '',
     };
 
     this.showPasswordResetModal = this.showPasswordResetModal.bind(this);
     this.hidePasswordResetModal = this.hidePasswordResetModal.bind(this);
     this.toggleUserInviteModal = this.toggleUserInviteModal.bind(this);
+
+    this.handleChangeSearchTextDebouce = debounce(3000, () => this.retrieveUsersByPagingNum(1));
   }
 
   /**
@@ -38,6 +44,81 @@ export default class AdminUsersContainer extends Container {
     return 'AdminUsersContainer';
   }
 
+  /**
+   * Workaround for status list
+   */
+  isSelected(statusType) {
+    return this.state.selectedStatusList.has(statusType);
+  }
+
+  handleClick(statusType) {
+    const all = 'all';
+    if (this.isSelected(statusType)) {
+      this.deleteStatusFromList(statusType);
+    }
+    else {
+      if (statusType === all) {
+        this.clearStatusList();
+      }
+      else {
+        this.deleteStatusFromList(all);
+      }
+      this.addStatusToList(statusType);
+    }
+  }
+
+  async clearStatusList() {
+    const { selectedStatusList } = this.state;
+    selectedStatusList.clear();
+    await this.setState({ selectedStatusList });
+  }
+
+  async addStatusToList(statusType) {
+    const { selectedStatusList } = this.state;
+    selectedStatusList.add(statusType);
+    await this.setState({ selectedStatusList });
+    this.retrieveUsersByPagingNum(1);
+  }
+
+  async deleteStatusFromList(statusType) {
+    const { selectedStatusList } = this.state;
+    selectedStatusList.delete(statusType);
+    await this.setState({ selectedStatusList });
+    this.retrieveUsersByPagingNum(1);
+  }
+
+  /**
+   * Workaround for Increment Search Text Input
+   */
+  async handleChangeSearchText(searchText) {
+    await this.setState({ searchText });
+    this.handleChangeSearchTextDebouce();
+  }
+
+  async clearSearchText() {
+    await this.setState({ searchText: '' });
+    this.retrieveUsersByPagingNum(1);
+  }
+
+  /**
+   * Workaround for Sorting
+   */
+  async sort(sort, isAsc) {
+    const sortOrder = isAsc ? 'asc' : 'desc';
+    await this.setState({ sort, sortOrder });
+    this.retrieveUsersByPagingNum(1);
+  }
+
+  async resetAllChanges() {
+    await this.setState({
+      sort: 'id',
+      sortOrder: 'asc',
+      searchText: '',
+      selectedStatusList: new Set(['all']),
+    });
+    this.retrieveUsersByPagingNum(1);
+  }
+
   /**
    * syncUsers of selectedPage
    * @memberOf AdminUsersContainer
@@ -45,7 +126,13 @@ export default class AdminUsersContainer extends Container {
    */
   async retrieveUsersByPagingNum(selectedPage) {
 
-    const params = { page: selectedPage };
+    const params = {
+      page: selectedPage,
+      sort: this.state.sort,
+      sortOrder: this.state.sortOrder,
+      selectedStatusList: Array.from(this.state.selectedStatusList),
+      searchText: this.state.searchText,
+    };
     const { data } = await this.appContainer.apiv3.get('/users', params);
 
     if (data.paginateResult == null) {

+ 1 - 5
src/client/js/services/PageContainer.js

@@ -41,7 +41,7 @@ export default class PageContainer extends Container {
       revisionAuthor: JSON.parse(mainContent.getAttribute('data-page-revision-author')),
       path: mainContent.getAttribute('data-path'),
       tocHtml: '',
-      isLiked: false,
+      isLiked: mainContent.getAttribute('data-page-is-liked'),
       seenUserIds: [],
       likerUserIds: [],
       createdAt: mainContent.getAttribute('data-page-created-at'),
@@ -97,10 +97,6 @@ export default class PageContainer extends Container {
   }
 
   initStateOthers() {
-    const likeButtonElem = document.getElementById('like-button');
-    if (likeButtonElem != null) {
-      this.state.isLiked = likeButtonElem.dataset.liked === 'true';
-    }
 
     const seenUserListElem = document.getElementById('seen-user-list');
     if (seenUserListElem != null) {

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

@@ -0,0 +1,248 @@
+import { Container } from 'unstated';
+
+import loggerFactory from '@alias/logger';
+
+// eslint-disable-next-line no-unused-vars
+const logger = loggerFactory('growi:services:PersonalContainer');
+
+const DEFAULT_IMAGE = '/images/icons/user.svg';
+
+/**
+ * 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,
+      isUploadedPicture: false,
+      uploadedPictureSrc: this.getUploadedPictureSrc(this.appContainer.currentUser),
+      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');
+    }
+  }
+
+  /**
+   * define a function for uploaded picture
+   */
+  getUploadedPictureSrc(user) {
+    if (user.image) {
+      this.setState({ isUploadedPicture: true });
+      return user.image;
+    }
+    if (user.imageAttachment != null) {
+      this.setState({ isUploadedPicture: true });
+      return user.imageAttachment.filePathProxied;
+    }
+
+    return DEFAULT_IMAGE;
+  }
+
+  /**
+   * 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');
+    }
+  }
+
+  /**
+   * Upload image
+   */
+  async uploadAttachment(file) {
+    try {
+      const formData = new FormData();
+      formData.append('file', file);
+      formData.append('_csrf', this.appContainer.csrfToken);
+      const response = await this.appContainer.apiPost('/attachments.uploadProfileImage', formData);
+      this.setState({ isUploadedPicture: true, uploadedPictureSrc: response.attachment.filePathProxied });
+    }
+    catch (err) {
+      this.setState({ retrieveError: err });
+      logger.error(err);
+      throw new Error('Failed to upload profile image');
+    }
+  }
+
+  /**
+   * Delete image
+   */
+  async deleteProfileImage() {
+    try {
+      await this.appContainer.apiPost('/attachments.removeProfileImage', { _csrf: this.appContainer.csrfToken });
+      this.setState({ isUploadedPicture: false, uploadedPictureSrc: DEFAULT_IMAGE });
+    }
+    catch (err) {
+      this.setState({ retrieveError: err });
+      logger.error(err);
+      throw new Error('Failed to delete profile image');
+    }
+  }
+
+  /**
+   * Associate LDAP account
+   */
+  async associateLdapAccount(account) {
+    try {
+      await this.appContainer.apiv3.put('/personal-setting/associate-ldap', account);
+    }
+    catch (err) {
+      this.setState({ retrieveError: err });
+      logger.error(err);
+      throw new Error('Failed to associate ldap account');
+    }
+  }
+
+  /**
+   * Disassociate LDAP account
+   */
+  async disassociateLdapAccount(account) {
+    try {
+      await this.appContainer.apiv3.put('/personal-setting/disassociate-ldap', account);
+    }
+    catch (err) {
+      this.setState({ retrieveError: err });
+      logger.error(err);
+      throw new Error('Failed to disassociate ldap account');
+    }
+  }
+
+}

+ 4 - 4
src/client/js/util/interceptor/drawio-interceptor.js

@@ -17,18 +17,18 @@ export class DrawioInterceptor extends BasicInterceptor {
     this.previousPreviewContext = null;
     this.appContainer = appContainer;
 
-    // draw.io の viewer.min.js から呼ばれるコールバックを定義する
+    // define callback function invoked by viewer.min.js of draw.io
     // refs: https://github.com/jgraph/drawio/blob/v12.9.1/etc/build/build.xml#L219-L232
     window.onDrawioViewerLoad = function() {
       const DrawioViewer = window.GraphViewer;
 
       if (DrawioViewer != null) {
-        // viewer.min.js の Resize による Scroll イベントを抑止するために
-        // useResizeSensor と checkVisibleState を無効化する
+        // disable useResizeSensor and checkVisibleState
+        //   for preventing resize event by viewer.min.js
         DrawioViewer.useResizeSensor = false;
         DrawioViewer.prototype.checkVisibleState = false;
 
-        // 初回レンダリング時に mxfile をレンダリングする
+        // initialize
         DrawioViewer.processElements();
       }
     };

+ 1 - 1
src/client/styles/agile-admin/inverse/sidebar-nav.scss

@@ -36,7 +36,7 @@
     > a {
         padding: 17px 30px 16px 15px!important;
     }
-    .img-circle {
+    .rounded-circle {
         width: 30px;
         margin-right: 10px;
     }

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

@@ -48,6 +48,12 @@
     }
   }
 
+  .page-comment-writer {
+    @include media-breakpoint-down(xs) {
+      height: 3.5em;
+    }
+  }
+
   .page-comment {
     position: relative;
 
@@ -66,6 +72,14 @@
     // コメントセクション
     .page-comment-main {
       @extend %comment-section;
+      @include media-breakpoint-up(sm){
+        margin-left: 4.5em !important;
+      }
+      @include media-breakpoint-down(xs) {
+        &:before {
+          content: none;
+        }
+      }
     }
 
     // コメント本文
@@ -85,11 +99,6 @@
   .page-comments-hidden-replies + .page-comment-reply {
     margin-top: 0;
   }
-  .page-comment-reply,
-  .page-comment-reply-form {
-    margin-right: 15px;
-    margin-left: 6em;
-  }
   // reply button
   .btn.btn-comment-reply {
     width: 120px;

+ 0 - 8
src/client/styles/scss/_create-template.scss

@@ -1,8 +0,0 @@
-.modal.create-template {
-  @include media-breakpoint-up(sm) {
-    // align .panel-body heights
-    .card-select-template .card-body {
-      min-height: 120px;
-    }
-  }
-}

+ 5 - 3
src/client/styles/scss/_layout.scss

@@ -90,9 +90,11 @@ header {
   }
 }
 
-.revision-toc {
-  overflow: hidden;
-  font-size: 0.9em;
+  .revision-toc {
+    // to get on the Attachment row
+    z-index: 1;
+    overflow: hidden;
+    font-size: 0.9em;
 
   .revision-toc-content {
     padding: 10px;

+ 90 - 15
src/client/styles/scss/_layout_kibela.scss

@@ -1,3 +1,5 @@
+@import '../scss/theme/layout_kibela_variable';
+
 body.kibela {
   .icon-link,
   .CodeMirror-hint-active,
@@ -11,6 +13,10 @@ body.kibela {
     background: #fefffe !important;
   }
 
+  .bg-primary {
+    background-color: $primary !important;
+  }
+
   .logo {
     background: transparent;
 
@@ -74,7 +80,7 @@ body.kibela {
   }
 
   .kibela-block {
-    position: absolute;
+    position: relative;
     top: 0px;
     right: 100px;
     bottom: 0px;
@@ -119,6 +125,24 @@ body.kibela {
     }
   }
 
+  /* admin navigation */
+  .admin-navigation {
+    .list-group-item + .list-group-item.active {
+      margin-top: 2px;
+    }
+
+    .list-group-item.active {
+      color: #fff;
+      background: #1256a3;
+    }
+
+    .list-group-item {
+      &:hover {
+        background: #eee;
+      }
+    }
+  }
+
   /* search page */
   .search-result-list,
   .page-list-li {
@@ -127,25 +151,28 @@ body.kibela {
 
   /* Tabs */
   .nav.nav-tabs {
-    border-bottom-color: #f4f5f6;
+    > .nav-item {
+      color: #5882fa;
+      cursor: pointer;
+      background: transparent;
 
-    > li > a {
-      &,
       &:hover,
       &:focus {
-        background: transparent;
-        border-top: none;
-        border-right: none;
-        border-left: none;
+        > .nav-link {
+          color: #7a94d9;
+        }
       }
-    }
 
-    > li.active > a {
-      background: transparent !important;
-      border-top: none;
-      border-right: none;
-      border-bottom: solid 2.7px #5584e1;
-      border-left: none;
+      > .nav-link {
+        color: #5882fa;
+        border: none;
+        border-radius: 3px;
+      }
+
+      > .nav-link.active {
+        background: transparent !important;
+        border-bottom: solid 2.7px #5584e1;
+      }
     }
 
     .wiki {
@@ -159,6 +186,54 @@ body.kibela {
     }
   }
 
+  /* Modal */
+  .modal-content {
+    background-color: $themelight;
+
+    .modal-header.bg-primary {
+      color: white;
+
+      .close {
+        color: white;
+      }
+    }
+  }
+
+  /* Inline Code */
+  :not(.hljs) > code:not(.hljs) {
+    background-color: $bgcolor-inline-code;
+    color: $color-inline-code;
+  }
+
+  /* Card */
+  .card {
+    border: 1px solid $border;
+
+    .card-header {
+      background-color: $lightthemecolor;
+      border-bottom: 1px solid $border;
+    }
+
+    .card-body {
+      background-color: $themelight;
+    }
+
+    .card-footer {
+      background: white;
+      border-top: 1px solid $border
+    }
+  }
+
+  /* button */
+  .btn {
+    border-radius: $radius;
+  }
+
+  .btn-primary {
+    background: $primary;
+    border: 1px solid $primary;
+  }
+
   /* edit */
   .CodeMirror {
     border: solid 1.2px #d8d8d8;

+ 104 - 54
src/client/styles/scss/_login.scss

@@ -5,6 +5,7 @@
   background: linear-gradient(45deg, darken($inverse, 30%) 0%, hsla(340, 100%, 55%, 0) 70%),
     linear-gradient(135deg, $growi-green 10%, hsla(225, 95%, 50%, 0) 70%), linear-gradient(225deg, $growi-blue 10%, hsla(140, 90%, 50%, 0) 80%),
     linear-gradient(315deg, darken($inverse, 25%) 100%, hsla(35, 95%, 55%, 0) 70%);
+
   #page-wrapper {
     background: none;
   }
@@ -47,25 +48,32 @@
             }
           }
         }
-      } // .main
-    } // #page-wrapper
-  } // #wrapper
+      }
+
+      // .main
+    }
+
+    // #page-wrapper
+  }
+
+  // #wrapper
 
   // styles
   .login-header {
     background-color: rgba(white, 0.5);
+
     .logo {
-      .group1,
-      .group2 {
-        fill: rgba(black, 0.5);
-      }
+      background-color: rgba(black, 0);
+      fill: rgba(black, 0.5);
     }
+
     h1 {
       font-size: 22px;
       line-height: 1em;
       color: rgba(black, 0.5);
     }
   }
+
   .login-dialog {
     background-color: rgba(white, 0.5);
   }
@@ -73,21 +81,24 @@
   .input-group {
     margin-bottom: 10px;
 
-    .input-group-addon {
+    .input-group-text {
       color: $gray-800-for-login;
       text-align: center;
       background-color: rgba(black, 0.4);
       border: none;
       border-radius: 0;
     }
+
     .form-control {
       color: white;
       background-color: rgba(lighten(black, 10%), 0.4);
+
       &::placeholder {
         color: $gray-800-for-login;
       }
     }
   }
+
   .input-group:not(.has-error) {
     .form-control {
       border: transparent;
@@ -97,10 +108,12 @@
   .external-auth {
     form {
       flex: 1;
+
       @media (min-width: 350px) {
         flex: 0.49;
       }
     }
+
     .spacer {
       height: 10px;
     }
@@ -108,6 +121,7 @@
 
   .collapse-external-auth {
     overflow: hidden;
+
     &:not(.in) {
       height: 0;
       padding: 0 !important;
@@ -115,92 +129,116 @@
   }
 
   // button style
-  .btn-login.fcbtn,
-  .btn-register.fcbtn,
-  .btn-login-oauth.fcbtn,
-  .btn-collapse-external-auth {
-    color: white;
-    background-color: rgba(lighten(black, 20%), 0.4);
-    border: none;
-
+  .btn-fill.login {
     .btn-label {
-      padding: 9px 15px;
-      margin: -8px 20px -8px -20px;
+      background-color: rgba($danger, 0.4);
     }
-    &:focus {
-      border: none;
+    .eff {
+      background-color: rgba(#7e4153, 0.5);
     }
   }
-  .btn-login-oauth {
-    flex: 1;
-    .btn-label-text {
-      flex: 1;
+
+  // google
+  .btn-fill#google {
+    .btn-label {
+      background-color: rgba(#24292e, 0.4);
+    }
+
+    .eff {
+      background-color: #555;
     }
   }
-  .btn-login.fcbtn {
+
+  // github
+  .btn-fill#github {
     .btn-label {
-      background-color: rgba($danger, 0.4);
+      background-color: rgba(lighten(black, 20%), 0.4);
     }
-    &:after {
-      background-color: #7e4153;
+
+    .eff {
+      background-color: #555;
     }
   }
-  .btn-login-oauth.fcbtn#google {
+
+  // facebook
+  .btn-fill#facebook {
     .btn-label {
-      background: rgba(#f13d25, 0.4);
+      background-color: rgba(#29487d, 0.4);
     }
-    &:after {
+
+    .eff {
       background-color: #555;
     }
   }
-  .btn-login-oauth.fcbtn#github {
+
+  // twitter
+  .btn-fill#twitter {
     .btn-label {
-      background-color: rgba(#24292e, 0.4);
+      background-color: rgba(#1da1f2, 0.4);
     }
-    &:after {
+
+    .eff {
       background-color: #555;
     }
   }
-  .btn-login-oauth.fcbtn#facebook {
+
+  // oidc
+  .btn-fill#oidc {
     .btn-label {
-      background-color: rgba(#29487d, 0.4);
+      background-color: rgba(#24292e, 0.4);
     }
-    &:after {
+
+    .eff {
       background-color: #555;
     }
   }
-  .btn-login-oauth.fcbtn#twitter {
+
+  // saml
+  .btn-fill#saml {
     .btn-label {
-      background-color: rgba(#1da1f2, 0.4);
+      background-color: rgba(#55a79a, 0.4);
     }
-    &:after {
+
+    .eff {
       background-color: #555;
     }
   }
-  .btn-login-oauth.fcbtn#saml {
+
+  // basic
+  .btn-fill#basic {
     .btn-label {
-      background-color: rgba(#55a79a, 0.4);
+      background-color: rgba(#24292e, 0.4);
     }
-    &:after {
+
+    .eff {
       background-color: #555;
     }
   }
-  .btn-register.fcbtn {
+  // register
+  .btn-fill#register {
     .btn-label {
       background-color: rgba($success, 0.4);
     }
-    .btn-label-text {
-      display: inline-block;
-      min-width: 45px;
-    }
-    &:after {
-      background-color: #3f7263;
+
+    .eff {
+      background-color: rgba(#3f7263, 0.5);
     }
   }
 
-  hr {
-    margin: 10px 0;
-    border-color: #ccc;
+  // external-auth
+  .btn-collapse-external-auth {
+    color: white;
+    background-color: rgba(lighten(black, 20%), 0.4);
+    border: none;
+
+    .btn-label {
+      padding: 9px 15px;
+      margin: -8px 20px -8px -20px;
+    }
+
+    &:focus {
+      border: none;
+    }
   }
 
   // footer link text
@@ -218,16 +256,20 @@
     &:hover,
     &.focus {
       color: black;
+
       .growi {
         color: darken($growi-green, 20%);
       }
+
       .org {
         color: darken($growi-blue, 15%);
       }
     }
   }
+
   .link-switch {
     color: $gray-200;
+
     &:hover {
       color: white;
     }
@@ -241,11 +283,13 @@
       .col-sm-offset-4 {
         margin-left: calc(50% - 160px);
       }
+
       .col-sm-4 {
         width: 320px;
       }
     }
   }
+
   .link-growi-org {
     position: absolute;
     bottom: 9px;
@@ -258,6 +302,7 @@
 
     &.to-flip {
       min-height: 295px;
+
       // has-error
       &.has-error {
         min-height: #{295px + 32px};
@@ -273,26 +318,30 @@
       -webkit-backface-visibility: hidden;
       -webkit-transform-style: preserve-3d;
     }
+
     .front {
       z-index: 2;
     }
+
     .back {
       position: absolute;
       top: 0;
       right: 15px;
       left: 15px;
     }
+
     .back,
     &.to-flip .front {
       transform: rotateY(180deg);
 
       // fix https://github.com/weseek/growi/issues/330
       // 'backface-visibility: hidden' and 'z-index: -1' breaks layout in iOS
-      .fcbtn:after {
+      ::after {
         z-index: 0;
         opacity: 0.3;
       }
     }
+
     &.to-flip .back {
       transform: rotateY(0deg);
     }
@@ -306,6 +355,7 @@
       .col-sm-offset-4 {
         margin-left: calc(50% - 240px);
       }
+
       .col-sm-4 {
         width: 480px;
       }

+ 0 - 7
src/client/styles/scss/_navbar.scss

@@ -12,13 +12,6 @@
     content: none;
   }
 
-  .search-top {
-    button {
-      background: transparent;
-      border: none;
-    }
-  }
-
   .nav-link {
     &:hover {
       background: rgba(0, 0, 0, 0.1);

+ 15 - 54
src/client/styles/scss/_navbar_kibela.scss

@@ -1,28 +1,25 @@
 /* navbar */
 
 .kibela {
-  .navbar-header {
-    display: inline-block;
-    min-width: 320px;
+  .grw-navbar {
     height: 60px;
-    padding: 5px 8px 5px 8px;
-    overflow: visible;
-    color: #3c4a60;
     background: white;
     border-bottom: solid 1px #e6e9ec;
-    .navbar-right {
-      a {
+    .navbar-nav {
+      .confidential {
+        color: white;
+        background: #0d3e75;
+      }
+      & > li > a {
+        height: 40px !important;
         margin-right: 1.5em;
+        color: #3c4a60;
+        border-radius: 0.35em;
+        &:hover {
+          color: #3c4a60;
+        }
       }
     }
-    .logo {
-      background: white;
-    }
-    .navbar-top-links > li > a {
-      height: 40px !important;
-      color: #3c4a60;
-      border-radius: 0.35em;
-    }
     .create-page {
       width: 8em;
       text-align: center;
@@ -36,48 +33,12 @@
         color: white;
       }
     }
-    .dropdown {
-      a {
-        .user-name {
-          margin-right: 0.5em;
-          margin-left: 0.5em;
-        }
-      }
-      .img-circle {
-        width: 36px;
-        height: 36px;
-      }
-    }
     @media screen and (max-width: 790px) {
-      .search-top,
-      .navbar-toggle {
+      .search-top {
         display: none !important;
       }
       @media screen and (max-width: 540px) {
-        .user-name {
-          display: none;
-        }
-        .navbar-right {
-          width: 80%;
-          li {
-            width: 25%;
-            text-align: center;
-            a {
-              margin-right: 0px;
-            }
-            .create-page {
-              width: 100%;
-            }
-            .dropdown {
-              text-align: center;
-              .dropdown-toggle {
-                width: 100%;
-                padding-left: 0em;
-                margin-right: 0px;
-              }
-            }
-          }
-        }
+        // TODO responsive after implementation of Sidebar
       }
     }
   }

+ 7 - 2
src/client/styles/scss/_on-edit.scss

@@ -25,6 +25,7 @@ body.on-edit {
   }
 
   // hide unnecessary elements
+  .grw-subnav,
   .row.row-alerts,
   .row.page-list,
   .row.page-comments-row,
@@ -33,12 +34,11 @@ body.on-edit {
   .users-info,
   .user-page-content-container,
   .portal-form-button,
-  .alert-info.alert-moved,
-  .alert-info.alert-unlinked,
   .btn-like,
   .btn-bookmark,
   .btn-edit,
   .authors,
+  .hide-on-edit,
   footer {
     display: none !important;
   }
@@ -71,6 +71,11 @@ body.on-edit {
     }
   }
 
+  // show compact subnav
+  .grw-compact-subnav {
+    display: block !important;
+  }
+
   /*****************
    * Expand Editor
    *****************/

+ 2 - 2
src/client/styles/scss/_override-bootstrap-variables.scss

@@ -6,9 +6,9 @@
 //
 $primary: #112744;
 $secondary: #6c757d;
-$info: #0d8ea5;
+$info: #009fbb;
 $success: #00bb83;
-$warning: #ee773b;
+$warning: #ffa32b;
 $danger: #ff0a54;
 $light: #dee2e6;
 $dark: #343a40;

+ 13 - 3
src/client/styles/scss/_override-bootstrap.scss

@@ -34,9 +34,14 @@ h5 {
   line-height: 18px;
 }
 
-h5 {
-  font-size: 14px;
-  line-height: 16px;
+h6 {
+  font-size: 12px;
+  line-height: 14px;
+}
+
+.small {
+  font-size: 65%;
+  line-height: 10px;
 }
 
 // Navs
@@ -166,3 +171,8 @@ fieldset[disabled] .btn {
   margin-bottom: 18px;
   overflow: hidden;
 }
+
+// badge
+.badge {
+  letter-spacing: 0.05em;
+}

+ 0 - 8
src/client/styles/scss/_page.scss

@@ -30,14 +30,6 @@
   }
 }
 
-// alert component settings
-.alert-trash,
-.alert-moved,
-.alert-unlinked,
-.alert-grant {
-  padding: 10px 15px;
-}
-
 .main .content-main .revision-history {
   .revision-history-list {
     .revision-history-outer {

+ 16 - 11
src/client/styles/scss/_page_list.scss

@@ -6,10 +6,8 @@
 
   .page-list-ul {
     padding-left: 0;
-    margin: 0;
 
     > li {
-      margin: 0;
       list-style: none;
 
       .picture {
@@ -18,20 +16,27 @@
       }
 
       > a {
-        display: inline;
-        padding: 0 4px;
+        padding: 0px;
         color: inherit;
-      }
 
-      > span.page-list-meta {
-        font-size: 0.9em;
+        &:hover {
+          color: inherit;
+        }
 
-        > span {
-          margin-right: 0.3rem;
+        span.page-path {
+          padding: 0 4px;
         }
 
-        i {
-          margin-right: 2px;
+        > span.page-list-meta {
+          font-size: 0.9em;
+
+          > span {
+            margin-right: 0.3rem;
+          }
+
+          i {
+            margin-right: 2px;
+          }
         }
       }
     }

+ 17 - 20
src/client/styles/scss/_search.scss

@@ -115,26 +115,23 @@
 
 .search-result {
   .search-result-list {
-    nav {
-      padding-right: 0;
-
-      &.affix {
-        top: 64px;
-        width: 33%;
-        height: 100%;
-        padding-right: 5px;
-        padding-bottom: 50px;
-        overflow-y: scroll;
-      }
-
-      .nav {
-        > li {
-          padding: 2px 8px;
-
-          &.active {
-            padding-right: 5px;
-            border-right: solid 3px transparent;
-          }
+    position: sticky;
+    top: 64px;
+    height: 100vh;
+    overflow-y: scroll;
+
+    .nav.nav-pills {
+      > li > a {
+        padding: 2px 8px;
+        border-radius: 0;
+
+        &:hover {
+          color: inherit;
+          text-decoration: none;
+        }
+        &.active {
+          padding: 2px 5px;
+          border-right: solid 3px transparent;
         }
       }
     }

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

@@ -31,6 +31,12 @@
 
     .user-page-username {
       font-weight: bold;
+
+      .user-page-email {
+      }
+
+      .user-page-introduction {
+      }
     }
 
     .user-page-email {

+ 54 - 13
src/client/styles/scss/atoms/_buttons.scss

@@ -26,25 +26,24 @@
   border-radius: 35px;
 }
 
-#like-button,
-#bookmark-button {
-  & button {
-    font-size: 1.2em;
-    line-height: 0.8em;
-
-    &:not(:hover):not(.active) {
-      background-color: transparent;
-    }
+.btn-like,
+.btn-bookmark {
+  font-size: 1.2em;
+  line-height: 0.8em;
+
+  &.active {
+    // header buttons are always white for active
+    color: white !important;
+  }
+
+  &:not(:hover):not(.active) {
+    background-color: transparent;
   }
 }
 
 .btn-copy,
 .btn-edit {
   opacity: 0.3;
-
-  &:hover {
-    background-color: $light;
-  }
 }
 
 .btn-edit-tags {
@@ -54,3 +53,45 @@
     opacity: 0.7;
   }
 }
+
+// fill button style
+.btn-fill {
+  position: relative;
+  overflow: hidden;
+  color: white;
+  text-align: center;
+  cursor: pointer;
+  background-color: rgba(lighten(black, 20%), 0.4);
+  border: none;
+
+  .btn-label {
+    position: relative;
+    z-index: 1;
+    color: white;
+    text-decoration: none;
+  }
+
+  .btn-label-text {
+    position: relative;
+    z-index: 1;
+    color: white;
+    text-decoration: none;
+  }
+
+  // effect
+  .eff {
+    position: absolute;
+    top: -50px;
+    left: 0px;
+    z-index: 0;
+    width: 100%;
+    height: 100%;
+    transition: all 0.5s ease;
+  }
+
+  &:hover {
+    .eff {
+      top: 0;
+    }
+  }
+}

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

@@ -30,7 +30,6 @@
 @import 'drawio';
 @import 'navbar_kibela';
 @import 'create-page';
-@import 'create-template';
 @import 'draft';
 @import 'editor-attachment';
 @import 'editor-navbar';

+ 16 - 10
src/client/styles/scss/theme/_apply-colors.scss

@@ -22,9 +22,14 @@ $link-hover-color: $color-link-hover;
 
 // Dropdown
 .dropdown-item {
+  color: $color-global;
   &.active,
   &:active {
-    @include gradient-bg($dropdown-link-active-bg);
+    color: $color-dropdown-link-active;
+    background-color: $bgcolor-dropdown-link-active;
+  }
+  &:hover:not(.active) {
+    color: $color-dropdown-link-hover;
   }
 }
 
@@ -275,17 +280,18 @@ body.on-edit {
   .search-result-list {
     .page-list {
       .page-list-ul {
-        > li > a {
-          background-color: transparent;
-        }
-
-        li:hover {
-          background-color: darken($bgcolor-global, 4%);
+        > li.nav-item > a.nav-link {
+          color: inherit;
         }
 
-        li.active {
-          background-color: darken($bgcolor-global, 8%);
-          border-color: theme-color('primary');
+        a {
+          &.hover {
+            background-color: darken($bgcolor-global, 4%);
+          }
+          &.active {
+            background-color: darken($bgcolor-global, 8%);
+            border-color: theme-color('primary');
+          }
         }
       }
     }

+ 39 - 0
src/client/styles/scss/theme/_layout_kibela_variable.scss

@@ -0,0 +1,39 @@
+$radius: .25em;
+
+$bgcolor-theme: rgb(18, 86, 163);
+$themelight: #f4f5f6;
+$subthemecolor: rgb(90, 149, 216);
+$lightthemecolor: rgba(181, 203, 247, 0.61);
+
+$bgcolor-navbar: $bgcolor-theme;
+$bgcolor-global: $themelight;
+$bgcolor-global: $themelight;
+
+$color-header: $bgcolor-theme;
+$color-global: #3c4a60;
+$linktext: rgb(74, 109, 204);
+$linktext-hover: lighten($linktext, 12%);
+$sidebar-text: $bgcolor-theme;
+
+$primary: $bgcolor-theme;
+$info: lighten($bgcolor-theme, 20%);
+
+$fillcolor-logo-mark: lighten($bgcolor-theme, 20%);
+$color-link-wiki: lighten($bgcolor-theme, 20%);
+$color-link-wiki-hover: lighten($color-link-wiki, 20%);
+$color-inline-code: $subthemecolor;
+$bgcolor-inline-code: lighten($subthemecolor, 70%);
+$border: $lightthemecolor;
+
+// change color of highlighted header in wiki (default: orange)
+.wiki {
+  .code-line.revision-head.highlighted {
+    color: $themelight;
+    background-color: lighten($bgcolor-theme, 20%);
+
+    .icon-note,
+    .icon-link {
+      color: $themelight;
+    }
+  }
+}

+ 9 - 5
src/client/styles/scss/theme/_reboot-bootstrap-colors.scss

@@ -190,13 +190,17 @@ body {
 //
 
 a {
-  color: $link-color;
-  // text-decoration: $link-decoration;
-  background-color: transparent; // Remove the gray background on active links in IE 10.
+  :not(.badge) {
+    color: $link-color;
+    // text-decoration: $link-decoration;
+    background-color: transparent; // Remove the gray background on active links in IE 10.
+  }
 
   @include hover() {
-    color: $link-hover-color;
-    // text-decoration: $link-hover-decoration;
+    &:not(.list-group-item) {
+      color: $link-hover-color;
+      // text-decoration: $link-hover-decoration;
+    }
   }
 }
 

+ 12 - 0
src/client/styles/scss/theme/default.scss

@@ -26,6 +26,7 @@ html:not([dark]) {
 
   // Font colors
   $color-global: #333333;
+  $color-reversal: #eeeeee;
   // $color-header: #2b2b2b;
   $color-link: lighten($primary, 20%);
   $color-link-hover: lighten($color-link, 20%);
@@ -39,6 +40,11 @@ html:not([dark]) {
   // Border colors
   $border-color-theme: #ccc; // former: `$navbar-border: #ccc;`
 
+  // Dropdown colors
+  $bgcolor-dropdown-link-active: $growi-blue;
+  $color-dropdown-link-active: $color-reversal;
+  $color-dropdown-link-hover: $color-global;
+
   @import 'apply-colors';
   @import 'apply-colors-light';
 }
@@ -58,6 +64,7 @@ html[dark] {
 
   // Font colors
   $color-global: #eeeeee;
+  $color-reversal: #333333;
   // $color-header: desaturate($primary, 20%);
   $color-link: $primary;
   $color-link-hover: lighten($color-link, 10%);
@@ -71,6 +78,11 @@ html[dark] {
   // Border colors
   $border-color-theme: black; // former: `$navbar-border: #ccc;`
 
+  // Dropdown colors
+  $bgcolor-dropdown-link-active: $primary;
+  $color-dropdown-link-active: $color-global;
+  $color-dropdown-link-hover: $color-reversal;
+
   @import 'apply-colors';
   @import 'apply-colors-dark';
 }

+ 1 - 0
src/client/styles/scss/theme/kibela.scss

@@ -1,5 +1,6 @@
 // import colors
 @import '../../agile-admin/inverse/colors/kibela';
+@import 'layout_kibela_valiable';
 
 // apply agile-admin theme
 @import '../../agile-admin/inverse/style';

+ 12 - 0
src/lib/util/isSecurityEnv.js

@@ -0,0 +1,12 @@
+/**
+ * return whether env belongs to Security settings
+ * @param {string} key ex. 'security:passport-saml:isEnabled' is true
+ * @returns {boolean}
+ * @memberof envUtils
+ */
+const isSecurityEnv = (key) => {
+  const array = key.split(':');
+  return (array[0] === 'security');
+};
+
+module.exports = isSecurityEnv;

+ 30 - 0
src/migrations/2020040216038-remove-deleteduser-from-relationgroup.js

@@ -0,0 +1,30 @@
+require('module-alias/register');
+const logger = require('@alias/logger')('growi:migrate:remove-deleteduser-from-relationgroup');
+
+const mongoose = require('mongoose');
+const config = require('@root/config/migrate');
+
+const { getModelSafely } = require('@commons/util/mongoose-utils');
+
+module.exports = {
+  async up(db) {
+    logger.info('Apply migration');
+    mongoose.connect(config.mongoUri, config.mongodb.options);
+
+    const User = getModelSafely('User') || require('@server/models/user')();
+    const UserGroupRelation = getModelSafely('UserGroupRelation') || require('@server/models/user-group-relation')();
+
+    const deletedUsers = await User.find({ status: 4 }); // deleted user
+    const requests = await UserGroupRelation.remove({ relatedUser: deletedUsers });
+
+    if (requests.size === 0) {
+      return logger.info('This migration terminates without any changes.');
+    }
+    logger.info('Migration has successfully applied');
+
+  },
+
+  down(db) {
+    // do not rollback
+  },
+};

+ 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: {
     userGroupCreate: require('./admin/userGroupCreate'),
   },

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

+ 6 - 4
src/server/models/bookmark.js

@@ -45,10 +45,12 @@ module.exports = function(crowi) {
     const Bookmark = this;
     const User = crowi.model('User');
 
-    return Bookmark.populate(bookmarks, [
-      { path: 'page' },
-      { path: 'lastUpdateUser', model: 'User', select: User.USER_PUBLIC_FIELDS },
-    ]);
+    return Bookmark.populate(bookmarks, {
+      path: 'page',
+      populate: {
+        path: 'lastUpdateUser', model: 'User', select: User.USER_PUBLIC_FIELDS, populate: User.IMAGE_POPULATION,
+      },
+    });
   };
 
   // bookmark チェック用

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

@@ -189,6 +189,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'),
@@ -214,6 +215,7 @@ module.exports = function(crowi) {
       env: {
         PLANTUML_URI: env.PLANTUML_URI || null,
         BLOCKDIAG_URI: env.BLOCKDIAG_URI || null,
+        DRAWIO_URI: env.DRAWIO_URI || null,
         HACKMD_URI: env.HACKMD_URI || null,
         MATHJAX: env.MATHJAX || null,
         NO_CDN: env.NO_CDN || null,

+ 6 - 1
src/server/models/user-group-relation.js

@@ -85,10 +85,15 @@ class UserGroupRelation {
    * @memberof UserGroupRelation
    */
   static findAllRelationForUserGroup(userGroup) {
+    const User = UserGroupRelation.crowi.model('User');
     debug('findAllRelationForUserGroup is called', userGroup);
     return this
       .find({ relatedGroup: userGroup })
-      .populate('relatedUser')
+      .populate({
+        path: 'relatedUser',
+        select: User.USER_PUBLIC_FIELDS,
+        populate: User.IMAGE_POPULATION,
+      })
       .exec();
   }
 

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

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

@@ -1,5 +1,6 @@
 const express = require('express');
 const PluginUtils = require('../../plugins/plugin-utils');
+const ConfigLoader = require('../../service/config-loader');
 
 const pluginUtils = new PluginUtils();
 
@@ -70,6 +71,7 @@ module.exports = (crowi) => {
       npmVersion: crowi.runtimeVersions.versions.npm ? crowi.runtimeVersions.versions.npm.version.version : '-',
       yarnVersion: crowi.runtimeVersions.versions.yarn ? crowi.runtimeVersions.versions.yarn.version.version : '-',
       installedPlugins: pluginUtils.listPlugins(crowi.rootDir),
+      envVars: await ConfigLoader.getEnvVarsForDisplay(true),
     };
 
     return res.apiv3({ adminHomeParams });

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

@@ -13,28 +13,26 @@ 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('/security-setting', require('./security-setting')(crowi));

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