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

Merge commit '3168cbaff0f94e4fc0a9019309c73e28bfae460f' into feat/Create-activities-of-data-importing

hiroki-h 3 лет назад
Родитель
Сommit
ff29412cee
70 измененных файлов с 1352 добавлено и 994 удалено
  1. 21 1
      CHANGELOG.md
  2. 1 1
      lerna.json
  3. 1 1
      package.json
  4. 2 2
      packages/app/docker/README.md
  5. 7 7
      packages/app/package.json
  6. 2 5
      packages/app/src/client/app.jsx
  7. 1 3
      packages/app/src/client/services/ContextExtractor.tsx
  8. 0 186
      packages/app/src/client/services/PersonalContainer.js
  9. 22 1
      packages/app/src/components/Admin/AuditLog/SelectActionDropdown.tsx
  10. 5 0
      packages/app/src/components/Admin/AuditLogManagement.tsx
  11. 2 0
      packages/app/src/components/Admin/Security/LdapAuthTest.jsx
  12. 2 4
      packages/app/src/components/Admin/SlackIntegration/WithProxyAccordions.jsx
  13. 7 2
      packages/app/src/components/BookmarkButtons.tsx
  14. 7 2
      packages/app/src/components/LikeButtons.tsx
  15. 0 116
      packages/app/src/components/Me/ApiSettings.jsx
  16. 90 0
      packages/app/src/components/Me/ApiSettings.tsx
  17. 0 152
      packages/app/src/components/Me/AssociateModal.jsx
  18. 111 0
      packages/app/src/components/Me/AssociateModal.tsx
  19. 0 181
      packages/app/src/components/Me/BasicInfoSettings.jsx
  20. 171 0
      packages/app/src/components/Me/BasicInfoSettings.tsx
  21. 0 98
      packages/app/src/components/Me/DisassociateModal.jsx
  22. 69 0
      packages/app/src/components/Me/DisassociateModal.tsx
  23. 8 18
      packages/app/src/components/Me/ExternalAccountLinkedMe.jsx
  24. 19 17
      packages/app/src/components/Me/PasswordSettings.jsx
  25. 4 6
      packages/app/src/components/Navbar/SubNavButtons.tsx
  26. 2 1
      packages/app/src/components/PageDeleteModal.tsx
  27. 3 5
      packages/app/src/components/SubscribeButton.tsx
  28. 1 1
      packages/app/src/components/User/SeenUserInfo.tsx
  29. 146 9
      packages/app/src/interfaces/activity.ts
  30. 10 0
      packages/app/src/interfaces/external-account.ts
  31. 0 5
      packages/app/src/interfaces/page-listing-results.ts
  32. 10 6
      packages/app/src/interfaces/user.ts
  33. 5 0
      packages/app/src/server/interfaces/search.ts
  34. 12 12
      packages/app/src/server/middlewares/login-required.js
  35. 1 1
      packages/app/src/server/models/interfaces/page-operation.ts
  36. 1 1
      packages/app/src/server/models/page-operation.ts
  37. 8 1
      packages/app/src/server/routes/admin.js
  38. 3 3
      packages/app/src/server/routes/apiv3/forgot-password.js
  39. 13 1
      packages/app/src/server/routes/apiv3/in-app-notification.ts
  40. 16 4
      packages/app/src/server/routes/apiv3/markdown-setting.js
  41. 38 7
      packages/app/src/server/routes/apiv3/notification-setting.js
  42. 25 2
      packages/app/src/server/routes/apiv3/page.js
  43. 11 1
      packages/app/src/server/routes/apiv3/pages.js
  44. 18 6
      packages/app/src/server/routes/apiv3/share-links.js
  45. 19 0
      packages/app/src/server/routes/attachment.js
  46. 11 11
      packages/app/src/server/routes/index.js
  47. 67 7
      packages/app/src/server/routes/login-passport.js
  48. 1 2
      packages/app/src/server/routes/login.js
  49. 45 9
      packages/app/src/server/routes/page.js
  50. 27 1
      packages/app/src/server/routes/search.ts
  51. 5 0
      packages/app/src/server/routes/tag.js
  52. 2 2
      packages/app/src/server/service/activity.ts
  53. 4 0
      packages/app/src/server/service/interfaces/search.ts
  54. 15 6
      packages/app/src/server/service/page.ts
  55. 9 9
      packages/app/src/server/service/search-delegator/elasticsearch.ts
  56. 0 1
      packages/app/src/server/views/layout-growi/not_found.html
  57. 1 5
      packages/app/src/stores/context.tsx
  58. 102 0
      packages/app/src/stores/personal-settings.tsx
  59. 5 5
      packages/app/src/stores/ui.tsx
  60. 1 3
      packages/app/src/styles/atoms/_buttons.scss
  61. 12 10
      packages/app/test/cypress/integration/60-home/home.spec.ts
  62. 142 45
      packages/app/test/integration/middlewares/login-required.test.js
  63. 1 1
      packages/codemirror-textlint/package.json
  64. 1 1
      packages/core/package.json
  65. 1 1
      packages/plugin-attachment-refs/package.json
  66. 1 1
      packages/plugin-lsx/package.json
  67. 1 1
      packages/plugin-pukiwiki-like-linker/package.json
  68. 1 1
      packages/slack/package.json
  69. 2 2
      packages/slackbot-proxy/package.json
  70. 1 1
      packages/ui/package.json

+ 21 - 1
CHANGELOG.md

@@ -1,9 +1,29 @@
 # Changelog
 
-## [Unreleased](https://github.com/weseek/growi/compare/v5.0.10...HEAD)
+## [Unreleased](https://github.com/weseek/growi/compare/v5.0.11...HEAD)
 
 *Please do not manually update this file. We've automated the process.*
 
