Jelajahi Sumber

Merge branch 'master' into fix/107837-clear-underline-of-notification-icon

kymn 3 tahun lalu
induk
melakukan
9aa5b38a79
100 mengubah file dengan 1764 tambahan dan 2041 penghapusan
  1. 1 1
      lerna.json
  2. 1 1
      package.json
  3. 0 173
      packages/app/_obsolete/src/client/admin.jsx
  4. 8 8
      packages/app/package.json
  5. 22 2
      packages/app/public/static/locales/en_US/admin.json
  6. 16 2
      packages/app/public/static/locales/en_US/commons.json
  7. 3 25
      packages/app/public/static/locales/en_US/translation.json
  8. 16 2
      packages/app/public/static/locales/ja_JP/admin.json
  9. 16 2
      packages/app/public/static/locales/ja_JP/commons.json
  10. 3 22
      packages/app/public/static/locales/ja_JP/translation.json
  11. 65 1
      packages/app/public/static/locales/zh_CN/admin.json
  12. 16 2
      packages/app/public/static/locales/zh_CN/commons.json
  13. 3 69
      packages/app/public/static/locales/zh_CN/translation.json
  14. 27 0
      packages/app/src/client/interfaces/global-notification.ts
  15. 1 1
      packages/app/src/client/util/editor.ts
  16. 1 1
      packages/app/src/components/Admin/App/AppSetting.jsx
  17. 5 7
      packages/app/src/components/Admin/Customize/CustomizeLayoutSetting.tsx
  18. 5 3
      packages/app/src/components/Admin/Customize/CustomizeThemeSetting.tsx
  19. 0 91
      packages/app/src/components/Admin/ManageExternalAccount.jsx
  20. 79 0
      packages/app/src/components/Admin/ManageExternalAccount.tsx
  21. 1 1
      packages/app/src/components/Admin/NotFoundPage.tsx
  22. 85 54
      packages/app/src/components/Admin/Notification/ManageGlobalNotification.tsx
  23. 4 4
      packages/app/src/components/Admin/Security/DeleteAllShareLinksModal.jsx
  24. 1 1
      packages/app/src/components/Admin/Security/LocalSecuritySettingContents.jsx
  25. 6 3
      packages/app/src/components/Admin/Security/SecurityManagementContents.jsx
  26. 0 208
      packages/app/src/components/Admin/Security/ShareLinkSetting.jsx
  27. 170 0
      packages/app/src/components/Admin/Security/ShareLinkSetting.tsx
  28. 1 1
      packages/app/src/components/Admin/UserGroup/UserGroupTable.tsx
  29. 1 1
      packages/app/src/components/Admin/UserGroupDetail/UserGroupDetailPage.tsx
  30. 0 235
      packages/app/src/components/Admin/UserManagement.jsx
  31. 43 3
      packages/app/src/components/Admin/UserManagement.module.scss
  32. 202 0
      packages/app/src/components/Admin/UserManagement.tsx
  33. 0 132
      packages/app/src/components/Admin/Users/ExternalAccountTable.jsx
  34. 8 0
      packages/app/src/components/Admin/Users/ExternalAccountTable.module.scss
  35. 122 0
      packages/app/src/components/Admin/Users/ExternalAccountTable.tsx
  36. 0 60
      packages/app/src/components/Admin/Users/GiveAdminButton.jsx
  37. 44 0
      packages/app/src/components/Admin/Users/GiveAdminButton.tsx
  38. 9 9
      packages/app/src/components/Admin/Users/PasswordResetModal.jsx
  39. 67 0
      packages/app/src/components/Admin/Users/RemoveAdminButton.tsx
  40. 3 3
      packages/app/src/components/Admin/Users/RemoveAdminMenuItem.tsx
  41. 9 13
      packages/app/src/components/Admin/Users/SortIcons.tsx
  42. 2 2
      packages/app/src/components/Admin/Users/StatusActivateButton.jsx
  43. 3 3
      packages/app/src/components/Admin/Users/StatusSuspendMenuItem.tsx
  44. 1 1
      packages/app/src/components/Admin/Users/UserRemoveButton.jsx
  45. 0 231
      packages/app/src/components/Admin/Users/UserTable.jsx
  46. 185 0
      packages/app/src/components/Admin/Users/UserTable.tsx
  47. 1 1
      packages/app/src/components/InvitedForm.tsx
  48. 3 6
      packages/app/src/components/Layout/AdminLayout.tsx
  49. 1 1
      packages/app/src/components/Layout/RawLayout.tsx
  50. 28 6
      packages/app/src/components/LoginForm.tsx
  51. 24 27
      packages/app/src/components/Navbar/AuthorInfo.tsx
  52. 9 12
      packages/app/src/components/Navbar/GrowiContextualSubNavigation.tsx
  53. 2 2
      packages/app/src/components/Page.tsx
  54. 5 6
      packages/app/src/components/PageAlert/TrashPageAlert.tsx
  55. 1 1
      packages/app/src/components/PageAttachment/DeleteAttachmentModal.tsx
  56. 4 4
      packages/app/src/components/PageComment.tsx
  57. 1 1
      packages/app/src/components/PageComment/Comment.tsx
  58. 1 1
      packages/app/src/components/PageComment/DeleteCommentModal.tsx
  59. 3 2
      packages/app/src/components/PageContentFooter.tsx
  60. 4 4
      packages/app/src/components/PageEditor/CodeMirrorEditor.jsx
  61. 3 3
      packages/app/src/components/PageEditor/ConflictDiffModal.tsx
  62. 9 1
      packages/app/src/components/PageEditor/HandsontableModal.jsx
  63. 24 0
      packages/app/src/components/PageEditor/HandsontableModal.module.scss
  64. 7 4
      packages/app/src/components/PageEditorByHackmd.tsx
  65. 1 1
      packages/app/src/components/PageHistory/Revision.tsx
  66. 1 1
      packages/app/src/components/PageStatusAlert.jsx
  67. 1 1
      packages/app/src/components/RevisionComparer/RevisionComparer.tsx
  68. 4 3
      packages/app/src/components/SavePageControls/GrantSelector.tsx
  69. 2 2
      packages/app/src/components/ShareLink/ShareLink.tsx
  70. 5 5
      packages/app/src/components/ShareLink/ShareLinkList.tsx
  71. 7 2
      packages/app/src/components/TableOfContents.tsx
  72. 1 1
      packages/app/src/components/User/UserInfo.tsx
  73. 0 30
      packages/app/src/components/User/Username.jsx
  74. 26 0
      packages/app/src/components/User/Username.tsx
  75. 4 0
      packages/app/src/interfaces/admin.ts
  76. 4 9
      packages/app/src/pages/[[...path]].page.tsx
  77. 32 0
      packages/app/src/pages/admin/[...path].page.tsx
  78. 74 0
      packages/app/src/pages/admin/global-notification/[globalNotificationId].page.tsx
  79. 1 0
      packages/app/src/pages/admin/global-notification/new.page.tsx
  80. 45 0
      packages/app/src/pages/admin/user-group-detail/[userGroupId].page.tsx
  81. 4 2
      packages/app/src/pages/login.page.tsx
  82. 0 12
      packages/app/src/pages/utils/layout.ts
  83. 0 212
      packages/app/src/server/routes/admin.js
  84. 3 0
      packages/app/src/server/routes/apiv3/index.js
  85. 30 12
      packages/app/src/server/routes/apiv3/notification-setting.js
  86. 122 21
      packages/app/src/server/routes/apiv3/user-activation.ts
  87. 1 1
      packages/app/src/server/routes/apiv3/users.js
  88. 2 42
      packages/app/src/server/routes/index.js
  89. 9 1
      packages/app/src/server/routes/login.js
  90. 0 108
      packages/app/src/server/routes/user-activation.ts
  91. 0 15
      packages/app/src/server/views/admin/app.html
  92. 0 11
      packages/app/src/server/views/admin/audit-log.html
  93. 0 14
      packages/app/src/server/views/admin/customize.html
  94. 0 11
      packages/app/src/server/views/admin/export.html
  95. 0 11
      packages/app/src/server/views/admin/external-accounts.html
  96. 0 12
      packages/app/src/server/views/admin/global-notification-detail.html
  97. 0 11
      packages/app/src/server/views/admin/importer.html
  98. 0 11
      packages/app/src/server/views/admin/index.html
  99. 0 11
      packages/app/src/server/views/admin/markdown.html
  100. 0 8
      packages/app/src/server/views/admin/not_found.html

+ 1 - 1
lerna.json

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

+ 1 - 1
package.json

@@ -1,6 +1,6 @@
 {
   "name": "growi",
-  "version": "6.0.0-RC.7",
+  "version": "6.0.0-RC.8",
   "description": "Team collaboration software using markdown",
   "tags": [
     "wiki",

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

+ 8 - 8
packages/app/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/app",
-  "version": "6.0.0-RC.7",
+  "version": "6.0.0-RC.8",
   "license": "MIT",
   "scripts": {
     "//// for production": "",
@@ -65,12 +65,12 @@
     "@elastic/elasticsearch7": "npm:@elastic/elasticsearch@^7.17.0",
     "@godaddy/terminus": "^4.9.0",
     "@google-cloud/storage": "^5.8.5",
-    "@growi/codemirror-textlint": "^6.0.0-RC.7",
-    "@growi/core": "^6.0.0-RC.7",
-    "@growi/hackmd": "^6.0.0-RC.7",
-    "@growi/plugin-attachment-refs": "^6.0.0-RC.7",
-    "@growi/plugin-lsx": "^6.0.0-RC.7",
-    "@growi/slack": "^6.0.0-RC.7",
+    "@growi/codemirror-textlint": "^6.0.0-RC.8",
+    "@growi/core": "^6.0.0-RC.8",
+    "@growi/hackmd": "^6.0.0-RC.8",
+    "@growi/plugin-attachment-refs": "^6.0.0-RC.8",
+    "@growi/plugin-lsx": "^6.0.0-RC.8",
+    "@growi/slack": "^6.0.0-RC.8",
     "@promster/express": "^7.0.2",
     "@promster/server": "^7.0.4",
     "@slack/events-api": "^3.0.0",
@@ -205,7 +205,7 @@
     "handsontable": "v7.0.0 or above is no loger MIT lisence."
   },
   "devDependencies": {
-    "@growi/ui": "^6.0.0-RC.7",
+    "@growi/ui": "^6.0.0-RC.8",
     "@handsontable/react": "=2.1.0",
     "@icon/themify-icons": "1.0.1-alpha.3",
     "@next/bundle-analyzer": "^12.2.3",

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

@@ -3,8 +3,14 @@
     "display_name": "English"
   },
   "wiki_management_home_page": "Wiki Management Home Page",
+  "last_login": "Last login",
+  "anyone_with_the_link": "anyone with the link",
+  "only_me": "only me",
+  "only_inside_the_group": "only inside the group",
   "security_settings": {
     "security_settings": "Security Settings",
+    "scope_of_page_disclosure": "Scope of page disclosure",
+    "set_point": "Set point",
     "Guest Users Access": "Guest users access",
     "always_hidden": "Always hidden",
     "always_displayed": "Always displayed",
@@ -73,6 +79,10 @@
       "restricted": "Restricted (Requires approval by administrators)",
       "closed": "Closed (Invitation Only)"
     },
+    "share_link_management": "Share Link Management",
+    "No_share_links":"No share links",
+    "share_link_notice":"remove all share links",
+    "delete_all_share_links":"Delete all share links",
     "share_link_rights": "Share link rights",
     "enable_link_sharing": "Enable link sharing",
     "all_share_links": "All share links",
@@ -95,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": {
@@ -267,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": {
@@ -1022,5 +1032,15 @@
     "ADMIN_SEARCH_CONNECTION": "Attempting to reconnect to Elasticsearch",
     "ADMIN_SEARCH_INDICES_NORMALIZE": "Normalize of Elasticsearch indexes",
     "ADMIN_SEARCH_INDICES_REBUILD": "Rebuild Elasticsearch indexes"
+  },
+  "toaster": {
+    "give_user_admin": "Succeeded to give {{username}} admin",
+    "remove_user_admin": "Succeeded to remove {{username}} admin",
+    "activate_user_success": "Succeeded to activating {{username}}",
+    "deactivate_user_success": "Succeeded to deactivate {{username}}",
+    "remove_user_success": "Succeeded to removing {{username}}",
+    "remove_external_user_success": "Succeeded to remove {{accountId}}",
+    "switch_disable_link_sharing_success": "Succeeded to update share link setting",
+    "failed_to_reset_password":"Failed to reset password"
   }
 }

+ 16 - 2
packages/app/public/static/locales/en_US/commons.json

@@ -6,12 +6,26 @@
     "create_succeeded": "Succeeded to create {{target}}",
     "create_failed": "Failed to create {{target}}",
     "update_successed": "Succeeded to update {{target}}",
-    "update_failed": "Failed to update {{target}}"
+    "update_failed": "Failed to update {{target}}",
+
+    "remove_share_link_success": "Succeeded to remove {{shareLinkId}}",
+    "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"
+  },
+
+  "share_links": {
+    "Share Link": "Share Link",
+    "Page Path": "Page Path",
+    "expire": "Expiration",
+    "description": "Description"
+  },
+  "not_found_page": {
+    "page_not_exist": "This page does not exist.",
   }
 }

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

@@ -78,7 +78,6 @@
   "username": "Username",
   "Created": "Created",
   "Last updated": "Updated",
-  "last_login": "Last login",
   "Share": "Share",
   "Markdown Link": "Markdown Link",
   "Create/Edit Template": "Create/Edit template page",
@@ -129,8 +128,6 @@
   "Only me": "Only me",
   "Only inside the group": "Only inside the group",
   "page_list": "Page List",
-  "scope_of_page_disclosure": "Scope of page disclosure",
-  "set_point": "Set point",
   "Reselect the group": "Reselect the group",
   "Shareable link": "Shareable link",
   "The whitelist of registration permission E-mail address": "The whitelist of registration permission E-mail address",
@@ -175,11 +172,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."
   },
@@ -240,10 +232,6 @@
     "Shere this page link to public": "Shere this page link to public",
     "share_link_list": "Share link list",
     "share_link_management": "Share Link Management",
-    "No_share_links":"No share links",
-    "Share Link": "Share Link",
-    "Page Path": "Page Path",
-    "share_link_notice":"remove all share links",
     "delete_all_share_links":"Delete all share links",
     "expire": "Expiration",
     "Days": "Days",
@@ -526,19 +514,8 @@
   "toaster": {
     "file_upload_succeeded": "File upload succeeded.",
     "file_upload_failed": "File upload failed.",
-    "initialize_successed": "Succeeded to initialize {{target}}",
-    "give_user_admin": "Succeeded to give {{username}} admin",
-    "remove_user_admin": "Succeeded to remove {{username}} admin",
-    "activate_user_success": "Succeeded to activating {{username}}",
-    "deactivate_user_success": "Succeeded to deactivate {{username}}",
-    "remove_user_success": "Succeeded to removing {{username}}",
-    "remove_external_user_success": "Succeeded to remove {{accountId}}",
-    "remove_share_link_success": "Succeeded to remove {{shareLinkId}}",
-    "issue_share_link": "Succeeded to issue new share link",
-    "remove_share_link": "Succeeded to remove {{count}} share links",
-    "switch_disable_link_sharing_success": "Succeeded to update share link setting",
-    "failed_to_reset_password":"Failed to reset password",
-    "save_succeeded": "Saved successfully"
+    "save_succeeded": "Saved successfully",
+    "issue_share_link": "Succeeded to issue new share link"
   },
   "template": {
     "modal_label": {
@@ -703,6 +680,7 @@
     "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.",
     "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.",

+ 16 - 2
packages/app/public/static/locales/ja_JP/admin.json

@@ -86,6 +86,10 @@
       "restricted": "制限 (登録完了には管理者の承認が必要)",
       "closed": "非公開 (登録には管理者による招待が必要)"
     },
+    "share_link_management": "共有リンク管理",
+    "No_share_links":"共有リンクが存在しません",
+    "share_link_notice":"共有リンクを全て削除します",
+    "delete_all_share_links":"全ての共有リンクを削除します",
     "share_link_rights": "シェアリンクの権限",
     "enable_link_sharing": "リンクのシェアを許可",
     "all_share_links": "全てのシェアリンク",
@@ -108,7 +112,6 @@
       "email_authentication": "ユーザー登録時のメール認証",
       "enable_email_authentication": "メール認証を有効にする",
       "enable_email_authentication_desc": "ユーザー登録時にメール認証を行います。",
-      "please_enable_mailer": "メール認証を有効にするには、メール設定を完了させてください。",
       "need_complete_mail_setting_warning": "以下の機能を使えるようにするには、メール設定を完了させてください。"
     },
     "ldap": {
@@ -276,7 +279,8 @@
     "delete_notification_pattern": "通知パターンを削除しました。",
     "delete_notification_pattern_desc1": "Path: {{path}} を削除します。",
     "delete_notification_pattern_desc2": "Once deleted, it cannot be recovered",
-    "toggle_notification": "{{path}}の通知設定を変更しました"
+    "toggle_notification": "{{path}}の通知設定を変更しました",
+    "not_found_global_notification_triggerid": "アクセス先の通知設定は見つかりませんでした"
   },
   "full_text_search_management": {
     "full_text_search_management": "全文検索管理",
@@ -1034,5 +1038,15 @@
     "ADMIN_SEARCH_CONNECTION": "Elasticsearch の再接続の試行",
     "ADMIN_SEARCH_INDICES_NORMALIZE": "Elasticsearch のインデックスの正規化",
     "ADMIN_SEARCH_INDICES_REBUILD": "Elasticsearch のインデックスのリビルド"
+  },
+  "toaster": {
+    "give_user_admin": "{{username}}を管理者に設定しました",
+    "remove_user_admin": "{{username}}を管理者から外しました",
+    "activate_user_success": "{{username}}を有効化しました",
+    "deactivate_user_success": "{{username}}を無効化しました",
+    "remove_user_success": "{{username}}を削除しました",
+    "remove_external_user_success": "{{accountId}}を削除しました",
+    "switch_disable_link_sharing_success": "共有リンクの設定を変更しました",
+    "failed_to_reset_password":"パスワードのリセットに失敗しました"
   }
 }

+ 16 - 2
packages/app/public/static/locales/ja_JP/commons.json

@@ -6,12 +6,26 @@
     "create_succeeded": "新しい{{target}}が作成されました",
     "create_failed": "{{target}}の作成に失敗しました",
     "update_successed": "{{target}}を更新しました",
