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

Merge branch 'support/apply-nextjs-2' into support/105164-Hackmd-error-handling

Yuken Tezuka 3 лет назад
Родитель
Сommit
d03977d19d

+ 66 - 43
packages/app/src/components/Navbar/GrowiContextualSubNavigation.tsx

@@ -1,6 +1,6 @@
 import React, { useState, useEffect, useCallback } from 'react';
 
-import { isPopulated } from '@growi/core';
+import { isPopulated, IUser } from '@growi/core';
 import { useTranslation } from 'next-i18next';
 import dynamic from 'next/dynamic';
 import { DropdownItem } from 'reactstrap';
@@ -9,13 +9,13 @@ import { exportAsMarkdown } from '~/client/services/page-operation';
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
 import { apiPost } from '~/client/util/apiv1-client';
 import {
-  IPageToRenameWithMeta, IPageWithMeta, IPageInfoForEntity, IPageHasId,
+  IPageToRenameWithMeta, IPageWithMeta, IPageInfoForEntity,
 } from '~/interfaces/page';
 import { IResTagsUpdateApiv1 } from '~/interfaces/tag';
 import { OnDuplicatedFunction, OnRenamedFunction, OnDeletedFunction } from '~/interfaces/ui';
 import {
-  useCurrentPageId,
-  useCurrentPathname, useIsNotFound,
+  useCurrentPageId, useCurrentPathname,
+  useIsNotFound,
   useCurrentUser, useIsGuestUser, useIsSharedUser, useShareLinkId, useTemplateTagData,
 } from '~/stores/context';
 import { usePageTagsForEditors } from '~/stores/editor';
@@ -39,9 +39,13 @@ import { Skelton } from '../Skelton';
 import { GrowiSubNavigation } from './GrowiSubNavigation';
 import { SubNavButtonsProps } from './SubNavButtons';
 
+import AuthorInfoStyles from './AuthorInfo.module.scss';
 import PageEditorModeManagerStyles from './PageEditorModeManager.module.scss';
 
 
+const AuthorInfoSkelton = () => <Skelton additionalClass={`${AuthorInfoStyles['grw-author-info-skelton']} py-1`} />;
+
+
 const PageEditorModeManager = dynamic(
   () => import('./PageEditorModeManager'),
   { ssr: false, loading: () => <Skelton additionalClass={`${PageEditorModeManagerStyles['grw-page-editor-mode-manager-skelton']}`} /> },
@@ -52,7 +56,10 @@ const SubNavButtons = dynamic<SubNavButtonsProps>(
   () => import('./SubNavButtons').then(mod => mod.SubNavButtons),
   { ssr: false, loading: () => <></> },
 );
-
+const AuthorInfo = dynamic(() => import('./AuthorInfo'), {
+  ssr: false,
+  loading: AuthorInfoSkelton,
+});
 
 type AdditionalMenuItemsProps = {
   pageId: string,
@@ -178,9 +185,9 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
   const { data: pageId } = useCurrentPageId();
   const { data: currentPathname } = useCurrentPathname();
   const { data: currentUser } = useCurrentUser();
+  const { data: isNotFound } = useIsNotFound();
   const { data: isGuestUser } = useIsGuestUser();
   const { data: isSharedUser } = useIsSharedUser();
-  const { data: isNotFound } = useIsNotFound();
   const { data: shareLinkId } = useShareLinkId();
 
   const { data: isAbleToShowPageManagement } = useIsAbleToShowPageManagement();
@@ -296,7 +303,7 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
   }, []);
 
 
