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

Merge branch 'master' into support/107699-next-PageStatusAlert

Yuken Tezuka 3 лет назад
Родитель
Сommit
85f50dca02
100 измененных файлов с 1114 добавлено и 952 удалено
  1. 0 173
      packages/app/_obsolete/src/client/admin.jsx
  2. 5 2
      packages/app/public/static/locales/en_US/admin.json
  3. 39 1
      packages/app/public/static/locales/en_US/commons.json
  4. 3 34
      packages/app/public/static/locales/en_US/translation.json
  5. 3 1
      packages/app/public/static/locales/ja_JP/admin.json
  6. 39 1
      packages/app/public/static/locales/ja_JP/commons.json
  7. 3 34
      packages/app/public/static/locales/ja_JP/translation.json
  8. 3 2
      packages/app/public/static/locales/zh_CN/admin.json
  9. 39 1
      packages/app/public/static/locales/zh_CN/commons.json
  10. 3 34
      packages/app/public/static/locales/zh_CN/translation.json
  11. 27 0
      packages/app/src/client/interfaces/global-notification.ts
  12. 8 0
      packages/app/src/client/interfaces/notification.ts
  13. 1 1
      packages/app/src/components/Admin/App/AppSetting.jsx
  14. 1 1
      packages/app/src/components/Admin/App/AppSettingsPageContents.tsx
  15. 7 7
      packages/app/src/components/Admin/Common/AdminNavigation.jsx
  16. 31 8
      packages/app/src/components/Admin/Customize/CustomizeLayoutSetting.tsx
  17. 1 3
      packages/app/src/components/Admin/Customize/CustomizeLogoSetting.tsx
  18. 5 3
      packages/app/src/components/Admin/Customize/CustomizeThemeSetting.tsx
  19. 1 1
      packages/app/src/components/Admin/NotFoundPage.tsx
  20. 2 2
      packages/app/src/components/Admin/Notification/GlobalNotificationList.jsx
  21. 42 52
      packages/app/src/components/Admin/Notification/ManageGlobalNotification.tsx
  22. 2 2
      packages/app/src/components/Admin/Notification/NotificationDeleteModal.jsx
  23. 0 43
      packages/app/src/components/Admin/Notification/NotificationTypeIcon.jsx
  24. 30 0
      packages/app/src/components/Admin/Notification/NotificationTypeIcon.tsx
  25. 1 1
      packages/app/src/components/Admin/Notification/UserNotificationRow.jsx
  26. 1 1
      packages/app/src/components/Admin/Security/GitHubSecuritySettingContents.jsx
  27. 1 1
      packages/app/src/components/Admin/Security/GoogleSecuritySettingContents.jsx
  28. 1 1
      packages/app/src/components/Admin/Security/LocalSecuritySettingContents.jsx
  29. 2 2
      packages/app/src/components/Admin/Security/OidcSecuritySettingContents.jsx
  30. 1 1
      packages/app/src/components/Admin/Security/SamlSecuritySettingContents.jsx
  31. 1 1
      packages/app/src/components/Admin/Security/TwitterSecuritySettingContents.jsx
  32. 1 1
      packages/app/src/components/Admin/SlackIntegration/ConfirmBotChangeModal.jsx
  33. 1 1
      packages/app/src/components/Admin/UserGroup/UserGroupDropdown.tsx
  34. 39 24
      packages/app/src/components/Admin/UserGroupDetail/UserGroupDetailPage.tsx
  35. 3 7
      packages/app/src/components/Admin/UserGroupDetail/UserGroupUserTable.tsx
  36. 3 3
      packages/app/src/components/AlertSiteUrlUndefined.tsx
  37. 2 1
      packages/app/src/components/Comments.tsx
  38. 3 1
      packages/app/src/components/Common/ImageCropModal.tsx
  39. 24 0
      packages/app/src/components/CompleteUserRegistration.tsx
  40. 135 90
      packages/app/src/components/CompleteUserRegistrationForm.tsx
  41. 2 1
      packages/app/src/components/DescendantsPageList.tsx
  42. 2 1
      packages/app/src/components/Fab.tsx
  43. 1 1
      packages/app/src/components/Hotkeys/Subscribers/CreatePage.jsx
  44. 1 1
      packages/app/src/components/InAppNotification/InAppNotificationDropdown.tsx
  45. 3 6
      packages/app/src/components/Layout/AdminLayout.tsx
  46. 50 15
      packages/app/src/components/LoginForm.tsx
  47. 3 2
      packages/app/src/components/Navbar/GlobalSearch.tsx
  48. 14 3
      packages/app/src/components/Navbar/GrowiContextualSubNavigation.tsx
  49. 2 1
      packages/app/src/components/Navbar/GrowiNavbar.tsx
  50. 2 1
      packages/app/src/components/Navbar/GrowiNavbarBottom.tsx
  51. 1 0
      packages/app/src/components/Navbar/PageEditorModeManager.jsx
  52. 4 4
      packages/app/src/components/Navbar/PersonalDropdown.jsx
  53. 23 18
      packages/app/src/components/Navbar/SubNavButtons.tsx
  54. 4 3
      packages/app/src/components/Page.tsx
  55. 5 2
      packages/app/src/components/Page/CopyDropdown.jsx
  56. 2 2
      packages/app/src/components/Page/DisplaySwitcher.tsx
  57. 1 2
      packages/app/src/components/PageAlert/TrashPageAlert.tsx
  58. 2 1
      packages/app/src/components/PageComment/CommentEditor.tsx
  59. 2 2
      packages/app/src/components/PageEditor.tsx
  60. 6 10
      packages/app/src/components/PageEditor/CodeMirrorEditor.jsx
  61. 1 1
      packages/app/src/components/PageEditor/Editor.tsx
  62. 2 1
      packages/app/src/components/PageEditor/EditorNavbarBottom.tsx
  63. 9 1
      packages/app/src/components/PageEditor/HandsontableModal.jsx
  64. 24 0
      packages/app/src/components/PageEditor/HandsontableModal.module.scss
  65. 1 1
      packages/app/src/components/PageEditor/LinkEditModal.jsx
  66. 6 6
      packages/app/src/components/PageEditorByHackmd.tsx
  67. 1 1
      packages/app/src/components/PageList/PageListItemL.tsx
  68. 1 1
      packages/app/src/components/PageTimeline.tsx
  69. 4 4
      packages/app/src/components/RevisionComparer/RevisionComparer.tsx
  70. 2 1
      packages/app/src/components/SavePageControls.tsx
  71. 14 3
      packages/app/src/components/SearchPage/SearchResultContent.tsx
  72. 2 1
      packages/app/src/components/Sidebar/PageTree.tsx
  73. 4 8
      packages/app/src/components/Sidebar/PageTree/Item.tsx
  74. 17 6
      packages/app/src/components/Sidebar/PageTree/ItemsTree.tsx
  75. 1 1
      packages/app/src/components/TableOfContents.tsx
  76. 16 9
      packages/app/src/components/UncontrolledCodeMirror.tsx
  77. 3 2
      packages/app/src/components/User/UserDate.jsx
  78. 4 0
      packages/app/src/interfaces/admin.ts
  79. 6 0
      packages/app/src/interfaces/errors/user-activation.ts
  80. 7 0
      packages/app/src/interfaces/registration-mode.ts
  81. 7 11
      packages/app/src/pages/[[...path]].page.tsx
  82. 5 1
      packages/app/src/pages/_search.page.tsx
  83. 32 0
      packages/app/src/pages/admin/[...path].page.tsx
  84. 2 2
      packages/app/src/pages/admin/app.page.tsx
  85. 1 0
      packages/app/src/pages/admin/global-notification/[globalNotificationId].page.tsx
  86. 2 1
      packages/app/src/pages/admin/global-notification/new.page.tsx
  87. 9 0
      packages/app/src/pages/admin/index.page.tsx
  88. 15 4
      packages/app/src/pages/admin/user-group-detail/[userGroupId].page.tsx
  89. 1 5
      packages/app/src/pages/installer.page.tsx
  90. 8 7
      packages/app/src/pages/login.page.tsx
  91. 1 2
      packages/app/src/pages/share/[[...path]].page.tsx
  92. 1 2
      packages/app/src/pages/trash.page.tsx
  93. 80 0
      packages/app/src/pages/user-activation.page.tsx
  94. 2 1
      packages/app/src/pages/utils/commons.ts
  95. 0 12
      packages/app/src/pages/utils/layout.ts
  96. 19 4
      packages/app/src/server/middlewares/inject-user-registration-order-by-token-middleware.ts
  97. 0 212
      packages/app/src/server/routes/admin.js
  98. 1 3
      packages/app/src/server/routes/apiv3/customize-setting.js
  99. 3 0
      packages/app/src/server/routes/apiv3/index.js
  100. 156 20
      packages/app/src/server/routes/apiv3/user-activation.ts

+ 0 - 173
packages/app/_obsolete/src/client/admin.jsx

@@ -1,173 +0,0 @@
-import React from 'react';
-
-import ReactDOM from 'react-dom';
-import { I18nextProvider } from 'react-i18next';
-import { SWRConfig } from 'swr';
-import { Provider } from 'unstated';
-
-import AdminAppContainer from '~/client/services/AdminAppContainer';
-import AdminBasicSecurityContainer from '~/client/services/AdminBasicSecurityContainer';
-import AdminCustomizeContainer from '~/client/services/AdminCustomizeContainer';
-import AdminExternalAccountsContainer from '~/client/services/AdminExternalAccountsContainer';
-import AdminGeneralSecurityContainer from '~/client/services/AdminGeneralSecurityContainer';
-import AdminGitHubSecurityContainer from '~/client/services/AdminGitHubSecurityContainer';
-import AdminGoogleSecurityContainer from '~/client/services/AdminGoogleSecurityContainer';
-import AdminHomeContainer from '~/client/services/AdminHomeContainer';
-import AdminImportContainer from '~/client/services/AdminImportContainer';
-import AdminLdapSecurityContainer from '~/client/services/AdminLdapSecurityContainer';
-import AdminLocalSecurityContainer from '~/client/services/AdminLocalSecurityContainer';
-import AdminMarkDownContainer from '~/client/services/AdminMarkDownContainer';
-import AdminNotificationContainer from '~/client/services/AdminNotificationContainer';
-import AdminOidcSecurityContainer from '~/client/services/AdminOidcSecurityContainer';
-import AdminSamlSecurityContainer from '~/client/services/AdminSamlSecurityContainer';
-import AdminSlackIntegrationLegacyContainer from '~/client/services/AdminSlackIntegrationLegacyContainer';
-import AdminSocketIoContainer from '~/client/services/AdminSocketIoContainer';
-import AdminTwitterSecurityContainer from '~/client/services/AdminTwitterSecurityContainer';
-// import AdminUserGroupDetailContainer from '~/client/services/AdminUserGroupDetailContainer';
-import AdminUsersContainer from '~/client/services/AdminUsersContainer';
-import ContextExtractor from '~/client/services/ContextExtractor';
-import loggerFactory from '~/utils/logger';
-import { swrGlobalConfiguration } from '~/utils/swr-utils';
-
-import AdminHome from '../components/Admin/AdminHome/AdminHome';
-// import AppSettingsPage from '../components/Admin/App/AppSettingsPage';
-import { AuditLogManagement } from '../components/Admin/AuditLogManagement';
-import AdminNavigation from '../components/Admin/Common/AdminNavigation';
-import Customize from '../components/Admin/Customize/Customize';
-import ExportArchiveDataPage from '../components/Admin/ExportArchiveDataPage';
-import FullTextSearchManagement from '../components/Admin/FullTextSearchManagement';
-// import ImportDataPage from '../components/Admin/ImportDataPage';
-import LegacySlackIntegration from '../components/Admin/LegacySlackIntegration/LegacySlackIntegration';
-import ManageExternalAccount from '../components/Admin/ManageExternalAccount';
-// import MarkdownSetting from '../components/Admin/MarkdownSetting/MarkDownSetting';
-// import ManageGlobalNotification from '../components/Admin/Notification/ManageGlobalNotification';
-import NotificationSetting from '../components/Admin/Notification/NotificationSetting';
-import SecurityManagement from '../components/Admin/Security/SecurityManagement';
-import SlackIntegration from '../components/Admin/SlackIntegration/SlackIntegration';
-import UserGroupPage from '../components/Admin/UserGroup/UserGroupPage';
-// import UserGroupDetailPage from '../components/Admin/UserGroupDetail/UserGroupDetailPage';
-import UserManagement from '../components/Admin/UserManagement';
-import ErrorBoundary from '../components/ErrorBoudary';
-
-import { appContainer, componentMappings } from './base';
-
-const logger = loggerFactory('growi:admin');
-
-appContainer.initContents();
-
-const { i18n } = appContainer;
-// create unstated container instance
-const adminAppContainer = new AdminAppContainer(appContainer);
-const adminImportContainer = new AdminImportContainer(appContainer);
-const adminSocketIoContainer = new AdminSocketIoContainer(appContainer);
-const adminHomeContainer = new AdminHomeContainer(appContainer);
-const adminCustomizeContainer = new AdminCustomizeContainer(appContainer);
-const adminUsersContainer = new AdminUsersContainer(appContainer);
-const adminExternalAccountsContainer = new AdminExternalAccountsContainer(appContainer);
-const adminNotificationContainer = new AdminNotificationContainer(appContainer);
-const adminSlackIntegrationLegacyContainer = new AdminSlackIntegrationLegacyContainer(appContainer);
-const adminMarkDownContainer = new AdminMarkDownContainer(appContainer);
-// const adminUserGroupDetailContainer = new AdminUserGroupDetailContainer(appContainer);
-const socketIoContainer = appContainer.getContainer('SocketIoContainer');
-const injectableContainers = [
-  appContainer,
-  adminAppContainer,
-  adminImportContainer,
-  adminSocketIoContainer,
-  adminHomeContainer,
-  adminCustomizeContainer,
-  adminUsersContainer,
-  adminExternalAccountsContainer,
-  adminNotificationContainer,
-  adminSlackIntegrationLegacyContainer,
-  adminMarkDownContainer,
-  // adminUserGroupDetailContainer,
-  socketIoContainer,
-];
-
-logger.info('unstated containers have been initialized');
-
-/**
- * define components
- *  key: id of element
- *  value: React Element
- */
-Object.assign(componentMappings, {
-  'admin-home': <AdminHome />,
-  // 'admin-app': <AppSettingsPage />,
-  // 'admin-markdown-setting': <MarkdownSetting />,
-  'admin-customize': <Customize />,
-  // 'admin-importer': <ImportDataPage />,
-  'admin-export-page': <ExportArchiveDataPage />,
-  'admin-notification-setting': <NotificationSetting />,
-  'admin-slack-integration': <SlackIntegration />,
-  'admin-slack-integration-legacy': <LegacySlackIntegration />,
-  // 'admin-global-notification-setting': <ManageGlobalNotification />,
-  'admin-user-page': <UserManagement />,
-  'admin-external-account-setting': <ManageExternalAccount />,
-  // 'admin-user-group-detail': <UserGroupDetailPage />,
-  'admin-full-text-search-management': <FullTextSearchManagement />,
-  'admin-user-group-page': <UserGroupPage />,
-  'admin-audit-log': <AuditLogManagement />,
-  'admin-navigation': <AdminNavigation />,
-});
-
-const renderMainComponents = () => {
-  Object.keys(componentMappings).forEach((key) => {
-    const elem = document.getElementById(key);
-    if (elem) {
-      ReactDOM.render(
-        <I18nextProvider i18n={i18n}>
-          <ErrorBoundary>
-            <Provider inject={injectableContainers}>
-              {componentMappings[key]}
-            </Provider>
-          </ErrorBoundary>
-        </I18nextProvider>,
-        elem,
-      );
-    }
-  });
-};
-
-// extract context before rendering main components
-const elem = document.getElementById('growi-context-extractor');
-if (elem != null) {
-  ReactDOM.render(
-    <SWRConfig value={swrGlobalConfiguration}>
-      <ContextExtractor></ContextExtractor>
-    </SWRConfig>,
-    elem,
-    renderMainComponents,
-  );
-}
-else {
-  renderMainComponents();
-}
-
-const adminSecuritySettingElem = document.getElementById('admin-security-setting');
-if (adminSecuritySettingElem != null) {
-  const adminGeneralSecurityContainer = new AdminGeneralSecurityContainer(appContainer);
-  const adminLocalSecurityContainer = new AdminLocalSecurityContainer(appContainer);
-  const adminLdapSecurityContainer = new AdminLdapSecurityContainer(appContainer);
-  const adminSamlSecurityContainer = new AdminSamlSecurityContainer(appContainer);
-  const adminOidcSecurityContainer = new AdminOidcSecurityContainer(appContainer);
-  const adminBasicSecurityContainer = new AdminBasicSecurityContainer(appContainer);
-  const adminGoogleSecurityContainer = new AdminGoogleSecurityContainer(appContainer);
-  const adminGitHubSecurityContainer = new AdminGitHubSecurityContainer(appContainer);
-  const adminTwitterSecurityContainer = new AdminTwitterSecurityContainer(appContainer);
-  const adminSecurityContainers = [
-    adminGeneralSecurityContainer, adminLocalSecurityContainer, adminLdapSecurityContainer, adminSamlSecurityContainer,
-    adminOidcSecurityContainer, adminBasicSecurityContainer, adminGoogleSecurityContainer, adminGitHubSecurityContainer, adminTwitterSecurityContainer,
-  ];
-  ReactDOM.render(
-    <I18nextProvider i18n={i18n}>
-      <ErrorBoundary>
-        <Provider inject={[...injectableContainers, ...adminSecurityContainers]}>
-          <SecurityManagement />
-        </Provider>
-      </ErrorBoundary>
-    </I18nextProvider>,
-    adminSecuritySettingElem,
-  );
-}

+ 5 - 2
packages/app/public/static/locales/en_US/admin.json

@@ -105,7 +105,6 @@
       "email_authentication": "Email authentication on user registration",
       "enable_email_authentication": "Enable email authentication",
       "enable_email_authentication_desc": "Email authentication is going to be performed for user registration.",
-      "please_enable_mailer": "Please setup mailer first.",
       "need_complete_mail_setting_warning": "To use the following functions, please complete the mail settings."
     },
     "ldap": {
@@ -277,7 +276,8 @@
     "delete_notification_pattern": "Delete notification pattern",
     "delete_notification_pattern_desc1": "Delete Path: {{path}}",
     "delete_notification_pattern_desc2": "Once deleted, it cannot be recovered",
-    "toggle_notification": "Updated setting of {{path}}"
+    "toggle_notification": "Updated setting of {{path}}",
+    "not_found_global_notification_triggerid": "Not found the global notification id"
   },
   "mailer_setup_required":"<a href='/admin/app'>Email settings</a> are required to send.",
   "admin_top": {
@@ -862,6 +862,9 @@
       "log_type": "https://docs.growi.org/en/admin-guide/admin-cookbook/audit-log-setup.html#log-types"
     }
   },
+  "cloud_setting_management": {
+    "to_cloud_settings": "Open GROWI.cloud Settings"
+  },
   "audit_log_action_category": {
     "Page": "Page",
     "Comment": "Comment",

+ 39 - 1
packages/app/public/static/locales/en_US/commons.json

@@ -12,16 +12,54 @@
     "remove_share_link": "Succeeded to remove {{count}} share links"
   },
   "alert": {
-    "siteUrl_is_not_set": "'Site URL' is NOT set. Set it from the {{link}}"
+    "siteUrl_is_not_set": "'Site URL' is NOT set. Set it from the {{link}}",
+    "please_enable_mailer": "Please setup mailer first."
   },
   "headers": {
     "app_settings": "App Settings"
   },
 
+  "header_search_box": {
+    "label": {
+      "All pages": "All pages",
+      "This tree": "This tree"
+    },
+    "item_label": {
+      "All pages": "All pages",
+      "This tree": "Only children of this tree"
+    }
+  },
+
   "share_links": {
     "Share Link": "Share Link",
     "Page Path": "Page Path",
     "expire": "Expiration",
     "description": "Description"
+  },
+
+  "personal_dropdown": {
+    "home": "Home",
+    "settings": "Settings"
+  },
+
+  "copy_to_clipboard": {
+    "Copy to clipboard": "Copy to clipboard",
+    "Page path": "Page path",
+    "Page URL": "Page URL",
+    "Permanent link": "Permanent link",
+    "Page path and permanent link": "Page path and permanent link",
+    "Markdown link": "Markdown link"
+  },
+
+  "crop_image_modal": {
+    "image_crop": "Image Crop",
+    "crop": "Crop",
+    "save": "Save",
+    "reset": "Reset",
+    "cancel": "Cancel"
+  },
+
+  "not_found_page": {
+    "page_not_exist": "This page does not exist."
   }
 }

+ 3 - 34
packages/app/public/static/locales/en_US/translation.json

@@ -159,8 +159,6 @@
   "Confirm": "Confirm",
   "Successfully requested": "Successfully requested.",
   "personal_dropdown": {
-    "home": "Home",
-    "settings": "Settings",
     "color_mode": "Color mode",
     "sidebar_mode": "Sidebar mode",
     "sidebar_mode_editor": "Sidebar mode on editor",
@@ -172,11 +170,6 @@
     "invalid_syntax": "The syntax of %s is invalid.",
     "title_required": "Title is required."
   },
-  "not_found_page": {
-    "Create Page": "Create Page",
-    "page_not_exist": "This page does not exist.",
-    "page_not_exist_alert": "This page does not exist. Please create a new page."
-  },
   "not_creatable_page": {
     "could_not_creata_path": "Couldn't create path."
   },
@@ -253,16 +246,6 @@
   "API Token Settings": "API token settings",
   "Current API Token": "Current API token",
   "Update API Token": "Update API token",
-  "header_search_box": {
-    "label": {
-      "All pages": "All pages",
-      "This tree": "This tree"
-    },
-    "item_label": {
-      "All pages": "All pages",
-      "This tree": "Only children of this tree"
-    }
-  },
   "in_app_notification": {
     "notification_list": "In-App Notification List",
     "see_all": "See All",
@@ -315,14 +298,6 @@
       "no_nfd": "textlint rule that disallow to use NFD like UTF8-MAC Sonant mark."
     }
   },
-  "copy_to_clipboard": {
-    "Copy to clipboard": "Copy to clipboard",
-    "Page path": "Page path",
-    "Page URL": "Page URL",
-    "Permanent link": "Permanent link",
-    "Page path and permanent link": "Page path and permanent link",
-    "Markdown link": "Markdown link"
-  },
   "search_help": {
     "title": "Searching Help",
     "and": {
@@ -650,10 +625,9 @@
       "error_duplicate_pages_found": "Multiple pages with the same path name were found. Please rename or delete and try again."
     }
   },
-  "to_cloud_settings": "Open GROWI.cloud Settings",
   "login": {
     "Sign in error": "Login error",
-    "Registration successful": "Registration successful",
+    "Registration successful": "Registration successful. Please wait for administrator approval.",
     "Setup": "Setup",
     "enabled_ldap_has_configuration_problem":"LDAP is enabled but the configuration has something wrong.",
     "set_env_var_for_logs": "(Please set the environment variables <code>DEBUG=crowi:service:PassportService</code> to get the logs)"
@@ -685,6 +659,8 @@
     "username_should_not_be_null":"Username should not be null. Please check Authentication Mechanism Settings on admin page",
     "email_address_is_already_registered":"This email address is already registered.",
     "can_not_register_maximum_number_of_users":"Can not register more than the maximum number of users.",
+    "email_settings_is_not_setup":"E-mail settings is not set up. Please ask the administrator.",
+    "email_authentication_is_not_enabled": "Email authentication is not enabled. Please ask the administrator.",
     "failed_to_register":"Failed to register.",
     "successfully_created":"The user {{username}} is successfully created.",
     "can_not_activate_maximum_number_of_users":"Can not activate more than the maximum number of users.",
@@ -792,13 +768,6 @@
     "belonging_to_no_group": "Could not find the groups you belong to.",
     "manage_user_groups": "Manage user groups"
   },