-    "update_failed": "{{target}}の更新に失敗しました"
+    "update_failed": "{{target}}の更新に失敗しました",
+
+    "remove_share_link_success": "{{shareLinkId}}を削除しました",
+    "remove_share_link": "共有リンクを{{count}}件削除しました"
   },
   "alert": {
-    "siteUrl_is_not_set": "'サイトURL' が設定されていません。{{link}} から設定してください。"
+    "siteUrl_is_not_set": "'サイトURL' が設定されていません。{{link}} から設定してください。",
+    "please_enable_mailer": "メール認証を有効にするには、メール設定を完了させてください。"
   },
   "headers": {
     "app_settings": "アプリ設定"
+  },
+
+  "share_links": {
+    "Share Link": "共有用リンク",
+    "Page Path": "ページパス",
+    "expire": "有効期限",
+    "description": "概要"
+  },
+  "not_found_page": {
+    "page_not_exist": "このページは存在しません。"
   }
 }

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

@@ -168,11 +168,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": "パスを作成できませんでした。"
   },
@@ -233,10 +228,6 @@
     "Shere this page link to public": "外部に共有するリンクを発行する",
     "share_link_list": "共有リンクリスト",
     "share_link_management": "共有リンク管理",
-    "No_share_links":"共有リンクが存在しません",
-    "Share Link": "共有用リンク",
-    "Page Path": "ページパス",
-    "share_link_notice":"共有リンクを全て削除します",
     "delete_all_share_links":"全ての共有リンクを削除します",
     "expire": "有効期限",
     "Days": "日間",
@@ -517,19 +508,8 @@
   "toaster": {
     "file_upload_succeeded": "ファイルをアップロードしました",
     "file_upload_failed": "ファイルのアップロードに失敗しました",
-    "initialize_successed": "{{target}}を初期化しました",
-    "give_user_admin": "{{username}}を管理者に設定しました",
-    "remove_user_admin": "{{username}}を管理者から外しました",
-    "activate_user_success": "{{username}}を有効化しました",
-    "deactivate_user_success": "{{username}}を無効化しました",
-    "remove_user_success": "{{username}}を削除しました",
-    "remove_external_user_success": "{{accountId}}を削除しました",
-    "remove_share_link_success": "{{shareLinkId}}を削除しました",
-    "issue_share_link": "共有リンクを作成しました",
-    "remove_share_link": "共有リンクを{{count}}件削除しました",
-    "switch_disable_link_sharing_success": "共有リンクの設定を変更しました",
-    "failed_to_reset_password":"パスワードのリセットに失敗しました",
-    "save_succeeded": "保存に成功しました"
+    "save_succeeded": "保存に成功しました",
+    "issue_share_link": "共有リンクを作成しました"
   },
   "template": {
     "modal_label": {
@@ -694,6 +674,7 @@
     "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 設定が完了していません。管理者に問い合わせてください。",
     "failed_to_register":"登録に失敗しました。",
     "successfully_created":"{{username}} が作成されました。",
     "can_not_activate_maximum_number_of_users":"ユーザーが上限に達したためアクティベートできません。",

+ 65 - 1
packages/app/public/static/locales/zh_CN/admin.json

@@ -9,6 +9,7 @@
   "Created": "创建",
   "Edit": "编辑",
   "Description": "描述",
+  "last_login": "上次登录",
   "wiki_management_home_page": "Wiki管理首页",
   "public": "公共",
   "anyone_with_the_link": "任何人",
@@ -87,6 +88,10 @@
 			"restricted": "受限(需要管理员批准)",
 			"closed": "已关闭(仅限邀请)"
 		},
+    "share_link_management": "Share Link Management",
+    "No_share_links":"No share links",
+    "share_link_notice":"remove all share links",
+    "delete_all_share_links":"Delete all share links",
     "share_link_rights": "分享链接权",
     "enable_link_sharing": "启用链接共享",
     "all_share_links": "所有共享链接",
@@ -109,7 +114,6 @@
       "email_authentication": "用户注册时的电子邮件身份验证",
       "enable_email_authentication": "启用电子邮件身份验证",
       "enable_email_authentication_desc": "用户注册将执行电子邮件身份验证。",
-      "please_enable_mailer": "请先设置邮件程序。",
       "need_complete_mail_setting_warning": "要使用以下功能,请完成邮件设置。"
 		},
 		"ldap": {
@@ -233,6 +237,56 @@
 			"ABLCRule": "Rule"
 		}
   },
+  "notification_settings": {
+		"slack_incoming_configuration": "Slack Incoming Webhooks configuration",
+		"prioritize_webhook": "Prioritize incoming webhook than Slack App",
+		"prioritize_webhook_desc": "Check this option and GROWI use Incoming Webhooks even if Slack App settings are enabled.",
+		"slack_app_configuration": "Slack app configuration",
+		"slack_app_configuration_desc": "This is the way that compatible with Crowi,<br /> but not recommended in GROWI because it is <strong>too complex</strong>.",
+		"use_instead": "Please use Slack Incoming Webhooks Configuration instead.",
+		"how_to": {
+			"header": "How to configure Incoming Webhooks?",
+			"workspace": "(At Workspace) Add a hook",
+			"workspace_desc1": "Go to <a href='https://slack.com/services/new/incoming-webhook'>Incoming Webhooks configuration page</a>.",
+			"workspace_desc2": "Choose the default channel to post.",
+			"workspace_desc3": "Add.",
+			"at_growi": "(At GROWI admin page) Set Webhook URL",
+			"at_growi_desc": "Input &rdquo;Webhook URL&rdquo; and submit on this page."
+		},
+		"user_trigger_notification_header": "Default notification settings for patterns",
+		"pattern": "Pattern",
+		"channel": "Channel",
+		"pattern_desc": "Path name of wiki. Pattern expression with <code>*</code> can be used.",
+		"channel_desc": "Slack channel name. Without <code>#</code>.",
+		"valid_page": "启用/禁用通知",
+		"link_notification_help": "<strong>只有那些知道“链接的任何人”链接的人才能查看的页面并不总是得到通知。</strong> ",
+		"just_me_notification_help": "<strong>被“仅限我”限制的页在编辑时被通知。</strong>",
+		"group_notification_help": "<strong>被“用户组”限制的页面在编辑时被通知。</strong>",
+		"notification_list": "List of notification settings",
+		"add_notification": "Add new",
+		"trigger_path": "Trigger path",
+		"trigger_path_help": "(expression with <code>*</code> is supported)",
+		"trigger_events": "Trigger events",
+		"notify_to": "Notify to",
+		"back_to_list": "Go back to list",
+		"notification_detail": "Notification Setting Details",
+		"event_pageCreate": "When new page is \"CREATED\"",
+		"event_pageEdit": "When page is \"EDITED\"",
+		"event_pageDelete": "When page is \"DELETED\"",
+		"event_pageMove": "When page is \"MOVED\" (renamed)",
+		"event_pageLike": "When someone \"LIKES\" page",
+		"event_comment": "When someone \"COMMENTS\" on page",
+		"email": {
+			"ifttt_link": "Create a new IFTTT applet with Email trigger"
+		},
+		"updated_slackApp": "Succeeded to update Slack App Configuration setting",
+		"add_notification_pattern": "Add user trigger notification patterns",
+		"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}}",
+    "not_found_global_notification_triggerid": "未找到全局通知 ID"
+	},
   "full_text_search_management": {
     "full_text_search_management": "全文搜索管理",
 		"elasticsearch_management": "Elasticsearch管理",
@@ -1000,5 +1054,15 @@
     "ADMIN_SEARCH_CONNECTION": "重试Elasticsearch连接",
     "ADMIN_SEARCH_INDICES_NORMALIZE": "试图重新连接Elasticsearch",
     "ADMIN_SEARCH_INDICES_REBUILD": "重建 Elasticsearch 索引"
+  },
+  "toaster": {
+    "give_user_admin": "Succeeded to give {{username}} admin",
+    "remove_user_admin": "Succeeded to remove {{username}} admin ",
+		"activate_user_success": "Succeeded to activating {{username}}",
+		"deactivate_user_success": "Succeeded to deactivate {{username}}",
+    "remove_user_success": "Succeeded to removing {{username}}",
+    "remove_external_user_success": "Succeeded to remove {{accountId}}",
+    "switch_disable_link_sharing_success": "成功更新分享链接设置",
+    "failed_to_reset_password":"Failed to reset password"
   }
 }

+ 16 - 2
packages/app/public/static/locales/zh_CN/commons.json

@@ -6,12 +6,26 @@
     "create_succeeded": "Succeeded to create {{target}}",
     "create_failed": "Failed to create {{target}}",
     "update_successed": "Succeeded to update {{target}}",
-    "update_failed": "Failed to update {{target}}"
+    "update_failed": "Failed to update {{target}}",
+
+    "remove_share_link_success": "Succeeded to remove {{shareLinkId}}",
+    "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": "系统设置"
+  },
+
+  "share_links": {
+    "Share Link": "Share Link",
+    "Page Path": "Page Path",
+    "expire": "Expiration",
+    "description": "Description"
+  },
+  "not_found_page": {
+    "page_not_exist": "该页面不存在"
   }
 }

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

@@ -73,7 +73,6 @@
   "username": "用户名",
 	"Created": "创建",
 	"Last updated": "上次更新",
-  "last_login": "上次登录",
 	"Share": "分享",
   "Share Link": "分享链接",
 	"Markdown Link": "Markdown链接",
@@ -170,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": "无法创建路径"
   },
