Przeglądaj źródła

Merge remote-tracking branch 'origin/master' into imprv/mutate-current-page-after-page-operation-by-pagetree

Yuki Takei 3 lat temu
rodzic
commit
0aa83968f9
58 zmienionych plików z 715 dodań i 404 usunięć
  1. 5 1
      packages/app/public/static/locales/en_US/admin.json
  2. 37 0
      packages/app/public/static/locales/en_US/commons.json
  3. 1 33
      packages/app/public/static/locales/en_US/translation.json
  4. 3 0
      packages/app/public/static/locales/ja_JP/admin.json
  5. 37 0
      packages/app/public/static/locales/ja_JP/commons.json
  6. 1 33
      packages/app/public/static/locales/ja_JP/translation.json
  7. 3 1
      packages/app/public/static/locales/zh_CN/admin.json
  8. 37 0
      packages/app/public/static/locales/zh_CN/commons.json
  9. 1 33
      packages/app/public/static/locales/zh_CN/translation.json
  10. 27 0
      packages/app/src/client/interfaces/global-notification.ts
  11. 1 1
      packages/app/src/components/Admin/App/AppSetting.jsx
  12. 1 1
      packages/app/src/components/Admin/App/AppSettingsPageContents.tsx
  13. 7 7
      packages/app/src/components/Admin/Common/AdminNavigation.jsx
  14. 1 1
      packages/app/src/components/Admin/NotFoundPage.tsx
  15. 1 1
      packages/app/src/components/Admin/Notification/GlobalNotificationList.jsx
  16. 42 52
      packages/app/src/components/Admin/Notification/ManageGlobalNotification.tsx
  17. 2 2
      packages/app/src/components/Admin/Notification/NotificationDeleteModal.jsx
  18. 1 1
      packages/app/src/components/Admin/Security/GitHubSecuritySettingContents.jsx
  19. 1 1
      packages/app/src/components/Admin/Security/GoogleSecuritySettingContents.jsx
  20. 2 2
      packages/app/src/components/Admin/Security/OidcSecuritySettingContents.jsx
  21. 1 1
      packages/app/src/components/Admin/Security/SamlSecuritySettingContents.jsx
  22. 1 1
      packages/app/src/components/Admin/Security/TwitterSecuritySettingContents.jsx
  23. 1 1
      packages/app/src/components/Admin/SlackIntegration/ConfirmBotChangeModal.jsx
  24. 1 1
      packages/app/src/components/Admin/UserGroup/UserGroupDropdown.tsx
  25. 39 24
      packages/app/src/components/Admin/UserGroupDetail/UserGroupDetailPage.tsx
  26. 3 7
      packages/app/src/components/Admin/UserGroupDetail/UserGroupUserTable.tsx
  27. 3 3
      packages/app/src/components/AlertSiteUrlUndefined.tsx
  28. 3 1
      packages/app/src/components/Common/ImageCropModal.tsx
  29. 113 87
      packages/app/src/components/CompleteUserRegistrationForm.tsx
  30. 1 1
      packages/app/src/components/InAppNotification/InAppNotificationDropdown.tsx
  31. 3 6
      packages/app/src/components/Layout/AdminLayout.tsx
  32. 1 1
      packages/app/src/components/Navbar/GlobalSearch.tsx
  33. 4 4
      packages/app/src/components/Navbar/PersonalDropdown.jsx
  34. 5 2
      packages/app/src/components/Page/CopyDropdown.jsx
  35. 1 1
      packages/app/src/components/PageList/PageListItemL.tsx
  36. 3 3
      packages/app/src/components/RevisionComparer/RevisionComparer.tsx
  37. 3 2
      packages/app/src/components/User/UserDate.jsx
  38. 6 0
      packages/app/src/interfaces/errors/user-activation.ts
  39. 32 0
      packages/app/src/pages/admin/[...path].page.tsx
  40. 2 2
      packages/app/src/pages/admin/app.page.tsx
  41. 1 0
      packages/app/src/pages/admin/global-notification/[globalNotificationId].page.tsx
  42. 2 1
      packages/app/src/pages/admin/global-notification/new.page.tsx
  43. 9 0
      packages/app/src/pages/admin/index.page.tsx
  44. 15 4
      packages/app/src/pages/admin/user-group-detail/[userGroupId].page.tsx
  45. 76 0
      packages/app/src/pages/user-activation.page.tsx
  46. 19 4
      packages/app/src/server/middlewares/inject-user-registration-order-by-token-middleware.ts
  47. 0 1
      packages/app/src/server/routes/apiv3/user-activation.ts
  48. 1 1
      packages/app/src/server/routes/apiv3/users.js
  49. 3 2
      packages/app/src/server/routes/index.js
  50. 33 9
      packages/app/src/server/routes/user-activation.ts
  51. 57 48
      packages/app/src/stores/context.tsx
  52. 2 2
      packages/app/src/stores/global-notification.ts
  53. 24 0
      packages/app/src/stores/use-context-swr.tsx
  54. 9 0
      packages/app/test/cypress/integration/20-basic-features/access-to-page.spec.ts
  55. 6 3
      packages/app/test/cypress/integration/20-basic-features/click-page-icons.spec.ts
  56. 4 4
      packages/app/test/cypress/integration/20-basic-features/use-tools.spec.ts
  57. 12 6
      packages/app/test/cypress/integration/50-sidebar/access-to-side-bar.spec.ts
  58. 5 1
      packages/app/test/cypress/integration/60-home/home.spec.ts

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

