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

Merge branch 'master' into support/108202-refactor-page-wide-view

Yuken Tezuka 3 лет назад
Родитель
Сommit
fca08b5e24
90 измененных файлов с 905 добавлено и 1293 удалено
  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. 2 33
      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. 2 33
      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. 2 33
      packages/app/public/static/locales/zh_CN/translation.json
  11. 27 0
      packages/app/src/client/interfaces/global-notification.ts
  12. 1 1
      packages/app/src/components/Admin/App/AppSetting.jsx
  13. 1 1
      packages/app/src/components/Admin/App/AppSettingsPageContents.tsx
  14. 7 7
      packages/app/src/components/Admin/Common/AdminNavigation.jsx
  15. 5 7
      packages/app/src/components/Admin/Customize/CustomizeLayoutSetting.tsx
  16. 5 3
      packages/app/src/components/Admin/Customize/CustomizeThemeSetting.tsx
  17. 1 1
      packages/app/src/components/Admin/NotFoundPage.tsx
  18. 1 1
      packages/app/src/components/Admin/Notification/GlobalNotificationList.jsx
  19. 42 52
      packages/app/src/components/Admin/Notification/ManageGlobalNotification.tsx
  20. 2 2
      packages/app/src/components/Admin/Notification/NotificationDeleteModal.jsx
  21. 1 1
      packages/app/src/components/Admin/Security/GitHubSecuritySettingContents.jsx
  22. 1 1
      packages/app/src/components/Admin/Security/GoogleSecuritySettingContents.jsx
  23. 1 1
      packages/app/src/components/Admin/Security/LocalSecuritySettingContents.jsx
  24. 2 2
      packages/app/src/components/Admin/Security/OidcSecuritySettingContents.jsx
  25. 1 1
      packages/app/src/components/Admin/Security/SamlSecuritySettingContents.jsx
  26. 1 1
      packages/app/src/components/Admin/Security/TwitterSecuritySettingContents.jsx
  27. 1 1
      packages/app/src/components/Admin/SlackIntegration/ConfirmBotChangeModal.jsx
  28. 3 3
      packages/app/src/components/AlertSiteUrlUndefined.tsx
  29. 3 1
      packages/app/src/components/Common/ImageCropModal.tsx
  30. 113 87
      packages/app/src/components/CompleteUserRegistrationForm.tsx
  31. 1 1
      packages/app/src/components/InAppNotification/InAppNotificationDropdown.tsx
  32. 3 6
      packages/app/src/components/Layout/AdminLayout.tsx
  33. 27 5
      packages/app/src/components/LoginForm.tsx
  34. 1 1
      packages/app/src/components/Navbar/GlobalSearch.tsx
  35. 4 4
      packages/app/src/components/Navbar/PersonalDropdown.jsx
  36. 2 2
      packages/app/src/components/Page.tsx
  37. 5 2
      packages/app/src/components/Page/CopyDropdown.jsx
  38. 4 4
      packages/app/src/components/PageEditor/CodeMirrorEditor.jsx
  39. 9 1
      packages/app/src/components/PageEditor/HandsontableModal.jsx
  40. 24 0
      packages/app/src/components/PageEditor/HandsontableModal.module.scss
  41. 3 3
      packages/app/src/components/RevisionComparer/RevisionComparer.tsx
  42. 4 0
      packages/app/src/interfaces/admin.ts
  43. 6 0
      packages/app/src/interfaces/errors/user-activation.ts
  44. 2 5
      packages/app/src/pages/[[...path]].page.tsx
  45. 32 0
      packages/app/src/pages/admin/[...path].page.tsx
  46. 2 2
      packages/app/src/pages/admin/app.page.tsx
  47. 1 0
      packages/app/src/pages/admin/global-notification/[globalNotificationId].page.tsx
  48. 2 1
      packages/app/src/pages/admin/global-notification/new.page.tsx
  49. 9 0
      packages/app/src/pages/admin/index.page.tsx
  50. 1 1
      packages/app/src/pages/login.page.tsx
  51. 76 0
      packages/app/src/pages/user-activation.page.tsx
  52. 0 12
      packages/app/src/pages/utils/layout.ts
  53. 19 4
      packages/app/src/server/middlewares/inject-user-registration-order-by-token-middleware.ts
  54. 0 212
      packages/app/src/server/routes/admin.js
  55. 2 0
      packages/app/src/server/routes/apiv3/index.js
  56. 121 21
      packages/app/src/server/routes/apiv3/user-activation.ts
  57. 1 1
      packages/app/src/server/routes/apiv3/users.js
  58. 5 44
      packages/app/src/server/routes/index.js
  59. 9 1
      packages/app/src/server/routes/login.js
  60. 26 110
      packages/app/src/server/routes/user-activation.ts
  61. 0 15
      packages/app/src/server/views/admin/app.html
  62. 0 11
      packages/app/src/server/views/admin/audit-log.html
  63. 0 14
      packages/app/src/server/views/admin/customize.html
  64. 0 11
      packages/app/src/server/views/admin/export.html
  65. 0 11
      packages/app/src/server/views/admin/external-accounts.html
  66. 0 12
      packages/app/src/server/views/admin/global-notification-detail.html
  67. 0 11
      packages/app/src/server/views/admin/importer.html
  68. 0 11
      packages/app/src/server/views/admin/index.html
  69. 0 11
      packages/app/src/server/views/admin/markdown.html
  70. 0 8
      packages/app/src/server/views/admin/not_found.html
  71. 0 11
      packages/app/src/server/views/admin/notification.html
  72. 0 11
      packages/app/src/server/views/admin/search.html
  73. 0 11
      packages/app/src/server/views/admin/security.html
  74. 0 12
      packages/app/src/server/views/admin/slack-integration-legacy.html
  75. 0 11
      packages/app/src/server/views/admin/slack-integration.html
  76. 0 15
      packages/app/src/server/views/admin/user-group-detail.html
  77. 0 11
      packages/app/src/server/views/admin/user-groups.html
  78. 0 11
      packages/app/src/server/views/admin/users.html
  79. 0 37
      packages/app/src/server/views/layout/admin.html
  80. 14 14
      packages/app/src/stores/admin/customize.tsx
  81. 59 89
      packages/app/src/stores/context.tsx
  82. 2 2
      packages/app/src/stores/global-notification.ts
  83. 24 0
      packages/app/src/stores/use-context-swr.tsx
  84. 0 35
      packages/app/src/styles/_handsontable.scss
  85. 1 1
      packages/app/src/styles/style-app.scss
  86. 19 0
      packages/app/src/styles/style-next.scss
  87. 9 0
      packages/app/test/cypress/integration/20-basic-features/access-to-page.spec.ts
  88. 6 3
      packages/app/test/cypress/integration/20-basic-features/click-page-icons.spec.ts
  89. 12 6
      packages/app/test/cypress/integration/50-sidebar/access-to-side-bar.spec.ts
  90. 5 1
      packages/app/test/cypress/integration/60-home/home.spec.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."
   }
 }

+ 2 - 33
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,7 +625,6 @@
       "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",
@@ -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": "このページは存在しません。"
   }
 }

+ 2 - 33
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,7 +619,6 @@
       "error_duplicate_pages_found": "同名のパスを持つページが複数見つかりました。リネームまたは削除してから再度実行してください"
     }
   },
-  "to_cloud_settings": "GROWI.cloud の管理画面へ",
   "login": {
     "Sign in error": "ログインエラー",
     "Registration successful": "登録完了",
@@ -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": "该页面不存在"
   }
 }

+ 2 - 33
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,7 +627,6 @@
       "error_duplicate_pages_found": "发现多个具有相同路径名称的页面。请重新命名或删除并重试。"
     }
   },
-	"to_cloud_settings": "進入 GROWI.cloud 的管理界面",
 	"login": {
 		"Sign in error": "登录错误",
 		"Registration successful": "注册成功",
@@ -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: 'email',
+  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[],
+};

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

+ 5 - 7
packages/app/src/components/Admin/Customize/CustomizeLayoutSetting.tsx

