Kaynağa Gözat

Merge branch 'master' into support/apply-bootstrap4

# Conflicts:
#	CHANGES.md
#	src/client/js/app.jsx
#	src/client/js/components/Admin/Security/LdapAuthTestModal.jsx
#	src/server/views/me/api_token.html
#	src/server/views/me/index.html
#	src/server/views/me/password.html
#	yarn.lock
yusuketk 6 yıl önce
ebeveyn
işleme
0698b9eb91
36 değiştirilmiş dosya ile 2172 ekleme ve 570 silme
  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 1
      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. 107 0
      src/client/js/components/Me/ApiSettings.jsx
  9. 120 0
      src/client/js/components/Me/AssociateModal.jsx
  10. 160 0
      src/client/js/components/Me/BasicInfoSettings.jsx
  11. 90 0
      src/client/js/components/Me/DisassociateModal.jsx
  12. 136 0
      src/client/js/components/Me/ExternalAccountLinkedMe.jsx
  13. 40 0
      src/client/js/components/Me/ExternalAccountRow.jsx
  14. 130 0
      src/client/js/components/Me/ImageCropModal.jsx
  15. 147 0
      src/client/js/components/Me/PasswordSettings.jsx
  16. 61 0
      src/client/js/components/Me/PersonalSettings.jsx
  17. 196 0
      src/client/js/components/Me/ProfileImageSettings.jsx
  18. 37 0
      src/client/js/components/Me/UserSettings.jsx
  19. 3 5
      src/client/js/components/User/UserPicture.jsx
  20. 248 0
      src/client/js/services/PersonalContainer.js
  21. 0 6
      src/server/form/index.js
  22. 0 7
      src/server/form/me/apiToken.js
  23. 0 7
      src/server/form/me/imagetype.js
  24. 0 9
      src/server/form/me/password.js
  25. 0 10
      src/server/form/me/user.js
  26. 1 0
      src/server/models/config.js
  27. 9 25
      src/server/models/user.js
  28. 6 8
      src/server/routes/apiv3/index.js
  29. 416 0
      src/server/routes/apiv3/personal-setting.js
  30. 2 0
      src/server/routes/apiv3/users.js
  31. 0 9
      src/server/routes/index.js
  32. 17 1
      src/server/routes/login-passport.js
  33. 1 258
      src/server/routes/me.js
  34. 35 15
      src/server/views/me/index.html
  35. 0 98
      src/server/views/me/password.html
  36. 19 0
      yarn.lock

+ 9 - 2
CHANGES.md

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

+ 3 - 1
package.json

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

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

@@ -123,6 +123,7 @@
   "List Drafts": "Drafts",
   "List Drafts": "Drafts",
   "Deleted Pages": "Deleted Pages",
   "Deleted Pages": "Deleted Pages",
   "Sign out": "Logout",
   "Sign out": "Logout",
+  "Disassociate": "Disassociate",
   "form_validation": {
   "form_validation": {
     "error_message": "Some values ​​are incorrect",
     "error_message": "Some values ​​are incorrect",
     "required": "%s is required",
     "required": "%s is required",
@@ -164,12 +165,16 @@
   },
   },
   "Password": "Password",
   "Password": "Password",
   "Password Settings": "Password Settings",
   "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",
   "security_settings": "Security Settings",
   "API Settings": "API Settings",
   "API Settings": "API Settings",
   "API Token Settings": "API Token Settings",
   "API Token Settings": "API Token Settings",

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

@@ -122,6 +122,7 @@
   "List Drafts": "下書き一覧",
   "List Drafts": "下書き一覧",
   "Deleted Pages": "削除済みページ",
   "Deleted Pages": "削除済みページ",
   "Sign out": "ログアウト",
   "Sign out": "ログアウト",