@@ -498,16 +492,8 @@
 	"toaster": {
     "file_upload_succeeded": "文件上传成功",
     "file_upload_failed": "文件上传失败",
-    "initialize_successed": "Succeeded to initialize {{target}}",
-		"give_user_admin": "Succeeded to give {{username}} admin",
-    "remove_user_admin": "Succeeded to remove {{username}} admin ",
-		"activate_user_success": "Succeeded to activating {{username}}",
-		"deactivate_user_success": "Succeeded to deactivate {{username}}",
-		"remove_user_success": "Succeeded to removing {{username}} ",
-    "remove_external_user_success": "Succeeded to remove {{accountId}} ",
-    "switch_disable_link_sharing_success": "成功更新分享链接设置",
-    "failed_to_reset_password":"Failed to reset password",
-    "save_succeeded": "已成功保存"
+    "save_succeeded": "已成功保存",
+    "issue_share_link": "Succeeded to issue new share link"
   },
 	"template": {
 		"modal_label": {
@@ -587,10 +573,6 @@
     "Shere this page link to public": "Shere this page link to public",
     "share_link_list": "Share link list",
     "share_link_management": "Share Link Management",
-    "No_share_links":"No share links",
-    "Share Link": "Share Link",
-    "Page Path": "Page Path",
-    "share_link_notice":"remove all share links",
     "delete_all_share_links":"Delete all share links",
     "expire": "Expiration",
     "Days": "Days",
@@ -603,55 +585,6 @@
     "Invalid_Number_of_Date" : "You entered invalid value",
     "link_sharing_is_disabled": "链接共享已被禁用"
   },
-	"notification_setting": {
-		"slack_incoming_configuration": "Slack Incoming Webhooks configuration",
-		"prioritize_webhook": "Prioritize incoming webhook than Slack App",
-		"prioritize_webhook_desc": "Check this option and GROWI use Incoming Webhooks even if Slack App settings are enabled.",
-		"slack_app_configuration": "Slack app configuration",
-		"slack_app_configuration_desc": "This is the way that compatible with Crowi,<br /> but not recommended in GROWI because it is <strong>too complex</strong>.",
-		"use_instead": "Please use Slack Incoming Webhooks Configuration instead.",
-		"how_to": {
-			"header": "How to configure Incoming Webhooks?",
-			"workspace": "(At Workspace) Add a hook",
-			"workspace_desc1": "Go to <a href='https://slack.com/services/new/incoming-webhook'>Incoming Webhooks configuration page</a>.",
-			"workspace_desc2": "Choose the default channel to post.",
-			"workspace_desc3": "Add.",
-			"at_growi": "(At GROWI admin page) Set Webhook URL",
-			"at_growi_desc": "Input &rdquo;Webhook URL&rdquo; and submit on this page."
-		},
-		"user_trigger_notification_header": "Default notification settings for patterns",
-		"pattern": "Pattern",
-		"channel": "Channel",
-		"pattern_desc": "Path name of wiki. Pattern expression with <code>*</code> can be used.",
-		"channel_desc": "Slack channel name. Without <code>#</code>.",
-		"valid_page": "启用/禁用通知",
-		"link_notification_help": "<strong>只有那些知道“链接的任何人”链接的人才能查看的页面并不总是得到通知。</strong> ",
-		"just_me_notification_help": "<strong>被“仅限我”限制的页在编辑时被通知。</strong>",
-		"group_notification_help": "<strong>被“用户组”限制的页面在编辑时被通知。</strong>",
-		"notification_list": "List of notification settings",
-		"add_notification": "Add new",
-		"trigger_path": "Trigger path",
-		"trigger_path_help": "(expression with <code>*</code> is supported)",
-		"trigger_events": "Trigger events",
-		"notify_to": "Notify to",
-		"back_to_list": "Go back to list",
-		"notification_detail": "Notification Setting Details",
-		"event_pageCreate": "When new page is \"CREATED\"",
-		"event_pageEdit": "When page is \"EDITED\"",
-		"event_pageDelete": "When page is \"DELETED\"",
-		"event_pageMove": "When page is \"MOVED\" (renamed)",
-		"event_pageLike": "When someone \"LIKES\" page",
-		"event_comment": "When someone \"COMMENTS\" on page",
-		"email": {
-			"ifttt_link": "Create a new IFTTT applet with Email trigger"
-		},
-		"updated_slackApp": "Succeeded to update Slack App Configuration setting",
-		"add_notification_pattern": "Add user trigger notification patterns",
-		"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}}"
-	},
 	"personal_dropdown": {
 		"home": "家",
 		"settings": "设置",
@@ -749,6 +682,7 @@
     "username_should_not_be_null":"用户名不应为空。请检查管理页面上的身份验证机制设置",
 		"email_address_is_already_registered": "此电子邮件地址已注册。",
 		"can_not_register_maximum_number_of_users": "注册的用户数不能超过最大值。",
+    "email_settings_is_not_setup":"邮箱设置未设置,请询问管理员。",
 		"failed_to_register": "注册失败。",
 		"successfully_created": "已成功创建用户{{username}。",
 		"can_not_activate_maximum_number_of_users": "无法激活超过最大用户数的用户。",

+ 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/client/util/editor.ts

@@ -1,4 +1,4 @@
-import { OptionsToSave } from '~/interfaces/editor-settings';
+import type { OptionsToSave } from '~/interfaces/editor-settings';
 
 export const getOptionsToSave = (
     isSlackEnabled: boolean,

+ 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') }));
+      toastSuccess(t('toaster.update_successed', { target: t('commons:headers.app_settings'), ns: 'commons' }));
     }
     catch (err) {
       toastError(err);

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

+ 0 - 91
packages/app/src/components/Admin/ManageExternalAccount.jsx

@@ -1,91 +0,0 @@
-import React, { Fragment } from 'react';
-
-import { useTranslation } from 'next-i18next';
-import PropTypes from 'prop-types';
-
-import AdminExternalAccountsContainer from '~/client/services/AdminExternalAccountsContainer';
-import { toastError } from '~/client/util/apiNotification';
-
-import PaginationWrapper from '../PaginationWrapper';
-import { withUnstatedContainers } from '../UnstatedUtils';
-
-import ExternalAccountTable from './Users/ExternalAccountTable';
-
-
-class ManageExternalAccount extends React.Component {
-
-  constructor(props) {
-    super(props);
-    this.handleExternalAccountPage = this.handleExternalAccountPage.bind(this);
-  }
-
-  UNSAFE_componentWillMount() {
-    this.handleExternalAccountPage(1);
-  }
-
-  async handleExternalAccountPage(selectedPage) {
-    try {
-      await this.props.adminExternalAccountsContainer.retrieveExternalAccountsByPagingNum(selectedPage);
-    }
-    catch (err) {
-      toastError(err);
-    }
-  }
-
-  render() {
-    const { t, adminExternalAccountsContainer } = this.props;
-    const { activePage, totalAccounts, pagingLimit } = adminExternalAccountsContainer.state;
-
-
-    const pager = (
-      <PaginationWrapper
-        activePage={activePage}
-        changePage={this.handleExternalAccountPage}
-        totalItemsCount={totalAccounts}
-        pagingLimit={pagingLimit}
-        align="center"
-        size="sm"
-      />
-    );
-    return (
-      <Fragment>
-        <p>
-          <a className="btn btn-outline-secondary" href="/admin/users">
-            <i className="icon-fw ti-arrow-left" aria-hidden="true"></i>
-            {t('admin:user_management.back_to_user_management')}
-          </a>
-        </p>
-
-        <h2>{t('admin:user_management.external_account_list')}</h2>
-        {(totalAccounts !== 0) ? (
-          <>
-            {pager}
-            <ExternalAccountTable />
-            {pager}
-          </>
-        )
-          : (
-            <>
-              {t('admin:user_management.external_account_none')}
-            </>
-          )}
-
-      </Fragment>
-    );
-  }
-
-}
-
-ManageExternalAccount.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-  adminExternalAccountsContainer: PropTypes.instanceOf(AdminExternalAccountsContainer).isRequired,
-};
-
-const ManageExternalAccountWrapperFC = (props) => {
-  const { t } = useTranslation();
-  return <ManageExternalAccount t={t} {...props} />;
-};
-
-const ManageExternalAccountWrapper = withUnstatedContainers(ManageExternalAccountWrapperFC, [AdminExternalAccountsContainer]);
-
-export default ManageExternalAccountWrapper;

+ 79 - 0
packages/app/src/components/Admin/ManageExternalAccount.tsx

@@ -0,0 +1,79 @@
+import React, { useCallback, useEffect } from 'react';
+
+import { useTranslation } from 'next-i18next';
+import Link from 'next/link';
+
+import AdminExternalAccountsContainer from '~/client/services/AdminExternalAccountsContainer';
+import { toastError } from '~/client/util/apiNotification';
+
+import PaginationWrapper from '../PaginationWrapper';
+import { withUnstatedContainers } from '../UnstatedUtils';
+
+import ExternalAccountTable from './Users/ExternalAccountTable';
+
+type ManageExternalAccountProps = {
+  adminExternalAccountsContainer: AdminExternalAccountsContainer,
+}
+
+const ManageExternalAccount = (props: ManageExternalAccountProps): JSX.Element => {
+
+  const { t } = useTranslation();
+  const { adminExternalAccountsContainer } = props;
+  const { activePage, totalAccounts, pagingLimit } = adminExternalAccountsContainer.state;
+
+  const externalAccountPageHandler = useCallback(async(selectedPage) => {
+    try {
+      await adminExternalAccountsContainer.retrieveExternalAccountsByPagingNum(selectedPage);
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }, [adminExternalAccountsContainer]);
+
+  // for Next routing
+  useEffect(() => {
+    externalAccountPageHandler(1);
+  }, [externalAccountPageHandler]);
+
+  const pager = (
+    <PaginationWrapper
+      activePage={activePage}
+      changePage={externalAccountPageHandler}
+      totalItemsCount={totalAccounts}
+      pagingLimit={pagingLimit}
+      align="center"
+      size="sm"
+    />
+  );
+
+  return (
+    <>
+      <p>
+        <Link href="/admin/users" prefetch={false}>
+          <a className="btn btn-outline-secondary">
+            <i className="icon-fw ti ti-arrow-left" aria-hidden="true"></i>
+            {t('admin:user_management.back_to_user_management')}
+          </a>
+        </Link>
+      </p>
+      <h2>{t('admin:user_management.external_account_list')}</h2>
+      {(totalAccounts !== 0) ? (
+        <>
+          {pager}
+          <ExternalAccountTable />
+          {pager}
+        </>
+      )
+        : (
+          <>
+            { t('admin:user_management.external_account_none') }
+          </>
+        )
+      }
+    </>
+  );
+};
+
+const ManageExternalAccountWrapper = withUnstatedContainers(ManageExternalAccount, [AdminExternalAccountsContainer]);
+
+export default ManageExternalAccountWrapper;

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

+ 85 - 54
packages/app/src/components/Admin/Notification/ManageGlobalNotification.jsx → packages/app/src/components/Admin/Notification/ManageGlobalNotification.tsx

@@ -1,16 +1,18 @@
-import React, { useCallback, useState } from 'react';
+import React, {
+  useCallback, useMemo, useEffect, useState,
+} from 'react';
 
 import { useTranslation } from 'next-i18next';
-import PropTypes from 'prop-types';
+import { useRouter } from 'next/router';
 
-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';
@@ -18,18 +20,50 @@ import TriggerEventCheckBox from './TriggerEventCheckBox';
 
 const logger = loggerFactory('growi:manageGlobalNotification');
 
-const ManageGlobalNotification = (props) => {
 
-  let globalNotification;
-  // TODO: securely fetch the data of globalNotification variable without using swig. URL https://redmine.weseek.co.jp/issues/103901
-  // globalNotification = JSON.parse(document.getElementById('admin-global-notification-setting').getAttribute('data-global-notification'));
+type Props = {
+  globalNotificationId?: string,
+}
+
+const ManageGlobalNotification = (props: Props): JSX.Element => {
 
-  const [globalNotificationId, setGlobalNotificationId] = useState(null);
   const [triggerPath, setTriggerPath] = useState('');
-  const [notifyToType, setNotifyToType] = useState('mail');
+  const [notifyType, setNotifyType] = useState<NotifyType>(NotifyType.Email);
   const [emailToSend, setEmailToSend] = useState('');
   const [slackChannelToSend, setSlackChannelToSend] = useState('');
-  const [triggerEvents, setTriggerEvents] = useState(new Set(globalNotification?.triggerEvents));
+  const [triggerEvents, setTriggerEvents] = useState(new Set());
+  const { data: globalNotificationData, update: updateGlobalNotification } = useSWRxGlobalNotification(props.globalNotificationId || '');
+  const globalNotification = useMemo(() => globalNotificationData?.globalNotification, [globalNotificationData?.globalNotification]);
+
+  const router = useRouter();
+
+
+  useEffect(() => {
+    if (globalNotification != null) {
+      const notifyType = globalNotification.__t;
+      setNotifyType(notifyType);
+
+      setTriggerPath(globalNotification.triggerPath);
+      setTriggerEvents(new Set(globalNotification.triggerEvents));
+
+      if (notifyType === NotifyType.Email) {
+        setEmailToSend(globalNotification.toEmail);
+      }
+      else {
+        setSlackChannelToSend(globalNotification.slackChannels);
+      }
+    }
+  }, [globalNotification]);
+
+  const isLoading = globalNotificationData === undefined;
+  const notExistsGlobalNotification = !isLoading && globalNotificationData == null;
+
+  useEffect(() => {
+    if (notExistsGlobalNotification) {
+      router.push('/admin/notification');
+    }
+  }, [notExistsGlobalNotification, router]);
+
 
   const onChangeTriggerEvents = useCallback((triggerEvent) => {
     let newTriggerEvents;
@@ -44,32 +78,34 @@ const ManageGlobalNotification = (props) => {
     }
   }, [triggerEvents]);
 
-  const updateButtonClickedHandler = useCallback(async() => {
 
+  const updateButtonClickedHandler = useCallback(async() => {
     const requestParams = {
       triggerPath,
-      notifyToType,
+      notifyType,
       toEmail: emailToSend,
       slackChannels: slackChannelToSend,
       triggerEvents: [...triggerEvents],
     };
 
     try {
-      if (globalNotificationId != null) {
-        await apiv3Put(`/notification-setting/global-notification/${globalNotificationId}`, requestParams);
+      if (props.globalNotificationId != null) {
+        await updateGlobalNotification(requestParams);
+        router.push('/admin/notification');
       }
       else {
         await apiv3Post('/notification-setting/global-notification', requestParams);
+        router.push('/admin/notification');
       }
     }
     catch (err) {
       toastError(err);
       logger.error(err);
     }
-  }, [emailToSend, globalNotificationId, notifyToType, slackChannelToSend, triggerEvents, triggerPath]);
+  }, [emailToSend, notifyType, props.globalNotificationId, router, slackChannelToSend, triggerEvents, triggerPath, updateGlobalNotification]);
+
 
   const { data: isMailerSetup } = useIsMailerSetup();
-  const { adminNotificationContainer } = props;
   const { t } = useTranslation('admin');
 
   return (
@@ -88,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
@@ -110,10 +148,10 @@ const ManageGlobalNotification = (props) => {
                 className="custom-control-input"
                 type="radio"
                 id="mail"
-                name="notifyToType"
+                name="notifyType"
                 value="mail"
-                checked={notifyToType === 'mail'}
-                onChange={() => { setNotifyToType('mail') }}
+                checked={notifyType === NotifyType.Email}
+                onChange={() => { setNotifyType(NotifyType.Email) }}
               />
               <label className="custom-control-label" htmlFor="mail">
                 <p className="font-weight-bold">Email</p>
@@ -124,10 +162,10 @@ const ManageGlobalNotification = (props) => {
                 className="custom-control-input"
                 type="radio"
                 id="slack"
-                name="notifyToType"
+                name="notifyType"
                 value="slack"
-                checked={notifyToType === 'slack'}
-                onChange={() => { setNotifyToType('slack') }}
+                checked={notifyType === NotifyType.SLACK}
+                onChange={() => { setNotifyType(NotifyType.SLACK) }}
               />
               <label className="custom-control-label" htmlFor="slack">
                 <p className="font-weight-bold">Slack</p>
@@ -135,7 +173,7 @@ const ManageGlobalNotification = (props) => {
             </div>
           </div>
 
-          {notifyToType === 'mail'
+          {notifyType === NotifyType.Email
             ? (
               <>
                 <div className="input-group notify-to-option" id="mail-input">
@@ -194,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
@@ -206,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
@@ -218,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
@@ -231,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
@@ -242,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
@@ -254,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
@@ -270,17 +308,10 @@ const ManageGlobalNotification = (props) => {
 
       <AdminUpdateButtonRow
         onClick={updateButtonClickedHandler}
-        disabled={adminNotificationContainer.state.retrieveError != null}
+        disabled={false}
       />
     </>
   );
 };
 
-ManageGlobalNotification.propTypes = {
-  adminNotificationContainer: PropTypes.instanceOf(AdminNotificationContainer).isRequired,
-};
-
-const ManageGlobalNotificationWrapper = withUnstatedContainers(ManageGlobalNotification, [AdminNotificationContainer]);
-
-
-export default ManageGlobalNotificationWrapper;
+export default ManageGlobalNotification;

+ 4 - 4
packages/app/src/components/Admin/Security/DeleteAllShareLinksModal.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 {
   Button, Modal, ModalHeader, ModalBody, ModalFooter,
 } from 'reactstrap';
@@ -36,11 +36,11 @@ const DeleteAllShareLinksModal = React.memo((props) => {
       <ModalHeader tag="h4" toggle={closeButtonHandler} className="bg-danger text-light">
         <span>
           <i className="icon-fw icon-fire"></i>
-          {t('share_links.delete_all_share_links')}
+          {t('security_settings.delete_all_share_links')}
         </span>
       </ModalHeader>
       <ModalBody>
-        { t('share_links.share_link_notice')}
+        { t('security_settings.share_link_notice')}
       </ModalBody>
       <ModalFooter>
         <Button onClick={closeButtonHandler}>{t('Cancel')}</Button>
@@ -66,7 +66,7 @@ DeleteAllShareLinksModal.propTypes = {
 
 // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
 const DeleteAllShareLinksModalWrapperFC = (props) => {
-  const { t } = useTranslation();
+  const { t } = useTranslation('admin');
 
   return <DeleteAllShareLinksModal t={t} {...props} />;
 };

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

+ 6 - 3
packages/app/src/components/Admin/Security/SecurityManagementContents.jsx

@@ -1,6 +1,7 @@
 import React, { useMemo, useState } from 'react';
 
 import { useTranslation } from 'next-i18next';
+import Link from 'next/link';
 import { TabContent, TabPane } from 'reactstrap';
 
 import CustomNav from '../../CustomNavigation/CustomNav';
@@ -95,9 +96,11 @@ const SecurityManagementContents = () => {
       <div className="mb-5">
         <h2 className="border-bottom">{t('security_settings.xss_prevent_setting')}</h2>
         <div className="text-center">
-          <a style={{ fontSize: 'large' }} href="/admin/markdown/#preventXSS">
-            <i className="fa-fw icon-login"></i> {t('security_settings.xss_prevent_setting_link')}
-          </a>
+          <Link href="/admin/markdown/#preventXSS" prefetch={false}>
+            <a style={{ fontSize: 'large' }}>
+              <i className="fa-fw icon-login"></i> {t('security_settings.xss_prevent_setting_link')}
+            </a>
+          </Link>
         </div>
       </div>
 

+ 0 - 208
packages/app/src/components/Admin/Security/ShareLinkSetting.jsx

@@ -1,208 +0,0 @@
-import React, { Fragment } from 'react';
-
-import { useTranslation } from 'next-i18next';
-import PropTypes from 'prop-types';
-
-import AdminGeneralSecurityContainer from '~/client/services/AdminGeneralSecurityContainer';
-import { toastSuccess, toastError } from '~/client/util/apiNotification';
-import { apiv3Delete } from '~/client/util/apiv3-client';
-
-import PaginationWrapper from '../../PaginationWrapper';
-import ShareLinkList from '../../ShareLink/ShareLinkList';
-import { withUnstatedContainers } from '../../UnstatedUtils';
-
-
-import DeleteAllShareLinksModal from './DeleteAllShareLinksModal';
-
-const Pager = (props) => {
-  if (props.links.length === 0) {
-    return null;
-  }
-  return (
-    <PaginationWrapper
-      activePage={props.activePage}
-      changePage={props.handlePage}
-      totalItemsCount={props.totalLinks}
-      pagingLimit={props.limit}
-      align="center"
-      size="sm"
-    />
-  );
-};
-
-Pager.propTypes = {
-  links: PropTypes.array.isRequired,
-  activePage: PropTypes.number.isRequired,
-  handlePage: PropTypes.func.isRequired,
-  totalLinks: PropTypes.number.isRequired,
-  limit: PropTypes.number.isRequired,
-};
-
-class ShareLinkSetting extends React.Component {
-
-  constructor() {
-    super();
-
-    this.state = {
-      isDeleteConfirmModalShown: false,
-    };
-    this.getShareLinkList = this.getShareLinkList.bind(this);
-    this.showDeleteConfirmModal = this.showDeleteConfirmModal.bind(this);
-    this.closeDeleteConfirmModal = this.closeDeleteConfirmModal.bind(this);
-    this.deleteAllLinksButtonHandler = this.deleteAllLinksButtonHandler.bind(this);
-    this.deleteLinkById = this.deleteLinkById.bind(this);
-    this.switchDisableLinkSharing = this.switchDisableLinkSharing.bind(this);
-  }
-
-  UNSAFE_componentWillMount() {
-    this.getShareLinkList(1);
-  }
-
-  async getShareLinkList(page) {
-    try {
-      await this.props.adminGeneralSecurityContainer.retrieveShareLinksByPagingNum(page);
-    }
-    catch (err) {
-      toastError(err);
-    }
-
-  }
-
-  showDeleteConfirmModal() {
-    this.setState({ isDeleteConfirmModalShown: true });
-  }
-
-  closeDeleteConfirmModal() {
-    this.setState({ isDeleteConfirmModalShown: false });
-  }
-
-  async deleteAllLinksButtonHandler() {
-    const { t } = this.props;
-
-    try {
-      const res = await apiv3Delete('/share-links/all');
-      const { deletedCount } = res.data;
-      toastSuccess(t('toaster.remove_share_link', { count: deletedCount }));
-    }
-    catch (err) {
-      toastError(err);
-    }
-    this.getShareLinkList(1);
-  }
-
-  async deleteLinkById(shareLinkId) {
-    const { t, adminGeneralSecurityContainer } = this.props;
-    const { shareLinksActivePage } = adminGeneralSecurityContainer.state;
-
-    try {
-      const res = await apiv3Delete(`/share-links/${shareLinkId}`);
-      const { deletedShareLink } = res.data;
-      toastSuccess(t('toaster.remove_share_link_success', { shareLinkId: deletedShareLink._id }));
-    }
-    catch (err) {
-      toastError(err);
-    }
-
-    this.getShareLinkList(shareLinksActivePage);
-  }
-
-  async switchDisableLinkSharing() {
-    const { t, adminGeneralSecurityContainer } = this.props;
-    try {
-      await adminGeneralSecurityContainer.switchDisableLinkSharing();
-      toastSuccess(t('toaster.switch_disable_link_sharing_success'));
-    }
-    catch (err) {
-      toastError(err);
-    }
-  }
-
-
-  render() {
-    const { t, adminGeneralSecurityContainer } = this.props;
-    const {
-      shareLinks, shareLinksActivePage, totalshareLinks, shareLinksPagingLimit, disableLinkSharing,
-    } = adminGeneralSecurityContainer.state;
-
-    return (
-      <Fragment>
-        <div className="mb-3">
-          <button
-            className="pull-right btn btn-danger"
-            disabled={shareLinks.length === 0}
-            type="button"
-            onClick={this.showDeleteConfirmModal}
-          >
-            {t('share_links.delete_all_share_links')}
-          </button>
-          <h2 className="alert-anchor border-bottom">{t('share_links.share_link_management')}</h2>
-        </div>
-        <h4>{t('security_settings.share_link_rights')}</h4>
-        <div className="row mb-5">
-          <div className="col-6 offset-3">
-            <div className="custom-control custom-switch custom-checkbox-success">
-              <input
-                type="checkbox"
-                className="custom-control-input"
-                id="disableLinkSharing"
-                checked={!disableLinkSharing}
-                onChange={() => this.switchDisableLinkSharing()}
-              />
-              <label className="custom-control-label" htmlFor="disableLinkSharing">
-                {t('security_settings.enable_link_sharing')}
-              </label>
-            </div>
-            {!adminGeneralSecurityContainer.state.setupStrategies.includes('local') && disableLinkSharing && (
-              <div className="badge badge-warning">{t('security_settings.setup_is_not_yet_complete')}</div>
-            )}
-          </div>
-        </div>
-        <h4>{t('security_settings.all_share_links')}</h4>
-        <Pager
-          links={shareLinks}
-          activePage={shareLinksActivePage}
-          handlePage={this.getShareLinkList}
-          totalLinks={totalshareLinks}
-          limit={shareLinksPagingLimit}
-        />
-
-        {(shareLinks.length !== 0) ? (
-          <ShareLinkList
-            shareLinks={shareLinks}
-            onClickDeleteButton={this.deleteLinkById}
-            isAdmin
-          />
-        )
-          : (<p className="text-center">{t('share_links.No_share_links')}</p>
-          )
-        }
-
-
-        <DeleteAllShareLinksModal
-          isOpen={this.state.isDeleteConfirmModalShown}
-          onClose={this.closeDeleteConfirmModal}
-          onClickDeleteButton={this.deleteAllLinksButtonHandler}
-        />
-
-      </Fragment>
-    );
-  }
-
-}
-
-ShareLinkSetting.propTypes = {
-  t: PropTypes.func.isRequired, //  i18next
-  adminGeneralSecurityContainer: PropTypes.instanceOf(AdminGeneralSecurityContainer).isRequired,
-};
-
-const ShareLinkSettingWrapperFC = (props) => {
-  const { t } = useTranslation('admin');
-  return <ShareLinkSetting t={t} {...props} />;
-};
-
-/**
- * Wrapper component for using unstated
- */
-const ShareLinkSettingWrapper = withUnstatedContainers(ShareLinkSettingWrapperFC, [AdminGeneralSecurityContainer]);
-
-export default ShareLinkSettingWrapper;

+ 170 - 0
packages/app/src/components/Admin/Security/ShareLinkSetting.tsx

@@ -0,0 +1,170 @@
+import React, {
+  useCallback, useEffect, useState,
+} from 'react';
+
+import { useTranslation } from 'next-i18next';
+
+import AdminGeneralSecurityContainer from '~/client/services/AdminGeneralSecurityContainer';
+import { toastSuccess, toastError } from '~/client/util/apiNotification';
+import { apiv3Delete } from '~/client/util/apiv3-client';
+
+import PaginationWrapper from '../../PaginationWrapper';
+import ShareLinkList from '../../ShareLink/ShareLinkList';
+import { withUnstatedContainers } from '../../UnstatedUtils';
+
+import DeleteAllShareLinksModal from './DeleteAllShareLinksModal';
+
+type PagerProps = {
+  activePage: number,
+  pagingHandler: (page: number) => Promise<void>,
+  totalLinks: number,
+  limit: number,
+}
+
+type ShareLinkSettingProps = {
+  adminGeneralSecurityContainer: AdminGeneralSecurityContainer,
+}
+
+const Pager = (props: PagerProps) => {
+  const {
+    activePage, pagingHandler, totalLinks, limit,
+  } = props;
+
+  return (
+    <PaginationWrapper
+      activePage={activePage}
+      changePage={pagingHandler}
+      totalItemsCount={totalLinks}
+      pagingLimit={limit}
+      align="center"
+      size="sm"
+    />
+  );
+};
+
+const ShareLinkSetting = (props: ShareLinkSettingProps) => {
+
+  const { t } = useTranslation('admin');
+  const { adminGeneralSecurityContainer } = props;
+  const {
+    shareLinks, shareLinksActivePage, totalshareLinks, shareLinksPagingLimit,
+    disableLinkSharing, setupStrategies,
+  } = adminGeneralSecurityContainer.state;
+  const [isDeleteConfirmModalShown, setIsDeleteConfirmModalShown] = useState<boolean>();
+
+  const getShareLinkList = useCallback(async(page: number) => {
+    try {
+      await adminGeneralSecurityContainer.retrieveShareLinksByPagingNum(page);
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }, [adminGeneralSecurityContainer]);
+
+  // for Next routing
+  useEffect(() => {
+    getShareLinkList(1);
+  }, [getShareLinkList]);
+
+  const deleteAllLinksButtonHandler = useCallback(async() => {
+    try {
+      const res = await apiv3Delete('/share-links/all');
+      const { deletedCount } = res.data;
+      toastSuccess(t('toaster.remove_share_link', { count: deletedCount, ns: 'commons' }));
+    }
+    catch (err) {
+      toastError(err);
+    }
+    getShareLinkList(1);
+  }, [getShareLinkList, t]);
+
+  const deleteLinkById = useCallback(async(shareLinkId: string) => {
+    try {
+      const res = await apiv3Delete(`/share-links/${shareLinkId}`);
+      const { deletedShareLink } = res.data;
+      toastSuccess(t('toaster.remove_share_link_success', { shareLinkId: deletedShareLink._id, ns: 'commons' }));
+    }
+    catch (err) {
+      toastError(err);
+    }
+    getShareLinkList(shareLinksActivePage);
+  }, [shareLinksActivePage, getShareLinkList, t]);
+
+  const switchDisableLinkSharing = useCallback(async() => {
+    try {
+      await adminGeneralSecurityContainer.switchDisableLinkSharing();
+      toastSuccess(t('toaster.switch_disable_link_sharing_success'));
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }, [adminGeneralSecurityContainer, t]);
+
+  return (
+    <>
+      <div className="mb-3">
+        <button
+          className="pull-right btn btn-danger"
+          disabled={shareLinks.length === 0}
+          type="button"
+          onClick={() => setIsDeleteConfirmModalShown(true)}
+        >
+          {t('security_settings.delete_all_share_links')}
+        </button>
+        <h2 className="alert-anchor border-bottom">{t('security_settings.share_link_management')}</h2>
+      </div>
+      <h4>{t('security_settings.share_link_rights')}</h4>
+      <div className="row mb-5">
+        <div className="col-6 offset-3">
+          <div className="custom-control custom-switch custom-checkbox-success">
+            <input
+              type="checkbox"
+              className="custom-control-input"
+              id="disableLinkSharing"
+              checked={!disableLinkSharing}
+              onChange={() => switchDisableLinkSharing()}
+            />
+            <label className="custom-control-label" htmlFor="disableLinkSharing">
+              {t('security_settings.enable_link_sharing')}
+            </label>
+          </div>
+          {!setupStrategies.includes('local') && disableLinkSharing && (
+            <div className="badge badge-warning">{t('security_settings.setup_is_not_yet_complete')}</div>
+          )}
+        </div>
+      </div>
+      <h4>{t('security_settings.all_share_links')}</h4>
+      <Pager
+        activePage={shareLinksActivePage}
+        pagingHandler={getShareLinkList}
+        totalLinks={totalshareLinks}
+        limit={shareLinksPagingLimit}
+      />
+
+      {(shareLinks.length !== 0) ? (
+        <ShareLinkList
+          shareLinks={shareLinks}
+          onClickDeleteButton={deleteLinkById}
+          isAdmin
+        />
+      )
+        : (<p className="text-center">{t('security_settings.No_share_links')}</p>
+        )
+      }
+
+      <DeleteAllShareLinksModal
+        isOpen={isDeleteConfirmModalShown}
+        onClose={() => setIsDeleteConfirmModalShown(false)}
+        onClickDeleteButton={deleteAllLinksButtonHandler}
+      />
+
+    </>
+  );
+};
+
+/**
+ * Wrapper component for using unstated
+ */
+const ShareLinkSettingWrapper = withUnstatedContainers(ShareLinkSetting, [AdminGeneralSecurityContainer]);
+
+export default ShareLinkSettingWrapper;

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

@@ -2,11 +2,11 @@ import React, {
   FC, useState, useEffect,
 } from 'react';
 
+import type { IUserGroupHasId, IUserGroupRelation, IUserHasId } from '@growi/core';
 import dateFnsFormat from 'date-fns/format';
 import { TFunctionResult } from 'i18next';
 import { useTranslation } from 'next-i18next';
 
-import { IUserGroupHasId, IUserGroupRelation, IUserHasId } from '~/interfaces/user';
 
 type Props = {
   headerLabel?: TFunctionResult,

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

@@ -37,7 +37,7 @@ const UpdateParentConfirmModal = dynamic(() => import('./UpdateParentConfirmModa
 
 
 type Props = {
-  userGroupId?: string,
+  userGroupId: string,
 }
 
 const UserGroupDetailPage = (props: Props): JSX.Element => {

+ 0 - 235
packages/app/src/components/Admin/UserManagement.jsx

@@ -1,235 +0,0 @@
-import React from 'react';
-
-import { useTranslation } from 'next-i18next';
-import PropTypes from 'prop-types';
-
-import AdminUsersContainer from '~/client/services/AdminUsersContainer';
-import { toastError } from '~/client/util/apiNotification';
-
-import PaginationWrapper from '../PaginationWrapper';
-import { withUnstatedContainers } from '../UnstatedUtils';
-
-
-import InviteUserControl from './Users/InviteUserControl';
-import PasswordResetModal from './Users/PasswordResetModal';
-import UserTable from './Users/UserTable';
-
-import styles from './UserManagement.module.scss';
-
-class UserManagement extends React.Component {
-
-  constructor(props) {
-    super();
-
-    this.state = {
-      isNotifyCommentShow: false,
-    };
-
-    this.handlePage = this.handlePage.bind(this);
-    this.handleChangeSearchText = this.handleChangeSearchText.bind(this);
-  }
-
-  UNSAFE_componentWillMount() {
-    this.handlePage(1);
-  }
-
-  async handlePage(selectedPage) {
-    try {
-      await this.props.adminUsersContainer.retrieveUsersByPagingNum(selectedPage);
-    }
-    catch (err) {
-      toastError(err);
-    }
-  }
-
-  /**
-   * For checking same check box twice
-   * @param {string} statusType
-   */
-  async handleClick(statusType) {
-    const { adminUsersContainer } = this.props;
-    if (!this.validateToggleStatus(statusType)) {
-      return this.setState({ isNotifyCommentShow: true });
-    }
-
-    if (this.state.isNotifyCommentShow) {
-      await this.setState({ isNotifyCommentShow: false });
-    }
-    adminUsersContainer.handleClick(statusType);
-  }
-
-  /**
-   * Workaround user status check box
-   * @param {string} statusType
-   */
-  validateToggleStatus(statusType) {
-    if (this.props.adminUsersContainer.isSelected(statusType)) {
-      return this.props.adminUsersContainer.state.selectedStatusList.size > 1;
-    }
-    return true;
-  }
-
-  /**
-   * Reset button
-   */
-  resetButtonClickHandler() {
-    const { adminUsersContainer } = this.props;
-    try {
-      adminUsersContainer.resetAllChanges();
-      this.searchUserElement.value = '';
-      this.setState({ isNotifyCommentShow: false });
-    }
-    catch (err) {
-      toastError(err);
-    }
-  }
-
-  /**
-   * Workaround increamental search
-   * @param {string} event
-   */
-  handleChangeSearchText(event) {
-    this.props.adminUsersContainer.handleChangeSearchText(event.target.value);
-  }
-
-  renderCheckbox(status, statusLabel, statusColor) {
-    return (
-      <div className={`custom-control custom-checkbox custom-checkbox-${statusColor} mr-2`}>
-        <input
-          className="custom-control-input"
-          type="checkbox"
-          id={`c_${status}`}
-          checked={this.props.adminUsersContainer.isSelected(status)}
-          onChange={() => { this.handleClick(status) }}
-        />
-        <label className="custom-control-label" htmlFor={`c_${status}`}>
-          <span className={`badge badge-pill badge-${statusColor} d-inline-block vt mt-1`}>
-            {statusLabel}
-          </span>
-        </label>
-      </div>
-    );
-  }
-
-  render() {
-    const { t, adminUsersContainer } = this.props;
-
-    const pager = (
-      <div className="my-3">
-        <PaginationWrapper
-          activePage={adminUsersContainer.state.activePage}
-          changePage={this.handlePage}
-          totalItemsCount={adminUsersContainer.state.totalUsers}
-          pagingLimit={adminUsersContainer.state.pagingLimit}
-          align="center"
-          size="sm"
-        />
-      </div>
-    );
-
-    const clearButton = (
-      adminUsersContainer.state.searchText.length > 0
-        ? (
-          <i
-            className={`icon-close ${styles['search-clear']}`}
-            onClick={() => {
-              adminUsersContainer.clearSearchText();
-              this.searchUserElement.value = '';
-            }}
-          />
-        )
-        : ''
-    );
-
-    return (
-      <div data-testid="admin-users">
-        {adminUsersContainer.state.userForPasswordResetModal != null
-        && (
-          <PasswordResetModal
-            isOpen={adminUsersContainer.state.isPasswordResetModalShown}
-            onClose={adminUsersContainer.hidePasswordResetModal}
-            userForPasswordResetModal={adminUsersContainer.state.userForPasswordResetModal}
-          />
-        )}
-        <p>
-          <InviteUserControl />
-          <a className="btn btn-outline-secondary ml-2" href="/admin/users/external-accounts" role="button">
-            <i className="icon-user-follow" aria-hidden="true"></i>
-            {t('admin:user_management.external_account')}
-          </a>
-        </p>
-
-        <h2>{t('user_management.user_management')}</h2>
-        <div className="border-top border-bottom">
-
-          <div className="row d-flex justify-content-start align-items-center my-2">
-            <div className="col-md-3 d-flex align-items-center my-2">
-              <i className="icon-magnifier mr-1"></i>
-              <span className="search-typeahead">
-                <input
-                  className="w-100"
-                  type="text"
-                  ref={(searchUserElement) => { this.searchUserElement = searchUserElement }}
-                  onChange={this.handleChangeSearchText}
-                />
-                { clearButton }
-              </span>
-            </div>
-
-            <div className="offset-md-1 col-md-6 my-2">
-              <div className="form-inline">
-                {this.renderCheckbox('all', 'All', 'secondary')}
-                {this.renderCheckbox('registered', 'Approval Pending', 'info')}
-                {this.renderCheckbox('active', 'Active', 'success')}
-                {this.renderCheckbox('suspended', 'Suspended', 'warning')}
-                {this.renderCheckbox('invited', 'Invited', 'pink')}
-              </div>
-              <div>
-                {
-                  this.state.isNotifyCommentShow
-                  && <span className="text-warning">{t('admin:user_management.click_twice_same_checkbox')}</span>
-                }
-              </div>
-            </div>
-
-            <div className="col-md-2 my-2">
-              <button
-                type="button"
-                className="btn btn-outline-secondary btn-sm"
-                onClick={() => { this.resetButtonClickHandler() }}
-              >
-                <span
-                  className="icon-refresh mr-1"
-                >
-                </span>
-                Reset
-              </button>
-            </div>
-          </div>
-        </div>
-
-
-        {pager}
-        <UserTable />
-        {pager}
-
-      </div>
-    );
-  }
-
-}
-
-
-UserManagement.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-  adminUsersContainer: PropTypes.instanceOf(AdminUsersContainer).isRequired,
-};
-
-const UserManagementFc = (props) => {
-  const { t } = useTranslation('admin');
-  return <UserManagement t={t} {...props} />;
-};
-
-const UserManagementWrapper = withUnstatedContainers(UserManagementFc, [AdminUsersContainer]);
-
-export default UserManagementWrapper;

+ 43 - 3
packages/app/src/components/Admin/UserManagement.module.scss

@@ -1,5 +1,45 @@
+@use '~/styles/bootstrap/init' as bs;
+
 // styles for admin user search
-.search-clear :global {
-  top: 90px;
-  right: 4px;
+.search-typeahead :global {
+  position: relative;
+  width: 100%;
+  // corner radius
+  border-top-right-radius: bs.$border-radius;
+  border-bottom-right-radius: bs.$border-radius;
+  .rbt-input-main {
+    padding-right: 36px;
+  }
+  .search-clear {
+    position: absolute;
+    top: 12px;
+    right: 1px;
+    z-index: 3;
+    width: 24px;
+    height: 24px;
+    padding: 0;
+    line-height: 0;
+  }
+
+  .rbt-menu {
+    max-height: none !important;
+    margin-top: 3px;
+
+    li a span {
+      .page-path {
+        display: inline;
+        padding: 0 4px;
+        color: inherit;
+      }
+
+      .page-list-meta {
+        font-size: 0.9em;
+        color: bs.$gray-400;
+
+        > span {
+          margin-right: 0.3rem;
+        }
+      }
+    }
+  }
 }

+ 202 - 0
packages/app/src/components/Admin/UserManagement.tsx

@@ -0,0 +1,202 @@
+import React, {
+  useEffect, useState, useRef, useCallback,
+} from 'react';
+
+import { useTranslation } from 'next-i18next';
+import Link from 'next/link';
+
+import AdminUsersContainer from '~/client/services/AdminUsersContainer';
+import { toastError } from '~/client/util/apiNotification';
+
+import PaginationWrapper from '../PaginationWrapper';
+import { withUnstatedContainers } from '../UnstatedUtils';
+
+import InviteUserControl from './Users/InviteUserControl';
+import PasswordResetModal from './Users/PasswordResetModal';
+import UserTable from './Users/UserTable';
+
+import styles from './UserManagement.module.scss';
+
+type UserManagementProps = {
+  adminUsersContainer: AdminUsersContainer
+}
+
+const UserManagement = (props: UserManagementProps) => {
+
+  const { t } = useTranslation('admin');
+  const { adminUsersContainer } = props;
+  const [isNotifyCommentShow, setIsNotifyCommentShow] = useState(false);
+  const inputRef = useRef<HTMLInputElement>(null);
+
+  const pagingHandler = useCallback(async(selectedPage: number) => {
+    try {
+      await adminUsersContainer.retrieveUsersByPagingNum(selectedPage);
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }, [adminUsersContainer]);
+
+  // for Next routing
+  useEffect(() => {
+    pagingHandler(1);
+  }, [pagingHandler]);
+
+  const validateToggleStatus = (statusType: string) => {
+    return (adminUsersContainer.isSelected(statusType)) ? (
+      adminUsersContainer.state.selectedStatusList.size > 1
+    )
+      : (
+        true
+      );
+  };
+
+  const clickHandler = (statusType: string) => {
+    if (!validateToggleStatus(statusType)) {
+      return setIsNotifyCommentShow(true);
+    }
+
+    if (isNotifyCommentShow) {
+      setIsNotifyCommentShow(false);
+    }
+    adminUsersContainer.handleClick(statusType);
+  };
+
+  const resetButtonClickHandler = useCallback(async() => {
+    try {
+      await adminUsersContainer.resetAllChanges();
+      setIsNotifyCommentShow(false);
+      if (inputRef.current != null) {
+        inputRef.current.value = '';
+      }
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }, [adminUsersContainer]);
+
+  const changeSearchTextHandler = useCallback(async(e: React.FormEvent<HTMLInputElement>) => {
+    await adminUsersContainer.handleChangeSearchText(e?.currentTarget.value);
+  }, [adminUsersContainer]);
+
+  const renderCheckbox = (status: string, statusLabel: string, statusColor: string) => {
+    return (
+      <div className={`custom-control custom-checkbox custom-checkbox-${statusColor} mr-2`}>
+        <input
+          className="custom-control-input"
+          type="checkbox"
+          id={`c_${status}`}
+          checked={adminUsersContainer.isSelected(status)}
+          onChange={() => clickHandler(status)}
+        />
+        <label className="custom-control-label" htmlFor={`c_${status}`}>
+          <span className={`badge badge-pill badge-${statusColor} d-inline-block vt mt-1`}>
+            {statusLabel}
+          </span>
+        </label>
+      </div>
+    );
+  };
+
+  const pager = (
+    <div className="my-3">
+      <PaginationWrapper
+        activePage={adminUsersContainer.state.activePage}
+        changePage={pagingHandler}
+        totalItemsCount={adminUsersContainer.state.totalUsers}
+        pagingLimit={adminUsersContainer.state.pagingLimit}
+        align="center"
+        size="sm"
+      />
+    </div>
+  );
+
+  return (
+    <div data-testid="admin-users">
+      { adminUsersContainer.state.userForPasswordResetModal != null
+      && (
+        <PasswordResetModal
+          isOpen={adminUsersContainer.state.isPasswordResetModalShown}
+          onClose={adminUsersContainer.hidePasswordResetModal}
+          userForPasswordResetModal={adminUsersContainer.state.userForPasswordResetModal}
+        />
+      ) }
+      <p>
+        <InviteUserControl />
+        <Link href="/admin/users/external-accounts" prefetch={false}>
+          <a className="btn btn-outline-secondary ml-2" role="button">
+            <i className="icon-user-follow mr-1" aria-hidden="true"></i>
+            {t('admin:user_management.external_account')}
+          </a>
+        </Link>
+      </p>
+
+      <h2>{t('user_management.user_management')}</h2>
+      <div className="border-top border-bottom">
+
+        <div className="row d-flex justify-content-start align-items-center my-2">
+          <div className="col-md-3 d-flex align-items-center my-2">
+            <i className="icon-magnifier mr-1"></i>
+            <span className={`search-typeahead ${styles['search-typeahead']}`}>
+              <input
+                className="w-100"
+                type="text"
+                ref={inputRef}
+                onChange={changeSearchTextHandler}
+              />
+              {
+                adminUsersContainer.state.searchText.length > 0
+                  ? (
+                    <i
+                      className="icon-close search-clear"
+                      onClick={async() => {
+                        await adminUsersContainer.clearSearchText();
+                        if (inputRef.current != null) {
+                          inputRef.current.value = '';
+                        }
+                      }}
+                    />
+                  )
+                  : ''
+              }
+            </span>
+          </div>
+
+          <div className="offset-md-1 col-md-6 my-2">
+            <div className="form-inline">
+              {renderCheckbox('all', 'All', 'secondary')}
+              {renderCheckbox('registered', 'Approval Pending', 'info')}
+              {renderCheckbox('active', 'Active', 'success')}
+              {renderCheckbox('suspended', 'Suspended', 'warning')}
+              {renderCheckbox('invited', 'Invited', 'pink')}
+            </div>
+            <div>
+              { isNotifyCommentShow && <span className="text-warning">{t('admin:user_management.click_twice_same_checkbox')}</span> }
+            </div>
+          </div>
+
+          <div className="col-md-2 my-2">
+            <button
+              type="button"
+              className="btn btn-outline-secondary btn-sm"
+              onClick={resetButtonClickHandler}
+            >
+              <span className="icon-refresh mr-1"></span>
+              Reset
+            </button>
+          </div>
+        </div>
+      </div>
+
+      {pager}
+      <UserTable />
+      {pager}
+
+    </div>
+  );
+
+};
+
+const UserManagementWrapper = withUnstatedContainers(UserManagement, [AdminUsersContainer]);
+
+export default UserManagementWrapper;

+ 0 - 132
packages/app/src/components/Admin/Users/ExternalAccountTable.jsx

@@ -1,132 +0,0 @@
-import React, { Fragment } from 'react';
-
-import dateFnsFormat from 'date-fns/format';
-import { useTranslation } from 'next-i18next';
-import PropTypes from 'prop-types';
-
-import AdminExternalAccountsContainer from '~/client/services/AdminExternalAccountsContainer';
-import { toastSuccess, toastError } from '~/client/util/apiNotification';
-
-import { withUnstatedContainers } from '../../UnstatedUtils';
-
-class ExternalAccountTable extends React.Component {
-
-  constructor(props) {
-    super(props);
-
-    this.state = {
-
-    };
-    this.removeExtenalAccount = this.removeExtenalAccount.bind(this);
-  }
-
-  // remove external-account
-  async removeExtenalAccount(externalAccountId) {
-    const { t } = this.props;
-
-    try {
-      const accountId = await this.props.adminExternalAccountsContainer.removeExternalAccountById(externalAccountId);
-      toastSuccess(t('toaster.remove_external_user_success', { accountId }));
-    }
-    catch (err) {
-      toastError(err);
-    }
-  }
-
-
-  render() {
-    const { t, adminExternalAccountsContainer } = this.props;
-    return (
-      <Fragment>
-        <table className="table table-bordered table-user-list">
-          <thead>
-            <tr>
-              <th width="120px">{t('admin:user_management.authentication_provider')}</th>
-              <th><code>accountId</code></th>
-              <th>{t('admin:user_management.related_username')}<code>username</code></th>
-              <th>
-                {t('admin:user_management.password_setting')}
-                <div
-                  className="text-muted"
-                  data-toggle="popover"
-                  data-placement="top"
-                  data-trigger="hover focus"
-                  tabIndex="0"
-                  role="button"
-                  data-animation="false"
-                  data-html="true"
-                  data-content={t('admin:user_management.password_setting_help')}
-                >
-                  <small>
-                    <i className="icon-question" aria-hidden="true"></i>
-                  </small>
-                </div>
-              </th>
-              <th width="100px">{t('Created')}</th>
-              <th width="70px"></th>
-            </tr>
-          </thead>
-          <tbody>
-            {adminExternalAccountsContainer.state.externalAccounts.map((ea) => {
-              return (
-                <tr key={ea._id}>
-                  <td>{ea.providerType}</td>
-                  <td>
-                    <strong>{ea.accountId}</strong>
-                  </td>
-                  <td>
-                    <strong>{ea.user.username}</strong>
-                  </td>
-                  <td>
-                    {ea.user.password
-                      ? (
-                        <span className="badge badge-info">
-                          {t('admin:user_management.set')}
-                        </span>
-                      )
-                      : (
-                        <span className="badge badge-warning">
-                          {t('admin:user_management.unset')}
-                        </span>
-                      )
-                    }
-                  </td>
-                  <td>{dateFnsFormat(new Date(ea.createdAt), 'yyyy-MM-dd')}</td>
-                  <td>
-                    <div className="btn-group admin-user-menu">
-                      <button type="button" className="btn btn-outline-secondary btn-sm dropdown-toggle" data-toggle="dropdown">
-                        <i className="icon-settings"></i> <span className="caret"></span>
-                      </button>
-                      <ul className="dropdown-menu" role="menu">
-                        <li className="dropdown-header">{t('admin:user_management.user_table.edit_menu')}</li>
-                        <button className="dropdown-item" type="button" role="button" onClick={() => { return this.removeExtenalAccount(ea._id) }}>
-                          <i className="icon-fw icon-fire text-danger"></i> {t('Delete')}
-                        </button>
-                      </ul>
-                    </div>
-                  </td>
-                </tr>
-              );
-            })}
-          </tbody>
-        </table>
-      </Fragment>
-    );
-  }
-
-}
-
-ExternalAccountTable.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-  adminExternalAccountsContainer: PropTypes.instanceOf(AdminExternalAccountsContainer).isRequired,
-};
-
-const ExternalAccountTableWrapperFC = (props) => {
-  const { t } = useTranslation();
-  return <ExternalAccountTable t={t} {...props} />;
-};
-
-const ExternalAccountTableWrapper = withUnstatedContainers(ExternalAccountTableWrapperFC, [AdminExternalAccountsContainer]);
-
-
-export default ExternalAccountTableWrapper;

+ 8 - 0
packages/app/src/components/Admin/Users/ExternalAccountTable.module.scss

@@ -0,0 +1,8 @@
+.ea-table :global {
+  thead th {
+    vertical-align: top;
+  }
+  td {
+    vertical-align: middle;
+  }
+}

+ 122 - 0
packages/app/src/components/Admin/Users/ExternalAccountTable.tsx

@@ -0,0 +1,122 @@
+import React, { useCallback } from 'react';
+
+import type { IAdminExternalAccount } from '@growi/core';
+import dateFnsFormat from 'date-fns/format';
+import { useTranslation } from 'next-i18next';
+
+import AdminExternalAccountsContainer from '~/client/services/AdminExternalAccountsContainer';
+import { toastSuccess, toastError } from '~/client/util/apiNotification';
+
+import { withUnstatedContainers } from '../../UnstatedUtils';
+
+import styles from './ExternalAccountTable.module.scss';
+
+type ExternalAccountTableProps = {
+  adminExternalAccountsContainer: AdminExternalAccountsContainer,
+}
+
+const ExternalAccountTable = (props: ExternalAccountTableProps): JSX.Element => {
+
+  const { t } = useTranslation('admin');
+
+  const { adminExternalAccountsContainer } = props;
+
+  const removeExtenalAccount = useCallback(async(externalAccountId) => {
+    try {
+      const accountId = await adminExternalAccountsContainer.removeExternalAccountById(externalAccountId);
+      toastSuccess(t('toaster.remove_external_user_success', { accountId }));
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }, [adminExternalAccountsContainer, t]);
+
+  return (
+    <div className="table-responsive text-nowrap">
+      <table className={`${styles['ea-table']} table table-bordered table-user-list`}>
+        <thead>
+          <tr>
+            <th style={{ width: '100px' }}>
+              <div className="d-flex align-items-center">
+                {t('user_management.authentication_provider')}
+              </div>
+            </th>
+            <th style={{ width: '200px' }}>
+              <div className="d-flex align-items-center">
+                <code>accountId</code>
+              </div>
+            </th>
+            <th style={{ width: '200px' }}>
+              <div className="d-flex align-items-center">
+                {t('user_management.related_username')}<code className="ml-2">username</code>
+              </div>
+            </th>
+            <th style={{ width: '100px' }}>
+              <div className="d-flex align-items-center">
+                {t('user_management.password_setting')}
+                <span
+                  role="button"
+                  className="text-muted mx-2"
+                  data-toggle="popper"
+                  data-placement="top"
+                  data-trigger="hover"
+                  data-html="true"
+                  title={t('user_management.password_setting_help')}
+                >
+                  <small><i className="icon-question" aria-hidden="true"></i></small>
+                </span>
+              </div>
+            </th>
+            <th style={{ width: '100px' }}>
+              <div className="d-flex align-items-center">
+                {t('Created')}
+              </div>
+            </th>
+            <th style={{ width: '70px' }}></th>
+          </tr>
+        </thead>
+        <tbody>
+          { adminExternalAccountsContainer.state.externalAccounts.map((ea: IAdminExternalAccount) => {
+            return (
+              <tr key={ea._id}>
+                <td><span>{ea.providerType}</span></td>
+                <td><strong>{ea.accountId}</strong></td>
+                <td><strong>{ea.user.username}</strong></td>
+                <td>
+                  {ea.user.password
+                    ? (<span className="badge badge-info">{t('user_management.set')}</span>)
+                    : (<span className="badge badge-warning">{t('user_management.unset')}</span>)
+                  }
+                </td>
+                <td>{dateFnsFormat(new Date(ea.createdAt), 'yyyy-MM-dd')}</td>
+                <td>
+                  <div className="btn-group admin-user-menu">
+                    <button type="button" className="btn btn-outline-secondary btn-sm dropdown-toggle" data-toggle="dropdown">
+                      <i className="icon-settings"></i> <span className="caret"></span>
+                    </button>
+                    <ul className="dropdown-menu" role="menu">
+                      <li className="dropdown-header">{t('user_management.user_table.edit_menu')}</li>
+                      <button
+                        className="dropdown-item"
+                        type="button"
+                        role="button"
+                        onClick={() => removeExtenalAccount(ea._id)}
+                      >
+                        <i className="icon-fw icon-fire text-danger"></i> {t('Delete')}
+                      </button>
+                    </ul>
+                  </div>
+                </td>
+              </tr>
+            );
+          }) }
+        </tbody>
+      </table>
+    </div>
+
+  );
+};
+
+const ExternalAccountTableWrapper = withUnstatedContainers(ExternalAccountTable, [AdminExternalAccountsContainer]);
+
+export default ExternalAccountTableWrapper;

+ 0 - 60
packages/app/src/components/Admin/Users/GiveAdminButton.jsx

@@ -1,60 +0,0 @@
-import React from 'react';
-
-import { useTranslation } from 'next-i18next';
-import PropTypes from 'prop-types';
-
-import AdminUsersContainer from '~/client/services/AdminUsersContainer';
-import { toastSuccess, toastError } from '~/client/util/apiNotification';
-
-import { withUnstatedContainers } from '../../UnstatedUtils';
-
-class GiveAdminButton extends React.Component {
-
-  constructor(props) {
-    super(props);
-
-    this.onClickGiveAdminBtn = this.onClickGiveAdminBtn.bind(this);
-  }
-
-  async onClickGiveAdminBtn() {
-    const { t } = this.props;
-
-    try {
-      const username = await this.props.adminUsersContainer.giveUserAdmin(this.props.user._id);
-      toastSuccess(t('toaster.give_user_admin', { username }));
-    }
-    catch (err) {
-      toastError(err);
-    }
-  }
-
-  render() {
-    const { t } = this.props;
-
-    return (
-      <button className="dropdown-item" type="button" onClick={() => { this.onClickGiveAdminBtn() }}>
-        <i className="icon-fw icon-user-following"></i> {t('admin:user_management.user_table.give_admin_access')}
-      </button>
-    );
-  }
-
-}
-
-const GiveAdminButtonWrapperFC = (props) => {
-  const { t } = useTranslation();
-  return <GiveAdminButton t={t} {...props} />;
-};
-
-/**
- * Wrapper component for using unstated
- */
-const GiveAdminButtonWrapper = withUnstatedContainers(GiveAdminButtonWrapperFC, [AdminUsersContainer]);
-
-GiveAdminButton.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-  adminUsersContainer: PropTypes.instanceOf(AdminUsersContainer).isRequired,
-
-  user: PropTypes.object.isRequired,
-};
-
-export default GiveAdminButtonWrapper;

+ 44 - 0
packages/app/src/components/Admin/Users/GiveAdminButton.tsx

@@ -0,0 +1,44 @@
+import React, { useCallback } from 'react';
+
+import type { IUserHasId } from '@growi/core';
+import { useTranslation } from 'next-i18next';
+
+import AdminUsersContainer from '~/client/services/AdminUsersContainer';
+import { toastSuccess, toastError } from '~/client/util/apiNotification';
+
+import { withUnstatedContainers } from '../../UnstatedUtils';
+
+type GiveAdminButtonProps = {
+  adminUsersContainer: AdminUsersContainer,
+  user: IUserHasId,
+}
+
+const GiveAdminButton = (props: GiveAdminButtonProps): JSX.Element => {
+
+  const { t } = useTranslation('admin');
+  const { adminUsersContainer, user } = props;
+
+  const onClickGiveAdminBtnHandler = useCallback(async() => {
+    try {
+      const username = await adminUsersContainer.giveUserAdmin(user._id);
+      toastSuccess(t('toaster.give_user_admin', { username }));
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }, [adminUsersContainer, t, user._id]);
+
+  return (
+    <button className="dropdown-item" type="button" onClick={() => onClickGiveAdminBtnHandler()}>
+      <i className="icon-fw icon-user-following"></i> {t('user_management.user_table.give_admin_access')}
+    </button>
+  );
+
+};
+
+/**
+ * Wrapper component for using unstated
+ */
+const GiveAdminButtonWrapper = withUnstatedContainers(GiveAdminButton, [AdminUsersContainer]);
+
+export default GiveAdminButtonWrapper;

+ 9 - 9
packages/app/src/components/Admin/Users/PasswordResetModal.jsx

@@ -42,11 +42,11 @@ class PasswordResetModal extends React.Component {
     return (
       <>
         <p>
-          {t('admin:user_management.reset_password_modal.password_never_seen')}<br />
-          <span className="text-danger">{t('admin:user_management.reset_password_modal.send_new_password')}</span>
+          {t('user_management.reset_password_modal.password_never_seen')}<br />
+          <span className="text-danger">{t('user_management.reset_password_modal.send_new_password')}</span>
         </p>
         <p>
-          {t('admin:user_management.reset_password_modal.target_user')}: <code>{userForPasswordResetModal.email}</code>
+          {t('user_management.reset_password_modal.target_user')}: <code>{userForPasswordResetModal.email}</code>
         </p>
       </>
     );
@@ -57,12 +57,12 @@ class PasswordResetModal extends React.Component {
 
     return (
       <>
-        <p className="alert alert-danger">{t('admin:user_management.reset_password_modal.password_reset_message')}</p>
+        <p className="alert alert-danger">{t('user_management.reset_password_modal.password_reset_message')}</p>
         <p>
-          {t('admin:user_management.reset_password_modal.target_user')}: <code>{userForPasswordResetModal.email}</code>
+          {t('user_management.reset_password_modal.target_user')}: <code>{userForPasswordResetModal.email}</code>
         </p>
         <p>
-          {t('admin:user_management.reset_password_modal.new_password')}: <code>{this.state.temporaryPassword}</code>
+          {t('user_management.reset_password_modal.new_password')}: <code>{this.state.temporaryPassword}</code>
         </p>
       </>
     );
@@ -72,7 +72,7 @@ class PasswordResetModal extends React.Component {
     const { t } = this.props;
     return (
       <button type="submit" className="btn btn-danger" onClick={this.resetPassword}>
-        {t('admin:user_management.reset_password')}
+        {t('user_management.reset_password')}
       </button>
     );
   }
@@ -94,7 +94,7 @@ class PasswordResetModal extends React.Component {
     return (
       <Modal isOpen={this.props.isOpen} toggle={this.props.onClose}>
         <ModalHeader tag="h4" toggle={this.props.onClose} className="bg-warning text-light">
-          {t('admin:user_management.reset_password') }
+          {t('user_management.reset_password') }
         </ModalHeader>
         <ModalBody>
           {this.state.isPasswordResetDone ? this.returnModalBodyAfterReset() : this.renderModalBodyBeforeReset()}
@@ -109,7 +109,7 @@ class PasswordResetModal extends React.Component {
 }
 
 const PasswordResetModalWrapperFC = (props) => {
-  const { t } = useTranslation();
+  const { t } = useTranslation('admin');
   return <PasswordResetModal t={t} {...props} />;
 };
 

+ 67 - 0
packages/app/src/components/Admin/Users/RemoveAdminButton.tsx

@@ -0,0 +1,67 @@
+import React, { useCallback } from 'react';
+
+import type { IUserHasId } from '@growi/core';
+import { useTranslation } from 'next-i18next';
+
+import AdminUsersContainer from '~/client/services/AdminUsersContainer';
+import { toastSuccess, toastError } from '~/client/util/apiNotification';
+import { useCurrentUser } from '~/stores/context';
+
+import { withUnstatedContainers } from '../../UnstatedUtils';
+
+type RemoveAdminButtonProps = {
+  adminUsersContainer: AdminUsersContainer,
+  user: IUserHasId,
+}
+
+const RemoveAdminButton = (props: RemoveAdminButtonProps): JSX.Element => {
+
+  const { t } = useTranslation('admin');
+  const { data: currentUser } = useCurrentUser();
+  const { adminUsersContainer, user } = props;
+
+  const onClickRemoveAdminBtnHandler = useCallback(async() => {
+    try {
+      const username = await adminUsersContainer.removeUserAdmin(user._id);
+      toastSuccess(t('toaster.remove_user_admin', { username }));
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }, [adminUsersContainer, t, user._id]);
+
+  const renderRemoveAdminBtn = () => {
+    return (
+      <button className="dropdown-item" type="button" onClick={() => onClickRemoveAdminBtnHandler()}>
+        <i className="icon-fw icon-user-unfollow"></i>{t('user_management.user_table.remove_admin_access')}
+      </button>
+    );
+  };
+
+  const renderRemoveAdminAlert = () => {
+    return (
+      <div className="px-4">
+        <i className="icon-fw icon-user-unfollow mb-2"></i>{t('user_management.user_table.remove_admin_access')}
+        <p className="alert alert-danger">{t('user_management.user_table.cannot_remove')}</p>
+      </div>
+    );
+  };
+
+  if (currentUser == null) {
+    return <></>;
+  }
+
+  return (
+    <>
+      {user.username !== currentUser.username ? renderRemoveAdminBtn()
+        : renderRemoveAdminAlert()}
+    </>
+  );
+};
+
+/**
+* Wrapper component for using unstated
+*/
+const RemoveAdminButtonWrapper = withUnstatedContainers(RemoveAdminButton, [AdminUsersContainer]);
+
+export default RemoveAdminButtonWrapper;

+ 3 - 3
packages/app/src/components/Admin/Users/RemoveAdminMenuItem.tsx

@@ -1,10 +1,10 @@
 import React, { useCallback } from 'react';
 
+import type { IUserHasId } from '@growi/core';
 import { useTranslation } from 'next-i18next';
 
 import AdminUsersContainer from '~/client/services/AdminUsersContainer';
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
-import { IUserHasId } from '~/interfaces/user';
 import { useCurrentUser } from '~/stores/context';
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
@@ -29,7 +29,7 @@ type Props = {
 }
 
 const RemoveAdminMenuItem = (props: Props): JSX.Element => {
-  const { t } = useTranslation();
+  const { t } = useTranslation('admin');
 
   const { adminUsersContainer, user } = props;
 
@@ -49,7 +49,7 @@ const RemoveAdminMenuItem = (props: Props): JSX.Element => {
   return user.username !== currentUser?.username
     ? (
       <button className="dropdown-item" type="button" onClick={clickRemoveAdminBtnHandler}>
-        <i className="icon-fw icon-user-unfollow"></i> {t('admin:user_management.user_table.remove_admin_access')}
+        <i className="icon-fw icon-user-unfollow"></i> {t('user_management.user_table.remove_admin_access')}
       </button>
     )
     : <RemoveAdminAlert />;

+ 9 - 13
packages/app/src/components/Admin/Users/SortIcons.jsx → packages/app/src/components/Admin/Users/SortIcons.tsx

@@ -1,31 +1,27 @@
 import React from 'react';
 
-import PropTypes from 'prop-types';
+type SortIconsProps = {
+  onClick: (sortOrder: string) => void,
+  isSelected: boolean,
+  isAsc: boolean,
+}
 
-const SortIcons = (props) => {
+export const SortIcons = (props: SortIconsProps): JSX.Element => {
 
-  const { isSelected, isAsc } = props;
+  const { onClick, isSelected, isAsc } = props;
 
   return (
     <div className="d-flex flex-column text-center">
       <a
         className={`fa ${isSelected && isAsc ? 'fa-chevron-up' : 'fa-angle-up'}`}
         aria-hidden="true"
-        onClick={() => props.onClick('asc')}
+        onClick={() => onClick('asc')}
       />
       <a
         className={`fa ${isSelected && !isAsc ? 'fa-chevron-down' : 'fa-angle-down'}`}
         aria-hidden="true"
-        onClick={() => props.onClick('desc')}
+        onClick={() => onClick('desc')}
       />
     </div>
   );
 };
-
-SortIcons.propTypes = {
-  onClick: PropTypes.func.isRequired,
-  isSelected: PropTypes.bool.isRequired,
-  isAsc: PropTypes.bool.isRequired,
-};
-
-export default SortIcons;

+ 2 - 2
packages/app/src/components/Admin/Users/StatusActivateButton.jsx

@@ -33,7 +33,7 @@ class StatusActivateButton extends React.Component {
 
     return (
       <button className="dropdown-item" type="button" onClick={() => { this.onClickAcceptBtn() }}>
-        <i className="icon-fw icon-user-following"></i> {t('admin:user_management.user_table.accept')}
+        <i className="icon-fw icon-user-following"></i> {t('user_management.user_table.accept')}
       </button>
     );
   }
@@ -41,7 +41,7 @@ class StatusActivateButton extends React.Component {
 }
 
 const StatusActivateFormWrapperFC = (props) => {
-  const { t } = useTranslation();
+  const { t } = useTranslation('admin');
   return <StatusActivateButton t={t} {...props} />;
 };
 

+ 3 - 3
packages/app/src/components/Admin/Users/StatusSuspendMenuItem.tsx

@@ -1,11 +1,11 @@
 import React, { useCallback } from 'react';
 
+import type { IUserHasId } from '@growi/core';
 import { useTranslation } from 'next-i18next';
 
 import AdminUsersContainer from '~/client/services/AdminUsersContainer';
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
 import { withUnstatedContainers } from '~/components/UnstatedUtils';
-import { IUserHasId } from '~/interfaces/user';
 import { useCurrentUser } from '~/stores/context';
 
 
@@ -28,7 +28,7 @@ type Props = {
 }
 
 const StatusSuspendMenuItem = (props: Props): JSX.Element => {
-  const { t } = useTranslation();
+  const { t } = useTranslation('admin');
 
   const { adminUsersContainer, user } = props;
 
@@ -47,7 +47,7 @@ const StatusSuspendMenuItem = (props: Props): JSX.Element => {
   return user.username !== currentUser?.username
     ? (
       <button className="dropdown-item" type="button" onClick={clickDeactiveBtnHandler}>
-        <i className="icon-fw icon-ban"></i> {t('admin:user_management.user_table.deactivate_account')}
+        <i className="icon-fw icon-ban"></i> {t('user_management.user_table.deactivate_account')}
       </button>
     )
     : <SuspendAlert />;

+ 1 - 1
packages/app/src/components/Admin/Users/UserRemoveButton.jsx

@@ -49,7 +49,7 @@ UserRemoveButton.propTypes = {
 };
 
 const UserRemoveButtonWrapperFC = (props) => {
-  const { t } = useTranslation();
+  const { t } = useTranslation('admin');
   return <UserRemoveButton t={t} {...props} />;
 };
 

+ 0 - 231
packages/app/src/components/Admin/Users/UserTable.jsx

@@ -1,231 +0,0 @@
-import React, { Fragment } from 'react';
-
-import { UserPicture } from '@growi/ui';
-import dateFnsFormat from 'date-fns/format';
-import { useTranslation } from 'next-i18next';
-import PropTypes from 'prop-types';
-
-import AdminUsersContainer from '~/client/services/AdminUsersContainer';
-
-import { withUnstatedContainers } from '../../UnstatedUtils';
-
-import SortIcons from './SortIcons';
-import UserMenu from './UserMenu';
-
-
-class UserTable extends React.Component {
-
-  constructor(props) {
-    super(props);
-
-    this.state = {
-
-    };
-
-    this.getUserStatusLabel = this.getUserStatusLabel.bind(this);
-  }
-
-  /**
-   * return status label element by `userStatus`
-   * @param {string} userStatus
-   * @return status label element
-   */
-  getUserStatusLabel(userStatus) {
-    let additionalClassName;
-    let text;
-
-    switch (userStatus) {
-      case 1:
-        additionalClassName = 'badge-info';
-        text = 'Approval Pending';
-        break;
-      case 2:
-        additionalClassName = 'badge-success';
-        text = 'Active';
-        break;
-      case 3:
-        additionalClassName = 'badge-warning';
-        text = 'Suspended';
-        break;
-      case 4:
-        additionalClassName = 'badge-danger';
-        text = 'Deleted';
-        break;
-      case 5:
-        additionalClassName = 'badge-pink';
-        text = 'Invited';
-        break;
-    }
-
-    return (
-      <span className={`badge badge-pill ${additionalClassName}`}>
-        {text}
-      </span>
-    );
-  }
-
-  /**
-   * return admin label element by `isAdmin`
-   * @param {string} isAdmin
-   * @return admin label element
-   */
-  getUserAdminLabel(isAdmin) {
-    const { t } = this.props;
-
-    if (isAdmin) {
-      return <span className="badge badge-indigo badge-pill ml-2">{t('admin:user_management.user_table.administrator')}</span>;
-    }
-  }
-
-  sortIconsClickedHandler(sort, sortOrder) {
-    const isAsc = sortOrder === 'asc';
-
-    const { adminUsersContainer } = this.props;
-    adminUsersContainer.sort(sort, isAsc);
-  }
-
-  render() {
-    const { t, adminUsersContainer } = this.props;
-
-    const isCurrentSortOrderAsc = adminUsersContainer.state.sortOrder === 'asc';
-
-    return (
-      <Fragment>
-        <div className="table-responsive text-nowrap h-100">
-          <table className="table table-default table-bordered table-user-list">
-            <thead>
-              <tr>
-                <th width="100px">#</th>
-                <th>
-                  <div className="d-flex align-items-center">
-                    <div className="mr-3">
-                      {t('user_management.status')}
-                    </div>
-                    <SortIcons
-                      isSelected={adminUsersContainer.state.sort === 'status'}
-                      isAsc={isCurrentSortOrderAsc}
-                      onClick={(sortOrder) => {
-                        this.sortIconsClickedHandler('status', sortOrder);
-                      }}
-                    />
-                  </div>
-                </th>
-                <th>
-                  <div className="d-flex align-items-center">
-                    <div className="mr-3">
-                      <code>username</code>
-                    </div>
-                    <SortIcons
-                      isSelected={adminUsersContainer.state.sort === 'username'}
-                      isAsc={isCurrentSortOrderAsc}
-                      onClick={(sortOrder) => {
-                        this.sortIconsClickedHandler('username', sortOrder);
-                      }}
-                    />
-                  </div>
-                </th>
-                <th>
-                  <div className="d-flex align-items-center">
-                    <div className="mr-3">
-                      {t('Name')}
-                    </div>
-                    <SortIcons
-                      isSelected={adminUsersContainer.state.sort === 'name'}
-                      isAsc={isCurrentSortOrderAsc}
-                      onClick={(sortOrder) => {
-                        this.sortIconsClickedHandler('name', sortOrder);
-                      }}
-                    />
-                  </div>
-                </th>
-                <th>
-                  <div className="d-flex align-items-center">
-                    <div className="mr-3">
-                      {t('Email')}
-                    </div>
-                    <SortIcons
-                      isSelected={adminUsersContainer.state.sort === 'email'}
-                      isAsc={isCurrentSortOrderAsc}
-                      onClick={(sortOrder) => {
-                        this.sortIconsClickedHandler('email', sortOrder);
-                      }}
-                    />
-                  </div>
-                </th>
-                <th width="100px">
-                  <div className="d-flex align-items-center">
-                    <div className="mr-3">
-                      {t('Created')}
-                    </div>
-                    <SortIcons
-                      isSelected={adminUsersContainer.state.sort === 'createdAt'}
-                      isAsc={isCurrentSortOrderAsc}
-                      onClick={(sortOrder) => {
-                        this.sortIconsClickedHandler('createdAt', sortOrder);
-                      }}
-                    />
-                  </div>
-                </th>
-                <th width="150px">
-                  <div className="d-flex align-items-center">
-                    <div className="mr-3">
-                      {t('last_login')}
-                    </div>
-                    <SortIcons
-                      isSelected={adminUsersContainer.state.sort === 'lastLoginAt'}
-                      isAsc={isCurrentSortOrderAsc}
-                      onClick={(sortOrder) => {
-                        this.sortIconsClickedHandler('lastLoginAt', sortOrder);
-                      }}
-                    />
-                  </div>
-                </th>
-                <th width="70px"></th>
-              </tr>
-            </thead>
-            <tbody>
-              {adminUsersContainer.state.users.map((user) => {
-                return (
-                  <tr data-testid="user-table-tr" key={user._id}>
-                    <td>
-                      <UserPicture user={user} />
-                    </td>
-                    <td>{this.getUserStatusLabel(user.status)} {this.getUserAdminLabel(user.admin)}</td>
-                    <td>
-                      <strong>{user.username}</strong>
-                    </td>
-                    <td>{user.name}</td>
-                    <td>{user.email}</td>
-                    <td>{dateFnsFormat(new Date(user.createdAt), 'yyyy-MM-dd')}</td>
-                    <td>
-                      {user.lastLoginAt && <span>{dateFnsFormat(new Date(user.lastLoginAt), 'yyyy-MM-dd HH:mm')}</span>}
-                    </td>
-                    <td>
-                      <UserMenu user={user} />
-                    </td>
-                  </tr>
-                );
-              })}
-            </tbody>
-          </table>
-        </div>
-      </Fragment>
-    );
-  }
-
-}
-
-
-UserTable.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-  adminUsersContainer: PropTypes.instanceOf(AdminUsersContainer).isRequired,
-};
-
-const UserTableWrapperFC = (props) => {
-  const { t } = useTranslation('admin');
-  return <UserTable t={t} {...props} />;
-};
-
-const UserTableWrapper = withUnstatedContainers(UserTableWrapperFC, [AdminUsersContainer]);
-
-export default UserTableWrapper;

+ 185 - 0
packages/app/src/components/Admin/Users/UserTable.tsx

@@ -0,0 +1,185 @@
+import React, { useCallback } from 'react';
+
+import type { IUserHasId } from '@growi/core';
+import { UserPicture } from '@growi/ui';
+import dateFnsFormat from 'date-fns/format';
+import { useTranslation } from 'next-i18next';
+
+import AdminUsersContainer from '~/client/services/AdminUsersContainer';
+
+import { withUnstatedContainers } from '../../UnstatedUtils';
+
+import { SortIcons } from './SortIcons';
+import UserMenu from './UserMenu';
+
+type UserTableProps = {
+  adminUsersContainer: AdminUsersContainer,
+}
+
+const UserTable = (props: UserTableProps) => {
+
+  const { t } = useTranslation('admin');
+  const { adminUsersContainer } = props;
+
+  const getUserStatusLabel = (userStatus: number) => {
+    let additionalClassName = 'badge-info';
+    let text = 'Approval Pending';
+
+    switch (userStatus) {
+      case 1:
+        additionalClassName = 'badge-info';
+        text = 'Approval Pending';
+        break;
+      case 2:
+        additionalClassName = 'badge-success';
+        text = 'Active';
+        break;
+      case 3:
+        additionalClassName = 'badge-warning';
+        text = 'Suspended';
+        break;
+      case 4:
+        additionalClassName = 'badge-danger';
+        text = 'Deleted';
+        break;
+      case 5:
+        additionalClassName = 'badge-pink';
+        text = 'Invited';
+        break;
+    }
+
+    return (
+      <span className={`badge badge-pill ${additionalClassName}`}>
+        {text}
+      </span>
+    );
+  };
+
+  const sortIconsClickedHandler = useCallback(async(sort: string, sortOrder: string) => {
+    const isAsc = sortOrder === 'asc';
+    await adminUsersContainer.sort(sort, isAsc);
+  }, [adminUsersContainer]);
+
+  const isCurrentSortOrderAsc = adminUsersContainer.state.sortOrder === 'asc';
+
+  return (
+    <div className="table-responsive text-nowrap h-100">
+      <table className="table table-default table-bordered table-user-list">
+        <thead>
+          <tr>
+            <th style={{ width: '100px' }}>#</th>
+            <th>
+              <div className="d-flex align-items-center">
+                <div className="mr-3">
+                  {t('user_management.status')}
+                </div>
+                <SortIcons
+                  isSelected={adminUsersContainer.state.sort === 'status'}
+                  isAsc={isCurrentSortOrderAsc}
+                  onClick={sortOrder => sortIconsClickedHandler('status', sortOrder)}
+                />
+              </div>
+            </th>
+            <th>
+              <div className="d-flex align-items-center">
+                <div className="mr-3">
+                  <code>username</code>
+                </div>
+                <SortIcons
+                  isSelected={adminUsersContainer.state.sort === 'username'}
+                  isAsc={isCurrentSortOrderAsc}
+                  onClick={sortOrder => sortIconsClickedHandler('username', sortOrder)}
+                />
+              </div>
+            </th>
+            <th>
+              <div className="d-flex align-items-center">
+                <div className="mr-3">
+                  {t('Name')}
+                </div>
+                <SortIcons
+                  isSelected={adminUsersContainer.state.sort === 'name'}
+                  isAsc={isCurrentSortOrderAsc}
+                  onClick={sortOrder => sortIconsClickedHandler('name', sortOrder)}
+                />
+              </div>
+            </th>
+            <th>
+              <div className="d-flex align-items-center">
+                <div className="mr-3">
+                  {t('Email')}
+                </div>
+                <SortIcons
+                  isSelected={adminUsersContainer.state.sort === 'email'}
+                  isAsc={isCurrentSortOrderAsc}
+                  onClick={sortOrder => sortIconsClickedHandler('email', sortOrder)}
+                />
+              </div>
+            </th>
+            <th style={{ width: '100px' }}>
+              <div className="d-flex align-items-center">
+                <div className="mr-3">
+                  {t('Created')}
+                </div>
+                <SortIcons
+                  isSelected={adminUsersContainer.state.sort === 'createdAt'}
+                  isAsc={isCurrentSortOrderAsc}
+                  onClick={sortOrder => sortIconsClickedHandler('createdAt', sortOrder)}
+                />
+              </div>
+            </th>
+            <th style={{ width: '150px' }}>
+              <div className="d-flex align-items-center">
+                <div className="mr-3">
+                  {t('last_login')}
+                </div>
+                <SortIcons
+                  isSelected={adminUsersContainer.state.sort === 'lastLoginAt'}
+                  isAsc={isCurrentSortOrderAsc}
+                  onClick={sortOrder => sortIconsClickedHandler('lastLoginAt', sortOrder)}
+                />
+              </div>
+            </th>
+            <th style={{ width: '70px' }}></th>
+          </tr>
+        </thead>
+        <tbody>
+          { adminUsersContainer.state.users.map((user: IUserHasId) => {
+            return (
+              <tr data-testid="user-table-tr" key={user._id}>
+                <td>
+                  <UserPicture user={user} />
+                </td>
+                <td>
+                  {getUserStatusLabel(user.status)}
+                  {(user.admin) && (
+                    <span className="badge badge-indigo badge-pill ml-2">
+                      {t('admin:user_management.user_table.administrator')}
+                    </span>
+                  )}
+                </td>
+                <td>
+                  <strong>{user.username}</strong>
+                </td>
+                <td>{user.name}</td>
+                <td>{user.email}</td>
+                <td>{dateFnsFormat(new Date(user.createdAt), 'yyyy-MM-dd')}</td>
+                <td>
+                  {user.lastLoginAt && <span>{dateFnsFormat(new Date(user.lastLoginAt), 'yyyy-MM-dd HH:mm')}</span>}
+                </td>
+                <td>
+                  <UserMenu user={user} />
+                </td>
+              </tr>
+            );
+          }) }
+        </tbody>
+      </table>
+    </div>
+  );
+
+};
+
+const UserTableWrapper = withUnstatedContainers(UserTable, [AdminUsersContainer]);
+
+export default UserTableWrapper;

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

@@ -44,7 +44,7 @@ export const InvitedForm = (props: InvitedFormProps): JSX.Element => {
       const res = await apiv3Post('/invited', { invitedForm });
       setIsConnectSuccess(true);
       const { redirectTo } = res.data;
-      router.push(redirectTo);
+      router.push(redirectTo ?? '/');
     }
     catch (err) {
       setLoginErrors(err);

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

+ 1 - 1
packages/app/src/components/Layout/RawLayout.tsx

@@ -21,7 +21,7 @@ type Props = {
 }
 
 export const RawLayout = ({ children, title, className }: Props): JSX.Element => {
-  const classNames: string[] = ['layout-root'];
+  const classNames: string[] = ['layout-root', 'growi'];
   if (className != null) {
     classNames.push(className);
   }

+ 28 - 6
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,
@@ -271,7 +277,12 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
     try {
       const res = await apiv3Post(requestPath, { registerForm });
       const { redirectTo } = res.data;
-      router.push(redirectTo);
+      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">

+ 24 - 27
packages/app/src/components/Navbar/AuthorInfo.jsx → packages/app/src/components/Navbar/AuthorInfo.tsx

@@ -1,18 +1,26 @@
 import React from 'react';
-import PropTypes from 'prop-types';
-import { format } from 'date-fns';
-import { UserPicture } from '@growi/ui';
-import { pagePathUtils } from '@growi/core';
 
-const { userPageRoot } = pagePathUtils;
+import { pagePathUtils } from '@growi/core';
+import type { IUser } from '@growi/core';
+import { UserPicture } from '@growi/ui';
+import { format } from 'date-fns';
+import Link from 'next/link';
 
+export type AuthorInfoProps = {
+  date: Date,
+  user: IUser,
+  mode: 'create' | 'update',
+  locate: 'subnav' | 'footer',
+}
 
-const formatType = 'yyyy/MM/dd HH:mm';
-const AuthorInfo = (props) => {
+export const AuthorInfo = (props: AuthorInfoProps): JSX.Element => {
   const {
-    mode, user, date, locate,
+    date, user, mode = 'create', locate = 'subnav',
   } = props;
 
+  const { userPageRoot } = pagePathUtils;
+  const formatType = 'yyyy/MM/dd HH:mm';
+
   const infoLabelForSubNav = mode === 'create'
     ? 'Created by'
     : 'Updated by';
@@ -23,16 +31,20 @@ const AuthorInfo = (props) => {
     ? 'Created at'
     : 'Last revision posted at';
   const userLabel = user != null
-    ? <a href={userPageRoot(user)}>{user.name}</a>
+    ? (
+      <Link href={userPageRoot(user)} prefetch={false}>
+        <a>{user.name}</a>
+      </Link>
+    )
     : <i>Unknown</i>;
 
   if (locate === 'footer') {
     try {
-      return <p>{infoLabelForFooter} {format(new Date(date), formatType)} by <UserPicture user={user} size="sm" /> {userLabel}</p>;
+      return <p>{infoLabelForFooter} {format(new Date(date), formatType)} by <UserPicture user={user} size="sm"/> {userLabel}</p>;
     }
     catch (err) {
       if (err instanceof RangeError) {
-        return <p>{nullinfoLabelForFooter} <UserPicture user={user} size="sm" /> {userLabel}</p>;
+        return <p>{nullinfoLabelForFooter} <UserPicture user={user} size="sm"/> {userLabel}</p>;
       }
       return <></>;
     }
@@ -50,7 +62,7 @@ const AuthorInfo = (props) => {
   return (
     <div className="d-flex align-items-center">
       <div className="mr-2">
-        <UserPicture user={user} size="sm" />
+        <UserPicture user={user} size="sm"/>
       </div>
       <div>
         <div>{infoLabelForSubNav} {userLabel}</div>
@@ -61,18 +73,3 @@ const AuthorInfo = (props) => {
     </div>
   );
 };
-
-AuthorInfo.propTypes = {
-  date: PropTypes.instanceOf(Date),
-  user: PropTypes.object,
-  mode: PropTypes.oneOf(['create', 'update']),
-  locate: PropTypes.oneOf(['subnav', 'footer']),
-};
-
-AuthorInfo.defaultProps = {
-  mode: 'create',
-  locate: 'subnav',
-};
-
-
-export default AuthorInfo;

+ 9 - 12
packages/app/src/components/Navbar/GrowiContextualSubNavigation.tsx

@@ -3,6 +3,7 @@ import React, { useState, useEffect, useCallback } from 'react';
 import { isPopulated, IUser } from '@growi/core';
 import { useTranslation } from 'next-i18next';
 import dynamic from 'next/dynamic';
+import { useRouter } from 'next/router';
 import { DropdownItem } from 'reactstrap';
 
 import { exportAsMarkdown } from '~/client/services/page-operation';
@@ -36,12 +37,12 @@ import PresentationIcon from '../Icons/PresentationIcon';
 import ShareLinkIcon from '../Icons/ShareLinkIcon';
 import { Skelton } from '../Skelton';
 
+import type { AuthorInfoProps } from './AuthorInfo';
 import { GrowiSubNavigation } from './GrowiSubNavigation';
-import { SubNavButtonsProps } from './SubNavButtons';
+import type { SubNavButtonsProps } from './SubNavButtons';
 
 import AuthorInfoStyles from './AuthorInfo.module.scss';
 import PageEditorModeManagerStyles from './PageEditorModeManager.module.scss';
-import { useRouter } from 'next/router';
 
 
 const AuthorInfoSkelton = () => <Skelton additionalClass={`${AuthorInfoStyles['grw-author-info-skelton']} py-1`} />;
@@ -57,7 +58,7 @@ const SubNavButtons = dynamic<SubNavButtonsProps>(
   () => import('./SubNavButtons').then(mod => mod.SubNavButtons),
   { ssr: false, loading: () => <></> },
 );
-const AuthorInfo = dynamic(() => import('./AuthorInfo'), {
+const AuthorInfo = dynamic<AuthorInfoProps>(() => import('./AuthorInfo').then(mod => mod.AuthorInfo), {
   ssr: false,
   loading: AuthorInfoSkelton,
 });
@@ -277,25 +278,21 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
     if (currentPathname != null) {
       router.push(currentPathname);
     }
-  }, []);
+  }, [currentPathname, router]);
 
   const duplicateItemClickedHandler = useCallback(async(page: IPageForPageDuplicateModal) => {
     const duplicatedHandler: OnDuplicatedFunction = (fromPath, toPath) => {
       router.push(toPath);
     };
     openDuplicateModal(page, { onDuplicated: duplicatedHandler });
-  }, [openDuplicateModal]);
+  }, [openDuplicateModal, router]);
 
   const renameItemClickedHandler = useCallback(async(page: IPageToRenameWithMeta<IPageInfoForEntity>) => {
     const renamedHandler: OnRenamedFunction = () => {
-      if (page.data._id !== null) {
-        router.push(`/${page.data._id}`);
-        return;
-      }
       reload();
     };
     openRenameModal(page, { onRenamed: renamedHandler });
-  }, [openRenameModal]);
+  }, [openRenameModal, reload]);
 
   const deleteItemClickedHandler = useCallback((pageWithMeta: IPageWithMeta) => {
     const deletedHandler: OnDeletedFunction = (pathOrPathsToDelete, isRecursively, isCompletely) => {
@@ -314,7 +311,7 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
       }
     };
     openDeleteModal([pageWithMeta], { onDeleted: deletedHandler });
-  }, [openDeleteModal]);
+  }, [openDeleteModal, reload, router]);
 
   const templateMenuItemClickHandler = useCallback(() => {
     setIsPageTempleteModalShown(true);
@@ -381,7 +378,7 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
             <ul className={`${AuthorInfoStyles['grw-author-info']} text-nowrap border-left d-none d-lg-block d-edit-none py-2 pl-4 mb-0 ml-3`}>
               <li className="pb-1">
                 { currentPage != null
-                  ? <AuthorInfo user={currentPage.creator as IUser} date={currentPage.createdAt} locate="subnav" />
+                  ? <AuthorInfo user={currentPage.creator as IUser} date={currentPage.createdAt} mode="create" locate="subnav" />
                   : <AuthorInfoSkelton />
                 }
               </li>

+ 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 - 6
packages/app/src/components/PageAlert/TrashPageAlert.tsx

@@ -2,11 +2,10 @@ import React from 'react';
 
 import { UserPicture } from '@growi/ui';
 import { format } from 'date-fns';
+import { useRouter } from 'next/router';
 import { useTranslation } from 'react-i18next';
 
-import {
-  useIsTrashPage, useShareLinkId,
-} from '~/stores/context';
+import { useIsTrashPage } from '~/stores/context';
 import { usePageDeleteModal, usePutBackPageModal } from '~/stores/modal';
 import { useSWRxPageInfo, useSWRxCurrentPage } from '~/stores/page';
 import { useIsAbleToShowTrashPageManagementButtons } from '~/stores/ui';
@@ -21,14 +20,14 @@ const onDeletedHandler = (pathOrPathsToDelete) => {
 
 export const TrashPageAlert = (): JSX.Element => {
   const { t } = useTranslation();
+  const router = useRouter();
 
   const { data: isAbleToShowTrashPageManagementButtons } = useIsAbleToShowTrashPageManagementButtons();
-  const { data: shareLinkId } = useShareLinkId();
   const { data: pageData } = useSWRxCurrentPage();
   const { data: isTrashPage } = useIsTrashPage();
   const pageId = pageData?._id;
   const pagePath = pageData?.path;
-  const { data: pageInfo } = useSWRxPageInfo(pageId ?? null, shareLinkId);
+  const { data: pageInfo } = useSWRxPageInfo(pageId ?? null);
 
 
   const { open: openDeleteModal } = usePageDeleteModal();
@@ -49,7 +48,7 @@ export const TrashPageAlert = (): JSX.Element => {
       return;
     }
     const putBackedHandler = () => {
-      window.location.reload();
+      router.push(`/${pageId}`);
     };
     openPutBackPageModal({ pageId, path: pagePath }, { onPutBacked: putBackedHandler });
   }

+ 1 - 1
packages/app/src/components/PageAttachment/DeleteAttachmentModal.tsx

@@ -8,7 +8,7 @@ import {
   Modal, ModalHeader, ModalBody, ModalFooter,
 } from 'reactstrap';
 
-import Username from '../User/Username';
+import { Username } from '../User/Username';
 
 import styles from './DeleteAttachmentModal.module.scss';
 

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

@@ -130,7 +130,7 @@ export const PageComment: FC<PageCommentProps> = memo((props:PageCommentProps):
   const revisionId = getIdForRef(revision);
   const revisionCreatedAt = (isPopulated(revision)) ? revision.createdAt : undefined;
 
-  const generateCommentElement = (comment: ICommentHasId) => (
+  const commentElement = (comment: ICommentHasId) => (
     <Comment
       rendererOptions={rendererOptions}
       comment={comment}
@@ -143,7 +143,7 @@ export const PageComment: FC<PageCommentProps> = memo((props:PageCommentProps):
     />
   );
 
-  const generateReplyCommentsElement = (replyComments: ICommentHasIdList) => (
+  const replyCommentsElement = (replyComments: ICommentHasIdList) => (
     <ReplyComments
       rendererOptions={rendererOptions}
       isReadOnly={isReadOnly}
@@ -172,8 +172,8 @@ export const PageComment: FC<PageCommentProps> = memo((props:PageCommentProps):
 
               return (
                 <div key={comment._id} className={commentThreadClasses}>
-                  {generateCommentElement(comment)}
-                  {hasReply && generateReplyCommentsElement(allReplies[comment._id])}
+                  {commentElement(comment)}
+                  {hasReply && replyCommentsElement(allReplies[comment._id])}
                   {(!isReadOnly && !showEditorIds.has(comment._id)) && (
                     <div className="text-right">
                       <Button

+ 1 - 1
packages/app/src/components/PageComment/Comment.tsx

@@ -13,7 +13,7 @@ import { ICommentHasId } from '../../interfaces/comment';
 import FormattedDistanceDate from '../FormattedDistanceDate';
 import HistoryIcon from '../Icons/HistoryIcon';
 import RevisionRenderer from '../Page/RevisionRenderer';
-import Username from '../User/Username';
+import { Username } from '../User/Username';
 
 import { CommentControl } from './CommentControl';
 import { CommentEditorProps } from './CommentEditor';

+ 1 - 1
packages/app/src/components/PageComment/DeleteCommentModal.tsx

@@ -7,7 +7,7 @@ import {
 } from 'reactstrap';
 
 import { ICommentHasId } from '../../interfaces/comment';
-import Username from '../User/Username';
+import { Username } from '../User/Username';
 
 import styles from './DeleteCommentModal.module.scss';
 

+ 3 - 2
packages/app/src/components/PageContentFooter.tsx

@@ -1,15 +1,16 @@
 import React from 'react';
 
-import { IPage, IUser } from '@growi/core';
+import type { IPage, IUser } from '@growi/core';
 import dynamic from 'next/dynamic';
 
 import { useSWRxCurrentPage } from '~/stores/page';
 
+import type { AuthorInfoProps } from './Navbar/AuthorInfo';
 import { Skelton } from './Skelton';
 
 import styles from './PageContentFooter.module.scss';
 
-const AuthorInfo = dynamic(() => import('./Navbar/AuthorInfo'), {
+const AuthorInfo = dynamic<AuthorInfoProps>(() => import('./Navbar/AuthorInfo').then(mod => mod.AuthorInfo), {
   ssr: false,
   loading: () => <Skelton additionalClass={`${styles['page-content-footer-skelton']} mb-3`} />,
 });

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

+ 3 - 3
packages/app/src/components/PageEditor/ConflictDiffModal.tsx

@@ -2,6 +2,7 @@ import React, {
   useState, useEffect, useRef, useMemo, useCallback,
 } from 'react';
 
+import type { IUser } from '@growi/core';
 import { UserPicture } from '@growi/ui';
 import CodeMirror from 'codemirror/lib/codemirror';
 import { format } from 'date-fns';
@@ -10,8 +11,7 @@ import {
   Modal, ModalHeader, ModalBody, ModalFooter,
 } from 'reactstrap';
 
-import { OptionsToSave } from '~/client/util/editor';
-import { IUser } from '~/interfaces/user';
+import type { OptionsToSave } from '~/interfaces/editor-settings';
 import { useCurrentUser } from '~/stores/context';
 import { useEditorMode } from '~/stores/ui';
 
@@ -119,7 +119,7 @@ const ConflictDiffModalCore = (props: ConflictDiffModalProps & { currentUser: IU
       // pageContainer.showErrorToastr(error);
     }
 
-  }, [pageContainer, editorMode, props.optionsToSave, close]);
+  }, []);
 
   const resizeAndCloseButtons = useMemo(() => (
     <div className="d-flex flex-nowrap">

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

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

@@ -13,8 +13,11 @@ import { apiPost } from '~/client/util/apiv1-client';
 import { getOptionsToSave } from '~/client/util/editor';
 import { IResHackmdIntegrated, IResHackmdDiscard } from '~/interfaces/hackmd';
 import {
-  useCurrentPagePath, useCurrentPageId, useHackmdUri, usePageIdOnHackmd, useHasDraftOnHackmd, useRevisionIdHackmdSynced,
+  useCurrentPagePath, useCurrentPageId, useHackmdUri,
 } from '~/stores/context';
+import {
+  usePageIdOnHackmd, useHasDraftOnHackmd, useRevisionIdHackmdSynced, useRemoteRevisionId,
+} from '~/stores/hackmd';
 import {
   useSWRxSlackChannels, useIsSlackEnabled, usePageTagsForEditors, useIsEnabledUnsavedWarning,
 } from '~/stores/editor';
@@ -68,7 +71,7 @@ export const PageEditorByHackmd = (): JSX.Element => {
   const { data: revisionIdHackmdSynced, mutate: mutateRevisionIdHackmdSynced } = useRevisionIdHackmdSynced();
   const { mutate: mutateIsEnabledUnsavedWarning } = useIsEnabledUnsavedWarning();
   const [isHackmdDraftUpdatingInRealtime, setIsHackmdDraftUpdatingInRealtime] = useState(false);
-  const [remoteRevisionId, setRemoteRevisionId] = useState(revision?._id); // initialize
+  const { data: remoteRevisionId, mutate: mutateRemoteRevisionId } = useRemoteRevisionId(revision?._id);
 
   const hackmdEditorRef = useRef<HackEditorRef>(null);
 
@@ -204,7 +207,7 @@ export const PageEditorByHackmd = (): JSX.Element => {
       setIsHackmdDraftUpdatingInRealtime(false);
       mutateHasDraftOnHackmd(false);
       mutatePageIdOnHackmd(res.pageIdOnHackmd);
-      setRemoteRevisionId(res.revisionIdHackmdSynced);
+      mutateRemoteRevisionId(res.revisionIdHackmdSynced);
       mutateRevisionIdHackmdSynced(res.revisionIdHackmdSynced);
 
 
@@ -235,7 +238,7 @@ export const PageEditorByHackmd = (): JSX.Element => {
       mutatePageData(res);
 
       // set updated data
-      setRemoteRevisionId(res.revision._id);
+      mutateRemoteRevisionId(res.revision._id);
       mutateRevisionIdHackmdSynced(res.page.revisionHackmdSynced);
       mutateHasDraftOnHackmd(res.page.hasDraftOnHackmd);
       mutateTagsInfo();

+ 1 - 1
packages/app/src/components/PageHistory/Revision.tsx

@@ -5,7 +5,7 @@ import { UserPicture } from '@growi/ui';
 import { useTranslation } from 'next-i18next';
 
 import UserDate from '../User/UserDate';
-import Username from '../User/Username';
+import { Username } from '../User/Username';
 
 import styles from './Revision.module.scss';
 

+ 1 - 1
packages/app/src/components/PageStatusAlert.jsx

@@ -5,7 +5,7 @@ import PropTypes from 'prop-types';
 
 // import AppContainer from '~/client/services/AppContainer';
 // import PageContainer from '~/client/services/PageContainer';
-import Username from '~/components/User/Username';
+// import Username from '~/components/User/Username';
 
 import { withUnstatedContainers } from './UnstatedUtils';
 

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

+ 4 - 3
packages/app/src/components/SavePageControls/GrantSelector.tsx

@@ -9,6 +9,7 @@ import {
   Modal, ModalHeader, ModalBody,
 } from 'reactstrap';
 
+import { IPageGrantData } from '~/interfaces/page';
 import { IUserGroupHasId } from '~/interfaces/user';
 import { useCurrentUser } from '~/stores/context';
 import { useSWRxMyUserGroupRelations } from '~/stores/user-group';
@@ -37,7 +38,7 @@ type Props = {
   grantGroupId?: string,
   grantGroupName?: string,
 
-  onUpdateGrant?: (args: { grant: number, grantGroupId?: string | null, grantGroupName?: string | null }) => void,
+  onUpdateGrant?: (grantData: IPageGrantData) => void,
 }
 
 /**
@@ -78,13 +79,13 @@ const GrantSelector = (props: Props): JSX.Element => {
     }
 
     if (onUpdateGrant != null) {
-      onUpdateGrant({ grant, grantGroupId: null, grantGroupName: null });
+      onUpdateGrant({ grant, grantedGroup: undefined });
     }
   }, [onUpdateGrant, showSelectGroupModal]);
 
   const groupListItemClickHandler = useCallback((grantGroup: IUserGroupHasId) => {
     if (onUpdateGrant != null) {
-      onUpdateGrant({ grant: 5, grantGroupId: grantGroup._id, grantGroupName: grantGroup.name });
+      onUpdateGrant({ grant: 5, grantedGroup: { id: grantGroup._id, name: grantGroup.name } });
     }
 
     // hide modal

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

@@ -29,7 +29,7 @@ const ShareLink = (): JSX.Element => {
     try {
       const res = await apiv3Delete('/share-links/', { relatedPage: currentPageId });
       const count = res.data.n;
-      toastSuccess(t('toaster.remove_share_link', { count }));
+      toastSuccess(t('toaster.remove_share_link', { count, ns: 'commons' }));
       mutate();
     }
     catch (err) {
@@ -41,7 +41,7 @@ const ShareLink = (): JSX.Element => {
     try {
       const res = await apiv3Delete(`/share-links/${shareLinkId}`);
       const { deletedShareLink } = res.data;
-      toastSuccess(t('toaster.remove_share_link_success', { shareLinkId: deletedShareLink._id }));
+      toastSuccess(t('toaster.remove_share_link_success', { shareLinkId: deletedShareLink._id, ns: 'commons' }));
       mutate();
     }
     catch (err) {

+ 5 - 5
packages/app/src/components/ShareLink/ShareLinkList.tsx

@@ -69,7 +69,7 @@ type Props = {
 
 const ShareLinkList = (props: Props): JSX.Element => {
 
-  const { t } = useTranslation();
+  const { t } = useTranslation('commons');
 
   function renderShareLinks() {
     return (
@@ -96,10 +96,10 @@ const ShareLinkList = (props: Props): JSX.Element => {
       <table className="table table-bordered">
         <thead>
           <tr>
-            <th>{t('share_links.Share Link')}</th>
-            {props.isAdmin && <th>{t('share_links.Page Path')}</th>}
-            <th>{t('share_links.expire')}</th>
-            <th>{t('share_links.description')}</th>
+            <th>{t('share_links.Share Link', { ns: 'commons' })}</th>
+            {props.isAdmin && <th>{t('share_links.Page Path', { ns: 'commons' })}</th>}
+            <th>{t('share_links.expire', { ns: 'commons' })}</th>
+            <th>{t('share_links.description', { ns: 'commons' })}</th>
             <th></th>
           </tr>
         </thead>

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

@@ -1,8 +1,9 @@
 import React, { useCallback } from 'react';
 
+import { pagePathUtils } from '@growi/core';
 import ReactMarkdown from 'react-markdown';
 
-import { useIsUserPage } from '~/stores/context';
+import { useCurrentPagePath } from '~/stores/context';
 import { useTocOptions } from '~/stores/renderer';
 import loggerFactory from '~/utils/logger';
 
@@ -10,12 +11,16 @@ import { StickyStretchableScroller } from './StickyStretchableScroller';
 
 import styles from './TableOfContents.module.scss';
 
+const { isUserPage: _isUserPage } = pagePathUtils;
+
 // eslint-disable-next-line no-unused-vars
 const logger = loggerFactory('growi:TableOfContents');
 
 const TableOfContents = (): JSX.Element => {
 
-  const { data: isUserPage } = useIsUserPage();
+  const { data: currentPagePath } = useCurrentPagePath();
+
+  const isUserPage = currentPagePath != null && _isUserPage(currentPagePath);
 
   // const [tocHtml, setTocHtml] = useState('');
 

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

@@ -1,6 +1,6 @@
 import React from 'react';
 
-import { IUserHasId } from '@growi/core';
+import type { IUserHasId } from '@growi/core';
 import { UserPicture } from '@growi/ui';
 
 import styles from './UserInfo.module.scss';

+ 0 - 30
packages/app/src/components/User/Username.jsx

@@ -1,30 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-
-export default class Username extends React.Component {
-
-  renderForNull() {
-    return <span>anyone</span>;
-  }
-
-  render() {
-    const { user } = this.props;
-
-    if (user == null) {
-      return this.renderForNull();
-    }
-
-    const name = user.name || '(no name)';
-    const username = user.username;
-    const href = `/user/${user.username}`;
-
-    return (
-      <a href={href}>{name} (@{username})</a>
-    );
-  }
-
-}
-
-Username.propTypes = {
-  user: PropTypes.oneOfType([PropTypes.object, PropTypes.string]), // Possibility of receiving a string of 'null'
-};

+ 26 - 0
packages/app/src/components/User/Username.tsx

@@ -0,0 +1,26 @@
+import React from 'react';
+
+import type { IUser } from '@growi/core';
+import Link from 'next/link';
+
+type UsernameProps = {
+ user?: IUser,
+}
+
+export const Username = (props: UsernameProps): JSX.Element => {
+  const { user } = props;
+
+  if (user == null) {
+    return <span>anyone</span>;
+  }
+
+  const name = user.name || '(no name)';
+  const username = user.username;
+  const href = `/user/${user.username}`;
+
+  return (
+    <Link href={href} prefetch={false}>
+      <a>{name} (@{username})</a>
+    </Link>
+  );
+};

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

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

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

@@ -58,20 +58,19 @@ import DisplaySwitcher from '../components/Page/DisplaySwitcher';
 import {
   useCurrentUser, useCurrentPagePath,
   useIsLatestRevision,
-  useIsForbidden, useIsNotFound, useIsTrashPage, useIsSharedUser,
+  useIsForbidden, useIsNotFound, useIsSharedUser,
   useIsEnabledStaleNotification, useIsIdenticalPath,
   useIsSearchServiceConfigured, useIsSearchServiceReachable, useDisableLinkSharing,
   useDrawioUri, useHackmdUri, useDefaultIndentSize, useIsIndentSizeForced,
-  useIsAclEnabled, useIsUserPage, useIsSearchPage,
+  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';
 
 
@@ -239,11 +238,9 @@ const GrowiPage: NextPage<Props> = (props: Props) => {
   const pagePath = pageWithMeta?.data.path ?? (!_isPermalink(props.currentPathname) ? props.currentPathname : undefined);
 
   useCurrentPageId(pageId ?? null);
-  useIsUserPage(pagePath != null && isUserPage(pagePath));
   // useIsNotCreatable(props.isForbidden || !isCreatablePage(pagePath)); // TODO: need to include props.isIdentical
   useCurrentPagePath(pagePath);
   useCurrentPathname(props.currentPathname);
-  useIsTrashPage(pagePath != null && _isTrashPage(pagePath));
 
   useSWRxCurrentPage(undefined, pageWithMeta?.data ?? null); // store initial data
   useEditingMarkdown(pageWithMeta?.data.revision?.body ?? '');
@@ -252,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
@@ -283,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;

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

@@ -0,0 +1,74 @@
+import { useEffect } from 'react';
+
+import { isClient, objectIdUtils } from '@growi/core';
+import {
+  NextPage, GetServerSideProps, GetServerSidePropsContext,
+} from 'next';
+import { useTranslation } from 'next-i18next';
+import dynamic from 'next/dynamic';
+import { useRouter } from 'next/router';
+import { Container, Provider } from 'unstated';
+
+import AdminNotificationContainer from '~/client/services/AdminNotificationContainer';
+import { toastError } from '~/client/util/apiNotification';
+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 });
+
+
+const AdminGlobalNotificationNewPage: NextPage<CommonProps> = (props) => {
+  const { t } = useTranslation('admin');
+  const router = useRouter();
+  const { globalNotificationId } = router.query;
+  const currentGlobalNotificationId = Array.isArray(globalNotificationId) ? globalNotificationId[0] : globalNotificationId;
+
+
+  useEffect(() => {
+    if (globalNotificationId == null) {
+      router.push('/admin/notification');
+    }
+    if ((currentGlobalNotificationId != null && !objectIdUtils.isValidObjectId(currentGlobalNotificationId))) {
+      toastError(t('notification_settings.not_found_global_notification_triggerid'));
+      router.push('/admin/global-notification/new');
+      return;
+    }
+  }, [currentGlobalNotificationId, globalNotificationId, router, t]);
+
+
+  const title = t('external_notification.external_notification');
+  const customTitle = useCustomTitle(props, title);
+
+
+  const injectableContainers: Container<any>[] = [];
+
+  if (isClient()) {
+    const adminNotificationContainer = new AdminNotificationContainer();
+    injectableContainers.push(adminNotificationContainer);
+  }
+
+
+  return (
+    <Provider inject={[...injectableContainers]}>
+      <AdminLayout title={customTitle} componentTitle={title} >
+        {
+          currentGlobalNotificationId != null && router.isReady
+      && <ManageGlobalNotification globalNotificationId={currentGlobalNotificationId} />
+        }
+      </AdminLayout>
+    </Provider>
+  );
+
+};
+
+
+export const getServerSideProps: GetServerSideProps = async(context: GetServerSidePropsContext) => {
+  const props = await retrieveServerSideProps(context);
+  return props;
+};
+
+
+export default AdminGlobalNotificationNewPage;

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

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

@@ -0,0 +1,45 @@
+import {
+  NextPage, GetServerSideProps, GetServerSidePropsContext,
+} from 'next';
+import { useTranslation } from 'next-i18next';
+import dynamic from 'next/dynamic';
+import { useRouter } from 'next/router';
+
+import { CommonProps, useCustomTitle } 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 UserGroupDetailPage = dynamic(() => import('~/components/Admin/UserGroupDetail/UserGroupDetailPage'), { ssr: false });
+
+
+const AdminUserGroupDetailPage: NextPage<CommonProps> = (props) => {
+  const { t } = useTranslation('admin');
+  useIsMaintenanceMode(props.isMaintenanceMode);
+  const router = useRouter();
+  const { userGroupId } = router.query;
+
+  const title = t('user_group_management.user_group_management');
+  const customTitle = useCustomTitle(props, title);
+
+
+  const currentUserGroupId = Array.isArray(userGroupId) ? userGroupId[0] : userGroupId;
+
+  return (
+    <AdminLayout title={customTitle} componentTitle={title} >
+      {
+        currentUserGroupId != null && router.isReady
+      && <UserGroupDetailPage userGroupId={currentUserGroupId} />
+      }
+    </AdminLayout>
+  );
+};
+
+export const getServerSideProps: GetServerSideProps = async(context: GetServerSidePropsContext) => {
+  const props = await retrieveServerSideProps(context);
+  return props;
+};
+
+
+export default AdminUserGroupDetailPage;

+ 4 - 2
packages/app/src/pages/login.page.tsx

@@ -29,6 +29,7 @@ type Props = CommonProps & {
   isLocalStrategySetup: boolean,
   isLdapStrategySetup: boolean,
   isLdapSetupFailed: boolean,
+  isEmailAuthenticationEnabled: boolean,
 };
 
 const LoginPage: NextPage<Props> = (props: Props) => {
@@ -49,11 +50,11 @@ const LoginPage: NextPage<Props> = (props: Props) => {
         isLocalStrategySetup={props.isLocalStrategySetup}
         isLdapStrategySetup={props.isLdapStrategySetup}
         isLdapSetupFailed={props.isLdapSetupFailed}
-        isEmailAuthenticationEnabled={false}
+        isEmailAuthenticationEnabled={props.isEmailAuthenticationEnabled}
         isRegistrationEnabled={true}
         registrationWhiteList={props.registrationWhiteList}
         isPasswordResetEnabled={true}
-        isMailerSetup={false}
+        isMailerSetup={props.isMailerSetup}
       />
     </NoLoginLayout>
   );
@@ -104,6 +105,7 @@ async function injectServerConfigurations(context: GetServerSidePropsContext, pr
   props.isLdapStrategySetup = passportService.isLdapStrategySetup;
   props.isLdapSetupFailed = configManager.getConfig('crowi', 'security:passport-ldap:isEnabled') && !props.isLdapStrategySetup;
   props.registrationWhiteList = configManager.getConfig('crowi', 'security:registrationWhiteList');
+  props.isEmailAuthenticationEnabled = configManager.getConfig('crowi', 'security:passport-local:isEmailAuthenticationEnabled');
 }
 
 export const getServerSideProps: GetServerSideProps = async(context: GetServerSidePropsContext) => {

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

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

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

@@ -55,6 +55,8 @@ module.exports = (crowi, app, isInstalled) => {
   routerForAuth.post('/register',
     applicationInstalled, registerFormValidator.registerRules(), registerFormValidator.registerValidation, addActivity, login.register);
 
+  routerForAuth.post('/user-activation/register', applicationInstalled, userActivation.registerRules(),
+    userActivation.validateRegisterForm, userActivation.registerAction(crowi));
 
   // installer
   if (!isInstalled) {
@@ -66,6 +68,7 @@ module.exports = (crowi, app, isInstalled) => {
 
   router.use('/personal-setting', require('./personal-setting')(crowi));
 
+  router.use('/user-group-relations', require('./user-group-relation')(crowi));
   router.use('/user-group-relations', require('./user-group-relation')(crowi));
 
   router.use('/statistics', require('./statistics')(crowi));

+ 30 - 12
packages/app/src/server/routes/apiv3/notification-setting.js

@@ -26,12 +26,12 @@ const validator = {
   globalNotification: [
     body('triggerPath').isString().trim().not()
       .isEmpty(),
-    body('notifyToType').isString().trim().isIn(['mail', 'slack']),
+    body('notifyType').isString().trim().isIn(['mail', 'slack']),
     body('toEmail').trim().custom((value, { req }) => {
-      return (req.body.notifyToType === 'mail') ? (!!value && value.match(/.+@.+\..+/)) : true;
+      return (req.body.notifyType === 'mail') ? (!!value && value.match(/.+@.+\..+/)) : true;
     }),
     body('slackChannels').trim().custom((value, { req }) => {
-      return (req.body.notifyToType === 'slack') ? !!value : true;
+      return (req.body.notifyType === 'slack') ? !!value : true;
     }),
   ],
   notifyForPageGrant: [
@@ -72,7 +72,7 @@ const validator = {
  *      GlobalNotificationParams:
  *        type: object
  *        properties:
- *          notifyToType:
+ *          notifyType:
  *            type: string
  *            description: What is type for notify
  *          toEmail:
@@ -232,6 +232,24 @@ module.exports = (crowi) => {
 
   });
 
+
+  router.get('/global-notification/:id', loginRequiredStrictly, adminRequired, validator.globalNotification, 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.apiv3({ globalNotification });
+  });
+
   /**
    * @swagger
    *
@@ -260,16 +278,16 @@ module.exports = (crowi) => {
   router.post('/global-notification', loginRequiredStrictly, adminRequired, addActivity, validator.globalNotification, apiV3FormValidator, async(req, res) => {
 
     const {
-      notifyToType, toEmail, slackChannels, triggerPath, triggerEvents,
+      notifyType, toEmail, slackChannels, triggerPath, triggerEvents,
     } = req.body;
 
     let notification;
 
-    if (notifyToType === GlobalNotificationSetting.TYPE.MAIL) {
+    if (notifyType === GlobalNotificationSetting.TYPE.MAIL) {
       notification = new GlobalNotificationMailSetting(crowi);
       notification.toEmail = toEmail;
     }
-    if (notifyToType === GlobalNotificationSetting.TYPE.SLACK) {
+    if (notifyType === GlobalNotificationSetting.TYPE.SLACK) {
       notification = new GlobalNotificationSlackSetting(crowi);
       notification.slackChannels = slackChannels;
     }
@@ -328,7 +346,7 @@ module.exports = (crowi) => {
   router.put('/global-notification/:id', loginRequiredStrictly, adminRequired, addActivity, validator.globalNotification, apiV3FormValidator, async(req, res) => {
     const { id } = req.params;
     const {
-      notifyToType, toEmail, slackChannels, triggerPath, triggerEvents,
+      notifyType, toEmail, slackChannels, triggerPath, triggerEvents,
     } = req.body;
 
     const models = {
@@ -342,7 +360,7 @@ module.exports = (crowi) => {
 
       // when switching from one type to another,
       // remove toEmail from slack setting and slackChannels from mail setting
-      if (setting.__t !== notifyToType) {
+      if (setting.__t !== notifyType) {
         setting = models[setting.__t].hydrate(setting);
         setting.toEmail = undefined;
         setting.slackChannels = undefined;
@@ -350,16 +368,16 @@ module.exports = (crowi) => {
         setting = setting.toObject();
       }
 
-      if (notifyToType === GlobalNotificationSetting.TYPE.MAIL) {
+      if (notifyType === GlobalNotificationSetting.TYPE.MAIL) {
         setting = GlobalNotificationMailSetting.hydrate(setting);
         setting.toEmail = toEmail;
       }
-      if (notifyToType === GlobalNotificationSetting.TYPE.SLACK) {
+      if (notifyType === GlobalNotificationSetting.TYPE.SLACK) {
         setting = GlobalNotificationSlackSetting.hydrate(setting);
         setting.slackChannels = slackChannels;
       }
 
-      setting.__t = notifyToType;
+      setting.__t = notifyType;
       setting.triggerPath = triggerPath;
       setting.triggerEvents = triggerEvents || [];
 

+ 122 - 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,102 @@ 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);
-      }
+        req.flash('successMessage', req.t('message.successfully_created', { username }));
+        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 {

+ 2 - 42
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
    */
@@ -240,7 +201,6 @@ module.exports = function(crowi, app) {
   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));
 
   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);
         }

+ 0 - 108
packages/app/src/server/routes/user-activation.ts

@@ -1,72 +1,8 @@
-import path from 'path';
-
-import { format, subSeconds } from 'date-fns';
-import { body, validationResult } from 'express-validator';
-
-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,
-    },
-  });
-}
-
-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 }));
-
-    return res.redirect('/login');
-  };
-};
-
 // middleware to handle error
 export const tokenErrorHandlerMiddeware = (err, req, res, next) => {
   if (err != null) {
@@ -75,47 +11,3 @@ export const tokenErrorHandlerMiddeware = (err, req, res, next) => {
   }
   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);
-
-  res.redirect('back');
-};

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

Beberapa file tidak ditampilkan karena terlalu banyak file yang berubah dalam diff ini