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

Merge remote-tracking branch 'origin/master' into support/apply-nextjs-2

Yuki Takei 3 лет назад
Родитель
Сommit
85a0748c0f

+ 2 - 5
packages/app/src/client/app.jsx

@@ -10,7 +10,6 @@ import { Provider } from 'unstated';
 import ContextExtractor from '~/client/services/ContextExtractor';
 import EditorContainer from '~/client/services/EditorContainer';
 import PageContainer from '~/client/services/PageContainer';
-import PersonalContainer from '~/client/services/PersonalContainer';
 import IdenticalPathPage from '~/components/IdenticalPathPage';
 import PrivateLegacyPages from '~/components/PrivateLegacyPages';
 import loggerFactory from '~/utils/logger';
@@ -57,9 +56,8 @@ const socketIoContainer = appContainer.getContainer('SocketIoContainer');
 // create unstated container instance
 const pageContainer = new PageContainer(appContainer);
 const editorContainer = new EditorContainer(appContainer);
-const personalContainer = new PersonalContainer(appContainer);
 const injectableContainers = [
-  appContainer, socketIoContainer, pageContainer, editorContainer, personalContainer,
+  appContainer, socketIoContainer, pageContainer, editorContainer,
 ];
 
 logger.info('unstated containers have been initialized');
