Jelajahi Sumber

Merge branch 'support/apply-bootstrap4' into support/apply-nav-tabs

akira-s 6 tahun lalu
induk
melakukan
4a8f81f4d7
100 mengubah file dengan 2788 tambahan dan 937 penghapusan
  1. 9 2
      CHANGES.md
  2. 3 1
      package.json
  3. 11 6
      resource/locales/en-US/translation.json
  4. 11 6
      resource/locales/ja/translation.json
  5. 5 6
      src/client/js/app.jsx
  6. 144 0
      src/client/js/components/Admin/Security/LdapAuthTest.jsx
  7. 8 96
      src/client/js/components/Admin/Security/LdapAuthTestModal.jsx
  8. 1 1
      src/client/js/components/Admin/UserGroupDetail/UserGroupUserTable.jsx
  9. 22 17
      src/client/js/components/Admin/UserManagement.jsx
  10. 9 4
      src/client/js/components/Admin/Users/UserInviteModal.jsx
  11. 1 1
      src/client/js/components/Admin/Users/UserTable.jsx
  12. 1 1
      src/client/js/components/BookmarkButton.jsx
  13. 1 1
      src/client/js/components/HeaderSearchBox.jsx
  14. 20 11
      src/client/js/components/InstallerForm.jsx
  15. 22 26
      src/client/js/components/LikeButton.jsx
  16. 107 0
      src/client/js/components/Me/ApiSettings.jsx
  17. 146 0
      src/client/js/components/Me/AssociateModal.jsx
  18. 164 0
      src/client/js/components/Me/BasicInfoSettings.jsx
  19. 90 0
      src/client/js/components/Me/DisassociateModal.jsx
  20. 136 0
      src/client/js/components/Me/ExternalAccountLinkedMe.jsx
  21. 40 0
      src/client/js/components/Me/ExternalAccountRow.jsx
  22. 130 0
      src/client/js/components/Me/ImageCropModal.jsx
  23. 145 0
      src/client/js/components/Me/PasswordSettings.jsx
  24. 61 0
      src/client/js/components/Me/PersonalSettings.jsx
  25. 198 0
      src/client/js/components/Me/ProfileImageSettings.jsx
  26. 37 0
      src/client/js/components/Me/UserSettings.jsx
  27. 17 10
      src/client/js/components/Navbar/GrowiSubNavigation.jsx
  28. 12 6
      src/client/js/components/Navbar/PageCreator.jsx
  29. 3 1
      src/client/js/components/Navbar/PersonalDropdown.jsx
  30. 11 6
      src/client/js/components/Navbar/RevisionAuthor.jsx
  31. 1 1
      src/client/js/components/Page/RevisionPath.jsx
  32. 1 1
      src/client/js/components/Page/TagEditor.jsx
  33. 2 2
      src/client/js/components/PageAttachment/Attachment.jsx
  34. 1 1
      src/client/js/components/PageAttachment/DeleteAttachmentModal.jsx
  35. 2 2
      src/client/js/components/PageComment/CommentEditor.jsx
  36. 3 3
      src/client/js/components/PageComment/DeleteCommentModal.jsx
  37. 1 1
      src/client/js/components/PageEditor/CodeMirrorEditor.jsx
  38. 1 1
      src/client/js/components/PageEditor/DrawioModal.jsx
  39. 3 3
      src/client/js/components/PageEditor/OptionsSelector.jsx
  40. 9 14
      src/client/js/components/PageList/Page.jsx
  41. 1 1
      src/client/js/components/SavePageControls/GrantSelector.jsx
  42. 1 1
      src/client/js/components/SearchPage/DeletePageListModal.jsx
  43. 10 8
      src/client/js/components/SearchPage/SearchPageForm.jsx
  44. 47 44
      src/client/js/components/SearchPage/SearchResult.jsx
  45. 2 1
      src/client/js/components/SearchPage/SearchResultList.jsx
  46. 1 1
      src/client/js/components/SlackNotification.jsx
  47. 9 1
      src/client/js/components/StaffCredit/Contributor.js
  48. 3 5
      src/client/js/components/User/UserPicture.jsx
  49. 1 0
      src/client/js/services/AdminGeneralSecurityContainer.js
  50. 9 5
      src/client/js/services/PageContainer.js
  51. 248 0
      src/client/js/services/PersonalContainer.js
  52. 1 1
      src/client/styles/agile-admin/inverse/sidebar-nav.scss
  53. 55 1
      src/client/styles/scss/_layout_kibela.scss
  54. 18 62
      src/client/styles/scss/_login.scss
  55. 2 2
      src/client/styles/scss/_override-bootstrap-variables.scss
  56. 5 0
      src/client/styles/scss/_override-bootstrap.scss
  57. 15 0
      src/client/styles/scss/_page_growi.scss
  58. 16 11
      src/client/styles/scss/_page_list.scss
  59. 17 20
      src/client/styles/scss/_search.scss
  60. 54 13
      src/client/styles/scss/atoms/_buttons.scss
  61. 8 0
      src/client/styles/scss/theme/_apply-colors-dark.scss
  62. 8 0
      src/client/styles/scss/theme/_apply-colors-light.scss
  63. 16 11
      src/client/styles/scss/theme/_apply-colors.scss
  64. 39 0
      src/client/styles/scss/theme/_layout_kibela_variable.scss
  65. 9 5
      src/client/styles/scss/theme/_reboot-bootstrap-colors.scss
  66. 12 0
      src/client/styles/scss/theme/default.scss
  67. 1 0
      src/client/styles/scss/theme/kibela.scss
  68. 30 0
      src/migrations/2020040216038-remove-deleteduser-from-relationgroup.js
  69. 0 6
      src/server/form/index.js
  70. 0 7
      src/server/form/me/apiToken.js
  71. 0 7
      src/server/form/me/imagetype.js
  72. 0 9
      src/server/form/me/password.js
  73. 0 10
      src/server/form/me/user.js
  74. 6 4
      src/server/models/bookmark.js
  75. 1 0
      src/server/models/config.js
  76. 6 1
      src/server/models/user-group-relation.js
  77. 9 25
      src/server/models/user.js
  78. 6 8
      src/server/routes/apiv3/index.js
  79. 2 2
      src/server/routes/apiv3/notification-setting.js
  80. 416 0
      src/server/routes/apiv3/personal-setting.js
  81. 1 0
      src/server/routes/apiv3/user-group.js
  82. 3 0
      src/server/routes/apiv3/users.js
  83. 0 9
      src/server/routes/index.js
  84. 17 1
      src/server/routes/login-passport.js
  85. 1 258
      src/server/routes/me.js
  86. 1 1
      src/server/views/admin/Users_reserve.html
  87. 20 12
      src/server/views/invited.html
  88. 2 2
      src/server/views/layout-crowi/widget/page_side_header.html
  89. 3 3
      src/server/views/layout-kibela/base/layout.html
  90. 1 1
      src/server/views/layout-kibela/page_list.html
  91. 2 2
      src/server/views/layout-kibela/widget/header.html
  92. 9 9
      src/server/views/login.html
  93. 35 15
      src/server/views/me/index.html
  94. 0 98
      src/server/views/me/password.html
  95. 3 3
      src/server/views/modal/create_page.html
  96. 7 6
      src/server/views/modal/put_back.html
  97. 3 1
      src/server/views/modal/rename.html
  98. 1 1
      src/server/views/search.html
  99. 6 6
      src/server/views/widget/forbidden_content.html
  100. 0 6
      src/server/views/widget/header-button-bookmark.html