@@ -276,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": {
@@ -861,6 +862,9 @@
       "log_type": "https://docs.growi.org/en/admin-guide/admin-cookbook/audit-log-setup.html#log-types"
     }
   },
+  "cloud_setting_management": {
+    "to_cloud_settings": "Open GROWI.cloud Settings"
+  },
   "audit_log_action_category": {
     "Page": "Page",
     "Comment": "Comment",

+ 37 - 0
packages/app/public/static/locales/en_US/commons.json

@@ -19,10 +19,47 @@
     "app_settings": "App Settings"
   },
 
+  "header_search_box": {
+    "label": {
+      "All pages": "All pages",
+      "This tree": "This tree"
+    },
+    "item_label": {
+      "All pages": "All pages",
+      "This tree": "Only children of this tree"
+    }
+  },
+
   "share_links": {
     "Share Link": "Share Link",
     "Page Path": "Page Path",
     "expire": "Expiration",
     "description": "Description"
+  },
+
+  "personal_dropdown": {
+    "home": "Home",
+    "settings": "Settings"
+  },
+
+  "copy_to_clipboard": {
+    "Copy to clipboard": "Copy to clipboard",
+    "Page path": "Page path",
+    "Page URL": "Page URL",
+    "Permanent link": "Permanent link",
+    "Page path and permanent link": "Page path and permanent link",
+    "Markdown link": "Markdown link"
+  },
+
+  "crop_image_modal": {
+    "image_crop": "Image Crop",
+    "crop": "Crop",
+    "save": "Save",
+    "reset": "Reset",
+    "cancel": "Cancel"
+  },
+
+  "not_found_page": {
+    "page_not_exist": "This page does not exist."
   }
 }

+ 1 - 33
packages/app/public/static/locales/en_US/translation.json

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

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

@@ -868,6 +868,9 @@
       "log_type": "https://docs.growi.org/ja/admin-guide/admin-cookbook/audit-log-setup.html#log-types"
     }
   },
+  "cloud_setting_management": {
+    "to_cloud_settings": "GROWI.cloud の管理画面へ"
+  },
   "audit_log_action_category": {
     "Page": "ページ",
     "Comment": "コメント",

+ 37 - 0
packages/app/public/static/locales/ja_JP/commons.json

@@ -19,10 +19,47 @@
     "app_settings": "アプリ設定"
   },
 