+  "Disassociate": "連携解除",
   "form_validation": {
   "form_validation": {
     "error_message": "いくつかの値が設定されていません",
     "error_message": "いくつかの値が設定されていません",
     "required": "%sに値を入力してください",
     "required": "%sに値を入力してください",
@@ -163,12 +164,16 @@
   },
   },
   "Password": "パスワード",
   "Password": "パスワード",
   "Password Settings": "パスワード設定",
   "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": "セキュリティ設定",
   "security_settings": "セキュリティ設定",
   "API Settings": "API設定",
   "API Settings": "API設定",
   "API Token Settings": "API Token設定",
   "API Token Settings": "API Token設定",

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

@@ -31,11 +31,13 @@ import MyDraftList from './components/MyDraftList/MyDraftList';
 import UserPictureList from './components/User/UserPictureList';
 import UserPictureList from './components/User/UserPictureList';
 import TableOfContents from './components/TableOfContents';
 import TableOfContents from './components/TableOfContents';
 
 
+import PersonalSettings from './components/Me/PersonalSettings';
 import PageContainer from './services/PageContainer';
 import PageContainer from './services/PageContainer';
 import CommentContainer from './services/CommentContainer';
 import CommentContainer from './services/CommentContainer';
 import EditorContainer from './services/EditorContainer';
 import EditorContainer from './services/EditorContainer';
 import TagContainer from './services/TagContainer';
 import TagContainer from './services/TagContainer';
 import GrowiSubNavigation from './components/Navbar/GrowiSubNavigation';
 import GrowiSubNavigation from './components/Navbar/GrowiSubNavigation';
+import PersonalContainer from './services/PersonalContainer';
 
 
 import { appContainer, componentMappings } from './bootstrap';
 import { appContainer, componentMappings } from './bootstrap';
 
 
@@ -49,8 +51,9 @@ const pageContainer = new PageContainer(appContainer);
 const commentContainer = new CommentContainer(appContainer);
 const commentContainer = new CommentContainer(appContainer);
 const editorContainer = new EditorContainer(appContainer, defaultEditorOptions, defaultPreviewOptions);
 const editorContainer = new EditorContainer(appContainer, defaultEditorOptions, defaultPreviewOptions);
 const tagContainer = new TagContainer(appContainer);
 const tagContainer = new TagContainer(appContainer);
+const personalContainer = new PersonalContainer(appContainer);
 const injectableContainers = [
 const injectableContainers = [
-  appContainer, websocketContainer, pageContainer, commentContainer, editorContainer, tagContainer,
+  appContainer, websocketContainer, pageContainer, commentContainer, editorContainer, tagContainer, personalContainer,
 ];
 ];
 
 
 logger.info('unstated containers have been initialized');
 logger.info('unstated containers have been initialized');
@@ -75,6 +78,7 @@ Object.assign(componentMappings, {
 
 
   'user-created-list': <RecentCreated />,
   'user-created-list': <RecentCreated />,
   'user-draft-list': <MyDraftList />,
   'user-draft-list': <MyDraftList />,
+  'personal-setting': <PersonalSettings crowi={personalContainer} />,
 });
 });
 
 
 // additional definitions if data exists
 // additional definitions if data exists

+ 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 React from 'react';
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
 import { withTranslation } from 'react-i18next';
 import { withTranslation } from 'react-i18next';