+ 9 - 2
CHANGES.md

@@ -5,9 +5,16 @@
 * Support: Upgrade libs
     * bootstrap
 
-## v3.7.2-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
 

+ 3 - 1
package.json

@@ -1,6 +1,6 @@
 {
   "name": "growi",
-  "version": "3.7.2-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",

+ 11 - 6
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",

+ 11 - 6
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設定",

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

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

+ 22 - 17
src/client/js/components/Admin/UserManagement.jsx

@@ -148,63 +148,68 @@ class UserManagement extends React.Component {
             </div>
 
             <div className="mx-5 form-inline">
-              <div className="checkbox checkbox-primary pl-0">
+              <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 htmlFor="c1">
-                  <span className="label label-primary d-inline-block vt mt-1">All</span>
+                <label className="custom-control-label" htmlFor="c1">
+                  <span className="badge badge-primary d-inline-block vt mt-1">All</span>
                 </label>
               </div>
 
-              <div className="checkbox checkbox-info">
+              <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 htmlFor="c2">
-                  <span className="label label-info d-inline-block vt mt-1">Approval Pending</span>
+                <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="checkbox checkbox-success">
+              <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 htmlFor="c3">
-                  <span className="label label-success d-inline-block vt mt-1">Active</span>
+                <label className="custom-control-label" htmlFor="c3">
+                  <span className="badge badge-success d-inline-block vt mt-1">Active</span>
                 </label>
               </div>
 
-              <div className="checkbox checkbox-warning">
+              <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 htmlFor="c4">
-                  <span className="label label-warning d-inline-block vt mt-1">Suspended</span>
+                <label className="custom-control-label" htmlFor="c4">
+                  <span className="badge badge-warning d-inline-block vt mt-1">Suspended</span>
                 </label>
               </div>
 
-              <div className="checkbox checkbox-info">
+              <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 htmlFor="c5">
-                  <span className="label label-info d-inline-block vt mt-1">Invited</span>
+                <label className="custom-control-label" htmlFor="c5">
+                  <span className="badge badge-info d-inline-block vt mt-1">Invited</span>
                 </label>
               </div>
             </div>
@@ -212,7 +217,7 @@ class UserManagement extends React.Component {
             <div>
               <button
                 type="button"
-                className="btn btn-default btn-outline btn-sm"
+                className="btn btn-outline-secondary btn-sm"
                 onClick={() => { this.resetButtonClickHandler() }}
               >
                 <span
@@ -224,7 +229,7 @@ class UserManagement extends React.Component {
             </div>
 
             <div className="ml-5">
-              {this.state.isNotifyCommentShow && <span className="text-warning">{t('admin:user_management.click_twice_same_checkbox')}</span>}
+              {this.state.isNotifyCommentShow && <span className="text-warning small">{t('admin:user_management.click_twice_same_checkbox')}</span>}
             </div>
 
           </div>

+ 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

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

@@ -185,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>

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

+ 17 - 10
src/client/js/components/Navbar/GrowiSubNavigation.jsx

@@ -18,8 +18,9 @@ const GrowiSubNavigation = (props) => {
   const isPageForbidden = document.querySelector('#grw-subnav').getAttribute('data-is-forbidden-page');
   const { appContainer, pageContainer } = props;
   const {
-    path, createdAt, creator, updatedAt, revisionAuthor,
+    pageId, path, createdAt, creator, updatedAt, revisionAuthor, isCompactMode,
   } = pageContainer.state;
+  const compactClassName = isCompactMode ? 'fixed-top grw-compact-subnavbar px-3' : null;
 
   // Display only the RevisionPath if the page is trash or forbidden
   if (isTrashPage(path) || isPageForbidden) {
@@ -27,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>
@@ -35,28 +36,34 @@ const GrowiSubNavigation = (props) => {
   }
 
   return (
-    <div className="d-flex align-items-center">
+    <div className={`d-flex align-items-center ${compactClassName}`}>
 
       {/* 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 */}
-      <ul className="authors hidden-sm hidden-xs text-nowrap">
-        {creator != null && <li><PageCreator creator={creator} createdAt={createdAt} /></li>}
-        {revisionAuthor != null && <li className="mt-1"><RevisionAuthor revisionAuthor={revisionAuthor} updatedAt={updatedAt} /></li>}
+      <ul className="authors text-nowrap d-none d-lg-block">
+        {creator != null && <li><PageCreator creator={creator} createdAt={createdAt} isCompactMode={isCompactMode} /></li>}
+        { revisionAuthor != null
+          && (
+            <li className="mt-1">
+              <RevisionAuthor revisionAuthor={revisionAuthor} updatedAt={updatedAt} isCompactMode={isCompactMode} />
+            </li>
+          )
+        }
       </ul>
 
     </div>

+ 12 - 6
src/client/js/components/Navbar/PageCreator.jsx

@@ -5,17 +5,18 @@ import UserPicture from '../User/UserPicture';
 import { userPageRoot } from '../../../../lib/util/path-utils';
 
 const PageCreator = (props) => {
-  const { creator, createdAt } = props;
+  const { creator, createdAt, isCompactMode } = props;
+  const creatInfo = isCompactMode
+    ? (<div>Created in <span className="text-muted">{createdAt}</span></div>)
+    : (<div><div>Created by <a href={userPageRoot(creator)}>{creator.name}</a></div><div className="text-muted">{createdAt}</div></div>);
+  const pictureSize = isCompactMode ? 'xs' : 'sm';
 
   return (
     <div className="d-flex align-items-center">
       <div className="mr-2" href={userPageRoot(creator)} data-toggle="tooltip" data-placement="bottom" title={creator.name}>
-        <UserPicture user={creator} size="sm" />
-      </div>
-      <div>
-        <div>Created by <a href={userPageRoot(creator)}>{creator.name}</a></div>
-        <div className="text-muted">{createdAt}</div>
+        <UserPicture user={creator} size={pictureSize} />
       </div>
+      {creatInfo}
     </div>
   );
 };
@@ -24,6 +25,11 @@ PageCreator.propTypes = {
 
   creator: PropTypes.object.isRequired,
   createdAt: PropTypes.string.isRequired,
+  isCompactMode: PropTypes.bool,
+};
+
+PageCreator.defaultProps = {
+  isCompactMode: false,
 };
 
 

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

+ 11 - 6
src/client/js/components/Navbar/RevisionAuthor.jsx

@@ -5,17 +5,18 @@ import UserPicture from '../User/UserPicture';
 import { userPageRoot } from '../../../../lib/util/path-utils';
 
 const RevisionAuthor = (props) => {
-  const { revisionAuthor, updatedAt } = props;
+  const { revisionAuthor, updatedAt, isCompactMode } = props;
+  const updateInfo = isCompactMode
+    ? (<div>Updated in <span className="text-muted">{updatedAt}</span></div>)
+    : (<div><div>Updated in  <a href={userPageRoot(revisionAuthor)}>{revisionAuthor.name}</a></div><div className="text-muted">{updatedAt}</div></div>);
+  const pictureSize = isCompactMode ? 'xs' : 'sm';
 
   return (
     <div className="d-flex align-items-center">
       <div className="mr-2" href={userPageRoot(revisionAuthor)} data-toggle="tooltip" data-placement="bottom" title={revisionAuthor.name}>
-        <UserPicture user={revisionAuthor} size="sm" />
-      </div>
-      <div>
-        <div>Updated by  <a href={userPageRoot(revisionAuthor)}>{revisionAuthor.name}</a></div>
-        <div className="text-muted">{updatedAt}</div>
+        <UserPicture user={revisionAuthor} size={pictureSize} />
       </div>
+      {updateInfo}
     </div>
   );
 };
@@ -24,7 +25,11 @@ RevisionAuthor.propTypes = {
 
   revisionAuthor: PropTypes.object.isRequired,
   updatedAt: PropTypes.string.isRequired,
+  isCompactMode: PropTypes.bool,
 };
 
+RevisionAuthor.defaultProps = {
+  isCompactMode: false,
+};
 
 export default RevisionAuthor;

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

@@ -152,7 +152,7 @@ class RevisionPath extends React.Component {
     });
 
     return (
-      <span className="d-flex align-items-center">
+      <span className="d-flex align-items-center flex-wrap">
 
         {rootElement}
         {afterElements}

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

+ 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/PageEditor/CodeMirrorEditor.jsx

@@ -772,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}
       >

+ 1 - 1
src/client/js/components/PageEditor/DrawioModal.jsx

@@ -134,7 +134,7 @@ class DrawioModal extends React.PureComponent {
 
   render() {
     return (
-      <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">

+ 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-white 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-white 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 color="white" caret>
+          <DropdownToggle color="light" caret>
             <i className="icon-settings"></i>
           </DropdownToggle>
 

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

+ 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 color="white" 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,

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

@@ -5,7 +5,10 @@ import loggerFactory from '@alias/logger';
 import * as entities from 'entities';
 import * as toastr from 'toastr';
 
+import { throttle } from 'throttle-debounce';
+
 const logger = loggerFactory('growi:services:PageContainer');
+const scrollAmountForFixed = 122;
 
 /**
  * Service container related to Page
@@ -38,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'),
@@ -55,6 +58,7 @@ export default class PageContainer extends Container {
       pageIdOnHackmd: mainContent.getAttribute('data-page-id-on-hackmd') || null,
       hasDraftOnHackmd: !!mainContent.getAttribute('data-page-has-draft-on-hackmd'),
       isHackmdDraftUpdatingInRealtime: false,
+      isCompactMode: false,
     };
 
     this.initStateMarkdown();
@@ -64,6 +68,10 @@ export default class PageContainer extends Container {
     this.save = this.save.bind(this);
     this.addWebSocketEventHandlers = this.addWebSocketEventHandlers.bind(this);
     this.addWebSocketEventHandlers();
+
+    window.addEventListener('scroll', throttle(300, () => {
+      this.setState({ isCompactMode: window.pageYOffset > scrollAmountForFixed });
+    }));
   }
 
   /**
@@ -89,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');
+    }
+  }
+
+}

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

+ 55 - 1
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;
@@ -180,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;

+ 18 - 62
src/client/styles/scss/_login.scss

@@ -129,50 +129,7 @@
   }
 
   // button style
-
-  .fcbtn {
-    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;
-      }
-    }
-  }
-
-  // login
-  .fcbtn.login {
+  .btn-fill.login {
     .btn-label {
       background-color: rgba($danger, 0.4);
     }
@@ -182,7 +139,7 @@
   }
 
   // google
-  .fcbtn#google {
+  .btn-fill#google {
     .btn-label {
       background-color: rgba(#24292e, 0.4);
     }
@@ -193,7 +150,7 @@
   }
 
   // github
-  .fcbtn#github {
+  .btn-fill#github {
     .btn-label {
       background-color: rgba(lighten(black, 20%), 0.4);
     }
@@ -204,7 +161,7 @@
   }
 
   // facebook
-  .fcbtn#facebook {
+  .btn-fill#facebook {
     .btn-label {
       background-color: rgba(#29487d, 0.4);
     }
@@ -215,7 +172,7 @@
   }
 
   // twitter
-  .fcbtn#twitter {
+  .btn-fill#twitter {
     .btn-label {
       background-color: rgba(#1da1f2, 0.4);
     }
@@ -226,7 +183,7 @@
   }
 
   // oidc
-  .fcbtn#oidc {
+  .btn-fill#oidc {
     .btn-label {
       background-color: rgba(#24292e, 0.4);
     }
@@ -237,7 +194,7 @@
   }
 
   // saml
-  .fcbtn#saml {
+  .btn-fill#saml {
     .btn-label {
       background-color: rgba(#55a79a, 0.4);
     }
@@ -248,7 +205,7 @@
   }
 
   // basic
-  .fcbtn#basic {
+  .btn-fill#basic {
     .btn-label {
       background-color: rgba(#24292e, 0.4);
     }
@@ -257,6 +214,16 @@
       background-color: #555;
     }
   }
+  // register
+  .btn-fill#register {
+    .btn-label {
+      background-color: rgba($success, 0.4);
+    }
+
+    .eff {
+      background-color: rgba(#3f7263, 0.5);
+    }
+  }
 
   // external-auth
   .btn-collapse-external-auth {
@@ -274,17 +241,6 @@
     }
   }
 
-  // register
-  .fcbtn#register {
-    .btn-label {
-      background-color: rgba($success, 0.4);
-    }
-
-    .eff {
-      background-color: rgba(#3f7263, 0.5);
-    }
-  }
-
   // footer link text
   .link-growi-org {
     font-size: smaller;

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

+ 5 - 0
src/client/styles/scss/_override-bootstrap.scss

@@ -171,3 +171,8 @@ fieldset[disabled] .btn {
   margin-bottom: 18px;
   overflow: hidden;
 }
+
+// badge
+.badge {
+  letter-spacing: 0.05em;
+}

+ 15 - 0
src/client/styles/scss/_page_growi.scss

@@ -20,5 +20,20 @@
         }
       }
     }
+    .grw-compact-subnavbar {
+      h2 {
+        font-size: 20px;
+        line-height: 1.1em;
+        @include media-breakpoint-down(md) {
+          font-size: 18px;
+        }
+        @include media-breakpoint-down(sm) {
+          font-size: 14px;
+        }
+        @include media-breakpoint-down(xs) {
+          font-size: 12px;
+        }
+      }
+    }
   }
 }

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

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

+ 8 - 0
src/client/styles/scss/theme/_apply-colors-dark.scss

@@ -136,6 +136,14 @@ header.affix {
   }
 }
 
+/*
+ * GROWI subnavigation
+ */
+.grw-compact-subnavbar {
+  background-color: rgba(darken($bgcolor-global, 90%), 0.9);
+  box-shadow: 0 0 2px darken($bgcolor-global, 5%);
+}
+
 /*
  * GROWI search page
  */

+ 8 - 0
src/client/styles/scss/theme/_apply-colors-light.scss

@@ -32,6 +32,14 @@ header.affix {
   }
 }
 
+/*
+ * GROWI subnavigation
+ */
+.grw-compact-subnavbar {
+  background-color: rgba(darken($bgcolor-global, 6%), 0.9);
+  box-shadow: 0 0 2px darken($bgcolor-global, 40%);
+}
+
 /*
  * GROWI page list
  */

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

@@ -25,7 +25,11 @@ $link-hover-color: $color-link-hover;
   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;
   }
 }
 
@@ -276,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');
+          }
         }
       }
     }
@@ -312,4 +317,4 @@ body.on-edit {
       }
     }
   }