+  "header_search_box": {
+    "label": {
+      "All pages": "全てのページ",
+      "This tree": "この階層"
+    },
+    "item_label": {
+      "All pages": "全てのページ",
+      "This tree": "この階層下の子ページのみ"
+    }
+  },
+
   "share_links": {
     "Share Link": "共有用リンク",
     "Page Path": "ページパス",
     "expire": "有効期限",
     "description": "概要"
+  },
+
+  "personal_dropdown": {
+    "home": "ホーム",
+    "settings": "設定"
+  },
+
+  "copy_to_clipboard": {
+    "Copy to clipboard": "クリップボードにコピー",
+    "Page path": "ページ名",
+    "Page URL": "ページURL",
+    "Permanent link": "パーマリンク",
+    "Page path and permanent link": "ページ名とパーマリンク",
+    "Markdown link": "マークダウン形式のリンク"
+  },
+
+  "crop_image_modal": {
+    "image_crop": "画像の切り抜き",
+    "crop": "トリミング",
+    "save": "保存",
+    "reset": "リセット",
+    "cancel": "キャンセル"
+  },
+
+  "not_found_page": {
+    "page_not_exist": "このページは存在しません。"
   }
 }

+ 1 - 33
packages/app/public/static/locales/ja_JP/translation.json

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

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

@@ -882,7 +882,9 @@
     "docs_url": {
       "log_type": "https://docs.growi.org/en/admin-guide/admin-cookbook/audit-log-setup.html#log-types"
     }
-
+  },
+  "cloud_setting_management": {
+    "to_cloud_settings": "進入 GROWI.cloud 的管理界面"
   },
   "audit_log_action_category": {
     "Page": "页面",

+ 37 - 0
packages/app/public/static/locales/zh_CN/commons.json

@@ -19,10 +19,47 @@
     "app_settings": "系统设置"
   },
 
+  "header_search_box": {
+		"label": {
+			"All pages": "所有页面",
+			"This tree": "当前分支"
+		},
+		"item_label": {
+			"All pages": "所有页面",
+			"This tree": "当前分支以下内容"
+		}
+  },
+
   "share_links": {
     "Share Link": "Share Link",
     "Page Path": "Page Path",
     "expire": "Expiration",
     "description": "Description"
+  },
+
+  "personal_dropdown": {
+    "home": "家",
+    "settings": "设置"
+  },
+
+	"copy_to_clipboard": {
+		"Copy to clipboard": "复制到剪贴板",
+		"Page path": "页面路径",
+		"Page URL": "页面Url",
+		"Parmanent link": "参数化链接",
+		"Page path and parmanent link": "页面路径及参数化链接",
+		"Markdown link": "Markdown链接"
+	},
+
+  "crop_image_modal": {
+    "image_crop": "图像裁剪",
+    "crop": "修剪",
+    "save": "节省",
+    "reset": "重启",
+    "cancel": "取消"
+  },
+
+  "not_found_page": {
+    "page_not_exist": "该页面不存在"
   }
 }

+ 1 - 33
packages/app/public/static/locales/zh_CN/translation.json

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

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

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

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

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

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

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

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

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

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

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

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

@@ -180,7 +180,7 @@ GlobalNotificationList.propTypes = {
 };
 
 const GlobalNotificationListWrapperFC = (props) => {
-  const { t } = useTranslation();
+  const { t } = useTranslation('admin');
 
   return <GlobalNotificationList t={t} {...props} />;
 };

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

@@ -147,7 +147,6 @@ export const completeRegistrationAction = (crowi) => {
           }
         }
 
-        req.flash('successMessage', req.t('message.successfully_created', { username }));
         res.apiv3({ status: 'ok' });
       });
     });

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

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