-import loggerFactory from '@alias/logger';
 
 
 import {
 import {
   Modal,
   Modal,
@@ -11,12 +10,11 @@ import {
 } from 'reactstrap';
 } from 'reactstrap';
 
 
 import { createSubscribedElement } from '../../UnstatedUtils';
 import { createSubscribedElement } from '../../UnstatedUtils';
-import { toastSuccess, toastError } from '../../../util/apiNotification';
 
 
 import AppContainer from '../../../services/AppContainer';
 import AppContainer from '../../../services/AppContainer';
 import AdminLdapSecurityContainer from '../../../services/AdminLdapSecurityContainer';
 import AdminLdapSecurityContainer from '../../../services/AdminLdapSecurityContainer';
+import LdapAuthTest from './LdapAuthTest';
 
 
-const logger = loggerFactory('growi:security:AdminLdapSecurityContainer');
 
 
 class LdapAuthTestModal extends React.Component {
 class LdapAuthTestModal extends React.Component {
 
 
@@ -26,15 +24,10 @@ class LdapAuthTestModal extends React.Component {
     this.state = {
     this.state = {
       username: '',
       username: '',
       password: '',
       password: '',
-      logs: '',
-      errorMessage: null,
-      successMessage: null,
     };
     };
 
 
     this.onChangeUsername = this.onChangeUsername.bind(this);
     this.onChangeUsername = this.onChangeUsername.bind(this);
     this.onChangePassword = this.onChangePassword.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 });
     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() {
   render() {
-    const { t } = this.props;
 
 
     return (
     return (
       <Modal isOpen={this.props.isOpen} toggle={this.props.onClose}>
       <Modal isOpen={this.props.isOpen} toggle={this.props.onClose}>
@@ -115,39 +52,14 @@ class LdapAuthTestModal extends React.Component {
           Test LDAP Account
           Test LDAP Account
         </ModalHeader>
         </ModalHeader>
         <ModalBody>
         <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>
         </ModalBody>
-        <ModalFooter>
-          <button type="button" className="btn btn-light mt-3 offset-5" onClick={this.testLdapCredentials}>Test</button>
-        </ModalFooter>
+        <ModalFooter />
       </Modal>
       </Modal>
     );
     );
   }
   }

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

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

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

@@ -0,0 +1,120 @@
+
+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}>
+        <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="active">
+              <a href="#passport-ldap" data-toggle="tab" role="tab"><i className="fa fa-sitemap"></i> LDAP</a>
+            </li>
+            <li className="tbd disabled"><a><i className="fa fa-github"></i> (TBD) GitHub</a></li>
+            <li className="tbd disabled"><a><i className="fa fa-google"></i> (TBD) Google OAuth</a></li>
+            <li className="tbd disabled"><a><i className="fa fa-facebook"></i> (TBD) Facebook</a></li>
+            <li className="tbd disabled"><a><i className="fa fa-twitter"></i> (TBD) Twitter</a></li>
+          </ul>
+          <LdapAuthTest
+            username={this.state.username}
+            password={this.state.password}
+            onChangeUsername={this.onChangeUsername}
+            onChangePassword={this.onChangePassword}
+          />
+        </ModalBody>
+        <ModalFooter>
+          <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);

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

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

+ 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">
+          <h2 className="border-bottom">
+            <button type="button" className="btn btn-default 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);

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

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

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

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

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

@@ -0,0 +1,196 @@
+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 col-sm-offset-1 col-sm-4">
+            <h4>
+              <div className="radio radio-primary">
+                <input
+                  type="radio"
+                  id="radioGravatar"
+                  form="formImageType"
+                  name="imagetypeForm[isGravatarEnabled]"
+                  checked={isGravatarEnabled}
+                  onChange={() => { personalContainer.changeIsGravatarEnabled(true) }}
+                />
+                <label htmlFor="radioGravatar">
+                  <img src="https://gravatar.com/avatar/00000000000000000000000000000000?s=24" /> Gravatar
+                </label>
+                <a href="https://gravatar.com/">
+                  <small><i className="icon-arrow-right-circle" aria-hidden="true"></i></small>
+                </a>
+              </div>
+            </h4>
+
+            <img src={this.generateGravatarSrc()} width="64" />
+          </div>
+
+          <div className="col-md-4 col-sm-7">
+            <h4>
+              <div className="radio radio-primary">
+                <input
+                  type="radio"
+                  id="radioUploadPicture"
+                  form="formImageType"
+                  name="imagetypeForm[isGravatarEnabled]"
+                  checked={!isGravatarEnabled}
+                  onChange={() => { personalContainer.changeIsGravatarEnabled(false) }}
+                />
+                <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 img-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="col-xs-offset-4 col-xs-5">
+            <button type="button" className="btn btn-primary" onClick={this.onClickSubmit} disabled={personalContainer.state.retrieveError != null}>
+              {t('Update')}
+            </button>
+          </div>
+        </div>
+
+      </React.Fragment>
+    );
+  }
+
+}
+
+
+const ProfileImageSettingsWrapper = (props) => {
+  return createSubscribedElement(ProfileImageSettings, props, [AppContainer, PersonalContainer]);
+};
+
+ProfileImageSettings.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  personalContainer: PropTypes.instanceOf(PersonalContainer).isRequired,
+};
+
+export default withTranslation()(ProfileImageSettingsWrapper);

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

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