-}
+}

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

+ 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 チェック用

+ 1 - 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'),

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

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

+ 2 - 2
src/server/routes/apiv3/notification-setting.js

@@ -14,9 +14,9 @@ const removeNullPropertyFromObject = require('../../../lib/util/removeNullProper
 
 const validator = {
   slackConfiguration: [
-    body('webhookUrl').isString().trim(),
+    body('webhookUrl').if(value => value != null).isString().trim(),
     body('isIncomingWebhookPrioritized').isBoolean(),
-    body('slackToken').isString().trim(),
+    body('slackToken').if(value => value != null).isString().trim(),
   ],
   userNotification: [
     body('pathPattern').isString().trim(),

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

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

+ 1 - 0
src/server/routes/apiv3/user-group.js

@@ -587,6 +587,7 @@ module.exports = (crowi) => {
         populate: {
           path: 'lastUpdateUser',
           select: User.USER_PUBLIC_FIELDS,
+          populate: User.IMAGE_POPULATION,
         },
       });
 

+ 3 - 0
src/server/routes/apiv3/users.js

@@ -73,6 +73,7 @@ module.exports = (crowi) => {
     User,
     Page,
     ExternalAccount,
+    UserGroupRelation,
   } = crowi.models;
 
   const { ApiV3FormValidator } = crowi.middlewares;
@@ -181,6 +182,7 @@ module.exports = (crowi) => {
         },
         {
           sort: sortOutput,
+          populate: User.IMAGE_POPULATION,
           page,
           limit: PAGE_ITEMS,
         },
@@ -455,6 +457,7 @@ module.exports = (crowi) => {
 
     try {
       const userData = await User.findById(id);
+      await UserGroupRelation.remove({ relatedUser: userData });
       await userData.statusDelete();
       await ExternalAccount.remove({ user: userData });
       await Page.removeByPath(`/user/${userData.username}`);

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

@@ -119,17 +119,8 @@ module.exports = function(crowi, app) {
   app.get('/admin/export/:fileName'             , loginRequiredStrictly , adminRequired ,admin.export.download);
 
   app.get('/me'                       , loginRequiredStrictly , me.index);
-  app.get('/me/password'              , loginRequiredStrictly , me.password);
-  app.get('/me/apiToken'              , loginRequiredStrictly , me.apiToken);
-  app.post('/me'                      , loginRequiredStrictly , csrf , form.me.user , me.index);
   // external-accounts
   app.get('/me/external-accounts'                         , loginRequiredStrictly , me.externalAccounts.list);
-  app.post('/me/external-accounts/disassociate'           , loginRequiredStrictly , me.externalAccounts.disassociate);
-  app.post('/me/external-accounts/associateLdap'          , loginRequiredStrictly , form.login , me.externalAccounts.associateLdap);
-
-  app.post('/me/password'             , form.me.password          , loginRequiredStrictly , me.password);
-  app.post('/me/imagetype'            , form.me.imagetype         , loginRequiredStrictly , me.imagetype);
-  app.post('/me/apiToken'             , form.me.apiToken          , loginRequiredStrictly , me.apiToken);
 
   app.get('/:id([0-9a-z]{24})'       , loginRequired , page.redirector);
   app.get('/_r/:id([0-9a-z]{24})'    , loginRequired , page.redirector); // alias

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

@@ -236,6 +236,8 @@ module.exports = function(crowi, app) {
   };
 
   const loginPassportGoogleCallback = async(req, res, next) => {
+    const globalLang = crowi.configManager.getConfig('crowi', 'app:globalLang');
+
     const providerId = 'google';
     const strategyName = 'google';
 
@@ -247,10 +249,24 @@ module.exports = function(crowi, app) {
       return loginFailureHandler(req, res);
     }
 
+    let name;
+
+    switch (globalLang) {
+      case 'en-US':
+        name = `${response.name.givenName} ${response.name.familyName}`;
+        break;
+      case 'ja':
+        name = `${response.name.familyName} ${response.name.givenName}`;
+        break;
+      default:
+        name = `${response.name.givenName} ${response.name.familyName}`;
+        break;
+    }
+
     const userInfo = {
       id: response.id,
       username: response.displayName,
-      name: `${response.name.givenName} ${response.name.familyName}`,
+      name,
     };
 
     // Emails are not empty if it exists

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

@@ -49,10 +49,7 @@
  */
 
 module.exports = function(crowi, app) {
-  const debug = require('debug')('growi:routes:me');
-  const logger = require('@alias/logger')('growi:routes:me');
   const models = crowi.models;
-  const User = models.User;
   const UserGroupRelation = models.UserGroupRelation;
   const ExternalAccount = models.ExternalAccount;
   const ApiResponse = require('../util/apiResponse');
@@ -104,90 +101,7 @@ module.exports = function(crowi, app) {
   };
 
   actions.index = function(req, res) {
-    const userForm = req.body.userForm;
-    const userData = req.user;
-
-    if (req.method === 'POST' && req.form.isValid) {
-      const name = userForm.name;
-      const email = userForm.email;
-      const lang = userForm.lang;
-      const isEmailPublished = userForm.isEmailPublished;
-
-      /*
-       * disabled because the system no longer allows undefined email -- 2017.10.06 Yuki Takei
-       *
-      if (!User.isEmailValid(email)) {
-        req.form.errors.push('You can\'t update to that email address');
-        return res.render('me/index', {});
-      }
-      */
-
-      User.findOneAndUpdate(
-        /* eslint-disable object-curly-newline */
-        { email: userData.email }, //                   query
-        { name, email, lang, isEmailPublished }, //     updating data
-        { runValidators: true, context: 'query' }, //   for validation
-        // see https://www.npmjs.com/package/mongoose-unique-validator#find--updates -- 2017.09.24 Yuki Takei
-        /* eslint-enable object-curly-newline */
-        (err) => {
-          if (err) {
-            Object.keys(err.errors).forEach((e) => {
-              req.form.errors.push(err.errors[e].message);
-            });
-
-            return res.render('me/index', {});
-          }
-          req.i18n.changeLanguage(lang);
-          req.flash('successMessage', req.t('Updated'));
-          return res.redirect('/me');
-        },
-      );
-    }
-    else { // method GET
-      /*
-       * disabled because the system no longer allows undefined email -- 2017.10.06 Yuki Takei
-       *
-      /// そのうちこのコードはいらなくなるはず
-      if (!userData.isEmailSet()) {
-        req.flash('warningMessage', 'メールアドレスが設定されている必要があります');
-      }
-      */
-
-      return res.render('me/index', {
-      });
-    }
-  };
-
-  actions.imagetype = function(req, res) {
-    if (req.method !== 'POST') {
-      // do nothing
-      return;
-    }
-    if (!req.form.isValid) {
-      req.flash('errorMessage', req.form.errors.join('\n'));
-      return;
-    }
-
-    const imagetypeForm = req.body.imagetypeForm;
-    const userData = req.user;
-
-    const isGravatarEnabled = imagetypeForm.isGravatarEnabled;
-
-    userData.updateIsGravatarEnabled(isGravatarEnabled, (err, userData) => {
-      if (err) {
-        /* eslint-disable no-restricted-syntax, no-prototype-builtins */
-        for (const e in err.errors) {
-          if (err.errors.hasOwnProperty(e)) {
-            req.form.errors.push(err.errors[e].message);
-          }
-        }
-        /* eslint-enable no-restricted-syntax, no-prototype-builtins */
-        return res.render('me/index', {});
-      }
-
-      req.flash('successMessage', req.t('Updated'));
-      return res.redirect('/me');
-    });
+    return res.render('me/index');
   };
 
   actions.externalAccounts = {};
@@ -210,177 +124,6 @@ module.exports = function(crowi, app) {
       });
   };
 
-  actions.externalAccounts.disassociate = function(req, res) {
-    const userData = req.user;
-
-    const redirectWithFlash = (type, msg) => {
-      req.flash(type, msg);
-      return res.redirect('/me/external-accounts');
-    };
-
-    if (req.body == null) {
-      redirectWithFlash('errorMessage', 'Invalid form.');
-    }
-
-    // make sure password set or this user has two or more ExternalAccounts
-    new Promise((resolve, reject) => {
-      if (userData.password != null) {
-        resolve(true);
-      }
-      else {
-        ExternalAccount.count({ user: userData })
-          .then((count) => {
-            resolve(count > 1);
-          });
-      }
-    })
-      .then((isDisassociatable) => {
-        if (!isDisassociatable) {
-          const e = new Error();
-          e.name = 'couldntDisassociateError';
-          throw e;
-        }
-
-        const providerType = req.body.providerType;
-        const accountId = req.body.accountId;
-
-        return ExternalAccount.findOneAndRemove({ providerType, accountId, user: userData });
-      })
-      .then((account) => {
-        if (account == null) {
-          return redirectWithFlash('errorMessage', 'ExternalAccount not found.');
-        }
-
-        return redirectWithFlash('successMessage', 'Successfully disassociated.');
-      })
-      .catch((err) => {
-        if (err) {
-          if (err.name === 'couldntDisassociateError') {
-            return redirectWithFlash('couldntDisassociateError', true);
-          }
-
-          return redirectWithFlash('errorMessage', err.message);
-        }
-      });
-  };
-
-  actions.externalAccounts.associateLdap = function(req, res) {
-    const passport = require('passport');
-    const passportService = crowi.passportService;
-
-    const redirectWithFlash = (type, msg) => {
-      req.flash(type, msg);
-      return res.redirect('/me/external-accounts');
-    };
-
-    if (!passportService.isLdapStrategySetup) {
-      debug('LdapStrategy has not been set up');
-      return redirectWithFlash('warning', 'LdapStrategy has not been set up');
-    }
-
-    passport.authenticate('ldapauth', (err, user, info) => {
-      if (res.headersSent) { // dirty hack -- 2017.09.25
-        return; //              cz: somehow passport.authenticate called twice when ECONNREFUSED error occurred
-      }
-
-      if (err) { // DB Error
-        logger.error('LDAP Server Error: ', err);
-        return redirectWithFlash('warningMessage', 'LDAP Server Error occured.');
-      }
-      if (info && info.message) {
-        return redirectWithFlash('warningMessage', info.message);
-      }
-      if (user) {
-        // create ExternalAccount
-        const ldapAccountId = passportService.getLdapAccountIdFromReq(req);
-        const user = req.user;
-
-        ExternalAccount.associate('ldap', ldapAccountId, user)
-          .then(() => {
-            return redirectWithFlash('successMessage', 'Successfully added.');
-          })
-          .catch((err) => {
-            return redirectWithFlash('errorMessage', err.message);
-          });
-      }
-    })(req, res, () => {});
-  };
-
-  actions.password = function(req, res) {
-    const passwordForm = req.body.mePassword;
-    const userData = req.user;
-
-    /*
-      * disabled because the system no longer allows undefined email -- 2017.10.06 Yuki Takei
-      *
-    // パスワードを設定する前に、emailが設定されている必要がある (schemaを途中で変更したため、最初の方の人は登録されていないかもしれないため)
-    // そのうちこのコードはいらなくなるはず
-    if (!userData.isEmailSet()) {
-      return res.redirect('/me');
-    }
-    */
-
-    if (req.method === 'POST' && req.form.isValid) {
-      const newPassword = passwordForm.newPassword;
-      const newPasswordConfirm = passwordForm.newPasswordConfirm;
-      const oldPassword = passwordForm.oldPassword;
-
-      if (userData.isPasswordSet() && !userData.isPasswordValid(oldPassword)) {
-        req.form.errors.push('Wrong current password');
-        return res.render('me/password', {
-        });
-      }
-
-      // check password confirm
-      if (newPassword !== newPasswordConfirm) {
-        req.form.errors.push('Failed to verify passwords');
-      }
-      else {
-        userData.updatePassword(newPassword, (err, userData) => {
-          if (err) {
-            /* eslint-disable no-restricted-syntax, no-prototype-builtins */
-            for (const e in err.errors) {
-              if (err.errors.hasOwnProperty(e)) {
-                req.form.errors.push(err.errors[e].message);
-              }
-            }
-            return res.render('me/password', {});
-          }
-          /* eslint-enable no-restricted-syntax, no-prototype-builtins */
-
-          req.flash('successMessage', 'Password updated');
-          return res.redirect('/me/password');
-        });
-      }
-    }
-    else { // method GET
-      return res.render('me/password', {
-      });
-    }
-  };
-
-  actions.apiToken = function(req, res) {
-    const userData = req.user;
-
-    if (req.method === 'POST' && req.form.isValid) {
-      userData.updateApiToken()
-        .then((userData) => {
-          req.flash('successMessage', 'API Token updated');
-          return res.redirect('/me/apiToken');
-        })
-        .catch((err) => {
-        // req.flash('successMessage',);
-          req.form.errors.push('Failed to update API Token');
-          return res.render('me/api_token', {
-          });
-        });
-    }
-    else {
-      return res.render('me/api_token', {
-      });
-    }
-  };
-
   actions.updates = function(req, res) {
     res.render('me/update', {
     });

+ 1 - 1
src/server/views/admin/Users_reserve.html

@@ -162,7 +162,7 @@
           {% set sUserId = sUser._id.toString() %}
           <tr>
             <td>
-              <img src="{{ sUser|picture }}" class="picture img-circle" />
+              <img src="{{ sUser|picture }}" class="picture rounded-circle" />
               {% if sUser.admin %}
               <span class="label label-inverse label-admin">
               {{ t('administrator') }}

+ 20 - 12
src/server/views/invited.html

@@ -26,7 +26,7 @@
 
   <div class="row">
 
-    <div class="login-header col-sm-offset-4 col-sm-4">
+    <div class="login-header offset-4 col-sm-4">
       <div class="logo">{% include 'widget/logo.html' %}</div>
       <h1>GROWI</h1>
 
@@ -50,7 +50,7 @@
       </div>
     </div>
 
-    <div class="login-dialog grw-pt-10px p-b-10 col-sm-offset-4 col-sm-4" id="login-dialog">
+    <div class="login-dialog grw-pt-10px p-b-10 offset-4 col-sm-4" id="login-dialog">
       <p class="alert alert-success">
         <strong>アカウントの作成</strong><br>
         <small>招待を受け取ったメールアドレスでアカウントを作成します</small>
@@ -59,12 +59,15 @@
       <form role="form" action="/login/activateInvited" method="post" id="invited-form">
 
         <div class="input-group">
-          <span class="input-group-addon"><i class="icon-envelope"></i></span>
+          <div class="input-group-prepend">
+            <span class="input-group-text"><i class="icon-envelope"></i></span>
+          </div>
           <input type="text" class="form-control" disabled value="{{ user.email }}">
         </div>
-
         <div class="input-group" id="input-group-username">
-          <span class="input-group-addon"><i class="icon-user"></i></span>
+          <div class="input-group-prepend">
+            <span class="input-group-text"><i class="icon-user"></i></span>
+          </div>
           <input type="text" class="form-control" placeholder="{{ t('User ID') }}" name="invitedForm[username]" value="{{ req.body.invitedForm.username }}" required>
         </div>
         <p class="help-block">
@@ -72,21 +75,26 @@
         </p>
 
         <div class="input-group">
-          <span class="input-group-addon"><i class="icon-tag"></i></span>
+          <div class="input-group-prepend">
+            <span class="input-group-text"><i class="icon-tag"></i></span>
+          </div>
           <input type="text" class="form-control" placeholder="{{ t('Name') }}" name="invitedForm[name]" value="{{ req.body.invitedForm.name }}" required>
         </div>
 
 
         <div class="input-group">
-          <span class="input-group-addon"><i class="icon-lock"></i></span>
+          <div class="input-group-prepend">
+            <span class="input-group-text"><i class="icon-lock"></i></span>
+          </div>
           <input type="password" class="form-control" placeholder="{{ t('Password') }}" name="invitedForm[password]" required>
         </div>
 
-        <input type="hidden" name="_csrf" value="{{ csrf() }}">
-        <div class="input-group mt-5 m-b-20 d-flex justify-content-center">
-          <button type="submit" class="fcbtn btn btn-success btn-1b btn-register">
-            <span class="btn-label"><i class="icon-user-follow"></i></span>
-            {{ t('Create') }}
+        <div class="input-group justify-content-center d-flex mt-5">
+          <input type="hidden" name="_csrf" value="{{ csrf() }}">
+          <button type="submit" class="btn btn-fill login px-0 py-2" id="register">
+            <div class="eff"></div>
+            <span class="btn-label p-3"><i class="icon-user-follow"></i></span>
+            <span class="btn-label-text p-3">{{ t('Create') }}</span>
           </button>
         </div>
 

+ 2 - 2
src/server/views/layout-crowi/widget/page_side_header.html

@@ -4,7 +4,7 @@
     {# default(author) としているのは、v1.1.1 以前に page.creator データが入ってないから。暫定として最新更新ユーザーを表示しちゃう。 #}
     <div class="col-md-3 creator-picture">
       <a href="{{ userPageRoot(page.creator) }}">
-        <img src="{{ page.creator|default(author)|picture }}" class="picture picture-lg img-circle"><br>
+        <img src="{{ page.creator|default(author)|picture }}" class="picture picture-lg rounded-circle"><br>
       </a>
     </div>
     <div class="col-md-9">
@@ -13,7 +13,7 @@
       </p>
       <p class="created-at">
         {{ t('Created') }}: {{ page.createdAt|datetz('Y/m/d H:i:s') }}<br>
-        {{ t('Last updated') }}: {{ page.updatedAt|datetz('Y/m/d H:i:s') }} <a href="/user/{{ page.revision.author.username }}"><img src="{{ page.revision.author|picture }}" class="picture picture-xs img-circle" alt="{{ page.revision.author.name }}"></a>
+        {{ t('Last updated') }}: {{ page.updatedAt|datetz('Y/m/d H:i:s') }} <a href="/user/{{ page.revision.author.username }}"><img src="{{ page.revision.author|picture }}" class="picture picture-xs rounded-circle" alt="{{ page.revision.author.name }}"></a>
       </p>
     </div>
   </div>

+ 3 - 3
src/server/views/layout-kibela/base/layout.html

@@ -7,13 +7,13 @@
 {% endblock %}
 
 {% block layout_main %}
-<div class="container-fluid">
+<div class="container-fluid p-0">
 
   <div class="row body m-0 p-0">
 
-    <div id="main" class="main mt-5 col-md-7 col-xs-12 kibela-block bg-white round-corner {% if page %}{{ css.grant(page) }}{% endif %}{% block main_css_class %}{% endblock %}">
+    <div id="main" class="main col-md-7 col-12 kibela-block bg-white round-corner {% if page %}{{ css.grant(page) }}{% endif %}{% block main_css_class %}{% endblock %}">
       <div class="row grw-subnav">
-        <div class="col-xs-12 grw-title-bar">
+        <div class="col-12 grw-title-bar">
           {% block content_header %} {% endblock %}
         </div>
       </div>

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

@@ -33,7 +33,7 @@
 </div>
 
   <div class="row page-list bg-white round-corner grw-pt-10px mb-5 {% if page.isPortal() %}mt-5{% endif %}">
-    <div class="col-xs-12">
+    <div class="col">
       {% include '../widget/page_list_and_timeline_kibela.html' %}
     </div>
   </div>

+ 2 - 2
src/server/views/layout-kibela/widget/header.html

@@ -17,7 +17,7 @@
       <li>
         <div class="d-flex align-items-center b">
           <a class="mr-2" href="{{ userPageRoot(page.creator) }}">
-            <img src="{{ page.creator|default(author)|picture }}" class="picture img-circle">
+            <img src="{{ page.creator|default(author)|picture }}" class="picture rounded-circle">
           </a>
           <div>
             <div>Created by
@@ -30,7 +30,7 @@
       <li class="mt-2">
         <div class="d-flex align-items-center">
           <a class="mr-2" href="{{ userPageRoot(author) }}">
-            <img src="{{ author|picture }}" class="picture img-circle">
+            <img src="{{ author|picture }}" class="picture rounded-circle">
           </a>
           <div>
             <div>Updated by

+ 9 - 9
src/server/views/login.html

@@ -141,7 +141,7 @@
 
               <div class="input-group justify-content-center d-flex mt-5">
                 <input type="hidden" name="_csrf" value="{{ csrf() }}">
-                <button type="submit" class="btn fcbtn login px-0 py-2">
+                <button type="submit" class="btn btn-fill login px-0 py-2">
                   <div class="eff"></div>
                   <span class="btn-label p-3"><i class="icon-login"></i></span>
                   <span class="btn-label-text p-3">{{ t('Sign in') }}</span>
@@ -167,7 +167,7 @@
                 {% if getConfig('crowi', 'security:passport-google:isEnabled') %}
                 <div class="input-group justify-content-center d-flex mt-5">
                   <form role="form" action="/passport/google" class="d-inline-flex flex-column">
-                    <button type="submit" class="btn fcbtn px-0 py-2" id="google">
+                    <button type="submit" class="btn btn-fill px-0 py-2" id="google">
                       <div class="eff"></div>
                       <span class="btn-label p-3"><i class="fa fa-google"></i></span>
                       <span class="btn-label-text p-3">{{ t('Sign in') }}</span>
@@ -180,7 +180,7 @@
                 <div class="input-group justify-content-center d-flex mt-5">
                   <form role="form" action="/passport/github" class="d-inline-flex flex-column">
                     <input type="hidden" name="_csrf" value="{{ csrf() }}">
-                    <button type="submit" class="btn fcbtn px-0 py-2" id="github">
+                    <button type="submit" class="btn btn-fill px-0 py-2" id="github">
                       <div class="eff"></div>
                       <span class="btn-label p-3"><i class="fa fa-github"></i></span>
                       <span class="btn-label-text p-3">{{ t('Sign in') }}</span>
@@ -193,7 +193,7 @@
                 <div class="input-group justify-content-center d-flex mt-5">
                   <form role="form" action="/passport/facebook" class="d-inline-flex flex-column">
                     <input type="hidden" name="_csrf" value="{{ csrf() }}">
-                    <button type="submit" class="btn fcbtn px-0 py-2" id="facebook">
+                    <button type="submit" class="btn btn-fill px-0 py-2" id="facebook">
                       <div class="eff"></div>
                       <span class="btn-label p-3"><i class="fa fa-facebook"></i></span>
                       <span class="btn-label-text p-3">{{ t('Sign in') }}</span>
@@ -206,7 +206,7 @@
                 <div class="input-group justify-content-center d-flex mt-5">
                   <form role="form" action="/passport/twitter" class="d-inline-flex flex-column">
                     <input type="hidden" name="_csrf" value="{{ csrf() }}">
-                    <button type="submit" class="btn fcbtn px-0 py-2" id="twitter">
+                    <button type="submit" class="btn btn-fill px-0 py-2" id="twitter">
                       <div class="eff"></div>
                       <span class="btn-label p-3"><i class="fa fa-twitter"></i></span>
                       <span class="btn-label-text p-3">{{ t('Sign in') }}</span>
@@ -219,7 +219,7 @@
                 <div class="input-group justify-content-center d-flex mt-5">
                   <form role="form" action="/passport/oidc" class="d-inline-flex flex-column">
                     <input type="hidden" name="_csrf" value="{{ csrf() }}">
-                    <button type="submit" class="btn fcbtn px-0 py-2" id="oidc">
+                    <button type="submit" class="btn btn-fill px-0 py-2" id="oidc">
                       <div class="eff"></div>
                       <span class="btn-label p-3"><i class="fa fa-openid"></i></span>
                       <span class="btn-label-text p-3">{{ t('Sign in') }}</span>
@@ -232,7 +232,7 @@
                 <div class="input-group justify-content-center d-flex mt-5">
                   <form role="form" action="/passport/saml" class="d-inline-flex flex-column">
                     <input type="hidden" name="_csrf" value="{{ csrf() }}">
-                    <button type="submit" class="btn fcbtn px-0 py-2" id="saml">
+                    <button type="submit" class="btn btn-fill px-0 py-2" id="saml">
                       <div class="eff"></div>
                       <span class="btn-label p-3"><i class="fa fa-key"></i></span>
                       <span class="btn-label-text p-3">{{ t('Sign in') }}</span>
@@ -245,7 +245,7 @@
                 <div class="input-group justify-content-center d-flex mt-5">
                   <form role="form" action="/passport/basic" class="d-inline-flex flex-column">
                     <input type="hidden" name="_csrf" value="{{ csrf() }}">
-                    <button type="submit" class="btn fcbtn px-0 py-2" id="basic">
+                    <button type="submit" class="btn btn-fill px-0 py-2" id="basic">
                       <div class="eff"></div>
                       <span class="btn-label p-3"><i class="fa fa-lock"></i></span>
                       <span class="btn-label-text p-3">{{ t('Sign in') }}</span>
@@ -352,7 +352,7 @@
 
               <div class="input-group justify-content-center mt-5">
                 <input type="hidden" name="_csrf" value="{{ csrf() }}">
-                <button type="submit" class="btn fcbtn px-0 py-2" id="register">
+                <button type="submit" class="btn btn-fill px-0 py-2" id="register">
                   <div class="eff"></div>
                   <span class="btn-label p-3"><i class="icon-user-follow"></i></span>
                   <span class="btn-label-text p-3">{{ t('Sign up') }}</span>

+ 35 - 15
src/server/views/me/index.html

@@ -9,7 +9,7 @@
 {% endblock %}
 
 {% block content_main %}
-<div class="content-main">
+<div class="content-main" id="personal-setting">
 
   <ul class="nav nav-tabs mb-4" role="tablist">
     <li class="nav-item">
@@ -216,21 +216,41 @@
               </div>
             </div>
           </div>
-          <div class="row">
-            <label for="" class="col-sm-4">{{ t('Upload new image') }}</label>
-              <div class="col-sm-8">
-                {% if fileUploadService.getIsUploadable() %}
-                <form action="/_api/attachments.uploadProfileImage" id="pictureUploadForm" method="post" class="form-group" role="form">
-                  <input type="hidden" name="_csrf" value="{{ csrf() }}">
-                  <input type="file" name="profileImage" accept="image/*">
-                  <div id="pictureUploadFormProgress" class="d-flex align-items-center"></div>
-                </form>
-                {% else %}
-                * {{ t('page_me.form_help.profile_image1') }}<br>
-                * {{ t('page_me.form_help.profile_image2') }}<br>
-                {% endif %}
-              </div>
+        </h4>
+
+        <img src="{{ user|gravatar }}" width="64">
+      </div><!-- /.col-sm* -->
+
+      <div class="form-group col-md-4 col-sm-7">
+        <h4>
+          <div class="radio radio-primary">
+            <input type="radio" id="radioUploadPicture" form="formImageType" name="imagetypeForm[isGravatarEnabled]" value="false" {% if !user.isGravatarEnabled  %}checked="checked"{% endif %}>
+            <label for="radioUploadPicture">
+              {{ t('Upload Image') }}
+            </label>
           </div>
+        </h4>
+        <div class="form-group">
+          <div id="pictureUploadFormMessage"></div>
+          <label for="" class="col-sm-4 control-label">
+            {{ t('Current Image') }}
+          </label>
+          <div class="col-sm-8">
+            <p>
+            <img src="{{ user|uploadedpicture }}" class="picture picture-lg img-circle" id="settingUserPicture"><br>
+            </p>
+            <p>
+            <form id="remove-attachment" action="/_api/attachments.removeProfileImage" method="post" class="form-horizontal"
+                style="{% if not user.imageAttachment %}display: none{% endif %}">
+              <input type="hidden" name="_csrf" value="{{ csrf() }}">
+              <button type="submit" class="btn btn-danger">{{ t('Delete Image') }}</button>
+            </form>
+            </p>
+          </div>
+        </div><!-- /.form-group -->
+
+        <div class="form-group">
+          <div id="profile-image-uploader"></div>
         </div>
       </div>
 

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

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

+ 3 - 3
src/server/views/modal/create_page.html

@@ -21,7 +21,7 @@
                   <input type="text" data-prefix="/{{ now|datetz('Y/m/d') }}/" class="page-today-input2 form-control" id="page-today-input2" name="" placeholder="{{ t('Input page name (optional)') }}">
                 </div>
                 <div class="create-page-button-container">
-                  <button type="submit" class="fcbtn btn btn-outline-primary rounded-pill btn-1b"><i class="icon-fw icon-doc"></i>{{ t('Create') }}</button>
+                  <button type="submit" class="btn btn-outline-primary rounded-pill"><i class="icon-fw icon-doc"></i>{{ t('Create') }}</button>
                 </div>
               </div>
             </fieldset>
@@ -41,7 +41,7 @@
                   {% endif %}
                 </div>
                 <div class="create-page-button-container">
-                  <button type="submit" class="fcbtn btn btn-outline-primary rounded-pill btn-1b"><i class="icon-fw icon-doc"></i>{{ t('Create') }}</button>
+                  <button type="submit" class="btn btn-outline-primary rounded-pill"><i class="icon-fw icon-doc"></i>{{ t('Create') }}</button>
                 </div>
               </div>
             </fieldset>
@@ -72,7 +72,7 @@
 
               </div>
               <div class="create-page-button-container my-auto">
-                <a id="link-to-template" href="{{ page.path || path }}" class="fcbtn btn btn-outline-primary rounded-pill btn-1b disabled">
+                <a id="link-to-template" href="{{ page.path || path }}" class="btn btn-outline-primary rounded-pill disabled">
                   <i class="icon-fw icon-doc"></i>
                   <span id="create-template-button-link">{{ t('Edit') }}</span>
                 </a>

+ 7 - 6
src/server/views/modal/put_back.html

@@ -5,19 +5,20 @@
       <form role="form" id="revert-delete-page-form" onsubmit="return false;">
 
         <div class="modal-header bg-info">
-          <button type="button" class="close" data-dismiss="modal" aria-hidden="true">&times;</button>
           <div class="modal-title"><i class="icon-action-undo"></i> {{ t('modal_putback.label.Put Back Page') }}</div>
+          <button type="button" class="close" data-dismiss="modal" aria-hidden="true">&times;</button>
         </div>
         <div class="modal-body">
           <div class="form-group">
             <label for="">Put back page:</label><br>
             <code>{{ page.path }}</code>
           </div>
-          <div class="checkbox checkbox-warning">
-            <input name="recursively" id="cbPutbackRecursively" value="1" type="checkbox" checked>
-            <label for="cbPutbackRecursively">{{ t('modal_putback.label.recursively') }}</label>
-            <p class="help-block"> {{ t('modal_putback.help.recursively', page.path) }}
-            </p>
+          <div class="custom-control custom-checkbox custom-checkbox-warning">
+            <input class="custom-control-input" name="recursively" id="cbPutbackRecursively" value="1" type="checkbox" checked>
+            <label class="custom-control-label" for="cbPutbackRecursively">
+              {{ t('modal_putback.label.recursively') }}
+              <p class="help-block mt-0">{{ t('modal_putback.help.recursively', page.path) }}</p>
+            </label>
           </div>
         </div>
         <div class="modal-footer">

+ 3 - 1
src/server/views/modal/rename.html

@@ -62,9 +62,11 @@
               <input type="hidden" name="path" value="{{ page.path }}">
               <input type="hidden" name="page_id" value="{{ page._id.toString() }}">
               <input type="hidden" name="revision_id" value="{{ page.revision._id.toString() }}">
-              <button type="submit" class="btn btn-primary">Rename</button>
             </div>
           </div>
+          <div class="d-flex justify-content-end">
+            <button type="submit" class="btn btn-primary">Rename</button>
+          </div>
         </div>
 
       </form>

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

@@ -15,7 +15,7 @@
 <div class="container-fluid">
 
   <div class="row">
-    <div id="main" class="main col-md-12 search-page">
+    <div id="main" class="main col-lg-12 search-page">
       <div class="" id="search-page"></div>
     </div>
   </div>

+ 6 - 6
src/server/views/widget/forbidden_content.html

@@ -1,5 +1,5 @@
 <div class="row not-found-message-row mb-4">
-  <div class="col-md-12">
+  <div class="col-lg-12">
     <h2 class="text-muted">
       <i class="icon-ban" aria-hidden="true"></i>
       Forbidden
@@ -7,22 +7,22 @@
   </div>
 </div>
 
-<div id="content-main" class="content-main content-main-not-found page-list"
+<div id="content-main" class="content-main page-list"
   data-path="{{ path | preventXss }}"
   data-current-user="{% if user %}{{ user._id.toString() }}{% endif %}"
   >
 
   <div class="row row-alerts">
-    <div class="col-xs-12">
-        <p class="alert alert-inverse alert-grant"> <!-- TODO remove inverse and grant -->
+    <div class="col-sm-12">
+        <p class="alert alert-primary py-3 px-4">
           <i class="icon-fw icon-lock" aria-hidden="true"></i> Browsing of this page is restricted
         </p>
     </div>
   </div>
 
-  <ul class="nav nav-tabs hidden-print">
+  <ul class="nav nav-tabs hidden-print" role="tablist">
     <li class="nav-item grw-nav-main-left-tab">
-      <a class="nav-link active" href="#revision-body" data-toggle="tab">
+      <a class="nav-link active" role="tab" href="#revision-body" data-toggle="tab">
         <i class="icon-notebook"></i> List
       </a>
     </li>

+ 0 - 6
src/server/views/widget/header-button-bookmark.html

@@ -1,6 +0,0 @@
-{# This widget will be rendered by React #}
-{% if not size == null %}
-  <span id="bookmark-button-{{size}}"></span>
-{% else %}
-  <span id="bookmark-button"></span>
-{% endif %}

Beberapa file tidak ditampilkan karena terlalu banyak file yang berubah dalam diff ini