@@ -95,8 +93,7 @@ Object.assign(componentMappings, {
 
   'page-timeline': <PageTimeline />,
 
-  'personal-setting': <PersonalSettings crowi={personalContainer} />,
-
+  'personal-setting': <PersonalSettings />,
   'my-drafts': <MyDraftList />,
 
   'grw-fab-container': <Fab />,

+ 0 - 186
packages/app/src/client/services/PersonalContainer.js

@@ -1,186 +0,0 @@
-import { Container } from 'unstated';
-
-import loggerFactory from '~/utils/logger';
-
-import { apiPost } from '../util/apiv1-client';
-import { apiv3Get, apiv3Put } from '../util/apiv3-client';
-
-// eslint-disable-next-line no-unused-vars
-const logger = loggerFactory('growi:services:PersonalContainer');
-
-/**
- * Service container for personal settings page (PersonalSettings.jsx)
- * @extends {Container} unstated Container
- */
-export default class PersonalContainer extends Container {
-
-  constructor(appContainer) {
-    super();
-
-    this.appContainer = appContainer;
-
-    this.state = {
-      retrieveError: null,
-      name: '',
-      email: '',
-      registrationWhiteList: this.appContainer.getConfig().registrationWhiteList,
-      isEmailPublished: false,
-      lang: 'en_US',
-      isGravatarEnabled: false,
-      externalAccounts: [],
-      apiToken: '',
-      slackMemberId: '',
-    };
-
-  }
-
-  /**
-   * Workaround for the mangling in production build to break constructor.name
-   */
-  static getClassName() {
-    return 'PersonalContainer';
-  }
-
-  /**
-   * retrieve personal data
-   */
-  async retrievePersonalData() {
-    try {
-      const response = await apiv3Get('/personal-setting/');
-      const { currentUser } = response.data;
-      this.setState({
-        name: currentUser.name,
-        email: currentUser.email,
-        isEmailPublished: currentUser.isEmailPublished,
-        lang: currentUser.lang,
-        isGravatarEnabled: currentUser.isGravatarEnabled,
-        apiToken: currentUser.apiToken,
-        slackMemberId: currentUser.slackMemberId,
-      });
-    }
-    catch (err) {
-      this.setState({ retrieveError: err });
-      logger.error(err);
-      throw new Error('Failed to fetch personal data');
-    }
-  }
-
-  /**
-   * retrieve external accounts that linked me
-   */
-  async retrieveExternalAccounts() {
-    try {
-      const response = await apiv3Get('/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 Slack Member ID
-   */
-  changeSlackMemberId(inputValue) {
-    this.setState({ slackMemberId: 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 apiv3Put('/personal-setting/', {
-        name: this.state.name,
-        email: this.state.email,
-        isEmailPublished: this.state.isEmailPublished,
-        lang: this.state.lang,
-        slackMemberId: this.state.slackMemberId,
-      });
-      const { updatedUser } = response.data;
-
-      this.setState({
-        name: updatedUser.name,
-        email: updatedUser.email,
-        isEmailPublished: updatedUser.isEmailPublished,
-        lang: updatedUser.lang,
-        slackMemberId: updatedUser.slackMemberId,
-      });
-    }
-    catch (err) {
-      this.setState({ retrieveError: err });
-      logger.error(err);
-      throw new Error('Failed to update personal data');
-    }
-  }
-
-  /**
-   * Associate LDAP account
-   */
-  async associateLdapAccount(account) {
-    try {
-      await apiv3Put('/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 apiv3Put('/personal-setting/disassociate-ldap', account);
-    }
-    catch (err) {
-      this.setState({ retrieveError: err });
-      logger.error(err);
-      throw new Error('Failed to disassociate ldap account');
-    }
-  }
-
-}

+ 2 - 0
packages/app/src/components/Admin/Security/LdapAuthTest.jsx

@@ -97,6 +97,7 @@ class LdapAuthTest extends React.Component {
               name="username"
               value={this.props.username}
               onChange={(e) => { this.props.onChangeUsername(e.target.value) }}
+              autoComplete="off"
             />
           </div>
         </div>
@@ -109,6 +110,7 @@ class LdapAuthTest extends React.Component {
               name="password"
               value={this.props.password}
               onChange={(e) => { this.props.onChangePassword(e.target.value) }}
+              autoComplete="off"
             />
           </div>
         </div>

+ 2 - 4
packages/app/src/components/Admin/SlackIntegration/WithProxyAccordions.jsx

@@ -9,7 +9,7 @@ import { Tooltip } from 'reactstrap';
 
 import AppContainer from '~/client/services/AppContainer';
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
-import { apiv3Put } from '~/client/util/apiv3-client';
+import { apiv3Put, apiv3Post } from '~/client/util/apiv3-client';
 import loggerFactory from '~/utils/logger';
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
@@ -234,7 +234,7 @@ const GeneratingTokensAndRegisteringProxyServiceProcess = withUnstatedContainers
 }, [AppContainer]);
 
 const TestProcess = ({
-  apiv3Post, slackAppIntegrationId, onSubmitForm, onSubmitFormFailed, isLatestConnectionSuccess,
+  slackAppIntegrationId, onSubmitForm, onSubmitFormFailed, isLatestConnectionSuccess,
 }) => {
 
   const { t } = useTranslation();
@@ -353,7 +353,6 @@ const WithProxyAccordions = (props) => {
     '④': {
       title: 'test_connection',
       content: <TestProcess
-        apiv3Post={props.apiv3Post}
         slackAppIntegrationId={props.slackAppIntegrationId}
         onSubmitForm={submitForm}
         onSubmitFormFailed={submitFormFailed}
@@ -397,7 +396,6 @@ const WithProxyAccordions = (props) => {
     '⑥': {
       title: 'test_connection',
       content: <TestProcess
-        apiv3Post={props.apiv3Post}
         slackAppIntegrationId={props.slackAppIntegrationId}
         onSubmitForm={submitForm}
         onSubmitFormFailed={submitFormFailed}

+ 0 - 116
packages/app/src/components/Me/ApiSettings.jsx

@@ -1,116 +0,0 @@
-
-import React from 'react';
-
-import PropTypes from 'prop-types';
-import { useTranslation } from 'next-i18next';
-
-import AppContainer from '~/client/services/AppContainer';
-import PersonalContainer from '~/client/services/PersonalContainer';
-import { toastSuccess, toastError } from '~/client/util/apiNotification';
-import { apiv3Put } from '~/client/util/apiv3-client';
-
-import { withUnstatedContainers } from '../UnstatedUtils';
-
-
-class ApiSettings extends React.Component {
-
-  constructor(appContainer) {
-    super();
-
-    this.onClickSubmit = this.onClickSubmit.bind(this);
-  }
-
-  async onClickSubmit() {
-    const { t, appContainer, personalContainer } = this.props;
-
-    try {
-      await apiv3Put('/personal-setting/api-token');
-
-      await personalContainer.retrievePersonalData();
-      toastSuccess(t('toaster.update_successed', { target: t('page_me_apitoken.api_token') }));
-    }
-    catch (err) {
-      toastError(err);
-    }
-
-  }
-
-  render() {
-    const { t, personalContainer } = this.props;
-    return (
-      <React.Fragment>
-
-        <h2 className="border-bottom my-4">{ t('API Token Settings') }</h2>
-
-        <div className="row mb-3">
-          <label htmlFor="apiToken" className="col-md-3 text-md-right">{t('Current API Token')}</label>
-          <div className="col-md-6">
-            {personalContainer.state.apiToken != null
-              ? (
-                <input
-                  data-testid="grw-api-settings-input"
-                  data-hide-in-vrt
-                  className="form-control"
-                  type="text"
-                  name="apiToken"
-                  value={personalContainer.state.apiToken}
-                  readOnly
-                />
-              )
-              : (
-                <p>
-                  { t('page_me_apitoken.notice.apitoken_issued') }
-                </p>
-              )}
-          </div>
-        </div>
-
-
-        <div className="row">
-          <div className="offset-lg-2 col-lg-7">
-
-            <p className="alert alert-warning">
-              { t('page_me_apitoken.notice.update_token1') }<br />
-              { t('page_me_apitoken.notice.update_token2') }
-            </p>
-
-          </div>
-        </div>
-
-        <div className="row my-3">
-          <div className="offset-4 col-5">
-            <button
-              data-testid="grw-api-settings-update-button"
-              type="button"
-              className="btn btn-primary text-nowrap"
-              onClick={this.onClickSubmit}
-            >
-              {t('Update API Token')}
-            </button>
-          </div>
-        </div>
-
-      </React.Fragment>
-
-    );
-  }
-
-}
-
-ApiSettings.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  personalContainer: PropTypes.instanceOf(PersonalContainer).isRequired,
-};
-
-const ApiSettingsWrapperFC = (props) => {
-  const { t } = useTranslation();
-  return <ApiSettings t={t} {...props} />;
-};
-
-/**
- * Wrapper component for using unstated
- */
-const ApiSettingsWrapper = withUnstatedContainers(ApiSettingsWrapperFC, [AppContainer, PersonalContainer]);
-
-export default ApiSettingsWrapper;

+ 91 - 0
packages/app/src/components/Me/ApiSettings.tsx

@@ -0,0 +1,91 @@
+import React, { useCallback } from 'react';
+
+import { useTranslation } from 'next-i18next';
+
+import { toastSuccess, toastError } from '~/client/util/apiNotification';
+import { apiv3Put } from '~/client/util/apiv3-client';
+import { useSWRxPersonalSettings, usePersonalSettings } from '~/stores/personal-settings';
+
+
+const ApiSettings = React.memo((): JSX.Element => {
+
+  const { t } = useTranslation();
+  const { mutate: mutateDatabaseData } = useSWRxPersonalSettings();
+  const { data: personalSettingsData } = usePersonalSettings();
+
+  const submitHandler = useCallback(async() => {
+
+    try {
+      await apiv3Put('/personal-setting/api-token');
+      mutateDatabaseData();
+
+      toastSuccess(t('toaster.update_successed', { target: t('page_me_apitoken.api_token') }));
+    }
+    catch (err) {
+      toastError(err);
+    }
+
+  }, [mutateDatabaseData, t]);
+
+  return (
+    <>
+
+      <h2 className="border-bottom my-4">{ t('API Token Settings') }</h2>
+
+      <div className="row mb-3">
+        <label htmlFor="apiToken" className="col-md-3 text-md-right">{t('Current API Token')}</label>
+        <div className="col-md-6">
+          {personalSettingsData?.apiToken != null
+            ? (
+              <input
+                data-testid="grw-api-settings-input"
+                data-hide-in-vrt
+                className="form-control"
+                type="text"
+                name="apiToken"
+                value={personalSettingsData.apiToken}
+                readOnly
+              />
+            )
+            : (
+              <p>
+                { t('page_me_apitoken.notice.apitoken_issued') }
+              </p>
+            )}
+        </div>
+      </div>
+
+
+      <div className="row">
+        <div className="offset-lg-2 col-lg-7">
+
+          <p className="alert alert-warning">
+            { t('page_me_apitoken.notice.update_token1') }<br />
+            { t('page_me_apitoken.notice.update_token2') }
+          </p>
+
+        </div>
+      </div>
+
+      <div className="row my-3">
+        <div className="offset-4 col-5">
+          <button
+            data-testid="grw-api-settings-update-button"
+            type="button"
+            className="btn btn-primary text-nowrap"
+            onClick={submitHandler}
+          >
+            {t('Update API Token')}
+          </button>
+        </div>
+      </div>
+
+    </>
+
+  );
+
+});
+
+ApiSettings.displayName = 'ApiSettings';
+
+export default ApiSettings;

+ 0 - 152
packages/app/src/components/Me/AssociateModal.jsx

@@ -1,152 +0,0 @@
-
-import React from 'react';
-
-import PropTypes from 'prop-types';
-import { useTranslation } from 'next-i18next';
-import {
-  Modal,
-  ModalHeader,
-  ModalBody,
-  ModalFooter,
-} from 'reactstrap';
-
-
-import AppContainer from '~/client/services/AppContainer';
-import PersonalContainer from '~/client/services/PersonalContainer';
-import { toastSuccess, toastError } from '~/client/util/apiNotification';
-
-import LdapAuthTest from '../Admin/Security/LdapAuthTest';
-import { withUnstatedContainers } from '../UnstatedUtils';
-
-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} size="lg" data-testid="grw-associate-modal">
-        <ModalHeader className="bg-primary text-light" toggle={this.props.onClose}>
-          { t('admin:user_management.create_external_account') }
-        </ModalHeader>
-        <ModalBody>
-          <ul className="nav nav-tabs passport-settings mb-2" role="tablist">
-            <li className="nav-item active">
-              <a href="#passport-ldap" className="nav-link active" data-toggle="tab" role="tab">
-                <i className="fa fa-sitemap"></i> LDAP
-              </a>
-            </li>
-            <li className="nav-item">
-              <a href="#github-tbd" className="nav-link" data-toggle="tab" role="tab">
-                <i className="fa fa-github"></i> (TBD) GitHub
-              </a>
-            </li>
-            <li className="nav-item">
-              <a href="#google-tbd" className="nav-link" data-toggle="tab" role="tab">
-                <i className="fa fa-google"></i> (TBD) Google OAuth
-              </a>
-            </li>
-            <li className="nav-item">
-              <a href="#facebook-tbd" className="nav-link" data-toggle="tab" role="tab">
-                <i className="fa fa-facebook"></i> (TBD) Facebook
-              </a>
-            </li>
-            <li className="nav-item">
-              <a href="#twitter-tbd" className="nav-link" data-toggle="tab" role="tab">
-                <i className="fa fa-twitter"></i> (TBD) Twitter
-              </a>
-            </li>
-          </ul>
-          <div className="tab-content">
-            <div id="passport-ldap" className="tab-pane active">
-              <LdapAuthTest
-                username={this.state.username}
-                password={this.state.password}
-                onChangeUsername={this.onChangeUsername}
-                onChangePassword={this.onChangePassword}
-              />
-            </div>
-            <div id="github-tbd" className="tab-pane" role="tabpanel">TBD</div>
-            <div id="google-tbd" className="tab-pane" role="tabpanel">TBD</div>
-            <div id="facebook-tbd" className="tab-pane" role="tabpanel">TBD</div>
-            <div id="twitter-tbd" className="tab-pane" role="tabpanel">TBD</div>
-          </div>
-        </ModalBody>
-        <ModalFooter className="border-top-0">
-          <button type="button" className="btn btn-primary mt-3" onClick={this.onClickAddBtn}>
-            <i className="fa fa-plus-circle" aria-hidden="true"></i>
-            {t('add')}
-          </button>
-        </ModalFooter>
-      </Modal>
-    );
-  }
-
-}
-
-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,
-};
-
-const AssociateModalWrapperFC = (props) => {
-  const { t } = useTranslation();
-  return <AssociateModal t={t} {...props} />;
-};
-
-/**
- * Wrapper component for using unstated
- */
-const AssociateModalWrapper = withUnstatedContainers(AssociateModalWrapperFC, [AppContainer, PersonalContainer]);
-
-export default AssociateModalWrapper;

+ 111 - 0
packages/app/src/components/Me/AssociateModal.tsx

@@ -0,0 +1,111 @@
+import React, { useState, useCallback } from 'react';
+
+import { useTranslation } from 'next-i18next';
+import {
+  Modal,
+  ModalHeader,
+  ModalBody,
+  ModalFooter,
+} from 'reactstrap';
+
+import { toastSuccess, toastError } from '~/client/util/apiNotification';
+import { usePersonalSettings, useSWRxPersonalExternalAccounts } from '~/stores/personal-settings';
+
+import LdapAuthTest from '../Admin/Security/LdapAuthTest';
+
+type Props = {
+  isOpen: boolean,
+  onClose: () => void,
+}
+
+const AssociateModal = (props: Props): JSX.Element => {
+  const { t } = useTranslation();
+  const { mutate: mutatePersonalExternalAccounts } = useSWRxPersonalExternalAccounts();
+  const { associateLdapAccount } = usePersonalSettings();
+  const { isOpen, onClose } = props;
+
+  const [username, setUsername] = useState('');
+  const [password, setPassword] = useState('');
+
+  const closeModalHandler = useCallback(() => {
+    onClose();
+    setUsername('');
+    setPassword('');
+  }, [onClose]);
+
+
+  const clickAddLdapAccountHandler = useCallback(async() => {
+    try {
+      await associateLdapAccount({ username, password });
+      mutatePersonalExternalAccounts();
+
+      closeModalHandler();
+      toastSuccess(t('security_setting.updated_general_security_setting'));
+    }
+    catch (err) {
+      toastError(err);
+    }
+
+  }, [associateLdapAccount, closeModalHandler, mutatePersonalExternalAccounts, password, t, username]);
+
+
+  return (
+    <Modal isOpen={isOpen} toggle={closeModalHandler} size="lg" data-testid="grw-associate-modal">
+      <ModalHeader className="bg-primary text-light" toggle={onClose}>
+        { t('admin:user_management.create_external_account') }
+      </ModalHeader>
+      <ModalBody>
+        <ul className="nav nav-tabs passport-settings mb-2" role="tablist">
+          <li className="nav-item active">
+            <a href="#passport-ldap" className="nav-link active" data-toggle="tab" role="tab">
+              <i className="fa fa-sitemap"></i> LDAP
+            </a>
+          </li>
+          <li className="nav-item">
+            <a href="#github-tbd" className="nav-link" data-toggle="tab" role="tab">
+              <i className="fa fa-github"></i> (TBD) GitHub
+            </a>
+          </li>
+          <li className="nav-item">
+            <a href="#google-tbd" className="nav-link" data-toggle="tab" role="tab">
+              <i className="fa fa-google"></i> (TBD) Google OAuth
+            </a>
+          </li>
+          <li className="nav-item">
+            <a href="#facebook-tbd" className="nav-link" data-toggle="tab" role="tab">
+              <i className="fa fa-facebook"></i> (TBD) Facebook
+            </a>
+          </li>
+          <li className="nav-item">
+            <a href="#twitter-tbd" className="nav-link" data-toggle="tab" role="tab">
+              <i className="fa fa-twitter"></i> (TBD) Twitter
+            </a>
+          </li>
+        </ul>
+        <div className="tab-content">
+          <div id="passport-ldap" className="tab-pane active">
+            <LdapAuthTest
+              username={username}
+              password={password}
+              onChangeUsername={username => setUsername(username)}
+              onChangePassword={password => setPassword(password)}
+            />
+          </div>
+          <div id="github-tbd" className="tab-pane" role="tabpanel">TBD</div>
+          <div id="google-tbd" className="tab-pane" role="tabpanel">TBD</div>
+          <div id="facebook-tbd" className="tab-pane" role="tabpanel">TBD</div>
+          <div id="twitter-tbd" className="tab-pane" role="tabpanel">TBD</div>
+        </div>
+      </ModalBody>
+      <ModalFooter className="border-top-0">
+        <button type="button" className="btn btn-primary mt-3" onClick={clickAddLdapAccountHandler}>
+          <i className="fa fa-plus-circle" aria-hidden="true"></i>
+          {t('add')}
+        </button>
+      </ModalFooter>
+    </Modal>
+  );
+};
+
+
+export default AssociateModal;

+ 0 - 181
packages/app/src/components/Me/BasicInfoSettings.jsx

@@ -1,181 +0,0 @@
-
-import React, { Fragment } from 'react';
-
-import PropTypes from 'prop-types';
-import { useTranslation } from 'next-i18next';
-
-import PersonalContainer from '~/client/services/PersonalContainer';
-import { toastSuccess, toastError } from '~/client/util/apiNotification';
-import { localeMetadatas } from '~/client/util/i18n';
-
-import { withUnstatedContainers } from '../UnstatedUtils';
-
-
-class BasicInfoSettings extends React.Component {
-
-  constructor() {
-    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="form-group row">
-          <label htmlFor="userForm[name]" className="text-left text-md-right col-md-3 col-form-label">{t('Name')}</label>
-          <div className="col-md-6">
-            <input
-              className="form-control"
-              type="text"
-              name="userForm[name]"
-              defaultValue={personalContainer.state.name}
-              onChange={(e) => { personalContainer.changeName(e.target.value) }}
-            />
-          </div>
-        </div>
-
-        <div className="form-group row">
-          <label htmlFor="userForm[email]" className="text-left text-md-right col-md-3 col-form-label">{t('Email')}</label>
-          <div className="col-md-6">
-            <input
-              className="form-control"
-              type="text"
-              name="userForm[email]"
-              defaultValue={personalContainer.state.email}
-              onChange={(e) => { personalContainer.changeEmail(e.target.value) }}
-            />
-            {registrationWhiteList.length !== 0 && (
-              <div className="form-text text-muted">
-                {t('page_register.form_help.email')}
-                <ul>
-                  {registrationWhiteList.map(data => <li key={data}><code>{data}</code></li>)}
-                </ul>
-              </div>
-            )}
-          </div>
-        </div>
-
-        <div className="form-group row">
-          <label className="text-left text-md-right col-md-3 col-form-label">{t('Disclose E-mail')}</label>
-          <div className="col-md-6">
-            <div className="custom-control custom-radio custom-control-inline">
-              <input
-                type="radio"
-                id="radioEmailShow"
-                className="custom-control-input"
-                name="userForm[isEmailPublished]"
-                checked={personalContainer.state.isEmailPublished}
-                onChange={() => { personalContainer.changeIsEmailPublished(true) }}
-              />
-              <label className="custom-control-label" htmlFor="radioEmailShow">{t('Show')}</label>
-            </div>
-            <div className="custom-control custom-radio custom-control-inline">
-              <input
-                type="radio"
-                id="radioEmailHide"
-                className="custom-control-input"
-                name="userForm[isEmailPublished]"
-                checked={!personalContainer.state.isEmailPublished}
-                onChange={() => { personalContainer.changeIsEmailPublished(false) }}
-              />
-              <label className="custom-control-label" htmlFor="radioEmailHide">{t('Hide')}</label>
-            </div>
-          </div>
-        </div>
-
-        <div className="form-group row">
-          <label className="text-left text-md-right col-md-3 col-form-label">{t('Language')}</label>
-          <div className="col-md-6">
-            {
-              localeMetadatas.map(meta => (
-                <div key={meta.id} className="custom-control custom-radio custom-control-inline">
-                  <input
-                    type="radio"
-                    id={`radioLang${meta.id}`}
-                    className="custom-control-input"
-                    name="userForm[lang]"
-                    checked={personalContainer.state.lang === meta.id}
-                    onChange={() => { personalContainer.changeLang(meta.id) }}
-                  />
-                  <label className="custom-control-label" htmlFor={`radioLang${meta.id}`}>{meta.displayName}</label>
-                </div>
-              ))
-            }
-          </div>
-        </div>
-        <div className="form-group row">
-          <label htmlFor="userForm[slackMemberId]" className="text-left text-md-right col-md-3 col-form-label">{t('Slack Member ID')}</label>
-          <div className="col-md-6">
-            <input
-              className="form-control"
-              type="text"
-              key={personalContainer.state.slackMemberId}
-              name="userForm[slackMemberId]"
-              defaultValue={personalContainer.state.slackMemberId}
-              onChange={(e) => { personalContainer.changeSlackMemberId(e.target.value) }}
-            />
-          </div>
-        </div>
-
-        <div className="row my-3">
-          <div className="offset-4 col-5">
-            <button
-              data-testid="grw-besic-info-settings-update-button"
-              type="button"
-              className="btn btn-primary"
-              onClick={this.onClickSubmit}
-              disabled={personalContainer.state.retrieveError != null}
-            >
-              {t('Update')}
-            </button>
-          </div>
-        </div>
-
-      </Fragment>
-    );
-  }
-
-}
-
-BasicInfoSettings.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-  personalContainer: PropTypes.instanceOf(PersonalContainer).isRequired,
-};
-
-const BasicInfoSettingsWrapperFC = (props) => {
-  const { t } = useTranslation();
-  return <BasicInfoSettings t={t} {...props} />;
-};
-
-/**
- * Wrapper component for using unstated
- */
-const BasicInfoSettingsWrapper = withUnstatedContainers(BasicInfoSettingsWrapperFC, [PersonalContainer]);
-
-export default BasicInfoSettingsWrapper;

+ 171 - 0
packages/app/src/components/Me/BasicInfoSettings.tsx

@@ -0,0 +1,171 @@
+import React from 'react';
+
+import { useTranslation } from 'next-i18next';
+
+import AppContainer from '~/client/services/AppContainer';
+import { toastSuccess, toastError } from '~/client/util/apiNotification';
+import { localeMetadatas } from '~/client/util/i18n';
+import { usePersonalSettings } from '~/stores/personal-settings';
+
+import { withUnstatedContainers } from '../UnstatedUtils';
+
+type Props = {
+  appContainer: AppContainer,
+}
+
+const BasicInfoSettings = (props: Props) => {
+  const { t } = useTranslation();
+  const { appContainer } = props;
+
+  const {
+    data: personalSettingsInfo, mutate: mutatePersonalSettings, sync, updateBasicInfo, error,
+  } = usePersonalSettings();
+
+
+  const submitHandler = async() => {
+
+    try {
+      await updateBasicInfo();
+      sync();
+      toastSuccess(t('toaster.update_successed', { target: t('Basic Info') }));
+    }
+    catch (err) {
+      toastError(err);
+    }
+  };
+
+
+  const { registrationWhiteList } = appContainer.getConfig();
+
+  const changePersonalSettingsHandler = (updateData) => {
+    if (personalSettingsInfo == null) {
+      return;
+    }
+    mutatePersonalSettings({ ...personalSettingsInfo, ...updateData });
+  };
+
+
+  return (
+    <>
+
+      <div className="form-group row">
+        <label htmlFor="userForm[name]" className="text-left text-md-right col-md-3 col-form-label">{t('Name')}</label>
+        <div className="col-md-6">
+          <input
+            className="form-control"
+            type="text"
+            name="userForm[name]"
+            defaultValue={personalSettingsInfo?.name || ''}
+            onChange={e => changePersonalSettingsHandler({ name: e.target.value })}
+          />
+        </div>
+      </div>
+
+      <div className="form-group row">
+        <label htmlFor="userForm[email]" className="text-left text-md-right col-md-3 col-form-label">{t('Email')}</label>
+        <div className="col-md-6">
+          <input
+            className="form-control"
+            type="text"
+            name="userForm[email]"
+            defaultValue={personalSettingsInfo?.email || ''}
+            onChange={e => changePersonalSettingsHandler({ email: e.target.value })}
+          />
+          {registrationWhiteList.length !== 0 && (
+            <div className="form-text text-muted">
+              {t('page_register.form_help.email')}
+              <ul>
+                {registrationWhiteList.map(data => <li key={data}><code>{data}</code></li>)}
+              </ul>
+            </div>
+          )}
+        </div>
+      </div>
+
+      <div className="form-group row">
+        <label className="text-left text-md-right col-md-3 col-form-label">{t('Disclose E-mail')}</label>
+        <div className="col-md-6">
+          <div className="custom-control custom-radio custom-control-inline">
+            <input
+              type="radio"
+              id="radioEmailShow"
+              className="custom-control-input"
+              name="userForm[isEmailPublished]"
+              checked={personalSettingsInfo?.isEmailPublished === true}
+              onChange={() => changePersonalSettingsHandler({ isEmailPublished: true })}
+            />
+            <label className="custom-control-label" htmlFor="radioEmailShow">{t('Show')}</label>
+          </div>
+          <div className="custom-control custom-radio custom-control-inline">
+            <input
+              type="radio"
+              id="radioEmailHide"
+              className="custom-control-input"
+              name="userForm[isEmailPublished]"
+              checked={personalSettingsInfo?.isEmailPublished === false}
+              onChange={() => changePersonalSettingsHandler({ isEmailPublished: false })}
+            />
+            <label className="custom-control-label" htmlFor="radioEmailHide">{t('Hide')}</label>
+          </div>
+        </div>
+      </div>
+
+      <div className="form-group row">
+        <label className="text-left text-md-right col-md-3 col-form-label">{t('Language')}</label>
+        <div className="col-md-6">
+          {
+            localeMetadatas.map(meta => (
+              <div key={meta.id} className="custom-control custom-radio custom-control-inline">
+                <input
+                  type="radio"
+                  id={`radioLang${meta.id}`}
+                  className="custom-control-input"
+                  name="userForm[lang]"
+                  checked={personalSettingsInfo?.lang === meta.id}
+                  onChange={() => changePersonalSettingsHandler({ lang: meta.id })}
+                />
+                <label className="custom-control-label" htmlFor={`radioLang${meta.id}`}>{meta.displayName}</label>
+              </div>
+            ))
+          }
+        </div>
+      </div>
+      <div className="form-group row">
+        <label htmlFor="userForm[slackMemberId]" className="text-left text-md-right col-md-3 col-form-label">{t('Slack Member ID')}</label>
+        <div className="col-md-6">
+          <input
+            className="form-control"
+            type="text"
+            key="slackMemberId"
+            name="userForm[slackMemberId]"
+            defaultValue={personalSettingsInfo?.slackMemberId || ''}
+            onChange={e => changePersonalSettingsHandler({ slackMemberId: e.target.value })}
+          />
+        </div>
+      </div>
+
+      <div className="row my-3">
+        <div className="offset-4 col-5">
+          <button
+            data-testid="grw-besic-info-settings-update-button"
+            type="button"
+            className="btn btn-primary"
+            onClick={submitHandler}
+            disabled={error != null}
+          >
+            {t('Update')}
+          </button>
+        </div>
+      </div>
+
+    </>
+  );
+};
+
+
+/**
+ * Wrapper component for using unstated
+ */
+const BasicInfoSettingsWrapper = withUnstatedContainers(BasicInfoSettings, [AppContainer]);
+
+export default BasicInfoSettingsWrapper;

+ 0 - 98
packages/app/src/components/Me/DisassociateModal.jsx

@@ -1,98 +0,0 @@
-
-import React from 'react';
-
-import PropTypes from 'prop-types';
-import { useTranslation } from 'next-i18next';
-import {
-  Modal,
-  ModalHeader,
-  ModalBody,
-  ModalFooter,
-} from 'reactstrap';
-
-import AppContainer from '~/client/services/AppContainer';
-import PersonalContainer from '~/client/services/PersonalContainer';
-import { toastSuccess, toastError } from '~/client/util/apiNotification';
-
-import { withUnstatedContainers } from '../UnstatedUtils';
-
-
-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 text-light" 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-outline-secondary" 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>
-    );
-  }
-
-}
-
-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,
-
-};
-
-const DisassociateModalWrapperFC = (props) => {
-  const { t } = useTranslation();
-  return <DisassociateModal t={t} {...props} />;
-};
-
-/**
- * Wrapper component for using unstated
- */
-const DisassociateModalWrapper = withUnstatedContainers(DisassociateModalWrapperFC, [AppContainer, PersonalContainer]);
-
-
-export default DisassociateModalWrapper;