+ 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 {
 export default class UserPicture extends React.Component {
 
 
   getUserPicture(user) {
   getUserPicture(user) {
-    let pictPath;
-
     // gravatar
     // gravatar
     if (user.isGravatarEnabled === true) {
     if (user.isGravatarEnabled === true) {
-      pictPath = this.generateGravatarSrc(user);
+      return this.generateGravatarSrc(user);
     }
     }
     // uploaded image
     // uploaded image
     if (user.image != null) {
     if (user.image != null) {
-      pictPath = user.image;
+      return user.image;
     }
     }
     if (user.imageAttachment != null) {
     if (user.imageAttachment != null) {
       return user.imageAttachment.filePathProxied;
       return user.imageAttachment.filePathProxied;
     }
     }
 
 
-    return pictPath || DEFAULT_IMAGE;
+    return DEFAULT_IMAGE;
   }
   }
 
 
   generateGravatarSrc(user) {
   generateGravatarSrc(user) {

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

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

@@ -4,12 +4,6 @@ module.exports = {
   invited: require('./invited'),
   invited: require('./invited'),
   revision: require('./revision'),
   revision: require('./revision'),
   comment: require('./comment'),
   comment: require('./comment'),
-  me: {
-    user: require('./me/user'),
-    password: require('./me/password'),
-    imagetype: require('./me/imagetype'),
-    apiToken: require('./me/apiToken'),
-  },
   admin: {
   admin: {
     userGroupCreate: require('./admin/userGroupCreate'),
     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(),
-);

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

@@ -189,6 +189,7 @@ module.exports = function(crowi) {
         image: crowi.fileUploadService.getIsUploadable(),
         image: crowi.fileUploadService.getIsUploadable(),
         file: crowi.fileUploadService.getFileUploadEnabled(),
         file: crowi.fileUploadService.getFileUploadEnabled(),
       },
       },
+      registrationWhiteList: crowi.configManager.getConfig('crowi', 'security:registrationWhiteList'),
       behaviorType: crowi.configManager.getConfig('crowi', 'customize:behavior'),
       behaviorType: crowi.configManager.getConfig('crowi', 'customize:behavior'),
       layoutType: crowi.configManager.getConfig('crowi', 'customize:layout'),
       layoutType: crowi.configManager.getConfig('crowi', 'customize:layout'),
       themeType: crowi.configManager.getConfig('crowi', 'customize:theme'),
       themeType: crowi.configManager.getConfig('crowi', 'customize:theme'),

+ 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.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.setPassword(password);
-    this.save((err, userData) => {
-      return callback(err, userData);
-    });
+    const userData = await this.save();
+    return userData;
   };
   };
 
 
   userSchema.methods.canDeleteCompletely = function(creatorId) {
   userSchema.methods.canDeleteCompletely = function(creatorId) {
@@ -224,19 +215,12 @@ module.exports = function(crowi) {
     return false;
     return false;
   };
   };
 
 
-  userSchema.methods.updateApiToken = function(callback) {
+  userSchema.methods.updateApiToken = async function() {
     const self = this;
     const self = this;
 
 
     self.apiToken = generateApiToken(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) {
   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));
   router.use('/healthcheck', require('./healthcheck')(crowi));
 
 
+  // admin
   router.use('/admin-home', require('./admin-home')(crowi));
   router.use('/admin-home', require('./admin-home')(crowi));
-
   router.use('/markdown-setting', require('./markdown-setting')(crowi));
   router.use('/markdown-setting', require('./markdown-setting')(crowi));
-
   router.use('/app-settings', require('./app-settings')(crowi));
   router.use('/app-settings', require('./app-settings')(crowi));
-
   router.use('/customize-setting', require('./customize-setting')(crowi));
   router.use('/customize-setting', require('./customize-setting')(crowi));
 
 
   router.use('/notification-setting', require('./notification-setting')(crowi));
   router.use('/notification-setting', require('./notification-setting')(crowi));
 
 
   router.use('/users', require('./users')(crowi));
   router.use('/users', require('./users')(crowi));
-
   router.use('/user-groups', require('./user-group')(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('/user-group-relations', require('./user-group-relation')(crowi));
 
 
   router.use('/mongo', require('./mongo')(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('/statistics', require('./statistics')(crowi));
 
 
   router.use('/security-setting', require('./security-setting')(crowi));
   router.use('/security-setting', require('./security-setting')(crowi));

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

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

@@ -73,6 +73,7 @@ module.exports = (crowi) => {
     User,
     User,
     Page,
     Page,
     ExternalAccount,
     ExternalAccount,
+    UserGroupRelation,
   } = crowi.models;
   } = crowi.models;
 
 
   const { ApiV3FormValidator } = crowi.middlewares;
   const { ApiV3FormValidator } = crowi.middlewares;
@@ -455,6 +456,7 @@ module.exports = (crowi) => {
 
 
     try {
     try {
       const userData = await User.findById(id);
       const userData = await User.findById(id);
+      await UserGroupRelation.remove({ relatedUser: userData });
       await userData.statusDelete();
       await userData.statusDelete();
       await ExternalAccount.remove({ user: userData });
       await ExternalAccount.remove({ user: userData });
       await Page.removeByPath(`/user/${userData.username}`);
       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('/admin/export/:fileName'             , loginRequiredStrictly , adminRequired ,admin.export.download);
 
 
   app.get('/me'                       , loginRequiredStrictly , me.index);
   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
   // external-accounts
   app.get('/me/external-accounts'                         , loginRequiredStrictly , me.externalAccounts.list);
   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('/:id([0-9a-z]{24})'       , loginRequired , page.redirector);
   app.get('/_r/:id([0-9a-z]{24})'    , loginRequired , page.redirector); // alias
   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 loginPassportGoogleCallback = async(req, res, next) => {
+    const globalLang = crowi.configManager.getConfig('crowi', 'app:globalLang');
+
     const providerId = 'google';
     const providerId = 'google';
     const strategyName = 'google';
     const strategyName = 'google';
 
 
@@ -247,10 +249,24 @@ module.exports = function(crowi, app) {
       return loginFailureHandler(req, res);
       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 = {
     const userInfo = {
       id: response.id,
       id: response.id,
       username: response.displayName,
       username: response.displayName,
-      name: `${response.name.givenName} ${response.name.familyName}`,
+      name,
     };
     };
 
 
     // Emails are not empty if it exists
     // Emails are not empty if it exists

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

@@ -49,10 +49,7 @@
  */
  */
 
 
 module.exports = function(crowi, app) {
 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 models = crowi.models;
-  const User = models.User;
   const UserGroupRelation = models.UserGroupRelation;
   const UserGroupRelation = models.UserGroupRelation;
   const ExternalAccount = models.ExternalAccount;
   const ExternalAccount = models.ExternalAccount;
   const ApiResponse = require('../util/apiResponse');
   const ApiResponse = require('../util/apiResponse');
@@ -104,90 +101,7 @@ module.exports = function(crowi, app) {
   };
   };
 
 
   actions.index = function(req, res) {
   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 = {};
   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) {
   actions.updates = function(req, res) {
     res.render('me/update', {
     res.render('me/update', {
     });
     });

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

@@ -9,7 +9,7 @@
 {% endblock %}
 {% endblock %}
 
 
 {% block content_main %}
 {% block content_main %}
-<div class="content-main">
+<div class="content-main" id="personal-setting">
 
 
   <ul class="nav nav-tabs mb-4" role="tablist">
   <ul class="nav nav-tabs mb-4" role="tablist">
     <li class="nav-item">
     <li class="nav-item">
@@ -216,21 +216,41 @@
               </div>
               </div>
             </div>
             </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>
           </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>
       </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 %}

+ 19 - 0
yarn.lock

@@ -2565,6 +2565,11 @@ astral-regex@^1.0.0:
   version "1.0.0"
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/astral-regex/-/astral-regex-1.0.0.tgz#6c8c3fb827dd43ee3918f27b82782ab7658a6fd9"
   resolved "https://registry.yarnpkg.com/astral-regex/-/astral-regex-1.0.0.tgz#6c8c3fb827dd43ee3918f27b82782ab7658a6fd9"
 
 
+async-canvas-to-blob@^1.0.3:
+  version "1.0.3"
+  resolved "https://registry.yarnpkg.com/async-canvas-to-blob/-/async-canvas-to-blob-1.0.3.tgz#dbea3ecdca99ecdf6d0340d645dc5342b5032be6"
+  integrity sha512-jXuowR9cJC9TzAyGv4sUh6ilOKuGUvjzJ1GAZMwgaa+q0rXO+SFVyo7GUUCp89mJ/OEVYlAT/gIx3Tlv0fChRw==
+
 async-each-series@0.1.1:
 async-each-series@0.1.1:
   version "0.1.1"
   version "0.1.1"
   resolved "https://registry.yarnpkg.com/async-each-series/-/async-each-series-0.1.1.tgz#7617c1917401fd8ca4a28aadce3dbae98afeb432"
   resolved "https://registry.yarnpkg.com/async-each-series/-/async-each-series-0.1.1.tgz#7617c1917401fd8ca4a28aadce3dbae98afeb432"
@@ -3766,6 +3771,11 @@ clone@^2.1.0:
   version "2.1.1"
   version "2.1.1"
   resolved "https://registry.yarnpkg.com/clone/-/clone-2.1.1.tgz#d217d1e961118e3ac9a4b8bba3285553bf647cdb"
   resolved "https://registry.yarnpkg.com/clone/-/clone-2.1.1.tgz#d217d1e961118e3ac9a4b8bba3285553bf647cdb"
 
 
+clsx@^1.0.4:
+  version "1.0.4"
+  resolved "https://registry.yarnpkg.com/clsx/-/clsx-1.0.4.tgz#0c0171f6d5cb2fe83848463c15fcc26b4df8c2ec"
+  integrity sha512-1mQ557MIZTrL/140j+JVdRM6e31/OA4vTYxXgqIIZlndyfjHpyawKZia1Im05Vp9BWmImkcNrNtFYQMyFcgJDg==
+
 co@^4.6.0:
 co@^4.6.0:
   version "4.6.0"
   version "4.6.0"
   resolved "https://registry.yarnpkg.com/co/-/co-4.6.0.tgz#6ea6bdf3d853ae54ccb8e47bfa0bf3f9031fb184"
   resolved "https://registry.yarnpkg.com/co/-/co-4.6.0.tgz#6ea6bdf3d853ae54ccb8e47bfa0bf3f9031fb184"
@@ -11749,6 +11759,15 @@ react-input-autosize@^2.2.2:
   dependencies:
   dependencies:
     prop-types "^15.5.8"
     prop-types "^15.5.8"
 
 
+react-image-crop@^8.3.0:
+  version "8.3.0"
+  resolved "https://registry.yarnpkg.com/react-image-crop/-/react-image-crop-8.3.0.tgz#a0642dd3daafd77f142bac01887628cb967876b7"
+  integrity sha512-iC6Soqkf588WvEHc4EpKWNaiw4YQe0UbXziBpC8KRPKyaccakmWf7MewDFnYiPfPNEWxj96S390q7BUJz8LGZg==
+  dependencies:
+    clsx "^1.0.4"
+    core-js "^3.2.1"
+    prop-types "^15.7.2"
+
 react-is@^16.12.0:
 react-is@^16.12.0:
   version "16.12.0"
   version "16.12.0"
   resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.12.0.tgz#2cc0fe0fba742d97fd527c42a13bec4eeb06241c"
   resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.12.0.tgz#2cc0fe0fba742d97fd527c42a13bec4eeb06241c"