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

Merge branch 'support/apply-nextjs-2' into feat/ssr-contents-wrapper

yohei0125 3 лет назад
Родитель
Сommit
ceda178f7f
32 измененных файлов с 911 добавлено и 998 удалено
  1. 2 5
      packages/app/src/client/app.jsx
  2. 0 186
      packages/app/src/client/services/PersonalContainer.js
  3. 2 0
      packages/app/src/components/Admin/Security/LdapAuthTest.jsx
  4. 2 4
      packages/app/src/components/Admin/SlackIntegration/WithProxyAccordions.jsx
  5. 4 4
      packages/app/src/components/DescendantsPageList.tsx
  6. 2 2
      packages/app/src/components/EmptyTrashButton.tsx
  7. 9 20
      packages/app/src/components/IdenticalPathPage.tsx
  8. 0 116
      packages/app/src/components/Me/ApiSettings.jsx
  9. 91 0
      packages/app/src/components/Me/ApiSettings.tsx
  10. 0 152
      packages/app/src/components/Me/AssociateModal.jsx
  11. 111 0
      packages/app/src/components/Me/AssociateModal.tsx
  12. 0 181
      packages/app/src/components/Me/BasicInfoSettings.jsx
  13. 171 0
      packages/app/src/components/Me/BasicInfoSettings.tsx
  14. 0 98
      packages/app/src/components/Me/DisassociateModal.jsx
  15. 69 0
      packages/app/src/components/Me/DisassociateModal.tsx
  16. 8 18
      packages/app/src/components/Me/ExternalAccountLinkedMe.jsx
  17. 19 17
      packages/app/src/components/Me/PasswordSettings.jsx
  18. 3 2
      packages/app/src/components/PageDeleteModal.tsx
  19. 1 2
      packages/app/src/components/SearchPage/SearchResultContent.tsx
  20. 5 4
      packages/app/src/components/SearchPage/SearchResultList.tsx
  21. 3 3
      packages/app/src/components/Sidebar/PageTree/ItemsTree.tsx
  22. 7 6
      packages/app/src/components/Sidebar/RecentChanges.tsx
  23. 10 0
      packages/app/src/interfaces/external-account.ts
  24. 10 6
      packages/app/src/interfaces/user.ts
  25. 98 38
      packages/app/src/pages/[[...path]].page.tsx
  26. 16 1
      packages/app/src/server/models/page.ts
  27. 14 5
      packages/app/src/server/routes/apiv3/page-listing.ts
  28. 22 15
      packages/app/src/server/routes/apiv3/page.js
  29. 116 0
      packages/app/src/stores/page-listing.tsx
  30. 2 103
      packages/app/src/stores/page.tsx
  31. 102 0
      packages/app/src/stores/personal-settings.tsx
  32. 12 10
      packages/app/test/cypress/integration/60-home/home.spec.ts

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

+ 4 - 4
packages/app/src/components/DescendantsPageList.tsx

@@ -12,9 +12,9 @@ import { IPagingResult } from '~/interfaces/paging-result';
 import { OnDeletedFunction, OnPutBackedFunction } from '~/interfaces/ui';
 import { useIsGuestUser, useIsSharedUser, useIsTrashPage } from '~/stores/context';
 import {
-  useSWRxDescendantsPageListForCurrrentPath, useSWRxPageInfoForList, useSWRxPageList, useDescendantsPageListForCurrentPathTermManager,
-} from '~/stores/page';
-import { usePageTreeTermManager } from '~/stores/page-listing';
+  usePageTreeTermManager, useDescendantsPageListForCurrentPathTermManager, useSWRxDescendantsPageListForCurrrentPath,
+  useSWRxPageInfoForList, useSWRxPageList,
+} from '~/stores/page-listing';
 
 import { ForceHideMenuItems, MenuItemType } from './Common/Dropdown/PageItemControl';
 import PageList from './PageList/PageList';
@@ -45,7 +45,7 @@ export const DescendantsPageListSubstance = (props: SubstanceProps): JSX.Element
   const { data: isGuestUser } = useIsGuestUser();
 
   const pageIds = pagingResult?.items?.map(page => page._id);
-  const { injectTo } = useSWRxPageInfoForList(pageIds, true, true);
+  const { injectTo } = useSWRxPageInfoForList(pageIds, null, true, true);
 
   let pageWithMetas: IDataWithMeta<IPageHasId, IPageInfoForOperation>[] = [];
 

+ 2 - 2
packages/app/src/components/EmptyTrashButton.tsx

@@ -9,7 +9,7 @@ import {
   IPageInfo,
 } from '~/interfaces/page';
 import { useEmptyTrashModal } from '~/stores/modal';