-  const ControlComponents = useCallback(() => {
+  const RightComponent = useCallback(() => {
     const additionalMenuItemsRenderer = () => {
       if (revisionId == null || pageId == null) {
         return <></>;
@@ -313,34 +320,53 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
 
     return (
       <>
-        <div className="d-flex flex-column align-items-end justify-content-center py-md-2" style={{ gap: `${isCompactMode ? '5px' : '7px'}` }}>
-          { isViewMode && (
-            <div className="h-50 w-100">
-              { pageId != null && (
-                <SubNavButtons
-                  isCompactMode={isCompactMode}
-                  pageId={pageId}
-                  revisionId={revisionId}
-                  shareLinkId={shareLinkId}
-                  path={path}
-                  disableSeenUserInfoPopover={isSharedUser}
-                  showPageControlDropdown={isAbleToShowPageManagement}
-                  additionalMenuItemRenderer={additionalMenuItemsRenderer}
-                  onClickDuplicateMenuItem={duplicateItemClickedHandler}
-                  onClickRenameMenuItem={renameItemClickedHandler}
-                  onClickDeleteMenuItem={deleteItemClickedHandler}
-                />
-              ) }
-            </div>
+        <div className="d-flex">
+          <div className="d-flex flex-column align-items-end justify-content-center py-md-2" style={{ gap: `${isCompactMode ? '5px' : '7px'}` }}>
+            { isViewMode && (
+              <div className="h-50 w-100">
+                { pageId != null && (
+                  <SubNavButtons
+                    isCompactMode={isCompactMode}
+                    pageId={pageId}
+                    revisionId={revisionId}
+                    shareLinkId={shareLinkId}
+                    path={path}
+                    disableSeenUserInfoPopover={isSharedUser}
+                    showPageControlDropdown={isAbleToShowPageManagement}
+                    additionalMenuItemRenderer={additionalMenuItemsRenderer}
+                    onClickDuplicateMenuItem={duplicateItemClickedHandler}
+                    onClickRenameMenuItem={renameItemClickedHandler}
+                    onClickDeleteMenuItem={deleteItemClickedHandler}
+                  />
+                ) }
+              </div>
+            ) }
+            {isAbleToShowPageEditorModeManager && (
+              <PageEditorModeManager
+                onPageEditorModeButtonClicked={viewType => mutateEditorMode(viewType)}
+                isBtnDisabled={isGuestUser}
+                editorMode={editorMode}
+              />
+            )}
+          </div>
+          { (isAbleToShowPageAuthors && !isCompactMode) && (
+            <ul className={`${AuthorInfoStyles['grw-author-info']} text-nowrap border-left d-none d-lg-block d-edit-none py-2 pl-4 mb-0 ml-3`}>
+              <li className="pb-1">
+                { currentPage != null
+                  ? <AuthorInfo user={currentPage.creator as IUser} date={currentPage.createdAt} locate="subnav" />
+                  : <AuthorInfoSkelton />
+                }
+              </li>
+              <li className="mt-1 pt-1 border-top">
+                { currentPage != null
+                  ? <AuthorInfo user={currentPage.lastUpdateUser as IUser} date={currentPage.updatedAt} mode="update" locate="subnav" />
+                  : <AuthorInfoSkelton />
+                }
+              </li>
+            </ul>
           ) }
-          {isAbleToShowPageEditorModeManager && (
-            <PageEditorModeManager
-              onPageEditorModeButtonClicked={viewType => mutateEditorMode(viewType)}
-              isBtnDisabled={isGuestUser}
-              editorMode={editorMode}
-            />
-          )}
         </div>
+
         {path != null && currentUser != null && (
           <CreateTemplateModal
             path={path}
@@ -351,28 +377,25 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
       </>
     );
   // eslint-disable-next-line max-len
-  }, [currentUser, pageId, revisionId, shareLinkId, path, editorMode, isCompactMode, isViewMode, isSharedUser, isAbleToShowPageManagement, isAbleToShowPageEditorModeManager, isLinkSharingDisabled, isGuestUser, isPageTemplateModalShown, duplicateItemClickedHandler, renameItemClickedHandler, deleteItemClickedHandler, mutateEditorMode, templateMenuItemClickHandler]);
+  }, [isCompactMode, isViewMode, pageId, revisionId, shareLinkId, path, isSharedUser, isAbleToShowPageManagement, duplicateItemClickedHandler, renameItemClickedHandler, deleteItemClickedHandler, isAbleToShowPageEditorModeManager, isGuestUser, editorMode, isAbleToShowPageAuthors, currentPage, currentUser, isPageTemplateModalShown, isLinkSharingDisabled, templateMenuItemClickHandler, mutateEditorMode]);
 
-  if (currentPathname == null) {
-    return <></>;
-  }
 
-  const notFoundPage: Partial<IPageHasId> = {
-    path: currentPathname,
-  };
+  const pagePath = isNotFound
+    ? currentPathname
+    : currentPage?.path;
 
   return (
     <GrowiSubNavigation
-      page={currentPage ?? notFoundPage}
+      pagePath={pagePath}
+      pageId={currentPage?._id}
       showDrawerToggler={isDrawerMode}
       showTagLabel={isAbleToShowTagLabel}
-      showPageAuthors={isAbleToShowPageAuthors}
       isGuestUser={isGuestUser}
       isDrawerMode={isDrawerMode}
       isCompactMode={isCompactMode}
       tags={isViewMode ? tagsInfoData?.tags : tagsForEditors}
       tagsUpdatedHandler={isViewMode ? tagsUpdatedHandlerForViewMode : tagsUpdatedHandlerForEditMode}
-      controls={ControlComponents}
+      rightComponent={RightComponent}
       additionalClasses={['container-fluid']}
     />
   );

+ 18 - 40
packages/app/src/components/Navbar/GrowiSubNavigation.tsx

@@ -2,42 +2,37 @@ import React from 'react';
 
 import dynamic from 'next/dynamic';
 
-import { IPageHasId } from '~/interfaces/page';
-import { IUser } from '~/interfaces/user';
 import {
   EditorMode, useEditorMode,
 } from '~/stores/ui';
 
 import { TagLabelsSkelton } from '../Page/TagLabels';
 import PagePathNav from '../PagePathNav';
-import { Skelton } from '../Skelton';
 
 import DrawerToggler from './DrawerToggler';
 
-import AuthorInfoStyles from './AuthorInfo.module.scss';
+
 import styles from './GrowiSubNavigation.module.scss';
 
+
 const TagLabels = dynamic(() => import('../Page/TagLabels').then(mod => mod.TagLabels), {
   ssr: false,
-  loading: () => <TagLabelsSkelton />,
-});
-const AuthorInfo = dynamic(() => import('./AuthorInfo'), {
-  ssr: false,
-  loading: () => <Skelton additionalClass={`${AuthorInfoStyles['grw-author-info-skelton']} py-1`} />,
+  loading: TagLabelsSkelton,
 });
 
 
 export type GrowiSubNavigationProps = {
-  page: Partial<IPageHasId>,
+  pagePath?: string,
+  pageId?: string,
+  isNotFound?: boolean,
   showDrawerToggler?: boolean,
   showTagLabel?: boolean,
-  showPageAuthors?: boolean,
   isGuestUser?: boolean,
   isDrawerMode?: boolean,
   isCompactMode?: boolean,
   tags?: string[],
   tagsUpdatedHandler?: (newTags: string[]) => Promise<void> | void,
-  controls: React.FunctionComponent,
+  rightComponent: React.FunctionComponent,
   additionalClasses?: string[],
 }
 
@@ -46,11 +41,11 @@ export const GrowiSubNavigation = (props: GrowiSubNavigationProps): JSX.Element
   const { data: editorMode } = useEditorMode();
 
   const {
-    page,
-    showDrawerToggler, showTagLabel, showPageAuthors,
+    pageId, pagePath,
+    showDrawerToggler, showTagLabel,
     isGuestUser, isDrawerMode, isCompactMode,
     tags, tagsUpdatedHandler,
-    controls: Controls,
+    rightComponent: RightComponent,
     additionalClasses = [],
   } = props;
 
@@ -58,15 +53,6 @@ export const GrowiSubNavigation = (props: GrowiSubNavigationProps): JSX.Element
   const isEditorMode = !isViewMode;
   const compactModeClasses = isCompactMode ? 'grw-subnav-compact d-print-none' : '';
 
-  const {
-    _id: pageId, path, creator, lastUpdateUser,
-    createdAt, updatedAt,
-  } = page;
-
-  if (path == null) {
-    return <></>;
-  }
-
   return (
     <div className={`grw-subnav ${styles['grw-subnav']} d-flex align-items-center justify-content-between ${additionalClasses.join(' ')}
     ${compactModeClasses}`} >
@@ -80,27 +66,19 @@ export const GrowiSubNavigation = (props: GrowiSubNavigationProps): JSX.Element
         <div className="grw-path-nav-container">
           { (showTagLabel && !isCompactMode) && (
             <div className="grw-taglabels-container">
-              <TagLabels tags={tags} isGuestUser={isGuestUser ?? false} tagsUpdateInvoked={tagsUpdatedHandler} />
+              { tags != null
+                ? <TagLabels tags={tags} isGuestUser={isGuestUser ?? false} tagsUpdateInvoked={tagsUpdatedHandler} />
+                : <TagLabelsSkelton />
+              }
             </div>
           ) }
-          <PagePathNav pageId={pageId} pagePath={path} isSingleLineMode={isEditorMode} isCompactMode={isCompactMode} />
+          { pagePath != null && (
+            <PagePathNav pageId={pageId} pagePath={pagePath} isSingleLineMode={isEditorMode} isCompactMode={isCompactMode} />
+          ) }
         </div>
       </div>
       {/* Right side. */}
-      <div className="d-flex">
-        <Controls />
-        {/* Page Authors */}
-        { (showPageAuthors && !isCompactMode) && (
-          <ul className={`${AuthorInfoStyles['grw-author-info']} text-nowrap border-left d-none d-lg-block d-edit-none py-2 pl-4 mb-0 ml-3`}>
-            <li className="pb-1">
-              <AuthorInfo user={creator as IUser} date={createdAt} locate="subnav" />
-            </li>
-            <li className="mt-1 pt-1 border-top">
-              <AuthorInfo user={lastUpdateUser as IUser} date={updatedAt} mode="update" locate="subnav" />
-            </li>
-          </ul>
-        ) }
-      </div>
+      <RightComponent />
     </div>
   );
 };

+ 11 - 6
packages/app/src/components/Navbar/PersonalDropdown.jsx

@@ -2,6 +2,7 @@ import React, { useRef } from 'react';
 
 import { UserPicture } from '@growi/ui';
 import { useTranslation } from 'next-i18next';
+import Link from 'next/link';
 import { useRipple } from 'react-use-ripple';
 
 import { toastError } from '~/client/util/apiNotification';
@@ -53,12 +54,16 @@ const PersonalDropdown = () => {
           </div>
 
           <div className="btn-group btn-block mt-2" role="group">
-            <a className="btn btn-sm btn-outline-secondary col" href={`/user/${user.username}`}>
-              <i className="icon-fw icon-home"></i>{ t('personal_dropdown.home') }
-            </a>
-            <a className="btn btn-sm btn-outline-secondary col" href="/me">
-              <i className="icon-fw icon-wrench"></i>{ t('personal_dropdown.settings') }
-            </a>
+            <Link href={`/user/${user.username}`}>
+              <a className="btn btn-sm btn-outline-secondary col">
+                <i className="icon-fw icon-home"></i>{ t('personal_dropdown.home') }
+              </a>
+            </Link>
+            <Link href="/me">
+              <a className="btn btn-sm btn-outline-secondary col">
+                <i className="icon-fw icon-wrench"></i>{ t('personal_dropdown.settings') }
+              </a>
+            </Link>
           </div>
         </div>
 

+ 2 - 2
packages/app/src/components/PageEditor/CodeMirrorEditor.jsx

@@ -230,10 +230,10 @@ class CodeMirrorEditor extends AbstractEditor {
    * @inheritDoc
    */
   forceToFocus() {
-    const editor = this.getCodeMirror();
     // use setInterval with reluctance -- 2018.01.11 Yuki Takei
     const intervalId = setInterval(() => {
-      this.getCodeMirror().focus();
+      const editor = this.getCodeMirror();
+      editor.focus();
       if (editor.hasFocus()) {
         clearInterval(intervalId);
         // refresh

+ 34 - 7
packages/app/src/components/PageEditorByHackmd.tsx

@@ -15,7 +15,9 @@ import { IResHackmdIntegrated, IResHackmdDiscard } from '~/interfaces/hackmd';
 import {
   useCurrentPagePath, useCurrentPageId, useHackmdUri, usePageIdOnHackmd, useHasDraftOnHackmd, useRevisionIdHackmdSynced,
 } from '~/stores/context';
-import { useSWRxSlackChannels, useIsSlackEnabled, usePageTagsForEditors } from '~/stores/editor';
+import {
+  useSWRxSlackChannels, useIsSlackEnabled, usePageTagsForEditors, useIsEnabledUnsavedWarning,
+} from '~/stores/editor';
 import { useSWRxCurrentPage, useSWRxTagsInfo } from '~/stores/page';
 import {
   EditorMode,
@@ -64,6 +66,7 @@ export const PageEditorByHackmd = (): JSX.Element => {
   const { data: pageIdOnHackmd, mutate: mutatePageIdOnHackmd } = usePageIdOnHackmd();
   const { data: hasDraftOnHackmd, mutate: mutateHasDraftOnHackmd } = useHasDraftOnHackmd();
   const { data: revisionIdHackmdSynced, mutate: mutateRevisionIdHackmdSynced } = useRevisionIdHackmdSynced();
+  const { mutate: mutateIsEnabledUnsavedWarning } = useIsEnabledUnsavedWarning();
   const [isHackmdDraftUpdatingInRealtime, setIsHackmdDraftUpdatingInRealtime] = useState(false);
   const [remoteRevisionId, setRemoteRevisionId] = useState(revision?._id); // initialize
 
@@ -98,13 +101,26 @@ export const PageEditorByHackmd = (): JSX.Element => {
       await mutatePageData();
       await mutateTagsInfo();
       mutateEditorMode(EditorMode.View);
-      toastSuccess(t('successfully_saved_the_page'));
+      mutateIsEnabledUnsavedWarning(false);
     }
     catch (error) {
       logger.error('failed to save', error);
       toastError(error.message);
     }
-  }, [currentPagePath, currentPathname, editorMode, grant, isSlackEnabled, pageId, pageTags, revision, slackChannels, mutateEditorMode, mutatePageData, t]);
+  }, [editorMode,
+      isSlackEnabled,
+      currentPathname,
+      slackChannels,
+      grant,
+      revision,
+      pageTags,
+      pageId,
+      currentPagePath,
+      mutatePageData,
+      mutateEditorMode,
+      mutateTagsInfo,
+      mutateIsEnabledUnsavedWarning,
+  ]);
 
   // set handler to save and reload Page
   useEffect(() => {
@@ -208,6 +224,7 @@ export const PageEditorByHackmd = (): JSX.Element => {
       mutateRevisionIdHackmdSynced(res.page.revisionHackmdSynced);
       mutateHasDraftOnHackmd(res.page.hasDraftOnHackmd);
       mutateTagsInfo();
+      mutateIsEnabledUnsavedWarning(false);
 
       logger.debug('success to save');
 
@@ -217,10 +234,20 @@ export const PageEditorByHackmd = (): JSX.Element => {
       logger.error('failed to save', error);
       toastError(error.message);
     }
-  }, [
-    grant, isSlackEnabled, pageTags, slackChannels, pageId, currentPagePath, currentPathname, mutateTagsInfo, revision?._id,
-    revisionIdHackmdSynced, mutatePageData, mutateHasDraftOnHackmd, mutateRevisionIdHackmdSynced, t, pageData,
-  ]);
+  }, [isSlackEnabled,
+      grant,
+      slackChannels,
+      pageId,
+      revisionIdHackmdSynced,
+      currentPathname,
+      pageTags,
+      currentPagePath,
+      mutatePageData,
+      mutateRevisionIdHackmdSynced,
+      mutateHasDraftOnHackmd,
+      mutateTagsInfo,
+      mutateIsEnabledUnsavedWarning,
+      t]);
 
   /**
    * onChange event of HackmdEditor handler

+ 8 - 7
packages/app/src/components/PasswordResetExecutionForm.jsx → packages/app/src/components/PasswordResetExecutionForm.tsx

@@ -1,6 +1,7 @@
-import React, { useState } from 'react';
+import React, { FC, useState } from 'react';
 
 import { useTranslation } from 'next-i18next';
+import Link from 'next/link';
 
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
 import { apiv3Put } from '~/client/util/apiv3-client';
@@ -9,7 +10,7 @@ import loggerFactory from '~/utils/logger';
 const logger = loggerFactory('growi:passwordReset');
 
 
-const PasswordResetExecutionForm = (props) => {
+const PasswordResetExecutionForm: FC = () => {
   const { t } = useTranslation();
 
   const [newPassword, setNewPassword] = useState('');
@@ -79,14 +80,14 @@ const PasswordResetExecutionForm = (props) => {
       <div className="form-group">
         <input name="reset-password-btn" className="btn btn-lg btn-primary btn-block" value={t('forgot_password.reset_password')} type="submit" />
       </div>
-      <a href="/login">
-        <i className="icon-login mr-1"></i>{t('forgot_password.sign_in_instead')}
-      </a>
+      <Link href="/login" prefetch={false}>
+        <a>
+          <i className="icon-login mr-1"></i>{t('forgot_password.sign_in_instead')}
+        </a>
+      </Link>
     </form>
   );
 };
 
-PasswordResetExecutionForm.propTypes = {
-};
 
 export default PasswordResetExecutionForm;

+ 2 - 8
packages/app/src/components/SavePageControls.tsx

@@ -36,7 +36,6 @@ export const SavePageControls = (props: Props): JSX.Element | null => {
   const { data: isAclEnabled } = useIsAclEnabled();
   const { data: grantData, mutate: mutateGrant } = useSelectedGrant();
   const { data: pageId } = useCurrentPageId();
-  const { mutate: mutateIsEnabledUnsavedWarning } = useIsEnabledUnsavedWarning();
 
 
   const updateGrantHandler = useCallback((grantData: IPageGrantData): void => {
@@ -44,19 +43,14 @@ export const SavePageControls = (props: Props): JSX.Element | null => {
   }, [mutateGrant]);
 
   const save = useCallback(async(): Promise<void> => {
-    // disable unsaved warning
-    mutateIsEnabledUnsavedWarning(false);
-
     // save
     (window as CustomWindow).globalEmitter.emit('saveAndReturnToView');
-  }, [mutateIsEnabledUnsavedWarning]);
+  }, []);
 
   const saveAndOverwriteScopesOfDescendants = useCallback(() => {
-    // disable unsaved warning
-    mutateIsEnabledUnsavedWarning(false);
     // save
     (window as CustomWindow).globalEmitter.emit('saveAndReturnToView', { overwriteScopesOfDescendants: true });
-  }, [mutateIsEnabledUnsavedWarning]);
+  }, []);
 
 
   if (isEditable == null || isAclEnabled == null || grantData == null) {

+ 4 - 3
packages/app/src/components/SearchPage/SearchResultContent.tsx

@@ -170,7 +170,7 @@ export const SearchResultContent: FC<Props> = (props: Props) => {
     openDeleteModal([pageToDelete], { onDeleted: onDeletedHandler });
   }, [onDeletedHandler, openDeleteModal]);
 
-  const ControlComponents = useCallback(() => {
+  const RightComponent = useCallback(() => {
     if (page == null) {
       return <></>;
     }
@@ -202,8 +202,9 @@ export const SearchResultContent: FC<Props> = (props: Props) => {
     <div key={page._id} data-testid="search-result-content" className="search-result-content grw-page-path-text-muted-container d-flex flex-column">
       <div className="grw-subnav-append-shadow-container">
         <GrowiSubNavigation
-          page={page}
-          controls={ControlComponents}
+          pagePath={page.path}
+          pageId={page._id}
+          rightComponent={RightComponent}
           isCompactMode
           additionalClasses={['px-4']}
         />

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

@@ -11,8 +11,8 @@ export const Skelton = (props: SkeltonProps): JSX.Element => {
   } = props;
 
   return (
-    <div className={`${additionalClass}`}>
-      <div className={`grw-skelton h-100 w-100 ${roundedPill ? 'rounded-pill' : ''}`}></div>
+    <div className={`${additionalClass ?? ''}`}>
+      <div className={`grw-skelton h-100 w-100 ${roundedPill ?? ''}`}></div>
     </div>
   );
 };

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

@@ -237,16 +237,16 @@ const GrowiPage: NextPage<Props> = (props: Props) => {
   }
 
   const pageId = pageWithMeta?.data._id;
-  const pagePath = pageWithMeta?.data.path ?? props.currentPathname;
+  const pagePath = pageWithMeta?.data.path ?? (!_isPermalink(props.currentPathname) ? props.currentPathname : undefined);
 
   useCurrentPageId(pageId);
   useSWRxCurrentPage(undefined, pageWithMeta?.data); // store initial data
-  useIsUserPage(isUserPage(pagePath));
+  useIsUserPage(pagePath != null && isUserPage(pagePath));
   // useIsNotCreatable(props.isForbidden || !isCreatablePage(pagePath)); // TODO: need to include props.isIdentical
   useCurrentPagePath(pagePath);
   useCurrentPathname(props.currentPathname);
   useEditingMarkdown(pageWithMeta?.data.revision?.body);
-  useIsTrashPage(_isTrashPage(pagePath));
+  useIsTrashPage(pagePath != null && _isTrashPage(pagePath));
 
   const { data: grantData } = useSWRxIsGrantNormalized(pageId);
   const { mutate: mutateSelectedGrant } = useSelectedGrant();

+ 1 - 1
packages/app/src/pages/forgot-password-errors.page.tsx

@@ -37,7 +37,7 @@ const ForgotPasswordErrorsPage: NextPage<Props> = (props: Props) => {
                   <h3 className="text-muted">{ t('forgot_password.feature_is_unavailable') }</h3>
                 )}
 
-                { errorCode === (forgotPasswordErrorCode.PASSWORD_RESET_ORDER_IS_NOT_APPROPRIATE || forgotPasswordErrorCode.TOKEN_NOT_FOUND) && (
+                { (errorCode === forgotPasswordErrorCode.PASSWORD_RESET_ORDER_IS_NOT_APPROPRIATE || errorCode === forgotPasswordErrorCode.TOKEN_NOT_FOUND) && (
                   <div>
                     <div className="alert alert-warning mb-3">
                       <h2>{ t('forgot_password.incorrect_token_or_expired_url') }</h2>

+ 1 - 1
packages/app/src/pages/me/[[...path]].page.tsx

@@ -196,7 +196,7 @@ export const getServerSideProps: GetServerSideProps = async(context: GetServerSi
 
   await injectUserUISettings(context, props);
   await injectServerConfigurations(context, props);
-  await injectNextI18NextConfigurations(context, props, ['translation']);
+  await injectNextI18NextConfigurations(context, props, ['translation', 'admin']);
 
   return {
     props,

+ 72 - 0
packages/app/src/pages/reset-password.page.tsx

@@ -0,0 +1,72 @@
+import React from 'react';
+
+import { NextPage, GetServerSideProps, GetServerSidePropsContext } from 'next';
+import { useTranslation } from 'next-i18next';
+import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
+import dynamic from 'next/dynamic';
+
+import {
+  CommonProps, getNextI18NextConfig, getServerSideCommonProps,
+} from './utils/commons';
+
+
+type Props = CommonProps & {
+  email: string
+};
+
+const PasswordResetExecutionForm = dynamic(() => import('~/components/PasswordResetExecutionForm'), { ssr: false });
+
+const ForgotPasswordPage: NextPage<Props> = (props: Props) => {
+  const { t } = useTranslation();
+
+  return (
+    <div id="main" className="main">
+      <div id="content-main" className="content-main container-lg">
+        <div className="container">
+          <div className="row justify-content-md-center">
+            <div className="col-md-6 mt-5">
+              <div className="text-center">
+                <h1><i className="icon-lock-open large"></i></h1>
+                <h2 className="text-center">{ t('forgot_password.reset_password') }</h2>
+                <h5>{ props.email }</h5>
+                <p className="mt-4">{ t('forgot_password.password_reset_excecution_desc') }</p>
+                <PasswordResetExecutionForm />
+              </div>
+            </div>
+          </div>
+        </div>
+      </div>
+    </div>
+  );
+};
+
+// eslint-disable-next-line max-len
+async function injectNextI18NextConfigurations(context: GetServerSidePropsContext, props: Props, namespacesRequired?: string[] | undefined): Promise<void> {
+  const nextI18NextConfig = await getNextI18NextConfig(serverSideTranslations, context, namespacesRequired);
+  props._nextI18Next = nextI18NextConfig._nextI18Next;
+}
+
+export const getServerSideProps: GetServerSideProps = async(context: GetServerSidePropsContext) => {
+  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;
+
+  const email = context.query.email;
+  if (typeof email === 'string') {
+    props.email = email;
+  }
+
+  await injectNextI18NextConfigurations(context, props, ['translation']);
+
+  return {
+    props,
+  };
+};
+
+export default ForgotPasswordPage;

+ 6 - 0
packages/app/src/server/middlewares/inject-reset-order-by-token-middleware.ts

@@ -2,9 +2,12 @@ import { NextFunction, Request, Response } from 'express';
 import createError from 'http-errors';
 
 import { forgotPasswordErrorCode } from '~/interfaces/errors/forgot-password';
+import loggerFactory from '~/utils/logger';
 
 import PasswordResetOrder, { IPasswordResetOrder } from '../models/password-reset-order';
 
+const logger = loggerFactory('growi:routes:forgot-password');
+
 export type ReqWithPasswordResetOrder = Request & {
   passwordResetOrder: IPasswordResetOrder,
 };
@@ -14,6 +17,7 @@ export default async(req: ReqWithPasswordResetOrder, res: Response, next: NextFu
   const token = req.params.token || req.body.token;
 
   if (token == null) {
+    logger.error('Token not found');
     return next(createError(400, 'Token not found', { code: forgotPasswordErrorCode.TOKEN_NOT_FOUND }));
   }
 
@@ -21,6 +25,8 @@ export default async(req: ReqWithPasswordResetOrder, res: Response, next: NextFu
 
   // check if the token is valid
   if (passwordResetOrder == null || passwordResetOrder.isExpired() || passwordResetOrder.isRevoked) {
+    const message = 'passwordResetOrder is null or expired or revoked';
+    logger.error(message);
     return next(createError(
       400,
       'passwordResetOrder is null or expired or revoked',

+ 19 - 10
packages/app/src/server/routes/forgot-password.ts

@@ -6,7 +6,7 @@ import createError from 'http-errors';
 import { forgotPasswordErrorCode } from '~/interfaces/errors/forgot-password';
 import loggerFactory from '~/utils/logger';
 
-import { ReqWithPasswordResetOrder } from '../middlewares/inject-reset-order-by-token-middleware';
+import { IPasswordResetOrder } from '../models/password-reset-order';
 
 const logger = loggerFactory('growi:routes:forgot-password');
 
@@ -33,15 +33,6 @@ export const checkForgotPasswordEnabledMiddlewareFactory = (crowi: any, forApi =
 
 };
 
-export const forgotPassword = (req: Request, res: Response): void => {
-  return res.render('forgot-password');
-};
-
-export const resetPassword = (req: ReqWithPasswordResetOrder, res: Response): void => {
-  const { passwordResetOrder } = req;
-  return res.render('reset-password', { email: passwordResetOrder.email });
-};
-
 type Crowi = {
   // eslint-disable-next-line @typescript-eslint/no-explicit-any
   nextApp: any,
@@ -51,6 +42,24 @@ type CrowiReq = Request & {
   crowi: Crowi,
 }
 
+export const renderForgotPassword = (crowi: Crowi) => {
+  return (req: CrowiReq, res: Response, next: NextFunction): void => {
+    const { nextApp } = crowi;
+    req.crowi = crowi;
+    nextApp.render(req, res, '/forgot-password');
+    return;
+  };
+};
+
+export const renderResetPassword = (crowi: Crowi) => {
+  return (req: CrowiReq & { passwordResetOrder: IPasswordResetOrder }, res: Response, next: NextFunction): void => {
+    const { nextApp } = crowi;
+    req.crowi = crowi;
+    nextApp.render(req, res, '/reset-password', { email: req.passwordResetOrder.email });
+    return;
+  };
+};
+
 // middleware to handle error
 export const handleErrorsMiddleware = (crowi: Crowi) => {
   return (error: Error & { code: string, statusCode: number }, req: CrowiReq, res: Response, next: NextFunction): void => {

+ 3 - 2
packages/app/src/server/routes/index.js

@@ -203,6 +203,7 @@ module.exports = function(crowi, app) {
   // app.get('/tags'                     , loginRequired, tag.showPage);
   app.get('/tags', loginRequired, next.delegateToNext);
 
+  app.get('/me'                                 , loginRequiredStrictly, injectUserUISettings, next.delegateToNext);
   app.get('/me/*'                                 , loginRequiredStrictly, injectUserUISettings, next.delegateToNext);
   // external-accounts
   // my in-app-notifications
@@ -231,8 +232,8 @@ module.exports = function(crowi, app) {
 
   app.use('/forgot-password', express.Router()
     .use(forgotPassword.checkForgotPasswordEnabledMiddlewareFactory(crowi))
-    .get('/forgot-password', next.delegateToNext)
-    .get('/:token', injectResetOrderByTokenMiddleware, forgotPassword.resetPassword, next.delegateToNext) // TODO: 104986
+    .get('/', forgotPassword.renderForgotPassword(crowi))
+    .get('/:token', injectResetOrderByTokenMiddleware, forgotPassword.renderResetPassword(crowi))
     .use(forgotPassword.handleErrorsMiddleware(crowi)));
 
   app.get('/_private-legacy-pages', next.delegateToNext);

+ 3 - 1
packages/app/src/server/routes/next.ts

@@ -12,7 +12,7 @@ type CrowiReq = Request & {
 }
 
 // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
-export default (crowi: Crowi) => {
+const delegator = (crowi: Crowi) => {
 
   const { nextApp } = crowi;
   const handle = nextApp.getRequestHandler();
@@ -27,3 +27,5 @@ export default (crowi: Crowi) => {
   };
 
 };
+
+export default delegator;

+ 2 - 2
packages/app/src/stores/context.tsx

@@ -55,8 +55,8 @@ export const useCurrentPagePath = (initialData?: Nullable<string>): SWRResponse<
   return useStaticSWR<Nullable<string>, Error>('currentPagePath', initialData);
 };
 
-export const useCurrentPathname = (initialData?: Nullable<string>): SWRResponse<Nullable<string>, Error> => {
-  return useStaticSWR<Nullable<string>, Error>('currentPathname', initialData);
+export const useCurrentPathname = (initialData?: string): SWRResponse<string, Error> => {
+  return useStaticSWR('currentPathname', initialData);
 };
 
 export const useCurrentPageId = (initialData?: Nullable<string>): SWRResponse<Nullable<string>, Error> => {

+ 6 - 5
packages/app/src/stores/ui.tsx

@@ -21,7 +21,7 @@ import { UpdateDescCountData } from '~/interfaces/websocket';
 import loggerFactory from '~/utils/logger';
 
 import {
-  useCurrentPageId, useCurrentPagePath, useIsEditable, useIsTrashPage, useIsUserPage, useIsGuestUser,
+  useCurrentPageId, useCurrentPagePath, useIsEditable, useIsTrashPage, useIsGuestUser,
   useIsSharedUser, useIsIdenticalPath, useCurrentUser, useIsNotFound, useShareLinkId,
 } from './context';
 import { localStorageMiddleware } from './middlewares/sync-to-storage';
@@ -458,14 +458,15 @@ export const useIsAbleToShowPageEditorModeManager = (): SWRResponse<boolean, Err
 export const useIsAbleToShowPageAuthors = (): SWRResponse<boolean, Error> => {
   const key = 'isAbleToShowPageAuthors';
   const { data: pageId } = useCurrentPageId();
-  const { data: isUserPage } = useIsUserPage();
+  const { data: pagePath } = useCurrentPagePath();
   const { data: isNotFound } = useIsNotFound();
 
-  const includesUndefined = [pageId, isUserPage, isNotFound].some(v => v === undefined);
+  const includesUndefined = [pageId, pagePath, isNotFound].some(v => v === undefined);
   const isPageExist = (pageId != null) && !isNotFound;
+  const isUsersTopPagePath = pagePath != null && isUsersTopPage(pagePath);
 
   return useSWRImmutable(
-    includesUndefined ? null : [key, pageId],
-    () => isPageExist && !isUserPage,
+    includesUndefined ? null : [key, pageId, pagePath, isNotFound],
+    () => isPageExist && !isUsersTopPagePath,
   );
 };