@@ -1,9 +1,8 @@
-import React, { useCallback, useEffect, useState } from 'react';
+import React, { useCallback, 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';
 
@@ -11,21 +10,20 @@ const CustomizeLayoutSetting = (): JSX.Element => {
   const { t } = useTranslation('admin');
 
   const { resolvedTheme } = useNextThemes();
-  const { data: layoutSetting, mutate: mutateLayoutSetting } = useSWRxLayoutSetting();
+  const { data: layoutSetting, update: updateLayoutSetting } = useSWRxLayoutSetting();
 
   const [isContainerFluid, setIsContainerFluid] = useState<boolean>(layoutSetting?.isContainerFluid ?? false);
   const [retrieveError, setRetrieveError] = useState<any>();
 
-  const onClickSubmit = async() => {
+  const onClickSubmit = useCallback(async() => {
     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]);
 
   return (
     <React.Fragment>

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

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

@@ -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} />;
 };

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

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

+ 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

+ 113 - 87
packages/app/src/components/CompleteUserRegistrationForm.tsx

@@ -1,30 +1,36 @@
-import React, { useState, useEffect } from 'react';
+import React, { useState, useEffect, useCallback } from 'react';
+
 import { useTranslation } from 'next-i18next';
+
 import { apiv3Get, apiv3Post } from '~/client/util/apiv3-client';
+import { UserActivationErrorCode } from '~/interfaces/errors/user-activation';
 
 import { toastSuccess, toastError } from '../client/util/apiNotification';
 
 interface Props {
-  messageErrors?: any,
-  inputs?: any,
   email: string,
   token: string,
+  errorCode?: UserActivationErrorCode,
+  isEmailAuthenticationEnabled: boolean,
 }
 
 const CompleteUserRegistrationForm: React.FC<Props> = (props: Props) => {
 
   const { t } = useTranslation();
   const {
-    messageErrors,
     email,
     token,
+    errorCode,
+    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);
 
   useEffect(() => {
     const delayDebounceFn = setTimeout(async() => {
@@ -42,7 +48,8 @@ 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', {
@@ -55,91 +62,110 @@ const CompleteUserRegistrationForm: React.FC<Props> = (props: Props) => {
       toastError(err, 'Registration failed');
       setDisableForm(false);
     }
-  }
+  }, [name, password, token, username]);
 
   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 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">
-            <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>
-
-          <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>
     </>
   );

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

+ 27 - 5
packages/app/src/components/LoginForm.tsx

@@ -49,6 +49,10 @@ 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 [isSuccessToSendRegistrationOrderEmail, setIsSuccessToSendRegistrationOrderEmail] = useState(false);
+
 
   useEffect(() => {
     const { hash } = window.location;
@@ -261,6 +265,8 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
 
   const handleRegisterFormSubmit = useCallback(async(e, requestPath) => {
     e.preventDefault();
+    setEmailForRegistrationOrder('');
+    setIsSuccessToSendRegistrationOrderEmail(false);
 
     const registerForm = {
       username: usernameForRegister,
@@ -272,6 +278,11 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
       const res = await apiv3Post(requestPath, { registerForm });
       const { redirectTo } = res.data;
       router.push(redirectTo ?? '/');
+
+      if (isEmailAuthenticationEnabled) {
+        setEmailForRegistrationOrder(emailForRegister);
+        setIsSuccessToSendRegistrationOrderEmail(true);
+      }
     }
     catch (err) {
       // Execute if error exists
@@ -280,7 +291,7 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
       }
     }
     return;
-  }, [emailForRegister, nameForRegister, passwordForRegister, router, usernameForRegister]);
+  }, [emailForRegister, nameForRegister, passwordForRegister, router, usernameForRegister, isEmailAuthenticationEnabled]);
 
   const resetRegisterErrors = useCallback(() => {
     if (registerErrors.length === 0) return;
@@ -313,7 +324,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 +342,14 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
           )
         }
 
+        {
+          (isEmailAuthenticationEnabled && isSuccessToSendRegistrationOrderEmail) && (
+            <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 +400,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 +472,11 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
         </div>
       </React.Fragment>
     );
-  }, [handleRegisterFormSubmit, isEmailAuthenticationEnabled, isMailerSetup,
-      props.email, props.name, props.username,
-      registerErrors, registrationMode, registrationWhiteList, switchForm, t]);
+  }, [
+    handleRegisterFormSubmit, isEmailAuthenticationEnabled, isMailerSetup,
+    isSuccessToSendRegistrationOrderEmail, props.email, props.name, props.username,
+    registerErrors, registrationMode, registrationWhiteList, emailForRegistrationOrder, switchForm, t,
+  ]);
 
   return (
     <div className="noLogin-dialog mx-auto" id="noLogin-dialog">

+ 1 - 1
packages/app/src/components/Navbar/GlobalSearch.tsx

@@ -22,7 +22,7 @@ export type GlobalSearchProps = {
 }
 
 export const GlobalSearch = (props: GlobalSearchProps): JSX.Element => {
-  const { t } = useTranslation();
+  const { t } = useTranslation('commons');
 
   const { dropup } = props;
 

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

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

@@ -36,7 +36,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 +188,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>
           )}

+ 4 - 4
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()));
   }
 
 
@@ -1135,11 +1135,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>
     );
   }

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

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

@@ -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>

+ 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];

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

@@ -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,
 } from '../stores/context';
 
 import {
   CommonProps, getNextI18NextConfig, getServerSideCommonProps, useCustomTitle,
 } from './utils/commons';
-import { calcIsContainerFluid } from './utils/layout';
 // import { useCurrentPageSWR } from '../stores/page';
 
 
@@ -250,7 +249,6 @@ const GrowiPage: NextPage<Props> = (props: Props) => {
   const { data: grantData } = useSWRxIsGrantNormalized(pageId);
   const { mutate: mutateSelectedGrant } = useSelectedGrant();
 
-  const { data: layoutSetting } = useLayoutSetting({ isContainerFluid: props.isContainerFluid });
   const { getClassNamesByEditorMode } = useEditorMode();
 
   const shouldRenderPutbackPageModal = pageWithMeta != null
@@ -281,8 +279,7 @@ const GrowiPage: NextPage<Props> = (props: Props) => {
     ? null
     : dataPageInfo.expandContentWidth;
   const isContainerFluidDefault = props.isContainerFluid;
-  const isContainerFluidAdmin = layoutSetting?.isContainerFluid;
-  const isContainerFluid = calcIsContainerFluid(isContainerFluidEachPage, isContainerFluidDefault, isContainerFluidAdmin);
+  const isContainerFluid = isContainerFluidEachPage ?? isContainerFluidDefault;
 
   return (
     <>

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

+ 1 - 1
packages/app/src/pages/login.page.tsx

@@ -54,7 +54,7 @@ const LoginPage: NextPage<Props> = (props: Props) => {
         isRegistrationEnabled={true}
         registrationWhiteList={props.registrationWhiteList}
         isPasswordResetEnabled={true}
-        isMailerSetup={true}
+        isMailerSetup={props.isMailerSetup}
       />
     </NoLoginLayout>
   );

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

@@ -0,0 +1,76 @@
+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 { IUserRegistrationOrder } from '~/server/models/user-registration-order';
+
+import {
+  getServerSideCommonProps, getNextI18NextConfig, useCustomTitle, CommonProps,
+} from './utils/commons';
+
+type Props = CommonProps & {
+  token: string
+  email: string
+  errorCode?: UserActivationErrorCode
+  isEmailAuthenticationEnabled: boolean
+}
+
+const UserActivationPage: NextPage<Props> = (props: Props) => {
+  return (
+    <NoLoginLayout title={useCustomTitle(props, 'GROWI')}>
+      <CompleteUserRegistrationForm
+        token={props.token}
+        email={props.email}
+        errorCode={props.errorCode}
+        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.isEmailAuthenticationEnabled = req.crowi.configManager.getConfig('crowi', 'security:passport-local:isEmailAuthenticationEnabled');
+
+  await injectNextI18NextConfigurations(context, props, ['translation']);
+
+  return {
+    props,
+  };
+};
+
+export default UserActivationPage;

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

+ 2 - 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) {

+ 121 - 21
packages/app/src/server/routes/apiv3/user-activation.ts

@@ -1,10 +1,16 @@
 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 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 [
@@ -70,11 +76,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 +116,23 @@ 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);
+        }
 
-          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 +140,101 @@ 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.');
+          }
+        }
 
-          req.flash('successMessage', req.t('message.successfully_created', { username }));
-          res.apiv3({ status: 'ok' });
-        });
-      }
-      else {
-        return res.apiv3Err(new ErrorV3('Email authentication configuration is disabled', 'registration-failed'), 403);
-      }
+        res.apiv3({ status: 'ok' });
+      });
     });
   };
 };