@@ -198,9 +198,10 @@ module.exports = function(crowi, app) {
     .use(forgotPassword.handleErrorsMiddleware(crowi)));
 
   app.get('/_private-legacy-pages', next.delegateToNext);
+
   app.use('/user-activation', express.Router()
-    .get('/:token', applicationInstalled, injectUserRegistrationOrderByTokenMiddleware, userActivation.form)
-    .use(userActivation.tokenErrorHandlerMiddeware));
+    .get('/:token', applicationInstalled, injectUserRegistrationOrderByTokenMiddleware, userActivation.renderUserActivationPage(crowi))
+    .use(userActivation.tokenErrorHandlerMiddeware(crowi)));
 
   app.get('/share/:linkId', next.delegateToNext);
 

+ 33 - 9
packages/app/src/server/routes/user-activation.ts

@@ -1,13 +1,37 @@
-export const form = (req, res): void => {
-  const { userRegistrationOrder } = req;
-  return res.render('user-activation', { userRegistrationOrder });
+import { Response, NextFunction } from 'express';
+
+import type { UserActivationErrorCode } from '~/interfaces/errors/user-activation';
+import { ReqWithUserRegistrationOrder } from '~/server/middlewares/inject-user-registration-order-by-token-middleware';
+
+type Crowi = {
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+  nextApp: any,
+}
+
+type CrowiReq = ReqWithUserRegistrationOrder & {
+  crowi: Crowi,
+}
+
+export const renderUserActivationPage = (crowi: Crowi) => {
+  return (req: CrowiReq, res: Response): void => {
+    const { userRegistrationOrder } = req;
+    const { nextApp } = crowi;
+    req.crowi = crowi;
+    nextApp.render(req, res, '/user-activation', { userRegistrationOrder });
+    return;
+  };
 };
 
 // middleware to handle error
-export const tokenErrorHandlerMiddeware = (err, req, res, next) => {
-  if (err != null) {
-    req.flash('errorMessage', req.t('message.incorrect_token_or_expired_url'));
-    return res.redirect('/login#register');
-  }
-  next();
+export const tokenErrorHandlerMiddeware = (crowi: Crowi) => {
+  return (error: Error & { code: UserActivationErrorCode, statusCode: number }, req: CrowiReq, res: Response, next: NextFunction): void => {
+    if (error != null) {
+      const { nextApp } = crowi;
+      req.crowi = crowi;
+      nextApp.render(req, res, '/user-activation', { errorCode: error.code });
+      return;
+    }
+
+    next();
+  };
 };

+ 57 - 48
packages/app/src/stores/context.tsx

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

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

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

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

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

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

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

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

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

+ 4 - 4
packages/app/test/cypress/integration/20-basic-features/use-tools.spec.ts

@@ -176,7 +176,7 @@ context('Page Accessories Modal', () => {
   });
 
   it('Page History is shown successfully', () => {
-     cy.visit('/Sandbox/Bootstrap4', {  });
+     cy.visit('/Sandbox/Bootstrap4');
      cy.get('#grw-subnav-container').within(() => {
       cy.getByTestid('open-page-item-control-btn').within(() => {
         cy.get('button.btn-page-item-control').click({force: true});
@@ -313,9 +313,9 @@ context('Tag Oprations', () =>{
           cy.wrap($row).within(() => {
             cy.getByTestid('open-page-item-control-btn').first().click();
             cy.getByTestid('page-item-control-menu').should('have.class', 'show').first().within(() => {
-            // eslint-disable-next-line cypress/no-unnecessary-waiting
-            cy.wait(300);
-            cy.screenshot(`${ssPrefix}2-open-page-item-control-menu`);
+              // empty sentence in page list empty: https://github.com/weseek/growi/pull/6880
+              cy.getByTestid('revision-short-body-in-page-list-item-L').invoke('text', '');
+              cy.screenshot(`${ssPrefix}2-open-page-item-control-menu`);
             })
           });
         }

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

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

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

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