+## [v5.0.11](https://github.com/weseek/growi/compare/v5.0.10...v5.0.11) - 2022-07-05
+
+### 💎 Features
+
+- feat: Integrate recount descendant count after paths fix (#6170) @Yohei-Shiina
+
+### 🚀 Improvement
+
+- imprv: Redirect when the anchor is #password (#6144) @Kami-jo
+
+### 🐛 Bug Fixes
+
+- fix: User registration page is not redirected after tmp login (#6197) @kaoritokashiki
+- fix: Empty trash doesn't work (#6168) @yukendev
+
+### 🧰 Maintenance
+
+- support: Ease rate limit temporary (#6191) @yuki-takei
+- support: Omit page history container and page revision comparer container (#6185) @yukendev
+
 ## [v5.0.10](https://github.com/weseek/growi/compare/v5.0.9...v5.0.10) - 2022-06-27
 
 ### 💎 Features

+ 1 - 1
lerna.json

@@ -1,7 +1,7 @@
 {
   "npmClient": "yarn",
   "useWorkspaces": true,
-  "version": "5.0.11-RC.0",
+  "version": "5.0.12-RC.0",
   "packages": [
     "packages/*"
   ]

+ 1 - 1
package.json

@@ -1,6 +1,6 @@
 {
   "name": "growi",
-  "version": "5.0.11-RC.0",
+  "version": "5.0.12-RC.0",
   "description": "Team collaboration software using markdown",
   "tags": [
     "wiki",

+ 2 - 2
packages/app/docker/README.md

@@ -10,8 +10,8 @@ GROWI Official docker image
 Supported tags and respective Dockerfile links
 ------------------------------------------------
 
-* [`5.0.10`, `5.0`, `5`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v5.0.10/docker/Dockerfile)
-* [`5.0.10-nocdn`, `5.0-nocdn`, `5-nocdn`, `latest-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v5.0.10/docker/Dockerfile)
+* [`5.0.11`, `5.0`, `5`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v5.0.11/docker/Dockerfile)
+* [`5.0.11-nocdn`, `5.0-nocdn`, `5-nocdn`, `latest-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v5.0.11/docker/Dockerfile)
 * [`4.5.22`, `4.5`, `4`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v4.5.22/docker/Dockerfile)
 * [`4.5.22-nocdn`, `4.5-nocdn`, `4-nocdn`, `latest-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v4.5.22/docker/Dockerfile)
 * [`4.4.13`, `4.4` (Dockerfile)](https://github.com/weseek/growi/blob/v4.4.13/docker/Dockerfile)

+ 7 - 7
packages/app/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/app",
-  "version": "5.0.11-RC.0",
+  "version": "5.0.12-RC.0",
   "license": "MIT",
   "scripts": {
     "//// for production": "",
@@ -64,11 +64,11 @@
     "@elastic/elasticsearch7": "npm:@elastic/elasticsearch@^7.17.0",
     "@godaddy/terminus": "^4.9.0",
     "@google-cloud/storage": "^5.8.5",
-    "@growi/codemirror-textlint": "^5.0.11-RC.0",
-    "@growi/plugin-attachment-refs": "^5.0.11-RC.0",
-    "@growi/plugin-lsx": "^5.0.11-RC.0",
-    "@growi/plugin-pukiwiki-like-linker": "^5.0.11-RC.0",
-    "@growi/slack": "^5.0.11-RC.0",
+    "@growi/codemirror-textlint": "^5.0.12-RC.0",
+    "@growi/plugin-attachment-refs": "^5.0.12-RC.0",
+    "@growi/plugin-lsx": "^5.0.12-RC.0",
+    "@growi/plugin-pukiwiki-like-linker": "^5.0.12-RC.0",
+    "@growi/slack": "^5.0.12-RC.0",
     "@promster/express": "^7.0.2",
     "@promster/server": "^7.0.4",
     "@slack/events-api": "^3.0.0",
@@ -170,7 +170,7 @@
   },
   "devDependencies": {
     "@alienfast/i18next-loader": "^1.1.4",
-    "@growi/ui": "^5.0.11-RC.0",
+    "@growi/ui": "^5.0.12-RC.0",
     "@handsontable/react": "=2.1.0",
     "@types/compression": "^1.7.0",
     "@types/express": "^4.17.11",

+ 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 />,

+ 1 - 3
packages/app/src/client/services/ContextExtractor.tsx

@@ -17,7 +17,7 @@ import {
   useCurrentPageId, usePageIdOnHackmd, usePageUser, useCurrentPagePath, useRevisionCreatedAt, useRevisionId, useRevisionIdHackmdSynced,
   useShareLinkId, useShareLinksNumber, useTemplateTagData, useCurrentUpdatedAt, useCreator, useRevisionAuthor, useCurrentUser, useTargetAndAncestors,
   useNotFoundTargetPathOrId, useIsSearchPage, useIsForbidden, useIsIdenticalPath, useHasParent,
-  useIsAclEnabled, useIsSearchServiceConfigured, useIsSearchServiceReachable, useIsEnabledAttachTitleHeader, useIsNotFoundPermalink,
+  useIsAclEnabled, useIsSearchServiceConfigured, useIsSearchServiceReachable, useIsEnabledAttachTitleHeader,
   useDefaultIndentSize, useIsIndentSizeForced, useCsrfToken, useIsEmptyPage, useEmptyPageId, useGrowiVersion, useAuditLogEnabled,
   useActivityExpirationSeconds, useAuditLogAvailableActions,
 } from '../../stores/context';
@@ -92,7 +92,6 @@ const ContextExtractorOnce: FC = () => {
   const revisionAuthor = JSON.parse(mainContent?.getAttribute('data-page-revision-author') || jsonNull);
   const targetAndAncestors = JSON.parse(document.getElementById('growi-pagetree-target-and-ancestors')?.textContent || jsonNull);
   const notFoundTargetPathOrId = JSON.parse(notFoundContentForPt?.getAttribute('data-not-found-target-path-or-id') || jsonNull);
-  const isNotFoundPermalink = JSON.parse(notFoundContext?.getAttribute('data-is-not-found-permalink') || jsonNull);
   const isSearchPage = document.getElementById('search-page') != null;
   const isEmptyPage = JSON.parse(mainContent?.getAttribute('data-page-is-empty') || jsonNull) ?? false;
 
@@ -156,7 +155,6 @@ const ContextExtractorOnce: FC = () => {
   useRevisionAuthor(revisionAuthor);
   useTargetAndAncestors(targetAndAncestors);
   useNotFoundTargetPathOrId(notFoundTargetPathOrId);
-  useIsNotFoundPermalink(isNotFoundPermalink);
   useIsSearchPage(isSearchPage);
   useIsEmptyPage(isEmptyPage);
   useHasParent(hasParent);

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

+ 22 - 1
packages/app/src/components/Admin/AuditLog/SelectActionDropdown.tsx

@@ -3,7 +3,8 @@ import React, { FC, useMemo, useCallback } from 'react';
 import { useTranslation } from 'react-i18next';
 
 import {
-  SupportedActionType, SupportedActionCategoryType, SupportedActionCategory, PageActions, CommentActions, UserActions, AdminActions,
+  SupportedActionType, SupportedActionCategoryType, SupportedActionCategory,
+  PageActions, CommentActions, TagActions, ShareLinkActions, AttachmentActions, InAppNotificationActions, SearchActions, UserActions, AdminActions,
 } from '~/interfaces/activity';
 
 type Props = {
@@ -30,6 +31,26 @@ export const SelectActionDropdown: FC<Props> = (props: Props) => {
           actionCategory: SupportedActionCategory.COMMENT,
           actions: CommentActions.filter(action => availableActions.includes(action)),
         },
+        {
+          actionCategory: SupportedActionCategory.TAG,
+          actions: TagActions.filter(action => availableActions.includes(action)),
+        },
+        {
+          actionCategory: SupportedActionCategory.ATTACHMENT,
+          actions: AttachmentActions.filter(action => availableActions.includes(action)),
+        },
+        {
+          actionCategory: SupportedActionCategory.SHARE_LINK,
+          actions: ShareLinkActions.filter(action => availableActions.includes(action)),
+        },
+        {
+          actionCategory: SupportedActionCategory.IN_APP_NOTIFICATION,
+          actions: InAppNotificationActions.filter(action => availableActions.includes(action)),
+        },
+        {
+          actionCategory: SupportedActionCategory.SEARCH,
+          actions: SearchActions.filter(action => availableActions.includes(action)),
+        },
         {
           actionCategory: SupportedActionCategory.USER,
           actions: UserActions.filter(action => availableActions.includes(action)),

+ 5 - 0
packages/app/src/components/Admin/AuditLogManagement.tsx

@@ -3,6 +3,7 @@ import React, { FC, useState, useCallback } from 'react';
 import { format } from 'date-fns';
 import { useTranslation } from 'react-i18next';
 
+import { toastError } from '~/client/util/apiNotification';
 import { SupportedActionType } from '~/interfaces/activity';
 import { useSWRxActivity } from '~/stores/activity';
 import { useAuditLogEnabled, useAuditLogAvailableActions } from '~/stores/context';
@@ -57,6 +58,10 @@ export const AuditLogManagement: FC = () => {
   const totalActivityNum = activityData?.totalDocs != null ? activityData.totalDocs : 0;
   const isLoading = activityData === undefined && error == null;
 
+  if (error != null) {
+    toastError('Failed to get Audit Log');
+  }
+
   const { data: auditLogEnabled } = useAuditLogEnabled();
 
   /*

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

+ 7 - 2
packages/app/src/components/BookmarkButtons.tsx

@@ -55,7 +55,7 @@ const BookmarkButtons: FC<Props> = (props: Props) => {
         type="button"
         id="bookmark-button"
         onClick={handleClick}
-        className={`btn btn-bookmark border-0
+        className={`shadow-none btn btn-bookmark border-0
           ${isBookmarked ? 'active' : ''} ${isGuestUser ? 'disabled' : ''}`}
       >
         <i className={`fa ${isBookmarked ? 'fa-bookmark' : 'fa-bookmark-o'}`}></i>
@@ -67,7 +67,12 @@ const BookmarkButtons: FC<Props> = (props: Props) => {
 
       { !hideTotalNumber && (
         <>
-          <button type="button" id="po-total-bookmarks" className={`btn btn-bookmark border-0 total-bookmarks ${props.isBookmarked ? 'active' : ''}`}>
+          <button
+            type="button"
+            id="po-total-bookmarks"
+            className={`shadow-none btn btn-bookmark border-0
+              total-bookmarks ${props.isBookmarked ? 'active' : ''}`}
+          >
             {bookmarkCount ?? 0}
           </button>
           { bookmarkedUsers != null && (

+ 7 - 2
packages/app/src/components/LikeButtons.tsx

@@ -51,7 +51,7 @@ const LikeButtons: FC<LikeButtonsProps> = (props: LikeButtonsProps) => {
         type="button"
         id="like-button"
         onClick={onLikeClicked}
-        className={`btn btn-like border-0
+        className={`shadow-none btn btn-like border-0
             ${isLiked ? 'active' : ''} ${isGuestUser ? 'disabled' : ''}`}
       >
         <i className={`fa ${isLiked ? 'fa-heart' : 'fa-heart-o'}`}></i>
@@ -63,7 +63,12 @@ const LikeButtons: FC<LikeButtonsProps> = (props: LikeButtonsProps) => {
 
       { !hideTotalNumber && (
         <>
-          <button type="button" id="po-total-likes" className={`btn btn-like border-0 total-likes ${isLiked ? 'active' : ''}`}>
+          <button
+            type="button"
+            id="po-total-likes"
+            className={`shadow-none btn btn-like border-0
+              total-likes ${isLiked ? 'active' : ''}`}
+          >
             {sumOfLikers}
           </button>
           <Popover placement="bottom" isOpen={isPopoverOpen} target="po-total-likes" toggle={togglePopover} trigger="legacy">

+ 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 'react-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;

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

@@ -0,0 +1,90 @@
+import React, { useCallback } from 'react';
+
+import { useTranslation } from 'react-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>
+
+    </>
+
+  );
+
+});
+
+
+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 'react-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 'react-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 'react-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 'react-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 'react-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 'react-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 'react-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 'react-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;

+ 4 - 6
packages/app/src/components/Navbar/SubNavButtons.tsx

@@ -155,12 +155,10 @@ const SubNavButtonsSubstance = (props: SubNavButtonsSubstanceProps): JSX.Element
   return (
     <div className="d-flex" style={{ gap: '2px' }}>
       {revisionId != null && (
-        <span>
-          <SubscribeButton
-            status={pageInfo.subscriptionStatus}
-            onClick={subscribeClickhandler}
-          />
-        </span>
+        <SubscribeButton
+          status={pageInfo.subscriptionStatus}
+          onClick={subscribeClickhandler}
+        />
       )}
       {revisionId != null && (
         <LikeButtons

+ 2 - 1
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 'react-i18next';
 import {
   Modal, ModalHeader, ModalBody, ModalFooter,
@@ -20,7 +21,7 @@ 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');

+ 3 - 5
packages/app/src/components/SubscribeButton.tsx

@@ -18,9 +18,6 @@ const SubscribeButton: FC<Props> = (props: Props) => {
 
   const isSubscribing = status === SubscriptionStatusType.SUBSCRIBE;
 
-  const buttonClass = `${isSubscribing ? 'active' : ''} ${isGuestUser ? 'disabled' : ''}`;
-  const iconClass = isSubscribing === false ? 'fa fa-eye-slash' : 'fa fa-eye';
-
   const getTooltipMessage = useCallback(() => {
     if (isGuestUser) {
       return 'Not available for guest';
@@ -38,9 +35,10 @@ const SubscribeButton: FC<Props> = (props: Props) => {
         type="button"
         id="subscribe-button"
         onClick={props.onClick}
-        className={`btn btn-subscribe border-0 ${buttonClass}`}
+        className={`shadow-none btn btn-subscribe border-0
+          ${isSubscribing ? 'active' : ''} ${isGuestUser ? 'disabled' : ''}`}
       >
-        <i className={iconClass}></i>
+        <i className={`fa ${isSubscribing ? 'fa-bell' : 'fa-bell-slash-o'}`}></i>
       </button>
 
       <UncontrolledTooltip placement="top" target="subscribe-button" fade={false}>

+ 1 - 1
packages/app/src/components/User/SeenUserInfo.tsx

@@ -24,7 +24,7 @@ const SeenUserInfo: FC<Props> = (props: Props) => {
 
   return (
     <div className="grw-seen-user-info">
-      <button type="button" id="btn-seen-user" className="btn btn-seen-user border-0">
+      <button type="button" id="btn-seen-user" className="shadow-none btn btn-seen-user border-0">
         <span className="mr-1 footstamp-icon">
           <FootstampIcon />
         </span>

+ 146 - 9
packages/app/src/interfaces/activity.ts

@@ -9,7 +9,14 @@ const MODEL_COMMENT = 'Comment';
 // Action
 const ACTION_UNSETTLED = 'UNSETTLED';
 const ACTION_USER_REGISTRATION_SUCCESS = 'USER_REGISTRATION_SUCCESS';
-const ACTION_USER_LOGIN_SUCCESS = 'USER_LOGIN_SUCCESS';
+const ACTION_USER_LOGIN_WITH_LOCAL = 'USER_LOGIN_WITH_LOCAL';
+const ACTION_USER_LOGIN_WITH_LDAP = 'USER_LOGIN_WITH_LDAP';
+const ACTION_USER_LOGIN_WITH_GOOGLE = 'USER_LOGIN_WITH_GOOGLE';
+const ACTION_USER_LOGIN_WITH_GITHUB = 'USER_LOGIN_WITH_GITHUB';
+const ACTION_USER_LOGIN_WITH_TWITTER = 'USER_LOGIN_WITH_TWITTER';
+const ACTION_USER_LOGIN_WITH_OIDC = 'USER_LOGIN_WITH_OIDC';
+const ACTION_USER_LOGIN_WITH_SAML = 'USER_LOGIN_WITH_SAML';
+const ACTION_USER_LOGIN_WITH_BASIC = 'USER_LOGIN_WITH_BASIC';
 const ACTION_USER_LOGIN_FAILURE = 'USER_LOGIN_FAILURE';
 const ACTION_USER_LOGOUT = 'USER_LOGOUT';
 const ACTION_USER_PERSONAL_SETTINGS_UPDATE = 'USER_PERSONAL_SETTINGS_UPDATE';
@@ -21,6 +28,10 @@ const ACTION_USER_API_TOKEN_UPDATE = 'USER_API_TOKEN_UPDATE';
 const ACTION_USER_EDITOR_SETTINGS_UPDATE = 'USER_EDITOR_SETTINGS_UPDATE';
 const ACTION_USER_IN_APP_NOTIFICATION_SETTINGS_UPDATE = 'USER_IN_APP_NOTIFICATION_SETTINGS_UPDATE';
 const ACTION_PAGE_VIEW = 'PAGE_VIEW';
+const ACTION_PAGE_USER_HOME_VIEW = 'PAGE_USER_HOME_VIEW';
+const ACTION_PAGE_NOT_FOUND = 'PAGE_NOT_FOUND';
+const ACTION_PAGE_FORBIDDEN = 'PAGE_FORBIDDEN';
+const ACTION_PAGE_NOT_CREATABLE = 'PAGE_NOT_CREATABLE';
 const ACTION_PAGE_LIKE = 'PAGE_LIKE';
 const ACTION_PAGE_UNLIKE = 'PAGE_UNLIKE';
 const ACTION_PAGE_BOOKMARK = 'PAGE_BOOKMARK';
@@ -32,9 +43,27 @@ const ACTION_PAGE_DUPLICATE = 'PAGE_DUPLICATE';
 const ACTION_PAGE_DELETE = 'PAGE_DELETE';
 const ACTION_PAGE_DELETE_COMPLETELY = 'PAGE_DELETE_COMPLETELY';
 const ACTION_PAGE_REVERT = 'PAGE_REVERT';
+const ACTION_PAGE_EMPTY_TRASH = 'PAGE_EMPTY_TRASH';
+const ACTION_PAGE_SUBSCRIBE = 'PAGE_SUBSCRIBE';
+const ACTION_PAGE_UNSUBSCRIBE = 'PAGE_UNSUBSCRIBE';
+const ACTION_PAGE_EXPORT = 'PAGE_EXPORT';
+const ACTION_TAG_UPDATE = 'TAG_UPDATE';
+const ACTION_IN_APP_NOTIFICATION_ALL_STATUSES_OPEN = 'IN_APP_NOTIFICATION_ALL_STATUSES_OPEN';
 const ACTION_COMMENT_CREATE = 'COMMENT_CREATE';
 const ACTION_COMMENT_UPDATE = 'COMMENT_UPDATE';
 const ACTION_COMMENT_REMOVE = 'COMMENT_REMOVE';
+const ACTION_SHARE_LINK_CREATE = 'SHARE_LINK_CREATE';
+const ACTION_SHARE_LINK_DELETE = 'SHARE_LINK_DELETE';
+const ACTION_SHARE_LINK_DELETE_BY_PAGE = 'SHARE_LINK_DELETE_BY_PAGE';
+const ACTION_SHARE_LINK_ALL_DELETE = 'SHARE_LINK_ALL_DELETE';
+const ACTION_SHARE_LINK_PAGE_VIEW = 'SHARE_LINK_PAGE_VIEW';
+const ACTION_SHARE_LINK_EXPIRED_PAGE_VIEW = 'SHARE_LINK_EXPIRED_PAGE_VIEW';
+const ACTION_SHARE_LINK_NOT_FOUND = 'SHARE_LINK_NOT_FOUND';
+const ACTION_ATTACHMENT_ADD = 'ATTACHMENT_ADD';
+const ACTION_ATTACHMENT_REMOVE = 'ATTACHMENT_REMOVE';
+const ACTION_ATTACHMENT_DOWNLOAD = 'ACTION_ATTACHMENT_DOWNLOAD';
+const ACTION_SEARCH_PAGE = 'SEARCH_PAGE';
+const ACTION_SEARCH_PAGE_VIEW = 'SEARCH_PAGE_VIEW';
 const ACTION_ADMIN_APP_SETTINGS_UPDATE = 'ADMIN_APP_SETTING_UPDATE';
 const ACTION_ADMIN_SITE_URL_UPDATE = 'ADMIN_SITE_URL_UPDATE';
 const ACTION_ADMIN_MAIL_SMTP_UPDATE = 'ADMIN_MAIL_SMTP_UPDATE';
@@ -47,7 +76,6 @@ const ACTION_ADMIN_MAINTENANCEMODE_DISABLED = 'ADMIN_MAINTENANCEMODE_DISABLED';
 const ACTION_ADMIN_SECURITY_SETTINGS_UPDATE = 'ADMIN_SECURITY_SETTINGS_UPDATE';
 const ACTION_ADMIN_PERMIT_SHARE_LINK = 'ADMIN_PERMIT_SHARE_LINK';
 const ACTION_ADMIN_REJECT_SHARE_LINK = 'ADMIN_REJECT_SHARE_LINK';
-const ACTION_ADMIN_DELETE_ALL_SHARE_LINK = 'ADMIN_DELETE_ALL_SHARE_LINK';
 const ACTION_ADMIN_AUTH_ID_PASS_ENABLED = 'ADMIN_AUTH_ID_PASS_ENABLED';
 const ACTION_ADMIN_AUTH_ID_PASS_DISABLED = 'ADMIN_AUTH_ID_PASS_DISABLED';
 const ACTION_ADMIN_AUTH_ID_PASS_UPDATE = 'ADMIN_AUTH_ID_PASS_UPDATE';
@@ -72,7 +100,10 @@ const ACTION_ADMIN_AUTH_GITHUB_UPDATE = 'ADMIN_AUTH_GITHUB_UPDATE';
 const ACTION_ADMIN_AUTH_TWITTER_ENABLED = 'ADMIN_AUTH_TWITTER_ENABLED';
 const ACTION_ADMIN_AUTH_TWITTER_DISABLED = 'ADMIN_AUTH_TWITTER_DISABLED';
 const ACTION_ADMIN_AUTH_TWITTER_UPDATE = 'ADMIN_AUTH_TWITTER_UPDATE';
-const ACTION_ADMIN_LINE_BREAK_UPDATE = 'ADMIN_LINE_BREAK_UPDATE';
+const ACTION_ADMIN_MARKDOWN_LINE_BREAK_UPDATE = 'ADMIN_MARKDOWN_LINE_BREAK_UPDATE';
+const ACTION_ADMIN_MARKDOWN_INDENT_UPDATE = 'ADMIN_MARKDOWN_INDENT_UPDATE';
+const ACTION_ADMIN_MARKDOWN_PRESENTATION_UPDATE = 'ADMIN_MARKDOWN_PRESENTATION_UPDATE';
+const ACTION_ADMIN_MARKDOWN_XSS_UPDATE = 'ADMIN_MARKDOWN_XSS_UPDATE';
 const ACTION_ADMIN_LAYOUT_UPDATE = 'ADMIN_LAYOUT_UPDATE';
 const ACTION_ADMIN_THEME_UPDATE = 'ADMIN_THEME_UPDATE';
 const ACTION_ADMIN_FUNCTION_UPDATE = 'ADMIN_FUNCTION_UPDATE';
@@ -84,6 +115,13 @@ const ACTION_ADMIN_CUSTOM_SCRIPT_UPDATE = 'ADMIN_CUSTOM_SCRIPT_UPDATE';
 const ACTION_ADMIN_ARCHIVE_DATA_UPLOAD = 'ADMIN_ARCHIVE_DATA_UPLOAD';
 const ACTION_ADMIN_ARCHIVE_DATA_CREATE = 'ADMIN_ARCHIVE_DATA_CREATE';
 const ACTION_ADMIN_USER_NOTIFICATION_SETTINGS_ADD = 'ADMIN_USER_NOTIFICATION_SETTINGS_ADD';
+const ACTION_ADMIN_USER_NOTIFICATION_SETTINGS_DELETE = 'ADMIN_USER_NOTIFICATION_SETTINGS_DELETE';
+const ACTION_ADMIN_GLOBAL_NOTIFICATION_SETTINGS_ADD = 'ADMIN_GLOBAL_NOTIFICATION_SETTINGS_ADD';
+const ACTION_ADMIN_GLOBAL_NOTIFICATION_SETTINGS_UPDATE = 'ADMIN_GLOBAL_NOTIFICATION_SETTINGS_UPDATE';
+const ACTION_ADMIN_NOTIFICATION_GRANT_SETTINGS_UPDATE = 'ADMIN_NOTIFICATION_GRANT_SETTINGS_UPDATE';
+const ACTION_ADMIN_GLOBAL_NOTIFICATION_SETTINGS_ENABLED = 'ADMIN_GLOBAL_NOTIFICATION_SETTINGS_ENABLED';
+const ACTION_ADMIN_GLOBAL_NOTIFICATION_SETTINGS_DISABLED = 'ADMIN_GLOBAL_NOTIFICATION_SETTINGS_DISABLED';
+const ACTION_ADMIN_GLOBAL_NOTIFICATION_SETTINGS_DELETE = 'ADMIN_GLOBAL_NOTIFICATION_SETTINGS_DELETE';
 const ACTION_ADMIN_SLACK_WORKSPACE_CREATE = 'ADMIN_SLACK_WORKSPACE_CREATE';
 const ACTION_ADMIN_SLACK_CONFIGURATION_SETTING_UPDATE = 'ADMIN_SLACK_CONFIGURATION_SETTING_UPDATE';
 const ACTION_ADMIN_USERS_INVITE = 'ADMIN_USERS_INVITE';
@@ -96,6 +134,11 @@ const ACTION_ADMIN_SEARCH_INDICES_REBUILD = 'ADMIN_SEARCH_INDICES_REBUILD';
 const ACTION_ADMIN_GROWI_DATA_IMPORTED = 'ADMIN_GROWI_DATA_IMPORTED';
 const ACTION_ADMIN_ESA_DATA_IMPORTED = 'ADMIN_ESA_DATA_IMPORTED';
 const ACTION_ADMIN_QIITA_DATA_IMPORTED = 'ADMIN_QIITA_DATA_IMPORTED';
+const ACTION_ADMIN_UPLOADED_GROWI_DATA_DISCARDED = 'ADMIN_UPLOADED_GROWI_DATA_DISCARDED';
+const ACTION_ADMIN_ESA_DATA_UPDATED = 'ADMIN_ESA_DATA_UPDATED';
+const ACTION_ADMIN_CONNECTION_TEST_OF_ESA_DATA = 'ADMIN_CONNECTION_TEST_OF_ESA_DATA';
+const ACTION_ADMIN_QIITA_DATA_UPDATED = 'ADMIN_QIITA_DATA_UPDATED';
+const ACTION_ADMIN_CONNECTION_TEST_OF_QIITA_DATA = 'ADMIN_CONNECTION_TEST_OF_QIITA_DATA';
 
 
 export const SupportedTargetModel = {
@@ -109,6 +152,11 @@ export const SupportedEventModel = {
 export const SupportedActionCategory = {
   PAGE: 'Page',
   COMMENT: 'Comment',
+  TAG: 'Tag',
+  ATTACHMENT: 'Attachment',
+  SHARE_LINK: 'ShareLink',
+  IN_APP_NOTIFICATION: 'InAppNotification',
+  SEARCH: 'Search',
   USER: 'User',
   ADMIN: 'Admin',
 } as const;
@@ -116,7 +164,14 @@ export const SupportedActionCategory = {
 export const SupportedAction = {
   ACTION_UNSETTLED,
   ACTION_USER_REGISTRATION_SUCCESS,
-  ACTION_USER_LOGIN_SUCCESS,
+  ACTION_USER_LOGIN_WITH_LOCAL,
+  ACTION_USER_LOGIN_WITH_LDAP,
+  ACTION_USER_LOGIN_WITH_GOOGLE,
+  ACTION_USER_LOGIN_WITH_GITHUB,
+  ACTION_USER_LOGIN_WITH_TWITTER,
+  ACTION_USER_LOGIN_WITH_OIDC,
+  ACTION_USER_LOGIN_WITH_SAML,
+  ACTION_USER_LOGIN_WITH_BASIC,
   ACTION_USER_LOGIN_FAILURE,
   ACTION_USER_LOGOUT,
   ACTION_USER_PERSONAL_SETTINGS_UPDATE,
@@ -128,6 +183,10 @@ export const SupportedAction = {
   ACTION_USER_EDITOR_SETTINGS_UPDATE,
   ACTION_USER_IN_APP_NOTIFICATION_SETTINGS_UPDATE,
   ACTION_PAGE_VIEW,
+  ACTION_PAGE_USER_HOME_VIEW,
+  ACTION_PAGE_FORBIDDEN,
+  ACTION_PAGE_NOT_FOUND,
+  ACTION_PAGE_NOT_CREATABLE,
   ACTION_PAGE_LIKE,
   ACTION_PAGE_UNLIKE,
   ACTION_PAGE_BOOKMARK,
@@ -139,9 +198,27 @@ export const SupportedAction = {
   ACTION_PAGE_DELETE,
   ACTION_PAGE_DELETE_COMPLETELY,
   ACTION_PAGE_REVERT,
+  ACTION_PAGE_EMPTY_TRASH,
+  ACTION_PAGE_SUBSCRIBE,
+  ACTION_PAGE_UNSUBSCRIBE,
+  ACTION_PAGE_EXPORT,
+  ACTION_TAG_UPDATE,
+  ACTION_IN_APP_NOTIFICATION_ALL_STATUSES_OPEN,
   ACTION_COMMENT_CREATE,
   ACTION_COMMENT_UPDATE,
   ACTION_COMMENT_REMOVE,
+  ACTION_SHARE_LINK_CREATE,
+  ACTION_SHARE_LINK_DELETE,
+  ACTION_SHARE_LINK_DELETE_BY_PAGE,
+  ACTION_SHARE_LINK_ALL_DELETE,
+  ACTION_SHARE_LINK_PAGE_VIEW,
+  ACTION_SHARE_LINK_EXPIRED_PAGE_VIEW,
+  ACTION_SHARE_LINK_NOT_FOUND,
+  ACTION_ATTACHMENT_ADD,
+  ACTION_ATTACHMENT_REMOVE,
+  ACTION_ATTACHMENT_DOWNLOAD,
+  ACTION_SEARCH_PAGE,
+  ACTION_SEARCH_PAGE_VIEW,
   ACTION_ADMIN_APP_SETTINGS_UPDATE,
   ACTION_ADMIN_SITE_URL_UPDATE,
   ACTION_ADMIN_MAIL_SMTP_UPDATE,
@@ -154,7 +231,6 @@ export const SupportedAction = {
   ACTION_ADMIN_SECURITY_SETTINGS_UPDATE,
   ACTION_ADMIN_PERMIT_SHARE_LINK,
   ACTION_ADMIN_REJECT_SHARE_LINK,
-  ACTION_ADMIN_DELETE_ALL_SHARE_LINK,
   ACTION_ADMIN_AUTH_ID_PASS_ENABLED,
   ACTION_ADMIN_AUTH_ID_PASS_DISABLED,
   ACTION_ADMIN_AUTH_ID_PASS_UPDATE,
@@ -179,7 +255,10 @@ export const SupportedAction = {
   ACTION_ADMIN_AUTH_TWITTER_ENABLED,
   ACTION_ADMIN_AUTH_TWITTER_DISABLED,
   ACTION_ADMIN_AUTH_TWITTER_UPDATE,
-  ACTION_ADMIN_LINE_BREAK_UPDATE,
+  ACTION_ADMIN_MARKDOWN_LINE_BREAK_UPDATE,
+  ACTION_ADMIN_MARKDOWN_INDENT_UPDATE,
+  ACTION_ADMIN_MARKDOWN_PRESENTATION_UPDATE,
+  ACTION_ADMIN_MARKDOWN_XSS_UPDATE,
   ACTION_ADMIN_LAYOUT_UPDATE,
   ACTION_ADMIN_THEME_UPDATE,
   ACTION_ADMIN_FUNCTION_UPDATE,
@@ -191,6 +270,13 @@ export const SupportedAction = {
   ACTION_ADMIN_ARCHIVE_DATA_UPLOAD,
   ACTION_ADMIN_ARCHIVE_DATA_CREATE,
   ACTION_ADMIN_USER_NOTIFICATION_SETTINGS_ADD,
+  ACTION_ADMIN_USER_NOTIFICATION_SETTINGS_DELETE,
+  ACTION_ADMIN_GLOBAL_NOTIFICATION_SETTINGS_ADD,
+  ACTION_ADMIN_GLOBAL_NOTIFICATION_SETTINGS_UPDATE,
+  ACTION_ADMIN_NOTIFICATION_GRANT_SETTINGS_UPDATE,
+  ACTION_ADMIN_GLOBAL_NOTIFICATION_SETTINGS_ENABLED,
+  ACTION_ADMIN_GLOBAL_NOTIFICATION_SETTINGS_DISABLED,
+  ACTION_ADMIN_GLOBAL_NOTIFICATION_SETTINGS_DELETE,
   ACTION_ADMIN_SLACK_WORKSPACE_CREATE,
   ACTION_ADMIN_SLACK_CONFIGURATION_SETTING_UPDATE,
   ACTION_ADMIN_USERS_INVITE,
@@ -203,6 +289,11 @@ export const SupportedAction = {
   ACTION_ADMIN_GROWI_DATA_IMPORTED,
   ACTION_ADMIN_ESA_DATA_IMPORTED,
   ACTION_ADMIN_QIITA_DATA_IMPORTED,
+  ACTION_ADMIN_UPLOADED_GROWI_DATA_DISCARDED,
+  ACTION_ADMIN_ESA_DATA_UPDATED,
+  ACTION_ADMIN_CONNECTION_TEST_OF_ESA_DATA,
+  ACTION_ADMIN_QIITA_DATA_UPDATED,
+  ACTION_ADMIN_CONNECTION_TEST_OF_QIITA_DATA,
 } as const;
 
 // Action required for notification
@@ -225,7 +316,14 @@ export const ActionGroupSize = {
 } as const;
 
 export const SmallActionGroup = {
-  ACTION_USER_LOGIN_SUCCESS,
+  ACTION_USER_LOGIN_WITH_LOCAL,
+  ACTION_USER_LOGIN_WITH_LDAP,
+  ACTION_USER_LOGIN_WITH_GOOGLE,
+  ACTION_USER_LOGIN_WITH_GITHUB,
+  ACTION_USER_LOGIN_WITH_TWITTER,
+  ACTION_USER_LOGIN_WITH_OIDC,
+  ACTION_USER_LOGIN_WITH_SAML,
+  ACTION_USER_LOGIN_WITH_BASIC,
   ACTION_USER_LOGIN_FAILURE,
   ACTION_USER_LOGOUT,
   ACTION_PAGE_CREATE,
@@ -255,14 +353,29 @@ export const MediumActionGroup = {
   ACTION_PAGE_DELETE,
   ACTION_PAGE_DELETE_COMPLETELY,
   ACTION_PAGE_REVERT,
+  ACTION_PAGE_EMPTY_TRASH,
+  ACTION_PAGE_SUBSCRIBE,
+  ACTION_PAGE_UNSUBSCRIBE,
+  ACTION_PAGE_EXPORT,
+  ACTION_TAG_UPDATE,
+  ACTION_IN_APP_NOTIFICATION_ALL_STATUSES_OPEN,
   ACTION_COMMENT_CREATE,
   ACTION_COMMENT_UPDATE,
   ACTION_COMMENT_REMOVE,
+  ACTION_SHARE_LINK_CREATE,
+  ACTION_SHARE_LINK_DELETE,
+  ACTION_SHARE_LINK_DELETE_BY_PAGE,
+  ACTION_ATTACHMENT_ADD,
+  ACTION_ATTACHMENT_REMOVE,
+  ACTION_ATTACHMENT_DOWNLOAD,
+  ACTION_SEARCH_PAGE,
+  ACTION_SEARCH_PAGE_VIEW,
 } as const;
 
 // MediumActionGroup + All Actions by Admin Users - PAGE_VIEW
 export const LargeActionGroup = {
   ...MediumActionGroup,
+  ACTION_SHARE_LINK_ALL_DELETE,
   ACTION_ADMIN_APP_SETTINGS_UPDATE,
   ACTION_ADMIN_SITE_URL_UPDATE,
   ACTION_ADMIN_MAIL_SMTP_UPDATE,
@@ -275,7 +388,6 @@ export const LargeActionGroup = {
   ACTION_ADMIN_SECURITY_SETTINGS_UPDATE,
   ACTION_ADMIN_PERMIT_SHARE_LINK,
   ACTION_ADMIN_REJECT_SHARE_LINK,
-  ACTION_ADMIN_DELETE_ALL_SHARE_LINK,
   ACTION_ADMIN_AUTH_ID_PASS_ENABLED,
   ACTION_ADMIN_AUTH_ID_PASS_DISABLED,
   ACTION_ADMIN_AUTH_ID_PASS_UPDATE,
@@ -300,7 +412,10 @@ export const LargeActionGroup = {
   ACTION_ADMIN_AUTH_TWITTER_ENABLED,
   ACTION_ADMIN_AUTH_TWITTER_DISABLED,
   ACTION_ADMIN_AUTH_TWITTER_UPDATE,
-  ACTION_ADMIN_LINE_BREAK_UPDATE,
+  ACTION_ADMIN_MARKDOWN_LINE_BREAK_UPDATE,
+  ACTION_ADMIN_MARKDOWN_INDENT_UPDATE,
+  ACTION_ADMIN_MARKDOWN_PRESENTATION_UPDATE,
+  ACTION_ADMIN_MARKDOWN_XSS_UPDATE,
   ACTION_ADMIN_LAYOUT_UPDATE,
   ACTION_ADMIN_THEME_UPDATE,
   ACTION_ADMIN_FUNCTION_UPDATE,
@@ -312,6 +427,13 @@ export const LargeActionGroup = {
   ACTION_ADMIN_ARCHIVE_DATA_UPLOAD,
   ACTION_ADMIN_ARCHIVE_DATA_CREATE,
   ACTION_ADMIN_USER_NOTIFICATION_SETTINGS_ADD,
+  ACTION_ADMIN_USER_NOTIFICATION_SETTINGS_DELETE,
+  ACTION_ADMIN_GLOBAL_NOTIFICATION_SETTINGS_ADD,
+  ACTION_ADMIN_GLOBAL_NOTIFICATION_SETTINGS_UPDATE,
+  ACTION_ADMIN_NOTIFICATION_GRANT_SETTINGS_UPDATE,
+  ACTION_ADMIN_GLOBAL_NOTIFICATION_SETTINGS_ENABLED,
+  ACTION_ADMIN_GLOBAL_NOTIFICATION_SETTINGS_DISABLED,
+  ACTION_ADMIN_GLOBAL_NOTIFICATION_SETTINGS_DELETE,
   ACTION_ADMIN_SLACK_WORKSPACE_CREATE,
   ACTION_ADMIN_SLACK_CONFIGURATION_SETTING_UPDATE,
   ACTION_ADMIN_USERS_INVITE,
@@ -324,6 +446,11 @@ export const LargeActionGroup = {
   ACTION_ADMIN_GROWI_DATA_IMPORTED,
   ACTION_ADMIN_ESA_DATA_IMPORTED,
   ACTION_ADMIN_QIITA_DATA_IMPORTED,
+  ACTION_ADMIN_UPLOADED_GROWI_DATA_DISCARDED,
+  ACTION_ADMIN_ESA_DATA_UPDATED,
+  ACTION_ADMIN_CONNECTION_TEST_OF_ESA_DATA,
+  ACTION_ADMIN_QIITA_DATA_UPDATED,
+  ACTION_ADMIN_CONNECTION_TEST_OF_QIITA_DATA,
 } as const;
 
 
@@ -341,11 +468,21 @@ export const AllLargeGroupActions = Object.values(LargeActionGroup);
 // Action categories(for SelectActionDropdown.tsx)
 const pageRegExp = new RegExp(`^${SupportedActionCategory.PAGE.toUpperCase()}_`);
 const commentRegExp = new RegExp(`^${SupportedActionCategory.COMMENT.toUpperCase()}_`);
+const tagRegExp = new RegExp(`^${SupportedActionCategory.TAG.toUpperCase()}_`);
+const attachmentRegExp = RegExp(`^${SupportedActionCategory.ATTACHMENT.toUpperCase()}_`);
+const shareLinkRegExp = RegExp(`^${SupportedActionCategory.SHARE_LINK.toUpperCase()}_`);
+const inAppNotificationRegExp = RegExp(`^${SupportedActionCategory.IN_APP_NOTIFICATION.toUpperCase()}_`);
+const searchRegExp = RegExp(`^${SupportedActionCategory.SEARCH.toUpperCase()}_`);
 const userRegExp = new RegExp(`^${SupportedActionCategory.USER.toUpperCase()}_`);
 const adminRegExp = new RegExp(`^${SupportedActionCategory.ADMIN.toUpperCase()}_`);
 
 export const PageActions = AllSupportedActions.filter(action => action.match(pageRegExp));
 export const CommentActions = AllSupportedActions.filter(action => action.match(commentRegExp));
+export const TagActions = AllSupportedActions.filter(action => action.match(tagRegExp));
+export const AttachmentActions = AllSupportedActions.filter(action => action.match(attachmentRegExp));
+export const ShareLinkActions = AllSupportedActions.filter(action => action.match(shareLinkRegExp));
+export const InAppNotificationActions = AllSupportedActions.filter(action => action.match(inAppNotificationRegExp));
+export const SearchActions = AllSupportedActions.filter(action => action.match(searchRegExp));
 export const UserActions = AllSupportedActions.filter(action => action.match(userRegExp));
 export const AdminActions = AllSupportedActions.filter(action => action.match(adminRegExp));
 

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

+ 0 - 5
packages/app/src/interfaces/page-listing-results.ts

@@ -23,11 +23,6 @@ export interface TargetAndAncestors {
 }
 
 
-export interface IsNotFoundPermalink {
-  isNotFoundPermalink: boolean
-}
-
-
 export interface V5MigrationStatus {
   isV5Compatible : boolean,
   migratablePagesCount: number

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

+ 5 - 0
packages/app/src/server/interfaces/search.ts

@@ -36,6 +36,11 @@ export type SearchableData<T = Partial<QueryTerms>> = {
   terms: T
 }
 
+export type UpdateOrInsertPagesOpts = {
+  shouldEmitProgress?: boolean
+  invokeGarbageCollection?: boolean
+}
+
 // Terms Key types
 export type AllTermsKey = keyof QueryTerms;
 export type UnavailableTermsKey<K extends AllTermsKey> = Exclude<AllTermsKey, K>;

+ 12 - 12
packages/app/src/server/middlewares/login-required.js

@@ -12,18 +12,6 @@ module.exports = (crowi, isGuestAllowed = false, fallback = null) => {
 
   return function(req, res, next) {
 
-    // check the route config and ACL
-    if (isGuestAllowed && crowi.aclService.isGuestAllowedToRead()) {
-      logger.debug('Allowed to read: ', req.path);
-      return next();
-    }
-
-    // check the page is shared
-    if (isGuestAllowed && req.isSharedPage) {
-      logger.debug('Target page is shared page');
-      return next();
-    }
-
     const User = crowi.model('User');
 
     // check the user logged in
@@ -43,6 +31,18 @@ module.exports = (crowi, isGuestAllowed = false, fallback = null) => {
       }
     }
 
+    // check the route config and ACL
+    if (isGuestAllowed && crowi.aclService.isGuestAllowedToRead()) {
+      logger.debug('Allowed to read: ', req.path);
+      return next();
+    }
+
+    // check the page is shared
+    if (isGuestAllowed && req.isSharedPage) {
+      logger.debug('Target page is shared page');
+      return next();
+    }
+
     // is api path
     const baseUrl = req.baseUrl || '';
     if (baseUrl.match(/^\/_api\/.+$/)) {

+ 1 - 1
packages/app/src/server/interfaces/page-operation.ts → packages/app/src/server/models/interfaces/page-operation.ts

@@ -1,4 +1,4 @@
-import { ObjectIdLike } from './mongoose-utils';
+import { ObjectIdLike } from '../../interfaces/mongoose-utils';
 
 export type IPageForResuming = {
   _id: ObjectIdLike,

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

@@ -6,7 +6,7 @@ import mongoose, {
 
 import {
   IPageForResuming, IUserForResuming, IOptionsForResuming,
-} from '~/server/interfaces/page-operation';
+} from '~/server/models/interfaces/page-operation';
 
 import loggerFactory from '../../utils/logger';
 import { ObjectIdLike } from '../interfaces/mongoose-utils';

+ 8 - 1
packages/app/src/server/routes/admin.js

@@ -384,6 +384,8 @@ module.exports = function(crowi, app) {
 
     await configManager.updateConfigsInTheSameNamespace('crowi', form);
     importer.initializeEsaClient(); // let it run in the back aftert res
+    const parameters = { action: SupportedAction.ACTION_ADMIN_ESA_DATA_UPDATED };
+    activityEvent.emit('update', res.locals.activity._id, parameters);
     return res.json(ApiResponse.success());
   };
 
@@ -404,7 +406,8 @@ module.exports = function(crowi, app) {
 
     await configManager.updateConfigsInTheSameNamespace('crowi', form);
     importer.initializeQiitaClient(); // let it run in the back aftert res
-
+    const parameters = { action: SupportedAction.ACTION_ADMIN_QIITA_DATA_UPDATED };
+    activityEvent.emit('update', res.locals.activity._id, parameters);
     return res.json(ApiResponse.success());
   };
 
@@ -467,6 +470,8 @@ module.exports = function(crowi, app) {
   actions.api.testEsaAPI = async(req, res) => {
     try {
       await importer.testConnectionToEsa();
+      const parameters = { action: SupportedAction.ACTION_ADMIN_CONNECTION_TEST_OF_ESA_DATA };
+      activityEvent.emit('update', res.locals.activity._id, parameters);
       return res.json(ApiResponse.success());
     }
     catch (err) {
@@ -483,6 +488,8 @@ module.exports = function(crowi, app) {
   actions.api.testQiitaAPI = async(req, res) => {
     try {
       await importer.testConnectionToQiita();
+      const parameters = { action: SupportedAction.ACTION_ADMIN_CONNECTION_TEST_OF_QIITA_DATA };
+      activityEvent.emit('update', res.locals.activity._id, parameters);
       return res.json(ApiResponse.success());
     }
     catch (err) {

+ 3 - 3
packages/app/src/server/routes/apiv3/forgot-password.js

@@ -42,10 +42,10 @@ module.exports = (crowi) => {
   };
 
   const apiLimiter = rateLimit({
-    windowMs: 15 * 60 * 1000, // 15 minutes
-    max: 10, // limit each IP to 10 requests per windowMs
+    windowMs: 1 * 60 * 1000, // 1 minutes
+    max: 30, // limit each IP to 30 requests per windowMs
     message:
-      'Too many requests were sent from this IP. Please try a password reset request again on the password reset request form',
+    'Too many requests were sent from this IP. Please try a password reset request again on the password reset request form',
   });
 
   const checkPassportStrategyMiddleware = checkForgotPasswordEnabledMiddlewareFactory(crowi, true);

+ 13 - 1
packages/app/src/server/routes/apiv3/in-app-notification.ts

@@ -1,6 +1,10 @@
+import { SupportedAction } from '~/interfaces/activity';
+import { generateAddActivityMiddleware } from '~/server/middlewares/add-activity';
+
 import { IInAppNotification } from '../../../interfaces/in-app-notification';
 
 const express = require('express');
+
 const { serializeUserSecurely } = require('../../models/serializers/user-serializer');
 
 const router = express.Router();
@@ -10,9 +14,14 @@ module.exports = (crowi) => {
   const accessTokenParser = require('../../middlewares/access-token-parser')(crowi);
   const loginRequiredStrictly = require('../../middlewares/login-required')(crowi);
   const csrf = require('../../middlewares/csrf')(crowi);
+  const addActivity = generateAddActivityMiddleware(crowi);
+
   const inAppNotificationService = crowi.inAppNotificationService;
+
   const User = crowi.model('User');
 
+  const activityEvent = crowi.event('activity');
+
   router.get('/list', accessTokenParser, loginRequiredStrictly, async(req, res) => {
     const user = req.user;
 
@@ -101,11 +110,14 @@ module.exports = (crowi) => {
     }
   });
 
-  router.put('/all-statuses-open', accessTokenParser, loginRequiredStrictly, csrf, async(req, res) => {
+  router.put('/all-statuses-open', accessTokenParser, loginRequiredStrictly, csrf, addActivity, async(req, res) => {
     const user = req.user;
 
     try {
       await inAppNotificationService.updateAllNotificationsAsOpened(user);
+
+      activityEvent.emit('update', res.locals.activity._id, { action: SupportedAction.ACTION_IN_APP_NOTIFICATION_ALL_STATUSES_OPEN });
+
       return res.apiv3();
     }
     catch (err) {

+ 16 - 4
packages/app/src/server/routes/apiv3/markdown-setting.js

@@ -172,7 +172,7 @@ module.exports = (crowi) => {
         isEnabledLinebreaksInComments: await crowi.configManager.getConfig('markdown', 'markdown:isEnabledLinebreaksInComments'),
       };
 
-      const parameters = { action: SupportedAction.ACTION_ADMIN_LINE_BREAK_UPDATE };
+      const parameters = { action: SupportedAction.ACTION_ADMIN_MARKDOWN_LINE_BREAK_UPDATE };
       activityEvent.emit('update', res.locals.activity._id, parameters);
 
       return res.apiv3({ lineBreaksParams });
@@ -185,7 +185,7 @@ module.exports = (crowi) => {
 
   });
 
-  router.put('/indent', loginRequiredStrictly, adminRequired, csrf, validator.indent, apiV3FormValidator, async(req, res) => {
+  router.put('/indent', loginRequiredStrictly, adminRequired, csrf, addActivity, validator.indent, apiV3FormValidator, async(req, res) => {
 
     const requestIndentParams = {
       'markdown:adminPreferredIndentSize': req.body.adminPreferredIndentSize,
@@ -198,6 +198,10 @@ module.exports = (crowi) => {
         adminPreferredIndentSize: await crowi.configManager.getConfig('markdown', 'markdown:adminPreferredIndentSize'),
         isIndentSizeForced: await crowi.configManager.getConfig('markdown', 'markdown:isIndentSizeForced'),
       };
+
+      const parameters = { action: SupportedAction.ACTION_ADMIN_MARKDOWN_INDENT_UPDATE };
+      activityEvent.emit('update', res.locals.activity._id, parameters);
+
       return res.apiv3({ indentParams });
     }
     catch (err) {
@@ -231,7 +235,7 @@ module.exports = (crowi) => {
    *                schema:
    *                  $ref: '#/components/schemas/PresentationParams'
    */
-  router.put('/presentation', loginRequiredStrictly, adminRequired, csrf, validator.presentationSetting, apiV3FormValidator, async(req, res) => {
+  router.put('/presentation', loginRequiredStrictly, adminRequired, csrf, addActivity, validator.presentationSetting, apiV3FormValidator, async(req, res) => {
     if (req.body.pageBreakSeparator === 3 && req.body.pageBreakCustomSeparator === '') {
       return res.apiv3Err(new ErrorV3('customRegularExpression is required'));
     }
@@ -247,6 +251,10 @@ module.exports = (crowi) => {
         pageBreakSeparator: await crowi.configManager.getConfig('markdown', 'markdown:presentation:pageBreakSeparator'),
         pageBreakCustomSeparator: await crowi.configManager.getConfig('markdown', 'markdown:presentation:pageBreakCustomSeparator') || '',
       };
+
+      const parameters = { action: SupportedAction.ACTION_ADMIN_MARKDOWN_PRESENTATION_UPDATE };
+      activityEvent.emit('update', res.locals.activity._id, parameters);
+
       return res.apiv3({ presentationParams });
     }
     catch (err) {
@@ -280,7 +288,7 @@ module.exports = (crowi) => {
    *                schema:
    *                  $ref: '#/components/schemas/XssParams'
    */
-  router.put('/xss', loginRequiredStrictly, adminRequired, csrf, validator.xssSetting, apiV3FormValidator, async(req, res) => {
+  router.put('/xss', loginRequiredStrictly, adminRequired, csrf, addActivity, validator.xssSetting, apiV3FormValidator, async(req, res) => {
     if (req.body.isEnabledXss && req.body.xssOption == null) {
       return res.apiv3Err(new ErrorV3('xss option is required'));
     }
@@ -300,6 +308,10 @@ module.exports = (crowi) => {
         tagWhiteList: await crowi.configManager.getConfig('markdown', 'markdown:xss:tagWhiteList'),
         attrWhiteList: await crowi.configManager.getConfig('markdown', 'markdown:xss:attrWhiteList'),
       };
+
+      const parameters = { action: SupportedAction.ACTION_ADMIN_MARKDOWN_XSS_UPDATE };
+      activityEvent.emit('update', res.locals.activity._id, parameters);
+
       return res.apiv3({ xssParams });
     }
     catch (err) {

+ 38 - 7
packages/app/src/server/routes/apiv3/notification-setting.js

@@ -213,11 +213,15 @@ module.exports = (crowi) => {
    *                      type: object
    *                      description: deleted notification
    */
-  router.delete('/user-notification/:id', loginRequiredStrictly, adminRequired, csrf, async(req, res) => {
+  router.delete('/user-notification/:id', loginRequiredStrictly, adminRequired, csrf, addActivity, async(req, res) => {
     const { id } = req.params;
 
     try {
       const deletedNotificaton = await UpdatePost.findOneAndRemove({ _id: id });
+
+      const parameters = { action: SupportedAction.ACTION_ADMIN_USER_NOTIFICATION_SETTINGS_DELETE };
+      activityEvent.emit('update', res.locals.activity._id, parameters);
+
       return res.apiv3(deletedNotificaton);
     }
     catch (err) {
@@ -253,7 +257,8 @@ module.exports = (crowi) => {
    *                      type: object
    *                      description: notification param created
    */
-  router.post('/global-notification', loginRequiredStrictly, adminRequired, csrf, validator.globalNotification, apiV3FormValidator, async(req, res) => {
+  // eslint-disable-next-line max-len
+  router.post('/global-notification', loginRequiredStrictly, adminRequired, csrf, addActivity, validator.globalNotification, apiV3FormValidator, async(req, res) => {
 
     const {
       notifyToType, toEmail, slackChannels, triggerPath, triggerEvents,
@@ -275,6 +280,10 @@ module.exports = (crowi) => {
 
     try {
       const createdNotification = await notification.save();
+
+      const parameters = { action: SupportedAction.ACTION_ADMIN_GLOBAL_NOTIFICATION_SETTINGS_ADD };
+      activityEvent.emit('update', res.locals.activity._id, parameters);
+
       return res.apiv3({ createdNotification }, 201);
     }
     catch (err) {
@@ -316,7 +325,8 @@ module.exports = (crowi) => {
    *                      type: object
    *                      description: notification param updated
    */
-  router.put('/global-notification/:id', loginRequiredStrictly, adminRequired, csrf, validator.globalNotification, apiV3FormValidator, async(req, res) => {
+  // eslint-disable-next-line max-len
+  router.put('/global-notification/:id', loginRequiredStrictly, adminRequired, csrf, addActivity, validator.globalNotification, apiV3FormValidator, async(req, res) => {
     const { id } = req.params;
     const {
       notifyToType, toEmail, slackChannels, triggerPath, triggerEvents,
@@ -355,6 +365,10 @@ module.exports = (crowi) => {
       setting.triggerEvents = triggerEvents || [];
 
       const createdNotification = await setting.save();
+
+      const parameters = { action: SupportedAction.ACTION_ADMIN_GLOBAL_NOTIFICATION_SETTINGS_UPDATE };
+      activityEvent.emit('update', res.locals.activity._id, parameters);
+
       return res.apiv3({ createdNotification });
     }
     catch (err) {
@@ -387,7 +401,8 @@ module.exports = (crowi) => {
    *                schema:
    *                  $ref: '#/components/schemas/NotifyForPageGrant'
    */
-  router.put('/notify-for-page-grant', loginRequiredStrictly, adminRequired, csrf, validator.notifyForPageGrant, apiV3FormValidator, async(req, res) => {
+  // eslint-disable-next-line max-len
+  router.put('/notify-for-page-grant', loginRequiredStrictly, adminRequired, csrf, addActivity, validator.notifyForPageGrant, apiV3FormValidator, async(req, res) => {
 
     let requestParams = {
       'notification:owner-page:isEnabled': req.body.isNotificationForOwnerPageEnabled,
@@ -402,6 +417,10 @@ module.exports = (crowi) => {
         isNotificationForOwnerPageEnabled: await crowi.configManager.getConfig('notification', 'notification:owner-page:isEnabled'),
         isNotificationForGroupPageEnabled: await crowi.configManager.getConfig('notification', 'notification:group-page:isEnabled'),
       };
+
+      const parameters = { action: SupportedAction.ACTION_ADMIN_NOTIFICATION_GRANT_SETTINGS_UPDATE };
+      activityEvent.emit('update', res.locals.activity._id, parameters);
+
       return res.apiv3({ responseParams });
     }
     catch (err) {
@@ -409,7 +428,9 @@ module.exports = (crowi) => {
       logger.error('Error', err);
       return res.apiv3Err(new ErrorV3(msg, 'update-notify-for-page-grant-failed'));
     }
+
   });
+
   /**
    * @swagger
    *
@@ -444,7 +465,7 @@ module.exports = (crowi) => {
    *                      type: object
    *                      description: notification id for updated
    */
-  router.put('/global-notification/:id/enabled', loginRequiredStrictly, adminRequired, csrf, async(req, res) => {
+  router.put('/global-notification/:id/enabled', loginRequiredStrictly, adminRequired, csrf, addActivity, async(req, res) => {
     const { id } = req.params;
     const { isEnabled } = req.body;
 
@@ -456,6 +477,13 @@ module.exports = (crowi) => {
         await GlobalNotificationSetting.disable(id);
       }
 
+      const parameters = {
+        action: isEnabled
+          ? SupportedAction.ACTION_ADMIN_GLOBAL_NOTIFICATION_SETTINGS_ENABLED
+          : SupportedAction.ACTION_ADMIN_GLOBAL_NOTIFICATION_SETTINGS_DISABLED,
+      };
+      activityEvent.emit('update', res.locals.activity._id, parameters);
+
       return res.apiv3({ id });
 
     }
@@ -492,11 +520,15 @@ module.exports = (crowi) => {
   *                      type: object
   *                      description: deleted notification
   */
-  router.delete('/global-notification/:id', loginRequiredStrictly, adminRequired, csrf, async(req, res) => {
+  router.delete('/global-notification/:id', loginRequiredStrictly, adminRequired, csrf, addActivity, async(req, res) => {
     const { id } = req.params;
 
     try {
       const deletedNotificaton = await GlobalNotificationSetting.findOneAndRemove({ _id: id });
+
+      const parameters = { action: SupportedAction.ACTION_ADMIN_GLOBAL_NOTIFICATION_SETTINGS_DELETE };
+      activityEvent.emit('update', res.locals.activity._id, parameters);
+
       return res.apiv3(deletedNotificaton);
     }
     catch (err) {
@@ -505,7 +537,6 @@ module.exports = (crowi) => {
       return res.apiv3Err(new ErrorV3(msg, 'delete-globalNotification-failed'));
     }
 
-
   });
 
   return router;

+ 25 - 2
packages/app/src/server/routes/apiv3/page.js

@@ -1,7 +1,7 @@
 import { pagePathUtils } from '@growi/core';
 
 import { SupportedAction, SupportedTargetModel } from '~/interfaces/activity';
-import { AllSubscriptionStatusType } from '~/interfaces/subscription';
+import { AllSubscriptionStatusType, SubscriptionStatusType } from '~/interfaces/subscription';
 import { generateAddActivityMiddleware } from '~/server/middlewares/add-activity';
 import Subscription from '~/server/models/subscription';
 import UserGroup from '~/server/models/user-group';
@@ -609,6 +609,17 @@ module.exports = (crowi) => {
       'Content-Disposition': `attachment;filename*=UTF-8''${fileName}.${format}`,
     });
 
+    const parameters = {
+      ip:  req.ip,
+      endpoint: req.originalUrl,
+      action: SupportedAction.ACTION_PAGE_EXPORT,
+      user: req.user?._id,
+      snapshot: {
+        username: req.user?.username,
+      },
+    };
+    await crowi.activityService.createActivity(parameters);
+
     return stream.pipe(res);
   });
 
@@ -780,12 +791,24 @@ module.exports = (crowi) => {
    *          500:
    *            description: Internal server error.
    */
-  router.put('/subscribe', accessTokenParser, loginRequiredStrictly, csrf, validator.subscribe, apiV3FormValidator, async(req, res) => {
+  router.put('/subscribe', accessTokenParser, loginRequiredStrictly, csrf, addActivity, validator.subscribe, apiV3FormValidator, async(req, res) => {
     const { pageId, status } = req.body;
     const userId = req.user._id;
 
     try {
       const subscription = await Subscription.subscribeByPageId(userId, pageId, status);
+
+      const parameters = {};
+      if (SubscriptionStatusType.SUBSCRIBE === status) {
+        Object.assign(parameters, { action: SupportedAction.ACTION_PAGE_SUBSCRIBE });
+      }
+      else if (SubscriptionStatusType.UNSUBSCRIBE === status) {
+        Object.assign(parameters, { action: SupportedAction.ACTION_PAGE_UNSUBSCRIBE });
+      }
+      if ('action' in parameters) {
+        activityEvent.emit('update', res.locals.activity._id, parameters);
+      }
+
       return res.apiv3({ subscription });
     }
     catch (err) {

+ 11 - 1
packages/app/src/server/routes/apiv3/pages.js

@@ -597,7 +597,7 @@ module.exports = (crowi) => {
    *          200:
    *            description: Succeeded to remove all trash pages
    */
-  router.delete('/empty-trash', accessTokenParser, loginRequired, csrf, apiV3FormValidator, async(req, res) => {
+  router.delete('/empty-trash', accessTokenParser, loginRequired, csrf, addActivity, apiV3FormValidator, async(req, res) => {
     const options = {};
 
     const pagesInTrash = await crowi.pageService.findChildrenByParentPathOrIdAndViewer('/trash', req.user);
@@ -609,14 +609,20 @@ module.exports = (crowi) => {
       return res.apiv3Err(new ErrorV3(msg), 500);
     }
 
+    const parameters = { action: SupportedAction.ACTION_PAGE_EMPTY_TRASH };
+
     // when some pages are not deletable
     if (deletablePages.length < pagesInTrash.length) {
       try {
         const options = { isCompletely: true, isRecursively: true };
         await crowi.pageService.deleteMultiplePages(deletablePages, req.user, options);
+
+        activityEvent.emit('update', res.locals.activity._id, parameters);
+
         return res.apiv3({ deletablePages });
       }
       catch (err) {
+        logger.error(err);
         return res.apiv3Err(new ErrorV3('Failed to update page.', 'unknown'), 500);
       }
     }
@@ -624,9 +630,13 @@ module.exports = (crowi) => {
     else {
       try {
         const pages = await crowi.pageService.emptyTrashPage(req.user, options);
+
+        activityEvent.emit('update', res.locals.activity._id, parameters);
+
         return res.apiv3({ pages });
       }
       catch (err) {
+        logger.error(err);
         return res.apiv3Err(new ErrorV3('Failed to update page.', 'unknown'), 500);
       }
     }

+ 18 - 6
packages/app/src/server/routes/apiv3/share-links.js

@@ -31,9 +31,11 @@ module.exports = (crowi) => {
   const loginRequired = require('../../middlewares/login-required')(crowi);
   const adminRequired = require('../../middlewares/admin-required')(crowi);
   const csrf = require('../../middlewares/csrf')(crowi);
+  const addActivity = generateAddActivityMiddleware(crowi);
+
   const ShareLink = crowi.model('ShareLink');
   const Page = crowi.model('Page');
-  const addActivity = generateAddActivityMiddleware(crowi);
+
   const activityEvent = crowi.event('activity');
 
   /**
@@ -134,7 +136,7 @@ module.exports = (crowi) => {
    *            description: Succeeded to create one share link
    */
 
-  router.post('/', loginRequired, linkSharingRequired, csrf, validator.shareLinkStatus, apiV3FormValidator, async(req, res) => {
+  router.post('/', loginRequired, linkSharingRequired, csrf, addActivity, validator.shareLinkStatus, apiV3FormValidator, async(req, res) => {
     const { relatedPage, expiredAt, description } = req.body;
 
     const page = await Page.findByIdAndViewer(relatedPage, req.user);
@@ -149,6 +151,9 @@ module.exports = (crowi) => {
 
     try {
       const postedShareLink = await ShareLink.create({ relatedPage, expiredAt, description });
+
+      activityEvent.emit('update', res.locals.activity._id, { action: SupportedAction.ACTION_SHARE_LINK_CREATE });
+
       return res.apiv3(postedShareLink, 201);
     }
     catch (err) {
@@ -183,7 +188,7 @@ module.exports = (crowi) => {
   *          200:
   *            description: Succeeded to delete o all share links related one page
   */
-  router.delete('/', loginRequired, csrf, validator.deleteShareLinks, apiV3FormValidator, async(req, res) => {
+  router.delete('/', loginRequired, csrf, addActivity, validator.deleteShareLinks, apiV3FormValidator, async(req, res) => {
     const { relatedPage } = req.query;
     const page = await Page.findByIdAndViewer(relatedPage, req.user);
 
@@ -195,6 +200,9 @@ module.exports = (crowi) => {
 
     try {
       const deletedShareLink = await ShareLink.remove({ relatedPage });
+
+      activityEvent.emit('update', res.locals.activity._id, { action: SupportedAction.ACTION_SHARE_LINK_DELETE_BY_PAGE });
+
       return res.apiv3(deletedShareLink);
     }
     catch (err) {
@@ -220,8 +228,9 @@ module.exports = (crowi) => {
     try {
       const deletedShareLink = await ShareLink.deleteMany({});
       const { deletedCount } = deletedShareLink;
-      const parameters = { action: SupportedAction.ACTION_ADMIN_DELETE_ALL_SHARE_LINK };
-      activityEvent.emit('update', res.locals.activity._id, parameters);
+
+      activityEvent.emit('update', res.locals.activity._id, { action: SupportedAction.ACTION_SHARE_LINK_ALL_DELETE });
+
       return res.apiv3({ deletedCount });
     }
     catch (err) {
@@ -253,7 +262,7 @@ module.exports = (crowi) => {
   *          200:
   *            description: Succeeded to delete one share link
   */
-  router.delete('/:id', loginRequired, csrf, validator.deleteShareLink, apiV3FormValidator, async(req, res) => {
+  router.delete('/:id', loginRequired, csrf, addActivity, validator.deleteShareLink, apiV3FormValidator, async(req, res) => {
     const { id } = req.params;
     const { user } = req;
 
@@ -273,6 +282,9 @@ module.exports = (crowi) => {
 
       // remove
       await shareLinkToDelete.remove();
+
+      activityEvent.emit('update', res.locals.activity._id, { action: SupportedAction.ACTION_SHARE_LINK_DELETE });
+
       return res.apiv3({ deletedShareLink: shareLinkToDelete });
     }
     catch (err) {

+ 19 - 0
packages/app/src/server/routes/attachment.js

@@ -1,5 +1,7 @@
+import { SupportedAction } from '~/interfaces/activity';
 import loggerFactory from '~/utils/logger';
 
+
 /* eslint-disable no-use-before-define */
 
 
@@ -135,6 +137,8 @@ module.exports = function(crowi, app) {
   const GlobalNotificationSetting = crowi.model('GlobalNotificationSetting');
   const { attachmentService, globalNotificationService } = crowi;
 
+  const activityEvent = crowi.event('activity');
+
   /**
    * Check the user is accessible to the related page
    *
@@ -214,6 +218,17 @@ module.exports = function(crowi, app) {
       return res.json(ApiResponse.error(e.message));
     }
 
+    const parameters = {
+      ip:  req.ip,
+      endpoint: req.originalUrl,
+      action: SupportedAction.ACTION_ATTACHMENT_DOWNLOAD,
+      user: req.user?._id,
+      snapshot: {
+        username: req.user?.username,
+      },
+    };
+    await crowi.activityService.createActivity(parameters);
+
     return fileStream.pipe(res);
   }
 
@@ -472,6 +487,8 @@ module.exports = function(crowi, app) {
       pageCreated,
     };
 
+    activityEvent.emit('update', res.locals.activity._id, { action: SupportedAction.ACTION_ATTACHMENT_ADD });
+
     res.json(ApiResponse.success(result));
 
     if (pageCreated) {
@@ -641,6 +658,8 @@ module.exports = function(crowi, app) {
       return res.status(500).json(ApiResponse.error('Error while deleting file'));
     }
 
+    activityEvent.emit('update', res.locals.activity._id, { action: SupportedAction.ACTION_ATTACHMENT_REMOVE });
+
     return res.json(ApiResponse.success({}));
   };
 

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

@@ -21,10 +21,10 @@ const multer = require('multer');
 const autoReap = require('multer-autoreap');
 
 const apiLimiter = rateLimit({
-  windowMs: 15 * 60 * 1000, // 15 minutes
-  max: 10, // limit each IP to 10 requests per windowMs
+  windowMs: 1 * 60 * 1000, // 1 minutes
+  max: 60, // limit each IP to 60 requests per windowMs
   message:
-    'Too many requests sent from this IP, please try again after 15 minutes',
+    'Too many requests sent from this IP, please try again after 1 minute',
 });
 
 autoReap.options.reapOnError = true; // continue reaping the file even if an error occurs
@@ -106,7 +106,7 @@ module.exports = function(crowi, app) {
   app.get('/passport/github/callback'             , loginPassport.loginPassportGitHubCallback   , loginPassport.loginFailure);
   app.get('/passport/twitter/callback'            , loginPassport.loginPassportTwitterCallback  , loginPassport.loginFailure);
   app.get('/passport/oidc/callback'               , loginPassport.loginPassportOidcCallback     , loginPassport.loginFailure);
-  app.post('/passport/saml/callback'              , loginPassport.loginPassportSamlCallback     , loginPassport.loginFailure);
+  app.post('/passport/saml/callback'              , addActivity, loginPassport.loginPassportSamlCallback, loginPassport.loginFailure);
 
   app.post('/_api/login/testLdap'    , apiLimiter , loginRequiredStrictly , loginFormValidator.loginRules() , loginFormValidator.loginValidation , loginPassport.testLdapCredentials);
 
@@ -144,12 +144,12 @@ module.exports = function(crowi, app) {
 
   // importer management for admin
   app.get('/admin/importer'                     , loginRequiredStrictly , adminRequired , admin.importer.index);
-  app.post('/_api/admin/settings/importerEsa'   , loginRequiredStrictly , adminRequired , csrf, admin.importer.api.validators.importer.esa(),admin.api.importerSettingEsa);
-  app.post('/_api/admin/settings/importerQiita' , loginRequiredStrictly , adminRequired , csrf , admin.importer.api.validators.importer.qiita(), admin.api.importerSettingQiita);
+  app.post('/_api/admin/settings/importerEsa'   , loginRequiredStrictly , adminRequired , csrf, addActivity, admin.importer.api.validators.importer.esa(),admin.api.importerSettingEsa);
+  app.post('/_api/admin/settings/importerQiita' , loginRequiredStrictly , adminRequired , csrf, addActivity, admin.importer.api.validators.importer.qiita(), admin.api.importerSettingQiita);
   app.post('/_api/admin/import/esa'             , loginRequiredStrictly , adminRequired , addActivity, admin.api.importDataFromEsa);
-  app.post('/_api/admin/import/testEsaAPI'      , loginRequiredStrictly , adminRequired , csrf, admin.api.testEsaAPI);
+  app.post('/_api/admin/import/testEsaAPI'      , loginRequiredStrictly , adminRequired , csrf, addActivity, admin.api.testEsaAPI);
   app.post('/_api/admin/import/qiita'           , loginRequiredStrictly , adminRequired , addActivity, admin.api.importDataFromQiita);
-  app.post('/_api/admin/import/testQiitaAPI'    , loginRequiredStrictly , adminRequired , csrf, admin.api.testQiitaAPI);
+  app.post('/_api/admin/import/testQiitaAPI'    , loginRequiredStrictly , adminRequired , csrf, addActivity, admin.api.testQiitaAPI);
 
   // export management for admin
   app.get('/admin/export'                       , loginRequiredStrictly , adminRequired ,admin.export.index);
@@ -184,15 +184,15 @@ module.exports = function(crowi, app) {
   apiV1Router.post('/pages.duplicate'    , accessTokenParser, loginRequiredStrictly, csrf, page.api.duplicate);
   apiV1Router.get('/tags.list'           , accessTokenParser, loginRequired, tag.api.list);
   apiV1Router.get('/tags.search'         , accessTokenParser, loginRequired, tag.api.search);
-  apiV1Router.post('/tags.update'        , accessTokenParser, loginRequiredStrictly, tag.api.update);
+  apiV1Router.post('/tags.update'        , accessTokenParser, loginRequiredStrictly, addActivity, tag.api.update);
   apiV1Router.get('/comments.get'        , accessTokenParser , loginRequired , comment.api.get);
   apiV1Router.post('/comments.add'       , comment.api.validators.add(), accessTokenParser , loginRequiredStrictly , csrf, addActivity, comment.api.add);
   apiV1Router.post('/comments.update'    , comment.api.validators.add(), accessTokenParser , loginRequiredStrictly , csrf, addActivity, comment.api.update);
   apiV1Router.post('/comments.remove'    , accessTokenParser , loginRequiredStrictly , csrf, addActivity, comment.api.remove);
 
-  apiV1Router.post('/attachments.add'                  , uploads.single('file'), autoReap, accessTokenParser, loginRequiredStrictly ,csrf, attachment.api.add);
+  apiV1Router.post('/attachments.add'                  , uploads.single('file'), autoReap, accessTokenParser, loginRequiredStrictly ,csrf, addActivity ,attachment.api.add);
   apiV1Router.post('/attachments.uploadProfileImage'   , uploads.single('file'), autoReap, accessTokenParser, loginRequiredStrictly ,csrf, attachment.api.uploadProfileImage);
-  apiV1Router.post('/attachments.remove'               , accessTokenParser , loginRequiredStrictly , csrf, attachment.api.remove);
+  apiV1Router.post('/attachments.remove'               , accessTokenParser , loginRequiredStrictly , csrf, addActivity ,attachment.api.remove);
   apiV1Router.post('/attachments.removeProfileImage'   , accessTokenParser , loginRequiredStrictly , csrf, attachment.api.removeProfileImage);
   apiV1Router.get('/attachments.limit'   , accessTokenParser , loginRequiredStrictly, attachment.api.limit);
 

+ 67 - 7
packages/app/src/server/routes/login-passport.js

@@ -33,9 +33,6 @@ module.exports = function(crowi, app) {
     // remove session.redirectTo
     delete req.session.redirectTo;
 
-    const parameters = { action: SupportedAction.ACTION_USER_LOGIN_SUCCESS };
-    activityEvent.emit('update', res.locals.activity._id, parameters);
-
     return res.safeRedirect(redirectTo);
   };
 
@@ -143,6 +140,10 @@ module.exports = function(crowi, app) {
     // login
     await req.logIn(user, (err) => {
       if (err) { debug(err.message); return next() }
+
+      const parameters = { action: SupportedAction.ACTION_USER_LOGIN_WITH_LDAP };
+      activityEvent.emit('update', res.locals.activity._id, parameters);
+
       return loginSuccessHandler(req, res, user);
     });
   };
@@ -235,6 +236,9 @@ module.exports = function(crowi, app) {
       req.logIn(user, (err) => {
         if (err) { debug(err.message); return next() }
 
+        const parameters = { action: SupportedAction.ACTION_USER_LOGIN_WITH_LOCAL };
+        activityEvent.emit('update', res.locals.activity._id, parameters);
+
         return loginSuccessHandler(req, res, user);
       });
     })(req, res, next);
@@ -303,8 +307,20 @@ module.exports = function(crowi, app) {
     const user = await externalAccount.getPopulatedUser();
 
     // login
-    req.logIn(user, (err) => {
+    req.logIn(user, async(err) => {
       if (err) { debug(err.message); return next() }
+
+      const parameters = {
+        ip:  req.ip,
+        endpoint: req.originalUrl,
+        action: SupportedAction.ACTION_USER_LOGIN_WITH_GOOGLE,
+        user: req.user?._id,
+        snapshot: {
+          username: req.user?.username,
+        },
+      };
+      await crowi.activityService.createActivity(parameters);
+
       return loginSuccessHandler(req, res, user);
     });
   };
@@ -345,8 +361,20 @@ module.exports = function(crowi, app) {
     const user = await externalAccount.getPopulatedUser();
 
     // login
-    req.logIn(user, (err) => {
+    req.logIn(user, async(err) => {
       if (err) { debug(err.message); return next() }
+
+      const parameters = {
+        ip:  req.ip,
+        endpoint: req.originalUrl,
+        action: SupportedAction.ACTION_USER_LOGIN_WITH_GITHUB,
+        user: req.user?._id,
+        snapshot: {
+          username: req.user?.username,
+        },
+      };
+      await crowi.activityService.createActivity(parameters);
+
       return loginSuccessHandler(req, res, user);
     });
   };
@@ -387,8 +415,20 @@ module.exports = function(crowi, app) {
     const user = await externalAccount.getPopulatedUser();
 
     // login
-    req.logIn(user, (err) => {
+    req.logIn(user, async(err) => {
       if (err) { debug(err.message); return next() }
+
+      const parameters = {
+        ip:  req.ip,
+        endpoint: req.originalUrl,
+        action: SupportedAction.ACTION_USER_LOGIN_WITH_TWITTER,
+        user: req.user?._id,
+        snapshot: {
+          username: req.user?.username,
+        },
+      };
+      await crowi.activityService.createActivity(parameters);
+
       return loginSuccessHandler(req, res, user);
     });
   };
@@ -435,8 +475,20 @@ module.exports = function(crowi, app) {
 
     // login
     const user = await externalAccount.getPopulatedUser();
-    req.logIn(user, (err) => {
+    req.logIn(user, async(err) => {
       if (err) { debug(err.message); return next() }
+
+      const parameters = {
+        ip:  req.ip,
+        endpoint: req.originalUrl,
+        action: SupportedAction.ACTION_USER_LOGIN_WITH_OIDC,
+        user: req.user?._id,
+        snapshot: {
+          username: req.user?.username,
+        },
+      };
+      await crowi.activityService.createActivity(parameters);
+
       return loginSuccessHandler(req, res, user);
     });
   };
@@ -499,6 +551,10 @@ module.exports = function(crowi, app) {
         logger.error(err);
         return loginFailureHandler(req, res);
       }
+
+      const parameters = { action: SupportedAction.ACTION_USER_LOGIN_WITH_SAML };
+      activityEvent.emit('update', res.locals.activity._id, parameters);
+
       return loginSuccessHandler(req, res, user);
     });
   };
@@ -541,6 +597,10 @@ module.exports = function(crowi, app) {
     const user = await externalAccount.getPopulatedUser();
     await req.logIn(user, (err) => {
       if (err) { debug(err.message); return next() }
+
+      const parameters = { action: SupportedAction.ACTION_USER_LOGIN_WITH_BASIC };
+      activityEvent.emit('update', res.locals.activity._id, parameters);
+
       return loginSuccessHandler(req, res, user);
     });
   };

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

@@ -222,8 +222,7 @@ module.exports = function(crowi, app) {
       }
     }
     else {
-      return res.render('invited', {
-      });
+      return res.render('invited');
     }
   };
 

+ 45 - 9
packages/app/src/server/routes/page.js

@@ -174,7 +174,7 @@ module.exports = function(crowi, app) {
   const actions = {};
 
   function getPathFromRequest(req) {
-    return pathUtils.normalizePath(req.pagePath || req.params[0] || '');
+    return pathUtils.normalizePath(req.pagePath || req.params[0] || req.params.id || '');
   }
 
   function generatePager(offset, limit, totalCount) {
@@ -278,9 +278,6 @@ module.exports = function(crowi, app) {
     }
 
     renderVars.notFoundTargetPathOrId = pathOrId;
-
-    const isPath = pathOrId.includes('/');
-    renderVars.isNotFoundPermalink = !isPath && !await Page.exists({ _id: pathOrId });
   }
 
   async function addRenderVarsWhenEmptyPage(renderVars, isEmpty, pageId) {
@@ -309,16 +306,20 @@ module.exports = function(crowi, app) {
     const pathOrId = req.params.id || path;
 
     let view;
+    let action;
     const renderVars = { path };
 
     if (!isCreatablePage(path)) {
       view = 'layout-growi/not_creatable';
+      action = SupportedAction.ACTION_PAGE_NOT_CREATABLE;
     }
     else if (req.isForbidden) {
       view = 'layout-growi/forbidden';
+      action = SupportedAction.ACTION_PAGE_FORBIDDEN;
     }
     else {
       view = 'layout-growi/not_found';
+      action = SupportedAction.ACTION_PAGE_NOT_FOUND;
 
       // retrieve templates
       if (req.user != null) {
@@ -345,6 +346,18 @@ module.exports = function(crowi, app) {
     await addRenderVarsForPageTree(renderVars, pathOrId, req.user);
     await addRenderVarsWhenNotFound(renderVars, pathOrId);
     await addRenderVarsWhenEmptyPage(renderVars, req.isEmpty, req.pageId);
+
+    const parameters = {
+      ip:  req.ip,
+      endpoint: req.originalUrl,
+      action,
+      user: req.user?._id,
+      snapshot: {
+        username: req.user?.username,
+      },
+    };
+    crowi.activityService.createActivity(parameters);
+
     return res.render(view, renderVars);
   }
 
@@ -421,7 +434,7 @@ module.exports = function(crowi, app) {
         username: req.user?.username,
       },
     };
-    crowi.activityService.createActivity(SupportedAction.ACTION_PAGE_VIEW, parameters);
+    crowi.activityService.createActivity(parameters);
 
     return res.render(view, renderVars);
   }
@@ -484,13 +497,13 @@ module.exports = function(crowi, app) {
     const parameters = {
       ip:  req.ip,
       endpoint: req.originalUrl,
-      action: SupportedAction.ACTION_PAGE_VIEW,
+      action: isUsersHomePage(path) ? SupportedAction.ACTION_PAGE_USER_HOME_VIEW : SupportedAction.ACTION_PAGE_VIEW,
       user: req.user?._id,
       snapshot: {
         username: req.user?.username,
       },
     };
-    crowi.activityService.createActivity(SupportedAction.ACTION_PAGE_VIEW, parameters);
+    crowi.activityService.createActivity(parameters);
 
     return res.render(view, renderVars);
   }
@@ -525,13 +538,30 @@ module.exports = function(crowi, app) {
     const revisionId = req.query.revision;
     const renderVars = {};
 
+    const parameters = {
+      ip:  req.ip,
+      endpoint: req.originalUrl,
+      user: req.user?._id,
+      snapshot: {
+        username: req.user?.username,
+      },
+    };
+
     const shareLink = await ShareLink.findOne({ _id: linkId }).populate('relatedPage');
 
     if (shareLink == null || shareLink.relatedPage == null || shareLink.relatedPage.isEmpty) {
+
+      Object.assign(parameters, { action: SupportedAction.ACTION_SHARE_LINK_NOT_FOUND });
+      crowi.activityService.createActivity(parameters);
+
       // page or sharelink are not found (or page is empty: abnormaly)
       return res.render('layout-growi/not_found_shared_page');
     }
     if (crowi.configManager.getConfig('crowi', 'security:disableLinkSharing')) {
+
+      Object.assign(parameters, { action: SupportedAction.ACTION_SHARE_LINK_NOT_FOUND });
+      crowi.activityService.createActivity(parameters);
+
       return res.render('layout-growi/forbidden');
     }
 
@@ -539,6 +569,9 @@ module.exports = function(crowi, app) {
 
     // check if share link is expired
     if (shareLink.isExpired()) {
+      Object.assign(parameters, { action: SupportedAction.ACTION_SHARE_LINK_EXPIRED_PAGE_VIEW });
+      crowi.activityService.createActivity(parameters);
+
       // page is not found
       return res.render('layout-growi/expired_shared_page', renderVars);
     }
@@ -561,6 +594,9 @@ module.exports = function(crowi, app) {
     addRenderVarsForPage(renderVars, page);
     addRenderVarsForScope(renderVars, page);
 
+    Object.assign(parameters, { action: SupportedAction.ACTION_SHARE_LINK_PAGE_VIEW });
+    crowi.activityService.createActivity(parameters);
+
     return res.render('layout-growi/shared_page', renderVars);
   };
 
@@ -681,7 +717,7 @@ module.exports = function(crowi, app) {
         username: req.user?.username,
       },
     };
-    crowi.activityService.createActivity(SupportedAction.ACTION_PAGE_VIEW, parameters);
+    crowi.activityService.createActivity(parameters);
     return redirector(req, res, next, path);
   };
 
@@ -698,7 +734,7 @@ module.exports = function(crowi, app) {
         username: req.user?.username,
       },
     };
-    crowi.activityService.createActivity(SupportedAction.ACTION_PAGE_VIEW, parameters);
+    crowi.activityService.createActivity(parameters);
 
     return redirector(req, res, next, path);
   };

+ 27 - 1
packages/app/src/server/routes/search.ts

@@ -1,6 +1,9 @@
+import { SupportedAction } from '~/interfaces/activity';
 import loggerFactory from '~/utils/logger';
+
 import { isSearchError } from '../models/vo/search-error';
 
+
 const logger = loggerFactory('growi:routes:search');
 
 /**
@@ -37,9 +40,20 @@ module.exports = function(crowi, app) {
   const actions: any = {};
   const api: any = {};
 
-  actions.searchPage = function(req, res) {
+  actions.searchPage = async function(req, res) {
     const keyword = req.query.q || null;
 
+    const parameters = {
+      ip:  req.ip,
+      endpoint: req.originalUrl,
+      action: SupportedAction.ACTION_SEARCH_PAGE_VIEW,
+      user: req.user?._id,
+      snapshot: {
+        username: req.user?.username,
+      },
+    };
+    await crowi.activityService.createActivity(parameters);
+
     return res.render('search', {
       q: keyword,
     });
@@ -168,6 +182,18 @@ module.exports = function(crowi, app) {
       logger.error(err);
       return res.json(ApiResponse.error(err));
     }
+
+    const parameters = {
+      ip:  req.ip,
+      endpoint: req.originalUrl,
+      action: SupportedAction.ACTION_SEARCH_PAGE,
+      user: req.user?._id,
+      snapshot: {
+        username: req.user?.username,
+      },
+    };
+    await crowi.activityService.createActivity(parameters);
+
     return res.json(ApiResponse.success(result));
   };
 

+ 5 - 0
packages/app/src/server/routes/tag.js

@@ -1,3 +1,4 @@
+import { SupportedAction } from '~/interfaces/activity';
 import Tag from '~/server/models/tag';
 
 /**
@@ -32,6 +33,7 @@ import Tag from '~/server/models/tag';
 module.exports = function(crowi, app) {
 
   const PageTagRelation = crowi.model('PageTagRelation');
+  const activityEvent = crowi.event('activity');
   const ApiResponse = require('../util/apiResponse');
   const actions = {};
   const api = {};
@@ -166,6 +168,9 @@ module.exports = function(crowi, app) {
     catch (err) {
       return res.json(ApiResponse.error(err));
     }
+
+    activityEvent.emit('update', res.locals.activity._id, { action: SupportedAction.ACTION_TAG_UPDATE });
+
     return res.json(ApiResponse.success(result));
   };
 

+ 2 - 2
packages/app/src/server/service/activity.ts

@@ -103,8 +103,8 @@ class ActivityService {
   }
 
   // for GET request
-  createActivity = async function(action: SupportedActionType, parameters): Promise<void> {
-    const shoudCreateActivity = this.crowi.activityService.shoudUpdateActivity(action);
+  createActivity = async function(parameters): Promise<void> {
+    const shoudCreateActivity = this.crowi.activityService.shoudUpdateActivity(parameters.action);
     if (shoudCreateActivity) {
       try {
         await Activity.createByParameters(parameters);

+ 4 - 0
packages/app/src/server/service/interfaces/search.ts

@@ -0,0 +1,4 @@
+export type UpdateOrInsertPagesOpts = {
+  shouldEmitProgress?: boolean
+  invokeGarbageCollection?: boolean
+}

+ 15 - 6
packages/app/src/server/service/page.ts

@@ -2606,7 +2606,7 @@ class PageService {
 
     // then migrate
     try {
-      await this.normalizeParentRecursively(['/'], null);
+      await this.normalizeParentRecursively(['/'], null, true);
     }
     catch (err) {
       logger.error('V5 initial miration failed.', err);
@@ -2653,7 +2653,7 @@ class PageService {
    * @param user To be used to filter pages to update. If null, only public pages will be updated.
    * @returns Promise<void>
    */
-  async normalizeParentRecursively(paths: string[], user: any | null, shouldEmit = false): Promise<number> {
+  async normalizeParentRecursively(paths: string[], user: any | null, shouldEmitProgress = false): Promise<number> {
     const Page = mongoose.model('Page') as unknown as PageModel;
 
     const ancestorPaths = paths.flatMap(p => collectAncestorPaths(p, []));
@@ -2672,7 +2672,7 @@ class PageService {
 
     const grantFiltersByUser: { $or: any[] } = Page.generateGrantCondition(user, userGroups);
 
-    return this._normalizeParentRecursively(pathAndRegExpsToNormalize, ancestorPaths, grantFiltersByUser, user, shouldEmit);
+    return this._normalizeParentRecursively(pathAndRegExpsToNormalize, ancestorPaths, grantFiltersByUser, user, shouldEmitProgress);
   }
 
   private buildFilterForNormalizeParentRecursively(pathOrRegExps: (RegExp | string)[], publicPathsToNormalize: string[], grantFiltersByUser: { $or: any[] }) {
@@ -2722,7 +2722,7 @@ class PageService {
       publicPathsToNormalize: string[],
       grantFiltersByUser: { $or: any[] },
       user,
-      shouldEmit = false,
+      shouldEmitProgress = false,
       count = 0,
       skiped = 0,
       isFirst = true,
@@ -2730,7 +2730,7 @@ class PageService {
     const BATCH_SIZE = 100;
     const PAGES_LIMIT = 1000;
 
-    const socket = shouldEmit ? this.crowi.socketIoService.getAdminSocket() : null;
+    const socket = shouldEmitProgress ? this.crowi.socketIoService.getAdminSocket() : null;
 
     const Page = mongoose.model('Page') as unknown as PageModel;
     const { PageQueryBuilder } = Page;
@@ -2895,7 +2895,16 @@ class PageService {
     await streamToPromise(migratePagesStream);
 
     if (await Page.exists(matchFilter) && shouldContinue) {
-      return this._normalizeParentRecursively(pathOrRegExps, publicPathsToNormalize, grantFiltersByUser, user, shouldEmit, nextCount, nextSkiped, false);
+      return this._normalizeParentRecursively(
+        pathOrRegExps,
+        publicPathsToNormalize,
+        grantFiltersByUser,
+        user,
+        shouldEmitProgress,
+        nextCount,
+        nextSkiped,
+        false,
+      );
     }
 
     // End

+ 9 - 9
packages/app/src/server/service/search-delegator/elasticsearch.ts

@@ -18,6 +18,7 @@ import {
 } from '../../interfaces/search';
 import { PageModel } from '../../models/page';
 import { createBatchStream } from '../../util/batch-stream';
+import { UpdateOrInsertPagesOpts } from '../interfaces/search';
 
 
 import ElasticsearchClient from './elasticsearch-client';
@@ -437,7 +438,7 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
 
   addAllPages() {
     const Page = mongoose.model('Page');
-    return this.updateOrInsertPages(() => Page.find(), { isEmittingProgressEvent: true, invokeGarbageCollection: true });
+    return this.updateOrInsertPages(() => Page.find(), { shouldEmitProgress: true, invokeGarbageCollection: true });
   }
 
   updateOrInsertPageById(pageId) {
@@ -456,8 +457,8 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
   /**
    * @param {function} queryFactory factory method to generate a Mongoose Query instance
    */
-  async updateOrInsertPages(queryFactory, option: any = {}) {
-    const { isEmittingProgressEvent = false, invokeGarbageCollection = false } = option;
+  async updateOrInsertPages(queryFactory, option: UpdateOrInsertPagesOpts = {}) {
+    const { shouldEmitProgress = false, invokeGarbageCollection = false } = option;
 
     const Page = mongoose.model('Page') as unknown as PageModel;
     const { PageQueryBuilder } = Page;
@@ -465,7 +466,7 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
     const Comment = mongoose.model('Comment') as any; // TODO: typescriptize model
     const PageTagRelation = mongoose.model('PageTagRelation') as any; // TODO: typescriptize model
 
-    const socket = this.socketIoService.getAdminSocket();
+    const socket = shouldEmitProgress ? this.socketIoService.getAdminSocket() : null;
 
     // prepare functions invoked from custom streams
     const prepareBodyForCreate = this.prepareBodyForCreate.bind(this);
@@ -583,8 +584,8 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
 
           logger.info(`Adding pages progressing: (count=${count}, errors=${res.errors}, took=${res.took}ms)`);
 
-          if (isEmittingProgressEvent) {
-            socket.emit('addPageProgress', { totalCount, count, skipped });
+          if (shouldEmitProgress) {
+            socket?.emit('addPageProgress', { totalCount, count, skipped });
           }
         }
         catch (err) {
@@ -607,8 +608,8 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
       final(callback) {
         logger.info(`Adding pages has completed: (totalCount=${totalCount}, skipped=${skipped})`);
 
-        if (isEmittingProgressEvent) {
-          socket.emit('finishAddPage', { totalCount, count, skipped });
+        if (shouldEmitProgress) {
+          socket?.emit('finishAddPage', { totalCount, count, skipped });
         }
         callback();
       },
@@ -623,7 +624,6 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
       .pipe(writeStream);
 
     return streamToPromise(writeStream);
-
   }
 
   deletePages(pages) {

+ 0 - 1
packages/app/src/server/views/layout-growi/not_found.html

@@ -10,7 +10,6 @@
   </div>
   <div
     id="growi-not-found-context"
-    data-is-not-found-permalink="{% if isNotFoundPermalink %}{{isNotFoundPermalink|json}}{% endif %}"
     data-page-id="{%if pageId %}{{pageId.toString()}}{% endif %}"
   >
   </div>

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

@@ -5,7 +5,7 @@ import useSWRImmutable from 'swr/immutable';
 
 import { SupportedActionType } from '~/interfaces/activity';
 
-import { TargetAndAncestors, IsNotFoundPermalink } from '../interfaces/page-listing-results';
+import { TargetAndAncestors } from '../interfaces/page-listing-results';
 import { IUser } from '../interfaces/user';
 
 import { useStaticSWR } from './use-static-swr';
@@ -138,10 +138,6 @@ export const useNotFoundTargetPathOrId = (initialData?: string): SWRResponse<str
   return useStaticSWR<string, Error>('notFoundTargetPathOrId', initialData);
 };
 
-export const useIsNotFoundPermalink = (initialData?: Nullable<IsNotFoundPermalink>): SWRResponse<Nullable<IsNotFoundPermalink>, Error> => {
-  return useStaticSWR<Nullable<IsNotFoundPermalink>, Error>('isNotFoundPermalink', initialData);
-};
-
 export const useIsAclEnabled = (initialData?: boolean) : SWRResponse<boolean, Error> => {
   return useStaticSWR<boolean, Error>('isAclEnabled', initialData);
 };

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

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

@@ -1,5 +1,7 @@
 import { RefObject } from 'react';
 
+import { constants } from 'zlib';
+
 import { isClient, pagePathUtils } from '@growi/core';
 import { Breakpoint, addBreakpointListener } from '@growi/ui';
 import SimpleBar from 'simplebar-react';
@@ -19,11 +21,10 @@ import loggerFactory from '~/utils/logger';
 
 import {
   useCurrentPageId, useCurrentPagePath, useIsEditable, useIsTrashPage, useIsUserPage, useIsGuestUser, useEmptyPageId,
-  useIsNotCreatable, useIsSharedUser, useNotFoundTargetPathOrId, useIsForbidden, useIsIdenticalPath, useIsNotFoundPermalink, useCurrentUser,
+  useIsNotCreatable, useIsSharedUser, useNotFoundTargetPathOrId, useIsForbidden, useIsIdenticalPath, useCurrentUser,
 } from './context';
 import { localStorageMiddleware } from './middlewares/sync-to-storage';
 import { useStaticSWR } from './use-static-swr';
-import { constants } from 'zlib';
 
 const { isSharedPage } = pagePathUtils;
 
@@ -444,13 +445,12 @@ export const useIsAbleToShowPageEditorModeManager = (): SWRResponse<boolean, Err
   const { data: isForbidden } = useIsForbidden();
   const { data: isTrashPage } = useIsTrashPage();
   const { data: isSharedUser } = useIsSharedUser();
-  const { data: isNotFoundPermalink } = useIsNotFoundPermalink();
 
-  const includesUndefined = [isNotCreatable, isForbidden, isTrashPage, isSharedUser, isNotFoundPermalink].some(v => v === undefined);
+  const includesUndefined = [isNotCreatable, isForbidden, isTrashPage, isSharedUser].some(v => v === undefined);
 
   return useSWRImmutable(
     includesUndefined ? null : key,
-    () => !isNotCreatable && !isForbidden && !isTrashPage && !isSharedUser && !isNotFoundPermalink,
+    () => !isNotCreatable && !isForbidden && !isTrashPage && !isSharedUser,
   );
 };
 

+ 1 - 3
packages/app/src/styles/atoms/_buttons.scss

@@ -7,7 +7,6 @@
   &:not(:disabled):not(.disabled):not(:hover) {
     background-color: transparent;
   }
-  box-shadow: none !important;
 }
 
 .btn.btn-bookmark {
@@ -19,7 +18,6 @@
   &:not(:disabled):not(.disabled):not(:hover) {
     background-color: transparent;
   }
-  box-shadow: none !important;
 }
 
 .btn.btn-subscribe {
@@ -31,6 +29,7 @@
   &:not(:disabled):not(.disabled):not(:hover) {
     background-color: transparent;
   }
+  width: 44px;
 }
 
 .btn.btn-seen-user {
@@ -48,7 +47,6 @@
   &:not(:disabled):not(.disabled):not(:hover) {
     background-color: transparent;
   }
-  box-shadow: none !important;
 }
 
 .btn-copy,

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

+ 142 - 45
packages/app/test/integration/middlewares/login-required.test.js

@@ -18,60 +18,157 @@ describe('loginRequired', () => {
   });
 
   describe('not strict mode', () => {
-    // setup req/res/next
-    const req = {
-      originalUrl: 'original url 1',
-      session: {},
-    };
     const res = {
       redirect: jest.fn().mockReturnValue('redirect'),
+      sendStatus: jest.fn().mockReturnValue('sendStatus'),
     };
     const next = jest.fn().mockReturnValue('next');
 
-    test('pass guest user when aclService.isGuestAllowedToRead() returns true', () => {
-      // prepare spy for AclService.isGuestAllowedToRead
-      const isGuestAllowedToReadSpy = jest.spyOn(crowi.aclService, 'isGuestAllowedToRead')
-        .mockImplementation(() => true);
-
-      const result = loginRequired(req, res, next);
-
-      expect(isGuestAllowedToReadSpy).toHaveBeenCalledTimes(1);
-      expect(fallbackMock).not.toHaveBeenCalled();
-      expect(next).toHaveBeenCalled();
-      expect(res.redirect).not.toHaveBeenCalled();
-      expect(result).toBe('next');
-    });
-
-    test('redirect to \'/login\' when aclService.isGuestAllowedToRead() returns false', () => {
-      // prepare spy for AclService.isGuestAllowedToRead
-      const isGuestAllowedToReadSpy = jest.spyOn(crowi.aclService, 'isGuestAllowedToRead')
-        .mockImplementation(() => false);
+    describe('and when aclService.isGuestAllowedToRead() returns false', () => {
+      let req;
+
+      let isGuestAllowedToReadSpy;
+
+      beforeEach(async() => {
+        // setup req
+        req = {
+          originalUrl: 'original url 1',
+          session: {},
+        };
+        // reset session object
+        req.session = {};
+        // prepare spy for AclService.isGuestAllowedToRead
+        isGuestAllowedToReadSpy = jest.spyOn(crowi.aclService, 'isGuestAllowedToRead')
+          .mockImplementation(() => false);
+      });
+
+      /* eslint-disable indent */
+      test.each`
+        userStatus  | expectedPath
+        ${1}        | ${'/login/error/registered'}
+        ${3}        | ${'/login/error/suspended'}
+        ${5}        | ${'/login/invited'}
+      `('redirect to \'$expectedPath\' when user.status is \'$userStatus\'', ({ userStatus, expectedPath }) => {
+
+        req.user = {
+          _id: 'user id',
+          status: userStatus,
+        };
+
+        const result = loginRequired(req, res, next);
+
+        expect(isGuestAllowedToReadSpy).not.toHaveBeenCalled();
+        expect(next).not.toHaveBeenCalled();
+        expect(fallbackMock).not.toHaveBeenCalled();
+        expect(res.sendStatus).not.toHaveBeenCalled();
+        expect(res.redirect).toHaveBeenCalledTimes(1);
+        expect(res.redirect).toHaveBeenCalledWith(expectedPath);
+        expect(result).toBe('redirect');
+        expect(req.session.redirectTo).toBe(undefined);
+      });
+      /* eslint-disable indent */
+
+      test('redirect to \'/login\' when the user does not loggedin', () => {
+        req.baseUrl = '/path/that/requires/loggedin';
+
+        const result = loginRequired(req, res, next);
+
+        expect(isGuestAllowedToReadSpy).toHaveBeenCalled();
+        expect(next).not.toHaveBeenCalled();
+        expect(fallbackMock).not.toHaveBeenCalled();
+        expect(res.sendStatus).not.toHaveBeenCalled();
+        expect(res.redirect).toHaveBeenCalledTimes(1);
+        expect(res.redirect).toHaveBeenCalledWith('/login');
+        expect(result).toBe('redirect');
+        expect(req.session.redirectTo).toBe('original url 1');
+      });
+
+      test('pass anyone into sharedPage', () => {
+
+        req.isSharedPage = true;
+
+        const result = loginRequired(req, res, next);
+
+        expect(isGuestAllowedToReadSpy).toHaveBeenCalled();
+        expect(fallbackMock).not.toHaveBeenCalled();
+        expect(res.sendStatus).not.toHaveBeenCalled();
+        expect(next).toHaveBeenCalled();
+        expect(res.redirect).not.toHaveBeenCalled();
+        expect(result).toBe('next');
+      });
 
-      const result = loginRequired(req, res, next);
-
-      expect(isGuestAllowedToReadSpy).toHaveBeenCalled();
-      expect(fallbackMock).not.toHaveBeenCalled();
-      expect(next).not.toHaveBeenCalled();
-      expect(res.redirect).toHaveBeenCalledTimes(1);
-      expect(res.redirect).toHaveBeenCalledWith('/login');
-      expect(result).toBe('redirect');
     });
 
-    test('pass anyone into sharedPage when aclService.isGuestAllowedToRead() returns false', () => {
+    describe('and when aclService.isGuestAllowedToRead() returns true', () => {
+      let req;
+
+      let isGuestAllowedToReadSpy;
+
+      beforeEach(async() => {
+        // setup req
+        req = {
+          originalUrl: 'original url 1',
+          session: {},
+        };
+        // reset session object
+        req.session = {};
+        // prepare spy for AclService.isGuestAllowedToRead
+        isGuestAllowedToReadSpy = jest.spyOn(crowi.aclService, 'isGuestAllowedToRead')
+          .mockImplementation(() => true);
+      });
+
+      /* eslint-disable indent */
+      test.each`
+        userStatus  | expectedPath
+        ${1}        | ${'/login/error/registered'}
+        ${3}        | ${'/login/error/suspended'}
+        ${5}        | ${'/login/invited'}
+      `('redirect to \'$expectedPath\' when user.status is \'$userStatus\'', ({ userStatus, expectedPath }) => {
+
+        req.user = {
+          _id: 'user id',
+          status: userStatus,
+        };
+
+        const result = loginRequired(req, res, next);
+
+        expect(isGuestAllowedToReadSpy).not.toHaveBeenCalled();
+        expect(next).not.toHaveBeenCalled();
+        expect(fallbackMock).not.toHaveBeenCalled();
+        expect(res.sendStatus).not.toHaveBeenCalled();
+        expect(res.redirect).toHaveBeenCalledTimes(1);
+        expect(res.redirect).toHaveBeenCalledWith(expectedPath);
+        expect(result).toBe('redirect');
+        expect(req.session.redirectTo).toBe(undefined);
+      });
+      /* eslint-disable indent */
+
+      test('pass guest user', () => {
+
+        const result = loginRequired(req, res, next);
+
+        expect(isGuestAllowedToReadSpy).toHaveBeenCalledTimes(1);
+        expect(fallbackMock).not.toHaveBeenCalled();
+        expect(res.sendStatus).not.toHaveBeenCalled();
+        expect(next).toHaveBeenCalled();
+        expect(res.redirect).not.toHaveBeenCalled();
+        expect(result).toBe('next');
+      });
+
+      test('pass anyone into sharedPage', () => {
+
+        req.isSharedPage = true;
+
+        const result = loginRequired(req, res, next);
+
+        expect(isGuestAllowedToReadSpy).toHaveBeenCalled();
+        expect(fallbackMock).not.toHaveBeenCalled();
+        expect(res.sendStatus).not.toHaveBeenCalled();
+        expect(next).toHaveBeenCalled();
+        expect(res.redirect).not.toHaveBeenCalled();
+        expect(result).toBe('next');
+      });
 
-      req.isSharedPage = true;
-
-      // prepare spy for AclService.isGuestAllowedToRead
-      const isGuestAllowedToReadSpy = jest.spyOn(crowi.aclService, 'isGuestAllowedToRead')
-        .mockImplementation(() => false);
-
-      const result = loginRequired(req, res, next);
-
-      expect(isGuestAllowedToReadSpy).toHaveBeenCalled();
-      expect(fallbackMock).not.toHaveBeenCalled();
-      expect(next).toHaveBeenCalled();
-      expect(res.redirect).not.toHaveBeenCalled();
-      expect(result).toBe('next');
     });
 
   });

+ 1 - 1
packages/codemirror-textlint/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/codemirror-textlint",
-  "version": "5.0.11-RC.0",
+  "version": "5.0.12-RC.0",
   "license": "MIT",
   "main": "dist/index.js",
   "scripts": {

+ 1 - 1
packages/core/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/core",
-  "version": "5.0.11-RC.0",
+  "version": "5.0.12-RC.0",
   "description": "GROWI Core Libraries",
   "license": "MIT",
   "keywords": [

+ 1 - 1
packages/plugin-attachment-refs/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/plugin-attachment-refs",
-  "version": "5.0.11-RC.0",
+  "version": "5.0.12-RC.0",
   "description": "GROWI Plugin to add ref/refimg/refs/refsimg tags",
   "license": "MIT",
   "keywords": [

+ 1 - 1
packages/plugin-lsx/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/plugin-lsx",
-  "version": "5.0.11-RC.0",
+  "version": "5.0.12-RC.0",
   "description": "GROWI plugin to list pages",
   "license": "MIT",
   "keywords": [

+ 1 - 1
packages/plugin-pukiwiki-like-linker/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/plugin-pukiwiki-like-linker",
-  "version": "5.0.11-RC.0",
+  "version": "5.0.12-RC.0",
   "description": "GROWI plugin to add PukiwikiLikeLinker",
   "license": "MIT",
   "keywords": [

+ 1 - 1
packages/slack/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/slack",
-  "version": "5.0.11-RC.0",
+  "version": "5.0.12-RC.0",
   "license": "MIT",
   "main": "dist/index.js",
   "typings": "dist/index.d.ts",

+ 2 - 2
packages/slackbot-proxy/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/slackbot-proxy",
-  "version": "5.0.11-slackbot-proxy.0",
+  "version": "5.0.12-slackbot-proxy.0",
   "license": "MIT",
   "scripts": {
     "build": "yarn tsc && tsc-alias -p tsconfig.build.json",
@@ -25,7 +25,7 @@
   },
   "dependencies": {
     "@godaddy/terminus": "^4.9.0",
-    "@growi/slack": "^5.0.11-RC.0",
+    "@growi/slack": "^5.0.12-RC.0",
     "@slack/oauth": "^2.0.1",
     "@slack/web-api": "^6.2.4",
     "@tsed/common": "^6.43.0",

+ 1 - 1
packages/ui/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/ui",
-  "version": "5.0.11-RC.0",
+  "version": "5.0.12-RC.0",
   "description": "GROWI UI Libraries",
   "license": "MIT",
   "keywords": [