+
+// 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);
+
+    if (!isRegisterableEmail) {
+      req.body.registerForm.email = email;
+      return res.apiv3Err(['message.email_address_is_already_registered'], 400);
+    }
+
+    try {
+      await makeRegistrationEmailToken(email, crowi);
+    }
+    catch (err) {
+      return res.apiv3Err(err);
+    }
+
+    return res.apiv3({ redirectTo: '/login#register' });
+  };
+};

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

@@ -828,7 +828,7 @@ module.exports = (crowi) => {
    *                      type: object
    *                      description: Target user
    */
-  router.put('/reset-password', loginRequiredStrictly, adminRequired, async(req, res) => {
+  router.put('/reset-password', loginRequiredStrictly, adminRequired, addActivity, async(req, res) => {
     const { id } = req.body;
 
     try {

+ 5 - 44
packages/app/src/server/routes/index.js

@@ -84,12 +84,11 @@ module.exports = function(crowi, app) {
 
   app.get('/register'                 , applicationInstalled, login.preLogin, login.register);
 
-  // load before "/admin/*"
-  app.get('/admin/export/:fileName'             , loginRequiredStrictly , adminRequired ,admin.export.api.validators.export.download(), admin.export.download);
+  // NOTE: get method "/admin/export/:fileName" should be loaded before "/admin/*"
+  app.get('/admin/export/:fileName'   , loginRequiredStrictly , adminRequired ,admin.export.api.validators.export.download(), admin.export.download);
 
   app.get('/admin/*'                    , applicationInstalled, loginRequiredStrictly , adminRequired , next.delegateToNext);
   app.get('/admin'                    , applicationInstalled, loginRequiredStrictly , adminRequired , next.delegateToNext);
-  // app.get('/admin/app'                , applicationInstalled, loginRequiredStrictly , adminRequired , admin.app.index);
 
   // installer
   if (!isInstalled) {
@@ -112,40 +111,7 @@ module.exports = function(crowi, app) {
 
   app.post('/_api/login/testLdap'    , loginRequiredStrictly , loginFormValidator.loginRules() , loginFormValidator.loginValidation , loginPassport.testLdapCredentials);
 
-  // security admin
-  // app.get('/admin/security'          , loginRequiredStrictly , adminRequired , admin.security.index);
-
-  // markdown admin
-  // app.get('/admin/markdown'          , loginRequiredStrictly , adminRequired , admin.markdown.index);
-
-  // customize admin
-  // app.get('/admin/customize'         , loginRequiredStrictly , adminRequired , admin.customize.index);
-
-  // search admin
-  // app.get('/admin/search'            , loginRequiredStrictly , adminRequired , admin.search.index);
-
-  // notification admin
-  // app.get('/admin/notification'                         , loginRequiredStrictly , adminRequired , admin.notification.index);
-  // app.get('/admin/notification/slackAuth'               , loginRequiredStrictly , adminRequired , admin.notification.slackAuth);
-  // app.get('/admin/notification/slackSetting/disconnect' , loginRequiredStrictly , adminRequired , admin.notification.disconnectFromSlack);
-  // app.get('/admin/global-notification/new'              , loginRequiredStrictly , adminRequired , admin.globalNotification.detail);
-  // app.get('/admin/global-notification/:id'              , loginRequiredStrictly , adminRequired , admin.globalNotification.detail);
-  // app.get('/admin/slack-integration-legacy'             , loginRequiredStrictly , adminRequired,  admin.slackIntegrationLegacy);
-  // app.get('/admin/slack-integration'                    , loginRequiredStrictly , adminRequired,  admin.slackIntegration);
-
-  // app.get('/admin/users'                                , loginRequiredStrictly , adminRequired , admin.user.index);
-
-  // app.get('/admin/users/external-accounts'              , loginRequiredStrictly , adminRequired , admin.externalAccount.index);
-
-  // user-groups admin
-  // app.get('/admin/user-groups'                          , loginRequiredStrictly, adminRequired, admin.userGroup.index);
-  // app.get('/admin/user-group-detail/:id'                , loginRequiredStrictly, adminRequired, admin.userGroup.detail);
-
-  // auditLog admin
-  // app.get('/admin/audit-log'                            , loginRequiredStrictly, adminRequired, admin.auditLog.index);
-
   // importer management for admin
-  // app.get('/admin/importer'                     , loginRequiredStrictly , adminRequired , admin.importer.index);
   app.post('/_api/admin/settings/importerEsa'   , loginRequiredStrictly , adminRequired , csrfProtection, addActivity, admin.importer.api.validators.importer.esa(),admin.api.importerSettingEsa);
   app.post('/_api/admin/settings/importerQiita' , loginRequiredStrictly , adminRequired , csrfProtection, addActivity, admin.importer.api.validators.importer.qiita(), admin.api.importerSettingQiita);
   app.post('/_api/admin/import/esa'             , loginRequiredStrictly , adminRequired , csrfProtection, addActivity, admin.api.importDataFromEsa);
@@ -153,11 +119,6 @@ module.exports = function(crowi, app) {
   app.post('/_api/admin/import/qiita'           , loginRequiredStrictly , adminRequired , csrfProtection, addActivity, admin.api.importDataFromQiita);
   app.post('/_api/admin/import/testQiitaAPI'    , loginRequiredStrictly , adminRequired , csrfProtection, addActivity, admin.api.testQiitaAPI);
 
-  // export management for admin
-  // app.get('/admin/export'                       , loginRequiredStrictly , adminRequired ,admin.export.index);
-
-  // app.get('/admin/*'                            , loginRequiredStrictly ,adminRequired, admin.notFound.index);
-
   /*
    * Routes below are unavailable when maintenance mode
    */
@@ -237,10 +198,10 @@ module.exports = function(crowi, app) {
     .use(forgotPassword.handleErrorsMiddleware(crowi)));
 
   app.get('/_private-legacy-pages', next.delegateToNext);
+
   app.use('/user-activation', express.Router()
-    .get('/:token', applicationInstalled, injectUserRegistrationOrderByTokenMiddleware, userActivation.form)
-    .use(userActivation.tokenErrorHandlerMiddeware));
-  app.post('/user-activation/register', applicationInstalled, csrfProtection, userActivation.registerRules(), userActivation.validateRegisterForm, userActivation.registerAction(crowi));
+    .get('/:token', applicationInstalled, injectUserRegistrationOrderByTokenMiddleware, userActivation.renderUserActivationPage(crowi))
+    .use(userActivation.tokenErrorHandlerMiddeware(crowi)));
 
   app.get('/share/:linkId', next.delegateToNext);
 

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

@@ -1,5 +1,6 @@
 import { SupportedAction } from '~/interfaces/activity';
 import loggerFactory from '~/utils/logger';
+
 // disable all of linting
 // because this file is a deprecated legacy of Crowi
 
@@ -147,6 +148,13 @@ module.exports = function(crowi, app) {
         return res.apiv3Err(errors, 400);
       }
 
+      const registrationMode = configManager.getConfig('crowi', 'security:registrationMode');
+      const isMailerSetup = mailService.isMailerSetup ?? false;
+
+      if (!isMailerSetup && registrationMode === aclService.labels.SECURITY_REGISTRATION_MODE_RESTRICTED) {
+        return res.apiv3Err(['email_settings_is_not_setup'], 403);
+      }
+
       User.createUserByEmailAndPassword(name, username, email, password, undefined, async(err, userData) => {
         if (err) {
           const errors = [];
@@ -159,7 +167,7 @@ module.exports = function(crowi, app) {
           return res.apiv3Err(errors, 405);
         }
 
-        if (configManager.getConfig('crowi', 'security:registrationMode') !== aclService.labels.SECURITY_REGISTRATION_MODE_RESTRICTED) {
+        if (registrationMode === aclService.labels.SECURITY_REGISTRATION_MODE_RESTRICTED) {
           // send mail asynchronous
           sendEmailToAllAdmins(userData);
         }

+ 26 - 110
packages/app/src/server/routes/user-activation.ts

@@ -1,121 +1,37 @@
-import path from 'path';
+import { Response, NextFunction } from 'express';
 
-import { format, subSeconds } from 'date-fns';
-import { body, validationResult } from 'express-validator';
+import type { UserActivationErrorCode } from '~/interfaces/errors/user-activation';
+import { ReqWithUserRegistrationOrder } from '~/server/middlewares/inject-user-registration-order-by-token-middleware';
 
-import UserRegistrationOrder from '../models/user-registration-order';
-
-export const form = (req, res): void => {
-  const { userRegistrationOrder } = req;
-  return res.render('user-activation', { userRegistrationOrder });
-};
-
-async function makeRegistrationEmailToken(email, crowi) {
-  const {
-    configManager,
-    mailService,
-    localeDir,
-    appService,
-  } = crowi;
-
-  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,
-    },
-  });
+type Crowi = {
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+  nextApp: any,
 }
 
-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);
-
-    if (!isRegisterableEmail) {
-      req.body.registerForm.email = email;
-      req.flash('registerWarningMessage', req.t('message.email_address_is_already_registered'));
-      req.flash('email', email);
-
-      return res.redirect('/login#register');
-    }
-
-    makeRegistrationEmailToken(email, crowi);
-
-    req.flash('successMessage', req.t('message.successfully_send_email_auth', { email }));
+type CrowiReq = ReqWithUserRegistrationOrder & {
+  crowi: Crowi,
+}
 
-    return res.redirect('/login');
+export const renderUserActivationPage = (crowi: Crowi) => {
+  return (req: CrowiReq, res: Response): void => {
+    const { userRegistrationOrder } = req;
+    const { nextApp } = crowi;
+    req.crowi = crowi;
+    nextApp.render(req, res, '/user-activation', { userRegistrationOrder });
+    return;
   };
 };
 
 // middleware to handle error
-export const tokenErrorHandlerMiddeware = (err, req, res, next) => {
-  if (err != null) {
-    req.flash('errorMessage', req.t('message.incorrect_token_or_expired_url'));
-    return res.redirect('/login#register');
-  }
-  next();
-};
-
-// 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 complete registration form
-export const validateCompleteRegistrationForm = (req, res, next) => {
-  const errors = validationResult(req);
-  if (errors.isEmpty()) {
-    return next();
-  }
-
-  const extractedErrors: string[] = [];
-  errors.array().map(err => extractedErrors.push(err.msg));
-
-  req.flash('errors', extractedErrors);
-  req.flash('inputs', req.body);
-
-  const token = req.body.token;
-  return res.redirect(`/user-activation/${token}`);
-};
-
-// middleware to validate register form if email authentication enabled
-export const validateRegisterForm = (req, res, next) => {
-  const errors = validationResult(req);
-  if (errors.isEmpty()) {
-    return next();
-  }
-
-  req.form = { isValid: false };
-  const extractedErrors: string[] = [];
-  errors.array().map(err => extractedErrors.push(err.msg));
-
-  req.flash('registerWarningMessage', extractedErrors);
+export const tokenErrorHandlerMiddeware = (crowi: Crowi) => {
+  return (error: Error & { code: UserActivationErrorCode, statusCode: number }, req: CrowiReq, res: Response, next: NextFunction): void => {
+    if (error != null) {
+      const { nextApp } = crowi;
+      req.crowi = crowi;
+      nextApp.render(req, res, '/user-activation', { errorCode: error.code });
+      return;
+    }
 
-  res.redirect('back');
+    next();
+  };
 };

+ 0 - 15
packages/app/src/server/views/admin/app.html

@@ -1,15 +0,0 @@
-{% extends '../layout/admin.html' %}
-
-{% block html_title %}{{ customizeService.generateCustomTitleForFixedPageName(t('app_settings')) }}{% endblock %}
-
-{% block head_warn_alert_siteurl_undefined %} {# remove including block for './widget/alert_siteurl_undefined.html' #}
-{% endblock %}
-
-{% block content_header %}
-<h1 class="title">{{ t('app_settings') }}</h1>
-{% endblock %}
-
-
-{% block content_main %}
-  <div id="admin-app"></div>
-{% endblock content_main %}

+ 0 - 11
packages/app/src/server/views/admin/audit-log.html

@@ -1,11 +0,0 @@
-{% extends '../layout/admin.html' %}
-
-{% block html_title %}{{ customizeService.generateCustomTitleForFixedPageName(t('audit_log_management.audit_log')) }}{% endblock %}
-
-{% block content_header %}
-<h1 class="title">{{ t('audit_log_management.audit_log') }}</h1>
-{% endblock %}
-
-{% block content_main %}
-<div id ="admin-audit-log"></div>
-{% endblock content_main %}

+ 0 - 14
packages/app/src/server/views/admin/customize.html

@@ -1,14 +0,0 @@
-{% extends '../layout/admin.html' %}
-
-{% block html_title %}{{ customizeService.generateCustomTitleForFixedPageName(t('admin:customize')) }}{% endblock %}
-
-{% block content_header %}
-<h1 class="title">{{ t('admin:customize') }}</h1>
-{% endblock %}
-
-{% block content_main %}
-<div id="grw-hljs-container-for-demo">
-  {{ cdnHighlightJsStyleTag(getConfig('crowi', 'customize:highlightJsStyle')) }}
-</div>
-<div id="admin-customize" class="admin-customize"></div>
-{% endblock content_main %}

+ 0 - 11
packages/app/src/server/views/admin/export.html

@@ -1,11 +0,0 @@
-{% extends '../layout/admin.html' %}
-
-{% block html_title %}{{ customizeService.generateCustomTitleForFixedPageName(t('export_archive_data')) }}{% endblock %}
-
-{% block content_header %}
-<h1 class="title">{{ t('export_archive_data') }}</h1>
-{% endblock %}
-
-{% block content_main %}
-<div id="admin-export-page" class="admin-export"></div>
-{% endblock content_main %}

+ 0 - 11
packages/app/src/server/views/admin/external-accounts.html

@@ -1,11 +0,0 @@
-{% extends '../layout/admin.html' %}
-
-{% block html_title %}{{ customizeService.generateCustomTitleForFixedPageName(t('external_account_management')) }}{% endblock %}
-
-{% block content_header %}
-<h1 class="title">{{ t('user_management.user_management') }} / {{ t('external_account_management') }}</h1>
-{% endblock %}
-
-{% block content_main %}
-<div id="admin-external-account-setting"></div>
-{% endblock content_main %}

+ 0 - 12
packages/app/src/server/views/admin/global-notification-detail.html

@@ -1,12 +0,0 @@
-<!-- {% extends '../layout/admin.html' %}
-
-{% block html_title %}{{ customizeService.generateCustomTitleForFixedPageName(t('external_notification.external_notification')) }}{% endblock %}
-
-{% block content_header %}
-<h1 class="title">{{ t('external_notification.external_notification') }}</h1>
-{% endblock %}
-
-{% block content_main %}
-<div id="admin-global-notification-setting"
-    data-global-notification="{{ globalNotification|json }}"></div>
-{% endblock content_main %} -->

+ 0 - 11
packages/app/src/server/views/admin/importer.html

@@ -1,11 +0,0 @@
-{% extends '../layout/admin.html' %}
-
-{% block html_title %}{{ customizeService.generateCustomTitleForFixedPageName(t('importer_management.import_data')) }}{% endblock %}
-
-{% block content_header %}
-<h1 class="title">{{ t('importer_management.import_data') }}</h1>
-{% endblock %}
-
-{% block content_main %}
-<div id="admin-importer" class="admin-importer"></div>
-{% endblock content_main %}

+ 0 - 11
packages/app/src/server/views/admin/index.html

@@ -1,11 +0,0 @@
-{% extends '../layout/admin.html' %}
-
-{% block html_title %}{{ customizeService.generateCustomTitleForFixedPageName(t('Wiki Management Home Page')) }}{% endblock %}
-
-{% block content_header %}
-<h1 class="title"> {{ t('Wiki Management Home Page') }}</h1>
-{% endblock %}
-
-{% block content_main %}
-<div id="admin-home"></div>
-{% endblock content_main %}

+ 0 - 11
packages/app/src/server/views/admin/markdown.html

@@ -1,11 +0,0 @@
-{% extends '../layout/admin.html' %}
-
-{% block html_title %}{{ customizeService.generateCustomTitleForFixedPageName(t('markdown_settings.markdown_settings')) }}{% endblock %}
-
-{% block content_header %}
-<h1 class="title">{{ t('markdown_settings.markdown_settings') }}</h1>
-{% endblock %}
-
-{% block content_main %}
-<div id="admin-markdown-setting"></div>
-{% endblock content_main %}

+ 0 - 8
packages/app/src/server/views/admin/not_found.html

@@ -1,8 +0,0 @@
-<!-- Use AdminNotFoundPage component instead -->
-<!-- {% extends '../layout/admin.html' %}
-
-{% block html_title %}{{ customizeService.generateCustomTitleForFixedPageName(t('not_found_page.page_not_exist')) }}{% endblock %}
-
-{% block content_main %}
-<h1 class="title">{{ t('not_found_page.page_not_exist') }}</h1>
-{% endblock content_main %} -->

+ 0 - 11
packages/app/src/server/views/admin/notification.html

@@ -1,11 +0,0 @@
-{% extends '../layout/admin.html' %}
-
-{% block html_title %}{{ customizeService.generateCustomTitleForFixedPageName(t('external_notification.external_notification')) }}{% endblock %}
-
-{% block content_header %}
-<h1 class="title">{{ t('external_notification.external_notification') }}</h1>
-{% endblock %}
-
-{% block content_main %}
-<div id="admin-notification-setting" class="admin-notification"></div>
-{% endblock content_main %}

+ 0 - 11
packages/app/src/server/views/admin/search.html

@@ -1,11 +0,0 @@
-{% extends '../layout/admin.html' %}
-
-{% block html_title %}{{ customizeService.generateCustomTitleForFixedPageName(t('full_text_search_management.full_text_search_management')) }}{% endblock %}
-
-{% block content_header %}
-<h1 class="title">{{ t('full_text_search_management.full_text_search_management') }}</h1>
-{% endblock %}
-
-{% block content_main %}
-  <div id ="admin-full-text-search-management"></div>
-{% endblock content_main %}

+ 0 - 11
packages/app/src/server/views/admin/security.html

@@ -1,11 +0,0 @@
-{% extends '../layout/admin.html' %}
-
-{% block html_title %}{{ customizeService.generateCustomTitleForFixedPageName(t('security_settings.security_settings')) }} · {% endblock %}
-
-{% block content_header %}
-<h1 class="title">{{ t('security_settings.security_settings') }}</h1>
-{% endblock %}
-
-{% block content_main %}
-<div id="admin-security-setting" class="admin-security"></div>
-{% endblock content_main %}

+ 0 - 12
packages/app/src/server/views/admin/slack-integration-legacy.html

@@ -1,12 +0,0 @@
-{% extends '../layout/admin.html' %}
-
-{% block html_title %}{{ customizeService.generateCustomTitleForFixedPageName(t('slack_integration_legacy.slack_integration_legacy')) }}{% endblock %}
-
-{% block content_header %}
-<h1 class="title">{{ t('slack_integration_legacy.slack_integration_legacy') }}</h1>
-{% endblock %}
-
-{% block content_main %}
-<div id="admin-slack-integration-legacy" class="admin-slack-integration-legacy"></div>
-{% endblock content_main %}
-

+ 0 - 11
packages/app/src/server/views/admin/slack-integration.html

@@ -1,11 +0,0 @@
-{% extends '../layout/admin.html' %}
-
-{% block html_title %}{{ customizeService.generateCustomTitleForFixedPageName(t('slack_integration.slack_integration')) }}{% endblock %}
-
-{% block content_header %}
-<h1 class="title">{{ t('slack_integration.slack_integration') }}</h1>
-{% endblock %}
-
-{% block content_main %}
-<div id="admin-slack-integration" class="admin-slack-integration"></div>
-{% endblock content_main %}

+ 0 - 15
packages/app/src/server/views/admin/user-group-detail.html

@@ -1,15 +0,0 @@
-<!-- {% extends '../layout/admin.html' %}
-
-{% block html_title %}{{ customizeService.generateCustomTitleForFixedPageName(t('user_group_management.user_group_management') + '/' + userGroup.name) | preventXss }}{% endblock %}
-
-{% block content_header %}
-<h1 class="title">{{ t('user_group_management.user_group_management') + '/' + userGroup.name | preventXss }}</h1>
-{% endblock %}
-
-{% block content_main %}
-<div
-  id="admin-user-group-detail"
-  data-user-group="{{ userGroup|json }}"
->
-</div>
-{% endblock content_main %} -->

+ 0 - 11
packages/app/src/server/views/admin/user-groups.html

@@ -1,11 +0,0 @@
-{% extends '../layout/admin.html' %}
-
-{% block html_title %}{{ customizeService.generateCustomTitleForFixedPageName(t('user_group_management.user_group_management')) }}{% endblock %}
-
-{% block content_header %}
-<h1 class="title">{{ t('user_group_management.user_group_management') }}</h1>
-{% endblock %}
-
-{% block content_main %}
-<div id ="admin-user-group-page"></div>
-{% endblock content_main %}

+ 0 - 11
packages/app/src/server/views/admin/users.html

@@ -1,11 +0,0 @@
-{% extends '../layout/admin.html' %}
-
-{% block html_title %}{{ customizeService.generateCustomTitleForFixedPageName(t('user_management.user_management')) }}{% endblock %}
-
-{% block content_header %}
-<h1 class="title">{{ t('user_management.user_management') }}</h1>
-{% endblock %}
-
-{% block content_main %}
-<div id ="admin-user-page" class="admin-user-page"></div>
-{% endblock content_main %}

+ 0 - 37
packages/app/src/server/views/layout/admin.html

@@ -1,37 +0,0 @@
-{% extends './layout.html' %}
-
-{% block html_base_css %}admin-page{% endblock %}
-
-{% block html_head_loading_app %}
-<script src="{{ webpack_asset('js/admin.js') }}" defer></script>
-{% endblock %}
-
-{% block layout_main %}
-
-{% block content_header_wrapper %}
-<header class="container-fluid py-0">
-  {% block content_header %}
-    <div id="grw-subnav-container"></div>
-  {% endblock %}
-</header>
-{% endblock %}
-
-<div id="main" class="main">
-
-  <div class="container-fluid">
-    <div class="row">
-      <div class="col-lg-3" id="admin-navigation"></div>
-      <div class="col-lg-9">
-        {% block content_main_before %}
-        {% endblock %}
-
-        {% block content_main %}
-        {% endblock content_main %}
-
-        {% block content_main_after %}
-        {% endblock %}
-      </div>
-    </div>
-  </div>
-</div><!-- /.main -->
-{% endblock %} {# layout_main #}

+ 14 - 14
packages/app/src/stores/admin/customize.tsx

@@ -2,26 +2,26 @@ import { useCallback } from 'react';
 
 import useSWR, { SWRResponse } from 'swr';
 
-import { apiv3Get } from '~/client/util/apiv3-client';
+import { apiv3Get, apiv3Put } from '~/client/util/apiv3-client';
+import { updateConfigMethodForAdmin } from '~/interfaces/admin';
 import { IResLayoutSetting } from '~/interfaces/customize';
 
-import { useLayoutSetting } from '../context';
-
-
-export const useSWRxLayoutSetting = (fallbackData?: IResLayoutSetting): SWRResponse<IResLayoutSetting, Error> => {
-  const { mutate: mutateStatic } = useLayoutSetting();
+export const useSWRxLayoutSetting = (): SWRResponse<IResLayoutSetting, Error> & updateConfigMethodForAdmin<IResLayoutSetting> => {
 
   const fetcher = useCallback(async() => {
     const res = await apiv3Get('/customize-setting/layout');
+    return res.data;
+  }, []);
 
-    mutateStatic(res.data);
+  const swrResponse = useSWR('/customize-setting/layout', fetcher);
 
-    return res.data;
-  }, [mutateStatic]);
+  const update = useCallback(async(layoutSetting: IResLayoutSetting) => {
+    await apiv3Put('/customize-setting/layout', layoutSetting);
+    await swrResponse.mutate();
+  }, [swrResponse]);
 
-  return useSWR(
-    '/customize-setting/layout',
-    fetcher,
-    { fallbackData },
-  );
+  return {
+    ...swrResponse,
+    update,
+  };
 };

+ 59 - 89
packages/app/src/stores/context.tsx

@@ -5,7 +5,6 @@ import useSWRImmutable from 'swr/immutable';
 
 
 import { SupportedActionType } from '~/interfaces/activity';
-import { IResLayoutSetting } from '~/interfaces/customize';
 import { EditorConfig } from '~/interfaces/editor-settings';
 // import { CustomWindow } from '~/interfaces/global';
 import { RendererConfig } from '~/interfaces/services/renderer';
@@ -14,6 +13,7 @@ import InterceptorManager from '~/services/interceptor-manager';
 
 import { TargetAndAncestors } from '../interfaces/page-listing-results';
 
+import { useContextSWR } from './use-context-swr';
 import { useStaticSWR } from './use-static-swr';
 
 
@@ -21,238 +21,208 @@ type Nullable<T> = T | null;
 
 
 export const useInterceptorManager = (): SWRResponse<InterceptorManager, Error> => {
-  return useStaticSWR<InterceptorManager, Error>('interceptorManager', undefined, { fallbackData: new InterceptorManager() });
+  return useContextSWR<InterceptorManager, Error>('interceptorManager', undefined, { fallbackData: new InterceptorManager() });
 };
 
 export const useCsrfToken = (initialData?: string): SWRResponse<string, Error> => {
-  return useStaticSWR<string, Error>('csrfToken', initialData);
+  return useContextSWR<string, Error>('csrfToken', initialData);
 };
 
 export const useAppTitle = (initialData?: string): SWRResponse<string, Error> => {
-  return useStaticSWR('appTitle', initialData);
+  return useContextSWR('appTitle', initialData);
 };
 
 export const useSiteUrl = (initialData?: string): SWRResponse<string, Error> => {
-  return useStaticSWR<string, Error>('siteUrl', initialData);
+  return useContextSWR<string, Error>('siteUrl', initialData);
 };
 
 export const useConfidential = (initialData?: string): SWRResponse<string, Error> => {
-  return useStaticSWR('confidential', initialData);
+  return useContextSWR('confidential', initialData);
 };
 
 export const useGrowiTheme = (initialData?: GrowiThemes): SWRResponse<GrowiThemes, Error> => {
-  return useStaticSWR('theme', initialData);
+  return useContextSWR('theme', initialData);
 };
 
 export const useCurrentUser = (initialData?: Nullable<IUser>): SWRResponse<Nullable<IUser>, Error> => {
-  return useStaticSWR<Nullable<IUser>, Error>('currentUser', initialData);
+  return useContextSWR<Nullable<IUser>, Error>('currentUser', initialData);
 };
 
 export const useRevisionId = (initialData?: Nullable<any>): SWRResponse<Nullable<any>, Error> => {
-  return useStaticSWR<Nullable<any>, Error>('revisionId', initialData);
+  return useContextSWR<Nullable<any>, Error>('revisionId', initialData);
 };
 
 export const useCurrentPagePath = (initialData?: Nullable<string>): SWRResponse<Nullable<string>, Error> => {
-  return useStaticSWR<Nullable<string>, Error>('currentPagePath', initialData);
+  return useContextSWR<Nullable<string>, Error>('currentPagePath', initialData);
 };
 
 export const useCurrentPathname = (initialData?: string): SWRResponse<string, Error> => {
-  return useStaticSWR('currentPathname', initialData);
+  return useContextSWR('currentPathname', initialData);
 };
 
 export const useCurrentPageId = (initialData?: Nullable<string>): SWRResponse<Nullable<string>, Error> => {
-  return useStaticSWR<Nullable<string>, Error>('currentPageId', initialData);
-};
-
-export const useRevisionCreatedAt = (initialData?: Nullable<any>): SWRResponse<Nullable<any>, Error> => {
-  return useStaticSWR<Nullable<any>, Error>('revisionCreatedAt', initialData);
-};
-
-export const useCurrentUpdatedAt = (initialData?: Nullable<Date>): SWRResponse<Nullable<Date>, Error> => {
-  return useStaticSWR<Nullable<Date>, Error>('updatedAt', initialData);
-};
-
-export const useDeletedAt = (initialData?: Nullable<any>): SWRResponse<Nullable<any>, Error> => {
-  return useStaticSWR<Nullable<any>, Error>('deletedAt', initialData);
+  return useContextSWR<Nullable<string>, Error>('currentPageId', initialData);
 };
 
 export const useIsIdenticalPath = (initialData?: boolean): SWRResponse<boolean, Error> => {
-  return useStaticSWR<boolean, Error>('isIdenticalPath', initialData, { fallbackData: false });
+  return useContextSWR<boolean, Error>('isIdenticalPath', initialData, { fallbackData: false });
 };
 
-// export const useIsNotCreatable = (initialData?: boolean): SWRResponse<boolean, Error> => {
-//   return useStaticSWR<boolean, Error>('isNotCreatable', initialData, { fallbackData: false });
-// };
 export const useIsForbidden = (initialData?: boolean): SWRResponse<boolean, Error> => {
-  return useStaticSWR<boolean, Error>('isForbidden', initialData, { fallbackData: false });
+  return useContextSWR<boolean, Error>('isForbidden', initialData, { fallbackData: false });
 };
 
 export const useIsNotFound = (initialData?: boolean): SWRResponse<boolean, Error> => {
-  return useStaticSWR<boolean, Error>('isNotFound', initialData, { fallbackData: false });
-};
-
-export const useHasChildren = (initialData?: Nullable<any>): SWRResponse<Nullable<any>, Error> => {
-  return useStaticSWR<Nullable<any>, Error>('hasChildren', initialData);
+  return useContextSWR<boolean, Error>('isNotFound', initialData, { fallbackData: false });
 };
 
 export const useTemplateTagData = (initialData?: Nullable<string>): SWRResponse<Nullable<string>, Error> => {
-  return useStaticSWR<Nullable<string>, Error>('templateTagData', initialData);
+  return useContextSWR<Nullable<string>, Error>('templateTagData', initialData);
 };
 
 export const useIsSharedUser = (initialData?: boolean): SWRResponse<boolean, Error> => {
-  return useStaticSWR<boolean, Error>('isSharedUser', initialData);
-};
-
-export const useShareLinksNumber = (initialData?: Nullable<any>): SWRResponse<Nullable<any>, Error> => {
-  return useStaticSWR<Nullable<any>, Error>('shareLinksNumber', initialData);
+  return useContextSWR<boolean, Error>('isSharedUser', initialData);
 };
 
 export const useShareLinkId = (initialData?: Nullable<string>): SWRResponse<Nullable<string>, Error> => {
-  return useStaticSWR<Nullable<string>, Error>('shareLinkId', initialData);
+  return useContextSWR<Nullable<string>, Error>('shareLinkId', initialData);
 };
 
 export const useDisableLinkSharing = (initialData?: Nullable<boolean>): SWRResponse<Nullable<boolean>, Error> => {
-  return useStaticSWR<Nullable<boolean>, Error>('disableLinkSharing', initialData);
+  return useContextSWR<Nullable<boolean>, Error>('disableLinkSharing', initialData);
 };
 
 export const useRegistrationWhiteList = (initialData?: Nullable<string[]>): SWRResponse<Nullable<string[]>, Error> => {
-  return useStaticSWR<Nullable<string[]>, Error>('registrationWhiteList', initialData);
+  return useContextSWR<Nullable<string[]>, Error>('registrationWhiteList', initialData);
 };
 
 export const useDrawioUri = (initialData?: string): SWRResponse<string, Error> => {
-  return useStaticSWR('drawioUri', initialData, { fallbackData: 'https://embed.diagrams.net/' });
+  return useContextSWR('drawioUri', initialData, { fallbackData: 'https://embed.diagrams.net/' });
 };
 
 export const useHackmdUri = (initialData?: Nullable<string>): SWRResponse<Nullable<string>, Error> => {
-  return useStaticSWR<Nullable<string>, Error>('hackmdUri', initialData);
-};
-
-export const useLastUpdateUsername = (initialData?: Nullable<any>): SWRResponse<Nullable<any>, Error> => {
-  return useStaticSWR<Nullable<any>, Error>('lastUpdateUsername', initialData);
-};
-
-export const useDeleteUsername = (initialData?: Nullable<any>): SWRResponse<Nullable<any>, Error> => {
-  return useStaticSWR<Nullable<any>, Error>('deleteUsername', initialData);
+  return useContextSWR<Nullable<string>, Error>('hackmdUri', initialData);
 };
 
 export const useIsSearchPage = (initialData?: Nullable<any>) : SWRResponse<Nullable<any>, Error> => {
-  return useStaticSWR<Nullable<any>, Error>('isSearchPage', initialData);
+  return useContextSWR<Nullable<any>, Error>('isSearchPage', initialData);
 };
 
 export const useTargetAndAncestors = (initialData?: TargetAndAncestors): SWRResponse<TargetAndAncestors, Error> => {
-  return useStaticSWR<TargetAndAncestors, Error>('targetAndAncestors', initialData);
+  return useContextSWR<TargetAndAncestors, Error>('targetAndAncestors', initialData);
 };
 
 export const useIsAclEnabled = (initialData?: boolean) : SWRResponse<boolean, Error> => {
-  return useStaticSWR<boolean, Error>('isAclEnabled', initialData);
+  return useContextSWR<boolean, Error>('isAclEnabled', initialData);
 };
 
 export const useIsSearchServiceConfigured = (initialData?: boolean) : SWRResponse<boolean, Error> => {
-  return useStaticSWR<boolean, Error>('isSearchServiceConfigured', initialData);
+  return useContextSWR<boolean, Error>('isSearchServiceConfigured', initialData);
 };
 
 export const useIsSearchServiceReachable = (initialData?: boolean) : SWRResponse<boolean, Error> => {
-  return useStaticSWR<boolean, Error>('isSearchServiceReachable', initialData);
+  return useContextSWR<boolean, Error>('isSearchServiceReachable', initialData);
 };
 
 export const useIsMailerSetup = (initialData?: boolean): SWRResponse<boolean, any> => {
-  return useStaticSWR('isMailerSetup', initialData);
+  return useContextSWR('isMailerSetup', initialData);
 };
 
 export const useIsSearchScopeChildrenAsDefault = (initialData?: boolean) : SWRResponse<boolean, Error> => {
-  return useStaticSWR<boolean, Error>('isSearchScopeChildrenAsDefault', initialData);
+  return useContextSWR<boolean, Error>('isSearchScopeChildrenAsDefault', initialData);
 };
 
 export const useIsSlackConfigured = (initialData?: boolean) : SWRResponse<boolean, Error> => {
-  return useStaticSWR<boolean, Error>('isSlackConfigured', initialData);
+  return useContextSWR<boolean, Error>('isSlackConfigured', initialData);
 };
 
 export const useIsEnabledAttachTitleHeader = (initialData?: boolean) : SWRResponse<boolean, Error> => {
-  return useStaticSWR<boolean, Error>('isEnabledAttachTitleHeader', initialData);
-};
-
-export const useHasParent = (initialData?: boolean) : SWRResponse<boolean, Error> => {
-  return useStaticSWR<boolean, Error>('hasParent', initialData);
+  return useContextSWR<boolean, Error>('isEnabledAttachTitleHeader', initialData);
 };
 
 export const useIsIndentSizeForced = (initialData?: boolean) : SWRResponse<boolean, Error> => {
-  return useStaticSWR<boolean, Error>('isIndentSizeForced', initialData, { fallbackData: false });
+  return useContextSWR<boolean, Error>('isIndentSizeForced', initialData, { fallbackData: false });
 };
 
 export const useDefaultIndentSize = (initialData?: number) : SWRResponse<number, Error> => {
-  return useStaticSWR<number, Error>('defaultIndentSize', initialData, { fallbackData: 4 });
+  return useContextSWR<number, Error>('defaultIndentSize', initialData, { fallbackData: 4 });
 };
 
 export const useAuditLogEnabled = (initialData?: boolean): SWRResponse<boolean, Error> => {
-  return useStaticSWR<boolean, Error>('auditLogEnabled', initialData, { fallbackData: false });
+  return useContextSWR<boolean, Error>('auditLogEnabled', initialData, { fallbackData: false });
 };
 
+// TODO: initialize in [[..path]].page.tsx?
 export const useActivityExpirationSeconds = (initialData?: number) : SWRResponse<number, Error> => {
-  return useStaticSWR<number, Error>('activityExpirationSeconds', initialData);
+  return useContextSWR<number, Error>('activityExpirationSeconds', initialData);
 };
 
 export const useAuditLogAvailableActions = (initialData?: Array<SupportedActionType>) : SWRResponse<Array<SupportedActionType>, Error> => {
-  return useStaticSWR<Array<SupportedActionType>, Error>('auditLogAvailableActions', initialData);
+  return useContextSWR<Array<SupportedActionType>, Error>('auditLogAvailableActions', initialData);
 };
 
 export const useGrowiVersion = (initialData?: string): SWRResponse<string, any> => {
-  return useStaticSWR('growiVersion', initialData);
+  return useContextSWR('growiVersion', initialData);
 };
 
 export const useIsEnabledStaleNotification = (initialData?: boolean): SWRResponse<boolean, any> => {
-  return useStaticSWR('isEnabledStaleNotification', initialData);
+  return useContextSWR('isEnabledStaleNotification', initialData);
 };
 
 export const useIsLatestRevision = (initialData?: boolean): SWRResponse<boolean, any> => {
-  return useStaticSWR('isLatestRevision', initialData);
+  return useContextSWR('isLatestRevision', initialData);
 };
 
 export const useEditorConfig = (initialData?: EditorConfig): SWRResponse<EditorConfig, Error> => {
-  return useStaticSWR<EditorConfig, Error>('editorConfig', initialData);
+  return useContextSWR<EditorConfig, Error>('editorConfig', initialData);
 };
 
 export const useRendererConfig = (initialData?: RendererConfig): SWRResponse<RendererConfig, any> => {
-  return useStaticSWR('growiRendererConfig', initialData);
+  return useContextSWR('growiRendererConfig', initialData);
 };
 
 export const useIsAllReplyShown = (initialData?: boolean): SWRResponse<boolean, Error> => {
-  return useStaticSWR('isAllReplyShown', initialData);
+  return useContextSWR('isAllReplyShown', initialData);
 };
 
 export const useIsBlinkedHeaderAtBoot = (initialData?: boolean): SWRResponse<boolean, Error> => {
-  return useStaticSWR('isBlinkedAtBoot', initialData, { fallbackData: false });
+  return useContextSWR('isBlinkedAtBoot', initialData, { fallbackData: false });
 };
 
 export const useEditingMarkdown = (initialData?: string): SWRResponse<string, Error> => {
-  return useStaticSWR('currentMarkdown', initialData);
+  return useContextSWR('currentMarkdown', initialData);
 };
 
 export const useIsUploadableImage = (initialData?: boolean): SWRResponse<boolean, Error> => {
-  return useStaticSWR('isUploadableImage', initialData);
+  return useContextSWR('isUploadableImage', initialData);
 };
 
 export const useIsUploadableFile = (initialData?: boolean): SWRResponse<boolean, Error> => {
-  return useStaticSWR('isUploadableFile', initialData);
+  return useContextSWR('isUploadableFile', initialData);
 };
 
 export const useShowPageLimitationL = (initialData?: number): SWRResponse<number, Error> => {
-  return useStaticSWR('showPageLimitationL', initialData);
+  return useContextSWR('showPageLimitationL', initialData);
 };
 
 export const useShowPageLimitationXL = (initialData?: number): SWRResponse<number, Error> => {
-  return useStaticSWR('showPageLimitationXL', initialData);
-};
-
-export const useLayoutSetting = (initialData?: IResLayoutSetting): SWRResponse<IResLayoutSetting, Error> => {
-  return useStaticSWR('layoutSetting', initialData);
+  return useContextSWR('showPageLimitationXL', initialData);
 };
 
 export const useCustomizeTitle = (initialData?: string): SWRResponse<string, Error> => {
-  return useStaticSWR('CustomizeTitle', initialData);
+  return useContextSWR('CustomizeTitle', initialData);
 };
 
 export const useCustomizedLogoSrc = (initialData?: string): SWRResponse<string, Error> => {
-  return useStaticSWR('customizedLogoSrc', initialData);
+  return useContextSWR('customizedLogoSrc', initialData);
+};
+
+export const useGrowiCloudUri = (initialData?: string): SWRResponse<string, Error> => {
+  return useStaticSWR('growiCloudUri', initialData);
+};
+
+export const useGrowiAppIdForGrowiCloud = (initialData?: number): SWRResponse<number, Error> => {
+  return useStaticSWR('growiAppIdForGrowiCloud', initialData);
 };
 
 /** **********************************************************

+ 2 - 2
packages/app/src/stores/global-notification.ts

@@ -1,6 +1,7 @@
 import { SWRResponseWithUtils, withUtils } from '@growi/core';
 import useSWRImmutable from 'swr/immutable';
 
+import { IGlobalNotification } from '~/client/interfaces/global-notification';
 
 import { apiv3Get, apiv3Put } from '../client/util/apiv3-client';
 
@@ -10,7 +11,6 @@ type Util = {
 };
 
 
-// TODO: typescriptize
 export const useSWRxGlobalNotification = (globalNotificationId: string): SWRResponseWithUtils<Util, any, Error> => {
   const swrResult = useSWRImmutable(
     globalNotificationId != null ? `/notification-setting/global-notification/${globalNotificationId}` : null,
@@ -22,7 +22,7 @@ export const useSWRxGlobalNotification = (globalNotificationId: string): SWRResp
   );
 
 
-  const update = async(updateData) => {
+  const update = async(updateData: IGlobalNotification) => {
     const { data } = swrResult;
 
     if (data == null) {

+ 24 - 0
packages/app/src/stores/use-context-swr.tsx

@@ -0,0 +1,24 @@
+import {
+  Key, SWRConfiguration, SWRResponse,
+} from 'swr';
+
+import { useStaticSWR } from './use-static-swr';
+
+export function useContextSWR<Data, Error>(key: Key): SWRResponse<Data, Error>;
+export function useContextSWR<Data, Error>(key: Key, data: Data | undefined): SWRResponse<Data, Error>;
+export function useContextSWR<Data, Error>(key: Key, data: Data | undefined,
+  configuration: SWRConfiguration<Data, Error> | undefined): SWRResponse<Data, Error>;
+
+export function useContextSWR<Data, Error>(
+    ...args: readonly [Key]
+    | readonly [Key, Data | undefined]
+    | readonly [Key, Data | undefined, SWRConfiguration<Data, Error> | undefined]
+): SWRResponse<Data, Error> {
+  const [key, data, configuration] = args;
+
+  const swrResponse = useStaticSWR<Data, Error>(key, data, configuration);
+
+  const result = Object.assign(swrResponse, { mutate: () => { throw Error('mutate can not be used in context') } });
+
+  return result;
+}

+ 0 - 35
packages/app/src/styles/_handsontable.scss

@@ -1,35 +0,0 @@
-.handsontable {
-  .handsontableInput {
-    max-width: 290px !important;
-  }
-
-  td {
-    word-break: break-all;
-  }
-}
-
-.handsontable-modal.grw-modal-expanded {
-  // expand .hot-table-container (with flexbox)
-  .hot-table-container {
-    flex: 1;
-  }
-}
-
-// Prevent handsontable/handsontable #2937 (Manual column resize does not work when handsontable is loaded inside Bootstrap 3.0 Modal)
-// see https://github.com/handsontable/handsontable/issues/2937#issuecomment-287390111
-.modal.in .modal-dialog.handsontable-modal {
-  transform: none;
-
-  .data-import-button {
-    position: relative;
-    padding-right: 35px;
-    padding-left: 10px;
-
-    i:before {
-      position: absolute;
-      top: 6px;
-      right: 8px;
-      font-size: 20px;
-    }
-  }
-}

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

@@ -44,7 +44,7 @@
 @import 'editor-attachment';
 @import 'editor-navbar';
 @import 'page-content-footer';
-@import 'handsontable';
+// @import 'handsontable';
 @import 'layout';
 @import 'login';
 @import 'me';

+ 19 - 0
packages/app/src/styles/style-next.scss

@@ -147,3 +147,22 @@
   }
 
 }
+
+// Prevent handsontable/handsontable #2937 (Manual column resize does not work when handsontable is loaded inside Bootstrap 3.0 Modal)
+// see https://github.com/handsontable/handsontable/issues/2937#issuecomment-287390111
+.modal.in .modal-dialog.handsontable-modal {
+  transform: none;
+
+  .data-import-button {
+    position: relative;
+    padding-right: 35px;
+    padding-left: 10px;
+
+    i:before {
+      position: absolute;
+      top: 6px;
+      right: 8px;
+      font-size: 20px;
+    }
+  }
+}

+ 9 - 0
packages/app/test/cypress/integration/20-basic-features/access-to-page.spec.ts

@@ -26,6 +26,7 @@ context('Access to page', () => {
     // https://stackoverflow.com/questions/5041494/selecting-and-manipulating-css-pseudo-elements-such-as-before-and-after-usin/21709814#21709814
     cy.get('#mdcont-headers').invoke('removeClass', 'blink');
 
+    cy.get('.grw-skelton').should('not.exist');
     cy.screenshot(`${ssPrefix}-sandbox-headers`);
   });
 
@@ -45,6 +46,14 @@ context('Access to page', () => {
 
   it('/user/admin is successfully loaded', () => {
     cy.visit('/user/admin', {  });
+
+    cy.get('.grw-skelton').should('not.exist');
+    // for check download toc data
+    cy.get('.toc-link').should('be.visible');
+
+    // eslint-disable-next-line cypress/no-unnecessary-waiting
+    cy.wait(2000); // wait for calcViewHeight and rendering
+
     cy.screenshot(`${ssPrefix}-user-admin`);
   });
 

+ 6 - 3
packages/app/test/cypress/integration/20-basic-features/click-page-icons.spec.ts

@@ -77,11 +77,14 @@ context('Click page icons button', () => {
 
   it('Successfully display list of "seen by user"', () => {
     cy.visit('/Sandbox');
+    cy.get('.grw-skelton').should('not.exist');
+    // eslint-disable-next-line cypress/no-unnecessary-waiting
+    cy.wait(2000); // wait for get method
     cy.get('#grw-subnav-container').within(() => {
-      cy.get('div.grw-seen-user-info > button#btn-seen-user').click({force: true});
+      cy.get('div.grw-seen-user-info').find('button#btn-seen-user').click({force: true});
     });
-    // TODO:
-    // cy.get('div.user-list-popover').should('be.visible');
+
+    cy.get('.user-list-popover').should('be.visible')
 
     cy.get('#grw-subnav-container').within(() => {
       cy.screenshot(`${ssPrefix}11-seen-user-list`);

+ 12 - 6
packages/app/test/cypress/integration/50-sidebar/access-to-side-bar.spec.ts

@@ -29,8 +29,7 @@ context('Access to sidebar', () => {
     cy.getByTestid('grw-recent-changes').should('be.visible');
     cy.get('.list-group-item').should('be.visible');
 
-    // Avoid blackout misalignment
-    cy.scrollTo('center');
+    cy.scrollTo('top');
     cy.screenshot(`${ssPrefix}recent-changes-1-page-list`);
 
     cy.get('#grw-sidebar-contents-wrapper').within(() => {
@@ -38,8 +37,7 @@ context('Access to sidebar', () => {
       cy.get('.list-group-item').should('be.visible');
     });
 
-    // Avoid blackout misalignment
-    cy.scrollTo('center');
+    cy.scrollTo('top');
     cy.screenshot(`${ssPrefix}recent-changes-2-switch-sidebar-size`);
   });
 
@@ -81,8 +79,11 @@ context('Access to sidebar', () => {
         cy.getByTestid('grw-navigation-resize-button').click({force: true});
       }
     });
-    cy.getByTestid('grw-contextual-navigation-sub').screenshot(`${ssPrefix}page-tree-1-access-to-page-tree`);
-    cy.get('.grw-pagetree-triangle-btn').eq(0).click();
+
+    cy.getByTestid('grw-contextual-navigation-sub').should('be.visible')
+    cy.get('.grw-pagetree-item-children').eq(0).should('be.visible');
+    cy.screenshot(`${ssPrefix}page-tree-1-access-to-page-tree`);
+
     cy.getByTestid('grw-contextual-navigation-sub').screenshot(`${ssPrefix}page-tree-2-hide-page-tree-item`);
     cy.get('.grw-pagetree-triangle-btn').eq(0).click();
 
@@ -180,6 +181,11 @@ context('Access to sidebar', () => {
     cy.get('.grw-sidebar-nav-secondary-container').within(() => {
       cy.get('a[href*="/trash"]').click();
     });
+
+    cy.get('.grw-page-path-hierarchical-link').should('be.visible');
+
+    cy.get('.grw-custom-nav-tab').should('be.visible');
+
     cy.screenshot(`${ssPrefix}access-to-trash-page`);
   });
 });

+ 5 - 1
packages/app/test/cypress/integration/60-home/home.spec.ts

@@ -15,10 +15,14 @@ context('Access Home', () => {
     cy.getByTestid('grw-personal-dropdown').click();
     cy.getByTestid('grw-personal-dropdown').find('.dropdown-menu .btn-group > .btn-outline-secondary:eq(0)').click();
 
-    cy.get('.grw-users-info').should('be.visible');
+    cy.get('.grw-skelton').should('not.exist');
     // for check download toc data
     cy.get('.toc-link').should('be.visible');
 
+    // eslint-disable-next-line cypress/no-unnecessary-waiting
+    cy.wait(2000); // wait for calcViewHeight and rendering
+
+    // same screenshot is taken in access-to-page.spec
     cy.screenshot(`${ssPrefix}-visit-home`);
   });