-  "crop_image_modal": {
-    "image_crop": "Image Crop",
-    "crop": "Crop",
-    "save": "Save",
-    "reset": "Reset",
-    "cancel": "Cancel"
-  },
   "fix_page_grant": {
     "modal": {
       "no_grant_available": "The list of selectable permissions could not be found. Please modify the permissions on the parent page first and try again.",

+ 3 - 1
packages/app/public/static/locales/ja_JP/admin.json

@@ -112,7 +112,6 @@
       "email_authentication": "ユーザー登録時のメール認証",
       "enable_email_authentication": "メール認証を有効にする",
       "enable_email_authentication_desc": "ユーザー登録時にメール認証を行います。",
-      "please_enable_mailer": "メール認証を有効にするには、メール設定を完了させてください。",
       "need_complete_mail_setting_warning": "以下の機能を使えるようにするには、メール設定を完了させてください。"
     },
     "ldap": {
@@ -869,6 +868,9 @@
       "log_type": "https://docs.growi.org/ja/admin-guide/admin-cookbook/audit-log-setup.html#log-types"
     }
   },
+  "cloud_setting_management": {
+    "to_cloud_settings": "GROWI.cloud の管理画面へ"
+  },
   "audit_log_action_category": {
     "Page": "ページ",
     "Comment": "コメント",

+ 39 - 1
packages/app/public/static/locales/ja_JP/commons.json

@@ -12,16 +12,54 @@
     "remove_share_link": "共有リンクを{{count}}件削除しました"
   },
   "alert": {
-    "siteUrl_is_not_set": "'サイトURL' が設定されていません。{{link}} から設定してください。"
+    "siteUrl_is_not_set": "'サイトURL' が設定されていません。{{link}} から設定してください。",
+    "please_enable_mailer": "メール認証を有効にするには、メール設定を完了させてください。"
   },
   "headers": {
     "app_settings": "アプリ設定"
   },
 
+  "header_search_box": {
+    "label": {
+      "All pages": "全てのページ",
+      "This tree": "この階層"
+    },
+    "item_label": {
+      "All pages": "全てのページ",
+      "This tree": "この階層下の子ページのみ"
+    }
+  },
+
   "share_links": {
     "Share Link": "共有用リンク",
     "Page Path": "ページパス",
     "expire": "有効期限",
     "description": "概要"
+  },
+
+  "personal_dropdown": {
+    "home": "ホーム",
+    "settings": "設定"
+  },
+
+  "copy_to_clipboard": {
+    "Copy to clipboard": "クリップボードにコピー",
+    "Page path": "ページ名",
+    "Page URL": "ページURL",
+    "Permanent link": "パーマリンク",
+    "Page path and permanent link": "ページ名とパーマリンク",
+    "Markdown link": "マークダウン形式のリンク"
+  },
+
+  "crop_image_modal": {
+    "image_crop": "画像の切り抜き",
+    "crop": "トリミング",
+    "save": "保存",
+    "reset": "リセット",
+    "cancel": "キャンセル"
+  },
+
+  "not_found_page": {
+    "page_not_exist": "このページは存在しません。"
   }
 }

+ 3 - 34
packages/app/public/static/locales/ja_JP/translation.json

@@ -155,8 +155,6 @@
   "Confirm": "確認",
   "Successfully requested": "正常に処理を受け付けました",
   "personal_dropdown": {
-    "home": "ホーム",
-    "settings": "設定",
     "color_mode": "カラーモード",
     "sidebar_mode": "サイドバーモード",
     "sidebar_mode_editor": "サイドバーモード(編集時)",
@@ -168,11 +166,6 @@
     "invalid_syntax": "%sの構文が不正です",
     "title_required": "タイトルを入力してください"
   },
-  "not_found_page": {
-    "Create Page": "ページを作成する",
-    "page_not_exist": "このページは存在しません。",
-    "page_not_exist_alert": "このページは存在しません。新たに作成する必要があります。"
-  },
   "not_creatable_page": {
     "could_not_creata_path": "パスを作成できませんでした。"
   },
@@ -249,16 +242,6 @@
   "API Token Settings": "API Token設定",
   "Current API Token": "現在のAPI Token",
   "Update API Token": "API Tokenを更新",
-  "header_search_box": {
-    "label": {
-      "All pages": "全てのページ",
-      "This tree": "この階層"
-    },
-    "item_label": {
-      "All pages": "全てのページ",
-      "This tree": "この階層下の子ページのみ"
-    }
-  },
   "in_app_notification": {
     "notification_list": "アプリ内通知一覧",
     "see_all": "通知一覧を見る",
@@ -310,14 +293,6 @@
       "no_nfd": "UTF8-MAC濁点のようなNFDの使用を禁止します。"
     }
   },
-  "copy_to_clipboard": {
-    "Copy to clipboard": "クリップボードにコピー",
-    "Page path": "ページ名",
-    "Page URL": "ページURL",
-    "Permanent link": "パーマリンク",
-    "Page path and permanent link": "ページ名とパーマリンク",
-    "Markdown link": "マークダウン形式のリンク"
-  },
   "search_help": {
     "title": "検索のヘルプ",
     "and": {
@@ -644,10 +619,9 @@
       "error_duplicate_pages_found": "同名のパスを持つページが複数見つかりました。リネームまたは削除してから再度実行してください"
     }
   },
-  "to_cloud_settings": "GROWI.cloud の管理画面へ",
   "login": {
     "Sign in error": "ログインエラー",
-    "Registration successful": "登録完了",
+    "Registration successful": "登録完了しました。管理者の承認をお待ちください。",
     "Setup": "セットアップ",
     "enabled_ldap_has_configuration_problem":"LDAPは有効ですが、設定に問題があります。",
     "set_env_var_for_logs": "(ログを取得するためには、環境変数 <code>DEBUG=crowi:service:PassportService</code> を設定してください。)"
@@ -679,6 +653,8 @@
     "username_should_not_be_null":"Username が null になっています 管理画面の認証機構設定にて設定の確認をしてください",
     "email_address_is_already_registered":"このメールアドレスは既に登録されています。",
     "can_not_register_maximum_number_of_users":"ユーザー数が上限を超えたため登録できません。",
+    "email_settings_is_not_setup":"E-mail 設定が完了していません。管理者に問い合わせてください。",
+    "email_authentication_is_not_enabled": "メール認証が有効になっていません。管理者に問い合わせてください。",
     "failed_to_register":"登録に失敗しました。",
     "successfully_created":"{{username}} が作成されました。",
     "can_not_activate_maximum_number_of_users":"ユーザーが上限に達したためアクティベートできません。",
@@ -786,13 +762,6 @@
     "belonging_to_no_group": "所属しているグループが見つかりませんでした。",
     "manage_user_groups": "グループ管理"
   },
-  "crop_image_modal": {
-    "image_crop": "画像の切り抜き",
-    "crop": "トリミング",
-    "save": "保存",
-    "reset": "リセット",
-    "cancel": "キャンセル"
-  },
   "fix_page_grant": {
     "modal": {
       "no_grant_available": "選択可能な権限のリストが見つかりませんでした。まず親ページの権限を修正したのちに再試行してください。",

+ 3 - 2
packages/app/public/static/locales/zh_CN/admin.json

@@ -114,7 +114,6 @@
       "email_authentication": "用户注册时的电子邮件身份验证",
       "enable_email_authentication": "启用电子邮件身份验证",
       "enable_email_authentication_desc": "用户注册将执行电子邮件身份验证。",
-      "please_enable_mailer": "请先设置邮件程序。",
       "need_complete_mail_setting_warning": "要使用以下功能,请完成邮件设置。"
 		},
 		"ldap": {
@@ -883,7 +882,9 @@
     "docs_url": {
       "log_type": "https://docs.growi.org/en/admin-guide/admin-cookbook/audit-log-setup.html#log-types"
     }
-
+  },
+  "cloud_setting_management": {
+    "to_cloud_settings": "進入 GROWI.cloud 的管理界面"
   },
   "audit_log_action_category": {
     "Page": "页面",

+ 39 - 1
packages/app/public/static/locales/zh_CN/commons.json

@@ -12,16 +12,54 @@
     "remove_share_link": "Succeeded to remove {{count}} share links"
   },
   "alert": {
-    "siteUrl_is_not_set": "主页URL未设置,通过 {{link}} 设置"
+    "siteUrl_is_not_set": "主页URL未设置,通过 {{link}} 设置",
+    "please_enable_mailer": "请先设置邮件程序。"
   },
   "headers": {
     "app_settings": "系统设置"
   },
 
+  "header_search_box": {
+		"label": {
+			"All pages": "所有页面",
+			"This tree": "当前分支"
+		},
+		"item_label": {
+			"All pages": "所有页面",
+			"This tree": "当前分支以下内容"
+		}
+  },
+
   "share_links": {
     "Share Link": "Share Link",
     "Page Path": "Page Path",
     "expire": "Expiration",
     "description": "Description"
+  },
+
+  "personal_dropdown": {
+    "home": "家",
+    "settings": "设置"
+  },
+
+	"copy_to_clipboard": {
+		"Copy to clipboard": "复制到剪贴板",
+		"Page path": "页面路径",
+		"Page URL": "页面Url",
+		"Parmanent link": "参数化链接",
+		"Page path and parmanent link": "页面路径及参数化链接",
+		"Markdown link": "Markdown链接"
+	},
+
+  "crop_image_modal": {
+    "image_crop": "图像裁剪",
+    "crop": "修剪",
+    "save": "节省",
+    "reset": "重启",
+    "cancel": "取消"
+  },
+
+  "not_found_page": {
+    "page_not_exist": "该页面不存在"
   }
 }

+ 3 - 34
packages/app/public/static/locales/zh_CN/translation.json

@@ -169,11 +169,6 @@
 		"invalid_syntax": "%s的语法无效。",
     "title_required": "标题是必需的。"
   },
-  "not_found_page": {
-    "Create Page": "创建页面",
-    "page_not_exist": "该页面不存在",
-    "page_not_exist_alert": "该页面不存在,请创建一个新页面"
-  },
   "not_creatable_page": {
     "could_not_creata_path": "无法创建路径"
   },
@@ -234,16 +229,6 @@
 	"API Token Settings": "API token 设置",
 	"Current API Token": "当前 API token",
 	"Update API Token": "更新 API token",
-	"header_search_box": {
-		"label": {
-			"All pages": "所有页面",
-			"This tree": "当前分支"
-		},
-		"item_label": {
-			"All pages": "所有页面",
-			"This tree": "当前分支以下内容"
-		}
-  },
   "in_app_notification": {
     "notification_list": "应用内通知列表",
     "see_all": "查看通知列表",
@@ -295,14 +280,6 @@
       "no_nfd": "禁止使用 UTF8-MAC 浊音等 NFD。"
     }
   },
-	"copy_to_clipboard": {
-		"Copy to clipboard": "复制到剪贴板",
-		"Page path": "页面路径",
-		"Page URL": "页面Url",
-		"Parmanent link": "参数化链接",
-		"Page path and parmanent link": "页面路径及参数化链接",
-		"Markdown link": "Markdown链接"
-	},
 	"search_help": {
 		"title": "搜索帮助",
 		"and": {
@@ -591,8 +568,6 @@
     "link_sharing_is_disabled": "链接共享已被禁用"
   },
 	"personal_dropdown": {
-		"home": "家",
-		"settings": "设置",
 		"color_mode": "颜色模式",
 		"sidebar_mode": "边栏模式",
 		"sidebar_mode_editor": "编辑器上的边栏模式",
@@ -652,10 +627,9 @@
       "error_duplicate_pages_found": "发现多个具有相同路径名称的页面。请重新命名或删除并重试。"
     }
   },
-	"to_cloud_settings": "進入 GROWI.cloud 的管理界面",
 	"login": {
 		"Sign in error": "登录错误",
-		"Registration successful": "注册成功",
+		"Registration successful": "注册成功。请等待管理员批准",
 		"Setup": "安装程序",
     "enabled_ldap_has_configuration_problem":"启用了LDAP,但配置有问题。",
     "set_env_var_for_logs": "(请设置环境变量 <code>DEBUG=crowi:service:PassportService</code> 以获得日志。)"
@@ -687,6 +661,8 @@
     "username_should_not_be_null":"用户名不应为空。请检查管理页面上的身份验证机制设置",
 		"email_address_is_already_registered": "此电子邮件地址已注册。",
 		"can_not_register_maximum_number_of_users": "注册的用户数不能超过最大值。",
+    "email_settings_is_not_setup":"邮箱设置未设置,请询问管理员。",
+    "email_authentication_is_not_enabled": "电子邮件验证未被激活, 请询问管理员。",
 		"failed_to_register": "注册失败。",
 		"successfully_created": "已成功创建用户{{username}。",
 		"can_not_activate_maximum_number_of_users": "无法激活超过最大用户数的用户。",
@@ -794,13 +770,6 @@
     "belonging_to_no_group": "无法找到你所属的团体。",
     "manage_user_groups": "管理用户组"
   },
