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

Merge branch 'support/apply-nextjs-2' of https://github.com/weseek/growi into feat/105499-create-activity-when-shared-page-is-viewed

Shun Miyazawa 3 лет назад
Родитель
Сommit
ef089268f1

+ 0 - 0
packages/app/src/client/services/ContextExtractor.tsx → packages/app/_obsolete/src/client/services/ContextExtractor.tsx


+ 1 - 1
packages/app/config/rate-limiter.ts

@@ -33,7 +33,7 @@ export const defaultConfig: IApiRateLimitEndpointMap = {
     maxRequests: MAX_REQUESTS_TIER_1,
     usersPerIpProspection: 100,
   },
-  '/invited/activateInvited': {
+  '/invited': {
     method: 'POST',
     maxRequests: MAX_REQUESTS_TIER_2,
   },

+ 10 - 4
packages/app/src/components/ContentLinkButtons.tsx

@@ -1,8 +1,9 @@
 import React, { useCallback } from 'react';
 
+import { IUserHasId } from '@growi/core';
+
 import { smoothScrollIntoView } from '~/client/util/smooth-scroll';
 import { RecentlyCreatedIcon } from '~/components/Icons/RecentlyCreatedIcon';
-import { usePageUser } from '~/stores/context';
 
 import styles from './ContentLinkButtons.module.scss';
 
@@ -52,11 +53,16 @@ const RecentlyCreatedLinkButton = React.memo(() => {
 
 RecentlyCreatedLinkButton.displayName = 'RecentlyCreatedLinkButton';
 
-export const ContentLinkButtons = (): JSX.Element => {
 
-  const { data: pageUser } = usePageUser();
+export type ContentLinkButtonsProps = {
+  author?: IUserHasId,
+}
+
+export const ContentLinkButtons = (props: ContentLinkButtonsProps): JSX.Element => {
+
+  const { author } = props;
 
-  if (pageUser == null || pageUser.status === 4) {
+  if (author == null || author.status === 4) {
     return <></>;
   }
 

+ 72 - 12
packages/app/src/components/InvitedForm.tsx

@@ -1,8 +1,12 @@
-import React from 'react';
+import React, { useCallback, useState } from 'react';
 
 import { useTranslation } from 'next-i18next';
+import { useRouter } from 'next/router';
+
+import { apiv3Post } from '~/client/util/apiv3-client';
+
+import { useCurrentUser } from '../stores/context';
 
-import { useCsrfToken, useCurrentUser } from '../stores/context';
 
 export type InvitedFormProps = {
   invitedFormUsername: string,
@@ -10,23 +14,79 @@ export type InvitedFormProps = {
 }
 
 export const InvitedForm = (props: InvitedFormProps): JSX.Element => {
+
   const { t } = useTranslation();
-  const { data: csrfToken } = useCsrfToken();
+  const router = useRouter();
   const { data: user } = useCurrentUser();
+  const [isConnectSuccess, setIsConnectSuccess] = useState<boolean>(false);
+  const [loginErrors, setLoginErrors] = useState<Error[]>([]);
 
   const { invitedFormUsername, invitedFormName } = props;
 
+  const submitHandler = useCallback(async(e) => {
+    e.preventDefault();
+
+    const formData = e.target.elements;
+
+    const {
+      'invitedForm[name]': { value: name },
+      'invitedForm[password]': { value: password },
+      'invitedForm[username]': { value: username },
+    } = formData;
+
+    const invitedForm = {
+      name,
+      password,
+      username,
+    };
+
+    try {
+      const res = await apiv3Post('/invited', { invitedForm });
+      setIsConnectSuccess(true);
+      const { redirectTo } = res.data;
+      router.push(redirectTo);
+    }
+    catch (err) {
+      setLoginErrors(err);
+    }
+  }, [router]);
+
+  const formNotification = useCallback(() => {
+
+    if (isConnectSuccess) {
+      return (
+        <p className="alert alert-success">
+          <strong>{ t('message.successfully_connected') }</strong><br></br>
+        </p>
+      );
+    }
+
+    return (
+      <>
+        { loginErrors != null && loginErrors.length > 0 ? (
+          <p className="alert alert-danger">
+            { loginErrors.map((err, index) => {
+              return <span key={index}>{ t(err.message) }<br/></span>;
+            }) }
+          </p>
+        ) : (
+          <p className="alert alert-success">
+            <strong>{ t('invited.discription_heading') }</strong><br></br>
+            <small>{ t('invited.discription') }</small>
+          </p>
+        ) }
+      </>
+    );
+  }, [isConnectSuccess, loginErrors, t]);
+
   if (user == null) {
     return <></>;
   }
 
   return (
-    <div className="noLogin-dialog p-3 mx-auto" id="noLogin-dialog">
-      <p className="alert alert-success">
-        <strong>{ t('invited.discription_heading') }</strong><br></br>
-        <small>{ t('invited.discription') }</small>
-      </p>
-      <form role="form" action="/invited/activateInvited" method="post" id="invited-form">
+    <div className="noLogin-dialog px-3 pb-3 mx-auto" id="noLogin-dialog">
+      { formNotification() }
+      <form role="form" onSubmit={submitHandler} id="invited-form">
         {/* Email Form */}
         <div className="input-group">
           <div className="input-group-prepend">
@@ -89,11 +149,11 @@ export const InvitedForm = (props: InvitedFormProps): JSX.Element => {
             placeholder={t('Password')}
             name="invitedForm[password]"
             required
+            minLength={6}
           />
         </div>
         {/* Create Button */}
-        <div className="input-group justify-content-center d-flex mt-5">
-          <input type="hidden" name="_csrf" value={csrfToken} />
+        <div className="input-group justify-content-center d-flex mt-4">
           <button type="submit" className="btn btn-fill" id="register">
             <div className="eff"></div>
             <span className="btn-label"><i className="icon-user-follow"></i></span>
@@ -101,7 +161,7 @@ export const InvitedForm = (props: InvitedFormProps): JSX.Element => {
           </button>
         </div>
       </form>
-      <div className="input-group mt-5 d-flex justify-content-center">
+      <div className="input-group mt-4 d-flex justify-content-center">
         <a href="https://growi.org" className="link-growi-org">
           <span className="growi">GROWI</span>.<span className="org">ORG</span>
         </a>

+ 6 - 4
packages/app/src/components/Page/DisplaySwitcher.tsx

@@ -13,10 +13,12 @@ import { useSWRxCurrentPage } from '~/stores/page';
 import { EditorMode, useEditorMode } from '~/stores/ui';
 
 import CountBadge from '../Common/CountBadge';
+import { ContentLinkButtonsProps } from '../ContentLinkButtons';
 import CustomTabContent from '../CustomNavigation/CustomTabContent';
 import PageListIcon from '../Icons/PageListIcon';
 import { Page } from '../Page';
 import TableOfContents from '../TableOfContents';
+import { UserInfoProps } from '../User/UserInfo';
 
 import styles from './DisplaySwitcher.module.scss';
 
@@ -27,9 +29,9 @@ const PageEditor = dynamic(() => import('../PageEditor'), { ssr: false });
 const PageEditorByHackmd = dynamic(() => import('../PageEditorByHackmd').then(mod => mod.PageEditorByHackmd), { ssr: false });
 const EditorNavbarBottom = dynamic(() => import('../PageEditor/EditorNavbarBottom'), { ssr: false });
 const HashChanged = dynamic(() => import('../EventListeneres/HashChanged'), { ssr: false });
-const ContentLinkButtons = dynamic(() => import('../ContentLinkButtons').then(mod => mod.ContentLinkButtons), { ssr: false });
+const ContentLinkButtons = dynamic<ContentLinkButtonsProps>(() => import('../ContentLinkButtons').then(mod => mod.ContentLinkButtons), { ssr: false });
 const NotFoundPage = dynamic(() => import('../NotFoundPage'), { ssr: false });
-const UserInfo = dynamic(() => import('../User/UserInfo').then(mod => mod.UserInfo), { ssr: false });
+const UserInfo = dynamic<UserInfoProps>(() => import('../User/UserInfo').then(mod => mod.UserInfo), { ssr: false });
 
 
 const PageView = React.memo((): JSX.Element => {
@@ -49,7 +51,7 @@ const PageView = React.memo((): JSX.Element => {
     <div className="d-flex flex-column flex-lg-row">
 
       <div className="flex-grow-1 flex-basis-0 mw-0">
-        { isUsersHomePagePath && <UserInfo /> }
+        { isUsersHomePagePath && <UserInfo author={currentPage?.creator} /> }
         { !isNotFound && <Page /> }
         { isNotFound && <NotFoundPage /> }
       </div>
@@ -94,7 +96,7 @@ const PageView = React.memo((): JSX.Element => {
 
             <div className="d-none d-lg-block">
               <TableOfContents />
-              { isUsersHomePagePath && <ContentLinkButtons /> }
+              { isUsersHomePagePath && <ContentLinkButtons author={currentPage?.creator} /> }
             </div>
 
           </div>

+ 19 - 9
packages/app/src/components/PageComment/CommentEditor.tsx

@@ -81,7 +81,7 @@ export const CommentEditor = (props: CommentEditorProps): JSX.Element => {
   const [comment, setComment] = useState(commentBody ?? '');
   const [activeTab, setActiveTab] = useState('comment_editor');
   const [error, setError] = useState();
-  const [slackChannels, setSlackChannels] = useState(slackChannelsData?.toString());
+  const [slackChannels, setSlackChannels] = useState<string>('');
 
   const editorRef = useRef<IEditorMethods>(null);
 
@@ -90,9 +90,19 @@ export const CommentEditor = (props: CommentEditorProps): JSX.Element => {
   }, []);
 
   useEffect(() => {
-    if (slackChannels === undefined) { return }
-    setSlackChannels(slackChannelsData?.toString());
-  }, [slackChannelsData, slackChannels]);
+    if (slackChannelsData != null) {
+      setSlackChannels(slackChannelsData.toString());
+      mutateIsSlackEnabled(false);
+    }
+  }, [mutateIsSlackEnabled, slackChannelsData]);
+
+  const isSlackEnabledToggleHandler = (isSlackEnabled: boolean) => {
+    mutateIsSlackEnabled(isSlackEnabled, false);
+  };
+
+  const slackChannelsChangedHandler = useCallback((slackChannels: string) => {
+    setSlackChannels(slackChannels);
+  }, []);
 
   const initializeEditor = useCallback(() => {
     setComment('');
@@ -289,14 +299,14 @@ export const CommentEditor = (props: CommentEditorProps): JSX.Element => {
             <span className="flex-grow-1" />
             <span className="d-none d-sm-inline">{ errorMessage && errorMessage }</span>
 
-            { isSlackConfigured
+            { isSlackConfigured && isSlackEnabled != null
               && (
                 <div className="form-inline align-self-center mr-md-2">
                   <SlackNotification
-                    isSlackEnabled
-                    slackChannels={slackChannelsData?.toString() ?? ''}
-                    onEnabledFlagChange={isSlackEnabled => mutateIsSlackEnabled(isSlackEnabled, false)}
-                    onChannelChange={setSlackChannels}
+                    isSlackEnabled={isSlackEnabled}
+                    slackChannels={slackChannels}
+                    onEnabledFlagChange={isSlackEnabledToggleHandler}
+                    onChannelChange={slackChannelsChangedHandler}
                     id="idForComment"
                   />
                 </div>

+ 7 - 2
packages/app/src/components/PageCreateModal.jsx

@@ -5,6 +5,7 @@ import React, {
 import { pagePathUtils, pathUtils } from '@growi/core';
 import { format } from 'date-fns';
 import { useTranslation } from 'next-i18next';
+import { useRouter } from 'next/router';
 import { Modal, ModalHeader, ModalBody } from 'reactstrap';
 import { debounce } from 'throttle-debounce';
 
@@ -21,6 +22,7 @@ const {
 
 const PageCreateModal = () => {
   const { t } = useTranslation();
+  const router = useRouter();
 
   const { data: currentUser } = useCurrentUser();
 
@@ -98,7 +100,10 @@ const PageCreateModal = () => {
   async function redirectToEditor(...paths) {
     try {
       const editorPath = await generateEditorPath(...paths);
-      window.location.href = editorPath;
+      router.push(editorPath);
+
+      // close modal
+      closeCreateModal();
     }
     catch (err) {
       toastError(err);
@@ -203,7 +208,7 @@ const PageCreateModal = () => {
               {isReachable
                 ? (
                   <PagePathAutoComplete
-                    initializedPath={pageNameInput}
+                    initializedPath={pageNameInputInitialValue}
                     addTrailingSlash
                     onSubmit={ppacSubmitHandler}
                     onInputChange={value => setPageNameInput(value)}

+ 6 - 4
packages/app/src/components/PageEditor.tsx

@@ -13,7 +13,8 @@ import { apiGet, apiPostForm } from '~/client/util/apiv1-client';
 import { getOptionsToSave } from '~/client/util/editor';
 import { IEditorMethods } from '~/interfaces/editor-methods';
 import {
-  useIsEditable, useIsIndentSizeForced, useCurrentPagePath, useCurrentPathname, useCurrentPageId, useIsUploadableFile, useIsUploadableImage,
+  useCurrentPagePath, useCurrentPathname, useCurrentPageId, useEditingMarkdown,
+  useIsEditable, useIsIndentSizeForced, useIsUploadableFile, useIsUploadableImage,
 } from '~/stores/context';
 import {
   useCurrentIndentSize, useSWRxSlackChannels, useIsSlackEnabled, useIsTextlintEnabled, usePageTagsForEditors,
@@ -51,6 +52,7 @@ const PageEditor = React.memo((): JSX.Element => {
   const { data: currentPagePath } = useCurrentPagePath();
   const { data: currentPathname } = useCurrentPathname();
   const { data: currentPage, mutate: mutateCurrentPage } = useSWRxCurrentPage();
+  const { data: editingMarkdown } = useEditingMarkdown();
   const { data: grantData, mutate: mutateGrant } = useSelectedGrant();
   const { data: pageTags } = usePageTagsForEditors(pageId);
 
@@ -69,10 +71,10 @@ const PageEditor = React.memo((): JSX.Element => {
   const { data: rendererOptions } = usePreviewOptions();
 
   const currentRevisionId = currentPage?.revision?._id;
-  const initialValue = currentPage?.revision?.body;
+  const initialValue = editingMarkdown ?? '';
 
-  const markdownToSave = useRef<string>(initialValue ?? '');
-  const [markdownToPreview, setMarkdownToPreview] = useState<string>(initialValue ?? '');
+  const markdownToSave = useRef<string>(initialValue);
+  const [markdownToPreview, setMarkdownToPreview] = useState<string>(initialValue);
 
   const slackChannels = useMemo(() => (slackChannelsData ? slackChannelsData.toString() : ''), [slackChannelsData]);
 

+ 51 - 46
packages/app/src/components/PageEditorByHackmd.tsx

@@ -18,7 +18,7 @@ import {
 import {
   useSWRxSlackChannels, useIsSlackEnabled, usePageTagsForEditors, useIsEnabledUnsavedWarning,
 } from '~/stores/editor';
-import { useSWRxCurrentPage } from '~/stores/page';
+import { useSWRxCurrentPage, useSWRxTagsInfo } from '~/stores/page';
 import {
   EditorMode,
   useEditorMode, useSelectedGrant,
@@ -44,12 +44,13 @@ export const PageEditorByHackmd = (): JSX.Element => {
   const { data: slackChannelsData } = useSWRxSlackChannels(currentPagePath);
   const { data: isSlackEnabled } = useIsSlackEnabled();
   const { data: pageId } = useCurrentPageId();
-  const { data: pageTags, mutate: updatePageTagsForEditors } = usePageTagsForEditors(pageId);
+  const { data: pageTags } = usePageTagsForEditors(pageId);
+  const { mutate: mutateTagsInfo } = useSWRxTagsInfo(pageId);
   const { data: grant } = useSelectedGrant();
   const { data: hackmdUri } = useHackmdUri();
 
   // pageData
-  const { data: pageData, mutate: updatePageData } = useSWRxCurrentPage();
+  const { data: pageData, mutate: mutatePageData } = useSWRxCurrentPage();
   const revision = pageData?.revision;
 
   const slackChannels = slackChannelsData?.toString();
@@ -72,35 +73,40 @@ export const PageEditorByHackmd = (): JSX.Element => {
   const hackmdEditorRef = useRef<HackEditorRef>(null);
 
   const saveAndReturnToViewHandler = useCallback(async(opts?: {overwriteScopesOfDescendants: boolean}) => {
-    if (editorMode !== EditorMode.HackMD) {
-      return;
-    }
+    if (editorMode !== EditorMode.HackMD) { return }
 
-    if (isSlackEnabled == null || currentPathname == null || slackChannels == null || grant == null || revision == null || hackmdEditorRef.current == null) {
-      return;
-    }
+    try {
+      if (isSlackEnabled == null || currentPathname == null || slackChannels == null || grant == null || revision == null || hackmdEditorRef.current == null) {
+        throw new Error('Some materials to save are invalid');
+      }
 
-    let optionsToSave;
+      let optionsToSave;
 
-    const currentOptionsToSave = getOptionsToSave(
-      isSlackEnabled, slackChannels, grant.grant, grant.grantedGroup?.id, grant.grantedGroup?.name, pageTags ?? [], true,
-    );
+      const currentOptionsToSave = getOptionsToSave(
+        isSlackEnabled, slackChannels, grant.grant, grant.grantedGroup?.id, grant.grantedGroup?.name, pageTags ?? [], true,
+      );
 
-    if (opts != null) {
-      optionsToSave = Object.assign(currentOptionsToSave, {
-        ...opts,
-      });
-    }
-    else {
-      optionsToSave = currentOptionsToSave;
-    }
+      if (opts != null) {
+        optionsToSave = Object.assign(currentOptionsToSave, {
+          ...opts,
+        });
+      }
+      else {
+        optionsToSave = currentOptionsToSave;
+      }
 
-    const markdown = await hackmdEditorRef.current.getValue();
+      const markdown = await hackmdEditorRef.current.getValue();
 
-    await saveOrUpdate(optionsToSave, { pageId, path: currentPagePath || currentPathname, revisionId: revision?._id }, markdown);
-    await updatePageData();
-    mutateEditorMode(EditorMode.View);
-    mutateIsEnabledUnsavedWarning(false);
+      await saveOrUpdate(optionsToSave, { pageId, path: currentPagePath || currentPathname, revisionId: revision?._id }, markdown);
+      await mutatePageData();
+      await mutateTagsInfo();
+      mutateEditorMode(EditorMode.View);
+      mutateIsEnabledUnsavedWarning(false);
+    }
+    catch (error) {
+      logger.error('failed to save', error);
+      toastError(error.message);
+    }
   }, [editorMode,
       isSlackEnabled,
       currentPathname,
@@ -110,8 +116,9 @@ export const PageEditorByHackmd = (): JSX.Element => {
       pageTags,
       pageId,
       currentPagePath,
-      updatePageData,
+      mutatePageData,
       mutateEditorMode,
+      mutateTagsInfo,
       mutateIsEnabledUnsavedWarning,
   ]);
 
@@ -150,7 +157,7 @@ export const PageEditorByHackmd = (): JSX.Element => {
       mutateRevisionIdHackmdSynced(res.revisionIdHackmdSynced);
     }
     catch (err) {
-      toastError(err);
+      toastError(err.message);
 
       setHasError(true);
       setErrorMessage('GROWI server failed to connect to HackMD.');
@@ -189,7 +196,7 @@ export const PageEditorByHackmd = (): JSX.Element => {
     }
     catch (err) {
       logger.error(err);
-      toastError(err);
+      toastError(err.message);
     }
   }, [setIsHackmdDraftUpdatingInRealtime, mutateHasDraftOnHackmd, mutatePageIdOnHackmd, mutateRevisionIdHackmdSynced, pageId]);
 
@@ -198,36 +205,34 @@ export const PageEditorByHackmd = (): JSX.Element => {
    * @param {string} markdown
    */
   const onSaveWithShortcut = useCallback(async(markdown) => {
-    if (
-      isSlackEnabled == null || grant == null || slackChannels == null || pageId == null || revisionIdHackmdSynced == null || currentPathname == null
-    ) { return }
-    const optionsToSave = getOptionsToSave(
-      isSlackEnabled, slackChannels, grant.grant, grant.grantedGroup?.id, grant.grantedGroup?.name, pageTags ?? [], true,
-    );
-
     try {
-      const res = await saveOrUpdate(optionsToSave, { pageId, path: currentPagePath || currentPathname, revisionId: revisionIdHackmdSynced }, markdown);
+      const currentPagePathOrPathname = currentPagePath || currentPathname;
+      if (
+        isSlackEnabled == null || grant == null || slackChannels == null || pageId == null
+        || revisionIdHackmdSynced == null || currentPagePathOrPathname == null
+      ) { throw new Error('Some materials to save are invalid') }
+      const optionsToSave = getOptionsToSave(
+        isSlackEnabled, slackChannels, grant.grant, grant.grantedGroup?.id, grant.grantedGroup?.name, pageTags ?? [], true,
+      );
+      const res = await saveOrUpdate(optionsToSave, { pageId, path: currentPagePathOrPathname, revisionId: revisionIdHackmdSynced }, markdown);
 
       // update pageData
-      updatePageData();
+      mutatePageData(res);
 
       // set updated data
       setRemoteRevisionId(res.revision._id);
       mutateRevisionIdHackmdSynced(res.page.revisionHackmdSynced);
       mutateHasDraftOnHackmd(res.page.hasDraftOnHackmd);
-      updatePageTagsForEditors(res.tags);
+      mutateTagsInfo();
       mutateIsEnabledUnsavedWarning(false);
 
-      // call reset
-      setIsInitialized(false);
-
       logger.debug('success to save');
 
       toastSuccess(t('successfully_saved_the_page'));
     }
     catch (error) {
       logger.error('failed to save', error);
-      toastError(error);
+      toastError(error.message);
     }
   }, [isSlackEnabled,
       grant,
@@ -237,10 +242,10 @@ export const PageEditorByHackmd = (): JSX.Element => {
       currentPathname,
       pageTags,
       currentPagePath,
-      updatePageData,
+      mutatePageData,
       mutateRevisionIdHackmdSynced,
       mutateHasDraftOnHackmd,
-      updatePageTagsForEditors,
+      mutateTagsInfo,
       mutateIsEnabledUnsavedWarning,
       t]);
 
@@ -267,7 +272,7 @@ export const PageEditorByHackmd = (): JSX.Element => {
   }, [pageId, revision?.body, hackmdUri]);
 
   const penpalErrorOccuredHandler = useCallback((error) => {
-    toastError(error);
+    toastError(error.message);
 
     setHasError(true);
     setErrorMessage(t('hackmd.fail_to_connect'));

+ 16 - 12
packages/app/src/components/User/UserInfo.tsx

@@ -1,37 +1,41 @@
 import React from 'react';
 
+import { IUserHasId } from '@growi/core';
 import { UserPicture } from '@growi/ui';
 
-import { usePageUser } from '~/stores/context';
-
 import styles from './UserInfo.module.scss';
 
-export const UserInfo = (): JSX.Element => {
 
-  const { data: pageUser } = usePageUser();
+export type UserInfoProps = {
+  author?: IUserHasId,
+}
+
+export const UserInfo = (props: UserInfoProps): JSX.Element => {
+
+  const { author } = props;
 
-  if (pageUser == null || pageUser.status === 4) {
+  if (author == null || author.status === 4) {
     return <></>;
   }
 
   return (
     <div className={`${styles['grw-users-info']} grw-users-info d-flex align-items-center d-edit-none mb-5 pb-3 border-bottom`}>
-      <UserPicture user={pageUser} />
+      <UserPicture user={author} />
       <div className="users-meta">
         <h1 className="user-page-name">
-          {pageUser.name}
+          {author.name}
         </h1>
         <div className="user-page-meta mt-3 mb-0">
-          <span className="user-page-username mr-4"><i className="icon-user mr-1"></i>{pageUser.username}</span>
+          <span className="user-page-username mr-4"><i className="icon-user mr-1"></i>{author.username}</span>
           <span className="user-page-email mr-2">
             <i className="icon-envelope mr-1"></i>
-            { pageUser.isEmailPublished
-              ? pageUser.email
+            { author.isEmailPublished
+              ? author.email
               : '*****'
             }
           </span>
-          { pageUser.introduction && (
-            <span className="user-page-introduction">{pageUser.introduction}</span>
+          { author.introduction && (
+            <span className="user-page-introduction">{author.introduction}</span>
           ) }
         </div>
       </div>

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

@@ -62,7 +62,7 @@ import {
   useIsAclEnabled, useIsUserPage,
   useCsrfToken, useIsSearchScopeChildrenAsDefault, useCurrentPageId, useCurrentPathname,
   useIsSlackConfigured, useRendererConfig, useEditingMarkdown,
-  useEditorConfig, useIsAllReplyShown, useIsUploadableFile, useIsUploadableImage, usePageUser,
+  useEditorConfig, useIsAllReplyShown, useIsUploadableFile, useIsUploadableImage,
 } from '../stores/context';
 
 import {
@@ -126,7 +126,7 @@ const PutbackPageModal = (): JSX.Element => {
 type Props = CommonProps & {
   currentUser: IUser,
 
-  pageWithMeta: IPageToShowRevisionWithMeta,
+  pageWithMeta: IPageToShowRevisionWithMeta | null,
   // pageUser?: any,
   redirectFrom?: string;
 
@@ -231,27 +231,25 @@ const GrowiPage: NextPage<Props> = (props: Props) => {
 
   const { pageWithMeta, userUISettings } = props;
 
-  let shouldRenderPutbackPageModal = false;
-  if (pageWithMeta != null) {
-    shouldRenderPutbackPageModal = _isTrashPage(pageWithMeta.data.path);
-  }
+  useSWRxCurrentPage(undefined, pageWithMeta?.data ?? null); // store initial data
+  useEditingMarkdown(pageWithMeta?.data.revision?.body ?? '');
 
   const pageId = pageWithMeta?.data._id;
   const pagePath = pageWithMeta?.data.path ?? (!_isPermalink(props.currentPathname) ? props.currentPathname : undefined);
 
-  useCurrentPageId(pageId);
-  useSWRxCurrentPage(undefined, pageWithMeta?.data); // store initial data
+  useCurrentPageId(pageId ?? null);
   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(pagePath != null && _isTrashPage(pagePath));
 
   const { data: grantData } = useSWRxIsGrantNormalized(pageId);
   const { mutate: mutateSelectedGrant } = useSelectedGrant();
 
-  usePageUser(pageWithMeta?.data.creator);
+  const shouldRenderPutbackPageModal = pageWithMeta != null
+    ? _isTrashPage(pageWithMeta.data.path)
+    : false;
 
   // sync grant data
   useEffect(() => {
@@ -329,8 +327,8 @@ const GrowiPage: NextPage<Props> = (props: Props) => {
           </div>
           { !props.isIdenticalPathPage && !props.isNotFound && (
             <footer className="footer d-edit-none">
-              { !isTopPagePath && (<Comments pageId={pageId} revision={pageWithMeta?.data.revision} />) }
-              { (pageWithMeta != null && isUsersHomePage(pageWithMeta.data.path)) && (
+              { pageWithMeta != null && !isTopPagePath && (<Comments pageId={pageId} revision={pageWithMeta.data.revision} />) }
+              { pageWithMeta != null && isUsersHomePage(pageWithMeta.data.path) && (
                 <UsersHomePageFooter creatorId={pageWithMeta.data.creator._id}/>
               ) }
               <CurrentPageContentFooter />
@@ -396,7 +394,7 @@ async function injectPageData(context: GetServerSidePropsContext, props: Props):
     }
   }
 
-  const pageWithMeta: IPageToShowRevisionWithMeta = await pageService.findPageAndMetaDataByViewer(pageId, currentPathname, user, true); // includeEmpty = true, isSharedPage = false
+  const pageWithMeta: IPageToShowRevisionWithMeta | null = await pageService.findPageAndMetaDataByViewer(pageId, currentPathname, user, true); // includeEmpty = true, isSharedPage = false
   const page = pageWithMeta?.data as unknown as PageDocument;
 
   // add user to seen users

+ 45 - 0
packages/app/src/server/middlewares/invited-form-validator.ts

@@ -0,0 +1,45 @@
+import { NextFunction, Response } from 'express';
+import { body, validationResult, ValidationChain } from 'express-validator';
+import { Request } from 'express-validator/src/base';
+
+const MININUM_PASSWORD_LENGTH = 6;
+
+export const invitedRules = (): ValidationChain[] => {
+  return [
+    body('invitedForm.username')
+      .matches(/^[\da-zA-Z\-_.]+$/)
+      .withMessage('message.Username has invalid characters')
+      .not()
+      .isEmpty()
+      .withMessage('message.Username field is required'),
+    body('invitedForm.name')
+      .not()
+      .isEmpty()
+      .withMessage('message.Name field is required'),
+    body('invitedForm.password')
+      .matches(/^[\x20-\x7F]*$/)
+      .withMessage('message.Password has invalid character')
+      .isLength({ min: MININUM_PASSWORD_LENGTH })
+      .withMessage(`message.Password minimum character should be more than ${MININUM_PASSWORD_LENGTH} characters`)
+      .not()
+      .isEmpty()
+      .withMessage('message.Password field is required'),
+  ];
+};
+
+export const invitedValidation = (req: Request, _res: Response, next: () => NextFunction): any => {
+  const form = req.body;
+  const errors = validationResult(req);
+  const extractedErrors: string[] = [];
+
+  if (errors.isEmpty()) {
+    Object.assign(form, { isValid: true });
+  }
+  else {
+    errors.array().map(err => extractedErrors.push(err.msg));
+    Object.assign(form, { isValid: false, errors: extractedErrors });
+  }
+
+  req.form = form;
+  return next();
+};

+ 0 - 43
packages/app/src/server/middlewares/login-form-validator.ts

@@ -1,48 +1,5 @@
 import { body, validationResult } from 'express-validator';
 
-// form rules
-export const inviteRules = () => {
-  return [
-    body('invitedForm.username')
-      .matches(/^[\da-zA-Z\-_.]+$/)
-      .withMessage('Username has invalid characters')
-      .not()
-      .isEmpty()
-      .withMessage('Username field is required'),
-    body('invitedForm.name').not().isEmpty().withMessage('Name field is required'),
-    body('invitedForm.password')
-      .matches(/^[\x20-\x7F]*$/)
-      .withMessage('Password has invalid character')
-      .isLength({ min: 6 })
-      .withMessage('Password minimum character should be more than 6 characters')
-      .not()
-      .isEmpty()
-      .withMessage('Password field is required'),
-  ];
-};
-
-// validation action
-export const inviteValidation = (req, res, next) => {
-  const form = req.body;
-
-  const errors = validationResult(req);
-  if (errors.isEmpty()) {
-    Object.assign(form, { isValid: true });
-    req.form = form;
-    return next();
-  }
-
-  const extractedErrors: string[] = [];
-  errors.array().map(err => extractedErrors.push(err.msg));
-
-  req.flash('errorMessages', extractedErrors);
-
-  Object.assign(form, { isValid: false });
-  req.form = form;
-
-  return next();
-};
-
 // form rules
 export const loginRules = () => {
   return [

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

@@ -49,11 +49,13 @@ module.exports = (crowi, app, isInstalled) => {
   routerForAuth.post('/login', applicationInstalled, loginFormValidator.loginRules(), loginFormValidator.loginValidation,
     addActivity, loginPassport.loginWithLocal, loginPassport.loginWithLdap, loginPassport.cannotLoginErrorHadnler, loginPassport.loginFailure);
 
+  routerForAuth.use('/invited', require('./invited')(crowi));
   routerForAuth.use('/logout', require('./logout')(crowi));
 
   routerForAuth.post('/register',
     applicationInstalled, registerFormValidator.registerRules(), registerFormValidator.registerValidation, addActivity, login.register);
 
+
   // installer
   if (!isInstalled) {
     routerForAdmin.use('/installer', require('./installer')(crowi));

+ 53 - 0
packages/app/src/server/routes/apiv3/invited.ts

@@ -0,0 +1,53 @@
+import express, { Request, Router } from 'express';
+
+import Crowi from '../../crowi';
+import { invitedRules, invitedValidation } from '../../middlewares/invited-form-validator';
+
+import { ApiV3Response } from './interfaces/apiv3-response';
+
+type InvitedFormRequest = Request & { form: any, user: any };
+
+module.exports = (crowi: Crowi): Router => {
+  const applicationInstalled = require('../../middlewares/application-installed')(crowi);
+  const debug = require('debug')('growi:routes:login');
+  const User = crowi.model('User');
+  const router = express.Router();
+
+  router.post('/', applicationInstalled, invitedRules(), invitedValidation, async(req: InvitedFormRequest, res: ApiV3Response) => {
+    if (!req.user) {
+      return res.apiv3({ redirectTo: '/login' });
+    }
+
+    if (!req.form.isValid) {
+      return res.apiv3Err(req.form.errors, 400);
+    }
+
+    const user = req.user;
+    const invitedForm = req.form.invitedForm || {};
+    const username = invitedForm.username;
+    const name = invitedForm.name;
+    const password = invitedForm.password;
+
+    // check user upper limit
+    const isUserCountExceedsUpperLimit = await User.isUserCountExceedsUpperLimit();
+    if (isUserCountExceedsUpperLimit) {
+      return res.apiv3Err('message.can_not_activate_maximum_number_of_users', 403);
+    }
+
+    const creatable = await User.isRegisterableUsername(username);
+    if (!creatable) {
+      debug('username', username);
+      return res.apiv3Err('message.unable_to_use_this_user', 403);
+    }
+
+    try {
+      await user.activateInvitedUser(username, name, password);
+      return res.apiv3({ redirectTo: '/' });
+    }
+    catch (err) {
+      return res.apiv3Err('message.failed_to_activate', 403);
+    }
+  });
+
+  return router;
+};

+ 1 - 1
packages/app/src/server/routes/index.js

@@ -81,7 +81,7 @@ module.exports = function(crowi, app) {
   app.get('/login/error/:reason'      , applicationInstalled, login.error);
   app.get('/login'                    , applicationInstalled, login.preLogin, next.delegateToNext);
   app.get('/invited'                  , applicationInstalled, next.delegateToNext);
-  app.post('/invited/activateInvited' , applicationInstalled, loginFormValidator.inviteRules(), loginFormValidator.inviteValidation, csrfProtection, login.invited);
+  // app.post('/login'                   , applicationInstalled, loginFormValidator.loginRules(), loginFormValidator.loginValidation, csrfProtection,  addActivity, loginPassport.loginWithLocal, loginPassport.loginWithLdap, loginPassport.loginFailure);
 
   app.get('/register'                 , applicationInstalled, login.preLogin, login.register);
 

+ 6 - 1
packages/app/src/server/routes/login-passport.js

@@ -11,6 +11,7 @@ module.exports = function(crowi, app) {
   const logger = loggerFactory('growi:routes:login-passport');
   const passport = require('passport');
   const ExternalAccount = crowi.model('ExternalAccount');
+  const User = crowi.model('User');
   const passportService = crowi.passportService;
 
   const activityEvent = crowi.event('activity');
@@ -91,6 +92,7 @@ module.exports = function(crowi, app) {
    * @param {*} res
    */
   const loginSuccessHandler = async(req, res, user, action) => {
+
     // update lastLoginAt
     user.updateLastLoginAt(new Date(), (err, userData) => {
       if (err) {
@@ -99,7 +101,9 @@ module.exports = function(crowi, app) {
       }
     });
 
-    const { redirectTo } = req.session;
+    // check for redirection to '/invited'
+    const redirectTo = req.user.status === User.STATUS_INVITED ? '/invited' : req.session.redirectTo;
+
     // remove session.redirectTo
     delete req.session.redirectTo;
 
@@ -112,6 +116,7 @@ module.exports = function(crowi, app) {
         username: req.user.username,
       },
     };
+
     await crowi.activityService.createActivity(parameters);
 
     return res.apiv3({ redirectTo });

+ 0 - 45
packages/app/src/server/routes/login.js

@@ -169,50 +169,5 @@ module.exports = function(crowi, app) {
     });
   };
 
-  actions.invited = async function(req, res) {
-    if (!req.user) {
-      return res.redirect('/login');
-    }
-
-    if (req.method === 'POST' && req.form.isValid) {
-      const user = req.user;
-      const invitedForm = req.form.invitedForm || {};
-      const username = invitedForm.username;
-      const name = invitedForm.name;
-      const password = invitedForm.password;
-
-      // check user upper limit
-      const isUserCountExceedsUpperLimit = await User.isUserCountExceedsUpperLimit();
-      if (isUserCountExceedsUpperLimit) {
-        req.flash('warningMessage', req.t('message.can_not_activate_maximum_number_of_users'));
-        return res.redirect('/invited');
-      }
-
-      const creatable = await User.isRegisterableUsername(username);
-      if (creatable) {
-        try {
-          await user.activateInvitedUser(username, name, password);
-          return res.redirect('/');
-        }
-        catch (err) {
-          req.flash('warningMessage', req.t('message.failed_to_activate'));
-          return res.render('invited');
-        }
-      }
-      else {
-        req.flash('warningMessage', req.t('message.unable_to_use_this_user'));
-        debug('username', username);
-        return res.render('invited');
-      }
-    }
-    else {
-      return res.render('invited');
-    }
-  };
-
-  actions.updateInvitedUser = function(req, res) {
-    return res.redirect('/');
-  };
-
   return actions;
 };

+ 1 - 5
packages/app/src/stores/context.tsx

@@ -1,3 +1,4 @@
+import { IUser } from '@growi/core';
 import { HtmlElementNode } from 'rehype-toc';
 import { Key, SWRResponse } from 'swr';
 import useSWRImmutable from 'swr/immutable';
@@ -11,7 +12,6 @@ import { GrowiThemes } from '~/interfaces/theme';
 import InterceptorManager from '~/services/interceptor-manager';
 
 import { TargetAndAncestors } from '../interfaces/page-listing-results';
-import { IUser, IUserHasId } from '../interfaces/user';
 
 import { useStaticSWR } from './use-static-swr';
 
@@ -98,10 +98,6 @@ export const useIsNotFound = (initialData?: boolean): SWRResponse<boolean, Error
   return useStaticSWR<boolean, Error>('isNotFound', initialData, { fallbackData: false });
 };
 
-export const usePageUser = (initialData?: IUserHasId): SWRResponse<IUserHasId, Error> => {
-  return useStaticSWR<IUserHasId, Error>('pageUser', initialData);
-};
-
 export const useHasChildren = (initialData?: Nullable<any>): SWRResponse<Nullable<any>, Error> => {
   return useStaticSWR<Nullable<any>, Error>('hasChildren', initialData);
 };

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

@@ -39,14 +39,16 @@ export const useSWRxPageByPath = (path?: string): SWRResponse<IPagePopulatedToSh
   );
 };
 
-export const useSWRxCurrentPage = (shareLinkId?: string, initialData?: IPagePopulatedToShowRevision): SWRResponse<IPagePopulatedToShowRevision|null, Error> => {
+export const useSWRxCurrentPage = (
+    shareLinkId?: string, initialData?: IPagePopulatedToShowRevision|null,
+): SWRResponse<IPagePopulatedToShowRevision|null, Error> => {
   const { data: currentPageId } = useCurrentPageId();
 
   const swrResult = useSWRxPage(currentPageId, shareLinkId);
 
   // use mutate because fallbackData does not work
   // see: https://github.com/weseek/growi/commit/5038473e8d6028c9c91310e374a7b5f48b921a15
-  if (initialData != null) {
+  if (initialData !== undefined) {
     swrResult.mutate(initialData);
   }
 

+ 3 - 0
packages/app/src/styles/molecules/toastr.scss

@@ -0,0 +1,3 @@
+:root {
+  @import '~toastr/build/toastr';
+}

+ 2 - 1
packages/app/src/styles/style-next.scss

@@ -30,7 +30,8 @@
 @import 'atoms/spinners';
 @import 'atoms/custom_control';
 
-// // molecules
+// molecules
+@import 'molecules/toastr';
 // @import 'molecules/copy-dropdown';
 // @import 'molecules/page-editor-mode-manager';
 // @import 'molecules/slack-notification';