-import { useSWRxDescendantsPageListForCurrrentPath, useSWRxPageInfoForList } from '~/stores/page';
+import { useSWRxDescendantsPageListForCurrrentPath, useSWRxPageInfoForList } from '~/stores/page-listing';
 
 
 const EmptyTrashButton = () => {
@@ -18,7 +18,7 @@ const EmptyTrashButton = () => {
   const { data: pagingResult, mutate } = useSWRxDescendantsPageListForCurrrentPath();
 
   const pageIds = pagingResult?.items?.map(page => page._id);
-  const { injectTo } = useSWRxPageInfoForList(pageIds, true, true);
+  const { injectTo } = useSWRxPageInfoForList(pageIds, null, true, true);
 
   let pageWithMetas: IDataWithMeta<IPageHasId, IPageInfo>[] = [];
 

+ 9 - 20
packages/app/src/components/IdenticalPathPage.tsx

@@ -1,12 +1,11 @@
 import React, { FC } from 'react';
-import { useTranslation } from 'next-i18next';
 
 import { DevidedPagePath } from '@growi/core';
+import { useTranslation } from 'next-i18next';
 
-import { IPageHasId } from '~/interfaces/page';
 import { useCurrentPagePath, useIsSharedUser } from '~/stores/context';
 import { useDescendantsPageListModal } from '~/stores/modal';
-import { useSWRxPageInfoForList } from '~/stores/page';
+import { useSWRxPageInfoForList, useSWRxPagesByPath } from '~/stores/page-listing';
 
 import PageListIcon from './Icons/PageListIcon';
 import { PageListItemL } from './PageList/PageListItemL';
@@ -47,29 +46,21 @@ const IdenticalPathAlert : FC<IdenticalPathAlertProps> = (props: IdenticalPathAl
 };
 
 
-type IdenticalPathPageProps= {
-  // add props and types here
-}
-
-
-const jsonNull = 'null';
-
-const IdenticalPathPage:FC<IdenticalPathPageProps> = (props: IdenticalPathPageProps) => {
+export const IdenticalPathPage = (): JSX.Element => {
   const { t } = useTranslation();
 
-  const identicalPageDocument = document.getElementById('identical-path-page');
-  const pages = JSON.parse(identicalPageDocument?.getAttribute('data-identical-path-pages') || jsonNull) as IPageHasId[];
-
-  const pageIds = pages.map(page => page._id) as string[];
-
-
   const { data: currentPath } = useCurrentPagePath();
   const { data: isSharedUser } = useIsSharedUser();
 
-  const { injectTo } = useSWRxPageInfoForList(pageIds, true, true);
+  const { data: pages } = useSWRxPagesByPath(currentPath);
+  const { injectTo } = useSWRxPageInfoForList(null, currentPath, true, true);
 
   const { open: openDescendantPageListModal } = useDescendantsPageListModal();
 
+  if (pages == null) {
+    return <></>;
+  }
+
   const injectedPages = injectTo(pages);
 
   return (
@@ -118,5 +109,3 @@ const IdenticalPathPage:FC<IdenticalPathPageProps> = (props: IdenticalPathPagePr
     </div>
   );
 };
-
-export default IdenticalPathPage;

+ 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 - 2
packages/app/src/components/PageDeleteModal.tsx

@@ -2,6 +2,7 @@ import React, {
   useState, FC, useMemo, useEffect,
 } from 'react';
 
+import { pagePathUtils } from '@growi/core';
 import { useTranslation } from 'next-i18next';
 import {
   Modal, ModalHeader, ModalBody, ModalFooter,
@@ -14,13 +15,13 @@ import {
   IDeleteSinglePageApiv1Result, IDeleteManyPageApiv3Result, IPageToDeleteWithMeta, IDataWithMeta, isIPageInfoForEntity, IPageInfoForEntity,
 } from '~/interfaces/page';
 import { usePageDeleteModal } from '~/stores/modal';
-import { useSWRxPageInfoForList } from '~/stores/page';
+import { useSWRxPageInfoForList } from '~/stores/page-listing';
 import loggerFactory from '~/utils/logger';
 
 
 import ApiErrorMessageList from './PageManagement/ApiErrorMessageList';
 
-import { isTrashPage } from '^/../core/src/utils/page-path-utils';
+const { isTrashPage } = pagePathUtils;
 
 
 const logger = loggerFactory('growi:cli:PageDeleteModal');

+ 1 - 2
packages/app/src/components/SearchPage/SearchResultContent.tsx

@@ -14,8 +14,7 @@ import { OnDuplicatedFunction, OnRenamedFunction, OnDeletedFunction } from '~/in
 import {
   usePageDuplicateModal, usePageRenameModal, usePageDeleteModal,
 } from '~/stores/modal';
-import { useDescendantsPageListForCurrentPathTermManager } from '~/stores/page';
-import { usePageTreeTermManager } from '~/stores/page-listing';
+import { useDescendantsPageListForCurrentPathTermManager, usePageTreeTermManager } from '~/stores/page-listing';
 import { useFullTextSearchTermManager } from '~/stores/search';
 
 

+ 5 - 4
packages/app/src/components/SearchPage/SearchResultList.tsx

@@ -2,7 +2,9 @@ import React, {
   forwardRef,
   ForwardRefRenderFunction, useCallback, useImperativeHandle, useRef,
 } from 'react';
+
 import { useTranslation } from 'next-i18next';
+
 import { ISelectable, ISelectableAll } from '~/client/interfaces/selectable-all';
 import { toastSuccess } from '~/client/util/apiNotification';
 import {
@@ -11,11 +13,10 @@ import {
 import { IPageSearchMeta } from '~/interfaces/search';
 import { OnDuplicatedFunction, OnRenamedFunction, OnDeletedFunction } from '~/interfaces/ui';
 import { useIsGuestUser } from '~/stores/context';
-import { useSWRxPageInfoForList } from '~/stores/page';
-import { usePageTreeTermManager } from '~/stores/page-listing';
+import { useSWRxPageInfoForList, usePageTreeTermManager } from '~/stores/page-listing';
 import { useFullTextSearchTermManager } from '~/stores/search';
-import { ForceHideMenuItems } from '../Common/Dropdown/PageItemControl';
 
+import { ForceHideMenuItems } from '../Common/Dropdown/PageItemControl';
 import { PageListItemL } from '../PageList/PageListItemL';
 
 
@@ -41,7 +42,7 @@ const SearchResultListSubstance: ForwardRefRenderFunction<ISelectableAll, Props>
     .map(page => page.data._id);
 
   const { data: isGuestUser } = useIsGuestUser();
-  const { data: idToPageInfo } = useSWRxPageInfoForList(pageIdsWithNoSnippet, true, true);
+  const { data: idToPageInfo } = useSWRxPageInfoForList(pageIdsWithNoSnippet, null, true, true);
 
   // for mutation
   const { advance: advancePt } = usePageTreeTermManager();

+ 3 - 3
packages/app/src/components/Sidebar/PageTree/ItemsTree.tsx

@@ -15,8 +15,9 @@ import { useIsEnabledAttachTitleHeader } from '~/stores/context';
 import {
   IPageForPageDuplicateModal, usePageDuplicateModal, usePageDeleteModal,
 } from '~/stores/modal';
-import { useDescendantsPageListForCurrentPathTermManager } from '~/stores/page';
-import { usePageTreeTermManager, useSWRxPageAncestorsChildren, useSWRxRootPage } from '~/stores/page-listing';
+import {
+  usePageTreeTermManager, useSWRxPageAncestorsChildren, useSWRxRootPage, useDescendantsPageListForCurrentPathTermManager,
+} from '~/stores/page-listing';
 import { useFullTextSearchTermManager } from '~/stores/search';
 import { usePageTreeDescCountMap, useSidebarScrollerRef } from '~/stores/ui';
 import { useGlobalSocket } from '~/stores/websocket';
@@ -25,7 +26,6 @@ import loggerFactory from '~/utils/logger';
 import Item from './Item';
 import { ItemNode } from './ItemNode';
 
-
 const logger = loggerFactory('growi:cli:ItemsTree');
 
 /*

+ 7 - 6
packages/app/src/components/Sidebar/RecentChanges.tsx

@@ -2,21 +2,22 @@ import React, {
   FC,
   useCallback, useEffect, useState,
 } from 'react';
-import PropTypes from 'prop-types';
 
+import { DevidedPagePath } from '@growi/core';
+import { UserPicture, FootstampIcon } from '@growi/ui';
 import { useTranslation } from 'next-i18next';
+import PropTypes from 'prop-types';
 
-import { UserPicture, FootstampIcon } from '@growi/ui';
-import { DevidedPagePath } from '@growi/core';
 
 import PagePathHierarchicalLink from '~/components/PagePathHierarchicalLink';
-import { useSWRInifinitexRecentlyUpdated } from '~/stores/page';
+import LinkedPagePath from '~/models/linked-page-path';
+import { useSWRInifinitexRecentlyUpdated } from '~/stores/page-listing';
 import loggerFactory from '~/utils/logger';
 
-import LinkedPagePath from '~/models/linked-page-path';
+import FormattedDistanceDate from '../FormattedDistanceDate';
+
 import InfiniteScroll from './InfiniteScroll';
 
-import FormattedDistanceDate from '../FormattedDistanceDate';
 
 const logger = loggerFactory('growi:History');
 

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

+ 98 - 38
packages/app/src/pages/[[...path]].page.tsx

@@ -1,6 +1,7 @@
 import React, { useEffect } from 'react';
 
 import { isClient, pagePathUtils, pathUtils } from '@growi/core';
+import ExtensibleCustomError from 'extensible-custom-error';
 import {
   NextPage, GetServerSideProps, GetServerSidePropsContext,
 } from 'next';
@@ -57,6 +58,13 @@ const logger = loggerFactory('growi:pages:all');
 const { isPermalink: _isPermalink, isUsersHomePage, isTrashPage: _isTrashPage } = pagePathUtils;
 const { removeHeadingSlash } = pathUtils;
 
+
+const IdenticalPathPage = (): JSX.Element => {
+  const IdenticalPathPage = dynamic(() => import('../components/IdenticalPathPage').then(mod => mod.IdenticalPathPage), { ssr: false });
+  return <IdenticalPathPage />;
+};
+
+
 type Props = CommonProps & {
   currentUser: string,
 
@@ -67,6 +75,7 @@ type Props = CommonProps & {
 
   // shareLinkId?: string;
 
+  isIdenticalPathPage?: boolean,
   isForbidden: boolean,
   isNotFound: boolean,
   // isAbleToDeleteCompletely: boolean,
@@ -216,14 +225,24 @@ const GrowiPage: NextPage<Props> = (props: Props) => {
         <div id="main" className={`main ${isUsersHomePage(props.currentPathname) && 'user-page'}`}>
 
           <div className="row">
-            <div className="col grw-page-content-container">
+            <div className="col">
               <div id="content-main" className="content-main grw-container-convertible">
-                {/* <PageAlerts /> */}
-                PageAlerts<br />
-                <DisplaySwitcher />
-                <div id="page-editor-navbar-bottom-container" className="d-none d-edit-block"></div>
-                {/* <PageStatusAlert /> */}
-                PageStatusAlert
+                { props.isIdenticalPathPage && <IdenticalPathPage /> }
+
+                { !props.isIdenticalPathPage && (
+                  <>
+                    {/* <PageAlerts /> */}
+                    PageAlerts<br />
+                    { props.isForbidden
+                      ? <>ForbiddenPage</>
+                      : <DisplaySwitcher />
+                    }
+                    <div id="page-editor-navbar-bottom-container" className="d-none d-edit-block"></div>
+                    {/* <PageStatusAlert /> */}
+                    PageStatusAlert
+                  </>
+                ) }
+
               </div>
             </div>
 
@@ -252,16 +271,38 @@ function getPageIdFromPathname(currentPathname: string): string | null {
   return _isPermalink(currentPathname) ? removeHeadingSlash(currentPathname) : null;
 }
 
+class MultiplePagesHitsError extends ExtensibleCustomError {
+
+  pagePath: string;
+
+  constructor(pagePath: string) {
+    super(`MultiplePagesHitsError occured by '${pagePath}'`);
+    this.pagePath = pagePath;
+  }
+
+}
+
 async function getPageData(context: GetServerSidePropsContext, props: Props): Promise<IPageWithMeta|null> {
   const req: CrowiRequest = context.req as CrowiRequest;
   const { crowi } = req;
   const { revisionId } = req.query;
-  const { pageService } = crowi;
 
-  const { user } = req;
+  const Page = crowi.model('Page') as PageModel;
+  const { pageService } = crowi;
 
   const { currentPathname } = props;
   const pageId = getPageIdFromPathname(currentPathname);
+  const isPermalink = _isPermalink(currentPathname);
+
+  const { user } = req;
+
+  // check whether the specified page path hits to multiple pages
+  if (!isPermalink) {
+    const count = await Page.countByPathAndViewer(currentPathname, user, null, true);
+    if (count > 1) {
+      throw new MultiplePagesHitsError(currentPathname);
+    }
+  }
 
   const result: IPageWithMeta = await pageService.findPageAndMetaDataByViewer(pageId, currentPathname, user, true); // includeEmpty = true, isSharedPage = false
   const page = result?.data as unknown as PageDocument;
@@ -278,7 +319,7 @@ async function getPageData(context: GetServerSidePropsContext, props: Props): Pr
 async function injectRoutingInformation(context: GetServerSidePropsContext, props: Props, pageWithMeta: IPageWithMeta|null): Promise<void> {
   const req: CrowiRequest = context.req as CrowiRequest;
   const { crowi } = req;
-  const Page = crowi.model('Page');
+  const Page = crowi.model('Page') as PageModel;
 
   const { currentPathname } = props;
   const pageId = getPageIdFromPathname(currentPathname);
@@ -286,15 +327,17 @@ async function injectRoutingInformation(context: GetServerSidePropsContext, prop
 
   const page = pageWithMeta?.data;
 
-  if (page == null) {
+  if (props.isIdenticalPathPage) {
+    // TBD
+  }
+  else if (page == null) {
     props.isNotFound = true;
 
     // check the page is forbidden or just does not exist.
     const count = isPermalink ? await Page.count({ _id: pageId }) : await Page.count({ path: currentPathname });
     props.isForbidden = count > 0;
   }
-
-  if (page != null) {
+  else {
     // /62a88db47fed8b2d94f30000 ==> /path/to/page
     if (isPermalink && page.isEmpty) {
       props.currentPathname = page.path;
@@ -324,35 +367,13 @@ async function injectRoutingInformation(context: GetServerSidePropsContext, prop
 //   }
 // }
 
-export const getServerSideProps: GetServerSideProps = async(context: GetServerSidePropsContext) => {
+async function injectServerConfigurations(context: GetServerSidePropsContext, props: Props): Promise<void> {
   const req: CrowiRequest = context.req as CrowiRequest;
   const { crowi } = req;
   const {
     appService, searchService, configManager, aclService, slackNotificationService, mailService,
   } = crowi;
 
-  const { user } = req;
-
-  const result = await getServerSideCommonProps(context);
-  const userUISettings = user == null ? null : await UserUISettings.findOne({ user: user._id }).exec();
-
-  // check for presence
-  // see: https://github.com/vercel/next.js/issues/19271#issuecomment-730006862
-  if (!('props' in result)) {
-    throw new Error('invalid getSSP result');
-  }
-
-  const props: Props = result.props as Props;
-  const pageWithMeta = await getPageData(context, props);
-
-  props.pageWithMetaStr = JSON.stringify(pageWithMeta);
-
-  injectRoutingInformation(context, props, pageWithMeta);
-
-  if (user != null) {
-    props.currentUser = JSON.stringify(user);
-  }
-
   props.isSearchServiceConfigured = searchService.isConfigured;
   props.isSearchServiceReachable = searchService.isReachable;
   props.isSearchScopeChildrenAsDefault = configManager.getConfig('crowi', 'customize:isSearchScopeChildrenAsDefault');
@@ -380,12 +401,51 @@ export const getServerSideProps: GetServerSideProps = async(context: GetServerSi
   // props.adminPreferredIndentSize = configManager.getConfig('markdown', 'markdown:adminPreferredIndentSize');
   // props.isIndentSizeForced = configManager.getConfig('markdown', 'markdown:isIndentSizeForced');
 
-  // UI
-  props.userUISettings = JSON.parse(JSON.stringify(userUISettings));
   props.sidebarConfig = {
     isSidebarDrawerMode: configManager.getConfig('crowi', 'customize:isSidebarDrawerMode'),
     isSidebarClosedAtDockMode: configManager.getConfig('crowi', 'customize:isSidebarClosedAtDockMode'),
   };
+}
+
+export const getServerSideProps: GetServerSideProps = async(context: GetServerSidePropsContext) => {
+  const req: CrowiRequest = context.req as CrowiRequest;
+
+  const { user } = req;
+
+  const result = await getServerSideCommonProps(context);
+
+  // check for presence
+  // see: https://github.com/vercel/next.js/issues/19271#issuecomment-730006862
+  if (!('props' in result)) {
+    throw new Error('invalid getSSP result');
+  }
+
+  const props: Props = result.props as Props;
+  let pageWithMeta;
+  try {
+    pageWithMeta = await getPageData(context, props);
+    props.pageWithMetaStr = JSON.stringify(pageWithMeta);
+  }
+  catch (err) {
+    if (err instanceof MultiplePagesHitsError) {
+      props.isIdenticalPathPage = true;
+    }
+    else {
+      throw err;
+    }
+  }
+
+  injectRoutingInformation(context, props, pageWithMeta);
+  injectServerConfigurations(context, props);
+
+  if (user != null) {
+    props.currentUser = JSON.stringify(user);
+  }
+
+  // UI
+  const userUISettings = user == null ? null : await UserUISettings.findOne({ user: user._id }).exec();
+  props.userUISettings = JSON.parse(JSON.stringify(userUISettings));
+
   return {
     props,
   };

+ 16 - 1
packages/app/src/server/models/page.ts

@@ -58,7 +58,9 @@ export type CreateMethod = (path: string, body: string, user, options: PageCreat
 export interface PageModel extends Model<PageDocument> {
   [x: string]: any; // for obsolete static methods
   findByIdsAndViewer(pageIds: ObjectIdLike[], user, userGroups?, includeEmpty?: boolean): Promise<PageDocument[]>
-  findByPathAndViewer(path: string | null, user, userGroups?, useFindOne?: boolean, includeEmpty?: boolean): Promise<PageDocument | PageDocument[] | null>
+  findByPathAndViewer(path: string | null, user, userGroups?, useFindOne?: true, includeEmpty?: boolean): Promise<PageDocument | PageDocument[] | null>
+  findByPathAndViewer(path: string | null, user, userGroups?, useFindOne?: false, includeEmpty?: boolean): Promise<PageDocument[]>
+  countByPathAndViewer(path: string | null, user, userGroups?, includeEmpty?:boolean): Promise<number>
   findTargetAndAncestorsByPathOrId(pathOrId: string): Promise<TargetAndAncestorsResult>
   findRecentUpdatedPages(path: string, user, option, includeEmpty?: boolean): Promise<PaginatedPages>
   generateGrantCondition(
@@ -572,6 +574,19 @@ schema.statics.findByPathAndViewer = async function(
   return queryBuilder.query.exec();
 };
 
+schema.statics.countByPathAndViewer = async function(path: string | null, user, userGroups = null, includeEmpty = false): Promise<number> {
+  if (path == null) {
+    throw new Error('path is required.');
+  }
+
+  const baseQuery = this.count({ path });
+  const queryBuilder = new PageQueryBuilder(baseQuery, includeEmpty);
+
+  await queryBuilder.addViewerCondition(user, userGroups);
+
+  return queryBuilder.query.exec();
+};
+
 schema.statics.findRecentUpdatedPages = async function(
     path: string, user, options, includeEmpty = false,
 ): Promise<PaginatedPages> {

+ 14 - 5
packages/app/src/server/routes/apiv3/page-listing.ts

@@ -34,8 +34,11 @@ const validator = {
     query('id').isMongoId(),
     query('path').isString(),
   ], 'id or path is required'),
+  pageIdsOrPathRequired: oneOf([
+    query('pageIds').isArray(),
+    query('path').isString(),
+  ], 'pageIds or path is required'),
   infoParams: [
-    query('pageIds').isArray().withMessage('pageIds is required'),
     query('attachBookmarkCount').isBoolean().optional(),
     query('attachShortBody').isBoolean().optional(),
   ],
@@ -44,7 +47,7 @@ const validator = {
 /*
  * Routes
  */
-export default (crowi: Crowi): Router => {
+const routerFactory = (crowi: Crowi): Router => {
   const accessTokenParser = require('../../middlewares/access-token-parser')(crowi);
   const loginRequired = require('../../middlewares/login-required')(crowi, true);
 
@@ -101,8 +104,10 @@ export default (crowi: Crowi): Router => {
   });
 
   // eslint-disable-next-line max-len
-  router.get('/info', accessTokenParser, loginRequired, validator.infoParams, apiV3FormValidator, async(req: AuthorizedRequest, res: ApiV3Response) => {
-    const { pageIds, attachBookmarkCount: attachBookmarkCountParam, attachShortBody: attachShortBodyParam } = req.query;
+  router.get('/info', accessTokenParser, loginRequired, validator.pageIdsOrPathRequired, validator.infoParams, apiV3FormValidator, async(req: AuthorizedRequest, res: ApiV3Response) => {
+    const {
+      pageIds, path, attachBookmarkCount: attachBookmarkCountParam, attachShortBody: attachShortBodyParam,
+    } = req.query;
 
     const attachBookmarkCount: boolean = attachBookmarkCountParam === 'true';
     const attachShortBody: boolean = attachShortBodyParam === 'true';
@@ -113,7 +118,9 @@ export default (crowi: Crowi): Router => {
     const pageService: PageService = crowi.pageService!;
 
     try {
-      const pages = await Page.findByIdsAndViewer(pageIds as string[], req.user, null, true);
+      const pages = pageIds != null
+        ? await Page.findByIdsAndViewer(pageIds as string[], req.user, null, true)
+        : await Page.findByPathAndViewer(path as string, req.user, null, false, true);
 
       const foundIds = pages.map(page => page._id);
 
@@ -157,3 +164,5 @@ export default (crowi: Crowi): Router => {
 
   return router;
 };
+
+export default routerFactory;

+ 22 - 15
packages/app/src/server/routes/apiv3/page.js

@@ -169,8 +169,9 @@ module.exports = (crowi) => {
 
   const validator = {
     getPage: [
-      query('id').if(value => value != null).isMongoId(),
-      query('path').if(value => value != null).isString(),
+      query('pageId').optional().isString(),
+      query('path').optional().isString(),
+      query('findAll').optional().isBoolean(),
     ],
     likes: [
       body('pageId').isString(),
@@ -246,19 +247,23 @@ module.exports = (crowi) => {
    */
   router.get('/', certifySharedPage, accessTokenParser, loginRequired, validator.getPage, apiV3FormValidator, async(req, res) => {
     const { user } = req;
-    const { pageId, path } = req.query;
+    const { pageId, path, findAll } = req.query;
 
     if (pageId == null && path == null) {
-      return res.apiv3Err(new ErrorV3('Parameter path or pageId is required.', 'invalid-request'));
+      return res.apiv3Err(new ErrorV3('Either parameter of path or pageId is required.', 'invalid-request'));
     }
 
     let page;
+    let pages;
     try {
       if (pageId != null) { // prioritized
         page = await Page.findByIdAndViewer(pageId, user);
       }
+      else if (!findAll) {
+        page = await Page.findByPathAndViewer(path, user, null, true);
+      }
       else {
-        page = await Page.findByPathAndViewer(path, user);
+        pages = await Page.findByPathAndViewer(path, user, null, false);
       }
     }
     catch (err) {
@@ -266,22 +271,24 @@ module.exports = (crowi) => {
       return res.apiv3Err(err, 500);
     }
 
-    if (page == null) {
+    if (page == null && (pages == null || pages.length === 0)) {
       return res.apiv3Err('Page is not found', 404);
     }
 
-    try {
-      page.initLatestRevisionField();
+    if (page != null) {
+      try {
+        page.initLatestRevisionField();
 
-      // populate
-      page = await page.populateDataToShowRevision();
-    }
-    catch (err) {
-      logger.error('populate-page-failed', err);
-      return res.apiv3Err(err, 500);
+        // populate
+        page = await page.populateDataToShowRevision();
+      }
+      catch (err) {
+        logger.error('populate-page-failed', err);
+        return res.apiv3Err(err, 500);
+      }
     }
 
-    return res.apiv3({ page });
+    return res.apiv3({ page, pages });
   });
 
   /**

+ 116 - 0
packages/app/src/stores/page-listing.tsx

@@ -1,12 +1,128 @@
 import useSWR, { SWRResponse } from 'swr';
 import useSWRImmutable from 'swr/immutable';
+import useSWRInfinite, { SWRInfiniteResponse } from 'swr/infinite';
+
+import { Nullable } from '~/interfaces/common';
+import { HasObjectId } from '~/interfaces/has-object-id';
+import {
+  IDataWithMeta, IPageHasId, IPageInfoForListing, IPageInfoForOperation,
+} from '~/interfaces/page';
+import { IPagingResult } from '~/interfaces/paging-result';
 
 import { apiv3Get } from '../client/util/apiv3-client';
 import {
   AncestorsChildrenResult, ChildrenResult, V5MigrationStatus, RootPageResult,
 } from '../interfaces/page-listing-results';
+
+import { useCurrentPagePath } from './context';
 import { ITermNumberManagerUtil, useTermNumberManager } from './use-static-swr';
 
+export const useSWRxPagesByPath = (path?: Nullable<string>): SWRResponse<IPageHasId[], Error> => {
+  const findAll = true;
+  return useSWR<IPageHasId[], Error>(
+    path != null ? ['/page', path, findAll] : null,
+    (endpoint, path, findAll) => apiv3Get(endpoint, { path, findAll }).then(result => result.data.pages),
+  );
+};
+
+export const useSWRxRecentlyUpdated = (): SWRResponse<(IPageHasId)[], Error> => {
+  return useSWR(
+    '/pages/recent',
+    endpoint => apiv3Get<{ pages:(IPageHasId)[] }>(endpoint).then(response => response.data?.pages),
+  );
+};
+export const useSWRInifinitexRecentlyUpdated = () : SWRInfiniteResponse<(IPageHasId)[], Error> => {
+  const getKey = (page: number) => {
+    return `/pages/recent?offset=${page + 1}`;
+  };
+  return useSWRInfinite(
+    getKey,
+    (endpoint: string) => apiv3Get<{ pages:(IPageHasId)[] }>(endpoint).then(response => response.data?.pages),
+    {
+      revalidateFirstPage: false,
+      revalidateAll: false,
+    },
+  );
+};
+// eslint-disable-next-line @typescript-eslint/no-unused-vars
+export const useSWRxPageList = (path: string | null, pageNumber?: number, termNumber?: number): SWRResponse<IPagingResult<IPageHasId>, Error> => {
+
+  const key = path != null
+    ? [`/pages/list?path=${path}&page=${pageNumber ?? 1}`, termNumber]
+    : null;
+
+  return useSWR(
+    key,
+    (endpoint: string) => apiv3Get<{pages: IPageHasId[], totalCount: number, limit: number}>(endpoint).then((response) => {
+      return {
+        items: response.data.pages,
+        totalCount: response.data.totalCount,
+        limit: response.data.limit,
+      };
+    }),
+  );
+};
+
+export const useDescendantsPageListForCurrentPathTermManager = (isDisabled?: boolean) : SWRResponse<number, Error> & ITermNumberManagerUtil => {
+  return useTermNumberManager(isDisabled === true ? null : 'descendantsPageListForCurrentPathTermNumber');
+};
+
+export const useSWRxDescendantsPageListForCurrrentPath = (pageNumber?: number): SWRResponse<IPagingResult<IPageHasId>, Error> => {
+  const { data: currentPagePath } = useCurrentPagePath();
+  const { data: termNumber } = useDescendantsPageListForCurrentPathTermManager();
+
+  const path = currentPagePath == null || termNumber == null
+    ? null
+    : currentPagePath;
+
+  return useSWRxPageList(path, pageNumber, termNumber);
+};
+
+
+type PageInfoInjector = {
+  injectTo: <D extends HasObjectId>(pages: (D | IDataWithMeta<D>)[]) => IDataWithMeta<D, IPageInfoForOperation>[],
+}
+
+const isIDataWithMeta = (item: HasObjectId | IDataWithMeta): item is IDataWithMeta => {
+  return 'data' in item;
+};
+
+export const useSWRxPageInfoForList = (
+    pageIds: string[] | null | undefined,
+    path: string | null | undefined = null,
+    attachBookmarkCount = false,
+    attachShortBody = false,
+): SWRResponse<Record<string, IPageInfoForListing>, Error> & PageInfoInjector => {
+
+  const shouldFetch = (pageIds != null && pageIds.length > 0) || path != null;
+
+  const swrResult = useSWRImmutable<Record<string, IPageInfoForListing>>(
+    shouldFetch ? ['/page-listing/info', pageIds, path, attachBookmarkCount, attachShortBody] : null,
+    (endpoint, pageIds, path, attachBookmarkCount, attachShortBody) => {
+      return apiv3Get(endpoint, {
+        pageIds, path, attachBookmarkCount, attachShortBody,
+      }).then(response => response.data);
+    },
+  );
+
+  return {
+    ...swrResult,
+    injectTo: <D extends HasObjectId>(pages: (D | IDataWithMeta<D>)[]) => {
+      return pages.map((item) => {
+        const page = isIDataWithMeta(item) ? item.data : item;
+        const orgPageMeta = isIDataWithMeta(item) ? item.meta : undefined;
+
+        // get an applicable IPageInfo
+        const applicablePageInfo = (swrResult.data ?? {})[page._id];
+
+        return {
+          data: page,
+          meta: applicablePageInfo ?? orgPageMeta,
+        };
+      });
+    },
+  };
+};
 
 export const usePageTreeTermManager = (isDisabled?: boolean) : SWRResponse<number, Error> & ITermNumberManagerUtil => {
   return useTermNumberManager(isDisabled === true ? null : 'fullTextSearchTermNumber');

+ 2 - 103
packages/app/src/stores/page.tsx

@@ -1,23 +1,18 @@
 import useSWR, { SWRResponse } from 'swr';
 import useSWRImmutable from 'swr/immutable';
-import useSWRInfinite, { SWRInfiniteResponse } from 'swr/infinite';
 
 import { apiv3Get } from '~/client/util/apiv3-client';
-import { HasObjectId } from '~/interfaces/has-object-id';
 import {
-  IPageInfo, IPageHasId, IPageInfoForOperation, IPageInfoForListing, IDataWithMeta, IPageInfoAll,
+  IPageInfo, IPageHasId, IPageInfoForOperation, IPageInfoAll,
 } from '~/interfaces/page';
 import { IRecordApplicableGrant, IResIsGrantNormalized } from '~/interfaces/page-grant';
-import { IPagingResult } from '~/interfaces/paging-result';
 import { IRevisionsForPagination } from '~/interfaces/revision';
 
 import { apiGet } from '../client/util/apiv1-client';
 import { Nullable } from '../interfaces/common';
 import { IPageTagsInfo } from '../interfaces/tag';
 
-import { useCurrentPageId, useCurrentPagePath } from './context';
-import { ITermNumberManagerUtil, useTermNumberManager } from './use-static-swr';
-
+import { useCurrentPageId } from './context';
 
 export const useSWRxPage = (pageId?: string, shareLinkId?: string, initialData?: IPageHasId): SWRResponse<IPageHasId, Error> => {
   return useSWR<IPageHasId, Error>(
@@ -40,60 +35,6 @@ export const useSWRxCurrentPage = (shareLinkId?: string, initialData?: IPageHasI
   return useSWRxPage(currentPageId ?? undefined, shareLinkId, initialData);
 };
 
-// eslint-disable-next-line @typescript-eslint/no-unused-vars
-export const useSWRxRecentlyUpdated = (): SWRResponse<(IPageHasId)[], Error> => {
-  return useSWR(
-    '/pages/recent',
-    endpoint => apiv3Get<{ pages:(IPageHasId)[] }>(endpoint).then(response => response.data?.pages),
-  );
-};
-export const useSWRInifinitexRecentlyUpdated = () : SWRInfiniteResponse<(IPageHasId)[], Error> => {
-  const getKey = (page: number) => {
-    return `/pages/recent?offset=${page + 1}`;
-  };
-  return useSWRInfinite(
-    getKey,
-    (endpoint: string) => apiv3Get<{ pages:(IPageHasId)[] }>(endpoint).then(response => response.data?.pages),
-    {
-      revalidateFirstPage: false,
-      revalidateAll: false,
-    },
-  );
-};
-// eslint-disable-next-line @typescript-eslint/no-unused-vars
-export const useSWRxPageList = (path: string | null, pageNumber?: number, termNumber?: number): SWRResponse<IPagingResult<IPageHasId>, Error> => {
-
-  const key = path != null
-    ? [`/pages/list?path=${path}&page=${pageNumber ?? 1}`, termNumber]
-    : null;
-
-  return useSWR(
-    key,
-    (endpoint: string) => apiv3Get<{pages: IPageHasId[], totalCount: number, limit: number}>(endpoint).then((response) => {
-      return {
-        items: response.data.pages,
-        totalCount: response.data.totalCount,
-        limit: response.data.limit,
-      };
-    }),
-  );
-};
-
-export const useDescendantsPageListForCurrentPathTermManager = (isDisabled?: boolean) : SWRResponse<number, Error> & ITermNumberManagerUtil => {
-  return useTermNumberManager(isDisabled === true ? null : 'descendantsPageListForCurrentPathTermNumber');
-};
-
-export const useSWRxDescendantsPageListForCurrrentPath = (pageNumber?: number): SWRResponse<IPagingResult<IPageHasId>, Error> => {
-  const { data: currentPagePath } = useCurrentPagePath();
-  const { data: termNumber } = useDescendantsPageListForCurrentPathTermManager();
-
-  const path = currentPagePath == null || termNumber == null
-    ? null
-    : currentPagePath;
-
-  return useSWRxPageList(path, pageNumber, termNumber);
-};
-
 export const useSWRxTagsInfo = (pageId: Nullable<string>): SWRResponse<IPageTagsInfo, Error> => {
   const key = pageId == null ? null : `/pages.getPageTag?pageId=${pageId}`;
 
@@ -120,48 +61,6 @@ export const useSWRxPageInfo = (
   );
 };
 
-type PageInfoInjector = {
-  injectTo: <D extends HasObjectId>(pages: (D | IDataWithMeta<D>)[]) => IDataWithMeta<D, IPageInfoForOperation>[],
-}
-
-const isIDataWithMeta = (item: HasObjectId | IDataWithMeta): item is IDataWithMeta => {
-  return 'data' in item;
-};
-
-export const useSWRxPageInfoForList = (
-    pageIds: string[] | null | undefined,
-    attachBookmarkCount = false,
-    attachShortBody = false,
-): SWRResponse<Record<string, IPageInfoForListing>, Error> & PageInfoInjector => {
-
-  const shouldFetch = pageIds != null && pageIds.length > 0;
-
-  const swrResult = useSWRImmutable<Record<string, IPageInfoForListing>>(
-    shouldFetch ? ['/page-listing/info', pageIds, attachBookmarkCount, attachShortBody] : null,
-    (endpoint, pageIds, attachBookmarkCount, attachShortBody) => {
-      return apiv3Get(endpoint, { pageIds, attachBookmarkCount, attachShortBody }).then(response => response.data);
-    },
-  );
-
-  return {
-    ...swrResult,
-    injectTo: <D extends HasObjectId>(pages: (D | IDataWithMeta<D>)[]) => {
-      return pages.map((item) => {
-        const page = isIDataWithMeta(item) ? item.data : item;
-        const orgPageMeta = isIDataWithMeta(item) ? item.meta : undefined;
-
-        // get an applicable IPageInfo
-        const applicablePageInfo = (swrResult.data ?? {})[page._id];
-
-        return {
-          data: page,
-          meta: applicablePageInfo ?? orgPageMeta,
-        };
-      });
-    },
-  };
-};
-
 export const useSWRxPageRevisions = (
     pageId: string,
     page: number, // page number of pagination

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