-  "crop_image_modal": {
-    "image_crop": "图像裁剪",
-    "crop": "修剪",
-    "save": "节省",
-    "reset": "重启",
-    "cancel": "取消"
-  },
   "fix_page_grant": {
     "modal": {
       "no_grant_available": "无法找到可选择的权限列表。 请先修改父页的权限,然后再试一次。",

+ 27 - 0
packages/app/src/client/interfaces/global-notification.ts

@@ -0,0 +1,27 @@
+export const NotifyType = {
+  Email: 'mail',
+  SLACK: 'slack',
+} as const;
+
+export type NotifyType = typeof NotifyType[keyof typeof NotifyType]
+
+
+export const TriggerEventType = {
+  CREATE: 'pageCreate',
+  EDIT: 'pageEdit',
+  MOVE: 'pageMove',
+  DELETE: 'pageDelete',
+  LIKE: 'pageLike',
+  POST: 'comment',
+} as const;
+
+type TriggerEventType = typeof TriggerEventType[keyof typeof TriggerEventType]
+
+
+export type IGlobalNotification = {
+  triggerPath: string,
+  notifyType: NotifyType,
+  emailToSend: string,
+  slackChannelToSend: string,
+  triggerEvents: TriggerEventType[],
+};

+ 8 - 0
packages/app/src/client/interfaces/notification.ts

@@ -0,0 +1,8 @@
+import type { NotifyType } from './global-notification';
+
+export type INotificationType = {
+  __t?: NotifyType
+  _id: string
+  // TOOD: Define the provider type
+  provider?: any
+}

+ 1 - 1
packages/app/src/components/Admin/App/AppSetting.jsx

@@ -23,7 +23,7 @@ const AppSetting = (props) => {
   const submitHandler = useCallback(async() => {
     try {
       await adminAppContainer.updateAppSettingHandler();
-      toastSuccess(t('toaster.update_successed', { target: t('commons:headers.app_settings'), ns: 'commons' }));
+      toastSuccess(t('commons:toaster.update_successed', { target: t('commons:headers.app_settings') }));
     }
     catch (err) {
       toastError(err);

+ 1 - 1
packages/app/src/components/Admin/App/AppSettingsPageContents.tsx

@@ -82,7 +82,7 @@ const AppSettingsPageContents = (props: Props) => {
 
       <div className="row">
         <div className="col-lg-12">
-          <h2 className="admin-setting-header">{t('commons:headers.app_settings')}</h2>
+          <h2 className="admin-setting-header">{t('headers.app_settings', { ns: 'commons' })}</h2>
           <AppSetting />
         </div>
       </div>

+ 7 - 7
packages/app/src/components/Admin/Common/AdminNavigation.jsx

@@ -6,7 +6,7 @@ import Link from 'next/link';
 import PropTypes from 'prop-types';
 import urljoin from 'url-join';
 
-
+import { useGrowiCloudUri, useGrowiAppIdForGrowiCloud } from '../../../stores/context';
 // import AppContainer from '~/client/services/AppContainer';
 
 // import { withUnstatedContainers } from '../../UnstatedUtils';
@@ -16,14 +16,14 @@ const AdminNavigation = (props) => {
   // const { appContainer } = props;
   const pathname = window.location.pathname;
 
-  // const growiCloudUri = appContainer.config.env.GROWI_CLOUD_URI;
-  // const growiAppIdForGrowiCloud = appContainer.config.env.GROWI_APP_ID_FOR_GROWI_CLOUD;
+  const { data: growiCloudUri } = useGrowiCloudUri();
+  const { data: growiAppIdForGrowiCloud } = useGrowiAppIdForGrowiCloud();
 
   // eslint-disable-next-line react/prop-types
   const MenuLabel = ({ menu }) => {
     switch (menu) {
       /* eslint-disable no-multi-spaces, max-len */
-      case 'app':                      return <><i className="mr-1 icon-fw icon-settings"></i>{        t('commons:headers.app_settings') }</>;
+      case 'app':                      return <><i className="mr-1 icon-fw icon-settings"></i>{        t('headers.app_settings', { ns: 'commons' }) }</>;
       case 'security':                 return <><i className="mr-1 icon-fw icon-shield"></i>{          t('security_settings.security_settings') }</>;
       case 'markdown':                 return <><i className="mr-1 icon-fw icon-note"></i>{            t('markdown_settings.markdown_settings') }</>;
       case 'customize':                return <><i className="mr-1 icon-fw icon-wrench"></i>{          t('customize_settings.customize_settings') }</>;
@@ -36,7 +36,7 @@ const AdminNavigation = (props) => {
       case 'user-groups':              return <><i className="mr-1 icon-fw icon-people"></i>{          t('user_group_management.user_group_management') }</>;
       case 'search':                   return <><i className="mr-1 icon-fw icon-magnifier"></i>{       t('full_text_search_management.full_text_search_management') }</>;
       case 'audit-log':                return <><i className="mr-1 icon-fw icon-feed"></i>{            t('audit_log_management.audit_log')}</>;
-      case 'cloud':                    return <><i className="mr-1 icon-fw icon-share-alt"></i>{       t('to_cloud_settings')} </>;
+      case 'cloud':                    return <><i className="mr-1 icon-fw icon-share-alt"></i>{       t('cloud_setting_management.to_cloud_settings')} </>;
       default:                         return <><i className="mr-1 icon-fw icon-home"></i>{            t('wiki_management_home_page') }</>;
       /* eslint-enable no-multi-spaces, max-len */
     }
@@ -92,7 +92,7 @@ const AdminNavigation = (props) => {
         <MenuLink menu="user-groups"  isListGroupItems isActive={isActiveMenu('/user-groups')} />
         <MenuLink menu="audit-log"    isListGroupItems isActive={isActiveMenu('/audit-log')} />
         <MenuLink menu="search"       isListGroupItems isActive={isActiveMenu('/search')} />
-        {/* {growiCloudUri != null && growiAppIdForGrowiCloud != null
+        {growiCloudUri != null && growiAppIdForGrowiCloud != null
           && (
             <a
               href={`${growiCloudUri}/my/apps/${growiAppIdForGrowiCloud}`}
@@ -101,7 +101,7 @@ const AdminNavigation = (props) => {
               <MenuLabel menu="cloud" />
             </a>
           )
-        } */}
+        }
         {/* eslint-enable no-multi-spaces */}
       </>
     );

+ 31 - 8
packages/app/src/components/Admin/Customize/CustomizeLayoutSetting.tsx

@@ -1,31 +1,54 @@
-import React, { useCallback, useEffect, useState } from 'react';
+import React, {
+  useCallback, useEffect, useState,
+} from 'react';
 
 import { useTranslation } from 'next-i18next';
 
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
-import { apiv3Put } from '~/client/util/apiv3-client';
 import { useSWRxLayoutSetting } from '~/stores/admin/customize';
 import { useNextThemes } from '~/stores/use-next-themes';
 
+const useIsContainerFluid = () => {
+  const { data: layoutSetting, update: updateLayoutSetting } = useSWRxLayoutSetting();
+  const [isContainerFluid, setIsContainerFluid] = useState<boolean>();
+
+  useEffect(() => {
+    setIsContainerFluid(layoutSetting?.isContainerFluid);
+  }, [layoutSetting?.isContainerFluid]);
+
+  return {
+    isContainerFluid,
+    setIsContainerFluid,
+    updateLayoutSetting,
+  };
+};
+
 const CustomizeLayoutSetting = (): JSX.Element => {
   const { t } = useTranslation('admin');
 
   const { resolvedTheme } = useNextThemes();
-  const { data: layoutSetting, mutate: mutateLayoutSetting } = useSWRxLayoutSetting();
 
-  const [isContainerFluid, setIsContainerFluid] = useState<boolean>(layoutSetting?.isContainerFluid ?? false);
+  const { isContainerFluid, setIsContainerFluid, updateLayoutSetting } = useIsContainerFluid();
   const [retrieveError, setRetrieveError] = useState<any>();
 
-  const onClickSubmit = async() => {
+  const onClickSubmit = useCallback(async() => {
+    if (isContainerFluid == null) { return }
     try {
-      await apiv3Put('/customize-setting/layout', { isContainerFluid });
+      await updateLayoutSetting({ isContainerFluid });
       toastSuccess(t('toaster.update_successed', { target: t('customize_settings.layout'), ns: 'commons' }));
-      mutateLayoutSetting();
     }
     catch (err) {
       toastError(err);
     }
-  };
+  }, [isContainerFluid, updateLayoutSetting, t]);
+
+  if (isContainerFluid == null) {
+    return (
+      <div className="text-muted text-center">
+        <i className="fa fa-2x fa-spinner fa-pulse"></i>
+      </div>
+    );
+  }
 
   return (
     <React.Fragment>

+ 1 - 3
packages/app/src/components/Admin/Customize/CustomizeLogoSetting.tsx

@@ -54,17 +54,15 @@ const CustomizeLogoSetting = (): JSX.Element => {
     try {
       const response = await apiv3Put('/customize-setting/customize-logo', {
         isDefaultLogo,
-        customizedLogoSrc,
       });
       const { customizedParams } = response.data;
       setIsDefaultLogo(customizedParams.isDefaultLogo);
-      setCustomizedLogoSrc(customizedParams.customizedLogoSrc);
       toastSuccess(t('toaster.update_successed', { target: t('admin:customize_settings.custom_logo'), ns: 'commons' }));
     }
     catch (err) {
       toastError(err);
     }
-  }, [t, isDefaultLogo, customizedLogoSrc]);
+  }, [t, isDefaultLogo]);
 
   const onClickDeleteBtn = useCallback(async() => {
     try {

+ 5 - 3
packages/app/src/components/Admin/Customize/CustomizeThemeSetting.tsx

@@ -20,12 +20,14 @@ type Props = {
 const CustomizeThemeSetting = (props: Props): JSX.Element => {
 
   const { adminCustomizeContainer } = props;
-  const { data: currentTheme, mutate: mutateGrowiTheme } = useGrowiTheme();
+  const { data: currentTheme } = useGrowiTheme();
   const { t } = useTranslation();
 
   const selectedHandler = useCallback((themeName) => {
-    mutateGrowiTheme(themeName);
-  }, [mutateGrowiTheme]);
+    // TODO: preview without using mutate of useGrowiTheme
+    // https://github.com/weseek/growi/pull/6860
+    // mutateGrowiTheme(themeName);
+  }, []);
 
   const submitHandler = useCallback(async() => {
     try {

+ 1 - 1
packages/app/src/components/Admin/NotFoundPage.tsx

@@ -3,7 +3,7 @@ import React from 'react';
 import { useTranslation } from 'next-i18next';
 
 export const AdminNotFoundPage = (): JSX.Element => {
-  const { t } = useTranslation();
+  const { t } = useTranslation('commons');
 
   return (
     <h1 className="title">{t('not_found_page.page_not_exist')}</h1>

+ 2 - 2
packages/app/src/components/Admin/Notification/GlobalNotificationList.jsx

@@ -12,7 +12,7 @@ import loggerFactory from '~/utils/logger';
 import { withUnstatedContainers } from '../../UnstatedUtils';
 
 import NotificationDeleteModal from './NotificationDeleteModal';
-import NotificationTypeIcon from './NotificationTypeIcon';
+import { NotificationTypeIcon } from './NotificationTypeIcon';
 
 
 const logger = loggerFactory('growi:GolobalNotificationList');
@@ -180,7 +180,7 @@ GlobalNotificationList.propTypes = {
 };
 
 const GlobalNotificationListWrapperFC = (props) => {
-  const { t } = useTranslation();
+  const { t } = useTranslation('admin');
 
   return <GlobalNotificationList t={t} {...props} />;
 };

+ 42 - 52
packages/app/src/components/Admin/Notification/ManageGlobalNotification.jsx → packages/app/src/components/Admin/Notification/ManageGlobalNotification.tsx

@@ -4,17 +4,15 @@ import React, {
 
 import { useTranslation } from 'next-i18next';
 import { useRouter } from 'next/router';
-import PropTypes from 'prop-types';
 
-import AdminNotificationContainer from '~/client/services/AdminNotificationContainer';
+import { NotifyType, TriggerEventType } from '~/client/interfaces/global-notification';
 import { toastError } from '~/client/util/apiNotification';
-import { apiv3Post, apiv3Put } from '~/client/util/apiv3-client';
+import { apiv3Post } from '~/client/util/apiv3-client';
 import { useIsMailerSetup } from '~/stores/context';
 import { useSWRxGlobalNotification } from '~/stores/global-notification';
 import loggerFactory from '~/utils/logger';
 
 
-import { withUnstatedContainers } from '../../UnstatedUtils';
 import AdminUpdateButtonRow from '../Common/AdminUpdateButtonRow';
 
 import TriggerEventCheckBox from './TriggerEventCheckBox';
@@ -23,14 +21,18 @@ import TriggerEventCheckBox from './TriggerEventCheckBox';
 const logger = loggerFactory('growi:manageGlobalNotification');
 
 
-const ManageGlobalNotification = (props) => {
+type Props = {
+  globalNotificationId?: string,
+}
+
+const ManageGlobalNotification = (props: Props): JSX.Element => {
 
   const [triggerPath, setTriggerPath] = useState('');
-  const [notifyType, setNotifyType] = useState('mail');
+  const [notifyType, setNotifyType] = useState<NotifyType>(NotifyType.Email);
   const [emailToSend, setEmailToSend] = useState('');
   const [slackChannelToSend, setSlackChannelToSend] = useState('');
   const [triggerEvents, setTriggerEvents] = useState(new Set());
-  const { data: globalNotificationData, update: updateGlobalNotification } = useSWRxGlobalNotification(props.globalNotificationId);
+  const { data: globalNotificationData, update: updateGlobalNotification } = useSWRxGlobalNotification(props.globalNotificationId || '');
   const globalNotification = useMemo(() => globalNotificationData?.globalNotification, [globalNotificationData?.globalNotification]);
 
   const router = useRouter();
@@ -44,15 +46,13 @@ const ManageGlobalNotification = (props) => {
       setTriggerPath(globalNotification.triggerPath);
       setTriggerEvents(new Set(globalNotification.triggerEvents));
 
-      if (notifyType === 'mail') {
+      if (notifyType === NotifyType.Email) {
         setEmailToSend(globalNotification.toEmail);
       }
       else {
         setSlackChannelToSend(globalNotification.slackChannels);
       }
     }
-
-
   }, [globalNotification]);
 
   const isLoading = globalNotificationData === undefined;
@@ -88,13 +88,10 @@ const ManageGlobalNotification = (props) => {
       triggerEvents: [...triggerEvents],
     };
 
-    const { _id: globalNotificationId } = globalNotification;
-
     try {
-      if (globalNotificationId != null) {
+      if (props.globalNotificationId != null) {
         await updateGlobalNotification(requestParams);
         router.push('/admin/notification');
-        // await apiv3Put(`/notification-setting/global-notification/${globalNotificationId}`, requestParams);
       }
       else {
         await apiv3Post('/notification-setting/global-notification', requestParams);
@@ -105,18 +102,17 @@ const ManageGlobalNotification = (props) => {
       toastError(err);
       logger.error(err);
     }
-  }, [emailToSend, globalNotification, notifyType, router, slackChannelToSend, triggerEvents, triggerPath, updateGlobalNotification]);
+  }, [emailToSend, notifyType, props.globalNotificationId, router, slackChannelToSend, triggerEvents, triggerPath, updateGlobalNotification]);
 
 
   const { data: isMailerSetup } = useIsMailerSetup();
-  const { adminNotificationContainer } = props;
   const { t } = useTranslation('admin');
 
   return (
     <>
       <div className="my-3">
         <a href="/admin/notification#global-notification" className="btn btn-outline-secondary">
-          <i className="icon-fw ti-arrow-left" aria-hidden="true"></i>
+          <i className="icon-fw ti ti-arrow-left" aria-hidden="true"></i>
           {t('notification_settings.back_to_list')}
         </a>
       </div>
@@ -128,9 +124,11 @@ const ManageGlobalNotification = (props) => {
         </div>
 
         <div className="col-sm-4">
-          <h3 htmlFor="triggerPath">{t('notification_settings.trigger_path')}
-            {/* eslint-disable-next-line react/no-danger */}
-            <small dangerouslySetInnerHTML={{ __html: t('notification_settings.trigger_path_help', '<code>*</code>') }} />
+          <h3>
+            <label htmlFor="triggerPath">{t('notification_settings.trigger_path')}
+              {/* eslint-disable-next-line react/no-danger */}
+              <small dangerouslySetInnerHTML={{ __html: t('notification_settings.trigger_path_help', '<code>*</code>') }} />
+            </label>
           </h3>
           <div className="form-group">
             <input
@@ -152,8 +150,8 @@ const ManageGlobalNotification = (props) => {
                 id="mail"
                 name="notifyType"
                 value="mail"
-                checked={notifyType === 'mail'}
-                onChange={() => { setNotifyType('mail') }}
+                checked={notifyType === NotifyType.Email}
+                onChange={() => { setNotifyType(NotifyType.Email) }}
               />
               <label className="custom-control-label" htmlFor="mail">
                 <p className="font-weight-bold">Email</p>
@@ -166,8 +164,8 @@ const ManageGlobalNotification = (props) => {
                 id="slack"
                 name="notifyType"
                 value="slack"
-                checked={notifyType === 'slack'}
-                onChange={() => { setNotifyType('slack') }}
+                checked={notifyType === NotifyType.SLACK}
+                onChange={() => { setNotifyType(NotifyType.SLACK) }}
               />
               <label className="custom-control-label" htmlFor="slack">
                 <p className="font-weight-bold">Slack</p>
@@ -175,7 +173,7 @@ const ManageGlobalNotification = (props) => {
             </div>
           </div>
 
-          {notifyType === 'mail'
+          {notifyType === NotifyType.Email
             ? (
               <>
                 <div className="input-group notify-to-option" id="mail-input">
@@ -234,9 +232,9 @@ const ManageGlobalNotification = (props) => {
             <div className="my-1">
               <TriggerEventCheckBox
                 checkbox="success"
-                event="pageCreate"
-                checked={triggerEvents.has('pageCreate')}
-                onChange={() => onChangeTriggerEvents('pageCreate')}
+                event={TriggerEventType.CREATE}
+                checked={triggerEvents.has(TriggerEventType.CREATE)}
+                onChange={() => onChangeTriggerEvents(TriggerEventType.CREATE)}
               >
                 <span className="badge badge-pill badge-success">
                   <i className="icon-doc mr-1" /> CREATE
@@ -246,9 +244,9 @@ const ManageGlobalNotification = (props) => {
             <div className="my-1">
               <TriggerEventCheckBox
                 checkbox="warning"
-                event="pageEdit"
-                checked={triggerEvents.has('pageEdit')}
-                onChange={() => onChangeTriggerEvents('pageEdit')}
+                event={TriggerEventType.EDIT}
+                checked={triggerEvents.has(TriggerEventType.EDIT)}
+                onChange={() => onChangeTriggerEvents(TriggerEventType.EDIT)}
               >
                 <span className="badge badge-pill badge-warning">
                   <i className="icon-pencil mr-1" />EDIT
@@ -258,9 +256,9 @@ const ManageGlobalNotification = (props) => {
             <div className="my-1">
               <TriggerEventCheckBox
                 checkbox="pink"
-                event="pageMove"
-                checked={triggerEvents.has('pageMove')}
-                onChange={() => onChangeTriggerEvents('pageMove')}
+                event={TriggerEventType.MOVE}
+                checked={triggerEvents.has(TriggerEventType.MOVE)}
+                onChange={() => onChangeTriggerEvents(TriggerEventType.MOVE)}
               >
                 <span className="badge badge-pill badge-pink">
                   <i className="icon-action-redo mr-1" />MOVE
@@ -271,8 +269,8 @@ const ManageGlobalNotification = (props) => {
               <TriggerEventCheckBox
                 checkbox="danger"
                 event="pageDelete"
-                checked={triggerEvents.has('pageDelete')}
-                onChange={() => onChangeTriggerEvents('pageDelete')}
+                checked={triggerEvents.has(TriggerEventType.DELETE)}
+                onChange={() => onChangeTriggerEvents(TriggerEventType.DELETE)}
               >
                 <span className="badge badge-pill badge-danger">
                   <i className="icon-fire mr-1" />DELETE
@@ -282,9 +280,9 @@ const ManageGlobalNotification = (props) => {
             <div className="my-1">
               <TriggerEventCheckBox
                 checkbox="info"
-                event="pageLike"
-                checked={triggerEvents.has('pageLike')}
-                onChange={() => onChangeTriggerEvents('pageLike')}
+                event={TriggerEventType.LIKE}
+                checked={triggerEvents.has(TriggerEventType.LIKE)}
+                onChange={() => onChangeTriggerEvents(TriggerEventType.LIKE)}
               >
                 <span className="badge badge-pill badge-info">
                   <i className="fa fa-heart-o mr-1" />LIKE
@@ -294,9 +292,9 @@ const ManageGlobalNotification = (props) => {
             <div className="my-1">
               <TriggerEventCheckBox
                 checkbox="secondary"
-                event="comment"
-                checked={triggerEvents.has('comment')}
-                onChange={() => onChangeTriggerEvents('comment')}
+                event={TriggerEventType.POST}
+                checked={triggerEvents.has(TriggerEventType.POST)}
+                onChange={() => onChangeTriggerEvents(TriggerEventType.POST)}
               >
                 <span className="badge badge-pill badge-secondary">
                   <i className="icon-bubble mr-1" />POST
@@ -310,18 +308,10 @@ const ManageGlobalNotification = (props) => {
 
       <AdminUpdateButtonRow
         onClick={updateButtonClickedHandler}
-        disabled={adminNotificationContainer.state.retrieveError != null}
+        disabled={false}
       />
     </>
   );
 };
 
-ManageGlobalNotification.propTypes = {
-  adminNotificationContainer: PropTypes.instanceOf(AdminNotificationContainer).isRequired,
-  globalNotificationId: PropTypes.string,
-};
-
-const ManageGlobalNotificationWrapper = withUnstatedContainers(ManageGlobalNotification, [AdminNotificationContainer]);
-
-
-export default ManageGlobalNotificationWrapper;
+export default ManageGlobalNotification;

+ 2 - 2
packages/app/src/components/Admin/Notification/NotificationDeleteModal.jsx

@@ -1,7 +1,7 @@
 import React from 'react';
 
-import PropTypes from 'prop-types';
 import { useTranslation } from 'next-i18next';
+import PropTypes from 'prop-types';
 import {
   Modal, ModalHeader, ModalBody, ModalFooter,
 } from 'reactstrap';
@@ -45,7 +45,7 @@ NotificationDeleteModal.propTypes = {
 
 // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
 const NotificationDeleteModalWrapperFC = (props) => {
-  const { t } = useTranslation();
+  const { t } = useTranslation('admin');
 
   return <NotificationDeleteModal t={t} {...props} />;
 };

+ 0 - 43
packages/app/src/components/Admin/Notification/NotificationTypeIcon.jsx

@@ -1,43 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-
-import { UncontrolledTooltip } from 'reactstrap';
-
-const SlackIcon = (props) => {
-  const { __t, _id, provider } = props.notification;
-
-  let type = 'slack';
-
-  // User trigger notification
-  if (provider != null) {
-    // only slack type
-  }
-
-  // Global notification
-  if (__t != null) {
-    if (__t === 'mail') {
-      type = 'mail';
-    }
-  }
-
-  const elemId = `notification-${type}-${_id}`;
-  const className = type === 'mail'
-    ? 'icon-fw fa fa-envelope-o'
-    : 'icon-fw fa fa-hashtag';
-
-  return (
-    <>
-      <i id={elemId} className={className}></i>
-      <UncontrolledTooltip target={elemId}>Slack</UncontrolledTooltip>
-    </>
-  );
-};
-
-SlackIcon.propTypes = {
-  // supports 2 types:
-  //   User trigger notification -> has 'provider: slack'
-  //   Global notification -> has '__t: slack|mail'
-  notification: PropTypes.object.isRequired,
-};
-
-export default SlackIcon;

+ 30 - 0
packages/app/src/components/Admin/Notification/NotificationTypeIcon.tsx

@@ -0,0 +1,30 @@
+import React from 'react';
+
+import { UncontrolledTooltip } from 'reactstrap';
+
+import type { INotificationType } from '~/client/interfaces/notification';
+
+
+type NotificationTypeIconProps = {
+  // supports 2 types:
+  //   User trigger notification -> has 'provider: slack'
+  //   Global notification -> has '__t: slack|mail'
+  notification: INotificationType
+}
+
+export const NotificationTypeIcon = (props: NotificationTypeIconProps): JSX.Element => {
+  const { __t, _id, provider } = props.notification;
+
+  const type = __t != null && __t === 'mail' ? 'mail' : 'slack';
+
+  // User trigger notification
+  if (provider != null) {
+    // only slack type
+  }
+
+  const elemId = `notification-${type}-${_id}`;
+  const className = type === 'mail' ? 'icon-fw fa fa-envelope-o' : 'icon-fw fa fa-hashtag';
+  const toolChip = type === 'mail' ? 'Mail' : 'Slack';
+
+  return <><i id={elemId} className={className}></i><UncontrolledTooltip target={elemId}>{toolChip}</UncontrolledTooltip></>;
+};

+ 1 - 1
packages/app/src/components/Admin/Notification/UserNotificationRow.jsx

@@ -7,7 +7,7 @@ import AdminNotificationContainer from '~/client/services/AdminNotificationConta
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
 
-import NotificationTypeIcon from './NotificationTypeIcon';
+import { NotificationTypeIcon } from './NotificationTypeIcon';
 
 class UserNotificationRow extends React.PureComponent {
 

+ 1 - 1
packages/app/src/components/Admin/Security/GitHubSecuritySettingContents.jsx

@@ -90,7 +90,7 @@ class GitHubSecurityManagementContents extends React.Component {
                 <i
                   className="icon-exclamation"
                   // eslint-disable-next-line max-len
-                  dangerouslySetInnerHTML={{ __html: t('commons:alert.siteUrl_is_not_set', { link: `<a href="/admin/app">${t('commons:headers.app_settings')}<i class="icon-login"></i></a>` }) }}
+                  dangerouslySetInnerHTML={{ __html: t('alert.siteUrl_is_not_set', { link: `<a href="/admin/app">${t('headers.app_settings', { ns: 'commons' })}<i class="icon-login"></i></a>`, ns: 'commons' }) }}
                 />
               </div>
             )}

+ 1 - 1
packages/app/src/components/Admin/Security/GoogleSecuritySettingContents.jsx

@@ -88,7 +88,7 @@ class GoogleSecurityManagementContents extends React.Component {
                 <i
                   className="icon-exclamation"
                   // eslint-disable-next-line max-len
-                  dangerouslySetInnerHTML={{ __html: t('commons:alert.siteUrl_is_not_set', { link: `<a href="/admin/app">${t('commons:headers.app_settings')}<i class="icon-login"></i></a>` }) }}
+                  dangerouslySetInnerHTML={{ __html: t('alert.siteUrl_is_not_set', { link: `<a href="/admin/app">${t('headers.app_settings', { ns: 'commons' })}<i class="icon-login"></i></a>`, ns: 'commons' }) }}
                 />
               </div>
             )}

+ 1 - 1
packages/app/src/components/Admin/Security/LocalSecuritySettingContents.jsx

@@ -212,7 +212,7 @@ class LocalSecuritySettingContents extends React.Component {
                 </div>
                 {!isMailerSetup && (
                   <div className="alert alert-warning p-1 my-1 small d-inline-block">
-                    <span>{t('security_settings.Local.please_enable_mailer')}</span>
+                    <span>{t('commons:alert.please_enable_mailer')}</span>
                     <a href="/admin/app#mail-settings"> <i className="fa fa-link"></i> {t('app_setting.mail_settings')}</a>
                   </div>
                 )}

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

@@ -82,7 +82,7 @@ class OidcSecurityManagementContents extends React.Component {
                 <i
                   className="icon-exclamation"
                   // eslint-disable-next-line max-len
-                  dangerouslySetInnerHTML={{ __html: t('commons:alert.siteUrl_is_not_set', { link: `<a href="/admin/app">${t('commons:headers.app_settings')}<i class="icon-login"></i></a>` }) }}
+                  dangerouslySetInnerHTML={{ __html: t('alert.siteUrl_is_not_set', { link: `<a href="/admin/app">${t('headers.app_settings', { ns: 'commons' })}<i class="icon-login"></i></a>`, ns: 'commons' }) }}
                 />
               </div>
             )}
@@ -378,7 +378,7 @@ class OidcSecurityManagementContents extends React.Component {
                     <i
                       className="icon-exclamation"
                       // eslint-disable-next-line max-len
-                      dangerouslySetInnerHTML={{ __html: t('commons:alert.siteUrl_is_not_set', { link: `<a href="/admin/app">${t('commons:headers.app_settings')}<i class="icon-login"></i></a>` }) }}
+                      dangerouslySetInnerHTML={{ __html: t('alert.siteUrl_is_not_set', { link: `<a href="/admin/app">${t('headers.app_settings', { ns: 'commons' })}<i class="icon-login"></i></a>`, ns: 'commons' }) }}
                     />
                   </div>
                 )}

+ 1 - 1
packages/app/src/components/Admin/Security/SamlSecuritySettingContents.jsx

@@ -99,7 +99,7 @@ class SamlSecurityManagementContents extends React.Component {
                 <i
                   className="icon-exclamation"
                   // eslint-disable-next-line max-len
-                  dangerouslySetInnerHTML={{ __html: t('commons:alert.siteUrl_is_not_set', { link: `<a href="/admin/app">${t('commons:headers.app_settings')}<i class="icon-login"></i></a>` }) }}
+                  dangerouslySetInnerHTML={{ __html: t('alert.siteUrl_is_not_set', { link: `<a href="/admin/app">${t('headers.app_settings', { ns: 'commons' })}<i class="icon-login"></i></a>`, ns: 'commons' }) }}
                 />
               </div>
             )}

+ 1 - 1
packages/app/src/components/Admin/Security/TwitterSecuritySettingContents.jsx

@@ -90,7 +90,7 @@ class TwitterSecuritySettingContents extends React.Component {
                 <i
                   className="icon-exclamation"
                   // eslint-disable-next-line max-len
-                  dangerouslySetInnerHTML={{ __html: t('commons:alert.siteUrl_is_not_set', { link: `<a href="/admin/app">${t('commons:headers.app_settings')}<i class="icon-login"></i></a>` }) }}
+                  dangerouslySetInnerHTML={{ __html: t('alert.siteUrl_is_not_set', { link: `<a href="/admin/app">${t('headers.app_settings', { ns: 'commons' })}<i class="icon-login"></i></a>`, ns: 'commons' }) }}
                 />
               </div>
             )}

+ 1 - 1
packages/app/src/components/Admin/SlackIntegration/ConfirmBotChangeModal.jsx

@@ -7,7 +7,7 @@ import {
 } from 'reactstrap';
 
 const ConfirmBotChangeModal = (props) => {
-  const { t } = useTranslation();
+  const { t } = useTranslation('admin');
 
   const handleCancelButton = () => {
     if (props.onCancelClick != null) {

+ 1 - 1
packages/app/src/components/Admin/UserGroup/UserGroupDropdown.tsx

@@ -2,7 +2,7 @@ import React, { FC, useCallback } from 'react';
 
 import { useTranslation } from 'next-i18next';
 
-import { IUserGroupHasId } from '~/interfaces/user';
+import type { IUserGroupHasId } from '~/interfaces/user';
 
 type Props = {
   selectableUserGroups?: IUserGroupHasId[]

+ 39 - 24
packages/app/src/components/Admin/UserGroupDetail/UserGroupDetailPage.tsx

@@ -5,6 +5,7 @@ import React, {
 import { objectIdUtils } from '@growi/core';
 import { useTranslation } from 'next-i18next';
 import dynamic from 'next/dynamic';
+import Link from 'next/link';
 import { useRouter } from 'next/router';
 
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
@@ -18,7 +19,7 @@ import { useIsAclEnabled } from '~/stores/context';
 import { useUpdateUserGroupConfirmModal } from '~/stores/modal';
 import {
   useSWRxUserGroupPages, useSWRxUserGroupRelationList, useSWRxChildUserGroupList, useSWRxUserGroup,
-  useSWRxSelectableParentUserGroups, useSWRxSelectableChildUserGroups, useSWRxAncestorUserGroups,
+  useSWRxSelectableParentUserGroups, useSWRxSelectableChildUserGroups, useSWRxAncestorUserGroups, useSWRxUserGroupRelations,
 } from '~/stores/user-group';
 
 import styles from './UserGroupDetailPage.module.scss';
@@ -71,13 +72,14 @@ const UserGroupDetailPage = (props: Props): JSX.Element => {
    */
   const { data: userGroupPages } = useSWRxUserGroupPages(currentUserGroupId, 10, 0);
 
+  const { data: userGroupRelations, mutate: mutateUserGroupRelations } = useSWRxUserGroupRelations(currentUserGroupId);
 
   const { data: childUserGroupsList, mutate: mutateChildUserGroups } = useSWRxChildUserGroupList(currentUserGroupId ? [currentUserGroupId] : [], true);
   const childUserGroups = childUserGroupsList != null ? childUserGroupsList.childUserGroups : [];
   const grandChildUserGroups = childUserGroupsList != null ? childUserGroupsList.grandChildUserGroups : [];
   const childUserGroupIds = childUserGroups.map(group => group._id);
 
-  const { data: userGroupRelationList, mutate: mutateUserGroupRelations } = useSWRxUserGroupRelationList(childUserGroupIds);
+  const { data: userGroupRelationList, mutate: mutateUserGroupRelationList } = useSWRxUserGroupRelationList(childUserGroupIds);
   const childUserGroupRelations = userGroupRelationList != null ? userGroupRelationList : [];
 
   const { data: selectableParentUserGroups, mutate: mutateSelectableParentUserGroups } = useSWRxSelectableParentUserGroups(currentUserGroupId);
@@ -106,19 +108,19 @@ const UserGroupDetailPage = (props: Props): JSX.Element => {
 
   const updateUserGroup = useCallback(async(userGroup: IUserGroupHasId, update: Partial<IUserGroupHasId>, forceUpdateParents: boolean) => {
     const parentId = typeof update.parent === 'string' ? update.parent : update.parent?._id;
-    const res = await apiv3Put<{ userGroup: IUserGroupHasId }>(`/user-groups/${userGroup._id}`, {
+    await apiv3Put<{ userGroup: IUserGroupHasId }>(`/user-groups/${userGroup._id}`, {
       name: update.name,
       description: update.description,
       parentId: parentId ?? null,
       forceUpdateParents,
     });
-    const { userGroup: updatedUserGroup } = res.data;
 
     // mutate
+    mutateChildUserGroups();
     mutateAncestorUserGroups();
     mutateSelectableChildUserGroups();
     mutateSelectableParentUserGroups();
-  }, [mutateAncestorUserGroups, mutateSelectableChildUserGroups, mutateSelectableParentUserGroups]);
+  }, [mutateAncestorUserGroups, mutateChildUserGroups, mutateSelectableChildUserGroups, mutateSelectableParentUserGroups]);
 
   const onSubmitUpdateGroup = useCallback(
     async(targetGroup: IUserGroupHasId, userGroupData: Partial<IUserGroupHasId>, forceUpdateParents: boolean): Promise<void> => {
@@ -170,22 +172,28 @@ const UserGroupDetailPage = (props: Props): JSX.Element => {
   }, [currentUserGroupId, searchType, isAlsoMailSearched, isAlsoNameSearched]);
 
   const addUserByUsername = useCallback(async(username: string) => {
-    await apiv3Post(`/user-groups/${currentUserGroupId}/users/${username}`);
-    setIsUserGroupUserModalShown(false);
-    mutateUserGroupRelations();
-  }, [currentUserGroupId, mutateUserGroupRelations]);
+    try {
+      await apiv3Post(`/user-groups/${currentUserGroupId}/users/${username}`);
+      setIsUserGroupUserModalShown(false);
+      mutateUserGroupRelations();
+      mutateUserGroupRelationList();
+    }
+    catch (err) {
+      toastError(new Error(`Unable to add "${username}" from "${currentUserGroup?.name}"`));
+    }
+  }, [currentUserGroup?.name, currentUserGroupId, mutateUserGroupRelationList, mutateUserGroupRelations]);
 
   // Fix: invalid csrf token => https://redmine.weseek.co.jp/issues/102704
   const removeUserByUsername = useCallback(async(username: string) => {
     try {
       await apiv3Delete(`/user-groups/${currentUserGroupId}/users/${username}`);
       toastSuccess(`Removed "${xss.process(username)}" from "${xss.process(currentUserGroup?.name)}"`);
-      mutateUserGroupRelations();
+      mutateUserGroupRelationList();
     }
     catch (err) {
       toastError(new Error(`Unable to remove "${xss.process(username)}" from "${xss.process(currentUserGroup?.name)}"`));
     }
-  }, [currentUserGroup?.name, currentUserGroupId, mutateUserGroupRelations, xss]);
+  }, [currentUserGroup?.name, currentUserGroupId, mutateUserGroupRelationList, xss]);
 
   const showUpdateModal = useCallback((group: IUserGroupHasId) => {
     setUpdateModalShown(true);
@@ -319,19 +327,27 @@ const UserGroupDetailPage = (props: Props): JSX.Element => {
     <div>
       <nav aria-label="breadcrumb">
         <ol className="breadcrumb">
-          <li className="breadcrumb-item"><a href="/admin/user-groups">{t('user_group_management.group_list')}</a></li>
+          <li className="breadcrumb-item">
+            <Link href="/admin/user-groups" prefetch={false}>
+              <a >{t('user_group_management.group_list')}</a>
+            </Link>
+          </li>
           {
-            ancestorUserGroups != null && ancestorUserGroups.length > 0 && (
-              ancestorUserGroups.map((ancestorUserGroup: IUserGroupHasId) => (
-                // eslint-disable-next-line max-len
-                <li key={ancestorUserGroup._id} className={`breadcrumb-item ${ancestorUserGroup._id === currentUserGroupId ? 'active' : ''}`} aria-current="page">
-                  { ancestorUserGroup._id === currentUserGroupId ? (
-                    <>{ancestorUserGroup.name}</>
-                  ) : (
+            ancestorUserGroups != null && ancestorUserGroups.length > 0 && (ancestorUserGroups.map((ancestorUserGroup: IUserGroupHasId) => (
+              <li
+                key={ancestorUserGroup._id}
+                className={`breadcrumb-item ${ancestorUserGroup._id === currentUserGroupId ? 'active' : ''}`}
+                aria-current="page"
+              >
+                { ancestorUserGroup._id === currentUserGroupId ? (
+                  <span>{ancestorUserGroup.name}</span>
+                ) : (
+                  <Link href={`/admin/user-group-detail/${ancestorUserGroup._id}`} prefetch={false}>
                     <a href={`/admin/user-group-detail/${ancestorUserGroup._id}`}>{ancestorUserGroup.name}</a>
-                  )}
-                </li>
-              ))
+                  </Link>
+                ) }
+              </li>
+            ))
             )
           }
         </ol>
@@ -347,8 +363,7 @@ const UserGroupDetailPage = (props: Props): JSX.Element => {
       </div>
       <h2 className="admin-setting-header mt-4">{t('user_group_management.user_list')}</h2>
       <UserGroupUserTable
-        userGroup={currentUserGroup}
-        userGroupRelations={childUserGroupRelations}
+        userGroupRelations={userGroupRelations}
         onClickPlusBtn={() => setIsUserGroupUserModalShown(true)}
         onClickRemoveUserBtn={removeUserByUsername}
       />

+ 3 - 7
packages/app/src/components/Admin/UserGroupDetail/UserGroupUserTable.tsx

@@ -4,12 +4,10 @@ import { UserPicture } from '@growi/ui';
 import dateFnsFormat from 'date-fns/format';
 import { useTranslation } from 'next-i18next';
 
-import { IUserGroupHasId, IUserGroupRelation } from '~/interfaces/user';
-import { useSWRxUserGroupRelations } from '~/stores/user-group';
+import type { IUserGroupRelationHasIdPopulatedUser } from '~/interfaces/user-group-response';
 
 type Props = {
-  userGroupRelations: IUserGroupRelation[],
-  userGroup: IUserGroupHasId,
+  userGroupRelations: IUserGroupRelationHasIdPopulatedUser[] | undefined,
   onClickRemoveUserBtn: (username: string) => Promise<void>,
   onClickPlusBtn: () => void,
 }
@@ -18,10 +16,8 @@ export const UserGroupUserTable = (props: Props): JSX.Element => {
   const { t } = useTranslation();
 
   const {
-    userGroup, onClickRemoveUserBtn, onClickPlusBtn,
+    userGroupRelations, onClickRemoveUserBtn, onClickPlusBtn,
   } = props;
-  const { data: userGroupRelations } = useSWRxUserGroupRelations(userGroup._id);
-
 
   return (
     <table className="table table-bordered table-user-list">

+ 3 - 3
packages/app/src/components/AlertSiteUrlUndefined.tsx

@@ -14,7 +14,7 @@ const isValidUrl = (str: string): boolean => {
 };
 
 export const AlertSiteUrlUndefined = (): JSX.Element => {
-  const { t } = useTranslation();
+  const { t } = useTranslation('commons');
   const { data: siteUrl, error: errorSiteUrl } = useSiteUrl();
   const isLoadingSiteUrl = siteUrl === undefined && errorSiteUrl === undefined;
 
@@ -30,8 +30,8 @@ export const AlertSiteUrlUndefined = (): JSX.Element => {
     <div className="alert alert-danger rounded-0 d-edit-none mb-0 px-4 py-2">
       <i className="icon-exclamation"></i>
       {
-        t('commons:alert.siteUrl_is_not_set', { link: t('commons:headers.app_settings') })
-      } &gt;&gt; <a href="/admin/app">{t('commons:headers.app_settings')}<i className="icon-login"></i></a>
+        t('alert.siteUrl_is_not_set', { link: t('headers.app_settings') })
+      } &gt;&gt; <a href="/admin/app">{t('headers.app_settings')}<i className="icon-login"></i></a>
     </div>
   );
 };

+ 2 - 1
packages/app/src/components/Comments.tsx

@@ -5,8 +5,9 @@ import dynamic from 'next/dynamic';
 
 import { PageComment } from '~/components/PageComment';
 import { useSWRxPageComment } from '~/stores/comment';
+import { useIsTrashPage } from '~/stores/page';
 
-import { useIsTrashPage, useCurrentUser } from '../stores/context';
+import { useCurrentUser } from '../stores/context';
 
 import { CommentEditorProps } from './PageComment/CommentEditor';
 

+ 3 - 1
packages/app/src/components/Common/ImageCropModal.tsx

@@ -12,6 +12,7 @@ import {
   ModalFooter,
 } from 'reactstrap';
 
+
 import { toastError } from '~/client/util/apiNotification';
 import loggerFactory from '~/utils/logger';
 import 'react-image-crop/dist/ReactCrop.css';
@@ -46,7 +47,8 @@ const ImageCropModal: FC<Props> = (props: Props) => {
   const [imageRef, setImageRef] = useState<HTMLImageElement | null>(null);
   const [cropOptions, setCropOtions] = useState<CropOptions>(null);
   const [isCropImage, setIsCropImage] = useState<boolean>(true);
-  const { t } = useTranslation();
+  const { t } = useTranslation('commons');
+
   const reset = useCallback(() => {
     if (imageRef) {
       // Some SVG files may not have width and height properties, causing the render size to be 0x0

+ 24 - 0
packages/app/src/components/CompleteUserRegistration.tsx

@@ -0,0 +1,24 @@
+import React, { FC } from 'react';
+
+import { useTranslation } from 'next-i18next';
+import Link from 'next/link';
+
+export const CompleteUserRegistration: FC = () => {
+  const { t } = useTranslation();
+
+  return (
+    <div className="noLogin-dialog mx-auto" id="noLogin-dialog">
+      <div className="row mx-0">
+        <div className="col-12 mb-3 text-center">
+          <p className="alert alert-success">
+            <span>{t('login.Registration successful')}</span>
+          </p>
+          {/* If the transition source is "/login", use <a /> tag since the transition will not occur if next/link is used. */}
+          <a href='/login'>
+            <i className="icon-login mr-1" />{t('Sign in is here')}
+          </a>
+        </div>
+      </div>
+    </div>
+  );
+};

+ 135 - 90
packages/app/src/components/CompleteUserRegistrationForm.tsx

@@ -1,30 +1,45 @@
-import React, { useState, useEffect } from 'react';
+import React, { useState, useEffect, useCallback } from 'react';
+
 import { useTranslation } from 'next-i18next';
+import { useRouter } from 'next/router';
+
 import { apiv3Get, apiv3Post } from '~/client/util/apiv3-client';
+import { UserActivationErrorCode } from '~/interfaces/errors/user-activation';
+import { RegistrationMode } from '~/interfaces/registration-mode';
+
+import { toastError } from '../client/util/apiNotification';
 
-import { toastSuccess, toastError } from '../client/util/apiNotification';
+import { CompleteUserRegistration } from './CompleteUserRegistration';
 
 interface Props {
-  messageErrors?: any,
-  inputs?: any,
   email: string,
   token: string,
+  errorCode?: UserActivationErrorCode,
+  registrationMode: RegistrationMode,
+  isEmailAuthenticationEnabled: boolean,
 }
 
 const CompleteUserRegistrationForm: React.FC<Props> = (props: Props) => {
 
   const { t } = useTranslation();
   const {
-    messageErrors,
     email,
     token,
+    errorCode,
+    registrationMode,
+    isEmailAuthenticationEnabled,
   } = props;
 
+  const forceDisableForm = errorCode != null || !isEmailAuthenticationEnabled;
+
   const [usernameAvailable, setUsernameAvailable] = useState(true);
   const [username, setUsername] = useState('');
   const [name, setName] = useState('');
   const [password, setPassword] = useState('');
-  const [disableForm, setDisableForm] = useState(false);
+  const [disableForm, setDisableForm] = useState(forceDisableForm);
+  const [isSuccessToRagistration, setIsSuccessToRagistration] = useState(false);
+
+  const router = useRouter();
 
   useEffect(() => {
     const delayDebounceFn = setTimeout(async() => {
@@ -42,104 +57,134 @@ const CompleteUserRegistrationForm: React.FC<Props> = (props: Props) => {
     return () => clearTimeout(delayDebounceFn);
   }, [username]);
 
-  async function submitRegistration() {
+  const handleSubmitRegistration = useCallback(async(e) => {
+    e.preventDefault();
     setDisableForm(true);
     try {
-      await apiv3Post('/complete-registration', {
+      const res = await apiv3Post('/complete-registration', {
         username, name, password, token,
       });
-      toastSuccess('Registration succeed');
-      window.location.href = '/login';
+
+      setIsSuccessToRagistration(true);
+
+      const { redirectTo } = res.data;
+      if (redirectTo != null) {
+        router.push(redirectTo);
+      }
     }
     catch (err) {
       toastError(err, 'Registration failed');
       setDisableForm(false);
+      setIsSuccessToRagistration(false);
     }
+  }, [username, name, password, token, router]);
+
+  if (isSuccessToRagistration && registrationMode === RegistrationMode.RESTRICTED) {
+    return <CompleteUserRegistration />;
   }
 
   return (
     <>
-      <div id="register-form-errors">
-        {messageErrors && (
-          <div className="alert alert-danger">
-            { messageErrors }
-          </div>
-        )}
-      </div>
-      <div id="register-dialog">
-
-        <fieldset id="registration-form" disabled={disableForm}>
-          <input type="hidden" name="token" value={token} />
-          <div className="input-group">
-            <div className="input-group-prepend">
-              <span className="input-group-text"><i className="icon-envelope"></i></span>
-            </div>
-            <input type="text" className="form-control" disabled value={email} />
-          </div>
-          <div className="input-group" id="input-group-username">
-            <div className="input-group-prepend">
-              <span className="input-group-text"><i className="icon-user"></i></span>
-            </div>
-            <input
-              type="text"
-              className="form-control"
-              placeholder={t('User ID')}
-              name="username"
-              onChange={e => setUsername(e.target.value)}
-              required
-            />
-          </div>
-          {!usernameAvailable && (
-            <p className="form-text text-red">
-              <span id="help-block-username"><i className="icon-fw icon-ban"></i>{t('installer.unavaliable_user_id')}</span>
-            </p>
-          )}
-
-          <div className="input-group">
-            <div className="input-group-prepend">
-              <span className="input-group-text"><i className="icon-tag"></i></span>
-            </div>
-            <input
-              type="text"
-              className="form-control"
-              placeholder={t('Name')}
-              name="name"
-              value={name}
-              onChange={e => setName(e.target.value)}
-              required
-            />
-          </div>
-
-          <div className="input-group">
-            <div className="input-group-prepend">
-              <span className="input-group-text"><i className="icon-lock"></i></span>
-            </div>
-            <input
-              type="password"
-              className="form-control"
-              placeholder={t('Password')}
-              name="password"
-              value={password}
-              onChange={e => setPassword(e.target.value)}
-              required
-            />
+      <div className="noLogin-dialog mx-auto" id="noLogin-dialog">
+        <div className="row mx-0">
+          <div className="col-12">
+
+            { (errorCode != null && errorCode === UserActivationErrorCode.TOKEN_NOT_FOUND) && (
+              <p className="alert alert-danger">
+                <span>Token not found</span>
+              </p>
+            )}
+
+            { (errorCode != null && errorCode === UserActivationErrorCode.USER_REGISTRATION_ORDER_IS_NOT_APPROPRIATE) && (
+              <p className="alert alert-danger">
+                <span>{t('message.incorrect_token_or_expired_url')}</span>
+              </p>
+            )}
+
+            { !isEmailAuthenticationEnabled && (
+              <p className="alert alert-danger">
+                <span>{t('message.email_authentication_is_not_enabled')}</span>
+              </p>
+            )}
+
+            <form role="form" onSubmit={handleSubmitRegistration} id="registration-form">
+              <input type="hidden" name="token" value={token} />
+
+              <div className="input-group">
+                <div className="input-group-prepend">
+                  <span className="input-group-text"><i className="icon-envelope"></i></span>
+                </div>
+                <input type="text" className="form-control" placeholder={t('Email')} disabled value={email} />
+              </div>
+
+              <div className="input-group" id="input-group-username">
+                <div className="input-group-prepend">
+                  <span className="input-group-text"><i className="icon-user"></i></span>
+                </div>
+                <input
+                  type="text"
+                  className="form-control"
+                  placeholder={t('User ID')}
+                  name="username"
+                  onChange={e => setUsername(e.target.value)}
+                  required
+                  disabled={forceDisableForm || disableForm}
+                />
+              </div>
+              {!usernameAvailable && (
+                <p className="form-text text-red">
+                  <span id="help-block-username"><i className="icon-fw icon-ban"></i>{t('installer.unavaliable_user_id')}</span>
+                </p>
+              )}
+
+              <div className="input-group">
+                <div className="input-group-prepend">
+                  <span className="input-group-text"><i className="icon-tag"></i></span>
+                </div>
+                <input
+                  type="text"
+                  className="form-control"
+                  placeholder={t('Name')}
+                  name="name"
+                  value={name}
+                  onChange={e => setName(e.target.value)}
+                  required
+                  disabled={forceDisableForm || disableForm}
+                />
+              </div>
+
+              <div className="input-group">
+                <div className="input-group-prepend">
+                  <span className="input-group-text"><i className="icon-lock"></i></span>
+                </div>
+                <input
+                  type="password"
+                  className="form-control"
+                  placeholder={t('Password')}
+                  name="password"
+                  value={password}
+                  onChange={e => setPassword(e.target.value)}
+                  required
+                  disabled={forceDisableForm || disableForm}
+                />
+              </div>
+
+              <div className="input-group justify-content-center d-flex mt-5">
+                <button disabled={forceDisableForm || disableForm} className="btn btn-fill" id="register">
+                  <div className="eff"></div>
+                  <span className="btn-label"><i className="icon-user-follow"></i></span>
+                  <span className="btn-label-text">{t('Create')}</span>
+                </button>
+              </div>
+
+              <div className="input-group mt-5 d-flex justify-content-center">
+                <a href="https://growi.org" className="link-growi-org">
+                  <span className="growi">GROWI</span>.<span className="org">ORG</span>
+                </a>
+              </div>
+            </form>
           </div>
-
-          <div className="input-group justify-content-center d-flex mt-5">
-            <button type="button" onClick={submitRegistration} className="btn btn-fill" id="register">
-              <div className="eff"></div>
-              <span className="btn-label"><i className="icon-user-follow"></i></span>
-              <span className="btn-label-text">{t('Create')}</span>
-            </button>
-          </div>
-
-          <div className="input-group mt-5 d-flex justify-content-center">
-            <a href="https://growi.org" className="link-growi-org">
-              <span className="growi">GROWI</span>.<span className="org">ORG</span>
-            </a>
-          </div>
-
-        </fieldset>
+        </div>
       </div>
     </>
   );

+ 2 - 1
packages/app/src/components/DescendantsPageList.tsx

@@ -11,8 +11,9 @@ import {
 import { IPagingResult } from '~/interfaces/paging-result';
 import { OnDeletedFunction, OnPutBackedFunction } from '~/interfaces/ui';
 import {
-  useIsGuestUser, useIsSharedUser, useIsTrashPage, useShowPageLimitationXL,
+  useIsGuestUser, useIsSharedUser, useShowPageLimitationXL,
 } from '~/stores/context';
+import { useIsTrashPage } from '~/stores/page';
 import {
   usePageTreeTermManager, useDescendantsPageListForCurrentPathTermManager, useSWRxDescendantsPageListForCurrrentPath,
   useSWRxPageInfoForList, useSWRxPageList,

+ 2 - 1
packages/app/src/components/Fab.tsx

@@ -7,8 +7,9 @@ import { useRipple } from 'react-use-ripple';
 import StickyEvents from 'sticky-events';
 
 import { DEFAULT_AUTO_SCROLL_OPTS } from '~/client/util/smooth-scroll';
-import { useCurrentPagePath, useCurrentUser } from '~/stores/context';
+import { useCurrentUser } from '~/stores/context';
 import { usePageCreateModal } from '~/stores/modal';
+import { useCurrentPagePath } from '~/stores/page';
 import loggerFactory from '~/utils/logger';
 
 import { CreatePageIcon } from './Icons/CreatePageIcon';

+ 1 - 1
packages/app/src/components/Hotkeys/Subscribers/CreatePage.jsx

@@ -2,8 +2,8 @@ import React, { useEffect } from 'react';
 
 import PropTypes from 'prop-types';
 
-import { useCurrentPagePath } from '~/stores/context';
 import { usePageCreateModal } from '~/stores/modal';
+import { useCurrentPagePath } from '~/stores/page';
 
 const CreatePage = React.memo((props) => {
 

+ 1 - 1
packages/app/src/components/InAppNotification/InAppNotificationDropdown.tsx

@@ -80,7 +80,7 @@ export const InAppNotificationDropdown = (): JSX.Element => {
 
   return (
     <Dropdown className="notification-wrapper grw-notification-dropdown" isOpen={isOpen} toggle={toggleDropdownHandler}>
-      <DropdownToggle tag="a" className="px-3 nav-link border-0 bg-transparentt" innerRef={buttonRef}>
+      <DropdownToggle className="px-3 nav-link border-0 bg-transparent" innerRef={buttonRef}>
         <i className="icon-bell" /> {badge}
       </DropdownToggle>
       <DropdownMenu right>

+ 3 - 6
packages/app/src/components/Layout/AdminLayout.tsx

@@ -8,15 +8,12 @@ import { RawLayout } from './RawLayout';
 
 import styles from './Admin.module.scss';
 
-
 const HotkeysManager = dynamic(() => import('../Hotkeys/HotkeysManager'), { ssr: false });
 
-const AdminNotFoundPage = dynamic(() => import('../Admin/NotFoundPage').then(mod => mod.AdminNotFoundPage), { ssr: false });
-
 
 type Props = {
-  title: string
-  componentTitle: string
+  title?: string
+  componentTitle?: string
   children?: ReactNode
 }
 
@@ -43,7 +40,7 @@ const AdminLayout = ({
                 <AdminNavigation />
               </div>
               <div className="col-lg-9">
-                {children || <AdminNotFoundPage />}
+                {children}
               </div>
             </div>
           </div>

+ 50 - 15
packages/app/src/components/LoginForm.tsx

@@ -9,15 +9,17 @@ import ReactCardFlip from 'react-card-flip';
 import { apiv3Post } from '~/client/util/apiv3-client';
 import { LoginErrorCode } from '~/interfaces/errors/login-error';
 import { IErrorV3 } from '~/interfaces/errors/v3-error';
+import { RegistrationMode } from '~/interfaces/registration-mode';
 import { toArrayIfNot } from '~/utils/array-utils';
 
+import { CompleteUserRegistration } from './CompleteUserRegistration';
+
 type LoginFormProps = {
   username?: string,
   name?: string,
   email?: string,
-  isRegistrationEnabled: boolean,
   isEmailAuthenticationEnabled: boolean,
-  registrationMode?: string,
+  registrationMode: RegistrationMode,
   registrationWhiteList: string[],
   isPasswordResetEnabled: boolean,
   isLocalStrategySetup: boolean,
@@ -31,7 +33,7 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
   const router = useRouter();
 
   const {
-    isLocalStrategySetup, isLdapStrategySetup, isLdapSetupFailed, isPasswordResetEnabled, isRegistrationEnabled,
+    isLocalStrategySetup, isLdapStrategySetup, isLdapSetupFailed, isPasswordResetEnabled,
     isEmailAuthenticationEnabled, registrationMode, registrationWhiteList, isMailerSetup, objOfIsExternalAuthEnableds,
   } = props;
   const isLocalOrLdapStrategiesEnabled = isLocalStrategySetup || isLdapStrategySetup;
@@ -49,6 +51,12 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
   const [emailForRegister, setEmailForRegister] = useState('');
   const [passwordForRegister, setPasswordForRegister] = useState('');
   const [registerErrors, setRegisterErrors] = useState<IErrorV3[]>([]);
+  // For UserActivation
+  const [emailForRegistrationOrder, setEmailForRegistrationOrder] = useState('');
+
+  const [isSuccessToRagistration, setIsSuccessToRagistration] = useState(false);
+
+  const isRegistrationEnabled = isLocalStrategySetup && registrationMode !== RegistrationMode.CLOSED;
 
   useEffect(() => {
     const { hash } = window.location;
@@ -259,8 +267,15 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
     );
   }, [props, renderExternalAuthInput]);
 
+  const resetRegisterErrors = useCallback(() => {
+    if (registerErrors.length === 0) return;
+    setRegisterErrors([]);
+  }, [registerErrors.length]);
+
   const handleRegisterFormSubmit = useCallback(async(e, requestPath) => {
     e.preventDefault();
+    setEmailForRegistrationOrder('');
+    setIsSuccessToRagistration(false);
 
     const registerForm = {
       username: usernameForRegister,
@@ -270,8 +285,19 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
     };
     try {
       const res = await apiv3Post(requestPath, { registerForm });
+
+      setIsSuccessToRagistration(true);
+      resetRegisterErrors();
+
       const { redirectTo } = res.data;
-      router.push(redirectTo ?? '/');
+      if (redirectTo != null) {
+        router.push(redirectTo);
+      }
+
+      if (isEmailAuthenticationEnabled) {
+        setEmailForRegistrationOrder(emailForRegister);
+        return;
+      }
     }
     catch (err) {
       // Execute if error exists
@@ -280,12 +306,7 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
       }
     }
     return;
-  }, [emailForRegister, nameForRegister, passwordForRegister, router, usernameForRegister]);
-
-  const resetRegisterErrors = useCallback(() => {
-    if (registerErrors.length === 0) return;
-    setRegisterErrors([]);
-  }, [registerErrors.length]);
+  }, [usernameForRegister, nameForRegister, emailForRegister, passwordForRegister, resetRegisterErrors, router, isEmailAuthenticationEnabled]);
 
   const switchForm = useCallback(() => {
     setIsRegistering(!isRegistering);
@@ -304,7 +325,7 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
 
     return (
       <React.Fragment>
-        {registrationMode === 'Restricted' && (
+        {registrationMode === RegistrationMode.RESTRICTED && (
           <p className="alert alert-warning">
             {t('page_register.notice.restricted')}
             <br />
@@ -313,7 +334,7 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
         )}
         { (!isMailerSetup && isEmailAuthenticationEnabled) && (
           <p className="alert alert-danger">
-            <span>{t('security_settings.Local.please_enable_mailer')}</span>
+            <span>{t('commons:alert.please_enable_mailer')}</span>
           </p>
         )}
 
@@ -331,6 +352,14 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
           )
         }
 
+        {
+          (isEmailAuthenticationEnabled && isSuccessToRagistration) && (
+            <p className="alert alert-success">
+              <span>{t('message.successfully_send_email_auth', { email: emailForRegistrationOrder })}</span>
+            </p>
+          )
+        }
+
         <form role="form" onSubmit={e => handleRegisterFormSubmit(e, registerAction) } id="register-form">
 
           {!isEmailAuthenticationEnabled && (
@@ -381,6 +410,7 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
             </div>
             {/* email */}
             <input type="email"
+              disabled={!isMailerSetup && isEmailAuthenticationEnabled}
               className="form-control rounded-0"
               onChange={(e) => { setEmailForRegister(e.target.value) }}
               placeholder={t('Email')}
@@ -452,9 +482,14 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
         </div>
       </React.Fragment>
     );
-  }, [handleRegisterFormSubmit, isEmailAuthenticationEnabled, isMailerSetup,
-      props.email, props.name, props.username,
-      registerErrors, registrationMode, registrationWhiteList, switchForm, t]);
+  }, [
+    t, isEmailAuthenticationEnabled, registrationMode, isMailerSetup, registerErrors, isSuccessToRagistration,
+    emailForRegistrationOrder, props.username, props.name, props.email, registrationWhiteList, switchForm, handleRegisterFormSubmit,
+  ]);
+
+  if (registrationMode === RegistrationMode.RESTRICTED && isSuccessToRagistration && !isEmailAuthenticationEnabled) {
+    return <CompleteUserRegistration />;
+  }
 
   return (
     <div className="noLogin-dialog mx-auto" id="noLogin-dialog">

+ 3 - 2
packages/app/src/components/Navbar/GlobalSearch.tsx

@@ -8,8 +8,9 @@ import { useRouter } from 'next/router';
 import { IFocusable } from '~/client/interfaces/focusable';
 import { IPageWithSearchMeta } from '~/interfaces/search';
 import {
-  useCurrentPagePath, useIsSearchScopeChildrenAsDefault, useIsSearchServiceReachable,
+  useIsSearchScopeChildrenAsDefault, useIsSearchServiceReachable,
 } from '~/stores/context';
+import { useCurrentPagePath } from '~/stores/page';
 import { useGlobalSearchFormRef } from '~/stores/ui';
 
 import SearchForm from '../SearchForm';
@@ -22,7 +23,7 @@ export type GlobalSearchProps = {
 }
 
 export const GlobalSearch = (props: GlobalSearchProps): JSX.Element => {
-  const { t } = useTranslation();
+  const { t } = useTranslation('commons');
 
   const { dropup } = props;
 

+ 14 - 3
packages/app/src/components/Navbar/GrowiContextualSubNavigation.tsx

@@ -6,7 +6,7 @@ import dynamic from 'next/dynamic';
 import { useRouter } from 'next/router';
 import { DropdownItem } from 'reactstrap';
 
-import { exportAsMarkdown } from '~/client/services/page-operation';
+import { exportAsMarkdown, updateContentWidth } from '~/client/services/page-operation';
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
 import { apiPost } from '~/client/util/apiv1-client';
 import {
@@ -17,7 +17,7 @@ import { OnDuplicatedFunction, OnRenamedFunction, OnDeletedFunction } from '~/in
 import {
   useCurrentPageId, useCurrentPathname,
   useIsNotFound,
-  useCurrentUser, useIsGuestUser, useIsSharedUser, useShareLinkId, useTemplateTagData,
+  useCurrentUser, useIsGuestUser, useIsSharedUser, useShareLinkId, useTemplateTagData, useIsContainerFluid,
 } from '~/stores/context';
 import { usePageTagsForEditors } from '~/stores/editor';
 import {
@@ -202,6 +202,7 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
   const { data: isGuestUser } = useIsGuestUser();
   const { data: isSharedUser } = useIsSharedUser();
   const { data: shareLinkId } = useShareLinkId();
+  const { data: isContainerFluid } = useIsContainerFluid();
 
   const { data: isAbleToShowPageManagement } = useIsAbleToShowPageManagement();
   const { data: isAbleToShowTagLabel } = useIsAbleToShowTagLabel();
@@ -313,6 +314,11 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
     openDeleteModal([pageWithMeta], { onDeleted: deletedHandler });
   }, [openDeleteModal, reload, router]);
 
+  const switchContentWidthHandler = useCallback(async(pageId: string, value: boolean) => {
+    await updateContentWidth(pageId, value);
+    mutateCurrentPage();
+  }, [mutateCurrentPage]);
+
   const templateMenuItemClickHandler = useCallback(() => {
     setIsPageTempleteModalShown(true);
   }, []);
@@ -356,12 +362,14 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
                     revisionId={revisionId}
                     shareLinkId={shareLinkId}
                     path={path ?? currentPathname} // If the page is empty, "path" is undefined
+                    expandContentWidth={currentPage?.expandContentWidth ?? isContainerFluid}
                     disableSeenUserInfoPopover={isSharedUser}
                     showPageControlDropdown={isAbleToShowPageManagement}
                     additionalMenuItemRenderer={additionalMenuItemsRenderer}
                     onClickDuplicateMenuItem={duplicateItemClickedHandler}
                     onClickRenameMenuItem={renameItemClickedHandler}
                     onClickDeleteMenuItem={deleteItemClickedHandler}
+                    onClickSwitchContentWidth={switchContentWidthHandler}
                   />
                 ) }
               </div>
@@ -402,7 +410,10 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
       </>
     );
   // eslint-disable-next-line max-len
-  }, [isCompactMode, isViewMode, pageId, revisionId, shareLinkId, path, currentPathname, isSharedUser, isAbleToShowPageManagement, duplicateItemClickedHandler, renameItemClickedHandler, deleteItemClickedHandler, isAbleToShowPageEditorModeManager, isGuestUser, editorMode, isAbleToShowPageAuthors, currentPage, currentUser, isPageTemplateModalShown, isLinkSharingDisabled, templateMenuItemClickHandler, mutateEditorMode]);
+  }, [isCompactMode, isViewMode, pageId, revisionId, shareLinkId, path, currentPathname, isSharedUser, isAbleToShowPageManagement,
+      duplicateItemClickedHandler, renameItemClickedHandler, deleteItemClickedHandler, isAbleToShowPageEditorModeManager, isGuestUser,
+      editorMode, isAbleToShowPageAuthors, currentPage, currentUser, isPageTemplateModalShown, isLinkSharingDisabled, templateMenuItemClickHandler,
+      mutateEditorMode, switchContentWidthHandler]);
 
 
   const pagePath = isNotFound

+ 2 - 1
packages/app/src/components/Navbar/GrowiNavbar.tsx

@@ -11,9 +11,10 @@ import { useRipple } from 'react-use-ripple';
 import { UncontrolledTooltip } from 'reactstrap';
 
 import {
-  useIsSearchPage, useCurrentPagePath, useIsGuestUser, useIsSearchServiceConfigured, useAppTitle, useConfidential, useCustomizedLogoSrc,
+  useIsSearchPage, useIsGuestUser, useIsSearchServiceConfigured, useAppTitle, useConfidential, useCustomizedLogoSrc,
 } from '~/stores/context';
 import { usePageCreateModal } from '~/stores/modal';
+import { useCurrentPagePath } from '~/stores/page';
 import { useIsDeviceSmallerThanMd } from '~/stores/ui';
 
 import { HasChildren } from '../../interfaces/common';

+ 2 - 1
packages/app/src/components/Navbar/GrowiNavbarBottom.tsx

@@ -1,7 +1,8 @@
 import React from 'react';
 
-import { useCurrentPagePath, useIsSearchPage } from '~/stores/context';
+import { useIsSearchPage } from '~/stores/context';
 import { usePageCreateModal } from '~/stores/modal';
+import { useCurrentPagePath } from '~/stores/page';
 import { useIsDeviceSmallerThanMd, useDrawerOpened } from '~/stores/ui';
 
 import { GlobalSearch } from './GlobalSearch';

+ 1 - 0
packages/app/src/components/Navbar/PageEditorModeManager.jsx

@@ -27,6 +27,7 @@ const PageEditorModeButtonWrapper = React.memo(({
       className={classNames.join(' ')}
       onClick={() => { onClick(targetMode) }}
       id={id}
+      data-testId={`${targetMode}-button`}
     >
       <span className="d-flex flex-column flex-md-row justify-content-center">
         <span className="grw-page-editor-mode-manager-icon mr-md-1">{icon}</span>

+ 4 - 4
packages/app/src/components/Navbar/PersonalDropdown.jsx

@@ -10,7 +10,7 @@ import { apiv3Post } from '~/client/util/apiv3-client';
 import { useCurrentUser } from '~/stores/context';
 
 const PersonalDropdown = () => {
-  const { t } = useTranslation();
+  const { t } = useTranslation('commons');
   const { data: currentUser } = useCurrentUser();
 
   // ripple
@@ -56,12 +56,12 @@ const PersonalDropdown = () => {
           <div className="btn-group btn-block mt-2" role="group">
             <Link href={`/user/${user.username}`}>
               <a className="btn btn-sm btn-outline-secondary col">
-                <i className="icon-fw icon-home"></i>{ t('personal_dropdown.home') }
+                <i className="icon-fw icon-home"></i>{t('personal_dropdown.home')}
               </a>
             </Link>
             <Link href="/me">
               <a className="btn btn-sm btn-outline-secondary col">
-                <i className="icon-fw icon-wrench"></i>{ t('personal_dropdown.settings') }
+                <i className="icon-fw icon-wrench"></i>{t('personal_dropdown.settings')}
               </a>
             </Link>
           </div>
@@ -69,7 +69,7 @@ const PersonalDropdown = () => {
 
         <div className="dropdown-divider"></div>
 
-        <button type="button" className="dropdown-item" onClick={logoutHandler}><i className="icon-fw icon-power"></i>{ t('Sign out') }</button>
+        <button type="button" className="dropdown-item" onClick={logoutHandler}><i className="icon-fw icon-power"></i>{t('Sign out')}</button>
       </div>
 
     </>

+ 23 - 18
packages/app/src/components/Navbar/SubNavButtons.tsx

@@ -4,7 +4,7 @@ import { useTranslation } from 'next-i18next';
 import { DropdownItem } from 'reactstrap';
 
 import {
-  toggleBookmark, toggleLike, toggleSubscribe, updateContentWidth,
+  toggleBookmark, toggleLike, toggleSubscribe,
 } from '~/client/services/page-operation';
 import { toastError } from '~/client/util/apiNotification';
 import {
@@ -28,22 +28,19 @@ import SeenUserInfo from '../User/SeenUserInfo';
 
 type WideViewMenuItemProps = AdditionalMenuItemsRendererProps & {
   onClickMenuItem: (newValue: boolean) => void,
+  expandContentWidth?: boolean,
 }
 
 const WideViewMenuItem = (props: WideViewMenuItemProps): JSX.Element => {
   const { t } = useTranslation();
 
   const {
-    pageInfo, onClickMenuItem,
+    onClickMenuItem, expandContentWidth,
   } = props;
 
-  if (!isIPageInfoForEntity(pageInfo)) {
-    return <></>;
-  }
-
   return (
     <DropdownItem
-      onClick={() => onClickMenuItem(!pageInfo.expandContentWidth)}
+      onClick={() => onClickMenuItem(!(expandContentWidth))}
       className="grw-page-control-dropdown-item"
     >
       <div className="custom-control custom-switch ml-1">
@@ -51,7 +48,7 @@ const WideViewMenuItem = (props: WideViewMenuItemProps): JSX.Element => {
           id="switchContentWidth"
           className="custom-control-input"
           type="checkbox"
-          checked={pageInfo.expandContentWidth}
+          checked={expandContentWidth}
           onChange={() => {}}
         />
         <label className="custom-control-label" htmlFor="switchContentWidth">
@@ -72,6 +69,7 @@ type CommonProps = {
   onClickDuplicateMenuItem?: (pageToDuplicate: IPageForPageDuplicateModal) => void,
   onClickRenameMenuItem?: (pageToRename: IPageToRenameWithMeta) => void,
   onClickDeleteMenuItem?: (pageToDelete: IPageToDeleteWithMeta) => void,
+  onClickSwitchContentWidth?: (pageId: string, value: boolean) => void,
 }
 
 type SubNavButtonsSubstanceProps = CommonProps & {
@@ -80,14 +78,15 @@ type SubNavButtonsSubstanceProps = CommonProps & {
   revisionId: string | null,
   path?: string | null,
   pageInfo: IPageInfoForOperation,
+  expandContentWidth?: boolean,
 }
 
 const SubNavButtonsSubstance = (props: SubNavButtonsSubstanceProps): JSX.Element => {
   const {
     pageInfo,
-    pageId, revisionId, path, shareLinkId,
+    pageId, revisionId, path, shareLinkId, expandContentWidth,
     isCompactMode, disableSeenUserInfoPopover, showPageControlDropdown, forceHideMenuItems, additionalMenuItemRenderer,
-    onClickDuplicateMenuItem, onClickRenameMenuItem, onClickDeleteMenuItem,
+    onClickDuplicateMenuItem, onClickRenameMenuItem, onClickDeleteMenuItem, onClickSwitchContentWidth,
   } = props;
 
   const { data: isGuestUser } = useIsGuestUser();
@@ -185,28 +184,30 @@ const SubNavButtonsSubstance = (props: SubNavButtonsSubstanceProps): JSX.Element
   }, [onClickDeleteMenuItem, pageId, pageInfo, path, revisionId]);
 
   const switchContentWidthClickHandler = useCallback(async(newValue: boolean) => {
-    if (isGuestUser == null || isGuestUser) {
+    if (onClickSwitchContentWidth == null || isGuestUser == null || isGuestUser) {
       return;
     }
     if (!isIPageInfoForEntity(pageInfo)) {
       return;
     }
     try {
-      await updateContentWidth(pageId, newValue);
-      mutatePageInfo();
+      onClickSwitchContentWidth(pageId, newValue);
     }
     catch (err) {
       toastError(err);
     }
-  }, [isGuestUser, mutatePageInfo, pageId, pageInfo]);
+  }, [isGuestUser, onClickSwitchContentWidth, pageId, pageInfo]);
 
   const additionalMenuItemOnTopRenderer = useMemo(() => {
     if (!isIPageInfoForEntity(pageInfo)) {
       return undefined;
     }
-    const wideviewMenuItemRenderer = (props: WideViewMenuItemProps) => <WideViewMenuItem {...props} onClickMenuItem={switchContentWidthClickHandler} />;
+    const wideviewMenuItemRenderer = (props: WideViewMenuItemProps) => {
+
+      return <WideViewMenuItem {...props} onClickMenuItem={switchContentWidthClickHandler} expandContentWidth={expandContentWidth} />;
+    };
     return wideviewMenuItemRenderer;
-  }, [pageInfo, switchContentWidthClickHandler]);
+  }, [pageInfo, switchContentWidthClickHandler, expandContentWidth]);
 
   if (!isIPageInfoForOperation(pageInfo)) {
     return <></>;
@@ -274,12 +275,14 @@ export type SubNavButtonsProps = CommonProps & {
   pageId: string,
   shareLinkId?: string | null,
   revisionId?: string | null,
-  path?: string | null
+  path?: string | null,
+  expandContentWidth?: boolean,
 };
 
 export const SubNavButtons = (props: SubNavButtonsProps): JSX.Element => {
   const {
-    pageId, revisionId, path, shareLinkId, onClickDuplicateMenuItem, onClickRenameMenuItem, onClickDeleteMenuItem,
+    pageId, revisionId, path, shareLinkId, expandContentWidth,
+    onClickDuplicateMenuItem, onClickRenameMenuItem, onClickDeleteMenuItem, onClickSwitchContentWidth,
   } = props;
 
   const { data: pageInfo, error } = useSWRxPageInfo(pageId ?? null, shareLinkId);
@@ -302,6 +305,8 @@ export const SubNavButtons = (props: SubNavButtonsProps): JSX.Element => {
       onClickDuplicateMenuItem={onClickDuplicateMenuItem}
       onClickRenameMenuItem={onClickRenameMenuItem}
       onClickDeleteMenuItem={onClickDeleteMenuItem}
+      onClickSwitchContentWidth={onClickSwitchContentWidth}
+      expandContentWidth={expandContentWidth}
     />
   );
 };

+ 4 - 3
packages/app/src/components/Page.tsx

@@ -13,7 +13,7 @@ import { HtmlElementNode } from 'rehype-toc';
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
 import { getOptionsToSave } from '~/client/util/editor';
 import {
-  useIsGuestUser, useCurrentPageTocNode, useShareLinkId,
+  useIsGuestUser, useShareLinkId,
 } from '~/stores/context';
 import {
   useSWRxSlackChannels, useIsSlackEnabled, usePageTagsForEditors, useIsEnabledUnsavedWarning,
@@ -21,6 +21,7 @@ import {
 import { useSWRxCurrentPage } from '~/stores/page';
 import { useViewOptions } from '~/stores/renderer';
 import {
+  useCurrentPageTocNode,
   useEditorMode, useIsMobile,
 } from '~/stores/ui';
 import loggerFactory from '~/utils/logger';
@@ -36,7 +37,7 @@ declare const globalEmitter: EventEmitter;
 
 // const DrawioModal = dynamic(() => import('./PageEditor/DrawioModal'), { ssr: false });
 const GridEditModal = dynamic(() => import('./PageEditor/GridEditModal'), { ssr: false });
-// const HandsontableModal = dynamic(() => import('./PageEditor/HandsontableModal'), { ssr: false });
+const HandsontableModal = dynamic(() => import('./PageEditor/HandsontableModal'), { ssr: false });
 const LinkEditModal = dynamic(() => import('./PageEditor/LinkEditModal'), { ssr: false });
 
 const logger = loggerFactory('growi:Page');
@@ -188,7 +189,7 @@ class PageSubstance extends React.Component<PageSubstanceProps> {
           <>
             <GridEditModal ref={this.gridEditModal} />
             <LinkEditModal ref={this.linkEditModal} />
-            {/* <HandsontableModal ref={this.handsontableModal} onSave={this.saveHandlerForHandsontableModal} /> */}
+            <HandsontableModal ref={this.handsontableModal} onSave={this.saveHandlerForHandsontableModal} />
             {/* TODO: use global DrawioModal https://redmine.weseek.co.jp/issues/105981 */}
             {/* <DrawioModal
               ref={this.drawioModal}

+ 5 - 2
packages/app/src/components/Page/CopyDropdown.jsx

@@ -101,7 +101,7 @@ const CopyDropdown = (props) => {
   /*
    * render
    */
-  const { t } = useTranslation();
+  const { t } = useTranslation('commons');
   const {
     dropdownToggleId, pageId, dropdownToggleClassName, children, isShareLinkMode,
   } = props;
@@ -172,7 +172,10 @@ const CopyDropdown = (props) => {
           { pageId && (
             <CopyToClipboard text={`${pagePathWithParams}\n${permalink}`} onCopy={showToolTip}>
               <DropdownItem className="px-3">
-                <DropdownItemContents title={t('copy_to_clipboard.Page path and permanent link')} contents={<>{pagePathWithParams}<br />{permalink}</>} />
+                <DropdownItemContents
+                  title={t('copy_to_clipboard.Page path and permanent link')}
+                  contents={<>{pagePathWithParams}<br />{permalink}</>}
+                />
               </DropdownItem>
             </CopyToClipboard>
           )}

+ 2 - 2
packages/app/src/components/Page/DisplaySwitcher.tsx

@@ -7,10 +7,10 @@ import { Link } from 'react-scroll';
 
 import { DEFAULT_AUTO_SCROLL_OPTS } from '~/client/util/smooth-scroll';
 import {
-  useCurrentPagePath, useIsSharedUser, useIsEditable, useShareLinkId, useIsNotFound,
+  useIsSharedUser, useIsEditable, useShareLinkId, useIsNotFound,
 } from '~/stores/context';
 import { useDescendantsPageListModal } from '~/stores/modal';
-import { useSWRxCurrentPage } from '~/stores/page';
+import { useCurrentPagePath, useSWRxCurrentPage } from '~/stores/page';
 import { EditorMode, useEditorMode } from '~/stores/ui';
 
 import CountBadge from '../Common/CountBadge';

+ 1 - 2
packages/app/src/components/PageAlert/TrashPageAlert.tsx

@@ -5,9 +5,8 @@ import { format } from 'date-fns';
 import { useRouter } from 'next/router';
 import { useTranslation } from 'react-i18next';
 
-import { useIsTrashPage } from '~/stores/context';
 import { usePageDeleteModal, usePutBackPageModal } from '~/stores/modal';
-import { useSWRxPageInfo, useSWRxCurrentPage } from '~/stores/page';
+import { useSWRxPageInfo, useSWRxCurrentPage, useIsTrashPage } from '~/stores/page';
 import { useIsAbleToShowTrashPageManagementButtons } from '~/stores/ui';
 
 const onDeletedHandler = (pathOrPathsToDelete) => {

+ 2 - 1
packages/app/src/components/PageComment/CommentEditor.tsx

@@ -13,10 +13,11 @@ import { apiPostForm } from '~/client/util/apiv1-client';
 import { IEditorMethods } from '~/interfaces/editor-methods';
 import { useSWRxPageComment } from '~/stores/comment';
 import {
-  useCurrentPagePath, useCurrentUser, useRevisionId, useIsSlackConfigured,
+  useCurrentUser, useRevisionId, useIsSlackConfigured,
   useIsUploadableFile, useIsUploadableImage,
 } from '~/stores/context';
 import { useSWRxSlackChannels, useIsSlackEnabled } from '~/stores/editor';
+import { useCurrentPagePath } from '~/stores/page';
 
 import { CustomNavTab } from '../CustomNavigation/CustomNav';
 import NotAvailableForGuest from '../NotAvailableForGuest';

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

@@ -15,14 +15,14 @@ import { apiGet, apiPostForm } from '~/client/util/apiv1-client';
 import { getOptionsToSave } from '~/client/util/editor';
 import { IEditorMethods } from '~/interfaces/editor-methods';
 import {
-  useCurrentPagePath, useCurrentPathname, useCurrentPageId,
+  useCurrentPathname, useCurrentPageId,
   useIsEditable, useIsIndentSizeForced, useIsUploadableFile, useIsUploadableImage, useEditingMarkdown,
 } from '~/stores/context';
 import {
   useCurrentIndentSize, useSWRxSlackChannels, useIsSlackEnabled, useIsTextlintEnabled, usePageTagsForEditors,
   useIsEnabledUnsavedWarning,
 } from '~/stores/editor';
-import { useSWRxCurrentPage } from '~/stores/page';
+import { useCurrentPagePath, useSWRxCurrentPage } from '~/stores/page';
 import { usePreviewOptions } from '~/stores/renderer';
 import {
   EditorMode,

+ 6 - 10
packages/app/src/components/PageEditor/CodeMirrorEditor.jsx

@@ -25,7 +25,7 @@ import EmojiPickerHelper from './EmojiPickerHelper';
 import GridEditModal from './GridEditModal';
 // TODO: re-impl with https://redmine.weseek.co.jp/issues/107248
 // import geu from './GridEditorUtil';
-// import HandsontableModal from './HandsontableModal';
+import HandsontableModal from './HandsontableModal';
 import LinkEditModal from './LinkEditModal';
 import mdu from './MarkdownDrawioUtil';
 import markdownLinkUtil from './MarkdownLinkUtil';
@@ -870,7 +870,7 @@ class CodeMirrorEditor extends AbstractEditor {
   }
 
   showHandsonTableHandler() {
-    // this.handsontableModal.current.show(mtu.getMarkdownTable(this.getCodeMirror()));
+    this.handsontableModal.current.show(mtu.getMarkdownTable(this.getCodeMirror()));
   }
 
 
@@ -1064,12 +1064,6 @@ class CodeMirrorEditor extends AbstractEditor {
           ref={this.cm}
           className={additionalClasses}
           placeholder="search"
-          // == temporary deactivate editorDidMount to use https://github.com/scniro/react-codemirror2/issues/284#issuecomment-1155928554
-          // editorDidMount={(editor) => {
-          // // add event handlers
-          //   editor.on('paste', this.pasteHandler);
-          //   editor.on('scrollCursorIntoView', this.scrollCursorIntoViewHandler);
-          // }}
           value={this.props.value}
           options={{
             indentUnit: this.props.indentSize,
@@ -1116,6 +1110,8 @@ class CodeMirrorEditor extends AbstractEditor {
           }}
           onKeyPress={this.keyPressHandler}
           onKeyDown={this.keyDownHandler}
+          onPasteFiles={this.pasteHandler}
+          onScrollCursorIntoView={this.scrollCursorIntoViewHandler}
         />
 
         { this.renderLoadingKeymapOverlay() }
@@ -1135,11 +1131,11 @@ class CodeMirrorEditor extends AbstractEditor {
           ref={this.linkEditModal}
           onSave={(linkText) => { return markdownLinkUtil.replaceFocusedMarkdownLinkWithEditor(this.getCodeMirror(), linkText) }}
         />
-        {/* <HandsontableModal
+        <HandsontableModal
           ref={this.handsontableModal}
           onSave={(table) => { return mtu.replaceFocusedMarkdownTableWithEditor(this.getCodeMirror(), table) }}
           autoFormatMarkdownTable={this.props.editorSettings.autoFormatMarkdownTable}
-        /> */}
+        />
       </div>
     );
   }

+ 1 - 1
packages/app/src/components/PageEditor/Editor.tsx

@@ -229,7 +229,7 @@ const Editor: ForwardRefRenderFunction<IEditorMethods, EditorPropsType> = (props
 
   const renderNavbar = useCallback(() => {
     return (
-      <div className="m-0 navbar navbar-default navbar-editor" style={{ minHeight: 'unset' }}>
+      <div className="m-0 navbar navbar-default navbar-editor" data-testId="navbar-editor" style={{ minHeight: 'unset' }}>
         <ul className="pl-2 nav nav-navbar">
           { (editorSubstance()?.getNavbarItems() ?? []).map((item, idx) => {
             // eslint-disable-next-line react/no-array-index-key

+ 2 - 1
packages/app/src/components/PageEditor/EditorNavbarBottom.tsx

@@ -4,8 +4,9 @@ import dynamic from 'next/dynamic';
 import { Collapse, Button } from 'reactstrap';
 
 
-import { useCurrentPagePath, useIsSlackConfigured } from '~/stores/context';
+import { useIsSlackConfigured } from '~/stores/context';
 import { useSWRxSlackChannels, useIsSlackEnabled } from '~/stores/editor';
+import { useCurrentPagePath } from '~/stores/page';
 import {
   EditorMode, useDrawerOpened, useEditorMode, useIsDeviceSmallerThanMd,
 } from '~/stores/ui';

+ 9 - 1
packages/app/src/components/PageEditor/HandsontableModal.jsx

@@ -16,6 +16,9 @@ import ExpandOrContractButton from '../ExpandOrContractButton';
 
 import MarkdownTableDataImportForm from './MarkdownTableDataImportForm';
 
+import styles from './HandsontableModal.module.scss';
+import 'handsontable/dist/handsontable.full.min.css';
+
 const DEFAULT_HOT_HEIGHT = 300;
 const MARKDOWNTABLE_TO_HANDSONTABLE_ALIGNMENT_SYMBOL_MAPPING = {
   r: 'htRight',
@@ -332,6 +335,10 @@ export default class HandsontableModal extends React.PureComponent {
     const align = this.state.markdownTable.options.align;
     const hotInstance = this.hotTable.hotInstance;
 
+    if (hotInstance.isDestroyed === true) {
+      return;
+    }
+
     for (let i = 0; i < align.length; i++) {
       for (let j = 0; j < hotInstance.countRows(); j++) {
         hotInstance.setCellMeta(j, i, 'className', MARKDOWNTABLE_TO_HANDSONTABLE_ALIGNMENT_SYMBOL_MAPPING[align[i]]);
@@ -435,7 +442,8 @@ export default class HandsontableModal extends React.PureComponent {
         backdrop="static"
         keyboard={false}
         size="lg"
-        className={`handsontable-modal ${this.state.isWindowExpanded && 'grw-modal-expanded'}`}
+        className={`handsontable-modal ${styles['grw-handsontable']}
+          ${this.state.isWindowExpanded && `grw-modal-expanded ${styles['grw-modal-expanded']}`}`}
       >
         <ModalHeader tag="h4" toggle={this.cancel} close={buttons} className="bg-primary text-light">
           Edit Table

+ 24 - 0
packages/app/src/components/PageEditor/HandsontableModal.module.scss

@@ -0,0 +1,24 @@
+.grw-handsontable :global {
+  .handsontable {
+    position: relative;
+
+    .handsontableInput {
+      max-width: 290px !important;
+    }
+
+    td {
+      word-break: break-all;
+    }
+
+    th {
+      text-align: inherit;
+    }
+  }
+}
+
+// expand .hot-table-container (with flexbox)
+.grw-modal-expanded :global {
+  .hot-table-container {
+    flex: 1;
+  }
+}

+ 1 - 1
packages/app/src/components/PageEditor/LinkEditModal.jsx

@@ -17,7 +17,7 @@ import validator from 'validator';
 
 import Linker from '~/client/models/Linker';
 import { apiv3Get } from '~/client/util/apiv3-client';
-import { useCurrentPagePath } from '~/stores/context';
+import { useCurrentPagePath } from '~/stores/page';
 
 import PagePreviewIcon from '../Icons/PagePreviewIcon';
 import SearchTypeahead from '../SearchTypeahead';

+ 6 - 6
packages/app/src/components/PageEditorByHackmd.tsx

@@ -13,15 +13,15 @@ import { apiPost } from '~/client/util/apiv1-client';
 import { getOptionsToSave } from '~/client/util/editor';
 import { IResHackmdIntegrated, IResHackmdDiscard } from '~/interfaces/hackmd';
 import {
-  useCurrentPagePath, useCurrentPageId, useHackmdUri,
+  useCurrentPageId, useCurrentPathname, useHackmdUri,
 } from '~/stores/context';
-import {
-  usePageIdOnHackmd, useHasDraftOnHackmd, useRevisionIdHackmdSynced, useRemoteRevisionId,
-} from '~/stores/hackmd';
 import {
   useSWRxSlackChannels, useIsSlackEnabled, usePageTagsForEditors, useIsEnabledUnsavedWarning,
 } from '~/stores/editor';
-import { useSWRxCurrentPage, useSWRxTagsInfo } from '~/stores/page';
+import {
+  usePageIdOnHackmd, useHasDraftOnHackmd, useRevisionIdHackmdSynced, useRemoteRevisionId,
+} from '~/stores/hackmd';
+import { useCurrentPagePath, useSWRxCurrentPage, useSWRxTagsInfo } from '~/stores/page';
 import {
   EditorMode,
   useEditorMode, useSelectedGrant,
@@ -43,7 +43,7 @@ export const PageEditorByHackmd = (): JSX.Element => {
   const { t } = useTranslation();
   const { data: editorMode, mutate: mutateEditorMode } = useEditorMode();
   const { data: currentPagePath } = useCurrentPagePath();
-  const { data: currentPathname } = useCurrentPagePath();
+  const { data: currentPathname } = useCurrentPathname();
   const { data: slackChannelsData } = useSWRxSlackChannels(currentPagePath);
   const { data: isSlackEnabled } = useIsSlackEnabled();
   const { data: pageId } = useCurrentPageId();

+ 1 - 1
packages/app/src/components/PageList/PageListItemL.tsx

@@ -248,7 +248,7 @@ const PageListItemLSubstance: ForwardRefRenderFunction<ISelectable, Props> = (pr
                   <div dangerouslySetInnerHTML={{ __html: elasticSearchResult.snippet }}></div>
                 ) }
                 { revisionShortBody != null && (
-                  <div>{revisionShortBody}</div>
+                  <div data-testid="revision-short-body-in-page-list-item-L">{revisionShortBody}</div>
                 ) }
                 {
                   !canRenderESSnippet && !canRenderRevisionSnippet && (

+ 1 - 1
packages/app/src/components/PageTimeline.tsx

@@ -5,7 +5,7 @@ import Link from 'next/link';
 
 import { apiv3Get } from '~/client/util/apiv3-client';
 import { IPageHasId } from '~/interfaces/page';
-import { useCurrentPagePath } from '~/stores/context';
+import { useCurrentPagePath } from '~/stores/page';
 import { useTimelineOptions } from '~/stores/renderer';
 
 import { RevisionLoader } from './Page/RevisionLoader';

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

@@ -7,7 +7,7 @@ import {
   Dropdown, DropdownToggle, DropdownMenu, DropdownItem,
 } from 'reactstrap';
 
-import { useCurrentPagePath } from '~/stores/context';
+import { useCurrentPagePath } from '~/stores/page';
 
 import { RevisionDiff } from '../PageHistory/RevisionDiff';
 
@@ -29,7 +29,7 @@ type RevisionComparerProps = {
 }
 
 export const RevisionComparer = (props: RevisionComparerProps): JSX.Element => {
-  const { t } = useTranslation();
+  const { t } = useTranslation(['translation', 'commons']);
 
   const {
     sourceRevision, targetRevision, currentPageId,
@@ -80,13 +80,13 @@ export const RevisionComparer = (props: RevisionComparerProps): JSX.Element => {
             {/* Page path URL */}
             <CopyToClipboard text={generateURL(currentPagePath)}>
               <DropdownItem className="px-3">
-                <DropdownItemContents title={t('copy_to_clipboard.Page URL')} contents={generateURL(currentPagePath)} />
+                <DropdownItemContents title={t('copy_to_clipboard.Page URL', { ns: 'commons' })} contents={generateURL(currentPagePath)} />
               </DropdownItem>
             </CopyToClipboard>
             {/* Permanent Link URL */}
             <CopyToClipboard text={generateURL(currentPageId)}>
               <DropdownItem className="px-3">
-                <DropdownItemContents title={t('copy_to_clipboard.Permanent link')} contents={generateURL(currentPageId)} />
+                <DropdownItemContents title={t('copy_to_clipboard.Permanent link', { ns: 'commons' })} contents={generateURL(currentPageId)} />
               </DropdownItem>
             </CopyToClipboard>
             <DropdownItem divider className="my-0"></DropdownItem>

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

@@ -11,9 +11,10 @@ import {
 import { CustomWindow } from '~/interfaces/global';
 import { IPageGrantData } from '~/interfaces/page';
 import {
-  useCurrentPagePath, useIsEditable, useCurrentPageId, useIsAclEnabled,
+  useIsEditable, useCurrentPageId, useIsAclEnabled,
 } from '~/stores/context';
 import { useIsEnabledUnsavedWarning } from '~/stores/editor';
+import { useCurrentPagePath } from '~/stores/page';
 import { useSelectedGrant } from '~/stores/ui';
 import loggerFactory from '~/utils/logger';
 

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

@@ -9,12 +9,12 @@ import { animateScroll } from 'react-scroll';
 import { DropdownItem } from 'reactstrap';
 
 
-import { exportAsMarkdown } from '~/client/services/page-operation';
+import { exportAsMarkdown, updateContentWidth } from '~/client/services/page-operation';
 import { toastSuccess } from '~/client/util/apiNotification';
 import { IPageToDeleteWithMeta, IPageToRenameWithMeta } from '~/interfaces/page';
 import { IPageWithSearchMeta } from '~/interfaces/search';
 import { OnDuplicatedFunction, OnRenamedFunction, OnDeletedFunction } from '~/interfaces/ui';
-import { useCurrentUser } from '~/stores/context';
+import { useCurrentUser, useIsContainerFluid } from '~/stores/context';
 import {
   usePageDuplicateModal, usePageRenameModal, usePageDeleteModal,
 } from '~/stores/modal';
@@ -156,6 +156,9 @@ export const SearchResultContent: FC<Props> = (props: Props) => {
   const { open: openDeleteModal } = usePageDeleteModal();
   const { data: rendererOptions } = useSearchResultOptions(pageWithMeta.data.path, highlightKeywords);
   const { data: currentUser } = useCurrentUser();
+  const { data: isContainerFluid } = useIsContainerFluid();
+
+  const [isExpandContentWidth, setIsExpandContentWidth] = useState(page.expandContentWidth);
 
   const duplicateItemClickedHandler = useCallback(async(pageToDuplicate) => {
     // eslint-disable-next-line @typescript-eslint/no-unused-vars
@@ -201,6 +204,11 @@ export const SearchResultContent: FC<Props> = (props: Props) => {
     openDeleteModal([pageToDelete], { onDeleted: onDeletedHandler });
   }, [onDeletedHandler, openDeleteModal]);
 
+  const switchContentWidthHandler = useCallback(async(pageId: string, value: boolean) => {
+    await updateContentWidth(pageId, value);
+    setIsExpandContentWidth(value);
+  }, []);
+
   const RightComponent = useCallback(() => {
     if (page == null) {
       return <></>;
@@ -214,6 +222,7 @@ export const SearchResultContent: FC<Props> = (props: Props) => {
           pageId={page._id}
           revisionId={revisionId}
           path={page.path}
+          expandContentWidth={isExpandContentWidth ?? isContainerFluid}
           showPageControlDropdown={showPageControlDropdown}
           forceHideMenuItems={forceHideMenuItems}
           additionalMenuItemRenderer={props => <AdditionalMenuItems {...props} pageId={page._id} revisionId={revisionId} />}
@@ -221,10 +230,12 @@ export const SearchResultContent: FC<Props> = (props: Props) => {
           onClickDuplicateMenuItem={duplicateItemClickedHandler}
           onClickRenameMenuItem={renameItemClickedHandler}
           onClickDeleteMenuItem={deleteItemClickedHandler}
+          onClickSwitchContentWidth={switchContentWidthHandler}
         />
       </div>
     );
-  }, [page, showPageControlDropdown, forceHideMenuItems, duplicateItemClickedHandler, renameItemClickedHandler, deleteItemClickedHandler]);
+  }, [page, isExpandContentWidth, showPageControlDropdown, forceHideMenuItems, isContainerFluid,
+      duplicateItemClickedHandler, renameItemClickedHandler, deleteItemClickedHandler, switchContentWidthHandler]);
 
   // return if page or growiRenderer is null
   if (page == null || rendererOptions == null) return <></>;

+ 2 - 1
packages/app/src/components/Sidebar/PageTree.tsx

@@ -3,8 +3,9 @@ import React, { FC, memo } from 'react';
 import { useTranslation } from 'next-i18next';
 
 import {
-  useCurrentPagePath, useCurrentPageId, useTargetAndAncestors, useIsGuestUser,
+  useCurrentPageId, useTargetAndAncestors, useIsGuestUser,
 } from '~/stores/context';
+import { useCurrentPagePath } from '~/stores/page';
 import { useSWRxV5MigrationStatus } from '~/stores/page-listing';
 
 import ItemsTree from './PageTree/ItemsTree';

+ 4 - 8
packages/app/src/components/Sidebar/PageTree/Item.tsx

@@ -39,7 +39,7 @@ interface ItemProps {
   targetPathOrId?: Nullable<string>
   isOpen?: boolean
   isEnabledAttachTitleHeader?: boolean
-  onRenamed?(): void
+  onRenamed?(fromPath: string | undefined, toPath: string): void
   onClickDuplicateMenuItem?(pageToDuplicate: IPageForPageDuplicateModal): void
   onClickDeleteMenuItem?(pageToDelete: IPageToDeleteWithMeta): void
 }
@@ -191,7 +191,7 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
       await mutateChildren();
 
       if (onRenamed != null) {
-        onRenamed();
+        onRenamed(page.path, newPagePath);
       }
 
       // force open
@@ -286,7 +286,7 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
       });
 
       if (onRenamed != null) {
-        onRenamed();
+        onRenamed(page.path, newPagePath);
       }
 
       toastSuccess(t('renamed_pages', { path: page.path }));
@@ -380,11 +380,6 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
   const pathRecoveryMenuItemClickHandler = async(pageId: string): Promise<void> => {
     try {
       await resumeRenameOperation(pageId);
-
-      if (onRenamed != null) {
-        onRenamed();
-      }
-
       toastSuccess(t('page_operation.paths_recovered'));
     }
     catch {
@@ -425,6 +420,7 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
   return (
     <div
       id={`pagetree-item-${page._id}`}
+      data-testid="grw-pagetree-item-container"
       className={`grw-pagetree-item-container ${isOver ? 'grw-pagetree-is-over' : ''}
     ${shouldHide ? 'd-none' : ''}`}
     >

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

@@ -15,6 +15,7 @@ import { useIsEnabledAttachTitleHeader } from '~/stores/context';
 import {
   IPageForPageDuplicateModal, usePageDuplicateModal, usePageDeleteModal,
 } from '~/stores/modal';
+import { useCurrentPagePath, useSWRxCurrentPage } from '~/stores/page';
 import {
   usePageTreeTermManager, useSWRxPageAncestorsChildren, useSWRxRootPage, useDescendantsPageListForCurrentPathTermManager,
 } from '~/stores/page-listing';
@@ -102,6 +103,7 @@ const ItemsTree = (props: ItemsTreeProps): JSX.Element => {
 
   const { data: ancestorsChildrenResult, error: error1 } = useSWRxPageAncestorsChildren(targetPath);
   const { data: rootPageResult, error: error2 } = useSWRxRootPage();
+  const { data: currentPagePath } = useCurrentPagePath();
   const { data: isEnabledAttachTitleHeader } = useIsEnabledAttachTitleHeader();
   const { open: openDuplicateModal } = usePageDuplicateModal();
   const { open: openDeleteModal } = usePageDeleteModal();
@@ -111,6 +113,7 @@ const ItemsTree = (props: ItemsTreeProps): JSX.Element => {
   const { data: ptDescCountMap, update: updatePtDescCountMap } = usePageTreeDescCountMap();
 
   // for mutation
+  const { mutate: mutateCurrentPage } = useSWRxCurrentPage();
   const { advance: advancePt } = usePageTreeTermManager();
   const { advance: advanceFts } = useFullTextSearchTermManager();
   const { advance: advanceDpl } = useDescendantsPageListForCurrentPathTermManager();
@@ -142,13 +145,17 @@ const ItemsTree = (props: ItemsTreeProps): JSX.Element => {
 
   }, [socket, ptDescCountMap, updatePtDescCountMap]);
 
-  const onRenamed = () => {
+  const onRenamed = useCallback((fromPath: string | undefined, toPath: string) => {
     advancePt();
     advanceFts();
     advanceDpl();
-  };
 
-  const onClickDuplicateMenuItem = (pageToDuplicate: IPageForPageDuplicateModal) => {
+    if (currentPagePath === fromPath || currentPagePath === toPath) {
+      mutateCurrentPage();
+    }
+  }, [advanceDpl, advanceFts, advancePt, currentPagePath, mutateCurrentPage]);
+
+  const onClickDuplicateMenuItem = useCallback((pageToDuplicate: IPageForPageDuplicateModal) => {
     // eslint-disable-next-line @typescript-eslint/no-unused-vars
     const duplicatedHandler: OnDuplicatedFunction = (fromPath, toPath) => {
       toastSuccess(t('duplicated_pages', { fromPath }));
@@ -159,9 +166,9 @@ const ItemsTree = (props: ItemsTreeProps): JSX.Element => {
     };
 
     openDuplicateModal(pageToDuplicate, { onDuplicated: duplicatedHandler });
-  };
+  }, [advanceDpl, advanceFts, advancePt, openDuplicateModal, t]);
 
-  const onClickDeleteMenuItem = (pageToDelete: IPageToDeleteWithMeta) => {
+  const onClickDeleteMenuItem = useCallback((pageToDelete: IPageToDeleteWithMeta) => {
     const onDeletedHandler: OnDeletedFunction = (pathOrPathsToDelete, isRecursively, isCompletely) => {
       if (typeof pathOrPathsToDelete !== 'string') {
         return;
@@ -179,10 +186,14 @@ const ItemsTree = (props: ItemsTreeProps): JSX.Element => {
       advancePt();
       advanceFts();
       advanceDpl();
+
+      if (currentPagePath === pathOrPathsToDelete) {
+        mutateCurrentPage();
+      }
     };
 
     openDeleteModal([pageToDelete], { onDeleted: onDeletedHandler });
-  };
+  }, [advanceDpl, advanceFts, advancePt, currentPagePath, mutateCurrentPage, openDeleteModal, t]);
 
   // ***************************  Scroll on init ***************************
   const scrollOnInit = useCallback(() => {

+ 1 - 1
packages/app/src/components/TableOfContents.tsx

@@ -3,7 +3,7 @@ import React, { useCallback } from 'react';
 import { pagePathUtils } from '@growi/core';
 import ReactMarkdown from 'react-markdown';
 
-import { useCurrentPagePath } from '~/stores/context';
+import { useCurrentPagePath } from '~/stores/page';
 import { useTocOptions } from '~/stores/renderer';
 import loggerFactory from '~/utils/logger';
 

+ 16 - 9
packages/app/src/components/UncontrolledCodeMirror.tsx

@@ -22,21 +22,33 @@ export interface UncontrolledCodeMirrorProps extends ICodeMirror {
   value: string;
   isGfmMode?: boolean;
   lineNumbers?: boolean;
-  onScrollCursorIntoView?: (line: number) => void;
   onSave?: () => Promise<void>;
-  onPasteFiles?: (event: Event) => void;
   onCtrlEnter?: (event: Event) => void;
+  onPasteFiles?: (editor: any, event: Event) => void;
+  onScrollCursorIntoView?: (editor: any, event: Event) => void;
 }
 
 export const UncontrolledCodeMirror = React.forwardRef<CodeMirror|null, UncontrolledCodeMirrorProps>((props, forwardedRef): JSX.Element => {
 
-  const wrapperRef = useRef<CodeMirror|null>();
+  const {
+    value, lineNumbers, options,
+    onPasteFiles, onScrollCursorIntoView,
+    ...rest
+  } = props;
 
   const editorRef = useRef<Editor>();
 
+  const wrapperRef = useRef<CodeMirror|null>();
+
   const editorDidMountHandler = useCallback((editor: Editor): void => {
     editorRef.current = editor;
-  }, []);
+    if (onPasteFiles != null) {
+      editor.on('paste', onPasteFiles);
+    }
+    if (onScrollCursorIntoView != null) {
+      editor.on('scrollCursorIntoView', onScrollCursorIntoView);
+    }
+  }, [onPasteFiles, onScrollCursorIntoView]);
 
   const editorWillUnmountHandler = useCallback((): void => {
     // workaround to fix editor duplicating by https://github.com/scniro/react-codemirror2/issues/284#issuecomment-1155928554
@@ -48,11 +60,6 @@ export const UncontrolledCodeMirror = React.forwardRef<CodeMirror|null, Uncontro
     }
   }, []);
 
-  const {
-    value, lineNumbers, options,
-    ...rest
-  } = props;
-
   // default true
   const isGfmMode = rest.isGfmMode ?? true;
 

+ 3 - 2
packages/app/src/components/User/UserDate.jsx

@@ -1,7 +1,8 @@
 import React from 'react';
-import PropTypes from 'prop-types';
 
 import { format } from 'date-fns';
+import PropTypes from 'prop-types';
+
 
 /**
  * UserDate
@@ -15,7 +16,7 @@ export default class UserDate extends React.Component {
     const dt = format(date, this.props.format);
 
     return (
-      <span className={this.props.className}>
+      <span className={this.props.className} data-hide-in-vrt>
         {dt}
       </span>
     );

+ 4 - 0
packages/app/src/interfaces/admin.ts

@@ -0,0 +1,4 @@
+
+export interface updateConfigMethodForAdmin<T> {
+  update: (arg: T) => void
+}

+ 6 - 0
packages/app/src/interfaces/errors/user-activation.ts

@@ -0,0 +1,6 @@
+export const UserActivationErrorCode = {
+  TOKEN_NOT_FOUND: 'token-not-found',
+  USER_REGISTRATION_ORDER_IS_NOT_APPROPRIATE: 'user-registration-order-is-not-appropriate',
+} as const;
+
+export type UserActivationErrorCode = typeof UserActivationErrorCode[keyof typeof UserActivationErrorCode];

+ 7 - 0
packages/app/src/interfaces/registration-mode.ts

@@ -0,0 +1,7 @@
+export const RegistrationMode = {
+  OPEN: 'Open',
+  RESTRICTED: 'Restricted',
+  CLOSED: 'Closed',
+} as const;
+
+export type RegistrationMode = typeof RegistrationMode[keyof typeof RegistrationMode];

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

@@ -56,7 +56,7 @@ import DisplaySwitcher from '../components/Page/DisplaySwitcher';
 // import { serializeUserSecurely } from '../server/models/serializers/user-serializer';
 // import PageStatusAlert from '../client/js/components/PageStatusAlert';
 import {
-  useCurrentUser, useCurrentPagePath,
+  useCurrentUser,
   useIsLatestRevision,
   useIsForbidden, useIsNotFound, useIsSharedUser,
   useIsEnabledStaleNotification, useIsIdenticalPath,
@@ -65,13 +65,12 @@ import {
   useIsAclEnabled, useIsSearchPage,
   useCsrfToken, useIsSearchScopeChildrenAsDefault, useCurrentPageId, useCurrentPathname,
   useIsSlackConfigured, useRendererConfig, useEditingMarkdown,
-  useEditorConfig, useIsAllReplyShown, useIsUploadableFile, useIsUploadableImage, useLayoutSetting, useCustomizedLogoSrc,
+  useEditorConfig, useIsAllReplyShown, useIsUploadableFile, useIsUploadableImage, useCustomizedLogoSrc, useIsContainerFluid,
 } from '../stores/context';
 
 import {
   CommonProps, getNextI18NextConfig, getServerSideCommonProps, useCustomTitle,
 } from './utils/commons';
-import { calcIsContainerFluid } from './utils/layout';
 // import { useCurrentPageSWR } from '../stores/page';
 
 
@@ -198,6 +197,7 @@ const GrowiPage: NextPage<Props> = (props: Props) => {
 
   // page
   useIsLatestRevision(props.isLatestRevision);
+  useIsContainerFluid(props.isContainerFluid);
   // useOwnerOfCurrentPage(props.pageUser != null ? JSON.parse(props.pageUser) : null);
   useIsForbidden(props.isForbidden);
   useIsNotFound(props.isNotFound);
@@ -240,17 +240,14 @@ const GrowiPage: NextPage<Props> = (props: Props) => {
 
   useCurrentPageId(pageId ?? null);
   // useIsNotCreatable(props.isForbidden || !isCreatablePage(pagePath)); // TODO: need to include props.isIdentical
-  useCurrentPagePath(pagePath);
   useCurrentPathname(props.currentPathname);
 
-  useSWRxCurrentPage(undefined, pageWithMeta?.data ?? null); // store initial data
+  const { data: currentPage } = useSWRxCurrentPage(undefined, pageWithMeta?.data ?? null); // store initial data
   useEditingMarkdown(pageWithMeta?.data.revision?.body ?? '');
 
-  const { data: dataPageInfo } = useSWRxPageInfo(pageId);
   const { data: grantData } = useSWRxIsGrantNormalized(pageId);
   const { mutate: mutateSelectedGrant } = useSelectedGrant();
 
-  const { data: layoutSetting } = useLayoutSetting({ isContainerFluid: props.isContainerFluid });
   const { getClassNamesByEditorMode } = useEditorMode();
 
   const shouldRenderPutbackPageModal = pageWithMeta != null
@@ -277,12 +274,11 @@ const GrowiPage: NextPage<Props> = (props: Props) => {
 
   const isTopPagePath = isTopPage(pageWithMeta?.data.path ?? '');
 
-  const isContainerFluidEachPage = dataPageInfo == null || !('expandContentWidth' in dataPageInfo)
+  const isContainerFluidEachPage = currentPage == null || !('expandContentWidth' in currentPage)
     ? null
-    : dataPageInfo.expandContentWidth;
+    : currentPage.expandContentWidth;
   const isContainerFluidDefault = props.isContainerFluid;
-  const isContainerFluidAdmin = layoutSetting?.isContainerFluid;
-  const isContainerFluid = calcIsContainerFluid(isContainerFluidEachPage, isContainerFluidDefault, isContainerFluidAdmin);
+  const isContainerFluid = isContainerFluidEachPage ?? isContainerFluidDefault;
 
   return (
     <>

+ 5 - 1
packages/app/src/pages/_search.page.tsx

@@ -12,7 +12,7 @@ import type { IUser, IUserHasId } from '~/interfaces/user';
 import type { IUserUISettings } from '~/interfaces/user-ui-settings';
 import type { UserUISettingsModel } from '~/server/models/user-ui-settings';
 import {
-  useCsrfToken, useCurrentUser, useIsSearchPage, useIsSearchScopeChildrenAsDefault,
+  useCsrfToken, useCurrentUser, useIsContainerFluid, useIsSearchPage, useIsSearchScopeChildrenAsDefault,
   useIsSearchServiceConfigured, useIsSearchServiceReachable, useRendererConfig, useShowPageLimitationL,
 } from '~/stores/context';
 import {
@@ -46,6 +46,8 @@ type Props = CommonProps & {
   // search limit
   showPageLimitationL: number
 
+  isContainerFluid: boolean,
+
 };
 
 const SearchResultPage: NextPage<Props> = (props: Props) => {
@@ -73,6 +75,7 @@ const SearchResultPage: NextPage<Props> = (props: Props) => {
   useRendererConfig(props.rendererConfig);
 
   useShowPageLimitationL(props.showPageLimitationL);
+  useIsContainerFluid(props.isContainerFluid);
 
   const PutbackPageModal = (): JSX.Element => {
     const PutbackPageModal = dynamic(() => import('../components/PutbackPageModal'), { ssr: false });
@@ -125,6 +128,7 @@ function injectServerConfigurations(context: GetServerSidePropsContext, props: P
   props.isSearchServiceConfigured = searchService.isConfigured;
   props.isSearchServiceReachable = searchService.isReachable;
   props.isSearchScopeChildrenAsDefault = configManager.getConfig('crowi', 'customize:isSearchScopeChildrenAsDefault');
+  props.isContainerFluid = configManager.getConfig('crowi', 'customize:isContainerFluid');
 
   props.sidebarConfig = {
     isSidebarDrawerMode: configManager.getConfig('crowi', 'customize:isSidebarDrawerMode'),

+ 32 - 0
packages/app/src/pages/admin/[...path].page.tsx

@@ -0,0 +1,32 @@
+import {
+  NextPage, GetServerSideProps, GetServerSidePropsContext,
+} from 'next';
+import dynamic from 'next/dynamic';
+
+import { CommonProps } from '~/pages/utils/commons';
+import { useIsMaintenanceMode } from '~/stores/maintenanceMode';
+
+import { retrieveServerSideProps } from '../../utils/admin-page-util';
+
+const AdminLayout = dynamic(() => import('~/components/Layout/AdminLayout'), { ssr: false });
+const AdminNotFoundPage = dynamic(() => import('~/components/Admin/NotFoundPage').then(mod => mod.AdminNotFoundPage), { ssr: false });
+
+
+const AdminAppPage: NextPage<CommonProps> = (props) => {
+  useIsMaintenanceMode(props.isMaintenanceMode);
+
+
+  return (
+    <AdminLayout>
+      <AdminNotFoundPage />
+    </AdminLayout>
+  );
+};
+
+export const getServerSideProps: GetServerSideProps = async(context: GetServerSidePropsContext) => {
+  const props = await retrieveServerSideProps(context);
+  return props;
+};
+
+
+export default AdminAppPage;

+ 2 - 2
packages/app/src/pages/admin/app.page.tsx

@@ -17,10 +17,10 @@ const AppSettingsPageContents = dynamic(() => import('~/components/Admin/App/App
 
 
 const AdminAppPage: NextPage<CommonProps> = (props) => {
-  const { t } = useTranslation('admin');
+  const { t } = useTranslation('commons');
   useIsMaintenanceMode(props.isMaintenanceMode);
 
-  const title = t('commons:headers.app_settings');
+  const title = t('headers.app_settings');
   const injectableContainers: Container<any>[] = [];
 
   if (isClient()) {

+ 1 - 0
packages/app/src/pages/admin/global-notification/[globalNotificationId].page.tsx

@@ -15,6 +15,7 @@ import { CommonProps, useCustomTitle } from '~/pages/utils/commons';
 
 import { retrieveServerSideProps } from '../../../utils/admin-page-util';
 
+
 const AdminLayout = dynamic(() => import('~/components/Layout/AdminLayout'), { ssr: false });
 const ManageGlobalNotification = dynamic(() => import('~/components/Admin/Notification/ManageGlobalNotification'), { ssr: false });
 

+ 2 - 1
packages/app/src/pages/admin/global-notification/new.page.tsx

@@ -8,6 +8,7 @@ import { Container, Provider } from 'unstated';
 
 import AdminNotificationContainer from '~/client/services/AdminNotificationContainer';
 import { CommonProps, useCustomTitle } from '~/pages/utils/commons';
+
 import { retrieveServerSideProps } from '../../../utils/admin-page-util';
 
 const AdminLayout = dynamic(() => import('~/components/Layout/AdminLayout'), { ssr: false });
@@ -15,7 +16,7 @@ const ManageGlobalNotification = dynamic(() => import('~/components/Admin/Notifi
 
 
 const AdminGlobalNotificationNewPage: NextPage<CommonProps> = (props) => {
-  const { t } = useTranslation();
+  const { t } = useTranslation('admin');
 
   const title = t('external_notification.external_notification');
   const injectableContainers: Container<any>[] = [];

+ 9 - 0
packages/app/src/pages/admin/index.page.tsx

@@ -11,6 +11,7 @@ import { CrowiRequest } from '~/interfaces/crowi-request';
 import { CommonProps, useCustomTitle } from '~/pages/utils/commons';
 import PluginUtils from '~/server/plugins/plugin-utils';
 
+import { useGrowiCloudUri, useGrowiAppIdForGrowiCloud } from '../../stores/context';
 import { retrieveServerSideProps } from '../../utils/admin-page-util';
 
 const AdminLayout = dynamic(() => import('~/components/Layout/AdminLayout'), { ssr: false });
@@ -22,10 +23,16 @@ type Props = CommonProps & {
   npmVersion: string,
   yarnVersion: string,
   installedPlugins: any,
+  growiCloudUri: string,
+  growiAppIdForGrowiCloud: number,
 };
 
 
 const AdminHomePage: NextPage<Props> = (props) => {
+
+  useGrowiCloudUri(props.growiCloudUri);
+  useGrowiAppIdForGrowiCloud(props.growiAppIdForGrowiCloud);
+
   const { t } = useTranslation('admin');
 
   const title = t('wiki_management_home_page');
@@ -62,6 +69,8 @@ const injectServerConfigurations = async(context: GetServerSidePropsContext, pro
   props.npmVersion = crowi.runtimeVersions.versions.npm ? crowi.runtimeVersions.versions.npm.version.version : null;
   props.yarnVersion = crowi.runtimeVersions.versions.yarn ? crowi.runtimeVersions.versions.yarn.version.version : null;
   props.installedPlugins = pluginUtils.listPlugins();
+  props.growiCloudUri = await crowi.configManager.getConfig('crowi', 'app:growiCloudUri');
+  props.growiAppIdForGrowiCloud = await crowi.configManager.getConfig('crowi', 'app:growiAppIdForCloud');
 };
 
 

+ 15 - 4
packages/app/src/pages/admin/user-group-detail/[userGroupId].page.tsx

@@ -5,7 +5,9 @@ import { useTranslation } from 'next-i18next';
 import dynamic from 'next/dynamic';
 import { useRouter } from 'next/router';
 
+import { CrowiRequest } from '~/interfaces/crowi-request';
 import { CommonProps, useCustomTitle } from '~/pages/utils/commons';
+import { useIsAclEnabled } from '~/stores/context';
 import { useIsMaintenanceMode } from '~/stores/maintenanceMode';
 
 import { retrieveServerSideProps } from '../../../utils/admin-page-util';
@@ -13,8 +15,11 @@ import { retrieveServerSideProps } from '../../../utils/admin-page-util';
 const AdminLayout = dynamic(() => import('~/components/Layout/AdminLayout'), { ssr: false });
 const UserGroupDetailPage = dynamic(() => import('~/components/Admin/UserGroupDetail/UserGroupDetailPage'), { ssr: false });
 
+type Props = CommonProps & {
+  isAclEnabled: boolean
+}
 
-const AdminUserGroupDetailPage: NextPage<CommonProps> = (props) => {
+const AdminUserGroupDetailPage: NextPage<Props> = (props: Props) => {
   const { t } = useTranslation('admin');
   useIsMaintenanceMode(props.isMaintenanceMode);
   const router = useRouter();
@@ -23,9 +28,10 @@ const AdminUserGroupDetailPage: NextPage<CommonProps> = (props) => {
   const title = t('user_group_management.user_group_management');
   const customTitle = useCustomTitle(props, title);
 
-
   const currentUserGroupId = Array.isArray(userGroupId) ? userGroupId[0] : userGroupId;
 
+  useIsAclEnabled(props.isAclEnabled);
+
   return (
     <AdminLayout title={customTitle} componentTitle={title} >
       {
@@ -36,10 +42,15 @@ const AdminUserGroupDetailPage: NextPage<CommonProps> = (props) => {
   );
 };
 
+const injectServerConfigurations = async(context: GetServerSidePropsContext, props: Props): Promise<void> => {
+  const req: CrowiRequest = context.req as CrowiRequest;
+  props.isAclEnabled = req.crowi.aclService.isAclEnabled();
+};
+
 export const getServerSideProps: GetServerSideProps = async(context: GetServerSidePropsContext) => {
-  const props = await retrieveServerSideProps(context);
+  const props = await retrieveServerSideProps(context, injectServerConfigurations);
+
   return props;
 };
 
-
 export default AdminUserGroupDetailPage;

+ 1 - 5
packages/app/src/pages/installer.page.tsx

@@ -10,8 +10,7 @@ import { NoLoginLayout } from '~/components/Layout/NoLoginLayout';
 
 import InstallerForm from '../components/InstallerForm';
 import {
-  useCurrentPagePath, useCsrfToken,
-  useAppTitle, useSiteUrl, useConfidential,
+  useCsrfToken, useAppTitle, useSiteUrl, useConfidential,
 } from '../stores/context';
 
 
@@ -40,9 +39,6 @@ const InstallerPage: NextPage<Props> = (props: Props) => {
   useConfidential(props.confidential);
   useCsrfToken(props.csrfToken);
 
-  // page
-  useCurrentPagePath(props.currentPathname);
-
   const classNames: string[] = [];
 
   return (

+ 8 - 7
packages/app/src/pages/login.page.tsx

@@ -1,6 +1,5 @@
 import React from 'react';
 
-
 import {
   NextPage, GetServerSideProps, GetServerSidePropsContext,
 } from 'next';
@@ -9,19 +8,19 @@ import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
 import { NoLoginLayout } from '~/components/Layout/NoLoginLayout';
 import { LoginForm } from '~/components/LoginForm';
 import type { CrowiRequest } from '~/interfaces/crowi-request';
+import type { RegistrationMode } from '~/interfaces/registration-mode';
 
 import {
   useCsrfToken,
   useCurrentPathname,
 } from '../stores/context';
 
-
 import {
   CommonProps, getServerSideCommonProps, useCustomTitle, getNextI18NextConfig,
 } from './utils/commons';
 
 type Props = CommonProps & {
-
+  registrationMode: RegistrationMode,
   pageWithMetaStr: string,
   isMailerSetup: boolean,
   enabledStrategies: unknown,
@@ -29,6 +28,7 @@ type Props = CommonProps & {
   isLocalStrategySetup: boolean,
   isLdapStrategySetup: boolean,
   isLdapSetupFailed: boolean,
+  isPasswordResetEnabled: boolean,
   isEmailAuthenticationEnabled: boolean,
 };
 
@@ -45,16 +45,15 @@ const LoginPage: NextPage<Props> = (props: Props) => {
   return (
     <NoLoginLayout title={useCustomTitle(props, 'GROWI')} className={classNames.join(' ')}>
       <LoginForm
-        // Todo: These props should be set properly. https://redmine.weseek.co.jp/issues/104847
         objOfIsExternalAuthEnableds={props.enabledStrategies}
         isLocalStrategySetup={props.isLocalStrategySetup}
         isLdapStrategySetup={props.isLdapStrategySetup}
         isLdapSetupFailed={props.isLdapSetupFailed}
         isEmailAuthenticationEnabled={props.isEmailAuthenticationEnabled}
-        isRegistrationEnabled={true}
         registrationWhiteList={props.registrationWhiteList}
-        isPasswordResetEnabled={true}
-        isMailerSetup={true}
+        isPasswordResetEnabled={props.isPasswordResetEnabled}
+        isMailerSetup={props.isMailerSetup}
+        registrationMode={props.registrationMode}
       />
     </NoLoginLayout>
   );
@@ -100,12 +99,14 @@ async function injectServerConfigurations(context: GetServerSidePropsContext, pr
     passportService,
   } = crowi;
 
+  props.isPasswordResetEnabled = crowi.configManager.getConfig('crowi', 'security:passport-local:isPasswordResetEnabled');
   props.isMailerSetup = mailService.isMailerSetup;
   props.isLocalStrategySetup = passportService.isLocalStrategySetup;
   props.isLdapStrategySetup = passportService.isLdapStrategySetup;
   props.isLdapSetupFailed = configManager.getConfig('crowi', 'security:passport-ldap:isEnabled') && !props.isLdapStrategySetup;
   props.registrationWhiteList = configManager.getConfig('crowi', 'security:registrationWhiteList');
   props.isEmailAuthenticationEnabled = configManager.getConfig('crowi', 'security:passport-local:isEmailAuthenticationEnabled');
+  props.registrationMode = configManager.getConfig('crowi', 'security:registrationMode');
 }
 
 export const getServerSideProps: GetServerSideProps = async(context: GetServerSidePropsContext) => {

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

@@ -20,7 +20,7 @@ import { CrowiRequest } from '~/interfaces/crowi-request';
 import { RendererConfig } from '~/interfaces/services/renderer';
 import { IShareLinkHasId } from '~/interfaces/share-link';
 import {
-  useCurrentUser, useCurrentPagePath, useCurrentPathname, useCurrentPageId, useRendererConfig, useIsSearchPage,
+  useCurrentUser, useCurrentPathname, useCurrentPageId, useRendererConfig, useIsSearchPage,
   useShareLinkId, useIsSearchServiceConfigured, useIsSearchServiceReachable, useIsSearchScopeChildrenAsDefault,
 } from '~/stores/context';
 import { useDescendantsPageListModal } from '~/stores/modal';
@@ -50,7 +50,6 @@ const SharedPage: NextPage<Props> = (props: Props) => {
   useIsSearchPage(false);
   useShareLinkId(props.shareLink?._id);
   useCurrentPageId(props.shareLink?.relatedPage._id);
-  useCurrentPagePath(props.shareLink?.relatedPage.path);
   useCurrentUser(props.currentUser);
   useCurrentPathname(props.currentPathname);
   useRendererConfig(props.rendererConfig);

+ 1 - 2
packages/app/src/pages/trash.page.tsx

@@ -16,7 +16,7 @@ import {
 
 import { BasicLayout } from '../components/Layout/BasicLayout';
 import {
-  useCurrentUser, useCurrentPageId, useCurrentPagePath, useCurrentPathname,
+  useCurrentUser, useCurrentPageId, useCurrentPathname,
   useIsSearchServiceConfigured, useIsSearchServiceReachable,
   useIsSearchScopeChildrenAsDefault, useIsSearchPage, useShowPageLimitationXL, useIsGuestUser,
 } from '../stores/context';
@@ -52,7 +52,6 @@ const TrashPage: NextPage<CommonProps> = (props: Props) => {
   useIsSearchPage(false);
   useCurrentPageId(null);
   useCurrentPathname('/trash');
-  useCurrentPagePath('/trash');
 
   // UserUISettings
   usePreferDrawerModeByUser(props.userUISettings?.preferDrawerModeByUser ?? props.sidebarConfig.isSidebarDrawerMode);

+ 80 - 0
packages/app/src/pages/user-activation.page.tsx

@@ -0,0 +1,80 @@
+import { NextPage, GetServerSideProps, GetServerSidePropsContext } from 'next';
+import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
+
+import CompleteUserRegistrationForm from '~/components/CompleteUserRegistrationForm';
+import { NoLoginLayout } from '~/components/Layout/NoLoginLayout';
+import type { CrowiRequest } from '~/interfaces/crowi-request';
+import type { UserActivationErrorCode } from '~/interfaces/errors/user-activation';
+import type { RegistrationMode } from '~/interfaces/registration-mode';
+import { IUserRegistrationOrder } from '~/server/models/user-registration-order';
+
+import {
+  getServerSideCommonProps, getNextI18NextConfig, useCustomTitle, CommonProps,
+} from './utils/commons';
+
+type Props = CommonProps & {
+  token: string
+  email: string
+  errorCode?: UserActivationErrorCode
+  registrationMode: RegistrationMode
+  isEmailAuthenticationEnabled: boolean
+}
+
+const UserActivationPage: NextPage<Props> = (props: Props) => {
+  return (
+    <NoLoginLayout title={useCustomTitle(props, 'GROWI')}>
+      <CompleteUserRegistrationForm
+        token={props.token}
+        email={props.email}
+        errorCode={props.errorCode}
+        registrationMode={props.registrationMode}
+        isEmailAuthenticationEnabled={props.isEmailAuthenticationEnabled}
+      />
+    </NoLoginLayout>
+  );
+};
+
+/**
+ * for Server Side Translations
+ * @param context
+ * @param props
+ * @param namespacesRequired
+ */
+async function injectNextI18NextConfigurations(context: GetServerSidePropsContext, props: Props, namespacesRequired?: string[] | undefined): Promise<void> {
+  const nextI18NextConfig = await getNextI18NextConfig(serverSideTranslations, context, namespacesRequired);
+  props._nextI18Next = nextI18NextConfig._nextI18Next;
+}
+
+export const getServerSideProps: GetServerSideProps = async(context: GetServerSidePropsContext) => {
+  const result = await getServerSideCommonProps(context);
+  const req: CrowiRequest = context.req as CrowiRequest;
+
+  // check for presence
+  // see: https://github.com/vercel/next.js/issues/19271#issuecomment-730006862
+  if (!('props' in result)) {
+    throw new Error('invalid getSSP result');
+  }
+
+  const props: Props = result.props as Props;
+
+  if (context.query.userRegistrationOrder != null) {
+    const userRegistrationOrder = context.query.userRegistrationOrder as unknown as IUserRegistrationOrder;
+    props.email = userRegistrationOrder.email;
+    props.token = userRegistrationOrder.token;
+  }
+
+  if (typeof context.query.errorCode === 'string') {
+    props.errorCode = context.query.errorCode as UserActivationErrorCode;
+  }
+
+  props.registrationMode = req.crowi.configManager.getConfig('crowi', 'security:registrationMode');
+  props.isEmailAuthenticationEnabled = req.crowi.configManager.getConfig('crowi', 'security:passport-local:isEmailAuthenticationEnabled');
+
+  await injectNextI18NextConfigurations(context, props, ['translation']);
+
+  return {
+    props,
+  };
+};
+
+export default UserActivationPage;

+ 2 - 1
packages/app/src/pages/utils/commons.ts

@@ -40,6 +40,7 @@ export const getServerSideCommonProps: GetServerSideProps<CommonProps> = async(c
 
   // eslint-disable-next-line max-len, no-nested-ternary
   const redirectDestination = !isMaintenanceMode && currentPathname === '/maintenance' ? '/' : isMaintenanceMode && !currentPathname.match('/admin/*') && !(currentPathname === '/maintenance') ? '/maintenance' : null;
+  const isDefaultLogo = crowi.configManager.getConfig('crowi', 'customize:isDefaultLogo');
 
   const props: CommonProps = {
     namespacesRequired: ['translation'],
@@ -54,7 +55,7 @@ export const getServerSideCommonProps: GetServerSideProps<CommonProps> = async(c
     growiVersion: crowi.version,
     isMaintenanceMode,
     redirectDestination,
-    customizedLogoSrc: configManager.getConfig('crowi', 'customize:customizedLogoSrc'),
+    customizedLogoSrc: isDefaultLogo ? null : configManager.getConfig('crowi', 'customize:customizedLogoSrc'),
   };
 
   return { props };

+ 0 - 12
packages/app/src/pages/utils/layout.ts

@@ -1,12 +0,0 @@
-// Use `props.isContainerFluid` as default, `layoutSetting.isContainerFluid` as admin setting, `dataPageInfo.expandContentWidth` as each page's setting
-export const calcIsContainerFluid = (
-    isContainerFluidEachPage: boolean | undefined | null,
-    isContainerFluidDefault: boolean,
-    isContainerFluidAdmin: boolean | undefined,
-): boolean => {
-  const isContainerFluid = isContainerFluidEachPage == null
-    ? isContainerFluidAdmin ?? isContainerFluidDefault
-    : isContainerFluidEachPage ?? isContainerFluidAdmin ?? isContainerFluidDefault;
-
-  return isContainerFluid;
-};

+ 19 - 4
packages/app/src/server/middlewares/inject-user-registration-order-by-token-middleware.ts

@@ -1,19 +1,34 @@
+import { Request, Response, NextFunction } from 'express';
 import createError from 'http-errors';
 
-import UserRegistrationOrder from '../models/user-registration-order';
+import { UserActivationErrorCode } from '~/interfaces/errors/user-activation';
+import loggerFactory from '~/utils/logger';
 
-export default async(req, res, next): Promise<void> => {
+import UserRegistrationOrder, { IUserRegistrationOrder } from '../models/user-registration-order';
+
+const logger = loggerFactory('growi:routes:user-activation');
+
+export type ReqWithUserRegistrationOrder = Request & {
+  userRegistrationOrder: IUserRegistrationOrder
+};
+
+// eslint-disable-next-line import/no-anonymous-default-export
+export default async(req: ReqWithUserRegistrationOrder, res: Response, next: NextFunction): Promise<void> => {
   const token = req.params.token || req.body.token;
 
   if (token == null) {
-    return next(createError(400, 'Token not found', { code: 'token-not-found' }));
+    const msg = 'Token not found';
+    logger.error(msg);
+    return next(createError(400, msg, { code: UserActivationErrorCode.TOKEN_NOT_FOUND }));
   }
 
   const userRegistrationOrder = await UserRegistrationOrder.findOne({ token });
 
   // check if the token is valid
   if (userRegistrationOrder == null || userRegistrationOrder.isExpired() || userRegistrationOrder.isRevoked) {
-    return next(createError(400, 'userRegistrationOrder is null or expired or revoked', { code: 'password-reset-order-is-not-appropriate' }));
+    const msg = 'userRegistrationOrder is null or expired or revoked';
+    logger.error(msg);
+    return next(createError(400, msg, { code: UserActivationErrorCode.USER_REGISTRATION_ORDER_IS_NOT_APPROPRIATE }));
   }
 
   req.userRegistrationOrder = userRegistrationOrder;

+ 0 - 212
packages/app/src/server/routes/admin.js

@@ -88,212 +88,12 @@ module.exports = function(crowi, app) {
     return pager;
   }
 
-  actions.index = function(req, res) {
-    return res.render('admin/index');
-  };
-
-  // app.get('/admin/app'                  , admin.app.index);
-  actions.app = {};
-  actions.app.index = function(req, res) {
-    return res.render('admin/app');
-  };
-
-  actions.app.settingUpdate = function(req, res) {
-  };
-
-  // app.get('/admin/security'                  , admin.security.index);
-  actions.security = {};
-  actions.security.index = function(req, res) {
-
-    return res.render('admin/security');
-  };
-
-  // app.get('/admin/markdown'                  , admin.markdown.index);
-  actions.markdown = {};
-  actions.markdown.index = function(req, res) {
-    const markdownSetting = configManager.getConfigByPrefix('markdown', 'markdown:');
-
-    return res.render('admin/markdown', {
-      markdownSetting,
-      recommendedWhitelist,
-    });
-  };
-
-  // app.get('/admin/customize' , admin.customize.index);
-  actions.customize = {};
-  actions.customize.index = function(req, res) {
-    const settingForm = configManager.getConfigByPrefix('crowi', 'customize:');
-
-    return res.render('admin/customize', {
-      settingForm,
-    });
-  };
-
-  // app.get('/admin/notification'               , admin.notification.index);
-  actions.notification = {};
-  actions.notification.index = async(req, res) => {
-
-    return res.render('admin/notification');
-  };
-
-  // app.get('/admin/notification/slackAuth'     , admin.notification.slackauth);
-  actions.notification.slackAuth = function(req, res) {
-    const code = req.query.code;
-    const { t } = req;
-
-    if (!code || !slackIntegrationService.isSlackConfigured()) {
-      return res.redirect('/admin/notification');
-    }
-
-    const slack = crowi.slack;
-    slack.getOauthAccessToken(code)
-      .then(async(data) => {
-        debug('oauth response', data);
-
-        try {
-          await configManager.updateConfigsInTheSameNamespace('notification', { 'slack:token': data.access_token });
-          req.flash('successMessage', [t('message.successfully_connected')]);
-        }
-        catch (err) {
-          req.flash('errorMessage', [t('message.fail_to_save_access_token')]);
-        }
-
-        return res.redirect('/admin/notification');
-      })
-      .catch((err) => {
-        debug('oauth response ERROR', err);
-        req.flash('errorMessage', [t('message.fail_to_fetch_access_token')]);
-        return res.redirect('/admin/notification');
-      });
-  };
-
-  // app.post('/admin/notification/slackSetting/disconnect' , admin.notification.disconnectFromSlack);
-  actions.notification.disconnectFromSlack = async function(req, res) {
-    await configManager.updateConfigsInTheSameNamespace('notification', { 'slack:token': '' });
-    req.flash('successMessage', [req.t('successfully_disconnected')]);
-
-    return res.redirect('/admin/notification');
-  };
-
-  // actions.globalNotification = {};
-  // actions.globalNotification.detail = async(req, res) => {
-  //   const notificationSettingId = req.params.id;
-  //   let globalNotification;
-
-  //   if (notificationSettingId) {
-  //     try {
-  //       globalNotification = await GlobalNotificationSetting.findOne({ _id: notificationSettingId });
-  //     }
-  //     catch (err) {
-  //       logger.error(`Error in finding a global notification setting with {_id: ${notificationSettingId}}`);
-  //     }
-  //   }
-
-  //   return res.render('admin/global-notification-detail', { globalNotification });
-  // };
-
-  actions.search = {};
-  actions.search.index = function(req, res) {
-    return res.render('admin/search', {});
-  };
-
-  actions.user = {};
-  actions.user.index = async function(req, res) {
-    return res.render('admin/users');
-  };
-
-  actions.externalAccount = {};
-  actions.externalAccount.index = function(req, res) {
-    return res.render('admin/external-accounts');
-  };
-
-  actions.slackIntegrationLegacy = {};
-  actions.slackIntegrationLegacy = function(req, res) {
-    return res.render('admin/slack-integration-legacy');
-  };
-
-  actions.slackIntegration = {};
-  actions.slackIntegration = function(req, res) {
-    return res.render('admin/slack-integration');
-  };
-
-  actions.userGroup = {};
-  actions.userGroup.index = function(req, res) {
-    const page = parseInt(req.query.page) || 1;
-    const renderVar = {
-      userGroups: [],
-      userGroupRelations: new Map(),
-      pager: null,
-    };
-
-    UserGroup.findUserGroupsWithPagination({ page })
-      .then((result) => {
-        const pager = createPager(result.total, result.limit, result.page, result.pages, MAX_PAGE_LIST);
-        const userGroups = result.docs;
-        renderVar.userGroups = userGroups;
-        renderVar.pager = pager;
-        return userGroups.map((userGroup) => {
-          return new Promise((resolve, reject) => {
-            UserGroupRelation.findAllRelationForUserGroup(userGroup)
-              .then((relations) => {
-                return resolve({
-                  id: userGroup._id,
-                  relatedUsers: relations.map((relation) => {
-                    return relation.relatedUser;
-                  }),
-                });
-              });
-          });
-        });
-      })
-      .then((allRelationsPromise) => {
-        return Promise.all(allRelationsPromise);
-      })
-      .then((relations) => {
-        for (const relation of relations) {
-          renderVar.userGroupRelations[relation.id] = relation.relatedUsers;
-        }
-        debug('in findUserGroupsWithPagination findAllRelationForUserGroupResult', renderVar.userGroupRelations);
-        return res.render('admin/user-groups', renderVar);
-      })
-      .catch((err) => {
-        debug('Error on find all relations', err);
-        return res.json(ApiResponse.error('Error'));
-      });
-  };
-
-  // グループ詳細
-  // actions.userGroup.detail = async function(req, res) {
-  //   const userGroupId = req.params.id;
-  //   const userGroup = await UserGroup.findOne({ _id: userGroupId }).populate('parent');
-
-  //   if (userGroup == null) {
-  //     logger.error('no userGroup is exists. ', userGroupId);
-  //     return res.redirect('/admin/user-groups');
-  //   }
-
-  //   return res.render('admin/user-group-detail', { userGroup });
-  // };
-
-  // AuditLog
-  actions.auditLog = {};
-  actions.auditLog.index = (req, res) => {
-    return res.render('admin/audit-log');
-  };
-
   // Importer management
   actions.importer = {};
   actions.importer.api = api;
   api.validators = {};
   api.validators.importer = {};
 
-  actions.importer.index = function(req, res) {
-    const settingForm = configManager.getConfigByPrefix('crowi', 'importer:');
-    return res.render('admin/importer', {
-      settingForm,
-    });
-  };
-
   api.validators.importer.esa = function() {
     const validator = [
       check('importer:esa:team_name').not().isEmpty().withMessage('Error. Empty esa:team_name'),
@@ -316,10 +116,6 @@ module.exports = function(crowi, app) {
   actions.export.api = api;
   api.validators.export = {};
 
-  actions.export.index = (req, res) => {
-    return res.render('admin/export');
-  };
-
   api.validators.export.download = function() {
     const validator = [
       // https://regex101.com/r/mD4eZs/6
@@ -507,13 +303,5 @@ module.exports = function(crowi, app) {
     return res.json(ApiResponse.success());
   };
 
-  /*
-  * Use AdminNotFoundPage component instead
-  */
-  // actions.notFound = {};
-  // actions.notFound.index = function(req, res) {
-  //   return res.render('admin/not_found');
-  // };
-
   return actions;
 };

+ 1 - 3
packages/app/src/server/routes/apiv3/customize-setting.js

@@ -686,18 +686,16 @@ module.exports = (crowi) => {
   router.put('/customize-logo', loginRequiredStrictly, adminRequired, validator.logo, apiV3FormValidator, async(req, res) => {
 
     const {
-      isDefaultLogo, customizedLogoSrc,
+      isDefaultLogo,
     } = req.body;
 
     const requestParams = {
       'customize:isDefaultLogo': isDefaultLogo,
-      'customize:customizedLogoSrc': customizedLogoSrc,
     };
     try {
       await crowi.configManager.updateConfigsInTheSameNamespace('crowi', requestParams);
       const customizedParams = {
         isDefaultLogo: await crowi.configManager.getConfig('crowi', 'customize:isDefaultLogo'),
-        customizedLogoSrc: await crowi.configManager.getConfig('crowi', 'customize:customizedLogoSrc'),
       };
       return res.apiv3({ customizedParams });
     }

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

@@ -55,6 +55,8 @@ module.exports = (crowi, app, isInstalled) => {
   routerForAuth.post('/register',
     applicationInstalled, registerFormValidator.registerRules(), registerFormValidator.registerValidation, addActivity, login.register);
 
+  routerForAuth.post('/user-activation/register', applicationInstalled, userActivation.registerRules(),
+    userActivation.validateRegisterForm, userActivation.registerAction(crowi));
 
   // installer
   if (!isInstalled) {
@@ -95,6 +97,7 @@ module.exports = (crowi, app, isInstalled) => {
   router.get('/check-username', user.api.checkUsername);
 
   router.post('/complete-registration',
+    addActivity,
     injectUserRegistrationOrderByTokenMiddleware,
     userActivation.completeRegistrationRules(),
     userActivation.validateCompleteRegistration,

+ 156 - 20
packages/app/src/server/routes/apiv3/user-activation.ts

@@ -1,10 +1,19 @@
 import path from 'path';
 
 import { ErrorV3 } from '@growi/core';
-import * as express from 'express';
+import { format, subSeconds } from 'date-fns';
 import { body, validationResult } from 'express-validator';
 
+import { SupportedAction } from '~/interfaces/activity';
+import { RegistrationMode } from '~/interfaces/registration-mode';
+import UserRegistrationOrder from '~/server/models/user-registration-order';
+import loggerFactory from '~/utils/logger';
+
+
+const logger = loggerFactory('growi:routes:apiv3:user-activation');
+
 const PASSOWRD_MINIMUM_NUMBER = 8;
+
 // validation rules for complete registration form
 export const completeRegistrationRules = () => {
   return [
@@ -58,6 +67,7 @@ async function sendEmailToAllAdmins(userData, admins, appTitle, mailService, tem
 
 export const completeRegistrationAction = (crowi) => {
   const User = crowi.model('User');
+  const activityEvent = crowi.event('activity');
   const {
     configManager,
     aclService,
@@ -70,11 +80,16 @@ export const completeRegistrationAction = (crowi) => {
       return res.apiv3Err(new ErrorV3('You have been logged in', 'registration-failed'), 403);
     }
 
-    // config で closed ならさよなら
+    // error when registration is not allowed
     if (configManager.getConfig('crowi', 'security:registrationMode') === aclService.labels.SECURITY_REGISTRATION_MODE_CLOSED) {
       return res.apiv3Err(new ErrorV3('Registration closed', 'registration-failed'), 403);
     }
 
+    // error when email authentication is disabled
+    if (configManager.getConfig('crowi', 'security:passport-local:isEmailAuthenticationEnabled') !== true) {
+      return res.apiv3Err(new ErrorV3('Email authentication configuration is disabled', 'registration-failed'), 403);
+    }
+
     const { userRegistrationOrder } = req;
     const registerForm = req.body;
 
@@ -105,21 +120,26 @@ export const completeRegistrationAction = (crowi) => {
         return res.apiv3Err(new ErrorV3(errorMessage, 'registration-failed'), 403);
       }
 
-      if (configManager.getConfig('crowi', 'security:passport-local:isEmailAuthenticationEnabled') === true) {
-        User.createUserByEmailAndPassword(name, username, email, password, undefined, async(err, userData) => {
-          if (err) {
-            if (err.name === 'UserUpperLimitException') {
-              errorMessage = req.t('message.can_not_register_maximum_number_of_users');
-            }
-            else {
-              errorMessage = req.t('message.failed_to_register');
-            }
-            return res.apiv3Err(new ErrorV3(errorMessage, 'registration-failed'), 403);
+      User.createUserByEmailAndPassword(name, username, email, password, undefined, async(err, userData) => {
+        if (err) {
+          if (err.name === 'UserUpperLimitException') {
+            errorMessage = req.t('message.can_not_register_maximum_number_of_users');
+          }
+          else {
+            errorMessage = req.t('message.failed_to_register');
           }
+          return res.apiv3Err(new ErrorV3(errorMessage, 'registration-failed'), 403);
+        }
+
+        const parameters = { action: SupportedAction.ACTION_USER_REGISTRATION_SUCCESS };
+        activityEvent.emit('update', res.locals.activity._id, parameters);
 
-          userRegistrationOrder.revokeOneTimeToken();
+        userRegistrationOrder.revokeOneTimeToken();
 
-          if (configManager.getConfig('crowi', 'security:registrationMode') !== aclService.labels.SECURITY_REGISTRATION_MODE_RESTRICTED) {
+        if (configManager.getConfig('crowi', 'security:registrationMode') === aclService.labels.SECURITY_REGISTRATION_MODE_RESTRICTED) {
+          const isMailerSetup = mailService.isMailerSetup ?? false;
+
+          if (isMailerSetup) {
             const admins = await User.findAdmins();
             const appTitle = appService.getAppTitle();
             const template = path.join(crowi.localeDir, 'en_US/admin/userWaitingActivation.txt');
@@ -127,14 +147,130 @@ export const completeRegistrationAction = (crowi) => {
 
             sendEmailToAllAdmins(userData, admins, appTitle, mailService, template, url);
           }
+          // This 'completeRegistrationAction' should not be able to be called if the email settings is not set up in the first place.
+          // So this method dows not stop processing as an error, but only displays a warning. -- 2022.11.01 Yuki Takei
+          else {
+            logger.warn('E-mail Settings must be set up.');
+          }
+
+          return res.apiv3({});
+        }
+
+        req.login(userData, (err) => {
+          if (err) {
+            logger.debug(err);
+          }
+          else {
+            // update lastLoginAt
+            userData.updateLastLoginAt(new Date(), (err) => {
+              if (err) {
+                logger.error(`updateLastLoginAt dumps error: ${err}`);
+              }
+            });
+          }
 
-          req.flash('successMessage', req.t('message.successfully_created', { username }));
-          res.apiv3({ status: 'ok' });
+          // userData.password cann't be empty but, prepare redirect because password property in User Model is optional
+          // https://github.com/weseek/growi/pull/6670
+          const redirectTo = userData.password != null ? '/' : '/me#password';
+          return res.apiv3({ redirectTo });
         });
-      }
-      else {
-        return res.apiv3Err(new ErrorV3('Email authentication configuration is disabled', 'registration-failed'), 403);
-      }
+      });
     });
   };
 };
+
+// validation rules for registration form when email authentication enabled
+export const registerRules = () => {
+  return [
+    body('registerForm.email')
+      .isEmail()
+      .withMessage('Email format is invalid.')
+      .exists()
+      .withMessage('Email field is required.'),
+  ];
+};
+
+// middleware to validate register form if email authentication enabled
+export const validateRegisterForm = (req, res, next) => {
+  const errors = validationResult(req);
+  if (errors.isEmpty()) {
+    return next();
+  }
+
+  const extractedErrors: string[] = [];
+  errors.array().map(err => extractedErrors.push(err.msg));
+
+  return res.apiv3Err(extractedErrors, 400);
+};
+
+async function makeRegistrationEmailToken(email, crowi) {
+  const {
+    configManager,
+    mailService,
+    localeDir,
+    appService,
+  } = crowi;
+
+  const isMailerSetup = mailService.isMailerSetup ?? false;
+  if (!isMailerSetup) {
+    throw Error('mailService is not setup');
+  }
+
+  const grobalLang = configManager.getConfig('crowi', 'app:globalLang');
+  const i18n = grobalLang;
+  const appUrl = appService.getSiteUrl();
+
+  const userRegistrationOrder = await UserRegistrationOrder.createUserRegistrationOrder(email);
+  const grwTzoffsetSec = crowi.appService.getTzoffset() * 60;
+  const expiredAt = subSeconds(userRegistrationOrder.expiredAt, grwTzoffsetSec);
+  const formattedExpiredAt = format(expiredAt, 'yyyy/MM/dd HH:mm');
+  const url = new URL(`/user-activation/${userRegistrationOrder.token}`, appUrl);
+  const oneTimeUrl = url.href;
+  const txtFileName = 'userActivation';
+
+  return mailService.send({
+    to: email,
+    subject: '[GROWI] User Activation',
+    template: path.join(localeDir, `${i18n}/notifications/${txtFileName}.txt`),
+    vars: {
+      appTitle: appService.getAppTitle(),
+      email,
+      expiredAt: formattedExpiredAt,
+      url: oneTimeUrl,
+    },
+  });
+}
+
+export const registerAction = (crowi) => {
+  const User = crowi.model('User');
+
+  return async function(req, res) {
+    const registerForm = req.body.registerForm || {};
+    const email = registerForm.email;
+    const isRegisterableEmail = await User.isRegisterableEmail(email);
+    const registrationMode = crowi.configManager.getConfig('crowi', 'security:registrationMode') as RegistrationMode;
+    const isEmailValid = await User.isEmailValid(email);
+
+    if (registrationMode === RegistrationMode.CLOSED) {
+      return res.apiv3Err(['message.registration_closed'], 400);
+    }
+
+    if (!isRegisterableEmail) {
+      req.body.registerForm.email = email;
+      return res.apiv3Err(['message.email_address_is_already_registered'], 400);
+    }
+
+    if (!isEmailValid) {
+      return res.apiv3Err(['message.email_address_could_not_be_used'], 400);
+    }
+
+    try {
+      await makeRegistrationEmailToken(email, crowi);
+    }
+    catch (err) {
+      return res.apiv3Err(err);
+    }
+
+    return res.apiv3({ redirectTo: '/login#register' });
+  };
+};

Некоторые файлы не были показаны из-за большого количества измененных файлов