+ 69 - 0
packages/app/src/components/Me/DisassociateModal.tsx

@@ -0,0 +1,69 @@
+import React, { useCallback } from 'react';
+
+import { useTranslation } from 'next-i18next';
+import {
+  Modal,
+  ModalHeader,
+  ModalBody,
+  ModalFooter,
+} from 'reactstrap';
+
+import { toastSuccess, toastError } from '~/client/util/apiNotification';
+import { IExternalAccount } from '~/interfaces/external-account';
+import { usePersonalSettings, useSWRxPersonalExternalAccounts } from '~/stores/personal-settings';
+
+type Props = {
+  isOpen: boolean,
+  onClose: () => void,
+  accountForDisassociate: IExternalAccount,
+}
+
+
+const DisassociateModal = (props: Props): JSX.Element => {
+
+  const { t } = useTranslation();
+  const { mutate: mutatePersonalExternalAccounts } = useSWRxPersonalExternalAccounts();
+  const { disassociateLdapAccount } = usePersonalSettings();
+
+  const { providerType, accountId } = props.accountForDisassociate;
+
+  const disassociateAccountHandler = useCallback(async() => {
+
+    try {
+      await disassociateLdapAccount({ providerType, accountId });
+      props.onClose();
+      toastSuccess(t('security_setting.updated_general_security_setting'));
+    }
+    catch (err) {
+      toastError(err);
+    }
+
+    if (mutatePersonalExternalAccounts != null) {
+      mutatePersonalExternalAccounts();
+    }
+  }, [accountId, disassociateLdapAccount, mutatePersonalExternalAccounts, props, providerType, t]);
+
+  return (
+    <Modal isOpen={props.isOpen} toggle={props.onClose}>
+      <ModalHeader className="bg-info text-light" toggle={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-outline-secondary" onClick={props.onClose}>
+          { t('Cancel') }
+        </button>
+        <button type="button" className="btn btn-sm btn-danger" onClick={disassociateAccountHandler}>
+          <i className="ti-unlink"></i>
+          { t('Disassociate') }
+        </button>
+      </ModalFooter>
+    </Modal>
+  );
+};
+
+
+export default DisassociateModal;

+ 8 - 18
packages/app/src/components/Me/ExternalAccountLinkedMe.jsx

@@ -1,4 +1,3 @@
-
 import React, { Fragment } from 'react';
 
 import PropTypes from 'prop-types';
@@ -6,8 +5,7 @@ import { useTranslation } from 'next-i18next';
 
 
 import AppContainer from '~/client/services/AppContainer';
-import PersonalContainer from '~/client/services/PersonalContainer';
-import { toastError } from '~/client/util/apiNotification';
+import { useSWRxPersonalExternalAccounts } from '~/stores/personal-settings';
 
 import { withUnstatedContainers } from '../UnstatedUtils';
 
@@ -32,15 +30,6 @@ class ExternalAccountLinkedMe extends React.Component {
     this.closeDisassociateModal = this.closeDisassociateModal.bind(this);
   }
 
-  async componentDidMount() {
-    try {
-      await this.props.personalContainer.retrieveExternalAccounts();
-    }
-    catch (err) {
-      toastError(err);
-    }
-  }
-
   openAssociateModal() {
     this.setState({ isAssociateModalOpen: true });
   }
@@ -65,8 +54,7 @@ class ExternalAccountLinkedMe extends React.Component {
   }
 
   render() {
-    const { t, personalContainer } = this.props;
-    const { externalAccounts } = personalContainer.state;
+    const { t, personalExternalAccounts } = this.props;
 
     return (
       <Fragment>
@@ -95,7 +83,7 @@ class ExternalAccountLinkedMe extends React.Component {
             </tr>
           </thead>
           <tbody>
-            {externalAccounts !== 0 && externalAccounts.map(account => (
+            {personalExternalAccounts != null && personalExternalAccounts.length > 0 && personalExternalAccounts.map(account => (
               <ExternalAccountRow
                 account={account}
                 key={account._id}
@@ -128,17 +116,19 @@ class ExternalAccountLinkedMe extends React.Component {
 ExternalAccountLinkedMe.propTypes = {
   t: PropTypes.func.isRequired, // i18next
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  personalContainer: PropTypes.instanceOf(PersonalContainer).isRequired,
+  personalExternalAccounts: PropTypes.arrayOf(PropTypes.object),
 };
 
 const ExternalAccountLinkedMeWrapperFC = (props) => {
   const { t } = useTranslation();
-  return <ExternalAccountLinkedMe t={t} {...props} />;
+  const { data: personalExternalAccountsData } = useSWRxPersonalExternalAccounts();
+
+  return <ExternalAccountLinkedMe t={t} personalExternalAccounts={personalExternalAccountsData} {...props} />;
 };
 
 /**
  * Wrapper component for using unstated
  */
-const ExternalAccountLinkedMeWrapper = withUnstatedContainers(ExternalAccountLinkedMeWrapperFC, [AppContainer, PersonalContainer]);
+const ExternalAccountLinkedMeWrapper = withUnstatedContainers(ExternalAccountLinkedMeWrapperFC, [AppContainer]);
 
 export default ExternalAccountLinkedMeWrapper;

+ 19 - 17
packages/app/src/components/Me/PasswordSettings.jsx

@@ -1,14 +1,12 @@
-
-import React from 'react';
+import React, { useCallback } from 'react';
 
 import PropTypes from 'prop-types';
 import { useTranslation } from 'next-i18next';
 
-import PersonalContainer from '~/client/services/PersonalContainer';
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
 import { apiv3Get, apiv3Put } from '~/client/util/apiv3-client';
+import { usePersonalSettings } from '~/stores/personal-settings';
 
-import { withUnstatedContainers } from '../UnstatedUtils';
 
 class PasswordSettings extends React.Component {
 
@@ -24,7 +22,7 @@ class PasswordSettings extends React.Component {
       minPasswordLength: null,
     };
 
-    this.onClickSubmit = this.onClickSubmit.bind(this);
+    this.submitHandler = this.submitHandler.bind(this);
     this.onChangeOldPassword = this.onChangeOldPassword.bind(this);
 
   }
@@ -42,8 +40,8 @@ class PasswordSettings extends React.Component {
 
   }
 
-  async onClickSubmit() {
-    const { t, personalContainer } = this.props;
+  async submitHandler() {
+    const { t, onSubmit } = this.props;
     const { oldPassword, newPassword, newPasswordConfirm } = this.state;
 
     try {
@@ -51,7 +49,9 @@ class PasswordSettings extends React.Component {
         oldPassword, newPassword, newPasswordConfirm,
       });
       this.setState({ oldPassword: '', newPassword: '', newPasswordConfirm: '' });
-      await personalContainer.retrievePersonalData();
+      if (onSubmit != null) {
+        onSubmit();
+      }
       toastSuccess(t('toaster.update_successed', { target: t('Password') }));
     }
     catch (err) {
@@ -140,7 +140,7 @@ class PasswordSettings extends React.Component {
               data-testid="grw-password-settings-update-button"
               type="button"
               className="btn btn-primary"
-              onClick={this.onClickSubmit}
+              onClick={this.submitHandler}
               disabled={isIncorrectConfirmPassword}
             >
               {t('Update')}
@@ -155,17 +155,19 @@ class PasswordSettings extends React.Component {
 
 PasswordSettings.propTypes = {
   t: PropTypes.func.isRequired, // i18next
-  personalContainer: PropTypes.instanceOf(PersonalContainer).isRequired,
+  onSubmit: PropTypes.func,
 };
 
 const PasswordSettingsWrapperFC = (props) => {
   const { t } = useTranslation();
-  return <PasswordSettings t={t} {...props} />;
-};
+  const { mutate: mutatePersonalSettings } = usePersonalSettings();
+
+  const submitHandler = useCallback(() => {
+    mutatePersonalSettings();
+  }, [mutatePersonalSettings]);
 
-/**
- * Wrapper component for using unstated
- */
-const PasswordSettingsWrapper = withUnstatedContainers(PasswordSettingsWrapperFC, [PersonalContainer]);
 
-export default PasswordSettingsWrapper;
+  return <PasswordSettings t={t} onSubmit={submitHandler} {...props} />;
+};
+
+export default PasswordSettingsWrapperFC;

+ 3 - 1
packages/app/src/components/PageDeleteModal.tsx

@@ -2,7 +2,7 @@ import React, {
   useState, FC, useMemo, useEffect,
 } from 'react';
 
-import { isTrashPage } from '^/../core/src/utils/page-path-utils';
+import { pagePathUtils } from '@growi/core';
 import { useTranslation } from 'next-i18next';
 import {
   Modal, ModalHeader, ModalBody, ModalFooter,
@@ -21,6 +21,8 @@ import loggerFactory from '~/utils/logger';
 
 import ApiErrorMessageList from './PageManagement/ApiErrorMessageList';
 
+const { isTrashPage } = pagePathUtils;
+
 
 const logger = loggerFactory('growi:cli:PageDeleteModal');
 

+ 10 - 0
packages/app/src/interfaces/external-account.ts

@@ -0,0 +1,10 @@
+import { Ref } from '~/interfaces/common';
+import { IUser } from '~/interfaces/user';
+
+
+export type IExternalAccount<ID = string> = {
+  _id: ID,
+  providerType: string,
+  accountId: string,
+  user: Ref<IUser>,
+}

+ 10 - 6
packages/app/src/interfaces/user.ts

@@ -3,15 +3,19 @@ import { Ref } from './common';
 import { HasObjectId } from './has-object-id';
 
 export type IUser = {
-  name: string;
-  username: string;
-  email: string;
-  password: string;
+  name: string,
+  username: string,
+  email: string,
+  password: string,
   image?: string, // for backward conpatibility
   imageAttachment?: Ref<IAttachment>,
-  imageUrlCached: string;
+  imageUrlCached: string,
   isGravatarEnabled: boolean,
-  admin: boolean;
+  admin: boolean,
+  apiToken?: string,
+  isEmailPublished: boolean,
+  lang: string,
+  slackMemberId?: string,
 }
 
 export type IUserGroupRelation = {

+ 102 - 0
packages/app/src/stores/personal-settings.tsx

@@ -0,0 +1,102 @@
+import useSWR, { SWRResponse } from 'swr';
+
+
+import { IExternalAccount } from '~/interfaces/external-account';
+import { IUser } from '~/interfaces/user';
+import loggerFactory from '~/utils/logger';
+
+import { apiv3Get, apiv3Put } from '../client/util/apiv3-client';
+
+import { useStaticSWR } from './use-static-swr';
+
+const logger = loggerFactory('growi:stores:personal-settings');
+
+
+export const useSWRxPersonalSettings = (): SWRResponse<IUser, Error> => {
+  return useSWR(
+    '/personal-setting',
+    endpoint => apiv3Get(endpoint).then(response => response.data.currentUser),
+  );
+};
+
+export type IPersonalSettingsInfoOption = {
+  sync: () => void,
+  updateBasicInfo: () => Promise<void>,
+  associateLdapAccount: (account: { username: string, password: string }) => Promise<void>,
+  disassociateLdapAccount: (account: { providerType: string, accountId: string }) => Promise<void>,
+}
+
+export const usePersonalSettings = (): SWRResponse<IUser, Error> & IPersonalSettingsInfoOption => {
+  const { data: personalSettingsDataFromDB, mutate: revalidate } = useSWRxPersonalSettings();
+  const key = personalSettingsDataFromDB != null ? 'personalSettingsInfo' : null;
+
+  const swrResult = useStaticSWR<IUser, Error>(key, undefined, { fallbackData: personalSettingsDataFromDB });
+
+  // Sync with database
+  const sync = async(): Promise<void> => {
+    const { mutate } = swrResult;
+    const result = await revalidate();
+    mutate(result);
+  };
+
+  const updateBasicInfo = async(): Promise<void> => {
+    const { data } = swrResult;
+
+    if (data == null) {
+      return;
+    }
+
+    const updateData = {
+      name: data.name,
+      email: data.email,
+      isEmailPublished: data.isEmailPublished,
+      lang: data.lang,
+      slackMemberId: data.slackMemberId,
+    };
+
+    // invoke API
+    try {
+      await apiv3Put('/personal-setting/', updateData);
+    }
+    catch (err) {
+      logger.error(err);
+      throw new Error('Failed to update personal data');
+    }
+  };
+
+
+  const associateLdapAccount = async(account): Promise<void> => {
+    try {
+      await apiv3Put('/personal-setting/associate-ldap', account);
+    }
+    catch (err) {
+      logger.error(err);
+      throw new Error('Failed to associate ldap account');
+    }
+  };
+
+  const disassociateLdapAccount = async(account): Promise<void> => {
+    try {
+      await apiv3Put('/personal-setting/disassociate-ldap', account);
+    }
+    catch (err) {
+      logger.error(err);
+      throw new Error('Failed to disassociate ldap account');
+    }
+  };
+
+  return {
+    ...swrResult,
+    sync,
+    updateBasicInfo,
+    associateLdapAccount,
+    disassociateLdapAccount,
+  };
+};
+
+export const useSWRxPersonalExternalAccounts = (): SWRResponse<IExternalAccount[], Error> => {
+  return useSWR(
+    '/personal-setting/external-accounts',
+    endpoint => apiv3Get(endpoint).then(response => response.data.externalAccounts),
+  );
+};

+ 12 - 10
packages/app/test/cypress/integration/60-home/home.spec.ts

@@ -33,14 +33,12 @@ context('Access User settings', () => {
     });
     // collapse sidebar
     cy.collapseSidebar(true);
-  });
-
-  it('Update settings', () => {
     cy.visit('/me');
-
     // hide fab
     cy.getByTestid('grw-fab-container').invoke('attr', 'style', 'display: none');
+  });
 
+  it('Access User information', () => {
     // User information
     cy.getByTestid('grw-user-settings').should('be.visible');
     cy.screenshot(`${ssPrefix}-user-information-1`);
@@ -50,8 +48,9 @@ context('Access User settings', () => {
 
     cy.get('.toast-close-button').click({ multiple: true }); // close toast alert
     cy.get('.toast').should('not.exist');
+  });
 
-    // Access External account
+  it('Access External account', () => {
     cy.getByTestid('grw-personal-settings').find('.nav-title.nav li:eq(1) a').click();
     cy.scrollTo('top');
     cy.screenshot(`${ssPrefix}-external-account-1`);
@@ -67,8 +66,9 @@ context('Access User settings', () => {
     cy.screenshot(`${ssPrefix}-external-account-4`);
 
     cy.get('.toast').should('not.exist');
+  });
 
-    // Access Password setting
+  it('Access Password setting', () => {
     cy.getByTestid('grw-personal-settings').find('.nav-title.nav li:eq(2) a').click();
     cy.scrollTo('top');
     cy.screenshot(`${ssPrefix}-password-settings-1`);
@@ -78,8 +78,9 @@ context('Access User settings', () => {
 
     cy.get('.toast-close-button').click({ multiple: true }); // close toast alert
     cy.get('.toast').should('not.exist');
+  });
 
-    // Access API setting
+  it('Access API setting', () => {
     cy.getByTestid('grw-personal-settings').find('.nav-title.nav li:eq(3) a').click();
     cy.scrollTo('top');
     cy.screenshot(`${ssPrefix}-api-setting-1`);
@@ -90,8 +91,9 @@ context('Access User settings', () => {
 
     cy.get('.toast-close-button').click({ multiple: true }); // close toast alert
     cy.get('.toast').should('not.exist');
+  });
 
-    // Access Editor setting
+  it('Access Editor setting', () => {
     cy.getByTestid('grw-personal-settings').find('.nav-title.nav li:eq(4) a').click();
     cy.scrollTo('top');
     cy.getByTestid('grw-editor-settings').should('be.visible');
@@ -102,8 +104,9 @@ context('Access User settings', () => {
 
     cy.get('.toast-close-button').click({ multiple: true }); // close toast alert
     cy.get('.toast').should('not.exist');
+  });
 
-    // Access In-app notification setting
+  it('Access In-app notification setting', () => {
     cy.getByTestid('grw-personal-settings').find('.nav-title.nav li:eq(5) a').click();
     cy.scrollTo('top');
     cy.screenshot(`${ssPrefix}-in-app-notification-setting-1`);
@@ -111,5 +114,4 @@ context('Access User settings', () => {
     cy.get('.toast').should('be.visible').invoke('attr', 'style', 'opacity: 1');
     cy.screenshot(`${ssPrefix}-in-app-notification-setting-2`);
   });
-
 });