jam411 3 лет назад
Родитель
Сommit
11225bc60d
87 измененных файлов с 1579 добавлено и 1213 удалено
  1. 6 5
      .github/workflows/release-slackbot-proxy.yml
  2. 2 2
      packages/app/docker/README.md
  3. 28 4
      packages/app/public/static/locales/en_US/admin.json
  4. 4 25
      packages/app/public/static/locales/en_US/translation.json
  5. 303 0
      packages/app/public/static/locales/ja_JP/admin.json
  6. 4 299
      packages/app/public/static/locales/ja_JP/translation.json
  7. 275 1
      packages/app/public/static/locales/zh_CN/admin.json
  8. 4 262
      packages/app/public/static/locales/zh_CN/translation.json
  9. 6 5
      packages/app/src/components/Admin/Common/AdminNavigation.jsx
  10. 1 1
      packages/app/src/components/Admin/Common/AdminUpdateButtonRow.tsx
  11. 5 5
      packages/app/src/components/Admin/Customize/CustomizeLayoutSetting.tsx
  12. 7 7
      packages/app/src/components/Admin/Customize/CustomizeSidebarSetting.tsx
  13. 3 3
      packages/app/src/components/Admin/Customize/CustomizeThemeOptions.jsx
  14. 1 1
      packages/app/src/components/Admin/ElasticsearchManagement/ElasticsearchManagement.tsx
  15. 1 1
      packages/app/src/components/Admin/ElasticsearchManagement/NormalizeIndicesControls.tsx
  16. 1 1
      packages/app/src/components/Admin/ElasticsearchManagement/RebuildIndexControls.jsx
  17. 1 1
      packages/app/src/components/Admin/ElasticsearchManagement/ReconnectControls.tsx
  18. 1 1
      packages/app/src/components/Admin/ElasticsearchManagement/StatusTable.jsx
  19. 1 1
      packages/app/src/components/Admin/ExportArchiveDataPage.jsx
  20. 2 2
      packages/app/src/components/Admin/ImportData/GrowiArchive/ImportCollectionConfigurationModal.jsx
  21. 2 2
      packages/app/src/components/Admin/ImportData/GrowiArchive/ImportForm.jsx
  22. 2 2
      packages/app/src/components/Admin/ImportData/GrowiArchive/UploadForm.jsx
  23. 7 7
      packages/app/src/components/Admin/ImportData/GrowiArchiveSection.jsx
  24. 21 21
      packages/app/src/components/Admin/ImportData/ImportDataPageContents.jsx
  25. 6 6
      packages/app/src/components/Admin/MarkdownSetting/IndentForm.tsx
  26. 7 7
      packages/app/src/components/Admin/MarkdownSetting/LineBreakForm.jsx
  27. 9 9
      packages/app/src/components/Admin/MarkdownSetting/MarkDownSettingContents.tsx
  28. 11 11
      packages/app/src/components/Admin/MarkdownSetting/PresentationForm.jsx
  29. 5 5
      packages/app/src/components/Admin/MarkdownSetting/WhiteListInput.jsx
  30. 9 9
      packages/app/src/components/Admin/MarkdownSetting/XssForm.jsx
  31. 7 7
      packages/app/src/components/Admin/Security/SecuritySetting.jsx
  32. 2 2
      packages/app/src/components/Admin/UserGroup/UserGroupTable.tsx
  33. 1 0
      packages/app/src/components/Admin/UserGroupDetail/UserGroupDetailPage.module.scss
  34. 2 1
      packages/app/src/components/Admin/UserGroupDetail/UserGroupDetailPage.tsx
  35. 1 1
      packages/app/src/components/Admin/UserManagement.jsx
  36. 2 2
      packages/app/src/components/Admin/Users/UserMenu.jsx
  37. 3 3
      packages/app/src/components/Admin/Users/UserTable.jsx
  38. 1 0
      packages/app/src/components/IdenticalPathPage.module.scss
  39. 1 1
      packages/app/src/components/IdenticalPathPage.tsx
  40. 16 0
      packages/app/src/components/InAppNotification/InAppNotificationElm.tsx
  41. 2 0
      packages/app/src/components/Layout/BasicLayout.tsx
  42. 24 24
      packages/app/src/components/PageAccessoriesModal.tsx
  43. 8 7
      packages/app/src/components/PageEditor/CodeMirrorEditor.jsx
  44. 37 0
      packages/app/src/components/PageEditor/CodeMirrorEditor.module.scss
  45. 66 20
      packages/app/src/components/PageEditor/Editor.module.scss
  46. 4 2
      packages/app/src/components/PageEditor/Editor.tsx
  47. 4 2
      packages/app/src/components/PageEditor/GridEditModal.jsx
  48. 43 0
      packages/app/src/components/PageEditor/GridEditModal.module.scss
  49. 1 1
      packages/app/src/components/PageEditor/Preview.tsx
  50. 1 0
      packages/app/src/components/PageList/PageList.module.scss
  51. 2 1
      packages/app/src/components/PageList/PageList.tsx
  52. 1 0
      packages/app/src/components/SearchPage2/SearchPageBase.module.scss
  53. 2 1
      packages/app/src/components/SearchPage2/SearchPageBase.tsx
  54. 41 0
      packages/app/src/components/UsersHomePageFooter.tsx
  55. 16 0
      packages/app/src/interfaces/activity.ts
  56. 0 1
      packages/app/src/interfaces/subscription.ts
  57. 1 0
      packages/app/src/pages/[[...path]].page.module.scss
  58. 52 73
      packages/app/src/pages/[[...path]].page.tsx
  59. 7 7
      packages/app/src/pages/admin/[[...path]].page.tsx
  60. 3 12
      packages/app/src/server/models/activity.ts
  61. 17 20
      packages/app/src/server/models/subscription.ts
  62. 8 12
      packages/app/src/server/routes/apiv3/pages.js
  63. 0 1
      packages/app/src/server/routes/apiv3/personal-setting.js
  64. 2 2
      packages/app/src/server/routes/index.js
  65. 14 17
      packages/app/src/server/routes/page.js
  66. 8 5
      packages/app/src/server/service/activity.ts
  67. 19 9
      packages/app/src/server/service/in-app-notification.ts
  68. 127 32
      packages/app/src/server/service/page.ts
  69. 2 2
      packages/app/src/server/views/admin/customize.html
  70. 2 2
      packages/app/src/server/views/admin/export.html
  71. 2 2
      packages/app/src/server/views/admin/importer.html
  72. 2 2
      packages/app/src/server/views/admin/markdown.html
  73. 2 2
      packages/app/src/server/views/admin/search.html
  74. 3 0
      packages/app/src/stores/personal-settings.tsx
  75. 0 28
      packages/app/src/styles/_editor-navbar.scss
  76. 0 72
      packages/app/src/styles/_editor-overlay.scss
  77. 20 0
      packages/app/src/styles/_mixins.scss
  78. 2 48
      packages/app/src/styles/_on-edit.scss
  79. 2 7
      packages/app/src/styles/molecules/_page_list.scss
  80. 0 1
      packages/app/src/styles/style-next.scss
  81. 38 13
      packages/app/test/integration/service/page.test.js
  82. 63 21
      packages/app/test/integration/service/v5.non-public-page.test.ts
  83. 14 4
      packages/app/test/integration/service/v5.page.test.ts
  84. 127 36
      packages/app/test/integration/service/v5.public-page.test.ts
  85. 15 0
      packages/core/src/interfaces/subscription.ts
  86. 3 1
      packages/plugin-lsx/src/components/LsxPageList/LsxListView.jsx
  87. 1 0
      packages/plugin-lsx/src/components/LsxPageList/LsxListView.module.scss

+ 6 - 5
.github/workflows/release-slackbot-proxy.yml

@@ -42,12 +42,13 @@ jobs:
         username: wsmoogle
         password: ${{ secrets.DOCKER_REGISTRY_ON_GITHUB_PASSWORD }}
 
-    - name: Setup gcloud
-      uses: google-github-actions/setup-gcloud@master
+    - name: Authenticate to Google Cloud for GROWI.cloud
+      uses: google-github-actions/auth@v0
       with:
-        project_id: ${{ secrets.GCP_PRJ_ID_SLACKBOT_PROXY }}
-        service_account_key: ${{ secrets.GCP_SA_KEY_SLACKBOT_PROXY }}
-        export_default_credentials: true
+        credentials_json: '${{ secrets.GCP_SA_KEY_SLACKBOT_PROXY }}'
+
+    - name: Setup gcloud
+      uses: google-github-actions/setup-gcloud@v0
 
     - name: Configure docker for gcloud
       run: |

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

@@ -10,8 +10,8 @@ GROWI Official docker image
 Supported tags and respective Dockerfile links
 ------------------------------------------------
 
-* [`5.1.3`, `5.1`, `5`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v5.1.3/docker/Dockerfile)
-* [`5.1.3-nocdn`, `5.1-nocdn`, `5-nocdn`, `latest-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v5.1.3/docker/Dockerfile)
+* [`5.1.3`, `5.1`, `5`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v5.1.3/packages/app/docker/Dockerfile)
+* [`5.1.3-nocdn`, `5.1-nocdn`, `5-nocdn`, `latest-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v5.1.3/packages/app/docker/Dockerfile)
 * [`5.0.11`, `5.0` (Dockerfile)](https://github.com/weseek/growi/blob/v5.0.11/packages/app/docker/Dockerfile)
 * [`5.0.11-nocdn`, `5.0-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v5.0.11/packages/app/docker/Dockerfile)
 * [`4.5.23`, `4.5`, `4`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v4.5.23/packages/app/docker/Dockerfile)

+ 28 - 4
packages/app/public/static/locales/en_US/admin.json

@@ -218,7 +218,6 @@
       "ABLCRule": "Rule"
     }
   },
-  "markdown_settings": "Markdown Settings",
   "notification_settings": {
     "notification_settings": "Notification Settings",
     "slack_incoming_configuration": "Slack Incoming Webhooks configuration",
@@ -269,8 +268,9 @@
     "delete_notification_pattern_desc2": "Once deleted, it cannot be recovered",
     "toggle_notification": "Updated setting of {{path}}"
   },
-  "Customize": "Customize",
-  "full_text_search_management": "Full Text Search Management",
+  "customize": "Customize",
+  "import_data": "Import Data",
+  "export_archive_data": "Export Archive Data",
   "mailer_setup_required":"<a href='/admin/app'>Email settings</a> are required to send.",
   "admin_top": {
     "management_wiki": "Management Wiki",
@@ -377,7 +377,8 @@
     "use_env_var_if_empty": "If the value in the database is empty, the value of the environment variable <code>{{variable}}</code> is used.",
     "note_for_the_only_env_option": "The GCS Settings is limited by the value of environment variable.<br>To change this setting, please change to false or delete the value of the environment variable <code>{{env}}</code> ."
   },
-  "markdown_setting": {
+  "markdown_settings": {
+    "markdown_settings": "Markdown Settings",
     "lineBreak_header": "Line break setting",
     "lineBreak_desc": "You can change line break settings.",
     "lineBreak_options": {
@@ -806,6 +807,29 @@
       "force_update_parents_description": "Enable this option to force the addition of missing users to the ancestor groups if they exist after changing a parent group."
     }
   },
+  "full_text_search_management": {
+    "full_text_search_management": "Full Text Search Management",
+    "elasticsearch_management": "Elasticsearch management",
+    "connection_status": "Connection status",
+    "connection_status_label_unconfigured": "UNCONFIGURED",
+    "connection_status_label_connected": "CONNECTED",
+    "connection_status_label_disconnected": "DISCONNECTED",
+    "connection_status_label_erroroccured": "ERROR OCCURED ON SEARCH SERVICE",
+    "indices_status": "Indices Status",
+    "indices_status_label_normalized": "NORMALIZED",
+    "indices_status_label_unnormalized": "REBUILDING or BROKEN",
+    "indices_summary": "Indices summary",
+    "reconnect": "Reconnect",
+    "reconnect_button": "Try to reconnect",
+    "reconnect_description": "Click the button to try to reconnect to Elasticsearch.",
+    "normalize": "Normalize",
+    "normalize_button": "Normalize indices",
+    "normalize_description": "Click the button to repair broken indices.",
+    "rebuild": "Rebuild",
+    "rebuild_button": "Rebuild index",
+    "rebuild_description_1": "Click the button to rebuild index and add all page datas.",
+    "rebuild_description_2": "This may take a while."
+  },
   "audit_log_management": {
     "audit_log": "Audit Log",
     "audit_log_settings": "Audit Log Settings",

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

@@ -121,8 +121,6 @@
   "external_account_management": "External Account Management",
   "UserGroup": "UserGroup",
   "ChildUserGroup": "ChildUserGroup",
-  "Import Data": "Import Data",
-  "Export Archive Data": "Export Archive Data",
   "Basic Settings": "Basic Settings",
   "Basic authentication": "Basic authentication",
   "Register limitation": "Register limitation",
@@ -154,7 +152,6 @@
   "add_bookmark": "Add to Bookmarks",
   "remove_bookmark": "Remove from Bookmarks",
   "wide_view": "Wide View",
-  "Recent Created": "Recent Created",
   "Recent Changes": "Recent Changes",
   "Page Tree": "Page Tree",
   "original_path":"Original path",
@@ -673,28 +670,6 @@
       "error_duplicate_pages_found": "Multiple pages with the same path name were found. Please rename or delete and try again."
     }
   },
-  "full_text_search_management": {
-    "elasticsearch_management": "Elasticsearch management",
-    "connection_status": "Connection status",
-    "connection_status_label_unconfigured": "UNCONFIGURED",
-    "connection_status_label_connected": "CONNECTED",
-    "connection_status_label_disconnected": "DISCONNECTED",
-    "connection_status_label_erroroccured": "ERROR OCCURED ON SEARCH SERVICE",
-    "indices_status": "Indices Status",
-    "indices_status_label_normalized": "NORMALIZED",
-    "indices_status_label_unnormalized": "REBUILDING or BROKEN",
-    "indices_summary": "Indices summary",
-    "reconnect": "Reconnect",
-    "reconnect_button": "Try to reconnect",
-    "reconnect_description": "Click the button to try to reconnect to Elasticsearch.",
-    "normalize": "Normalize",
-    "normalize_button": "Normalize indices",
-    "normalize_description": "Click the button to repair broken indices.",
-    "rebuild": "Rebuild",
-    "rebuild_button": "Rebuild index",
-    "rebuild_description_1": "Click the button to rebuild index and add all page datas.",
-    "rebuild_description_2": "This may take a while."
-  },
   "to_cloud_settings": "Open GROWI.cloud Settings",
   "login": {
     "Sign in error": "Login error",
@@ -864,5 +839,9 @@
   "page_operation":{
     "paths_recovered": "Paths recovered successfully",
     "path_recovery_failed":"Path recovery failed"
+  },
+  "footer": {
+    "bookmarks": "Bookmarks",
+    "recently_created": "Recently Created"
   }
 }

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

@@ -1,4 +1,299 @@
 {
+  "wiki_management_home_page": "Wiki管理トップ",
+  "app_settings": "アプリ設定",
+  "public": "公開",
+  "anyone_with_the_link": "リンクを知っている人のみ",
+  "specified_users": "特定ユーザーのみ",
+  "only_me": "自分のみ",
+  "only_inside_the_group": "特定グループのみ",
+  "security_settings": {
+    "security_settings": "セキュリティ設定",
+    "scope_of_page_disclosure": "ページの公開範囲",
+    "set_point": "設定値",
+    "Guest Users Access":"ゲストユーザーのアクセス",
+    "always_hidden": "非表示 (固定)",
+    "always_displayed": "表示 (固定)",
+    "displayed_or_hidden": "表示 / 非表示",
+    "Fixed by env var": "環境変数 <code>{{forcewikimode}}={{wikimode}}</code> により固定されています。",
+    "Register limitation": "登録の制限",
+    "Register limitation desc": "新しいユーザーを登録する方法を制限します.",
+    "The whitelist of registration permission E-mail address": "登録許可メールアドレスの<br>ホワイトリスト",
+    "users_without_account": "アカウントを持たないユーザーはアクセス不可",
+    "example": "例",
+    "restrict_emails": "登録可能なメールアドレスを制限することができます。",
+    "for_example": "例えば、",
+    "in_this_case": "と記載することで、そのドメインのメールアドレスを持っている人のみ登録可能になります。",
+    "insert_single": "1行に1メールアドレス入力してください。",
+    "page_list_and_search_results": "ページリスト・検索結果",
+    "page_listing_1": "ページのリスト表示と検索<br>'自分のみ'に閲覧制限しているページ",
+    "page_listing_1_desc": "ページのリスト表示や検索結果において、'自分のみ'に閲覧制限をしているページをアクセス権のないユーザーにも表示します。",
+    "page_listing_2": "ページのリスト表示と検索<br>特定グループに閲覧制限しているページ",
+    "page_listing_2_desc": "ページのリスト表示や検索結果において、特定グループにのみ閲覧制限をしているページをアクセス権のないユーザーにも表示します。",
+    "page_access_rights": "ページの閲覧権限",
+    "page_delete_rights": "ページの削除権限",
+    "page_delete": "ゴミ箱に入れる",
+    "page_delete_completely": "完全に削除する",
+    "other_options": "その他のオプション",
+    "deletion_explain": "ページをゴミ箱に入れることができるユーザーを制限します。",
+    "complete_deletion_explain": "ページを完全削除することができるユーザーを制限します。",
+    "recursive_deletion_explain": "子孫を含めたページをゴミ箱に入れることができるユーザーを制限します。",
+    "recursive_complete_deletion_explain": "子孫を含めたページを完全削除することができるユーザーを制限します。",
+    "inherit": "単体のみと同じ",
+    "admin_only": "管理者のみ可能",
+    "admin_and_author": "管理者とページ作者が可能",
+    "anyone": "誰でも可能",
+    "session": "セッション",
+    "max_age": "有効期間 (ミリ秒)",
+    "max_age_desc": "ユーザーのセッション情報の有効期間をミリ秒で指定できます。<br>デフォルト値: 2592000000 (30日間)",
+    "max_age_caution": "この値を変更した後は、サーバーを再起動する必要があります。",
+    "forced_update_desc": "設定が強制変更されました。前回の設定: ",
+    "page_delete_rights_caution": "「(子孫ページを含む)ゴミ箱に入れる操作 / 完全に削除する」の権限は、「ゴミ箱に入れる操作 / 完全に削除する」よりも強い権限になるように強制されます。 <br><br> 管理者のみ可能 > 管理者とページ作者が可能 > 誰でも可能",
+    "Authentication mechanism settings": "認証機構設定",
+    "setup_is_not_yet_complete":"セットアップはまだ完了してません",
+    "alert_siteUrl_is_not_set": "'サイトURL' が設定されていません。{{link}} から設定してください。",
+    "xss_prevent_setting": "XSS(Cross Site Scripting)対策設定",
+    "xss_prevent_setting_link": "マークダウン設定ページに移動",
+    "callback_URL": "コールバックURL",
+    "desc_of_callback_URL": "{{AuthName}} プロバイダ側の設定で利用してください。",
+    "authorization_endpoint": "認可エンドポイント",
+    "token_endpoint": "トークンエンドポイント",
+    "revocation_endpoint": "失効エンドポイント",
+    "introspection_endpoint": "検証エンドポイント",
+    "userinfo_endpoint": "ユーザ情報エンドポイント",
+    "end_session_endpoint": "セッション終了エンドポイント",
+    "registration_endpoint": "登録エンドポイント",
+    "jwks_uri": "JSON Web Key Set URL",
+    "clientID": "クライアントID",
+    "client_secret": "クライアントシークレット",
+    "updated_general_security_setting": "セキュリティ設定を更新しました。",
+    "setup_not_completed_yet": "まだセットアップは完了していません。",
+    "guest_mode": {
+      "deny": "拒否 (アカウントを持つユーザーのみ利用可能)",
+      "readonly": "許可 (ゲストユーザーも閲覧のみ可能)"
+    },
+    "registration_mode": {
+      "open": "公開 (だれでも登録可能)",
+      "restricted": "制限 (登録完了には管理者の承認が必要)",
+      "closed": "非公開 (登録には管理者による招待が必要)"
+    },
+    "share_link_rights": "シェアリンクの権限",
+    "enable_link_sharing": "リンクのシェアを許可",
+    "all_share_links": "全てのシェアリンク",
+    "configuration": "設定",
+    "optional": "オプション",
+    "Treat username matching as identical": "新規ログイン時、<code>username</code> が一致したローカルアカウントが存在した場合は自動的に紐付ける",
+    "Treat username matching as identical_warn": "警告: <code>username</code> の一致を以て同一ユーザーであるとみなすので、セキュリティに注意してください",
+    "Treat email matching as identical": "新規ログイン時、<code>email</code> が一致したローカルアカウントが存在した場合は自動的に紐付ける",
+    "Treat email matching as identical_warn": "警告: <code>email</code> の一致を以て同一ユーザーであるとみなすので、セキュリティに注意してください",
+    "Use env var if empty": "空の場合、環境変数 <code>{{env}}</code> を利用します",
+    "Use default if both are empty": "どちらの値も空の場合、デフォルト値 <code>{{target}}</code> を利用します",
+    "missing mandatory configs": "以下の必須項目の値がデータベースと環境変数のどちらにも設定されていません",
+    "Local": {
+      "name": "ID/Password",
+      "note for the only env option": "現在LOCAL認証のON/OFFは環境変数の値によって制限されています<br>この設定を変更する場合は環境変数 <code>{{env}}</code> の値をfalseに変更もしくは削除してください",
+      "enable_local": "ID/Password を有効にする",
+      "password_reset_by_users": "ユーザーによるパスワード再設定",
+      "enable_password_reset_by_users": "ユーザーによるパスワード再設定を有効にする",
+      "password_reset_desc": "ログイン時のパスワードを忘れた際に、ユーザー自身がパスワードを再設定できます。",
+      "email_authentication": "ユーザー登録時のメール認証",
+      "enable_email_authentication": "メール認証を有効にする",
+      "enable_email_authentication_desc": "ユーザー登録時にメール認証を行います。",
+      "please_enable_mailer": "メール認証を有効にするには、メール設定を完了させてください。",
+      "need_complete_mail_setting_warning": "以下の機能を使えるようにするには、メール設定を完了させてください。"
+    },
+    "ldap": {
+      "enable_ldap": "LDAP を有効にする",
+      "server_url_detail": "LDAP URLを <code>ldap://host:port/DN</code> または <code>ldaps://host:port/DN</code> の形式で入力してください。",
+      "bind_mode": "Bind モード",
+      "bind_manager": "管理者 Bind",
+      "bind_user": "ユーザー Bind",
+      "bind_DN_manager_detail": "ディレクトリーサービスに認証する際のアカウント DN",
+      "bind_DN_user_detail1": "ディレクトリーサービスに Bind するアカウント DN を決定するためのクエリ",
+      "bind_DN_user_detail2": "ログイン時に入力されるユーザー名を使用するには <code>&#123;&#123;username&#125;&#125;</code> の形式を使用してください。",
+      "bind_DN_password": "Bind DN パスワード",
+      "bind_DN_password_manager_detail": "Bind DN アカウントのパスワード",
+      "bind_DN_password_user_detail": "ログイン時のパスワードが使用されます。",
+      "search_filter": "検索フィルター",
+      "search_filter_detail1": "認証されるユーザーを一意に決定するための LDAP フィルタ",
+      "search_filter_detail2": "ログイン時のユーザー名を使用するには <code>&#123;&#123;username&#125;&#125;</code> の形式を使用してください。",
+      "search_filter_detail3": "空欄の場合 <code>(uid=&#123;&#123;username&#125;&#125;)</code> が使用されます。",
+      "search_filter_example1": "'uid' または 'mail' に一致",
+      "search_filter_example2": "'sAMAccountName' に一致 (Active Directory)",
+      "username_detail": "新規ユーザーのアカウント名(<code>username</code>)に関連付ける属性",
+      "name_detail": "新規ユーザーの表示名に関連付ける属性",
+      "mail_detail": "新規ユーザーのメールアドレスに関連付ける属性",
+      "group_search_base_DN": "グループ検索ベース DN",
+      "group_search_base_DN_detail": "グループ検索を実行するベース DN。利用する場合は <code>グループ検索フィルター</code> も入力する必要があります。",
+      "group_search_filter": "グループ検索フィルター",
+      "group_search_filter_detail1": "グループフィルターに用いるクエリ",
+      "group_search_filter_detail2": "このクエリにヒットするグループがあったときのみ、LDAPでのログインが成功します。",
+      "group_search_filter_detail3": "ログイン対象ユーザーオブジェクトのプロパティーで置換する場合は <code>&#123;&#123;dn&#125;&#125;</code> を用いてください。",
+      "group_search_filter_detail4": "<code>(&(cn=group1)(memberUid=&#123;&#123;dn&#125;&#125;))</code> は <code>cn=group1</code> と、ユーザーの <code>uid</code> を含む <code>memberUid</code> を持つグループにヒットします(<code>ユーザーの DN プロパティー</code> がデフォルトから変更されていない場合)",
+      "group_search_user_DN_property": "ユーザーの DN プロパティー",
+      "group_search_user_DN_property_detail": "<code>グループ検索フィルター</code> 内の <code>&#123;&#123;dn&#125;&#125;</code> で置換される、ユーザーオブジェクトのプロパティー",
+      "test_config": "ログインテスト",
+      "updated_ldap": "LDAP設定 を更新しました"
+    },
+    "SAML": {
+      "name": "SAML",
+      "enable_saml": "SAML を有効にする",
+      "id_detail": "SAML Identity プロバイダ内で一意に識別可能な値を格納している属性",
+      "username_detail": "新規ユーザーのアカウント名(<code>username</code>)に関連付ける属性",
+      "mapping_detail": "新規ユーザーの{{target}}に関連付ける属性",
+      "cert_detail": "IdP からのレスポンスの validation を行うためのPEMエンコードされた X.509 証明書",
+      "Use env var if empty": "データベース側の値が空の場合、環境変数 <code>{{env}}</code> の値を利用します",
+      "note for the only env option": "現在SAML認証のON/OFFの設定値及びハイライトされている設定値は環境変数の値のみを使用するようになっています<br>この設定を変更する場合は環境変数 <code>{{env}}</code> の値をfalseに変更もしくは削除してください",
+      "attr_based_login_control_detail": "SAMLの <code>&lt;saml:AttributeStatement&gt;</code> 要素に含まれる <code>&lt;saml:Attribute&gt;</code> 要素と、その子要素 <code>&lt;saml:AttributeValue&gt;</code> を利用してログインの可否を制御します。",
+      "attr_based_login_control_rule_help": "<h5>利用可能なクエリ:</h5><ul><li>Terms</li><li>Fields</li><li>AND/NOT/OR Operator</li><li>Grouping</li></ul><h5>利用不可なクエリ:</h5><ul><li>Wildcard, Fuzzy, Proximity, Range and Boosting</li><li>+/- Operator</li><li>Field Grouping</li></ul><h5>特殊文字のエスケープ</h5>次の特殊文字はエスケープする必要があります。<code>+ - && || ! ( ) { } [ ] ^ &quot; &tilde; * ? : &#92;</code> and <code>/</code>",
+      "attr_based_login_control_rule_example1": "<h5>条件式の例</h5>ルールに <code>(Department: A || Department: B) && Position: Leader</code> を指定した場合, <code>Department: A</code> または <code>Department: B</code> のどちらかに該当し、かつ <code>Position: Leader</code> を持つユーザーにログインを<strong>許可</strong>します。",
+      "attr_based_login_control_rule_exampl2": "<h5>エスケープの例</h5>ルールに URL を利用したい場合は、次のようにエスケープしてください:<br><code>http&#92;:&#92;/&#92;/schemas.example.com&#92;/ws&#92;/2005&#92;/05&#92;/identity&#92;/claims&#92;/emailaddress: &quot;myname@example.com&quot;</code>",
+      "updated_saml": "Succeeded to update SAML setting"
+    },
+    "Basic": {
+      "enable_basic": "Basic を有効にする",
+      "name": "Basic 認証",
+      "desc_1": "Authorization ヘッダに格納されている <code>username</code> でログインします。",
+      "desc_2": "ユーザーが存在しなかった場合は自動生成します。",
+      "updated_basic": "Basic認証 を更新しました"
+    },
+    "OAuth": {
+      "enable_oidc": "OIDC を有効にする",
+      "register": "%sに登録",
+      "change_redirect_url": "承認済みのリダイレクトURLに、 <code>%s</code> を入力",
+      "Google": {
+        "enable_google": "Google OAuth を有効にする",
+        "name": "Google OAuth",
+        "register_1": "{{link}}へアクセス",
+        "register_2": "プロジェクトがない場合はプロジェクトを作成",
+        "register_3": "認証情報を作成 &rightarrow; OAuthクライアントID &rightarrow; ウェブアプリケーションを選択",
+        "register_4": "承認済みのリダイレクトURIを<code>{{url}}</code>としてGrowiを登録",
+        "register_5": "上記フォームにクライアントIDとクライアントシークレットを入力",
+        "updated_google": "Google OAuth を更新しました"
+      },
+      "Facebook": {
+        "name": "Facebook OAuth"
+      },
+      "Twitter": {
+        "enable_twitter": "Twitter OAuth を有効にする",
+        "name": "Twitter OAuth",
+        "register_1": "{{link}} へアクセス",
+        "register_2": "Twitterにサインイン",
+        "register_3": "Create New Appをクリック &rightarrow; Application Detailsの各項目を入力",
+        "register_4": "Create your Twitter Applicationで作成",
+        "register_5": "上記フォームにクライアントIDとクライアントシークレットを入力",
+        "updated_twitter": "Twitter OAuth を更新しました"
+      },
+      "GitHub": {
+        "enable_github": "GitHub OAuth を有効にする",
+        "name": "GitHub OAuth",
+        "register_1": "{{link}} へアクセス",
+        "register_2": "\"Authorization callback URL\"を<code>{{url}}</code>としてGrowiを登録",
+        "register_3": "上記フォームにクライアントIDとクライアントシークレットを入力",
+        "updated_github": "GitHub OAuth を更新しました"
+      },
+      "OIDC": {
+        "name": "OpenID Connect",
+        "id_detail": "OIDC claims で一意に識別可能な値を格納している属性",
+        "username_detail": "新規ユーザーのアカウント名(<code>username</code>)に関連付ける属性",
+        "name_detail": "新規ユーザー名(<code>name</code>)に関連付ける属性",
+        "mapping_detail": "新規ユーザーの{{target}}に関連付ける属性",
+        "updated_oidc": "OpenID Connect を更新しました",
+        "Use discovered URL if empty": "データベース側の値が空の場合、\"Issuer Host\"から検出した値を利用します。"
+      },
+      "how_to": {
+        "google": "Google OAuth の設定方法",
+        "github": "GitHub OAuth の設定方法",
+        "twitter": "Twitter OAuth の設定方法"
+      }
+    },
+    "form_item_name": {
+      "entryPoint": "エントリーポイント",
+      "issuer": "発行者",
+      "cert": "証明書",
+      "attrMapId": "ID",
+      "attrMapUsername": "ユーザー名",
+      "attrMapMail": "メールアドレス",
+      "attrMapFirstName": "姓",
+      "attrMapLastName": "名",
+      "ABLCRule": "ルール"
+    }
+  },
+  "notification_settings": {
+    "notification_settings": "通知設定",
+    "slack_incoming_configuration": "Slack Incoming Webhooks 設定",
+    "prioritize_webhook": "Slack アプリより Incoming Webhook を優先する",
+    "prioritize_webhook_desc": "このオプションをオンにすると、 Slack App が有効になっていても GROWI は Incoming Webhook を使用します。",
+    "slack_app_configuration": "Slack App 設定",
+    "slack_app_configuration_desc": "Crowi 互換の機能です。<br /> <strong>設定が複雑すぎる</strong>のでオススメしません。",
+    "use_instead": "代わりに Slack Incoming Webhooks 設定を使用してください。",
+    "how_to": {
+      "header": "Incoming Webhooks の設定方法",
+      "workspace": "ワークスペースで Webhook を追加します。",
+      "workspace_desc1": "<a href='https://slack.com/services/new/incoming-webhook'>Incoming Webhooks Configuration page</a> にアクセスします。",
+      "workspace_desc2": "投稿するチャンネルを選びます。",
+      "workspace_desc3": "追加します。",
+      "at_growi": "GROWI 管理画面で Webhook URL を設定します。",
+      "at_growi_desc": "このページで &rdquo;Webhook URL&rdquo; を入力して送信します。"
+    },
+    "user_trigger_notification_header": "デフォルトパターンの通知設定",
+    "pattern": "パターン",
+    "channel": "チャンネル名",
+    "pattern_desc": "Wiki のパス名。 パスには <code>*</code> を使用できます。",
+    "channel_desc": "<code>#</code> を除いた Slack チャンネル名",
+    "valid_page": "通知の有効 / 無効",
+    "link_notification_help": "<strong>linkを知っている人のみ閲覧できるページ</strong>は常に通知されません。",
+    "just_me_notification_help": "<strong>'自分のみ'に閲覧制限をしているページ</strong>に変更を加えた際に通知する",
+    "group_notification_help": "<strong>'特定グループにのみ'に閲覧制限をしているページ</strong>に変更を加えた際に通知する",
+    "notification_list": "通知設定の一覧",
+    "add_notification": "通知設定の追加",
+    "trigger_path": "トリガーパス",
+    "trigger_path_help": "(<code>*</code>が使用できます)",
+    "trigger_events": "トリガーイベント",
+    "notify_to": "通知先",
+    "back_to_list": "通知設定一覧に戻る",
+    "notification_detail": "通知詳細設定",
+    "event_pageCreate": "ページが新規作成されたとき",
+    "event_pageEdit": "ページが編集されたとき",
+    "event_pageDelete": "ページが削除されたとき",
+    "event_pageMove": "ページが移動(名前が変更)されたとき",
+    "event_pageLike": "ページに「いいね」がついたとき",
+    "event_comment": "コメントが投稿されたとき",
+    "email": {
+      "ifttt_link": "IFTTT でメールトリガの新しいアプレットを作る"
+    },
+    "updated_slackApp": "SlackApp設定を更新しました",
+    "add_notification_pattern": "通知パターンを追加しました。",
+    "delete_notification_pattern": "通知パターンを削除しました。",
+    "delete_notification_pattern_desc1": "Path: {{path}} を削除します。",
+    "delete_notification_pattern_desc2": "Once deleted, it cannot be recovered",
+    "toggle_notification": "{{path}}の通知設定を変更しました"
+  },
+  "customize": "カスタマイズ",
+  "import_data": "データインポート",
+  "export_archive_data": "データアーカイブ",
+  "full_text_search_management": {
+    "full_text_search_management": "全文検索管理",
+    "elasticsearch_management": "Elasticsearch 管理",
+    "connection_status": "接続の状態",
+    "connection_status_label_unconfigured": "設定されていません",
+    "connection_status_label_connected": "接続されています",
+    "connection_status_label_disconnected": "切断されています",
+    "connection_status_label_erroroccured": "SearchService でエラーが発生しています",
+    "indices_status": "インデックスの状態",
+    "indices_status_label_normalized": "正規化されています",
+    "indices_status_label_unnormalized": "リビルド中 または 破損しています",
+    "indices_summary": "インデックスのサマリ",
+    "reconnect": "再接続",
+    "reconnect_button": "再接続の試行",
+    "reconnect_description": "Elasticsearch への再接続を試みます。",
+    "normalize": "正規化",
+    "normalize_button": "インデックスの正規化",
+    "normalize_description": "破損したインデックスを修復します。",
+    "rebuild": "リビルド",
+    "rebuild_button": "インデックスのリビルド",
+    "rebuild_description_1": "全てのページのインデックスを削除し、作り直します。",
+    "rebuild_description_2": "この作業には数秒かかります。"
+  },
   "mailer_setup_required": "送信するには <a href='/admin/app'>メールの設定</a> が必要です。",
   "admin_top": {
     "management_wiki": "Wiki管理",
@@ -106,6 +401,7 @@
     "note_for_the_only_env_option": "現在GCS設定は環境変数の値によって制限されています<br>この設定を変更する場合は環境変数 <code>{{env}}</code> の値をfalseに変更もしくは削除してください"
   },
   "markdown_setting": {
+    "markdown_settings": "マークダウン設定",
     "lineBreak_header": "Line Break設定",
     "lineBreak_desc": "Line Breakの設定を変更できます。",
     "lineBreak_options": {
@@ -305,12 +601,14 @@
     "Directory_hierarchy_tag": "ディレクトリ階層タグ"
   },
   "external_notification": {
+    "external_notification": "外部ツールへの通知",
     "enabled": "有効",
     "disabled": "無効",
     "header_status": "Slack 連携の状態",
     "caution_enabled": "CAUTION: このページで設定される通知は、Primary として設定された Slack ワークスペースにのみ送信されます。 "
   },
   "slack_integration": {
+    "slack_integration": "Slack連携",
     "selecting_bot_types": {
       "slack_bot": "Slack bot",
       "detailed_explanation": "詳しい説明はこちら",
@@ -421,10 +719,12 @@
     }
   },
   "slack_integration_legacy": {
+    "slack_integration_legacy":  "Slack連携 (レガシー)",
     "alert_disabled": "<a href='/admin/slack-integration'>新しい設定</a>が有効になっているため、この 'Slack連携 (レガシー)' は現在無効になっています。",
     "alert_deplicated": "この 'Slack連携 (レガシー)' は将来廃止されます。代わりに<a href='/admin/slack-integration'>新しいSlack連携機能</a>を利用してください。"
   },
   "user_management": {
+    "user_management": "ユーザー管理",
     "invite_users": "新規ユーザーの仮発行",
     "click_twice_same_checkbox": "少なくとも一つはチェックしてください。",
     "invite_modal": {
@@ -482,6 +782,7 @@
     "current_users": "現在のユーザー数:"
   },
   "user_group_management": {
+    "user_group_management": "グループ管理",
     "create_group": "新規グループの作成",
     "add_child_group": "子グループの追加",
     "remove_child_group": "解除",
@@ -528,6 +829,8 @@
     }
   },
   "audit_log_management": {
+    "audit_log": "監査ログ",
+    "audit_log_settings": "監査ログ設定",
     "user": "ユーザー",
     "username": "ユーザー名",
     "date": "日付",

+ 4 - 299
packages/app/public/static/locales/ja_JP/translation.json

@@ -114,28 +114,13 @@
   "Input page name (optional)": "ページ名を入力(空欄OK)",
   "New Page": "新規ページ",
   "Create under": "ページを以下に作成",
-  "Wiki Management Home Page": "Wiki管理トップ",
-  "App Settings": "アプリ設定",
   "V5 Page Migration": "V5 互換形式 への変換",
   "GROWI.5.0_new_schema": "GROWI.5.0における新スキーマについて",
   "See_more_detail_on_new_schema": "詳しくは<a href='#'>{{url}}</a><i class='icon-share-alt'></i>を参照ください。",
   "Site URL settings": "サイトURL設定",
-  "Markdown Settings": "マークダウン設定",
-  "Customize": "カスタマイズ",
-  "Notification Settings": "通知設定",
-  "slack_integration": "Slack連携",
-  "External_Notification": "外部ツールへの通知",
-  "Legacy_Slack_Integration": "Slack連携 (レガシー)",
-  "User_Management": "ユーザー管理",
   "external_account_management": "外部アカウント管理",
   "UserGroup": "グループ",
   "ChildUserGroup": "子グループ",
-  "UserGroup Management": "グループ管理",
-  "AuditLog": "監査ログ",
-  "AuditLog Settings": "監査ログ設定",
-  "Full Text Search Management": "全文検索管理",
-  "Import Data": "データインポート",
-  "Export Archive Data": "データアーカイブ",
   "Basic Settings": "基本設定",
   "Register limitation": "登録の制限",
   "The contents entered here will be shown in the header etc": "ここに入力した内容は、ヘッダー等に表示されます。",
@@ -145,11 +130,6 @@
   "Only me": "自分のみ",
   "Only inside the group": "特定グループのみ",
   "page_list": "ページリスト",
-  "scope_of_page_disclosure": "ページの公開範囲",
-  "set_point": "設定値",
-  "always_displayed": "表示 (固定)",
-  "always_hidden": "非表示 (固定)",
-  "displayed_or_hidden": "表示 / 非表示",
   "Reselect the group": "グループの再選択",
   "Shareable link": "このページの共有用URL",
   "The whitelist of registration permission E-mail address": "登録許可メールアドレスの<br>ホワイトリスト",
@@ -172,7 +152,6 @@
   "add_bookmark": "ブックマークに追加",
   "remove_bookmark": "ブックマークから削除",
   "wide_view": "ワイドビュー",
-  "Recent Created": "最新の作成",
   "Recent Changes": "最新の変更",
   "Page Tree": "ページツリー",
   "original_path":"元のパス",
@@ -258,7 +237,6 @@
     "new_password_confirm": "(確認用)",
     "password_is_not_set": "パスワードが設定されていません"
   },
-  "security_settings": "セキュリティ設定",
   "share_links": {
     "Shere this page link to public": "外部に共有するリンクを発行する",
     "share_link_list": "共有リンクリスト",
@@ -690,283 +668,6 @@
       "error_duplicate_pages_found": "同名のパスを持つページが複数見つかりました。リネームまたは削除してから再度実行してください"
     }
   },
-  "security_setting": {
-    "Guest Users Access": "ゲストユーザーのアクセス",
-    "Fixed by env var": "環境変数 <code>{{forcewikimode}}={{wikimode}}</code> により固定されています。",
-    "Register limitation": "登録の制限",
-    "Register limitation desc": "新しいユーザーを登録する方法を制限します.",
-    "The whitelist of registration permission E-mail address": "登録許可メールアドレスの<br>ホワイトリスト",
-    "users_without_account": "アカウントを持たないユーザーはアクセス不可",
-    "example": "例",
-    "restrict_emails": "登録可能なメールアドレスを制限することができます。",
-    "for_example": "例えば、",
-    "in_this_case": "と記載することで、そのドメインのメールアドレスを持っている人のみ登録可能になります。",
-    "insert_single": "1行に1メールアドレス入力してください。",
-    "page_list_and_search_results": "ページリスト・検索結果",
-    "page_listing_1": "ページのリスト表示と検索<br>'自分のみ'に閲覧制限しているページ",
-    "page_listing_1_desc": "ページのリスト表示や検索結果において、'自分のみ'に閲覧制限をしているページをアクセス権のないユーザーにも表示します。",
-    "page_listing_2": "ページのリスト表示と検索<br>特定グループに閲覧制限しているページ",
-    "page_listing_2_desc": "ページのリスト表示や検索結果において、特定グループにのみ閲覧制限をしているページをアクセス権のないユーザーにも表示します。",
-    "page_access_rights": "ページの閲覧権限",
-    "page_delete_rights": "ページの削除権限",
-    "page_delete": "ゴミ箱に入れる",
-    "page_delete_completely": "完全に削除する",
-    "other_options": "その他のオプション",
-    "deletion_explain": "ページをゴミ箱に入れることができるユーザーを制限します。",
-    "complete_deletion_explain": "ページを完全削除することができるユーザーを制限します。",
-    "recursive_deletion_explain": "子孫を含めたページをゴミ箱に入れることができるユーザーを制限します。",
-    "recursive_complete_deletion_explain": "子孫を含めたページを完全削除することができるユーザーを制限します。",
-    "inherit": "単体のみと同じ",
-    "admin_only": "管理者のみ可能",
-    "admin_and_author": "管理者とページ作者が可能",
-    "anyone": "誰でも可能",
-    "session": "セッション",
-    "max_age": "有効期間 (ミリ秒)",
-    "max_age_desc": "ユーザーのセッション情報の有効期間をミリ秒で指定できます。<br>デフォルト値: 2592000000 (30日間)",
-    "max_age_caution": "この値を変更した後は、サーバーを再起動する必要があります。",
-    "forced_update_desc": "設定が強制変更されました。前回の設定: ",
-    "page_delete_rights_caution": "「(子孫ページを含む)ゴミ箱に入れる操作 / 完全に削除する」の権限は、「ゴミ箱に入れる操作 / 完全に削除する」よりも強い権限になるように強制されます。 <br><br> 管理者のみ可能 > 管理者とページ作者が可能 > 誰でも可能",
-    "Authentication mechanism settings": "認証機構設定",
-    "setup_is_not_yet_complete":"セットアップはまだ完了してません",
-    "alert_siteUrl_is_not_set": "'サイトURL' が設定されていません。{{link}} から設定してください。",
-    "xss_prevent_setting": "XSS(Cross Site Scripting)対策設定",
-    "xss_prevent_setting_link": "マークダウン設定ページに移動",
-    "callback_URL": "コールバックURL",
-    "desc_of_callback_URL": "{{AuthName}} プロバイダ側の設定で利用してください。",
-    "authorization_endpoint": "認可エンドポイント",
-    "token_endpoint": "トークンエンドポイント",
-    "revocation_endpoint": "失効エンドポイント",
-    "introspection_endpoint": "検証エンドポイント",
-    "userinfo_endpoint": "ユーザ情報エンドポイント",
-    "end_session_endpoint": "セッション終了エンドポイント",
-    "registration_endpoint": "登録エンドポイント",
-    "jwks_uri": "JSON Web Key Set URL",
-    "clientID": "クライアントID",
-    "client_secret": "クライアントシークレット",
-    "updated_general_security_setting": "セキュリティ設定を更新しました。",
-    "setup_not_completed_yet": "まだセットアップは完了していません。",
-    "guest_mode": {
-      "deny": "拒否 (アカウントを持つユーザーのみ利用可能)",
-      "readonly": "許可 (ゲストユーザーも閲覧のみ可能)"
-    },
-    "registration_mode": {
-      "open": "公開 (だれでも登録可能)",
-      "restricted": "制限 (登録完了には管理者の承認が必要)",
-      "closed": "非公開 (登録には管理者による招待が必要)"
-    },
-    "share_link_rights": "シェアリンクの権限",
-    "enable_link_sharing": "リンクのシェアを許可",
-    "all_share_links": "全てのシェアリンク",
-    "configuration": "設定",
-    "optional": "オプション",
-    "Treat username matching as identical": "新規ログイン時、<code>username</code> が一致したローカルアカウントが存在した場合は自動的に紐付ける",
-    "Treat username matching as identical_warn": "警告: <code>username</code> の一致を以て同一ユーザーであるとみなすので、セキュリティに注意してください",
-    "Treat email matching as identical": "新規ログイン時、<code>email</code> が一致したローカルアカウントが存在した場合は自動的に紐付ける",
-    "Treat email matching as identical_warn": "警告: <code>email</code> の一致を以て同一ユーザーであるとみなすので、セキュリティに注意してください",
-    "Use env var if empty": "空の場合、環境変数 <code>{{env}}</code> を利用します",
-    "Use default if both are empty": "どちらの値も空の場合、デフォルト値 <code>{{target}}</code> を利用します",
-    "missing mandatory configs": "以下の必須項目の値がデータベースと環境変数のどちらにも設定されていません",
-    "Local": {
-      "name": "ID/Password",
-      "note for the only env option": "現在LOCAL認証のON/OFFは環境変数の値によって制限されています<br>この設定を変更する場合は環境変数 <code>{{env}}</code> の値をfalseに変更もしくは削除してください",
-      "enable_local": "ID/Password を有効にする",
-      "password_reset_by_users": "ユーザーによるパスワード再設定",
-      "enable_password_reset_by_users": "ユーザーによるパスワード再設定を有効にする",
-      "password_reset_desc": "ログイン時のパスワードを忘れた際に、ユーザー自身がパスワードを再設定できます。",
-      "email_authentication": "ユーザー登録時のメール認証",
-      "enable_email_authentication": "メール認証を有効にする",
-      "enable_email_authentication_desc": "ユーザー登録時にメール認証を行います。",
-      "please_enable_mailer": "メール認証を有効にするには、メール設定を完了させてください。",
-      "need_complete_mail_setting_warning": "以下の機能を使えるようにするには、メール設定を完了させてください。"
-    },
-    "ldap": {
-      "enable_ldap": "LDAP を有効にする",
-      "server_url_detail": "LDAP URLを <code>ldap://host:port/DN</code> または <code>ldaps://host:port/DN</code> の形式で入力してください。",
-      "bind_mode": "Bind モード",
-      "bind_manager": "管理者 Bind",
-      "bind_user": "ユーザー Bind",
-      "bind_DN_manager_detail": "ディレクトリーサービスに認証する際のアカウント DN",
-      "bind_DN_user_detail1": "ディレクトリーサービスに Bind するアカウント DN を決定するためのクエリ",
-      "bind_DN_user_detail2": "ログイン時に入力されるユーザー名を使用するには <code>&#123;&#123;username&#125;&#125;</code> の形式を使用してください。",
-      "bind_DN_password": "Bind DN パスワード",
-      "bind_DN_password_manager_detail": "Bind DN アカウントのパスワード",
-      "bind_DN_password_user_detail": "ログイン時のパスワードが使用されます。",
-      "search_filter": "検索フィルター",
-      "search_filter_detail1": "認証されるユーザーを一意に決定するための LDAP フィルタ",
-      "search_filter_detail2": "ログイン時のユーザー名を使用するには <code>&#123;&#123;username&#125;&#125;</code> の形式を使用してください。",
-      "search_filter_detail3": "空欄の場合 <code>(uid=&#123;&#123;username&#125;&#125;)</code> が使用されます。",
-      "search_filter_example1": "'uid' または 'mail' に一致",
-      "search_filter_example2": "'sAMAccountName' に一致 (Active Directory)",
-      "username_detail": "新規ユーザーのアカウント名(<code>username</code>)に関連付ける属性",
-      "name_detail": "新規ユーザーの表示名に関連付ける属性",
-      "mail_detail": "新規ユーザーのメールアドレスに関連付ける属性",
-      "group_search_base_DN": "グループ検索ベース DN",
-      "group_search_base_DN_detail": "グループ検索を実行するベース DN。利用する場合は <code>グループ検索フィルター</code> も入力する必要があります。",
-      "group_search_filter": "グループ検索フィルター",
-      "group_search_filter_detail1": "グループフィルターに用いるクエリ",
-      "group_search_filter_detail2": "このクエリにヒットするグループがあったときのみ、LDAPでのログインが成功します。",
-      "group_search_filter_detail3": "ログイン対象ユーザーオブジェクトのプロパティーで置換する場合は <code>&#123;&#123;dn&#125;&#125;</code> を用いてください。",
-      "group_search_filter_detail4": "<code>(&(cn=group1)(memberUid=&#123;&#123;dn&#125;&#125;))</code> は <code>cn=group1</code> と、ユーザーの <code>uid</code> を含む <code>memberUid</code> を持つグループにヒットします(<code>ユーザーの DN プロパティー</code> がデフォルトから変更されていない場合)",
-      "group_search_user_DN_property": "ユーザーの DN プロパティー",
-      "group_search_user_DN_property_detail": "<code>グループ検索フィルター</code> 内の <code>&#123;&#123;dn&#125;&#125;</code> で置換される、ユーザーオブジェクトのプロパティー",
-      "test_config": "ログインテスト",
-      "updated_ldap": "LDAP設定 を更新しました"
-    },
-    "SAML": {
-      "name": "SAML",
-      "enable_saml": "SAML を有効にする",
-      "id_detail": "SAML Identity プロバイダ内で一意に識別可能な値を格納している属性",
-      "username_detail": "新規ユーザーのアカウント名(<code>username</code>)に関連付ける属性",
-      "mapping_detail": "新規ユーザーの{{target}}に関連付ける属性",
-      "cert_detail": "IdP からのレスポンスの validation を行うためのPEMエンコードされた X.509 証明書",
-      "Use env var if empty": "データベース側の値が空の場合、環境変数 <code>{{env}}</code> の値を利用します",
-      "note for the only env option": "現在SAML認証のON/OFFの設定値及びハイライトされている設定値は環境変数の値のみを使用するようになっています<br>この設定を変更する場合は環境変数 <code>{{env}}</code> の値をfalseに変更もしくは削除してください",
-      "attr_based_login_control_detail": "SAMLの <code>&lt;saml:AttributeStatement&gt;</code> 要素に含まれる <code>&lt;saml:Attribute&gt;</code> 要素と、その子要素 <code>&lt;saml:AttributeValue&gt;</code> を利用してログインの可否を制御します。",
-      "attr_based_login_control_rule_help": "<h5>利用可能なクエリ:</h5><ul><li>Terms</li><li>Fields</li><li>AND/NOT/OR Operator</li><li>Grouping</li></ul><h5>利用不可なクエリ:</h5><ul><li>Wildcard, Fuzzy, Proximity, Range and Boosting</li><li>+/- Operator</li><li>Field Grouping</li></ul><h5>特殊文字のエスケープ</h5>次の特殊文字はエスケープする必要があります。<code>+ - && || ! ( ) { } [ ] ^ &quot; &tilde; * ? : &#92;</code> and <code>/</code>",
-      "attr_based_login_control_rule_example1": "<h5>条件式の例</h5>ルールに <code>(Department: A || Department: B) && Position: Leader</code> を指定した場合, <code>Department: A</code> または <code>Department: B</code> のどちらかに該当し、かつ <code>Position: Leader</code> を持つユーザーにログインを<strong>許可</strong>します。",
-      "attr_based_login_control_rule_exampl2": "<h5>エスケープの例</h5>ルールに URL を利用したい場合は、次のようにエスケープしてください:<br><code>http&#92;:&#92;/&#92;/schemas.example.com&#92;/ws&#92;/2005&#92;/05&#92;/identity&#92;/claims&#92;/emailaddress: &quot;myname@example.com&quot;</code>",
-      "updated_saml": "Succeeded to update SAML setting"
-    },
-    "Basic": {
-      "enable_basic": "Basic を有効にする",
-      "name": "Basic 認証",
-      "desc_1": "Authorization ヘッダに格納されている <code>username</code> でログインします。",
-      "desc_2": "ユーザーが存在しなかった場合は自動生成します。",
-      "updated_basic": "Basic認証 を更新しました"
-    },
-    "OAuth": {
-      "enable_oidc": "OIDC を有効にする",
-      "register": "%sに登録",
-      "change_redirect_url": "承認済みのリダイレクトURLに、 <code>%s</code> を入力",
-      "Google": {
-        "enable_google": "Google OAuth を有効にする",
-        "name": "Google OAuth",
-        "register_1": "{{link}}へアクセス",
-        "register_2": "プロジェクトがない場合はプロジェクトを作成",
-        "register_3": "認証情報を作成 &rightarrow; OAuthクライアントID &rightarrow; ウェブアプリケーションを選択",
-        "register_4": "承認済みのリダイレクトURIを<code>{{url}}</code>としてGrowiを登録",
-        "register_5": "上記フォームにクライアントIDとクライアントシークレットを入力",
-        "updated_google": "Google OAuth を更新しました"
-      },
-      "Facebook": {
-        "name": "Facebook OAuth"
-      },
-      "Twitter": {
-        "enable_twitter": "Twitter OAuth を有効にする",
-        "name": "Twitter OAuth",
-        "register_1": "{{link}} へアクセス",
-        "register_2": "Twitterにサインイン",
-        "register_3": "Create New Appをクリック &rightarrow; Application Detailsの各項目を入力",
-        "register_4": "Create your Twitter Applicationで作成",
-        "register_5": "上記フォームにクライアントIDとクライアントシークレットを入力",
-        "updated_twitter": "Twitter OAuth を更新しました"
-      },
-      "GitHub": {
-        "enable_github": "GitHub OAuth を有効にする",
-        "name": "GitHub OAuth",
-        "register_1": "{{link}} へアクセス",
-        "register_2": "\"Authorization callback URL\"を<code>{{url}}</code>としてGrowiを登録",
-        "register_3": "上記フォームにクライアントIDとクライアントシークレットを入力",
-        "updated_github": "GitHub OAuth を更新しました"
-      },
-      "OIDC": {
-        "name": "OpenID Connect",
-        "id_detail": "OIDC claims で一意に識別可能な値を格納している属性",
-        "username_detail": "新規ユーザーのアカウント名(<code>username</code>)に関連付ける属性",
-        "name_detail": "新規ユーザー名(<code>name</code>)に関連付ける属性",
-        "mapping_detail": "新規ユーザーの{{target}}に関連付ける属性",
-        "updated_oidc": "OpenID Connect を更新しました",
-        "Use discovered URL if empty": "データベース側の値が空の場合、\"Issuer Host\"から検出した値を利用します。"
-      },
-      "how_to": {
-        "google": "Google OAuth の設定方法",
-        "github": "GitHub OAuth の設定方法",
-        "twitter": "Twitter OAuth の設定方法"
-      }
-    },
-    "form_item_name": {
-      "entryPoint": "エントリーポイント",
-      "issuer": "発行者",
-      "cert": "証明書",
-      "attrMapId": "ID",
-      "attrMapUsername": "ユーザー名",
-      "attrMapMail": "メールアドレス",
-      "attrMapFirstName": "姓",
-      "attrMapLastName": "名",
-      "ABLCRule": "ルール"
-    }
-  },
-  "notification_setting": {
-    "slack_incoming_configuration": "Slack Incoming Webhooks 設定",
-    "prioritize_webhook": "Slack アプリより Incoming Webhook を優先する",
-    "prioritize_webhook_desc": "このオプションをオンにすると、 Slack App が有効になっていても GROWI は Incoming Webhook を使用します。",
-    "slack_app_configuration": "Slack App 設定",
-    "slack_app_configuration_desc": "Crowi 互換の機能です。<br /> <strong>設定が複雑すぎる</strong>のでオススメしません。",
-    "use_instead": "代わりに Slack Incoming Webhooks 設定を使用してください。",
-    "how_to": {
-      "header": "Incoming Webhooks の設定方法",
-      "workspace": "ワークスペースで Webhook を追加します。",
-      "workspace_desc1": "<a href='https://slack.com/services/new/incoming-webhook'>Incoming Webhooks Configuration page</a> にアクセスします。",
-      "workspace_desc2": "投稿するチャンネルを選びます。",
-      "workspace_desc3": "追加します。",
-      "at_growi": "GROWI 管理画面で Webhook URL を設定します。",
-      "at_growi_desc": "このページで &rdquo;Webhook URL&rdquo; を入力して送信します。"
-    },
-    "user_trigger_notification_header": "デフォルトパターンの通知設定",
-    "pattern": "パターン",
-    "channel": "チャンネル名",
-    "pattern_desc": "Wiki のパス名。 パスには <code>*</code> を使用できます。",
-    "channel_desc": "<code>#</code> を除いた Slack チャンネル名",
-    "valid_page": "通知の有効 / 無効",
-    "link_notification_help": "<strong>linkを知っている人のみ閲覧できるページ</strong>は常に通知されません。",
-    "just_me_notification_help": "<strong>'自分のみ'に閲覧制限をしているページ</strong>に変更を加えた際に通知する",
-    "group_notification_help": "<strong>'特定グループにのみ'に閲覧制限をしているページ</strong>に変更を加えた際に通知する",
-    "notification_list": "通知設定の一覧",
-    "add_notification": "通知設定の追加",
-    "trigger_path": "トリガーパス",
-    "trigger_path_help": "(<code>*</code>が使用できます)",
-    "trigger_events": "トリガーイベント",
-    "notify_to": "通知先",
-    "back_to_list": "通知設定一覧に戻る",
-    "notification_detail": "通知詳細設定",
-    "event_pageCreate": "ページが新規作成されたとき",
-    "event_pageEdit": "ページが編集されたとき",
-    "event_pageDelete": "ページが削除されたとき",
-    "event_pageMove": "ページが移動(名前が変更)されたとき",
-    "event_pageLike": "ページに「いいね」がついたとき",
-    "event_comment": "コメントが投稿されたとき",
-    "email": {
-      "ifttt_link": "IFTTT でメールトリガの新しいアプレットを作る"
-    },
-    "updated_slackApp": "SlackApp設定を更新しました",
-    "add_notification_pattern": "通知パターンを追加しました。",
-    "delete_notification_pattern": "通知パターンを削除しました。",
-    "delete_notification_pattern_desc1": "Path: {{path}} を削除します。",
-    "delete_notification_pattern_desc2": "Once deleted, it cannot be recovered",
-    "toggle_notification": "{{path}}の通知設定を変更しました"
-  },
-  "full_text_search_management": {
-    "elasticsearch_management": "Elasticsearch 管理",
-    "connection_status": "接続の状態",
-    "connection_status_label_unconfigured": "設定されていません",
-    "connection_status_label_connected": "接続されています",
-    "connection_status_label_disconnected": "切断されています",
-    "connection_status_label_erroroccured": "SearchService でエラーが発生しています",
-    "indices_status": "インデックスの状態",
-    "indices_status_label_normalized": "正規化されています",
-    "indices_status_label_unnormalized": "リビルド中 または 破損しています",
-    "indices_summary": "インデックスのサマリ",
-    "reconnect": "再接続",
-    "reconnect_button": "再接続の試行",
-    "reconnect_description": "Elasticsearch への再接続を試みます。",
-    "normalize": "正規化",
-    "normalize_button": "インデックスの正規化",
-    "normalize_description": "破損したインデックスを修復します。",
-    "rebuild": "リビルド",
-    "rebuild_button": "インデックスのリビルド",
-    "rebuild_description_1": "全てのページのインデックスを削除し、作り直します。",
-    "rebuild_description_2": "この作業には数秒かかります。"
-  },
   "to_cloud_settings": "GROWI.cloud の管理画面へ",
   "login": {
     "Sign in error": "ログインエラー",
@@ -1136,5 +837,9 @@
   "page_operation":{
     "paths_recovered": "パスを修復しました",
     "path_recovery_failed":"パスを修復できませんでした"
+  },
+  "footer": {
+    "bookmarks": "ブックマーク",
+    "recently_created": "最近作成したページ"
   }
 }

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

@@ -1,4 +1,260 @@
 {
+  "Update": "更新",
+  "Delete": "删除",
+  "User": "用户",
+  "Name": "姓名",
+  "Created": "创建",
+  "Edit": "编辑",
+  "Description": "描述",
+  "wiki_management_home_page": "Wiki管理首页",
+  "app_settings": "系统设置",
+  "public": "公共",
+  "anyone_with_the_link": "任何人",
+  "specified_users": "仅指定用户",
+  "only_me": "只有我",
+  "only_inside_the_group": "仅组内",
+  "security_settings": {
+    "security_settings": "安全设置",
+    "scope_of_page_disclosure": "页面公开范围",
+    "set_point": "设定值",
+    "always_displayed": "始终显示",
+    "always_hidden": "总是隐藏",
+    "displayed_or_hidden": "显示/隐藏",
+    "Guest Users Access": "来宾用户访问",
+		"Fixed by env var": "这是由env var<code>%s=%s</code>修复的。",
+		"Register limitation": "注册限制",
+		"Register limitation desc": "限制新用户注册",
+		"The whitelist of registration permission E-mail address": "注册许可电子邮件地址的白名单",
+		"users_without_account": "无法访问没有帐户的用户",
+		"example": "例子",
+		"restrict_emails": "您可以通过编写电子邮件域(以@开头)将电子邮件注册限制为wiki。",
+		"for_example": " 例如,如果要将注册限制为growi.org网站域,你可以写",
+		"in_this_case": ";在这种情况下,只有growi.org网站域将能够注册,所有其他用户将被拒绝。",
+		"insert_single": "请每行插入一个电子邮件地址。",
+    "page_list_and_search_results": "页面列表/搜索结果",
+		"page_listing_1": "页面列表/搜索<br>受“仅限我”限制",
+		"page_listing_1_desc": "列出/搜索时显示受“仅限我”选项限制的页面",
+		"page_listing_2": "页面列表/搜索<br>受用户组限制",
+		"page_listing_2_desc": "显示列出/搜索时受用户组限制的页面",
+    "page_access_rights": "页面访问",
+    "page_delete_rights": "删除权限",
+    "page_delete": "删除",
+    "page_delete_completely": "彻底删除",
+    "other_options": "其他选项",
+    "deletion_explain": "限制用户对选定的单一页面进行垃圾处理。",
+    "complete_deletion_explain": "限制可以完全删除所选单页的用户。",
+    "recursive_deletion_explain": "限制用户可以捣毁包括子孙在内的页面。",
+    "recursive_complete_deletion_explain": "限制可以完全删除页面的用户,包括子孙。",
+    "inherit": "继承(使用与单页相同的设置)。",
+		"admin_only": "仅管理员",
+		"admin_and_author": "管理员|作者",
+		"anyone": "任何人",
+    "session": "会议",
+    "max_age": "有效期间  (msec)",
+    "max_age_desc": "指定使用户会话过期的数量(以毫秒为单位)。<br>默认值: 2592000000 (30天)",
+    "max_age_caution": "修改该值后需要重启服务器。",
+    "forced_update_desc": "设置已被强行更改。以前的设置: ",
+    "page_delete_rights_caution": "\"删除/全部删除\"权限(包括后代页面)被强制强于\"删除/完全删除\"权限。 <br> <br> 仅管理员 > 管理员|作者 > 何人",
+		"Authentication mechanism settings": "身份验证机制设置",
+		"setup_is_not_yet_complete": "安装尚未完成",
+		"alert_siteUrl_is_not_set": "主页URL未设置,通过 {{link}} 设置",
+		"xss_prevent_setting": "阻止XSS(跨站点脚本)",
+		"xss_prevent_setting_link": "转到Markdown设置",
+		"callback_URL": "回调URL",
+		"providerName": "提供程序名称",
+		"issuerHost": "发行者主机",
+		"scope": "Scope",
+		"desc_of_callback_URL": "在{{AuthName}}身份提供程序的设置中使用它",
+    "authorization_endpoint": "Authorization Endpoint",
+    "token_endpoint": "Token Endpoint",
+    "revocation_endpoint": "Revocation Endpoint",
+    "introspection_endpoint": "Introspection Endpoint",
+    "userinfo_endpoint": "UserInfo Endpoint",
+    "end_session_endpoint": "EndSessioin Endpoint",
+    "registration_endpoint": "Registration Endpoint",
+    "jwks_uri": "JSON Web Key Set URL",
+		"clientID": "Client ID",
+		"client_secret": "客户机密",
+		"updated_general_security_setting": "更新安全设置成功",
+		"setup_not_completed_yet": "安装尚未完成",
+		"guest_mode": {
+			"deny": "拒绝(仅限注册用户)",
+			"readonly": "接受(来宾可以只读)"
+		},
+		"registration_mode": {
+			"open": "打开(任何人都可以注册)",
+			"restricted": "受限(需要管理员批准)",
+			"closed": "已关闭(仅限邀请)"
+		},
+    "share_link_rights": "分享链接权",
+    "enable_link_sharing": "启用链接共享",
+    "all_share_links": "所有共享链接",
+		"configuration": " 配置",
+		"optional": "可选的",
+		"Treat username matching as identical": "Automatically bind external accounts newly logged in to local accounts when <code>username</code> match",
+		"Treat username matching as identical_warn": "WARNING: Be aware of security because the system treats the same user as a match of <code>username</code>.",
+		"Treat email matching as identical": "Automatically bind external accounts newly logged in to local accounts when <code>email</code> match",
+		"Treat email matching as identical_warn": "WARNING: Be aware of security because the system treats the same user as a match of <code>email</code>.",
+		"Use env var if empty": "Use env var <code>{{env}}</code> if empty",
+		"Use default if both are empty": "If both ​​are empty, the default value <code>{{target}}</code> is used.",
+		"missing mandatory configs": "The following mandatory items are not set in either database nor environment variables.",
+		"Local": {
+			"name": "ID/Password",
+			"note for the only env option": "The LOCAL authentication is limited by the value of environment variable.<br>To change this setting, please change to false or delete the value of the environment variable <code>{{env}}</code> .",
+      "enable_local": "Enable ID/Password",
+      "password_reset_by_users": "用户重置密码",
+      "enable_password_reset_by_users": "启用用户重置密码",
+      "password_reset_desc": "忘记密码时,用户可以自行重置",
+      "email_authentication": "用户注册时的电子邮件身份验证",
+      "enable_email_authentication": "启用电子邮件身份验证",
+      "enable_email_authentication_desc": "用户注册将执行电子邮件身份验证。",
+      "please_enable_mailer": "请先设置邮件程序。",
+      "need_complete_mail_setting_warning": "要使用以下功能,请完成邮件设置。"
+		},
+		"ldap": {
+			"enable_ldap": "Enable LDAP",
+			"server_url_detail": "The LDAP URL of the directory service in the format <code>ldap://host:port/DN</code> or <code>ldaps://host:port/DN</code>.",
+			"bind_mode": "Binding Mode",
+			"bind_manager": "Manager Bind",
+			"bind_user": "User Bind",
+			"bind_DN_manager_detail": "The DN of the account that authenticates and queries the directory service",
+			"bind_DN_user_detail1": "The query used to bind with the directory service.",
+			"bind_DN_user_detail2": "Use <code>&#123;&#123;username&#125;&#125;</code> to reference the username entered in the login page.",
+			"bind_DN_password": "Bind DN Password",
+			"bind_DN_password_manager_detail": "The password for the Bind DN account.",
+			"bind_DN_password_user_detail": "The password that is entered in the login page will be used to bind.",
+			"search_filter": "Search Filter",
+			"search_filter_detail1": "The query used to locate the authenticated user.",
+			"search_filter_detail2": "Use <code>&#123;&#123;username&#125;&#125;</code> to reference the username entered in the login page.",
+			"search_filter_detail3": "If empty, the filter <code>(uid=&#123;&#123;username&#125;&#125;)</code> is used.",
+			"search_filter_example1": "Match with 'uid' or 'mail'",
+			"search_filter_example2": "Match with 'sAMAccountName' for Active Directory",
+			"username_detail": "Specification of mappings for <code>username</code> when creating new users",
+			"name_detail": "Specification of mappings for full name when creating new users",
+			"mail_detail": "Specification of mappings for mail address when creating new users",
+			"group_search_base_DN": "Group Search Base DN",
+			"group_search_base_DN_detail": "The base DN from which to search for groups. If defined, also <code>Group Search Filter</code> must be defined for the search to work.",
+			"group_search_filter": "Group Search Filter",
+			"group_search_filter_detail1": "The query used to filter for groups.",
+			"group_search_filter_detail2": "Login via LDAP is accepted only when this query hits one or more groups.",
+			"group_search_filter_detail3": "Use <code>&#123;&#123;dn&#125;&#125;</code> to have it replaced of the found user object.",
+			"group_search_filter_detail4": "<code>(&(cn=group1)(memberUid=&#123;&#123;dn&#125;&#125;))</code> hits the groups which has <code>cn=group1</code> and <code>memberUid</code> includes the user's <code>uid</code>(when <code>Group DN Property</code> is not changed from the default value.)",
+			"group_search_user_DN_property": "User DN Property",
+			"group_search_user_DN_property_detail": "The property of user object to use in <code>&#123;&#123;dn&#125;&#125;</code> interpolation of <code>Group Search Filter</code>.",
+			"test_config": "Test Saved Configuration",
+			"updated_ldap": "Succeeded to update LDAP setting"
+		},
+		"SAML": {
+			"name": "SAML",
+			"enable_saml": "Enable SAML",
+			"id_detail": "Specification of the name of attribute which can identify the user in SAML Identity Provider",
+			"username_detail": "Specification of mappings for <code>username</code> when creating new users",
+			"mapping_detail": "Specification of mappings for {{target}} when creating new users",
+			"cert_detail": "PEM-encoded X.509 signing certificate to validate the response from IdP",
+			"Use env var if empty": "If the value in the database is empty, the value of the environment variable <code>{{env}}</code> is used.",
+			"note for the only env option": "The setting item that enables or disables the SAML authentication and the highlighted setting items use only the value of environment variables.<br>To change this setting, please change to false or delete the value of the environment variable <code>{{env}}</code> .",
+			"attr_based_login_control_detail": "Limit who can sign up by using <code>&lt;saml: Attribute&gt;</code> element included in <code>&lt;saml: AttributeStatement&gt;</code> element and its child element <code>&lt;saml: AttributeValue&gt;</code>.",
+			"attr_based_login_control_rule_help": "<h5>Supported Queries:</h5><ul><li>Terms</li><li>Fields</li><li>AND/NOT/OR Operator</li><li>Grouping</li></ul><h5>Unsupported Queries:</h5><ul><li>Wildcard, Fuzzy, Proximity, Range and Boosting</li><li>+/- Operator</li><li>Field Grouping</li></ul><h5>Escaping special characters</h5>It is needed to escape following special characters:<br><code>+ - && || ! ( ) { } [ ] ^ &quot; &tilde; * ? : &#92;</code> and <code>/</code>",
+			"attr_based_login_control_rule_example1": "<h5>Example for conditions</h5>If a rule is <code>(Department: A || Department: B) && Position: Leader</code>, users who have either <code>Department: A</code> or <code>Department: B</code> and have <code>Position: Leader</code> <strong>can</strong> sign in.",
+      "attr_based_login_control_rule_example2": "<h5>Example for escaping</h5>If you would like to use URL as a query value, escape the following:<br><code>http&#92;:&#92;/&#92;/schemas.example.com&#92;/ws&#92;/2005&#92;/05&#92;/identity&#92;/claims&#92;/emailaddress: &quot;myname@example.com&quot;</code>",
+      "updated_saml": "Succeeded to update SAML setting"
+		},
+		"Basic": {
+			"enable_basic": "Enable Basic",
+			"name": "Basic Authentication",
+			"desc_1": "Login with <code>username</code> in Authorization header.",
+			"desc_2": "User will be automatically generated if not exist.",
+			"updated_basic": "Succeeded to update Basic setting"
+		},
+		"OAuth": {
+			"enable_oidc": "Enable OIDC",
+			"register": "Register for %s",
+			"change_redirect_url": "Enter <code>%s</code> <br>(where <code>%s</code> is your host name) for \"Authorized redirect URIs\".",
+			"Google": {
+				"enable_google": "Enable Google OAuth",
+				"name": "Google OAuth",
+				"register_1": "Access {{link}}",
+				"register_2": "Create Project if no projects exist",
+				"register_3": "Create Credentials &rightarrow; OAuth client ID &rightarrow; Select \"Web application\"",
+				"register_4": "Register your OAuth App with one of Authorized redirect URIs as <code>{{url}}</code>",
+				"register_5": "Copy and paste your ClientID and Client Secret above",
+				"updated_google": "Succeeded to update Google OAuth setting"
+			},
+			"Facebook": {
+				"name": "Facebook OAuth"
+			},
+			"Twitter": {
+				"enable_twitter": "Enable Twitter OAuth",
+				"name": "Twitter OAuth",
+				"register_1": "Access {{link}}",
+				"register_2": "Sign in Twitter",
+				"register_3": "Create Credentials &rightarrow; OAuth client ID &rightarrow; Select \"Web application\"",
+				"register_4": "Register your OAuth App with one of Authorized redirect URIs as <code>{{url}}</code>",
+				"register_5": "Copy and paste your ClientID and Client Secret above",
+				"updated_twitter": "Succeeded to update Twitter OAuth setting"
+			},
+			"GitHub": {
+				"enable_github": "Enable GitHub OAuth",
+				"name": "GitHub OAuth",
+				"register_1": "Access {{link}}",
+				"register_2": "Register your OAuth App with \"Authorization callback URL\" as <code>{{url}}</code>",
+				"register_3": "Copy and paste your ClientID and Client Secret above",
+				"updated_github": "Succeeded to update GitHub OAuth setting"
+			},
+			"OIDC": {
+				"name": "OpenID Connect",
+				"id_detail": "Specification of the name of attribute which can identify the user in OIDC claims",
+				"username_detail": "Specification of mappings for <code>username</code> when creating new users",
+				"name_detail": "Specification of mappings for <code>name</code> when creating new users",
+				"mapping_detail": "Specification of mappings for %s when creating new users",
+				"register_1": "Contant to OIDC IdP Administrator",
+				"register_2": "Register your OIDC App with \"Authorization callback URL\" as <code>%s</code>",
+				"register_3": "Copy and paste your ClientID and Client Secret above",
+				"updated_oidc": "Succeeded to update OpenID Connect",
+        "Use discovered URL if empty": "Use discovered URL from \"Issuer Host\" if empty"
+			},
+			"how_to": {
+				"google": "How to configure Google OAuth?",
+				"github": "How to configure GitHub OAuth?",
+				"twitter": "How to configure Twitter OAuth?",
+				"oidc": "How to configure OIDC?"
+			}
+		},
+		"form_item_name": {
+			"entryPoint": "Entry point",
+			"issuer": "Issuer",
+			"cert": "Certificate",
+			"attrMapId": "ID",
+			"attrMapUsername": "Username",
+			"attrMapMail": "Mail Address",
+			"attrMapFirstName": "First Name",
+			"attrMapLastName": "Last Name",
+			"ABLCRule": "Rule"
+		}
+  },
+  "full_text_search_management": {
+    "full_text_search_management": "全文搜索管理",
+		"elasticsearch_management": "Elasticsearch管理",
+		"connection_status": "连接状态",
+		"connection_status_label_unconfigured": "未配置",
+		"connection_status_label_connected": "已连接",
+		"connection_status_label_disconnected": "断开的",
+		"connection_status_label_erroroccured": "搜索服务出错",
+		"indices_status": "索引状态",
+		"indices_status_label_normalized": "标准化",
+		"indices_status_label_unnormalized": "重建或损坏",
+		"indices_summary": "索引摘要",
+		"reconnect": "重新连接",
+		"reconnect_button": "尝试重新连接",
+		"reconnect_description": "单击按钮尝试重新连接到Elasticsearch。",
+		"normalize": "规范化",
+		"normalize_button": "规范化索引",
+		"normalize_description": "单击按钮修复损坏的索引。",
+		"rebuild": "重建",
+		"rebuild_button": "重建索引",
+		"rebuild_description_1": "单击按钮以重新生成索引并添加所有页面数据。",
+		"rebuild_description_2": "这可能需要一段时间。"
+	},
   "mailer_setup_required": "<a href='/admin/app'>Email settings</a> are required to send.",
   "admin_top": {
     "management_wiki": "管理Wiki",
@@ -104,7 +360,8 @@
     "use_env_var_if_empty": "如果数据库中的值为空,则环境变量的值 <cod>{{variable}}</code> 启用。",
     "note_for_the_only_env_option": "The GCS settings is limited by the value of environment variable.<br>To change this setting, please change to false or delete the value of the environment variable <code>{{env}}</code> ."
   },
-  "markdown_setting": {
+  "markdown_settings": {
+    "markdown_settings": "Markdown设置",
     "lineBreak_header": "换行设置",
     "lineBreak_desc": "您可以更改换行设置。",
     "lineBreak_options": {
@@ -147,7 +404,9 @@
       "import_recommended": "导入建议 {{target}}"
     }
   },
+  "export_archive_data": "导出主题数据",
   "customize_setting": {
+    "customize_setting": "页面定制",
     "default_sidebar_mode": {
       "title": "默认的侧边栏模式",
       "desc": "你可以为新用户和访问该网页的客人设置侧边栏模式。",
@@ -226,6 +485,12 @@
     "delete_logo": "删除徽标"
   },
   "importer_management": {
+    "import_data": "导入数据",
+    "article": "主题",
+    "category": "分类",
+    "tag": "标签",
+    "page": "页面",
+    "page_path": "相对路径",
     "beta_warning": "这个函数是Beta。",
     "import_from": "Import from {{from}}",
     "import_growi_archive": "Import GROWI archive",
@@ -315,12 +580,14 @@
     "delete": "删除"
   },
   "external_notification": {
+    "external_notification": "外部通知",
     "enabled": "Enabled",
     "disabled": "Disabled",
     "header_status": "Slack整合状态",
     "caution_enabled": "CAUTION: 目前,在此页面中配置的通知只会通知设置为主要的 Slack 工作区。 "
   },
   "slack_integration": {
+    "slack_integration": "Slack一体化",
     "selecting_bot_types": {
       "slack_bot": "Slack bot",
       "detailed_explanation": "详细说明",
@@ -431,12 +698,15 @@
     }
   },
   "slack_integration_legacy": {
+    "slack_integration_legacy": "旧版Slack一体化",
     "alert_disabled": "由于<a href='/admin/slack-integration'>新设置</a>已启用,因此该'旧版Slack一体化'目前已被禁用。",
     "alert_deplicated": "这个 '旧版Slack一体化' 已经过时了,将来会停止使用。使用<a href='/admin/slack-integration'>新的设置</a>来代替。"
   },
   "user_management": {
+    "user_management": "用户管理",
     "invite_users": "临时发布新用户",
     "click_twice_same_checkbox": "您应该至少选中一个复选框。",
+    "status": "状态",
     "invite_modal": {
       "emails": "电子邮件",
       "description1": "通过电子邮件地址临时发布新用户。",
@@ -492,12 +762,14 @@
     "current_users": "当前用户:"
   },
   "user_group_management": {
+    "user_group_management": "用户组管理",
     "create_group": "创建新组",
     "add_child_group": "添加一个子组",
     "remove_child_group": "移除",
     "deny_create_group": "不能用当前设置创建新组。",
     "group_name": "组名",
     "group_example": "e.g.:第1组",
+    "child_user_group": "儿童用户组",
     "parent_group": "父母组",
     "select_parent_group": "选择父组",
     "release_parent_group": "Release parent group",
@@ -538,6 +810,8 @@
     }
   },
   "audit_log_management": {
+    "audit_log": "审计日志",
+    "audit_log_settings": "审计日志设置",
     "user": "用户",
     "username": "帐号",
     "date": "日期",

+ 4 - 262
packages/app/public/static/locales/zh_CN/translation.json

@@ -27,7 +27,6 @@
   "Description": "描述",
 	"Admin": "管理",
 	"administrator": "管理员",
-	"Tag": "标签",
 	"Tags": "Tags",
   "New": "新建",
   "Close": "Close",
@@ -36,12 +35,6 @@
 	"eg": "e.g.",
 	"add": "添加",
 	"Undo": "撤销",
-	"Article": "主题",
-	"Page": "页面",
-	"Page Path": "相对路径",
-	"Category": "分类",
-	"User": "用户",
-	"status": "状态",
 	"account_id": "用户Id",
 	"Initialize": "初始化",
   "Update": "更新",
@@ -122,28 +115,15 @@
 	"Input page name (optional)": "Input page name (optional)",
 	"New Page": "新页面",
 	"Create under": "Create page under below:",
-	"Wiki Management Home Page": "Wiki管理首页",
-	"App Settings": "系统设置",
   "V5 Page Migration": "转换为V5的兼容性",
   "GROWI.5.0_new_schema": "GROWI.5.0 new schema",
   "See_more_detail_on_new_schema": "更多详情请见<a href='#'>{{url}}</a> <i class='icon-share-alt'></i> ",
 	"Site URL settings": "主页URL设置",
 	"Markdown Settings": "Markdown设置",
-	"Customize": "页面定制",
 	"Notification Settings": "通知设置",
-  "slack_integration": "Slack一体化",
-  "External_Notification": "外部通知",
-  "Legacy_Slack_Integration": "旧版Slack一体化",
-	"User_Management": "用户管理",
 	"external_account_management": "外部账户管理",
   "UserGroup": "用户组",
   "ChildUserGroup": "儿童用户组",
-	"UserGroup Management": "用户组管理",
-  "AuditLog": "审计日志",
-  "AuditLog Settings": "审计日志设置",
-	"Full Text Search Management": "全文搜索管理",
-	"Import Data": "导入数据",
-	"Export Archive Data": "导出主题数据",
 	"Basic Settings": "基础设置",
 	"Basic authentication": "基本身份验证",
 	"Register limitation": "注册限制",
@@ -154,11 +134,6 @@
 	"Only me": "只有我",
   "Only inside the group": "仅组内",
   "page_list": "Page List",
-	"scope_of_page_disclosure": "页面公开范围",
-	"set_point": "设定值",
-	"always_displayed": "始终显示",
-	"always_hidden": "总是隐藏",
-	"displayed_or_hidden": "显示/隐藏",
 	"Reselect the group": "重新选择组",
 	"Shareable link": "可分享链接",
 	"The whitelist of registration permission E-mail address": "注册许可电子邮件地址的白名单",
@@ -178,7 +153,6 @@
   "add_bookmark": "添加到书签",
   "remove_bookmark": "从书签中删除",
   "wide_view": "视野开阔",
-	"Recent Created": "最新创建",
   "Recent Changes": "最新修改",
   "Page Tree": "页面树",
   "original_path":"Original path",
@@ -256,7 +230,6 @@
 		"new_password_confirm": "重复新密码",
 		"password_is_not_set": "密码未设置"
 	},
-	"Security Settings": "安全设置",
 	"API Settings": "API设置",
 	"API Token Settings": "API token 设置",
 	"Current API Token": "当前 API token",
@@ -631,219 +604,6 @@
     "share_settings" :"Share settings",
     "Invalid_Number_of_Date" : "You entered invalid value"
   },
-	"security_setting": {
-		"Guest Users Access": "来宾用户访问",
-		"Fixed by env var": "这是由env var<code>%s=%s</code>修复的。",
-		"Register limitation": "注册限制",
-		"Register limitation desc": "限制新用户注册",
-		"The whitelist of registration permission E-mail address": "注册许可电子邮件地址的白名单",
-		"users_without_account": "无法访问没有帐户的用户",
-		"example": "例子",
-		"restrict_emails": "您可以通过编写电子邮件域(以@开头)将电子邮件注册限制为wiki。",
-		"for_example": " 例如,如果要将注册限制为growi.org网站域,你可以写",
-		"in_this_case": ";在这种情况下,只有growi.org网站域将能够注册,所有其他用户将被拒绝。",
-		"insert_single": "请每行插入一个电子邮件地址。",
-    "page_list_and_search_results": "页面列表/搜索结果",
-		"page_listing_1": "页面列表/搜索<br>受“仅限我”限制",
-		"page_listing_1_desc": "列出/搜索时显示受“仅限我”选项限制的页面",
-		"page_listing_2": "页面列表/搜索<br>受用户组限制",
-		"page_listing_2_desc": "显示列出/搜索时受用户组限制的页面",
-    "page_access_rights": "页面访问",
-    "page_delete_rights": "删除权限",
-    "page_delete": "删除",
-    "page_delete_completely": "彻底删除",
-    "other_options": "其他选项",
-    "deletion_explain": "限制用户对选定的单一页面进行垃圾处理。",
-    "complete_deletion_explain": "限制可以完全删除所选单页的用户。",
-    "recursive_deletion_explain": "限制用户可以捣毁包括子孙在内的页面。",
-    "recursive_complete_deletion_explain": "限制可以完全删除页面的用户,包括子孙。",
-    "inherit": "继承(使用与单页相同的设置)。",
-		"admin_only": "仅管理员",
-		"admin_and_author": "管理员|作者",
-		"anyone": "任何人",
-    "session": "会议",
-    "max_age": "有效期间  (msec)",
-    "max_age_desc": "指定使用户会话过期的数量(以毫秒为单位)。<br>默认值: 2592000000 (30天)",
-    "max_age_caution": "修改该值后需要重启服务器。",
-    "forced_update_desc": "设置已被强行更改。以前的设置: ",
-    "page_delete_rights_caution": "\"删除/全部删除\"权限(包括后代页面)被强制强于\"删除/完全删除\"权限。 <br> <br> 仅管理员 > 管理员|作者 > 何人",
-		"Authentication mechanism settings": "身份验证机制设置",
-		"setup_is_not_yet_complete": "安装尚未完成",
-		"alert_siteUrl_is_not_set": "主页URL未设置,通过 {{link}} 设置",
-		"xss_prevent_setting": "阻止XSS(跨站点脚本)",
-		"xss_prevent_setting_link": "转到Markdown设置",
-		"callback_URL": "回调URL",
-		"providerName": "提供程序名称",
-		"issuerHost": "发行者主机",
-		"scope": "Scope",
-		"desc_of_callback_URL": "在{{AuthName}}身份提供程序的设置中使用它",
-    "authorization_endpoint": "Authorization Endpoint",
-    "token_endpoint": "Token Endpoint",
-    "revocation_endpoint": "Revocation Endpoint",
-    "introspection_endpoint": "Introspection Endpoint",
-    "userinfo_endpoint": "UserInfo Endpoint",
-    "end_session_endpoint": "EndSessioin Endpoint",
-    "registration_endpoint": "Registration Endpoint",
-    "jwks_uri": "JSON Web Key Set URL",
-		"clientID": "Client ID",
-		"client_secret": "客户机密",
-		"updated_general_security_setting": "更新安全设置成功",
-		"setup_not_completed_yet": "安装尚未完成",
-		"guest_mode": {
-			"deny": "拒绝(仅限注册用户)",
-			"readonly": "接受(来宾可以只读)"
-		},
-		"registration_mode": {
-			"open": "打开(任何人都可以注册)",
-			"restricted": "受限(需要管理员批准)",
-			"closed": "已关闭(仅限邀请)"
-		},
-    "share_link_rights": "分享链接权",
-    "enable_link_sharing": "启用链接共享",
-    "all_share_links": "所有共享链接",
-		"configuration": " 配置",
-		"optional": "可选的",
-		"Treat username matching as identical": "Automatically bind external accounts newly logged in to local accounts when <code>username</code> match",
-		"Treat username matching as identical_warn": "WARNING: Be aware of security because the system treats the same user as a match of <code>username</code>.",
-		"Treat email matching as identical": "Automatically bind external accounts newly logged in to local accounts when <code>email</code> match",
-		"Treat email matching as identical_warn": "WARNING: Be aware of security because the system treats the same user as a match of <code>email</code>.",
-		"Use env var if empty": "Use env var <code>{{env}}</code> if empty",
-		"Use default if both are empty": "If both ​​are empty, the default value <code>{{target}}</code> is used.",
-		"missing mandatory configs": "The following mandatory items are not set in either database nor environment variables.",
-		"Local": {
-			"name": "ID/Password",
-			"note for the only env option": "The LOCAL authentication is limited by the value of environment variable.<br>To change this setting, please change to false or delete the value of the environment variable <code>{{env}}</code> .",
-      "enable_local": "Enable ID/Password",
-      "password_reset_by_users": "用户重置密码",
-      "enable_password_reset_by_users": "启用用户重置密码",
-      "password_reset_desc": "忘记密码时,用户可以自行重置",
-      "email_authentication": "用户注册时的电子邮件身份验证",
-      "enable_email_authentication": "启用电子邮件身份验证",
-      "enable_email_authentication_desc": "用户注册将执行电子邮件身份验证。",
-      "please_enable_mailer": "请先设置邮件程序。",
-      "need_complete_mail_setting_warning": "要使用以下功能,请完成邮件设置。"
-		},
-		"ldap": {
-			"enable_ldap": "Enable LDAP",
-			"server_url_detail": "The LDAP URL of the directory service in the format <code>ldap://host:port/DN</code> or <code>ldaps://host:port/DN</code>.",
-			"bind_mode": "Binding Mode",
-			"bind_manager": "Manager Bind",
-			"bind_user": "User Bind",
-			"bind_DN_manager_detail": "The DN of the account that authenticates and queries the directory service",
-			"bind_DN_user_detail1": "The query used to bind with the directory service.",
-			"bind_DN_user_detail2": "Use <code>&#123;&#123;username&#125;&#125;</code> to reference the username entered in the login page.",
-			"bind_DN_password": "Bind DN Password",
-			"bind_DN_password_manager_detail": "The password for the Bind DN account.",
-			"bind_DN_password_user_detail": "The password that is entered in the login page will be used to bind.",
-			"search_filter": "Search Filter",
-			"search_filter_detail1": "The query used to locate the authenticated user.",
-			"search_filter_detail2": "Use <code>&#123;&#123;username&#125;&#125;</code> to reference the username entered in the login page.",
-			"search_filter_detail3": "If empty, the filter <code>(uid=&#123;&#123;username&#125;&#125;)</code> is used.",
-			"search_filter_example1": "Match with 'uid' or 'mail'",
-			"search_filter_example2": "Match with 'sAMAccountName' for Active Directory",
-			"username_detail": "Specification of mappings for <code>username</code> when creating new users",
-			"name_detail": "Specification of mappings for full name when creating new users",
-			"mail_detail": "Specification of mappings for mail address when creating new users",
-			"group_search_base_DN": "Group Search Base DN",
-			"group_search_base_DN_detail": "The base DN from which to search for groups. If defined, also <code>Group Search Filter</code> must be defined for the search to work.",
-			"group_search_filter": "Group Search Filter",
-			"group_search_filter_detail1": "The query used to filter for groups.",
-			"group_search_filter_detail2": "Login via LDAP is accepted only when this query hits one or more groups.",
-			"group_search_filter_detail3": "Use <code>&#123;&#123;dn&#125;&#125;</code> to have it replaced of the found user object.",
-			"group_search_filter_detail4": "<code>(&(cn=group1)(memberUid=&#123;&#123;dn&#125;&#125;))</code> hits the groups which has <code>cn=group1</code> and <code>memberUid</code> includes the user's <code>uid</code>(when <code>Group DN Property</code> is not changed from the default value.)",
-			"group_search_user_DN_property": "User DN Property",
-			"group_search_user_DN_property_detail": "The property of user object to use in <code>&#123;&#123;dn&#125;&#125;</code> interpolation of <code>Group Search Filter</code>.",
-			"test_config": "Test Saved Configuration",
-			"updated_ldap": "Succeeded to update LDAP setting"
-		},
-		"SAML": {
-			"name": "SAML",
-			"enable_saml": "Enable SAML",
-			"id_detail": "Specification of the name of attribute which can identify the user in SAML Identity Provider",
-			"username_detail": "Specification of mappings for <code>username</code> when creating new users",
-			"mapping_detail": "Specification of mappings for {{target}} when creating new users",
-			"cert_detail": "PEM-encoded X.509 signing certificate to validate the response from IdP",
-			"Use env var if empty": "If the value in the database is empty, the value of the environment variable <code>{{env}}</code> is used.",
-			"note for the only env option": "The setting item that enables or disables the SAML authentication and the highlighted setting items use only the value of environment variables.<br>To change this setting, please change to false or delete the value of the environment variable <code>{{env}}</code> .",
-			"attr_based_login_control_detail": "Limit who can sign up by using <code>&lt;saml: Attribute&gt;</code> element included in <code>&lt;saml: AttributeStatement&gt;</code> element and its child element <code>&lt;saml: AttributeValue&gt;</code>.",
-			"attr_based_login_control_rule_help": "<h5>Supported Queries:</h5><ul><li>Terms</li><li>Fields</li><li>AND/NOT/OR Operator</li><li>Grouping</li></ul><h5>Unsupported Queries:</h5><ul><li>Wildcard, Fuzzy, Proximity, Range and Boosting</li><li>+/- Operator</li><li>Field Grouping</li></ul><h5>Escaping special characters</h5>It is needed to escape following special characters:<br><code>+ - && || ! ( ) { } [ ] ^ &quot; &tilde; * ? : &#92;</code> and <code>/</code>",
-			"attr_based_login_control_rule_example1": "<h5>Example for conditions</h5>If a rule is <code>(Department: A || Department: B) && Position: Leader</code>, users who have either <code>Department: A</code> or <code>Department: B</code> and have <code>Position: Leader</code> <strong>can</strong> sign in.",
-      "attr_based_login_control_rule_example2": "<h5>Example for escaping</h5>If you would like to use URL as a query value, escape the following:<br><code>http&#92;:&#92;/&#92;/schemas.example.com&#92;/ws&#92;/2005&#92;/05&#92;/identity&#92;/claims&#92;/emailaddress: &quot;myname@example.com&quot;</code>",
-      "updated_saml": "Succeeded to update SAML setting"
-		},
-		"Basic": {
-			"enable_basic": "Enable Basic",
-			"name": "Basic Authentication",
-			"desc_1": "Login with <code>username</code> in Authorization header.",
-			"desc_2": "User will be automatically generated if not exist.",
-			"updated_basic": "Succeeded to update Basic setting"
-		},
-		"OAuth": {
-			"enable_oidc": "Enable OIDC",
-			"register": "Register for %s",
-			"change_redirect_url": "Enter <code>%s</code> <br>(where <code>%s</code> is your host name) for \"Authorized redirect URIs\".",
-			"Google": {
-				"enable_google": "Enable Google OAuth",
-				"name": "Google OAuth",
-				"register_1": "Access {{link}}",
-				"register_2": "Create Project if no projects exist",
-				"register_3": "Create Credentials &rightarrow; OAuth client ID &rightarrow; Select \"Web application\"",
-				"register_4": "Register your OAuth App with one of Authorized redirect URIs as <code>{{url}}</code>",
-				"register_5": "Copy and paste your ClientID and Client Secret above",
-				"updated_google": "Succeeded to update Google OAuth setting"
-			},
-			"Facebook": {
-				"name": "Facebook OAuth"
-			},
-			"Twitter": {
-				"enable_twitter": "Enable Twitter OAuth",
-				"name": "Twitter OAuth",
-				"register_1": "Access {{link}}",
-				"register_2": "Sign in Twitter",
-				"register_3": "Create Credentials &rightarrow; OAuth client ID &rightarrow; Select \"Web application\"",
-				"register_4": "Register your OAuth App with one of Authorized redirect URIs as <code>{{url}}</code>",
-				"register_5": "Copy and paste your ClientID and Client Secret above",
-				"updated_twitter": "Succeeded to update Twitter OAuth setting"
-			},
-			"GitHub": {
-				"enable_github": "Enable GitHub OAuth",
-				"name": "GitHub OAuth",
-				"register_1": "Access {{link}}",
-				"register_2": "Register your OAuth App with \"Authorization callback URL\" as <code>{{url}}</code>",
-				"register_3": "Copy and paste your ClientID and Client Secret above",
-				"updated_github": "Succeeded to update GitHub OAuth setting"
-			},
-			"OIDC": {
-				"name": "OpenID Connect",
-				"id_detail": "Specification of the name of attribute which can identify the user in OIDC claims",
-				"username_detail": "Specification of mappings for <code>username</code> when creating new users",
-				"name_detail": "Specification of mappings for <code>name</code> when creating new users",
-				"mapping_detail": "Specification of mappings for %s when creating new users",
-				"register_1": "Contant to OIDC IdP Administrator",
-				"register_2": "Register your OIDC App with \"Authorization callback URL\" as <code>%s</code>",
-				"register_3": "Copy and paste your ClientID and Client Secret above",
-				"updated_oidc": "Succeeded to update OpenID Connect",
-        "Use discovered URL if empty": "Use discovered URL from \"Issuer Host\" if empty"
-			},
-			"how_to": {
-				"google": "How to configure Google OAuth?",
-				"github": "How to configure GitHub OAuth?",
-				"twitter": "How to configure Twitter OAuth?",
-				"oidc": "How to configure OIDC?"
-			}
-		},
-		"form_item_name": {
-			"entryPoint": "Entry point",
-			"issuer": "Issuer",
-			"cert": "Certificate",
-			"attrMapId": "ID",
-			"attrMapUsername": "Username",
-			"attrMapMail": "Mail Address",
-			"attrMapFirstName": "First Name",
-			"attrMapLastName": "Last Name",
-			"ABLCRule": "Rule"
-		}
-	},
 	"notification_setting": {
 		"slack_incoming_configuration": "Slack Incoming Webhooks configuration",
 		"prioritize_webhook": "Prioritize incoming webhook than Slack App",
@@ -893,28 +653,6 @@
 		"delete_notification_pattern_desc2": "Once deleted, it cannot be recovered",
 		"toggle_notification": "Updated setting of {{path}}"
 	},
-	"full_text_search_management": {
-		"elasticsearch_management": "Elasticsearch管理",
-		"connection_status": "连接状态",
-		"connection_status_label_unconfigured": "未配置",
-		"connection_status_label_connected": "已连接",
-		"connection_status_label_disconnected": "断开的",
-		"connection_status_label_erroroccured": "搜索服务出错",
-		"indices_status": "索引状态",
-		"indices_status_label_normalized": "标准化",
-		"indices_status_label_unnormalized": "重建或损坏",
-		"indices_summary": "索引摘要",
-		"reconnect": "重新连接",
-		"reconnect_button": "尝试重新连接",
-		"reconnect_description": "单击按钮尝试重新连接到Elasticsearch。",
-		"normalize": "规范化",
-		"normalize_button": "规范化索引",
-		"normalize_description": "单击按钮修复损坏的索引。",
-		"rebuild": "重建",
-		"rebuild_button": "重建索引",
-		"rebuild_description_1": "单击按钮以重新生成索引并添加所有页面数据。",
-		"rebuild_description_2": "这可能需要一段时间。"
-	},
 	"personal_dropdown": {
 		"home": "家",
 		"settings": "设置",
@@ -1146,5 +884,9 @@
   "page_operation":{
     "paths_recovered": "成功恢复了页面路径",
     "path_recovery_failed":"路径恢复失败"
+  },
+  "footer": {
+    "bookmarks": "书签",
+    "recently_created": "最近创建页面"
   }
 }

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

@@ -27,16 +27,17 @@ const AdminNavigation = (props) => {
     switch (menu) {
       case 'app':                      return <><i className="icon-fw icon-settings"></i>        { t('app_settings') }</>;
       case 'security':                 return <><i className="icon-fw icon-shield"></i>          { t('security_settings.security_settings') }</>;
-      case 'markdown':                 return <><i className="icon-fw icon-note"></i>            { t('markdown_settings') }</>;
-      case 'customize':                return <><i className="icon-fw icon-wrench"></i>          { t('Customize') }</>;
-      case 'importer':                 return <><i className="icon-fw icon-cloud-upload"></i>    { t('Import Data') }</>;
-      case 'export':                   return <><i className="icon-fw icon-cloud-download"></i>  { t('Export Archive Data') }</>;
+      case 'markdown':                 return <><i className="icon-fw icon-note"></i>            { t('markdown_settings.markdown_settings') }</>;
+      case 'customize':                return <><i className="icon-fw icon-wrench"></i>          { t('customize') }</>;
+      case 'importer':                 return <><i className="icon-fw icon-cloud-upload"></i>    { t('importer_management.import_data') }</>;
+      case 'export':                   return <><i className="icon-fw icon-cloud-download"></i>  { t('export_archive_data') }</>;
       case 'notification':             return <><i className="icon-fw icon-bell"></i>            { t('external_notification.external_notification')}</>;
       case 'slack-integration':        return <><i className="icon-fw icon-shuffle"></i>         { t('slack_integration.slack_integration') }</>;
       case 'slack-integration-legacy': return <><i className="icon-fw icon-shuffle"></i>         { t('slack_integration_legacy.slack_integration_legacy')}</>;
       case 'users':                    return <><i className="icon-fw icon-user"></i>            { t('user_management.user_management') }</>;
       case 'user-groups':              return <><i className="icon-fw icon-people"></i>          { t('user_group_management.user_group_management') }</>;
-      case 'search':                   return <><i className="icon-fw icon-magnifier"></i>       { t('full_text_search_management') }</>;
+      case 'search':                   return <><i className="icon-fw icon-magnifier"></i>
+        { t('full_text_search_management.full_text_search_management') }</>;
       // TODO: Consider where to place the "AuditLog"
       case 'audit-log':                return <><i className="icon-fw icon-feed"></i>            { t('audit_log_management.audit_log')}</>;
       case 'cloud':                    return <><i className="icon-fw icon-share-alt"></i>       { t('to_cloud_settings')} </>;

+ 1 - 1
packages/app/src/components/Admin/Common/AdminUpdateButtonRow.tsx

@@ -9,7 +9,7 @@ type Props = {
 }
 
 const AdminUpdateButtonRow = (props: Props): JSX.Element => {
-  const { t } = useTranslation();
+  const { t } = useTranslation('admin');
 
   return (
     <div className="row my-3">

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

@@ -7,7 +7,7 @@ import { apiv3Get, apiv3Put } from '~/client/util/apiv3-client';
 import { useNextThemes } from '~/stores/use-next-themes';
 
 const CustomizeLayoutSetting = (): JSX.Element => {
-  const { t } = useTranslation();
+  const { t } = useTranslation('admin');
 
   const { resolvedTheme } = useNextThemes();
 
@@ -32,7 +32,7 @@ const CustomizeLayoutSetting = (): JSX.Element => {
   const onClickSubmit = async() => {
     try {
       await apiv3Put('/customize-setting/layout', { isContainerFluid });
-      toastSuccess(t('toaster.update_successed', { target: t('admin:customize_setting.layout') }));
+      toastSuccess(t('toaster.update_successed', { target: t('customize_setting.layout') }));
       retrieveData();
     }
     catch (err) {
@@ -44,7 +44,7 @@ const CustomizeLayoutSetting = (): JSX.Element => {
     <React.Fragment>
       <div className="row">
         <div className="col-12">
-          <h2 className="admin-setting-header">{t('admin:customize_setting.layout')}</h2>
+          <h2 className="admin-setting-header">{t('customize_setting.layout')}</h2>
 
           <div className="d-flex justify-content-around mt-5">
             <div id="layoutOptions" className="card-deck">
@@ -55,7 +55,7 @@ const CustomizeLayoutSetting = (): JSX.Element => {
               >
                 <img src={`/images/customize-settings/default-${resolvedTheme}.svg`} />
                 <div className="card-body text-center">
-                  {t('admin:customize_setting.layout_options.default')}
+                  {t('customize_setting.layout_options.default')}
                 </div>
               </div>
               <div
@@ -65,7 +65,7 @@ const CustomizeLayoutSetting = (): JSX.Element => {
               >
                 <img src={`/images/customize-settings/fluid-${resolvedTheme}.svg`} />
                 <div className="card-body  text-center">
-                  {t('admin:customize_setting.layout_options.expanded')}
+                  {t('customize_setting.layout_options.expanded')}
                 </div>
               </div>
             </div>

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

@@ -8,7 +8,7 @@ import { useSWRxSidebarConfig } from '~/stores/ui';
 import { useNextThemes } from '~/stores/use-next-themes';
 
 const CustomizeSidebarsetting = (): JSX.Element => {
-  const { t } = useTranslation();
+  const { t } = useTranslation('admin');
   const {
     update, isSidebarDrawerMode, isSidebarClosedAtDockMode, setIsSidebarDrawerMode, setIsSidebarClosedAtDockMode,
   } = useSWRxSidebarConfig();
@@ -20,7 +20,7 @@ const CustomizeSidebarsetting = (): JSX.Element => {
   const onClickSubmit = useCallback(async() => {
     try {
       await update();
-      toastSuccess(t('toaster.update_successed', { target: t('admin:customize_setting.default_sidebar_mode.title') }));
+      toastSuccess(t('toaster.update_successed', { target: t('customize_setting.default_sidebar_mode.title') }));
     }
     catch (err) {
       toastError(err);
@@ -32,11 +32,11 @@ const CustomizeSidebarsetting = (): JSX.Element => {
       <div className="row">
         <div className="col-12">
 
-          <h2 className="admin-setting-header">{t('admin:customize_setting.default_sidebar_mode.title')}</h2>
+          <h2 className="admin-setting-header">{t('customize_setting.default_sidebar_mode.title')}</h2>
 
           <Card className="card well my-3">
             <CardBody className="px-0 py-2">
-              {t('admin:customize_setting.default_sidebar_mode.desc')}
+              {t('customize_setting.default_sidebar_mode.desc')}
             </CardBody>
           </Card>
 
@@ -67,7 +67,7 @@ const CustomizeSidebarsetting = (): JSX.Element => {
 
           <Card className="card well my-5">
             <CardBody className="px-0 py-2">
-              {t('admin:customize_setting.default_sidebar_mode.dock_mode_default_desc')}
+              {t('customize_setting.default_sidebar_mode.dock_mode_default_desc')}
             </CardBody>
           </Card>
 
@@ -83,7 +83,7 @@ const CustomizeSidebarsetting = (): JSX.Element => {
                 onChange={() => setIsSidebarClosedAtDockMode(false)}
               />
               <label className="custom-control-label" htmlFor="is-open">
-                {t('admin:customize_setting.default_sidebar_mode.dock_mode_default_open')}
+                {t('customize_setting.default_sidebar_mode.dock_mode_default_open')}
               </label>
             </div>
             <div className="custom-control custom-radio my-3">
@@ -97,7 +97,7 @@ const CustomizeSidebarsetting = (): JSX.Element => {
                 onChange={() => setIsSidebarClosedAtDockMode(true)}
               />
               <label className="custom-control-label" htmlFor="is-closed">
-                {t('admin:customize_setting.default_sidebar_mode.dock_mode_default_close')}
+                {t('customize_setting.default_sidebar_mode.dock_mode_default_close')}
               </label>
             </div>
           </div>

+ 3 - 3
packages/app/src/components/Admin/Customize/CustomizeThemeOptions.jsx

@@ -52,14 +52,14 @@ const CustomizeThemeOptions = (props) => {
   const { adminCustomizeContainer, currentTheme } = props;
   const { currentLayout } = adminCustomizeContainer.state;
 
-  const { t } = useTranslation();
+  const { t } = useTranslation('admin');
 
 
   return (
     <div id="themeOptions" className={`${currentLayout === 'kibela' && 'disabled'}`}>
       {/* Light and Dark Themes */}
       <div>
-        <h3>{t('admin:customize_setting.theme_desc.light_and_dark')}</h3>
+        <h3>{t('customize_setting.theme_desc.light_and_dark')}</h3>
         <div className="d-flex flex-wrap">
           {lightNDarkTheme.map((theme) => {
             return (
@@ -75,7 +75,7 @@ const CustomizeThemeOptions = (props) => {
       </div>
       {/* Unique Theme */}
       <div className="mt-3">
-        <h3>{t('admin:customize_setting.theme_desc.unique')}</h3>
+        <h3>{t('customize_setting.theme_desc.unique')}</h3>
         <div className="d-flex flex-wrap">
           {uniqueTheme.map((theme) => {
             return (

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

@@ -15,7 +15,7 @@ import ReconnectControls from './ReconnectControls';
 import StatusTable from './StatusTable';
 
 const ElasticsearchManagement = () => {
-  const { t } = useTranslation();
+  const { t } = useTranslation('admin');
   const { data: isSearchServiceReachable } = useIsSearchServiceReachable();
   const { data: socket } = useAdminSocket();
 

+ 1 - 1
packages/app/src/components/Admin/ElasticsearchManagement/NormalizeIndicesControls.tsx

@@ -9,7 +9,7 @@ type Props = {
 }
 
 const NormalizeIndicesControls = (props: Props): JSX.Element => {
-  const { t } = useTranslation();
+  const { t } = useTranslation('admin');
   const { isNormalized, isRebuildingProcessing } = props;
 
   const isEnabled = (isNormalized != null) && !isNormalized && !isRebuildingProcessing;

+ 1 - 1
packages/app/src/components/Admin/ElasticsearchManagement/RebuildIndexControls.jsx

@@ -108,7 +108,7 @@ class RebuildIndexControls extends React.Component {
 }
 
 const RebuildIndexControlsFC = (props) => {
-  const { t } = useTranslation();
+  const { t } = useTranslation('admin');
   const { data: socket } = useAdminSocket();
   return <RebuildIndexControls t={t} socket={socket} {...props} />;
 };

+ 1 - 1
packages/app/src/components/Admin/ElasticsearchManagement/ReconnectControls.tsx

@@ -9,7 +9,7 @@ type Props = {
 }
 
 const ReconnectControls = (props: Props): JSX.Element => {
-  const { t } = useTranslation();
+  const { t } = useTranslation('admin');
 
   const { isEnabled, isProcessing } = props;
 

+ 1 - 1
packages/app/src/components/Admin/ElasticsearchManagement/StatusTable.jsx

@@ -161,7 +161,7 @@ class StatusTable extends React.PureComponent {
 }
 
 const StatusTableWrapperFC = (props) => {
-  const { t } = useTranslation();
+  const { t } = useTranslation('admin');
 
   return <StatusTable t={t} {...props} />;
 };

+ 1 - 1
packages/app/src/components/Admin/ExportArchiveDataPage.jsx

@@ -212,7 +212,7 @@ class ExportArchiveDataPage extends React.Component {
 
     return (
       <div data-testid="admin-export-archive-data">
-        <h2>{t('Export Archive Data')}</h2>
+        <h2>{t('export_archive_data')}</h2>
 
         <button type="button" className="btn btn-outline-secondary" disabled={isExporting} onClick={this.openExportModal}>
           {t('admin:export_management.create_new_archive_data')}

+ 2 - 2
packages/app/src/components/Admin/ImportData/GrowiArchive/ImportCollectionConfigurationModal.jsx

@@ -2,8 +2,8 @@
 
 import React from 'react';
 
-import PropTypes from 'prop-types';
 import { useTranslation } from 'next-i18next';
+import PropTypes from 'prop-types';
 import {
   Modal,
   ModalHeader,
@@ -233,7 +233,7 @@ ImportCollectionConfigurationModal.propTypes = {
 };
 
 const ImportCollectionConfigurationModalWrapperFc = (props) => {
-  const { t } = useTranslation();
+  const { t } = useTranslation('admin');
 
   return <ImportCollectionConfigurationModal t={t} {...props} />;
 };

+ 2 - 2
packages/app/src/components/Admin/ImportData/GrowiArchive/ImportForm.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 AdminSocketIoContainer from '~/client/services/AdminSocketIoContainer';
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
@@ -505,7 +505,7 @@ ImportForm.propTypes = {
 };
 
 const ImportFormWrapperFc = (props) => {
-  const { t } = useTranslation();
+  const { t } = useTranslation('admin');
 
   return <ImportForm t={t} {...props} />;
 };

+ 2 - 2
packages/app/src/components/Admin/ImportData/GrowiArchive/UploadForm.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 { toastError } from '~/client/util/apiNotification';
 import { apiv3PostForm } from '~/client/util/apiv3-client';
@@ -98,7 +98,7 @@ UploadForm.propTypes = {
 };
 
 const UploadFormWrapperFc = (props) => {
-  const { t } = useTranslation();
+  const { t } = useTranslation('admin');
 
   return <UploadForm t={t} {...props} />;
 };

+ 7 - 7
packages/app/src/components/Admin/ImportData/GrowiArchiveSection.jsx

@@ -95,7 +95,7 @@ class GrowiArchiveSection extends React.Component {
     const { t } = this.props;
     return (
       <div className="alert alert-warning mt-3">
-        {t('admin:importer_management.growi_settings.errors.different_versions')}
+        {t('importer_management.growi_settings.errors.different_versions')}
       </div>
     );
   }
@@ -110,17 +110,17 @@ class GrowiArchiveSection extends React.Component {
 
     return (
       <Fragment>
-        <h2>{t('admin:importer_management.import_growi_archive')}</h2>
+        <h2>{t('importer_management.import_growi_archive')}</h2>
         <div className="card well mb-4 small">
           <ul>
-            <li>{t('admin:importer_management.skip_username_and_email_when_overlapped')}</li>
-            <li>{t('admin:importer_management.prepare_new_account_for_migration')}</li>
+            <li>{t('importer_management.skip_username_and_email_when_overlapped')}</li>
+            <li>{t('importer_management.prepare_new_account_for_migration')}</li>
             <li>
               <a
-                href={`${t('admin:importer_management.admin_archive_data_import_guide_url')}`}
+                href={`${t('importer_management.admin_archive_data_import_guide_url')}`}
                 target="_blank"
                 rel="noopener noreferrer"
-              >{t('admin:importer_management.archive_data_import_detail')}
+              >{t('importer_management.archive_data_import_detail')}
               </a>
             </li>
           </ul>
@@ -154,7 +154,7 @@ GrowiArchiveSection.propTypes = {
 };
 
 const GrowiArchiveSectionWrapperFc = (props) => {
-  const { t } = useTranslation();
+  const { t } = useTranslation('admin');
 
   return <GrowiArchiveSection t={t} {...props} />;
 };

+ 21 - 21
packages/app/src/components/Admin/ImportData/ImportDataPageContents.jsx

@@ -29,7 +29,7 @@ class ImportDataPageContents extends React.Component {
           role="form"
         >
           <fieldset>
-            <h2 className="admin-setting-header">{t('admin:importer_management.import_from', { from: 'esa.io' })}</h2>
+            <h2 className="admin-setting-header">{t('importer_management.import_from', { from: 'esa.io' })}</h2>
             <table className="table table-bordered table-mapping">
               <thead>
                 <tr>
@@ -40,14 +40,14 @@ class ImportDataPageContents extends React.Component {
               </thead>
               <tbody>
                 <tr>
-                  <th>{t('Article')}</th>
+                  <th>{t('importer_management.article')}</th>
                   <th><i className="icon-arrow-right-circle text-success"></i></th>
-                  <th>{t('Page')}</th>
+                  <th>{t('importer_management.page')}</th>
                 </tr>
                 <tr>
-                  <th>{t('Category')}</th>
+                  <th>{t('importer_management.category')}</th>
                   <th><i className="icon-arrow-right-circle text-success"></i></th>
-                  <th>{t('Page Path')}</th>
+                  <th>{t('importer_management.page_path')}</th>
                 </tr>
                 <tr>
                   <th>{t('User')}</th>
@@ -59,7 +59,7 @@ class ImportDataPageContents extends React.Component {
 
             <div className="card well mb-0 small">
               <ul>
-                <li>{t('admin:importer_management.page_skip')}</li>
+                <li>{t('importer_management.page_skip')}</li>
               </ul>
             </div>
 
@@ -69,7 +69,7 @@ class ImportDataPageContents extends React.Component {
 
             <div className="form-group row">
               <label htmlFor="settingForm[importer:esa:team_name]" className="text-left text-md-right col-md-3 col-form-label">
-                {t('admin:importer_management.esa_settings.team_name')}
+                {t('importer_management.esa_settings.team_name')}
               </label>
               <div className="col-md-6">
                 <input
@@ -85,7 +85,7 @@ class ImportDataPageContents extends React.Component {
 
             <div className="form-group row">
               <label htmlFor="settingForm[importer:esa:access_token]" className="text-left text-md-right col-md-3 col-form-label">
-                {t('admin:importer_management.esa_settings.access_token')}
+                {t('importer_management.esa_settings.access_token')}
               </label>
               <div className="col-md-6">
                 <input
@@ -106,7 +106,7 @@ class ImportDataPageContents extends React.Component {
                   className="btn btn-primary btn-esa"
                   name="Esa"
                   onClick={adminImportContainer.esaHandleSubmit}
-                  value={t('admin:importer_management.import')}
+                  value={t('importer_management.import')}
                 />
                 <input type="button" className="btn btn-secondary" onClick={adminImportContainer.esaHandleSubmitUpdate} value={t('Update')} />
                 <span className="offset-0 offset-sm-1">
@@ -116,7 +116,7 @@ class ImportDataPageContents extends React.Component {
                     name="Esa"
                     className="btn btn-secondary btn-esa"
                     onClick={adminImportContainer.esaHandleSubmitTest}
-                    value={t('admin:importer_management.esa_settings.test_connection')}
+                    value={t('importer_management.esa_settings.test_connection')}
                   />
                 </span>
 
@@ -131,7 +131,7 @@ class ImportDataPageContents extends React.Component {
           role="form"
         >
           <fieldset>
-            <h2 className="admin-setting-header">{t('admin:importer_management.import_from', { from: 'Qiita:Team' })}</h2>
+            <h2 className="admin-setting-header">{t('importer_management.import_from', { from: 'Qiita:Team' })}</h2>
             <table className="table table-bordered table-mapping">
               <thead>
                 <tr>
@@ -142,17 +142,17 @@ class ImportDataPageContents extends React.Component {
               </thead>
               <tbody>
                 <tr>
-                  <th>{t('Article')}</th>
+                  <th>{t('importer_management.article')}</th>
                   <th><i className="icon-arrow-right-circle text-success"></i></th>
-                  <th>{t('Page')}</th>
+                  <th>{t('page')}</th>
                 </tr>
                 <tr>
-                  <th>{t('Tag')}</th>
+                  <th>{t('importer_management.tag')}</th>
                   <th></th>
                   <th>-</th>
                 </tr>
                 <tr>
-                  <th>{t('admin:importer_management.Directory_hierarchy_tag')}</th>
+                  <th>{t('importer_management.Directory_hierarchy_tag')}</th>
                   <th></th>
                   <th>(TBD)</th>
                 </tr>
@@ -165,7 +165,7 @@ class ImportDataPageContents extends React.Component {
             </table>
             <div className="card well mb-0 small">
               <ul>
-                <li>{t('admin:importer_management.page_skip')}</li>
+                <li>{t('importer_management.page_skip')}</li>
               </ul>
             </div>
 
@@ -174,7 +174,7 @@ class ImportDataPageContents extends React.Component {
             </div>
             <div className="form-group row">
               <label htmlFor="settingForm[importer:qiita:team_name]" className="text-left text-md-right col-md-3 col-form-label">
-                {t('admin:importer_management.qiita_settings.team_name')}
+                {t('importer_management.qiita_settings.team_name')}
               </label>
               <div className="col-md-6">
                 <input
@@ -189,7 +189,7 @@ class ImportDataPageContents extends React.Component {
 
             <div className="form-group row">
               <label htmlFor="settingForm[importer:qiita:access_token]" className="text-left text-md-right col-md-3 col-form-label">
-                {t('admin:importer_management.qiita_settings.access_token')}
+                {t('importer_management.qiita_settings.access_token')}
               </label>
               <div className="col-md-6">
                 <input
@@ -211,7 +211,7 @@ class ImportDataPageContents extends React.Component {
                   className="btn btn-primary btn-qiita"
                   name="Qiita"
                   onClick={adminImportContainer.qiitaHandleSubmit}
-                  value={t('admin:importer_management.import')}
+                  value={t('importer_management.import')}
                 />
                 <input type="button" className="btn btn-secondary" onClick={adminImportContainer.qiitaHandleSubmitUpdate} value={t('Update')} />
                 <span className="offset-0 offset-sm-1">
@@ -221,7 +221,7 @@ class ImportDataPageContents extends React.Component {
                     id="importFromQiita"
                     className="btn btn-secondary btn-qiita"
                     onClick={adminImportContainer.qiitaHandleSubmitTest}
-                    value={t('admin:importer_management.qiita_settings.test_connection')}
+                    value={t('importer_management.qiita_settings.test_connection')}
                   />
                 </span>
 
@@ -245,7 +245,7 @@ ImportDataPageContents.propTypes = {
 };
 
 const ImportDataPageContentsWrapperFc = (props) => {
-  const { t } = useTranslation();
+  const { t } = useTranslation('admin');
 
   const { adminImportContainer } = props;
 

+ 6 - 6
packages/app/src/components/Admin/MarkdownSetting/IndentForm.tsx

@@ -21,12 +21,12 @@ type Props = {
 }
 
 const IndentForm = (props: Props) => {
-  const { t } = useTranslation();
+  const { t } = useTranslation('admin');
 
   const onClickSubmit = useCallback(async(props) => {
     try {
       await props.adminMarkDownContainer.updateIndentSetting();
-      toastSuccess(t('toaster.update_successed', { target: t('admin:markdown_setting.indent_header') }));
+      toastSuccess(t('toaster.update_successed', { target: t('markdown_settings.indent_header') }));
     }
     catch (err) {
       toastError(err);
@@ -41,7 +41,7 @@ const IndentForm = (props: Props) => {
     return (
       <div className="col">
         <div>
-          <label htmlFor="adminPreferredIndentSize">{t('admin:markdown_setting.indent_options.indentSize')}</label>
+          <label htmlFor="adminPreferredIndentSize">{t('markdown_settings.indent_options.indentSize')}</label>
           <UncontrolledDropdown id="adminPreferredIndentSize">
             <DropdownToggle caret className="col-3 col-sm-2 col-md-5 col-lg-5 col-xl-3 text-right">
               <span className="float-left">
@@ -60,7 +60,7 @@ const IndentForm = (props: Props) => {
           </UncontrolledDropdown>
         </div>
         <p className="form-text text-muted">
-          {t('admin:markdown_setting.indent_options.indentSize_desc')}
+          {t('markdown_settings.indent_options.indentSize_desc')}
         </p>
       </div>
     );
@@ -70,7 +70,7 @@ const IndentForm = (props: Props) => {
     const { adminMarkDownContainer } = props;
     const { isIndentSizeForced } = adminMarkDownContainer.state;
 
-    const helpIndentInComment = { __html: t('admin:markdown_setting.indent_options.disallow_indent_change_desc') };
+    const helpIndentInComment = { __html: t('markdown_settings.indent_options.disallow_indent_change_desc') };
 
     return (
       <div className="col">
@@ -85,7 +85,7 @@ const IndentForm = (props: Props) => {
             }}
           />
           <label className="custom-control-label" htmlFor="isIndentSizeForced">
-            {t('admin:markdown_setting.indent_options.disallow_indent_change')}
+            {t('markdown_settings.indent_options.disallow_indent_change')}
           </label>
         </div>
         <p className="form-text text-muted" dangerouslySetInnerHTML={helpIndentInComment} />

+ 7 - 7
packages/app/src/components/Admin/MarkdownSetting/LineBreakForm.jsx

@@ -1,8 +1,8 @@
 /* eslint-disable react/no-danger */
 import React from 'react';
 
-import PropTypes from 'prop-types';
 import { useTranslation } from 'next-i18next';
+import PropTypes from 'prop-types';
 
 import AdminMarkDownContainer from '~/client/services/AdminMarkDownContainer';
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
@@ -27,7 +27,7 @@ class LineBreakForm extends React.Component {
 
     try {
       await this.props.adminMarkDownContainer.updateLineBreakSetting();
-      toastSuccess(t('toaster.update_successed', { target: t('admin:markdown_setting.lineBreak_header') }));
+      toastSuccess(t('toaster.update_successed', { target: t('markdown_settings.lineBreak_header') }));
     }
     catch (err) {
       toastError(err);
@@ -39,7 +39,7 @@ class LineBreakForm extends React.Component {
     const { t, adminMarkDownContainer } = this.props;
     const { isEnabledLinebreaks } = adminMarkDownContainer.state;
 
-    const helpLineBreak = { __html: t('admin:markdown_setting.lineBreak_options.enable_lineBreak_desc') };
+    const helpLineBreak = { __html: t('markdown_settings.lineBreak_options.enable_lineBreak_desc') };
 
     return (
       <div className="col">
@@ -52,7 +52,7 @@ class LineBreakForm extends React.Component {
             onChange={() => { adminMarkDownContainer.setState({ isEnabledLinebreaks: !isEnabledLinebreaks }) }}
           />
           <label className="custom-control-label" htmlFor="isEnabledLinebreaks">
-            {t('admin:markdown_setting.lineBreak_options.enable_lineBreak') }
+            {t('markdown_settings.lineBreak_options.enable_lineBreak') }
           </label>
         </div>
         <p className="form-text text-muted" dangerouslySetInnerHTML={helpLineBreak} />
@@ -64,7 +64,7 @@ class LineBreakForm extends React.Component {
     const { t, adminMarkDownContainer } = this.props;
     const { isEnabledLinebreaksInComments } = adminMarkDownContainer.state;
 
-    const helpLineBreakInComment = { __html: t('admin:markdown_setting.lineBreak_options.enable_lineBreak_for_comment_desc') };
+    const helpLineBreakInComment = { __html: t('markdown_settings.lineBreak_options.enable_lineBreak_for_comment_desc') };
 
     return (
       <div className="col">
@@ -77,7 +77,7 @@ class LineBreakForm extends React.Component {
             onChange={() => { adminMarkDownContainer.setState({ isEnabledLinebreaksInComments: !isEnabledLinebreaksInComments }) }}
           />
           <label className="custom-control-label" htmlFor="isEnabledLinebreaksInComments">
-            {t('admin:markdown_setting.lineBreak_options.enable_lineBreak_for_comment') }
+            {t('markdown_settings.lineBreak_options.enable_lineBreak_for_comment') }
           </label>
         </div>
         <p className="form-text text-muted" dangerouslySetInnerHTML={helpLineBreakInComment} />
@@ -102,7 +102,7 @@ class LineBreakForm extends React.Component {
 }
 
 const LineBreakFormFC = (props) => {
-  const { t } = useTranslation();
+  const { t } = useTranslation('admin');
   return <LineBreakForm t={t} {...props} />;
 };
 

+ 9 - 9
packages/app/src/components/Admin/MarkdownSetting/MarkDownSettingContents.tsx

@@ -22,7 +22,7 @@ type Props ={
 }
 
 const MarkDownSettingContents = React.memo((props: Props): JSX.Element => {
-  const { t } = useTranslation();
+  const { t } = useTranslation('admin');
   const { adminMarkDownContainer } = props;
 
   useEffect(() => {
@@ -43,30 +43,30 @@ const MarkDownSettingContents = React.memo((props: Props): JSX.Element => {
   return (
     <div data-testid="admin-markdown">
       {/* Line Break Setting */}
-      <h2 className="admin-setting-header">{t('admin:markdown_setting.lineBreak_header')}</h2>
+      <h2 className="admin-setting-header">{t('markdown_settings.lineBreak_header')}</h2>
       <Card className="card well my-3">
-        <CardBody className="px-0 py-2">{ t('admin:markdown_setting.lineBreak_desc') }</CardBody>
+        <CardBody className="px-0 py-2">{ t('markdown_settings.lineBreak_desc') }</CardBody>
       </Card>
       <LineBreakForm />
 
       {/* Indent Setting */}
-      <h2 className="admin-setting-header">{t('admin:markdown_setting.indent_header')}</h2>
+      <h2 className="admin-setting-header">{t('markdown_settings.indent_header')}</h2>
       <Card className="card well my-3">
-        <CardBody className="px-0 py-2">{t('admin:markdown_setting.indent_desc') }</CardBody>
+        <CardBody className="px-0 py-2">{t('markdown_settings.indent_desc') }</CardBody>
       </Card>
       <IndentForm />
 
       {/* Presentation Setting */}
-      <h2 className="admin-setting-header">{ t('admin:markdown_setting.presentation_header') }</h2>
+      <h2 className="admin-setting-header">{ t('markdown_settings.presentation_header') }</h2>
       <Card className="card well my-3">
-        <CardBody className="px-0 py-2">{ t('admin:markdown_setting.presentation_desc') }</CardBody>
+        <CardBody className="px-0 py-2">{ t('markdown_settings.presentation_desc') }</CardBody>
       </Card>
       <PresentationForm />
 
       {/* XSS Setting */}
-      <h2 className="admin-setting-header">{ t('admin:markdown_setting.xss_header') }</h2>
+      <h2 className="admin-setting-header">{ t('markdown_settings.xss_header') }</h2>
       <Card className="card well my-3">
-        <CardBody className="px-0 py-2">{ t('admin:markdown_setting.xss_desc') }</CardBody>
+        <CardBody className="px-0 py-2">{ t('markdown_settings.xss_desc') }</CardBody>
       </Card>
       <XssForm />
     </div>

+ 11 - 11
packages/app/src/components/Admin/MarkdownSetting/PresentationForm.jsx

@@ -25,7 +25,7 @@ class PresentationForm extends React.Component {
 
     try {
       await this.props.adminMarkDownContainer.updatePresentationSetting();
-      toastSuccess(t('toaster.update_successed', { target: t('admin:markdown_setting.presentation_header') }));
+      toastSuccess(t('toaster.update_successed', { target: t('markdown_settings.presentation_header') }));
     }
     catch (err) {
       toastError(err);
@@ -42,7 +42,7 @@ class PresentationForm extends React.Component {
       <fieldset className="form-group col-12 my-2">
 
         <label className="col-8 offset-4 col-form-label font-weight-bold text-left mt-3">
-          {t('admin:markdown_setting.presentation_options.page_break_setting')}
+          {t('markdown_settings.presentation_options.page_break_setting')}
         </label>
 
         <div className="form-group col-12 my-3">
@@ -57,13 +57,13 @@ class PresentationForm extends React.Component {
                   onChange={() => adminMarkDownContainer.switchPageBreakSeparator(1)}
                 />
                 <label className="custom-control-label w-100" htmlFor="pageBreakOption1">
-                  <p className="font-weight-bold">{ t('admin:markdown_setting.presentation_options.preset_one_separator') }</p>
+                  <p className="font-weight-bold">{ t('markdown_settings.presentation_options.preset_one_separator') }</p>
                   <div className="mt-3">
-                    { t('admin:markdown_setting.presentation_options.preset_one_separator_desc') }
+                    { t('markdown_settings.presentation_options.preset_one_separator_desc') }
                     <input
                       className="form-control"
                       type="text"
-                      value={t('admin:markdown_setting.presentation_options.preset_one_separator_value')}
+                      value={t('markdown_settings.presentation_options.preset_one_separator_value')}
                       readOnly
                     />
                   </div>
@@ -81,13 +81,13 @@ class PresentationForm extends React.Component {
                   onChange={() => adminMarkDownContainer.switchPageBreakSeparator(2)}
                 />
                 <label className="custom-control-label w-100" htmlFor="pageBreakOption2">
-                  <p className="font-weight-bold">{ t('admin:markdown_setting.presentation_options.preset_two_separator') }</p>
+                  <p className="font-weight-bold">{ t('markdown_settings.presentation_options.preset_two_separator') }</p>
                   <div className="mt-3">
-                    { t('admin:markdown_setting.presentation_options.preset_two_separator_desc') }
+                    { t('markdown_settings.presentation_options.preset_two_separator_desc') }
                     <input
                       className="form-control"
                       type="text"
-                      value={t('admin:markdown_setting.presentation_options.preset_two_separator_value')}
+                      value={t('markdown_settings.presentation_options.preset_two_separator_value')}
                       readOnly
                     />
                   </div>
@@ -104,9 +104,9 @@ class PresentationForm extends React.Component {
                   onChange={() => adminMarkDownContainer.switchPageBreakSeparator(3)}
                 />
                 <label className="custom-control-label w-100" htmlFor="pageBreakOption3">
-                  <p className="font-weight-bold">{ t('admin:markdown_setting.presentation_options.custom_separator') }</p>
+                  <p className="font-weight-bold">{ t('markdown_settings.presentation_options.custom_separator') }</p>
                   <div className="mt-3">
-                    { t('admin:markdown_setting.presentation_options.custom_separator_desc') }
+                    { t('markdown_settings.presentation_options.custom_separator_desc') }
                     <input
                       className="form-control"
                       defaultValue={pageBreakCustomSeparator}
@@ -133,7 +133,7 @@ PresentationForm.propTypes = {
 };
 
 const PresentationFormWrapperFC = (props) => {
-  const { t } = useTranslation();
+  const { t } = useTranslation('admin');
 
   return <PresentationForm t={t} {...props} />;
 };

+ 5 - 5
packages/app/src/components/Admin/MarkdownSetting/WhiteListInput.jsx

@@ -37,9 +37,9 @@ class WhiteListInput extends React.Component {
       <>
         <div className="mt-4">
           <div className="d-flex justify-content-between">
-            {t('admin:markdown_setting.xss_options.tag_names')}
+            {t('markdown_settings.xss_options.tag_names')}
             <p id="btn-import-tags" className="btn btn-sm btn-primary mb-0" onClick={this.onClickRecommendTagButton}>
-              {t('admin:markdown_setting.xss_options.import_recommended', { target: 'Tags' })}
+              {t('markdown_settings.xss_options.import_recommended', { target: 'Tags' })}
             </p>
           </div>
           <textarea
@@ -54,9 +54,9 @@ class WhiteListInput extends React.Component {
         </div>
         <div className="mt-4">
           <div className="d-flex justify-content-between">
-            {t('admin:markdown_setting.xss_options.tag_attributes')}
+            {t('markdown_settings.xss_options.tag_attributes')}
             <p id="btn-import-tags" className="btn btn-sm btn-primary mb-0" onClick={this.onClickRecommendAttrButton}>
-              {t('admin:markdown_setting.xss_options.import_recommended', { target: 'Attrs' })}
+              {t('markdown_settings.xss_options.import_recommended', { target: 'Attrs' })}
             </p>
           </div>
           <textarea
@@ -83,7 +83,7 @@ WhiteListInput.propTypes = {
 };
 
 const PresentationFormWrapperFC = (props) => {
-  const { t } = useTranslation();
+  const { t } = useTranslation('admin');
 
   return <WhiteListInput t={t} {...props} />;
 };

+ 9 - 9
packages/app/src/components/Admin/MarkdownSetting/XssForm.jsx

@@ -28,7 +28,7 @@ class XssForm extends React.Component {
 
     try {
       await this.props.adminMarkDownContainer.updateXssSetting();
-      toastSuccess(t('toaster.update_successed', { target: t('admin:markdown_setting.xss_header') }));
+      toastSuccess(t('toaster.update_successed', { target: t('markdown_settings.xss_header') }));
     }
     catch (err) {
       toastError(err);
@@ -54,9 +54,9 @@ class XssForm extends React.Component {
                 onChange={() => { adminMarkDownContainer.setState({ xssOption: 1 }) }}
               />
               <label className="custom-control-label w-100" htmlFor="xssOption1">
-                <p className="font-weight-bold">{t('admin:markdown_setting.xss_options.remove_all_tags')}</p>
+                <p className="font-weight-bold">{t('markdown_settings.xss_options.remove_all_tags')}</p>
                 <div className="mt-4">
-                  {t('admin:markdown_setting.xss_options.remove_all_tags_desc')}
+                  {t('markdown_settings.xss_options.remove_all_tags_desc')}
                 </div>
               </label>
             </div>
@@ -73,10 +73,10 @@ class XssForm extends React.Component {
                 onChange={() => { adminMarkDownContainer.setState({ xssOption: 2 }) }}
               />
               <label className="custom-control-label w-100" htmlFor="xssOption2">
-                <p className="font-weight-bold">{t('admin:markdown_setting.xss_options.recommended_setting')}</p>
+                <p className="font-weight-bold">{t('markdown_settings.xss_options.recommended_setting')}</p>
                 <div className="mt-4">
                   <div className="d-flex justify-content-between">
-                    {t('admin:markdown_setting.xss_options.tag_names')}
+                    {t('markdown_settings.xss_options.tag_names')}
                   </div>
                   <textarea
                     className="form-control xss-list"
@@ -89,7 +89,7 @@ class XssForm extends React.Component {
                 </div>
                 <div className="mt-4">
                   <div className="d-flex justify-content-between">
-                    {t('admin:markdown_setting.xss_options.tag_attributes')}
+                    {t('markdown_settings.xss_options.tag_attributes')}
                   </div>
                   <textarea
                     className="form-control xss-list"
@@ -115,7 +115,7 @@ class XssForm extends React.Component {
                 onChange={() => { adminMarkDownContainer.setState({ xssOption: 3 }) }}
               />
               <label className="custom-control-label w-100" htmlFor="xssOption3">
-                <p className="font-weight-bold">{t('admin:markdown_setting.xss_options.custom_whitelist')}</p>
+                <p className="font-weight-bold">{t('markdown_settings.xss_options.custom_whitelist')}</p>
                 <WhiteListInput customizable />
               </label>
             </div>
@@ -144,7 +144,7 @@ class XssForm extends React.Component {
                   onChange={adminMarkDownContainer.switchEnableXss}
                 />
                 <label className="custom-control-label w-100" htmlFor="XssEnable">
-                  {t('admin:markdown_setting.xss_options.enable_xss_prevention')}
+                  {t('markdown_settings.xss_options.enable_xss_prevention')}
                 </label>
               </div>
             </div>
@@ -168,7 +168,7 @@ XssForm.propTypes = {
 };
 
 const XssFormWrapperFC = (props) => {
-  const { t } = useTranslation();
+  const { t } = useTranslation('admin');
 
   return <XssForm t={t} {...props} />;
 };

+ 7 - 7
packages/app/src/components/Admin/Security/SecuritySetting.jsx

@@ -1,8 +1,8 @@
 /* eslint-disable react/no-danger */
 import React from 'react';
 
-import PropTypes from 'prop-types';
 import { useTranslation } from 'next-i18next';
+import PropTypes from 'prop-types';
 import { Collapse } from 'reactstrap';
 
 import AdminGeneralSecurityContainer from '~/client/services/AdminGeneralSecurityContainer';
@@ -340,21 +340,21 @@ class SecuritySetting extends React.Component {
           <table className="table table-bordered col-lg-9 mb-5">
             <thead>
               <tr>
-                <th scope="col">{ t('scope_of_page_disclosure') }</th>
-                <th scope="col">{ t('set_point') }</th>
+                <th scope="col">{ t('security_settings.scope_of_page_disclosure') }</th>
+                <th scope="col">{ t('security_settings.set_point') }</th>
               </tr>
             </thead>
             <tbody>
               <tr>
-                <th scope="row">{ t('Public') }</th>
+                <th scope="row">{ t('public') }</th>
                 <td>{ t('security_settings.always_displayed') }</td>
               </tr>
               <tr>
-                <th scope="row">{ t('Anyone with the link') }</th>
+                <th scope="row">{ t('anyone_with_the_link') }</th>
                 <td>{ t('security_settings.always_hidden') }</td>
               </tr>
               <tr>
-                <th scope="row">{ t('Only me') }</th>
+                <th scope="row">{ t('only_me') }</th>
                 <td>
                   <div className="custom-control custom-switch custom-checkbox-success">
                     <input
@@ -371,7 +371,7 @@ class SecuritySetting extends React.Component {
                 </td>
               </tr>
               <tr>
-                <th scope="row">{ t('Only inside the group') }</th>
+                <th scope="row">{ t('only_inside_the_group') }</th>
                 <td>
                   <div className="custom-control custom-switch custom-checkbox-success">
                     <input

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

@@ -54,7 +54,7 @@ const generateGroupIdToChildGroupsMap = (childUserGroups: IUserGroupHasId[]): Re
 
 
 export const UserGroupTable: FC<Props> = (props: Props) => {
-  const { t } = useTranslation();
+  const { t } = useTranslation('admin');
 
   /*
    * State
@@ -135,7 +135,7 @@ export const UserGroupTable: FC<Props> = (props: Props) => {
             <th>{t('Name')}</th>
             <th>{t('Description')}</th>
             <th>{t('User')}</th>
-            <th>{t('ChildUserGroup')}</th>
+            <th>{t('user_group_management.child_user_group')}</th>
             <th style={{ width: 100 }}>{t('Created')}</th>
             <th style={{ width: 70 }}></th>
           </tr>

+ 1 - 0
packages/app/src/components/Admin/UserGroupDetail/UserGroupDetailPage.module.scss

@@ -0,0 +1 @@
+@use '~/styles/molecules/page_list';

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

@@ -21,6 +21,7 @@ import {
   useSWRxSelectableParentUserGroups, useSWRxSelectableChildUserGroups, useSWRxAncestorUserGroups,
 } from '~/stores/user-group';
 
+import styles from './UserGroupDetailPage.module.scss';
 
 const UserGroupPageList = dynamic(() => import('./UserGroupPageList'), { ssr: false });
 const UserGroupUserTable = dynamic(() => import('./UserGroupUserTable'), { ssr: false });
@@ -412,7 +413,7 @@ const UserGroupDetailPage = (props: Props): JSX.Element => {
       />
 
       <h2 className="admin-setting-header mt-4">{t('Page')}</h2>
-      <div className="page-list">
+      <div className={`page-list ${styles['page-list']}`}>
         <UserGroupPageList userGroupId={currentUserGroupId} relatedPages={userGroupPages} />
       </div>
     </div>

+ 1 - 1
packages/app/src/components/Admin/UserManagement.jsx

@@ -224,7 +224,7 @@ UserManagement.propTypes = {
 };
 
 const UserManagementFc = (props) => {
-  const { t } = useTranslation();
+  const { t } = useTranslation('admin');
   return <UserManagement t={t} {...props} />;
 };
 

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

@@ -62,7 +62,7 @@ class UserMenu extends React.Component {
     return (
       <Fragment>
         <li className="dropdown-divider"></li>
-        <li className="dropdown-header">{t('status')}</li>
+        <li className="dropdown-header">{t('user_management.status')}</li>
         <li>
           {(user.status === 1 || user.status === 3) && <StatusActivateButton user={user} />}
           {user.status === 2 && <StatusSuspendedMenuItem user={user} />}
@@ -116,7 +116,7 @@ class UserMenu extends React.Component {
 }
 
 const UserMenuWrapperFC = (props) => {
-  const { t } = useTranslation();
+  const { t } = useTranslation('admin');
   return <UserMenu t={t} {...props} />;
 };
 

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

@@ -2,8 +2,8 @@ import React, { Fragment } from 'react';
 
 import { UserPicture } from '@growi/ui';
 import dateFnsFormat from 'date-fns/format';
-import PropTypes from 'prop-types';
 import { useTranslation } from 'next-i18next';
+import PropTypes from 'prop-types';
 
 import AdminUsersContainer from '~/client/services/AdminUsersContainer';
 
@@ -99,7 +99,7 @@ class UserTable extends React.Component {
                 <th>
                   <div className="d-flex align-items-center">
                     <div className="mr-3">
-                      {t('status')}
+                      {t('user_management.status')}
                     </div>
                     <SortIcons
                       isSelected={adminUsersContainer.state.sort === 'status'}
@@ -222,7 +222,7 @@ UserTable.propTypes = {
 };
 
 const UserTableWrapperFC = (props) => {
-  const { t } = useTranslation();
+  const { t } = useTranslation('admin');
   return <UserTable t={t} {...props} />;
 };
 

+ 1 - 0
packages/app/src/components/IdenticalPathPage.module.scss

@@ -1,4 +1,5 @@
 @use '~/styles/molecules/page-accessories-control';
+@use '~/styles/molecules/page_list';
 
 .grw-page-accessories-control :global {
   @extend %grw-page-accessories-control;

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

@@ -89,7 +89,7 @@ export const IdenticalPathPage = (): JSX.Element => {
 
         <IdenticalPathAlert path={currentPath} />
 
-        <div className="page-list">
+        <div className={`page-list ${styles['page-list']}`}>
           <ul className="page-list-ul list-group list-group-flush">
             {injectedPages.map((pageWithMeta) => {
               const pageId = pageWithMeta.data._id;

+ 16 - 0
packages/app/src/components/InAppNotification/InAppNotificationElm.tsx

@@ -119,6 +119,22 @@ const InAppNotificationElm: FC<Props> = (props: Props) => {
       actionMsg = 'reverted';
       actionIcon = 'icon-action-undo';
       break;
+    case 'PAGE_RECURSIVELY_RENAME':
+      actionMsg = 'renamed under';
+      actionIcon = 'icon-action-redo';
+      break;
+    case 'PAGE_RECURSIVELY_DELETE':
+      actionMsg = 'deleted under';
+      actionIcon = 'icon-trash';
+      break;
+    case 'PAGE_RECURSIVELY_DELETE_COMPLETELY':
+      actionMsg = 'deleted completely under';
+      actionIcon = 'icon-fire';
+      break;
+    case 'PAGE_RECURSIVELY_REVERT':
+      actionMsg = 'reverted under';
+      actionIcon = 'icon-action-undo';
+      break;
     case 'COMMENT_CREATE':
       actionMsg = 'commented on';
       actionIcon = 'icon-bubble';

+ 2 - 0
packages/app/src/components/Layout/BasicLayout.tsx

@@ -18,6 +18,7 @@ const PageDuplicateModal = dynamic(() => import('../PageDuplicateModal'), { ssr:
 const PageDeleteModal = dynamic(() => import('../PageDeleteModal'), { ssr: false });
 const PageRenameModal = dynamic(() => import('../PageRenameModal'), { ssr: false });
 const PagePresentationModal = dynamic(() => import('../PagePresentationModal'), { ssr: false });
+const PageAccessoriesModal = dynamic(() => import('../PageAccessoriesModal'), { ssr: false });
 
 
 type Props = {
@@ -54,6 +55,7 @@ export const BasicLayout = ({
       <PageDeleteModal />
       <PageRenameModal />
       <PagePresentationModal />
+      <PageAccessoriesModal />
       {/* <HotkeysManager /> */}
 
       <ShortcutsModal />

+ 24 - 24
packages/app/src/components/PageAccessoriesModal.tsx

@@ -6,8 +6,7 @@ import {
 } from 'reactstrap';
 
 
-import AppContainer from '~/client/services/AppContainer';
-import { useIsGuestUser, useIsSharedUser } from '~/stores/context';
+import { useDisableLinkSharing, useIsGuestUser, useIsSharedUser } from '~/stores/context';
 import { usePageAccessoriesModal, PageAccessoriesModalContents } from '~/stores/modal';
 
 import { CustomNavTab } from './CustomNavigation/CustomNav';
@@ -19,21 +18,10 @@ import ShareLinkIcon from './Icons/ShareLinkIcon';
 import PageAttachment from './PageAttachment';
 import PageHistory from './PageHistory';
 import ShareLink from './ShareLink/ShareLink';
-import { withUnstatedContainers } from './UnstatedUtils';
 
 import styles from './PageAccessoriesModal.module.scss';
 
-type Props = {
-  appContainer: AppContainer,
-  isLinkSharingDisabled: boolean,
-}
-
-const PageAccessoriesModal = (props: Props): JSX.Element => {
-  const {
-    appContainer,
-  } = props;
-
-  const isLinkSharingDisabled = appContainer.config.disableLinkSharing;
+const PageAccessoriesModal = (): JSX.Element => {
 
   const { t } = useTranslation();
 
@@ -42,6 +30,7 @@ const PageAccessoriesModal = (props: Props): JSX.Element => {
 
   const { data: isSharedUser } = useIsSharedUser();
   const { data: isGuestUser } = useIsGuestUser();
+  const { data: isLinkSharingDisabled } = useDisableLinkSharing();
 
   const { data: status, mutate, close } = usePageAccessoriesModal();
 
@@ -59,29 +48,45 @@ const PageAccessoriesModal = (props: Props): JSX.Element => {
   }, [mutate, status]);
 
   const navTabMapping = useMemo(() => {
+    const isOpened = status == null ? false : status.isOpened;
     return {
       [PageAccessoriesModalContents.PageHistory]: {
         Icon: HistoryIcon,
-        Content: () => <PageHistory />,
+        Content: () => {
+          if (!isOpened) {
+            return <></>;
+          }
+          return <PageHistory />;
+        },
         i18n: t('History'),
         index: 0,
         isLinkEnabled: () => !isGuestUser && !isSharedUser,
       },
       [PageAccessoriesModalContents.Attachment]: {
         Icon: AttachmentIcon,
-        Content: () => <PageAttachment />,
+        Content: () => {
+          if (!isOpened) {
+            return <></>;
+          }
+          return <PageAttachment />;
+        },
         i18n: t('attachment_data'),
         index: 1,
       },
       [PageAccessoriesModalContents.ShareLink]: {
         Icon: ShareLinkIcon,
-        Content: () => <ShareLink />,
+        Content: () => {
+          if (!isOpened) {
+            return <></>;
+          }
+          return <ShareLink />;
+        },
         i18n: t('share_links.share_link_management'),
         index: 2,
         isLinkEnabled: () => !isGuestUser && !isSharedUser && !isLinkSharingDisabled,
       },
     };
-  }, [t, isGuestUser, isSharedUser, isLinkSharingDisabled]);
+  }, [status, t, isGuestUser, isSharedUser, isLinkSharingDisabled]);
 
   const buttons = useMemo(() => (
     <div className="d-flex flex-nowrap">
@@ -128,9 +133,4 @@ const PageAccessoriesModal = (props: Props): JSX.Element => {
   );
 };
 
-/**
- * Wrapper component for using unstated
- */
-const PageAccessoriesModalWrapper = withUnstatedContainers(PageAccessoriesModal, [AppContainer]);
-
-export default PageAccessoriesModalWrapper;
+export default PageAccessoriesModal;

+ 8 - 7
packages/app/src/components/PageEditor/CodeMirrorEditor.jsx

@@ -25,7 +25,8 @@ import geu from './GridEditorUtil';
 // import HandsontableModal from './HandsontableModal';
 // import LinkEditModal from './LinkEditModal';
 import mdu from './MarkdownDrawioUtil';
-import mlu from './MarkdownLinkUtil';
+import markdownLinkUtil from './MarkdownLinkUtil';
+import markdownListUtil from './MarkdownListUtil';
 import MarkdownTableInterceptor from './MarkdownTableInterceptor';
 import mtu from './MarkdownTableUtil';
 import pasteHelper from './PasteHelper';
@@ -521,7 +522,7 @@ class CodeMirrorEditor extends AbstractEditor {
     interceptorManager.process('preHandleEnter', context)
       .then(() => {
         if (context.handlers.length === 0) {
-          codemirror.commands.newlineAndIndentContinueMarkdownList(this.getCodeMirror());
+          markdownListUtil.newlineAndIndentContinueMarkdownList(this);
         }
       });
   }
@@ -548,7 +549,7 @@ class CodeMirrorEditor extends AbstractEditor {
     const hasLinkClass = additionalClassSet.has(MARKDOWN_LINK_ACTIVATED_CLASS);
 
     const isInTable = mtu.isInTable(editor);
-    const isInLink = mlu.isInLink(editor);
+    const isInLink = markdownLinkUtil.isInLink(editor);
 
     if (!hasCustomClass && isInTable) {
       additionalClassSet.add(MARKDOWN_TABLE_ACTIVATED_CLASS);
@@ -794,7 +795,7 @@ class CodeMirrorEditor extends AbstractEditor {
   }
 
   showLinkEditHandler() {
-    // this.linkEditModal.current.show(mlu.getMarkdownLink(this.getCodeMirror()));
+    // this.linkEditModal.current.show(markdownLinkUtil.getMarkdownLink(this.getCodeMirror()));
   }
 
   showHandsonTableHandler() {
@@ -1060,9 +1061,9 @@ class CodeMirrorEditor extends AbstractEditor {
         /> */}
         {/* <LinkEditModal
           ref={this.linkEditModal}
-          onSave={(linkText) => { return mlu.replaceFocusedMarkdownLinkWithEditor(this.getCodeMirror(), linkText) }}
-        /> */}
-        {/* <HandsontableModal
+          onSave={(linkText) => { return markdownLinkUtil.replaceFocusedMarkdownLinkWithEditor(this.getCodeMirror(), linkText) }}
+        />
+        <HandsontableModal
           ref={this.handsontableModal}
           onSave={(table) => { return mtu.replaceFocusedMarkdownTableWithEditor(this.getCodeMirror(), table) }}
           autoFormatMarkdownTable={this.props.editorSettings.autoFormatMarkdownTable}

+ 37 - 0
packages/app/src/components/PageEditor/CodeMirrorEditor.module.scss

@@ -90,4 +90,41 @@
       }
     }
   }
+
+  // cheat sheat
+  .overlay.overlay-gfm-cheatsheet {
+    align-items: flex-end;
+    justify-content: flex-end;
+
+    pointer-events: none;
+
+    .card.gfm-cheatsheet {
+      box-shadow: unset;
+      opacity: 0.6;
+      .card-body {
+        min-width: 30em;
+        padding-bottom: 0;
+        font-family: monospace;
+        color: bs.$text-muted;
+      }
+      ul > li {
+        list-style: none;
+      }
+    }
+
+    .gfm-cheatsheet-modal-link {
+      color: bs.$text-muted;
+      pointer-events: all;
+      cursor: pointer;
+      background-color: transparent;
+      border: none;
+
+      opacity: 0.6;
+
+      &:hover,
+      &:focus {
+        opacity: 1;
+      }
+    }
+  }
 }

+ 66 - 20
packages/app/src/styles/_editor-attachment.scss → packages/app/src/components/PageEditor/Editor.module.scss

@@ -1,19 +1,29 @@
-@import 'editor-overlay';
+@use '~/styles/mixins' as ms;
+@use '~/styles/bootstrap/init' as bs;
+
+
+.editor-container :global {
+  // overlay in .editor-container
+  .overlay {
+    position: absolute;
+    top: 0;
+    right: 0;
+    bottom: 0;
+    left: 0;
+    z-index: 7; // forward than .CodeMirror-vscrollbar
+    display: flex;
+    align-items: center;
+    justify-content: center;
+  }
+
+  // loading keymap
+  @include ms.overlay-processing-style(overlay-loading-keymap, 2.5em, 0.3em);
 
-.editor-container {
   // for Dropzone
   .dropzone {
-    @mixin insertSimpleLineIcons($code) {
-      &:before {
-        margin-right: 0.2em;
-        font-family: 'simple-line-icons';
-        content: $code;
-      }
-    }
-
     position: relative; // against .overlay position: absolute
 
-    @include overlay-processing-style(overlay-dropzone-active, 2.5em, 0.5em);
+    @include ms.overlay-processing-style(overlay-dropzone-active, 2.5em, 0.5em);
 
     // unuploadable or rejected
     &.dropzone-unuploadable,
@@ -22,14 +32,14 @@
         background: rgba(200, 200, 200, 0.8);
 
         .overlay-content {
-          color: $gray-700;
+          color: bs.$gray-300;
         }
       }
     }
 
     // uploading
     &.dropzone-uploading {
-      @include overlay-processing-style(overlay-dropzone-active, 2.5em, 0.5em);
+      @include ms.overlay-processing-style(overlay-dropzone-active, 2.5em, 0.5em);
     }
 
     // unuploadable
@@ -37,7 +47,7 @@
       .overlay.overlay-dropzone-active {
         .overlay-content {
           // insert content
-          @include insertSimpleLineIcons('\e617'); // icon-exclamation
+          @include ms.insertSimpleLineIcons('\e617'); // icon-exclamation
 
           &:after {
             content: 'File uploading is disabled';
@@ -51,18 +61,18 @@
       // accepted
       &.dropzone-accepted:not(.dropzone-rejected) {
         .overlay.overlay-dropzone-active {
-          border: 4px dashed $gray-300;
+          border: 4px dashed bs.$gray-300;
 
           .overlay-content {
             // insert content
-            @include insertSimpleLineIcons('\e084'); // icon-cloud-upload
+            @include ms.insertSimpleLineIcons('\e084'); // icon-cloud-upload
 
             &:after {
               content: 'Drop here to upload';
             }
 
             // style
-            color: $secondary;
+            color: bs.$secondary;
             background: rgba(200, 200, 200, 0.8);
           }
         }
@@ -73,7 +83,7 @@
         .overlay.overlay-dropzone-active {
           .overlay-content {
             // insert content
-            @include insertSimpleLineIcons('\e032'); // icon-picture
+            @include ms.insertSimpleLineIcons('\e032'); // icon-picture
 
             &:after {
               content: 'Only an image file is allowed';
@@ -87,7 +97,7 @@
         .overlay.overlay-dropzone-active {
           .overlay-content {
             // insert content
-            @include insertSimpleLineIcons('\e617'); // icon-exclamation
+            @include ms.insertSimpleLineIcons('\e617'); // icon-exclamation
 
             &:after {
               content: 'Only 1 file is allowed';
@@ -106,7 +116,7 @@
     padding-bottom: 3px;
     font-size: small;
     border: none;
-    border-top: 1px dotted $gray-300;
+    border-top: 1px dotted bs.$gray-300;
     border-bottom: none;
 
     &:hover,
@@ -114,4 +124,40 @@
       border-bottom: none;
     }
   }
+
+  // for Navbar editor
+  .navbar-editor {
+    height: 30px;
+    padding: 0;
+
+    border-bottom: 1px solid transparent;
+
+    li {
+      display: inline-block;
+      i {
+        font-size: 16px;
+      }
+    }
+
+    button {
+      padding: 0px;
+      margin: 0 2px;
+      font-size: 1rem;
+      line-height: 1;
+      background-color: transparent;
+      border: none;
+    }
+
+    img {
+      vertical-align: bottom;
+    }
+  }
+}
+
+.modal-gfm-cheatsheet :global {
+  .modal-body {
+    .hljs {
+      font-family: bs.$font-family-monospace;
+    }
+  }
 }

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

@@ -20,6 +20,8 @@ import CodeMirrorEditor from './CodeMirrorEditor';
 import pasteHelper from './PasteHelper';
 import TextAreaEditor from './TextAreaEditor';
 
+import styles from './Editor.module.scss';
+
 type EditorPropsType = {
   value?: string,
   isGfmMode?: boolean,
@@ -252,7 +254,7 @@ const Editor = React.forwardRef((props: EditorPropsType, ref): JSX.Element => {
     };
 
     return (
-      <Modal isOpen={isCheatsheetModalShown} toggle={hideCheatsheetModal} className="modal-gfm-cheatsheet">
+      <Modal isOpen={isCheatsheetModalShown} toggle={hideCheatsheetModal} className={`${styles['modal-gfm-cheatsheet']}`} >
         <ModalHeader tag="h4" toggle={hideCheatsheetModal} className="bg-primary text-light">
           <i className="icon-fw icon-question" />Markdown help
         </ModalHeader>
@@ -275,7 +277,7 @@ const Editor = React.forwardRef((props: EditorPropsType, ref): JSX.Element => {
 
   return (
     <>
-      <div style={flexContainer} className="editor-container">
+      <div style={flexContainer} className={`editor-container ${styles['editor-container']}`} >
         <Dropzone
           ref={dropzoneRef}
           accept={getAcceptableType()}

+ 4 - 2
packages/app/src/components/PageEditor/GridEditModal.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';
@@ -10,6 +10,8 @@ import BootstrapGrid from '~/client/models/BootstrapGrid';
 
 import geu from './GridEditorUtil';
 
+import styles from './GridEditModal.module.scss';
+
 const resSizes = BootstrapGrid.ResponsiveSize;
 const resSizeObj = {
   [resSizes.XS_SIZE]: { displayText: 'grid_edit.smart_no' },
@@ -188,7 +190,7 @@ class GridEditModal extends React.Component {
   render() {
     const { t } = this.props;
     return (
-      <Modal isOpen={this.state.show} toggle={this.cancel} size="xl" className="grw-grid-edit-modal">
+      <Modal isOpen={this.state.show} toggle={this.cancel} size="xl" className={`${styles['grw-grid-edit-modal']}`}>
         <ModalHeader tag="h4" toggle={this.cancel} className="bg-primary text-light">
           {t('grid_edit.create_bootstrap_4_grid')}
         </ModalHeader>

+ 43 - 0
packages/app/src/components/PageEditor/GridEditModal.module.scss

@@ -0,0 +1,43 @@
+@use '~/styles/bootstrap/init' as bs;
+
+.grw-grid-edit-modal {
+  .desktop-preview,
+  .tablet-preview,
+  .mobile-preview {
+    .row {
+      height: 140px;
+      margin: 0px;
+    }
+  }
+  .desktop-preview {
+    .row {
+      div {
+        padding: 0px;
+      }
+    }
+  }
+
+  .tablet-preview {
+    .row {
+      div {
+        padding: 0px;
+      }
+    }
+  }
+
+  .mobile-preview {
+    width: 75%;
+    .row {
+      div {
+        padding: 0px;
+      }
+    }
+  }
+
+  .grid-division-menu {
+    width: 60vw;
+    @include bs.media-breakpoint-down(lg) {
+      width: 80vw;
+    }
+  }
+}

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

@@ -39,7 +39,7 @@ const Preview = React.forwardRef((props: Props, ref: RefObject<HTMLDivElement>):
         }
       }}
     >
-      <ReactMarkdown {...rendererOptions} >{markdown || ''}</ReactMarkdown>
+      <ReactMarkdown {...rendererOptions} className='wiki'>{markdown || ''}</ReactMarkdown>
     </div>
   );
 

+ 1 - 0
packages/app/src/components/PageList/PageList.module.scss

@@ -0,0 +1 @@
+@use '~/styles/molecules/page_list';

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

@@ -9,6 +9,7 @@ import { ForceHideMenuItems } from '../Common/Dropdown/PageItemControl';
 
 import { PageListItemL } from './PageListItemL';
 
+import styles from './PageList.module.scss';
 
 type Props<M extends IPageInfoForEntity> = {
   pages: IPageWithMeta<M>[],
@@ -54,7 +55,7 @@ const PageList = (props: Props<IPageInfoForEntity>): JSX.Element => {
   }
 
   return (
-    <div className="page-list">
+    <div className={`page-list ${styles['page-list']}`}>
       <ul className="page-list-ul list-group list-group-flush">
         {pageList}
       </ul>

+ 1 - 0
packages/app/src/components/SearchPage2/SearchPageBase.module.scss

@@ -0,0 +1 @@
+@use '~/styles/molecules/page_list';

+ 2 - 1
packages/app/src/components/SearchPage2/SearchPageBase.tsx

@@ -16,6 +16,7 @@ import { usePageTreeTermManager } from '~/stores/page-listing';
 import { ForceHideMenuItems } from '../Common/Dropdown/PageItemControl';
 import { SearchResultList } from '../SearchPage/SearchResultList';
 
+import styles from './SearchPageBase.module.scss';
 
 // https://regex101.com/r/brrkBu/1
 const highlightKeywordsSplitter = new RegExp('"[^"]+"|[^\u{20}\u{3000}]+', 'ug');
@@ -176,7 +177,7 @@ const SearchPageBaseSubstance: ForwardRefRenderFunction<ISelectableAll & IReturn
                 </div>
 
                 { pages.length > 0 && (
-                  <div className="page-list px-md-4">
+                  <div className={`page-list ${styles['page-list']} px-md-4`}>
                     <SearchResultList
                       ref={searchResultListRef}
                       pages={pages}

+ 41 - 0
packages/app/src/components/UsersHomePageFooter.tsx

@@ -0,0 +1,41 @@
+import React from 'react';
+
+import { useTranslation } from 'next-i18next';
+
+import { RecentlyCreatedIcon } from '~/components/Icons/RecentlyCreatedIcon';
+import BookmarkList from '~/components/PageList/BookmarkList';
+import { RecentCreated } from '~/components/RecentCreated/RecentCreated';
+import styles from '~/components/UsersHomePageFooter.module.scss';
+
+export type UsersHomePageFooterProps = {
+  creatorId: string,
+}
+
+export const UsersHomePageFooter = (props: UsersHomePageFooterProps): JSX.Element => {
+  const { t } = useTranslation();
+  const { creatorId } = props;
+
+  return (
+    <div className={`container-lg user-page-footer py-5 ${styles['user-page-footer']}`}>
+      <div className="grw-user-page-list-m d-edit-none">
+        <h2 id="bookmarks-list" className="grw-user-page-header border-bottom pb-2 mb-3">
+          <i style={{ fontSize: '1.3em' }} className="fa fa-fw fa-bookmark-o"></i>
+          {t('footer.bookmarks')}
+        </h2>
+        <div id="user-bookmark-list" className="page-list">
+          <BookmarkList userId={creatorId} />
+        </div>
+      </div>
+      <div className="grw-user-page-list-m mt-5 d-edit-none">
+        <h2 id="recently-created-list" className="grw-user-page-header border-bottom pb-2 mb-3">
+          <i id="recent-created-icon" className="mr-1"><RecentlyCreatedIcon /></i>
+          {t('footer.recently_created')}
+        </h2>
+        <div id="user-created-list" className="page-list">
+          <RecentCreated userId={creatorId} />
+        </div>
+      </div>
+    </div>
+  );
+
+};

+ 16 - 0
packages/app/src/interfaces/activity.ts

@@ -46,6 +46,10 @@ const ACTION_PAGE_DELETE = 'PAGE_DELETE';
 const ACTION_PAGE_DELETE_COMPLETELY = 'PAGE_DELETE_COMPLETELY';
 const ACTION_PAGE_REVERT = 'PAGE_REVERT';
 const ACTION_PAGE_EMPTY_TRASH = 'PAGE_EMPTY_TRASH';
+const ACTION_PAGE_RECURSIVELY_RENAME = 'PAGE_RECURSIVELY_RENAME';
+const ACTION_PAGE_RECURSIVELY_DELETE = 'PAGE_RECURSIVELY_DELETE';
+const ACTION_PAGE_RECURSIVELY_DELETE_COMPLETELY = 'PAGE_RECURSIVELY_DELETE_COMPLETELY';
+const ACTION_PAGE_RECURSIVELY_REVERT = 'PAGE_RECURSIVELY_REVERT';
 const ACTION_PAGE_SUBSCRIBE = 'PAGE_SUBSCRIBE';
 const ACTION_PAGE_UNSUBSCRIBE = 'PAGE_UNSUBSCRIBE';
 const ACTION_PAGE_EXPORT = 'PAGE_EXPORT';
@@ -225,6 +229,10 @@ export const SupportedAction = {
   ACTION_PAGE_DELETE_COMPLETELY,
   ACTION_PAGE_REVERT,
   ACTION_PAGE_EMPTY_TRASH,
+  ACTION_PAGE_RECURSIVELY_RENAME,
+  ACTION_PAGE_RECURSIVELY_DELETE,
+  ACTION_PAGE_RECURSIVELY_DELETE_COMPLETELY,
+  ACTION_PAGE_RECURSIVELY_REVERT,
   ACTION_PAGE_SUBSCRIBE,
   ACTION_PAGE_UNSUBSCRIBE,
   ACTION_PAGE_EXPORT,
@@ -354,6 +362,10 @@ export const EssentialActionGroup = {
   ACTION_PAGE_DELETE,
   ACTION_PAGE_DELETE_COMPLETELY,
   ACTION_PAGE_REVERT,
+  ACTION_PAGE_RECURSIVELY_RENAME,
+  ACTION_PAGE_RECURSIVELY_DELETE,
+  ACTION_PAGE_RECURSIVELY_DELETE_COMPLETELY,
+  ACTION_PAGE_RECURSIVELY_REVERT,
   ACTION_COMMENT_CREATE,
 } as const;
 
@@ -405,6 +417,10 @@ export const MediumActionGroup = {
   ACTION_PAGE_DELETE,
   ACTION_PAGE_DELETE_COMPLETELY,
   ACTION_PAGE_REVERT,
+  ACTION_PAGE_RECURSIVELY_RENAME,
+  ACTION_PAGE_RECURSIVELY_DELETE,
+  ACTION_PAGE_RECURSIVELY_DELETE_COMPLETELY,
+  ACTION_PAGE_RECURSIVELY_REVERT,
   ACTION_PAGE_EMPTY_TRASH,
   ACTION_PAGE_SUBSCRIBE,
   ACTION_PAGE_UNSUBSCRIBE,

+ 0 - 1
packages/app/src/interfaces/subscription.ts

@@ -1 +0,0 @@
-export { SubscriptionStatusType, AllSubscriptionStatusType } from '@growi/core';

+ 1 - 0
packages/app/src/pages/[[...path]].page.module.scss

@@ -0,0 +1 @@
+@use '~/styles/molecules/page_list';

+ 52 - 73
packages/app/src/pages/[[...path]].page.tsx

@@ -1,5 +1,6 @@
 import React, { useEffect } from 'react';
 
+
 import EventEmitter from 'events';
 
 import {
@@ -21,8 +22,7 @@ import { RecentlyCreatedIcon } from '~/components/Icons/RecentlyCreatedIcon';
 import { PageAlerts } from '~/components/PageAlert/PageAlerts';
 // import { useTranslation } from '~/i18n';
 import { PageContentFooter } from '~/components/PageContentFooter';
-import { BookmarkList } from '~/components/PageList/BookmarkList';
-import { RecentCreated } from '~/components/RecentCreated/RecentCreated';
+import { UsersHomePageFooterProps } from '~/components/UsersHomePageFooter';
 import { CrowiRequest } from '~/interfaces/crowi-request';
 // import { renderScriptTagByName, renderHighlightJsStyleTag } from '~/service/cdn-resources-loader';
 // import { useIndentSize } from '~/stores/editor';
@@ -76,6 +76,8 @@ const NotCreatablePage = dynamic(() => import('../components/NotCreatablePage').
 const ForbiddenPage = dynamic(() => import('../components/ForbiddenPage'), { ssr: false });
 const UnsavedAlertDialog = dynamic(() => import('./UnsavedAlertDialog'), { ssr: false });
 const GrowiSubNavigationSwitcher = dynamic(() => import('../components/Navbar/GrowiSubNavigationSwitcher'), { ssr: false });
+const UsersHomePageFooter = dynamic<UsersHomePageFooterProps>(() => import('../components/UsersHomePageFooter')
+  .then(mod => mod.UsersHomePageFooter), { ssr: false });
 
 const logger = loggerFactory('growi:pages:all');
 
@@ -291,83 +293,60 @@ const GrowiPage: NextPage<Props> = (props: Props) => {
       </Head>
       {/* <BasicLayout title={useCustomTitle(props, t('GROWI'))} className={classNames.join(' ')}> */}
       <BasicLayout title={useCustomTitle(props, 'GROWI')} className={classNames.join(' ')} expandContainer={props.isContainerFluid}>
-        <header className="py-0 position-relative">
-          <GrowiContextualSubNavigation isLinkSharingDisabled={props.disableLinkSharing} />
-        </header>
-        <div className="d-edit-none">
-          <GrowiSubNavigationSwitcher />
-        </div>
+        <div className="h-100 d-flex flex-column justify-content-between">
+          <header className="py-0 position-relative">
+            <GrowiContextualSubNavigation isLinkSharingDisabled={props.disableLinkSharing} />
+          </header>
+          <div className="d-edit-none">
+            <GrowiSubNavigationSwitcher />
+          </div>
 
-        <div id="grw-subnav-sticky-trigger" className="sticky-top"></div>
-        <div id="grw-fav-sticky-trigger" className="sticky-top"></div>
-
-        <div id="main" className={`main ${isUsersHomePage(props.currentPathname) && 'user-page'}`}>
-          <div id="content-main" className="content-main grw-container-convertible">
-            <div className="row">
-              <div className="col">
-                { props.isIdenticalPathPage && <IdenticalPathPage /> }
-
-                { !props.isIdenticalPathPage && (
-                  <>
-                    <PageAlerts />
-                    { props.isForbidden && <ForbiddenPage /> }
-                    { props.IsNotCreatable && <NotCreatablePage />}
-                    { !props.isForbidden && !props.IsNotCreatable && <DisplaySwitcher />}
-                    {/* <DisplaySwitcher /> */}
-                    <div id="page-editor-navbar-bottom-container" className="d-none d-edit-block"></div>
-                    {/* <PageStatusAlert /> */}
-                  </>
-                ) }
+          <div id="grw-subnav-sticky-trigger" className="sticky-top"></div>
+          <div id="grw-fav-sticky-trigger" className="sticky-top"></div>
+
+          <div className="flex-grow-1">
+            <div id="main" className={`main ${isUsersHomePage(props.currentPathname) && 'user-page'}`}>
+              <div id="content-main" className="content-main grw-container-convertible">
+                <div className="row">
+                  <div className="col">
+                    { props.isIdenticalPathPage && <IdenticalPathPage /> }
+
+                    { !props.isIdenticalPathPage && (
+                      <>
+                        <PageAlerts />
+                        { props.isForbidden && <ForbiddenPage /> }
+                        { props.IsNotCreatable && <NotCreatablePage />}
+                        { !props.isForbidden && !props.IsNotCreatable && <DisplaySwitcher />}
+                        {/* <DisplaySwitcher /> */}
+                        <div id="page-editor-navbar-bottom-container" className="d-none d-edit-block"></div>
+                        {/* <PageStatusAlert /> */}
+                      </>
+                    ) }
+
+                  </div>
+                </div>
 
+                {/* <div className="col-xl-2 col-lg-3 d-none d-lg-block revision-toc-container">
+                  <div id="revision-toc" className="revision-toc mt-3 sps sps--abv" data-sps-offset="123">
+                    <div id="revision-toc-content" className="revision-toc-content"></div>
+                  </div>
+                </div> */}
               </div>
             </div>
-
-            {/* <div className="col-xl-2 col-lg-3 d-none d-lg-block revision-toc-container">
-              <div id="revision-toc" className="revision-toc mt-3 sps sps--abv" data-sps-offset="123">
-                <div id="revision-toc-content" className="revision-toc-content"></div>
-              </div>
-            </div> */}
           </div>
+          <footer className="footer d-edit-none">
+            {/* TODO: Enable page_list.html */}
+            { !props.isIdenticalPathPage && (<Comments pageId={pageId} />) }
+            { (pageWithMeta != null && isUsersHomePage(pageWithMeta.data.path)) && (
+              <UsersHomePageFooter creatorId={pageWithMeta.data.creator._id}/>
+            )}
+            <PageContentFooter />
+          </footer>
+
+          <UnsavedAlertDialog />
+          <DescendantsPageListModal />
+          {shouldRenderPutbackPageModal && <PutbackPageModal />}
         </div>
-        {/* TODO: Check CSS import */}
-        <footer className="footer d-edit-none">
-          {/* TODO: Enable page_list.html */}
-          {/* TODO: Enable isIdenticalPathPage or useIdenticalPath */}
-          {/* { !props.isIdenticalPathPage && ( */}
-          <Comments pageId={pageId} />
-          {/* )} */}
-          {/* TODO: Create UsersHomePageFooter conponent */}
-          { (pageWithMeta != null && isUsersHomePage(pageWithMeta?.data.path)) && (
-            <div className="container-lg user-page-footer py-5">
-              <div className="grw-user-page-list-m d-edit-none">
-                <h2 id="bookmarks-list" className="grw-user-page-header border-bottom pb-2 mb-3">
-                  <i style={{ fontSize: '1.3em' }} className="fa fa-fw fa-bookmark-o"></i>
-                  Bookmarks
-                </h2>
-                <div id="user-bookmark-list" className="page-list">
-                  { (pageWithMeta != null) && (<BookmarkList userId={pageWithMeta?.data.creator._id} />) }
-                </div>
-              </div>
-              <div className="grw-user-page-list-m mt-5 d-edit-none">
-                <h2 id="recently-created-list" className="grw-user-page-header border-bottom pb-2 mb-3">
-                  <i id="recent-created-icon" className="mr-1">
-                    <RecentlyCreatedIcon />
-                  </i>
-                  Recently Created
-                </h2>
-                <div id="user-created-list" className="page-list">
-                  { (pageWithMeta != null) && (<RecentCreated userId={pageWithMeta?.data.creator._id} />) }
-                </div>
-              </div>
-            </div>
-          )}
-          <PageContentFooter />
-        </footer>
-
-        <UnsavedAlertDialog />
-        <DescendantsPageListModal />
-        {shouldRenderPutbackPageModal && <PutbackPageModal />}
-
       </BasicLayout>
     </>
   );

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

@@ -38,7 +38,7 @@ import {
 } from '~/stores/context';
 
 import {
-  CommonProps, getServerSideCommonProps, useCustomTitle, getNextI18NextConfig,
+  CommonProps, getServerSideCommonProps, getNextI18NextConfig,
 } from '../utils/commons';
 
 
@@ -120,19 +120,19 @@ const AdminMarkdownSettingsPage: NextPage<Props> = (props: Props) => {
       component: <SecurityManagementContents />,
     },
     markdown: {
-      title: t('markdown_settings'),
+      title: t('markdown_settings.markdown_settings'),
       component: <MarkDownSettingContents />,
     },
     customize: {
-      title: t('Customize Settings'),
+      title: t('customize_setting.customize_setting'),
       component: <CustomizeSettingContents />,
     },
     importer: {
-      title: t('Import Data'),
+      title: t('importer_management.import_data'),
       component: <DataImportPageContents />,
     },
     export: {
-      title: t('Export Archive Data'),
+      title: t('export_archive_data'),
       component: <ExportArchiveDataPage />,
     },
     notification: {
@@ -170,7 +170,7 @@ const AdminMarkdownSettingsPage: NextPage<Props> = (props: Props) => {
       },
     },
     search: {
-      title: t('full_text_search_management'),
+      title: t('full_text_search_management.full_text_search_management'),
       component: <ElasticsearchManagement />,
     },
     'audit-log': {
@@ -324,7 +324,7 @@ export const getServerSideProps: GetServerSideProps = async(context: GetServerSi
   }
 
   injectServerConfigurations(context, props);
-  injectNextI18NextConfigurations(context, props, ['admin']);
+  await injectNextI18NextConfigurations(context, props, ['admin']);
 
   return {
     props,

+ 3 - 12
packages/app/src/server/models/activity.ts

@@ -1,3 +1,4 @@
+import { Ref, IPage } from '@growi/core';
 import {
   Types, Document, Model, Schema,
 } from 'mongoose';
@@ -95,18 +96,8 @@ activitySchema.post('save', function() {
 activitySchema.methods.getNotificationTargetUsers = async function() {
   const User = getModelSafely('User') || require('~/server/models/user')();
   const { user: actionUser, target } = this;
-
-  const [subscribeUsers, unsubscribeUsers] = await Promise.all([
-    Subscription.getSubscription((target as any) as Types.ObjectId),
-    Subscription.getUnsubscription((target as any) as Types.ObjectId),
-  ]);
-
-  const unique = array => Object.values(array.reduce((objects, object) => ({ ...objects, [object.toString()]: object }), {}));
-  const filter = (array, pull) => {
-    const ids = pull.map(object => object.toString());
-    return array.filter(object => !ids.includes(object.toString()));
-  };
-  const notificationUsers = filter(unique([...subscribeUsers]), [...unsubscribeUsers, actionUser]);
+  const subscribedUsers = await Subscription.getSubscription(target as unknown as Ref<IPage>);
+  const notificationUsers = subscribedUsers.filter(item => (item.toString() !== actionUser._id.toString()));
   const activeNotificationUsers = await User.find({
     _id: { $in: notificationUsers },
     status: User.STATUS_ACTIVE,

+ 17 - 20
packages/app/src/server/models/subscription.ts

@@ -1,4 +1,6 @@
-import { SubscriptionStatusType, AllSubscriptionStatusType } from '@growi/core';
+import {
+  SubscriptionStatusType, AllSubscriptionStatusType, Ref, IPage, IUser, ISubscription,
+} from '@growi/core';
 import {
   Types, Document, Model, Schema,
 } from 'mongoose';
@@ -8,25 +10,15 @@ import { AllSupportedTargetModels } from '~/interfaces/activity';
 import { getOrCreateModel } from '../util/mongoose-utils';
 
 
-export interface ISubscription {
-  user: Types.ObjectId
-  targetModel: string
-  target: Types.ObjectId
-  status: string
-  createdAt: Date
-
-  isSubscribing(): boolean
-  isUnsubscribing(): boolean
-}
-
 export interface SubscriptionDocument extends ISubscription, Document {}
 
 export interface SubscriptionModel extends Model<SubscriptionDocument> {
   findByUserIdAndTargetId(userId: Types.ObjectId | string, targetId: Types.ObjectId | string): any
-  upsertSubscription(user: Types.ObjectId, targetModel: string, target: Types.ObjectId, status: string): any
-  subscribeByPageId(user: Types.ObjectId, pageId: Types.ObjectId, status: string): any
-  getSubscription(target: Types.ObjectId): Promise<Types.ObjectId[]>
-  getUnsubscription(target: Types.ObjectId): Promise<Types.ObjectId[]>
+  upsertSubscription(user: Ref<IUser>, targetModel: string, target: Ref<IPage>, status: string): any
+  subscribeByPageId(userId: Types.ObjectId, pageId: Types.ObjectId, status: string): any
+  getSubscription(target: Ref<IPage>): Promise<Ref<IUser>[]>
+  getUnsubscription(target: Ref<IPage>): Promise<Ref<IUser>[]>
+  getSubscriptions(targets: Ref<IPage>[]): Promise<Ref<IUser>[]>
 }
 
 const subscriptionSchema = new Schema<SubscriptionDocument, SubscriptionModel>({
@@ -43,6 +35,7 @@ const subscriptionSchema = new Schema<SubscriptionDocument, SubscriptionModel>({
   },
   target: {
     type: Schema.Types.ObjectId,
+    ref: 'Page',
     refPath: 'targetModel',
     required: true,
   },
@@ -76,16 +69,20 @@ subscriptionSchema.statics.upsertSubscription = function(user, targetModel, targ
   return this.findOneAndUpdate(query, doc, options);
 };
 
-subscriptionSchema.statics.subscribeByPageId = function(user, pageId, status) {
-  return this.upsertSubscription(user, 'Page', pageId, status);
+subscriptionSchema.statics.subscribeByPageId = function(userId, pageId, status) {
+  return this.upsertSubscription(userId, 'Page', pageId, status);
 };
 
-subscriptionSchema.statics.getSubscription = async function(target) {
+subscriptionSchema.statics.getSubscription = async function(target: Ref<IPage>) {
   return this.find({ target, status: SubscriptionStatusType.SUBSCRIBE }).distinct('user');
 };
 
-subscriptionSchema.statics.getUnsubscription = async function(target) {
+subscriptionSchema.statics.getUnsubscription = async function(target: Ref<IPage>) {
   return this.find({ target, status: SubscriptionStatusType.UNSUBSCRIBE }).distinct('user');
 };
 
+subscriptionSchema.statics.getSubscriptions = async function(targets: Ref<IPage>[]) {
+  return this.find({ target: { $in: targets }, status: SubscriptionStatusType.SUBSCRIBE }).distinct('user');
+};
+
 export default getOrCreateModel<SubscriptionDocument, SubscriptionModel>('Subscription', subscriptionSchema);

+ 8 - 12
packages/app/src/server/routes/apiv3/pages.js

@@ -488,7 +488,7 @@ module.exports = (crowi) => {
    *          409:
    *            description: page path is already existed
    */
-  router.put('/rename', accessTokenParser, loginRequiredStrictly, addActivity, validator.renamePage, apiV3FormValidator, async(req, res) => {
+  router.put('/rename', accessTokenParser, loginRequiredStrictly, validator.renamePage, apiV3FormValidator, async(req, res) => {
     const { pageId, revisionId } = req.body;
 
     let newPagePath = pathUtils.normalizePath(req.body.newPagePath);
@@ -500,6 +500,11 @@ module.exports = (crowi) => {
       isMoveMode: req.body.isMoveMode,
     };
 
+    const activityParameters = {
+      ip: req.ip,
+      endpoint: req.originalUrl,
+    };
+
     if (!isCreatablePage(newPagePath)) {
       return res.apiv3Err(new ErrorV3(`Could not use the path '${newPagePath}'`, 'invalid_path'), 409);
     }
@@ -518,6 +523,7 @@ module.exports = (crowi) => {
 
     try {
       page = await Page.findByIdAndViewer(pageId, req.user, null, true);
+      options.isRecursively = page.descendantCount > 0;
 
       if (page == null) {
         return res.apiv3Err(new ErrorV3(`Page '${pageId}' is not found or forbidden`, 'notfound_or_forbidden'), 401);
@@ -531,15 +537,13 @@ module.exports = (crowi) => {
       if (!page.isEmpty && !page.isUpdatable(revisionId)) {
         return res.apiv3Err(new ErrorV3('Someone could update this page, so couldn\'t delete.', 'notfound_or_forbidden'), 409);
       }
-      renamedPage = await crowi.pageService.renamePage(page, newPagePath, req.user, options);
+      renamedPage = await crowi.pageService.renamePage(page, newPagePath, req.user, options, activityParameters);
     }
     catch (err) {
       logger.error(err);
       return res.apiv3Err(new ErrorV3('Failed to update page.', 'unknown'), 500);
     }
-
     const result = { page: serializePageSecurely(renamedPage ?? page) };
-
     try {
       // global notification
       await globalNotificationService.fire(GlobalNotificationSetting.EVENT.PAGE_MOVE, page, req.user, {
@@ -550,14 +554,6 @@ module.exports = (crowi) => {
       logger.error('Move notification failed', err);
     }
 
-    const activityId = res.locals.activity._id;
-    const parameters = {
-      targetModel: SupportedTargetModel.MODEL_PAGE,
-      target: page,
-      action: SupportedAction.ACTION_PAGE_RENAME,
-    };
-    activityEvent.emit('update', activityId, parameters, page);
-
     return res.apiv3(result);
   });
 

+ 0 - 1
packages/app/src/server/routes/apiv3/personal-setting.js

@@ -242,7 +242,6 @@ module.exports = (crowi) => {
       user.slackMemberId = req.body.slackMemberId;
 
       const updatedUser = await user.save();
-      req.i18n.changeLanguage(req.body.lang);
 
       const parameters = { action: SupportedAction.ACTION_USER_PERSONAL_SETTINGS_UPDATE };
       activityEvent.emit('update', res.locals.activity._id, parameters);

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

@@ -181,8 +181,8 @@ module.exports = function(crowi, app) {
   apiV1Router.get('/pages.updatePost'    , accessTokenParser, loginRequired, page.api.getUpdatePost);
   apiV1Router.get('/pages.getPageTag'    , accessTokenParser , loginRequired , page.api.getPageTag);
   // allow posting to guests because the client doesn't know whether the user logged in
-  apiV1Router.post('/pages.remove'       , loginRequiredStrictly , addActivity, page.validator.remove, apiV1FormValidator, page.api.remove); // (Avoid from API Token)
-  apiV1Router.post('/pages.revertRemove' , loginRequiredStrictly , addActivity, page.validator.revertRemove, apiV1FormValidator, page.api.revertRemove); // (Avoid from API Token)
+  apiV1Router.post('/pages.remove'       , loginRequiredStrictly , page.validator.remove, apiV1FormValidator, page.api.remove); // (Avoid from API Token)
+  apiV1Router.post('/pages.revertRemove' , loginRequiredStrictly , page.validator.revertRemove, apiV1FormValidator, page.api.revertRemove); // (Avoid from API Token)
   apiV1Router.post('/pages.unlink'       , loginRequiredStrictly , page.api.unlink); // (Avoid from API Token)
   apiV1Router.post('/pages.duplicate'    , accessTokenParser, loginRequiredStrictly, page.api.duplicate);
   apiV1Router.get('/tags.list'           , accessTokenParser, loginRequired, tag.api.list);

+ 14 - 17
packages/app/src/server/routes/page.js

@@ -1270,6 +1270,11 @@ module.exports = function(crowi, app) {
 
     const options = {};
 
+    const activityParameters = {
+      ip: req.ip,
+      endpoint: req.originalUrl,
+    };
+
     const page = await Page.findByIdAndViewer(pageId, req.user, null, true);
 
     if (page == null) {
@@ -1293,7 +1298,7 @@ module.exports = function(crowi, app) {
         if (!crowi.pageService.canDeleteCompletely(page.path, creator, req.user, isRecursively)) {
           return res.json(ApiResponse.error('You can not delete this page completely', 'user_not_admin'));
         }
-        await crowi.pageService.deleteCompletely(page, req.user, options, isRecursively);
+        await crowi.pageService.deleteCompletely(page, req.user, options, isRecursively, false, activityParameters);
       }
       else {
         // behave like not found
@@ -1310,7 +1315,7 @@ module.exports = function(crowi, app) {
           return res.json(ApiResponse.error('You can not delete this page', 'user_not_admin'));
         }
 
-        await crowi.pageService.deletePage(page, req.user, options, isRecursively);
+        await crowi.pageService.deletePage(page, req.user, options, isRecursively, activityParameters);
       }
     }
     catch (err) {
@@ -1324,13 +1329,6 @@ module.exports = function(crowi, app) {
     result.isRecursively = isRecursively;
     result.isCompletely = isCompletely;
 
-    const parameters = {
-      targetModel: SupportedTargetModel.MODEL_PAGE,
-      target: page,
-      action: isCompletely ? SupportedAction.ACTION_PAGE_DELETE_COMPLETELY : SupportedAction.ACTION_PAGE_DELETE,
-    };
-    activityEvent.emit('update', res.locals.activity._id, parameters, page);
-
     res.json(ApiResponse.success(result));
 
     try {
@@ -1362,13 +1360,19 @@ module.exports = function(crowi, app) {
     // get recursively flag
     const isRecursively = req.body.recursively;
 
+    const activityParameters = {
+      ip: req.ip,
+      endpoint: req.originalUrl,
+    };
+
     let page;
+    let descendantPages;
     try {
       page = await Page.findByIdAndViewer(pageId, req.user);
       if (page == null) {
         throw new Error(`Page '${pageId}' is not found or forbidden`, 'notfound_or_forbidden');
       }
-      page = await crowi.pageService.revertDeletedPage(page, req.user, {}, isRecursively);
+      page = await crowi.pageService.revertDeletedPage(page, req.user, {}, isRecursively, activityParameters);
     }
     catch (err) {
       if (err instanceof PathAlreadyExistsError) {
@@ -1382,13 +1386,6 @@ module.exports = function(crowi, app) {
     const result = {};
     result.page = page; // TODO consider to use serializePageSecurely method -- 2018.08.06 Yuki Takei
 
-    const parameters = {
-      targetModel: SupportedTargetModel.MODEL_PAGE,
-      target: page,
-      action: SupportedAction.ACTION_PAGE_REVERT,
-    };
-    activityEvent.emit('update', res.locals.activity._id, parameters, page);
-
     return res.json(ApiResponse.success(result));
   };
 

+ 8 - 5
packages/app/src/server/service/activity.ts

@@ -1,10 +1,10 @@
+import { Ref, IPage, IUser } from '@growi/core';
 import mongoose from 'mongoose';
 
 import {
   IActivity, SupportedActionType, AllSupportedActions, ActionGroupSize,
   AllEssentialActions, AllSmallGroupActions, AllMediumGroupActions, AllLargeGroupActions,
 } from '~/interfaces/activity';
-import { IPage } from '~/interfaces/page';
 import Activity from '~/server/models/activity';
 
 import loggerFactory from '../../utils/logger';
@@ -39,7 +39,7 @@ class ActivityService {
   }
 
   initActivityEventListeners(): void {
-    this.activityEvent.on('update', async(activityId: string, parameters, target?: IPage) => {
+    this.activityEvent.on('update', async(activityId: string, parameters, target?: IPage, descendantsSubscribedUsers?: Ref<IUser>[]) => {
       let activity: IActivity;
       const shoudUpdate = this.shoudUpdateActivity(parameters.action);
 
@@ -52,7 +52,7 @@ class ActivityService {
           return;
         }
 
-        this.activityEvent.emit('updated', activity, target);
+        this.activityEvent.emit('updated', activity, target, descendantsSubscribedUsers);
       }
     });
   }
@@ -103,16 +103,19 @@ class ActivityService {
   };
 
   // for GET request
-  createActivity = async function(parameters): Promise<void> {
+  createActivity = async function(parameters): Promise<IActivity | null> {
     const shoudCreateActivity = this.crowi.activityService.shoudUpdateActivity(parameters.action);
     if (shoudCreateActivity) {
+      let activity: IActivity;
       try {
-        await Activity.createByParameters(parameters);
+        activity = await Activity.createByParameters(parameters);
+        return activity;
       }
       catch (err) {
         logger.error('Create activity failed', err);
       }
     }
+    return null;
   };
 
   createTtlIndex = async function() {

+ 19 - 9
packages/app/src/server/service/in-app-notification.ts

@@ -1,11 +1,11 @@
-import { HasObjectId, SubscriptionStatusType } from '@growi/core';
+import {
+  HasObjectId, SubscriptionStatusType, Ref, IPage, IUser,
+} from '@growi/core';
 import { subDays } from 'date-fns';
 import { Types } from 'mongoose';
 
 import { AllEssentialActions, SupportedAction } from '~/interfaces/activity';
 import { InAppNotificationStatuses, PaginateResult } from '~/interfaces/in-app-notification';
-import { IPage } from '~/interfaces/page';
-import { IUser } from '~/interfaces/user';
 import { stringifySnapshot } from '~/models/serializers/in-app-notification-snapshot/page';
 import { ActivityDocument } from '~/server/models/activity';
 import {
@@ -17,6 +17,7 @@ import Subscription from '~/server/models/subscription';
 import loggerFactory from '~/utils/logger';
 
 import Crowi from '../crowi';
+import { PageDocument } from '../models/page';
 import { RoomPrefix, getRoomNameWithId } from '../util/socket-io-helpers';
 
 
@@ -50,11 +51,11 @@ export default class InAppNotificationService {
   }
 
   initActivityEventListeners(): void {
-    this.activityEvent.on('updated', async(activity: ActivityDocument, target: IPage) => {
+    this.activityEvent.on('updated', async(activity: ActivityDocument, target: IPage, descendantsSubscribedUsers?: Ref<IUser>[]) => {
       try {
         const shouldNotification = activity != null && target != null && (AllEssentialActions as ReadonlyArray<string>).includes(activity.action);
         if (shouldNotification) {
-          await this.createInAppNotification(activity, target);
+          await this.createInAppNotification(activity, target, descendantsSubscribedUsers);
         }
       }
       catch (err) {
@@ -198,17 +199,26 @@ export default class InAppNotificationService {
     return;
   };
 
-  createInAppNotification = async function(activity: ActivityDocument, target: IPage): Promise<void> {
+  createInAppNotification = async function(activity: ActivityDocument, target: IPage, descendantsSubscribedUsers?: Ref<IUser>[]): Promise<void> {
     const shouldNotification = activity != null && target != null && (AllEssentialActions as ReadonlyArray<string>).includes(activity.action);
+    const snapshot = stringifySnapshot(target);
     if (shouldNotification) {
       let mentionedUsers: IUser[] = [];
       if (activity.action === SupportedAction.ACTION_COMMENT_CREATE) {
         mentionedUsers = await this.crowi.commentService.getMentionedUsers(activity.event);
       }
       const notificationTargetUsers = await activity?.getNotificationTargetUsers();
-      const snapshot = stringifySnapshot(target as IPage);
-      await this.upsertByActivity([...notificationTargetUsers, ...mentionedUsers], activity, snapshot);
-      await this.emitSocketIo(notificationTargetUsers);
+      let notificationDescendantsUsers = [];
+      if (descendantsSubscribedUsers != null) {
+        const User = this.crowi.model('User');
+        const descendantsUsers = descendantsSubscribedUsers.filter(item => (item.toString() !== activity.user._id.toString()));
+        notificationDescendantsUsers = await User.find({
+          _id: { $in: descendantsUsers },
+          status: User.STATUS_ACTIVE,
+        }).distinct('_id');
+      }
+      await this.upsertByActivity([...notificationTargetUsers, ...mentionedUsers, ...notificationDescendantsUsers], activity, snapshot);
+      await this.emitSocketIo([...notificationTargetUsers, notificationDescendantsUsers]);
     }
     else {
       throw Error('No activity to notify');

+ 127 - 32
packages/app/src/server/service/page.ts

@@ -3,22 +3,21 @@ import { Readable, Writable } from 'stream';
 
 import {
   pagePathUtils, pathUtils, Ref, HasObjectId,
+  IUserHasId,
+  IPage, IPageInfo, IPageInfoAll, IPageInfoForEntity, IPageWithMeta,
 } from '@growi/core';
 import escapeStringRegexp from 'escape-string-regexp';
 import mongoose, { ObjectId, QueryCursor } from 'mongoose';
 import streamToPromise from 'stream-to-promise';
 
+import { SupportedAction } from '~/interfaces/activity';
 import { V5ConversionErrCode } from '~/interfaces/errors/v5-conversion-error';
-import {
-  IPage, IPageInfo, IPageInfoAll, IPageInfoForEntity, IPageWithMeta,
-} from '~/interfaces/page';
 import {
   PageDeleteConfigValue, IPageDeleteConfigValueToProcessValidation,
 } from '~/interfaces/page-delete-config';
 import {
   IPageOperationProcessInfo, IPageOperationProcessData, PageActionStage, PageActionType,
 } from '~/interfaces/page-operation';
-import { IUserHasId } from '~/interfaces/user';
 import { PageMigrationErrorData, SocketEventName, UpdateDescCountRawData } from '~/interfaces/websocket';
 import {
   CreateMethod, PageCreateOptions, PageModel, PageDocument, pushRevision, PageQueryBuilder,
@@ -137,10 +136,13 @@ class PageService {
 
   tagEvent: any;
 
+  activityEvent: any;
+
   constructor(crowi) {
     this.crowi = crowi;
     this.pageEvent = crowi.event('page');
     this.tagEvent = crowi.event('tag');
+    this.activityEvent = crowi.event('activity');
 
     // init
     this.initPageEvent();
@@ -348,12 +350,26 @@ class PageService {
       .cursor({ batchSize: BULK_REINDEX_SIZE });
   }
 
-  async renamePage(page, newPagePath, user, options) {
+  async renamePage(page: IPage, newPagePath, user, options, activityParameters): Promise<PageDocument | null> {
     /*
      * Common Operation
      */
     const Page = mongoose.model('Page') as unknown as PageModel;
 
+    const parameters = {
+      ip: activityParameters.ip,
+      endpoint: activityParameters.endpoint,
+      action: page.descendantCount > 0 ? SupportedAction.ACTION_PAGE_RECURSIVELY_RENAME : SupportedAction.ACTION_PAGE_RENAME,
+      user,
+      targetModel: 'Page',
+      target: page,
+      snapshot: {
+        username: user.username,
+      },
+    };
+
+    const activity = await this.crowi.activityService.createActivity(parameters);
+
     const isExist = await Page.exists({ path: newPagePath });
     if (isExist) {
       throw Error(`Page already exists at ${newPagePath}`);
@@ -403,10 +419,9 @@ class PageService {
       logger.error('Failed to create PageOperation document.', err);
       throw err;
     }
-
     let renamedPage: PageDocument | null = null;
     try {
-      renamedPage = await this.renameMainOperation(page, newPagePath, user, options, pageOp._id);
+      renamedPage = await this.renameMainOperation(page, newPagePath, user, options, pageOp._id, activity);
     }
     catch (err) {
       logger.error('Error occurred while running renameMainOperation', err);
@@ -416,11 +431,13 @@ class PageService {
 
       throw err;
     }
-
+    if (page.descendantCount < 1) {
+      this.activityEvent.emit('updated', activity, page);
+    }
     return renamedPage;
   }
 
-  async renameMainOperation(page, newPagePath: string, user, options, pageOpId: ObjectIdLike) {
+  async renameMainOperation(page, newPagePath: string, user, options, pageOpId: ObjectIdLike, activity?): Promise<PageDocument | null> {
     const Page = mongoose.model('Page') as unknown as PageModel;
 
     const updateMetadata = options.updateMetadata || false;
@@ -505,12 +522,12 @@ class PageService {
     /*
      * Sub Operation
      */
-    this.renameSubOperation(page, newPagePath, user, options, renamedPage, pageOp._id);
+    this.renameSubOperation(page, newPagePath, user, options, renamedPage, pageOp._id, activity);
 
     return renamedPage;
   }
 
-  async renameSubOperation(page, newPagePath: string, user, options, renamedPage, pageOpId: ObjectIdLike): Promise<void> {
+  async renameSubOperation(page, newPagePath: string, user, options, renamedPage, pageOpId: ObjectIdLike, activity?): Promise<void> {
     const Page = mongoose.model('Page') as unknown as PageModel;
 
     const exParentId = page.parent;
@@ -518,7 +535,10 @@ class PageService {
     const timerObj = this.crowi.pageOperationService.autoUpdateExpiryDate(pageOpId);
     try {
     // update descendants first
-      await this.renameDescendantsWithStream(page, newPagePath, user, options, false);
+      const descendantsSubscribedSets = new Set();
+      await this.renameDescendantsWithStream(page, newPagePath, user, options, false, descendantsSubscribedSets);
+      const descendantsSubscribedUsers = Array.from(descendantsSubscribedSets);
+      this.activityEvent.emit('updated', activity, page, descendantsSubscribedUsers);
     }
     catch (err) {
       logger.warn(err);
@@ -549,7 +569,7 @@ class PageService {
     await PageOperation.findByIdAndDelete(pageOpId);
   }
 
-  async resumeRenameSubOperation(renamedPage: PageDocument, pageOp: PageOperationDocument): Promise<void> {
+  async resumeRenameSubOperation(renamedPage: PageDocument, pageOp: PageOperationDocument, activity?): Promise<void> {
     const isProcessable = pageOp.isProcessable();
     if (!isProcessable) {
       throw Error('This page operation is currently being processed');
@@ -562,7 +582,7 @@ class PageService {
       page, fromPath, toPath, options, user,
     } = pageOp;
 
-    this.fixPathsAndDescendantCountOfAncestors(page, user, options, renamedPage, pageOp._id, fromPath, toPath);
+    this.fixPathsAndDescendantCountOfAncestors(page, user, options, renamedPage, pageOp._id, fromPath, toPath, activity);
   }
 
   /**
@@ -570,8 +590,8 @@ class PageService {
    * `renameSubOperation` to restart rename operation
    * `updateDescendantCountOfPagesWithPaths` to fix descendantCount of ancestors
    */
-  private async fixPathsAndDescendantCountOfAncestors(page, user, options, renamedPage, pageOpId, fromPath, toPath): Promise<void> {
-    await this.renameSubOperation(page, toPath, user, options, renamedPage, pageOpId);
+  private async fixPathsAndDescendantCountOfAncestors(page, user, options, renamedPage, pageOpId, fromPath, toPath, activity?): Promise<void> {
+    await this.renameSubOperation(page, toPath, user, options, renamedPage, pageOpId, activity);
     const ancestorsPaths = this.crowi.pageOperationService.getAncestorsPathsByFromAndToPath(fromPath, toPath);
     await this.updateDescendantCountOfPagesWithPaths(ancestorsPaths);
   }
@@ -806,7 +826,7 @@ class PageService {
     this.pageEvent.emit('updateMany', pages, user);
   }
 
-  private async renameDescendantsWithStream(targetPage, newPagePath, user, options = {}, shouldUseV4Process = true) {
+  private async renameDescendantsWithStream(targetPage, newPagePath, user, options = {}, shouldUseV4Process = true, descendantsSubscribedSets?) {
     // v4 compatible process
     if (shouldUseV4Process) {
       return this.renameDescendantsWithStreamV4(targetPage, newPagePath, user, options);
@@ -829,6 +849,10 @@ class PageService {
           await renameDescendants(
             batch, user, options, pathRegExp, newPagePathPrefix, shouldUseV4Process,
           );
+          const subscribedUsers = await Subscription.getSubscriptions(batch);
+          subscribedUsers.forEach((eachUser) => {
+            descendantsSubscribedSets.add(eachUser);
+          });
           logger.debug(`Renaming pages progressing: (count=${count})`);
         }
         catch (err) {
@@ -1335,7 +1359,7 @@ class PageService {
   /*
    * Delete
    */
-  async deletePage(page, user, options = {}, isRecursively = false) {
+  async deletePage(page, user, options = {}, isRecursively = false, activityParameters?) {
     /*
      * Common Operation
      */
@@ -1373,6 +1397,20 @@ class PageService {
       await Page.replaceTargetWithPage(page, null, true);
     }
 
+    const parameters = {
+      ip: activityParameters.ip,
+      endpoint: activityParameters.endpoint,
+      action: page.descendantCount > 0 ? SupportedAction.ACTION_PAGE_RECURSIVELY_DELETE : SupportedAction.ACTION_PAGE_DELETE,
+      user,
+      target: page,
+      targetModel: 'Page',
+      snapshot: {
+        username: user.username,
+      },
+    };
+
+    const activity = await this.crowi.activityService.createActivity(parameters);
+
     // Delete target (only updating an existing document's properties )
     let deletedPage;
     if (!page.isEmpty) {
@@ -1416,7 +1454,7 @@ class PageService {
        */
       (async() => {
         try {
-          await this.deleteRecursivelyMainOperation(page, user, pageOp._id);
+          await this.deleteRecursivelyMainOperation(page, user, pageOp._id, activity);
         }
         catch (err) {
           logger.error('Error occurred while running deleteRecursivelyMainOperation.', err);
@@ -1428,6 +1466,9 @@ class PageService {
         }
       })();
     }
+    else {
+      this.activityEvent.emit('updated', activity, page);
+    }
 
     return deletedPage;
   }
@@ -1459,8 +1500,12 @@ class PageService {
     return deletedPage;
   }
 
-  async deleteRecursivelyMainOperation(page, user, pageOpId: ObjectIdLike): Promise<void> {
-    await this.deleteDescendantsWithStream(page, user, false);
+  async deleteRecursivelyMainOperation(page, user, pageOpId: ObjectIdLike, activity?): Promise<void> {
+    const descendantsSubscribedSets = new Set();
+    await this.deleteDescendantsWithStream(page, user, false, descendantsSubscribedSets);
+
+    const descendantsSubscribedUsers = Array.from(descendantsSubscribedSets);
+    this.activityEvent.emit('updated', activity, page, descendantsSubscribedUsers);
 
     await PageOperation.findByIdAndDelete(pageOpId);
 
@@ -1582,7 +1627,7 @@ class PageService {
   /**
    * Create delete stream and return deleted document count
    */
-  private async deleteDescendantsWithStream(targetPage, user, shouldUseV4Process = true): Promise<number> {
+  private async deleteDescendantsWithStream(targetPage, user, shouldUseV4Process = true, descendantsSubscribedSets?): Promise<number> {
     let readStream;
     if (shouldUseV4Process) {
       readStream = await this.generateReadStreamToOperateOnlyDescendants(targetPage.path, user);
@@ -1605,6 +1650,10 @@ class PageService {
         try {
           count += batch.length;
           await deleteDescendants(batch, user);
+          const subscribedUsers = await Subscription.getSubscriptions(batch);
+          subscribedUsers.forEach((eachUser) => {
+            descendantsSubscribedSets.add(eachUser);
+          });
           logger.debug(`Deleting pages progressing: (count=${count})`);
         }
         catch (err) {
@@ -1669,7 +1718,7 @@ class PageService {
     return;
   }
 
-  async deleteCompletely(page, user, options = {}, isRecursively = false, preventEmitting = false) {
+  async deleteCompletely(page, user, options = {}, isRecursively = false, preventEmitting = false, activityParameters?) {
     /*
      * Common Operation
      */
@@ -1699,6 +1748,20 @@ class PageService {
 
     logger.debug('Deleting completely', paths);
 
+    const parameters = {
+      ip: activityParameters.ip,
+      endpoint: activityParameters.endpoint,
+      action: page.descendantCount > 0 ? SupportedAction.ACTION_PAGE_RECURSIVELY_DELETE_COMPLETELY : SupportedAction.ACTION_PAGE_DELETE_COMPLETELY,
+      user,
+      target: page,
+      targetModel: 'Page',
+      snapshot: {
+        username: user.username,
+      },
+    };
+
+    const activity = await this.crowi.activityService.createActivity(parameters);
+
     // 1. update descendantCount
     if (isRecursively) {
       const inc = page.isEmpty ? -page.descendantCount : -(page.descendantCount + 1);
@@ -1744,7 +1807,7 @@ class PageService {
        */
       (async() => {
         try {
-          await this.deleteCompletelyRecursivelyMainOperation(page, user, options, pageOp._id);
+          await this.deleteCompletelyRecursivelyMainOperation(page, user, options, pageOp._id, activity);
         }
         catch (err) {
           logger.error('Error occurred while running deleteCompletelyRecursivelyMainOperation.', err);
@@ -1756,12 +1819,18 @@ class PageService {
         }
       })();
     }
+    else {
+      this.activityEvent.emit('updated', activity, page);
+    }
 
     return;
   }
 
-  async deleteCompletelyRecursivelyMainOperation(page, user, options, pageOpId: ObjectIdLike): Promise<void> {
-    await this.deleteCompletelyDescendantsWithStream(page, user, options, false);
+  async deleteCompletelyRecursivelyMainOperation(page, user, options, pageOpId: ObjectIdLike, activity?): Promise<void> {
+    const descendantsSubscribedSets = new Set();
+    await this.deleteCompletelyDescendantsWithStream(page, user, options, false, descendantsSubscribedSets);
+    const descendantsSubscribedUsers = Array.from(descendantsSubscribedSets);
+    this.activityEvent.emit('updated', activity, page, descendantsSubscribedUsers);
 
     await PageOperation.findByIdAndDelete(pageOpId);
 
@@ -1794,7 +1863,7 @@ class PageService {
   /**
    * Create delete completely stream
    */
-  private async deleteCompletelyDescendantsWithStream(targetPage, user, options = {}, shouldUseV4Process = true): Promise<number> {
+  private async deleteCompletelyDescendantsWithStream(targetPage, user, options = {}, shouldUseV4Process = true, descendantsSubscribedSets?): Promise<number> {
     let readStream;
 
     if (shouldUseV4Process) { // pages don't have parents
@@ -1817,6 +1886,10 @@ class PageService {
         try {
           count += batch.length;
           await deleteMultipleCompletely(batch, user, options);
+          const subscribedUsers = await Subscription.getSubscriptions(batch);
+          subscribedUsers.forEach((eachUser) => {
+            descendantsSubscribedSets.add(eachUser);
+          });
           logger.debug(`Adding pages progressing: (count=${count})`);
         }
         catch (err) {
@@ -1900,13 +1973,27 @@ class PageService {
     }
   }
 
-  async revertDeletedPage(page, user, options = {}, isRecursively = false) {
+  async revertDeletedPage(page, user, options = {}, isRecursively = false, activityParameters?) {
     /*
      * Common Operation
      */
     const Page = this.crowi.model('Page');
     const PageTagRelation = this.crowi.model('PageTagRelation');
 
+    const parameters = {
+      ip: activityParameters.ip,
+      endpoint: activityParameters.endpoint,
+      action: page.descendantCount > 0 ? SupportedAction.ACTION_PAGE_RECURSIVELY_REVERT : SupportedAction.ACTION_PAGE_REVERT,
+      user,
+      target: page,
+      targetModel: 'Page',
+      snapshot: {
+        username: user.username,
+      },
+    };
+
+    const activity = await this.crowi.activityService.createActivity(parameters);
+
     // 1. Separate v4 & v5 process
     const shouldUseV4Process = this.shouldUseV4ProcessForRevert(page);
     if (shouldUseV4Process) {
@@ -1941,6 +2028,7 @@ class PageService {
 
     if (!isRecursively) {
       await this.updateDescendantCountOfAncestors(parent._id, 1, true);
+      this.activityEvent.emit('updated', activity, page);
     }
     else {
       let pageOp;
@@ -1964,7 +2052,7 @@ class PageService {
        */
       (async() => {
         try {
-          await this.revertRecursivelyMainOperation(page, user, options, pageOp._id);
+          await this.revertRecursivelyMainOperation(page, user, options, pageOp._id, activity);
         }
         catch (err) {
           logger.error('Error occurred while running revertRecursivelyMainOperation.', err);
@@ -1980,10 +2068,13 @@ class PageService {
     return updatedPage;
   }
 
-  async revertRecursivelyMainOperation(page, user, options, pageOpId: ObjectIdLike): Promise<void> {
+  async revertRecursivelyMainOperation(page, user, options, pageOpId: ObjectIdLike, activity?): Promise<void> {
     const Page = mongoose.model('Page') as unknown as PageModel;
 
-    await this.revertDeletedDescendantsWithStream(page, user, options, false);
+    const descendantsSubscribedSets = new Set();
+    await this.revertDeletedDescendantsWithStream(page, user, options, false, descendantsSubscribedSets);
+    const descendantsSubscribedUsers = Array.from(descendantsSubscribedSets);
+    this.activityEvent.emit('updated', activity, page, descendantsSubscribedUsers);
 
     const newPath = Page.getRevertDeletedPageName(page.path);
     // normalize parent of descendant pages
@@ -2058,7 +2149,7 @@ class PageService {
   /**
    * Create revert stream
    */
-  private async revertDeletedDescendantsWithStream(targetPage, user, options = {}, shouldUseV4Process = true): Promise<number> {
+  private async revertDeletedDescendantsWithStream(targetPage, user, options = {}, shouldUseV4Process = true, descendantsSubscribedSets?): Promise<number> {
     if (shouldUseV4Process) {
       return this.revertDeletedDescendantsWithStreamV4(targetPage, user, options);
     }
@@ -2073,6 +2164,10 @@ class PageService {
         try {
           count += batch.length;
           await revertDeletedDescendants(batch, user);
+          const subscribedUsers = await Subscription.getSubscriptions(batch);
+          subscribedUsers.forEach((eachUser) => {
+            descendantsSubscribedSets.add(eachUser);
+          });
           logger.debug(`Reverting pages progressing: (count=${count})`);
         }
         catch (err) {

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

@@ -1,9 +1,9 @@
 {% extends '../layout/admin.html' %}
 
-{% block html_title %}{{ customizeService.generateCustomTitleForFixedPageName(t('Customize')) }}{% endblock %}
+{% block html_title %}{{ customizeService.generateCustomTitleForFixedPageName(t('admin:customize')) }}{% endblock %}
 
 {% block content_header %}
-<h1 class="title">{{ t('Customize') }}</h1>
+<h1 class="title">{{ t('admin:customize') }}</h1>
 {% endblock %}
 
 {% block content_main %}

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

@@ -1,9 +1,9 @@
 {% extends '../layout/admin.html' %}
 
-{% block html_title %}{{ customizeService.generateCustomTitleForFixedPageName(t('Export Archive Data')) }}{% endblock %}
+{% block html_title %}{{ customizeService.generateCustomTitleForFixedPageName(t('export_archive_data')) }}{% endblock %}
 
 {% block content_header %}
-<h1 class="title">{{ t('Export Archive Data') }}</h1>
+<h1 class="title">{{ t('export_archive_data') }}</h1>
 {% endblock %}
 
 {% block content_main %}

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

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

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

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

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

@@ -1,9 +1,9 @@
 {% extends '../layout/admin.html' %}
 
-{% block html_title %}{{ customizeService.generateCustomTitleForFixedPageName(t('full_text_search_management')) }}{% endblock %}
+{% block html_title %}{{ customizeService.generateCustomTitleForFixedPageName(t('full_text_search_management.full_text_search_management')) }}{% endblock %}
 
 {% block content_header %}
-<h1 class="title">{{ t('full_text_search_management') }}</h1>
+<h1 class="title">{{ t('full_text_search_management.full_text_search_management') }}</h1>
 {% endblock %}
 
 {% block content_main %}

+ 3 - 0
packages/app/src/stores/personal-settings.tsx

@@ -1,3 +1,4 @@
+import { useTranslation } from 'next-i18next';
 import useSWR, { SWRResponse } from 'swr';
 
 
@@ -27,6 +28,7 @@ export type IPersonalSettingsInfoOption = {
 }
 
 export const usePersonalSettings = (): SWRResponse<IUser, Error> & IPersonalSettingsInfoOption => {
+  const { i18n } = useTranslation();
   const { data: personalSettingsDataFromDB, mutate: revalidate } = useSWRxPersonalSettings();
   const key = personalSettingsDataFromDB != null ? 'personalSettingsInfo' : null;
 
@@ -57,6 +59,7 @@ export const usePersonalSettings = (): SWRResponse<IUser, Error> & IPersonalSett
     // invoke API
     try {
       await apiv3Put('/personal-setting/', updateData);
+      i18n.changeLanguage(updateData.lang);
     }
     catch (err) {
       logger.error(err);

+ 0 - 28
packages/app/src/styles/_editor-navbar.scss

@@ -1,28 +0,0 @@
-.editor-container {
-  .navbar-editor {
-    height: 30px;
-    padding: 0;
-
-    border-bottom: 1px solid transparent;
-
-    li {
-      display: inline-block;
-      i {
-        font-size: 16px;
-      }
-    }
-
-    button {
-      padding: 0px;
-      margin: 0 2px;
-      font-size: 1rem;
-      line-height: 1;
-      background-color: transparent;
-      border: none;
-    }
-
-    img {
-      vertical-align: bottom;
-    }
-  }
-}

+ 0 - 72
packages/app/src/styles/_editor-overlay.scss

@@ -1,72 +0,0 @@
-@mixin overlay-processing-style($additionalSelector, $contentFontSize: inherit, $contentPadding: inherit) {
-  .overlay.#{$additionalSelector} {
-    background: rgba(255, 255, 255, 0.5);
-    .overlay-content {
-      padding: $contentPadding;
-      font-size: $contentFontSize;
-      color: $gray-700;
-      background: rgba(200, 200, 200, 0.5);
-    }
-  }
-}
-
-// overlay in .editor-container
-.editor-container {
-  .overlay {
-    position: absolute;
-    top: 0;
-    right: 0;
-    bottom: 0;
-    left: 0;
-    z-index: 7; // forward than .CodeMirror-vscrollbar
-    display: flex;
-    align-items: center;
-    justify-content: center;
-  }
-
-  // loading keymap
-  @include overlay-processing-style(overlay-loading-keymap, 2.5em, 0.3em);
-
-  // cheat sheat
-  .overlay.overlay-gfm-cheatsheet {
-    align-items: flex-end;
-    justify-content: flex-end;
-
-    pointer-events: none;
-
-    .card.gfm-cheatsheet {
-      box-shadow: unset;
-      opacity: 0.6;
-      .card-body {
-        min-width: 30em;
-        padding-bottom: 0;
-        font-family: monospace;
-        color: $text-muted;
-      }
-      ul > li {
-        list-style: none;
-      }
-    }
-
-    .gfm-cheatsheet-modal-link {
-      color: $text-muted;
-      pointer-events: all;
-      cursor: pointer;
-      background-color: transparent;
-      border: none;
-
-      opacity: 0.6;
-
-      &:hover,
-      &:focus {
-        opacity: 1;
-      }
-    }
-  }
-}
-
-.modal-gfm-cheatsheet .modal-body {
-  .hljs {
-    font-family: $font-family-monospace;
-  }
-}

+ 20 - 0
packages/app/src/styles/_mixins.scss

@@ -199,3 +199,23 @@
     }
   }
 }
+
+@mixin overlay-processing-style($additionalSelector, $contentFontSize: inherit, $contentPadding: inherit) {
+  .overlay.#{$additionalSelector} {
+    background: rgba(255, 255, 255, 0.5);
+    .overlay-content {
+      padding: $contentPadding;
+      font-size: $contentFontSize;
+      color: bs.$gray-700;
+      background: rgba(200, 200, 200, 0.5);
+    }
+  }
+}
+
+@mixin insertSimpleLineIcons($code) {
+  &:before {
+    margin-right: 0.2em;
+    font-family: 'simple-line-icons';
+    content: $code;
+  }
+}

+ 2 - 48
packages/app/src/styles/_on-edit.scss

@@ -2,9 +2,8 @@
 @import './variables' ;
 @import './mixins' ;
 @import 'sidebar-wiki';
-@import 'editor-overlay';
-
 
+// global imported
 body.on-edit {
   overflow-y: hidden !important;
 
@@ -306,6 +305,7 @@ body.on-edit {
   }
 }
 
+// TODO: Never used this id class
 #tag-edit-button-tooltip {
   .tooltip-inner {
     color: black;
@@ -317,49 +317,3 @@ body.on-edit {
     border-bottom: 5px solid $gray-300;
   }
 }
-
-/*
- Grid Edit Modal
-*/
-
-.grw-grid-edit-modal {
-  .desktop-preview,
-  .tablet-preview,
-  .mobile-preview {
-    .row {
-      height: 140px;
-      margin: 0px;
-    }
-  }
-  .desktop-preview {
-    .row {
-      div {
-        padding: 0px;
-      }
-    }
-  }
-
-  .tablet-preview {
-    .row {
-      div {
-        padding: 0px;
-      }
-    }
-  }
-
-  .mobile-preview {
-    width: 75%;
-    .row {
-      div {
-        padding: 0px;
-      }
-    }
-  }
-
-  .grid-division-menu {
-    width: 60vw;
-    @include media-breakpoint-down(lg) {
-      width: 80vw;
-    }
-  }
-}

+ 2 - 7
packages/app/src/styles/_page_list.scss → packages/app/src/styles/molecules/_page_list.scss

@@ -1,11 +1,6 @@
-@use './bootstrap/variables' as var;
-
-body .page-list {
-  .page-list-container {
-    font-size: 15px;
-    line-height: 1.6em;
-  }
+@use '../bootstrap/variables' as var;
 
+.page-list :global {
   .btn-page-item-control {
     width: 20px;
     height: 20px;

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

@@ -55,7 +55,6 @@
 // @import 'old-ios';
 @import 'on-edit';
 // @import 'page-duplicate-modal';
-@import 'page_list';
 
 // @import 'page-path';
 // @import 'page-tree';

+ 38 - 13
packages/app/test/integration/service/page.test.js

@@ -386,7 +386,8 @@ describe('PageService', () => {
 
   describe('rename page without using renameDescendantsWithStreamSpy', () => {
     test('rename page with different tree with isRecursively [deeper]', async() => {
-      const resultPage = await crowi.pageService.renamePage(parentForRename6, '/parentForRename6/renamedChild', testUser1, { isRecursively: true });
+      const resultPage = await crowi.pageService.renamePage(parentForRename6, '/parentForRename6/renamedChild', testUser1, { isRecursively: true },
+        { ip: '::ffff:127.0.0.1', endpoint: '/_api/v3/pages/rename' });
       const wrongPage = await Page.findOne({ path: '/parentForRename6/renamedChild/renamedChild' });
       const expectPage1 = await Page.findOne({ path: '/parentForRename6/renamedChild' });
       const expectPage2 = await Page.findOne({ path: '/parentForRename6-2021H1' });
@@ -408,7 +409,8 @@ describe('PageService', () => {
 
       // when
       //   rename /level1/level2 --> /level1
-      await crowi.pageService.renamePage(parentForRename7, '/level1', testUser1, { isRecursively: true });
+      await crowi.pageService.renamePage(parentForRename7, '/level1', testUser1, { isRecursively: true },
+        { ip: '::ffff:127.0.0.1', endpoint: '/_api/v3/pages/rename' });
 
       // then
       expect(await Page.findOne({ path: '/level1' })).not.toBeNull();
@@ -438,7 +440,8 @@ describe('PageService', () => {
 
       test('rename page without options', async() => {
 
-        const resultPage = await crowi.pageService.renamePage(parentForRename1, '/renamed1', testUser2, {});
+        const resultPage = await crowi.pageService.renamePage(parentForRename1,
+          '/renamed1', testUser2, {}, { ip: '::ffff:127.0.0.1', endpoint: '/_api/v3/pages/rename' });
 
         expect(xssSpy).toHaveBeenCalled();
 
@@ -451,7 +454,8 @@ describe('PageService', () => {
 
       test('rename page with updateMetadata option', async() => {
 
-        const resultPage = await crowi.pageService.renamePage(parentForRename2, '/renamed2', testUser2, { updateMetadata: true });
+        const resultPage = await crowi.pageService.renamePage(parentForRename2, '/renamed2', testUser2, { updateMetadata: true },
+          { ip: '::ffff:127.0.0.1', endpoint: '/_api/v3/pages/rename' });
 
         expect(xssSpy).toHaveBeenCalled();
 
@@ -464,7 +468,8 @@ describe('PageService', () => {
 
       test('rename page with createRedirectPage option', async() => {
 
-        const resultPage = await crowi.pageService.renamePage(parentForRename3, '/renamed3', testUser2, { createRedirectPage: true });
+        const resultPage = await crowi.pageService.renamePage(parentForRename3, '/renamed3', testUser2, { createRedirectPage: true },
+          { ip: '::ffff:127.0.0.1', endpoint: '/_api/v3/pages/rename' });
 
         expect(xssSpy).toHaveBeenCalled();
         expect(pageEventSpy).toHaveBeenCalledWith('rename');
@@ -476,7 +481,8 @@ describe('PageService', () => {
 
       test('rename page with isRecursively', async() => {
 
-        const resultPage = await crowi.pageService.renamePage(parentForRename4, '/renamed4', testUser2, { isRecursively: true });
+        const resultPage = await crowi.pageService.renamePage(parentForRename4, '/renamed4', testUser2, { isRecursively: true },
+          { ip: '::ffff:127.0.0.1', endpoint: '/_api/v3/pages/rename' });
 
         expect(xssSpy).toHaveBeenCalled();
         expect(renameDescendantsWithStreamSpy).toHaveBeenCalled();
@@ -489,7 +495,8 @@ describe('PageService', () => {
 
       test('rename page with different tree with isRecursively', async() => {
 
-        const resultPage = await crowi.pageService.renamePage(parentForRename5, '/parentForRename5/renamedChild', testUser1, { isRecursively: true });
+        const resultPage = await crowi.pageService.renamePage(parentForRename5, '/parentForRename5/renamedChild', testUser1, { isRecursively: true },
+          { ip: '::ffff:127.0.0.1', endpoint: '/_api/v3/pages/rename' });
         const wrongPage = await Page.findOne({ path: '/parentForRename5/renamedChild/renamedChild' });
         const expectPage = await Page.findOne({ path: '/parentForRename5/renamedChild' });
 
@@ -644,7 +651,10 @@ describe('PageService', () => {
     });
 
     test('delete page without options', async() => {
-      const resultPage = await crowi.pageService.deletePage(parentForDelete1, testUser2, { });
+      const resultPage = await crowi.pageService.deletePage(parentForDelete1, testUser2, { }, false, {
+        ip: '::ffff:127.0.0.1',
+        endpoint: '/_api/v3/pages/delete',
+      });
 
       expect(getDeletedPageNameSpy).toHaveBeenCalled();
       expect(deleteDescendantsWithStreamSpy).not.toHaveBeenCalled();
@@ -661,7 +671,10 @@ describe('PageService', () => {
     });
 
     test('delete page with isRecursively', async() => {
-      const resultPage = await crowi.pageService.deletePage(parentForDelete2, testUser2, { }, true);
+      const resultPage = await crowi.pageService.deletePage(parentForDelete2, testUser2, { }, true, {
+        ip: '::ffff:127.0.0.1',
+        endpoint: '/_api/v3/pages/delete',
+      });
 
       expect(getDeletedPageNameSpy).toHaveBeenCalled();
       expect(deleteDescendantsWithStreamSpy).toHaveBeenCalled();
@@ -731,7 +744,10 @@ describe('PageService', () => {
     });
 
     test('delete completely without options', async() => {
-      await crowi.pageService.deleteCompletely(parentForDeleteCompletely, testUser2, { });
+      await crowi.pageService.deleteCompletely(parentForDeleteCompletely, testUser2, { }, false, false, {
+        ip: '::ffff:127.0.0.1',
+        endpoint: '/_api/v3/pages/deletecompletely',
+      });
 
       expect(deleteCompletelyOperationSpy).toHaveBeenCalled();
       expect(deleteCompletelyDescendantsWithStreamSpy).not.toHaveBeenCalled();
@@ -741,7 +757,10 @@ describe('PageService', () => {
 
 
     test('delete completely with isRecursively', async() => {
-      await crowi.pageService.deleteCompletely(parentForDeleteCompletely, testUser2, { }, true);
+      await crowi.pageService.deleteCompletely(parentForDeleteCompletely, testUser2, { }, true, false, {
+        ip: '::ffff:127.0.0.1',
+        endpoint: '/_api/v3/pages/deletecompletely',
+      });
 
       expect(deleteCompletelyOperationSpy).toHaveBeenCalled();
       expect(deleteCompletelyDescendantsWithStreamSpy).toHaveBeenCalled();
@@ -764,7 +783,10 @@ describe('PageService', () => {
     });
 
     test('revert deleted page when the redirect from page exists', async() => {
-      const resultPage = await crowi.pageService.revertDeletedPage(parentForRevert1, testUser2);
+      const resultPage = await crowi.pageService.revertDeletedPage(parentForRevert1, testUser2, {}, false, {
+        ip: '::ffff:127.0.0.1',
+        endpoint: '/_api/v3/pages/revert',
+      });
 
       expect(getRevertDeletedPageNameSpy).toHaveBeenCalledWith(parentForRevert1.path);
       expect(revertDeletedDescendantsWithStreamSpy).not.toHaveBeenCalled();
@@ -782,7 +804,10 @@ describe('PageService', () => {
         return null;
       });
 
-      const resultPage = await crowi.pageService.revertDeletedPage(parentForRevert2, testUser2, {}, true);
+      const resultPage = await crowi.pageService.revertDeletedPage(parentForRevert2, testUser2, {}, true, {
+        ip: '::ffff:127.0.0.1',
+        endpoint: '/_api/v3/pages/revert',
+      });
 
       expect(getRevertDeletedPageNameSpy).toHaveBeenCalledWith(parentForRevert2.path);
       expect(findByPathSpy).toHaveBeenCalledWith('/parentForRevert2');

+ 63 - 21
packages/app/test/integration/service/v5.non-public-page.test.ts

@@ -859,10 +859,10 @@ describe('PageService page operations with non-public pages', () => {
   });
 
   describe('Rename', () => {
-    const renamePage = async(page, newPagePath, user, options) => {
+    const renamePage = async(page, newPagePath, user, options, activityParameters?) => {
       // mock return value
       const mockedRenameSubOperation = jest.spyOn(crowi.pageService, 'renameSubOperation').mockReturnValue(null);
-      const renamedPage = await crowi.pageService.renamePage(page, newPagePath, user, options);
+      const renamedPage = await crowi.pageService.renamePage(page, newPagePath, user, options, activityParameters);
 
       // retrieve the arguments passed when calling method renameSubOperation inside renamePage method
       const argsForRenameSubOperation = mockedRenameSubOperation.mock.calls[0];
@@ -894,7 +894,11 @@ describe('PageService page operations with non-public pages', () => {
 
       const newPathForPage2 = '/np_rename1_destination/np_rename2';
       const newPathForPage3 = '/np_rename1_destination/np_rename2/np_rename3';
-      await renamePage(_page2, newPathForPage2, npDummyUser2, {});
+      await renamePage(_page2, newPathForPage2, npDummyUser2, {}, {
+        ip: '::ffff:127.0.0.1',
+        endpoint: '/_api/v3/pages/rename',
+        activityId: '62e291bc10e0ab61bd691794',
+      });
 
       const pageD = await Page.findOne({ path: _pathD, ..._propertiesD });
       const page2 = await Page.findOne({ path: _path2, ..._properties2 }); // not exist
@@ -930,7 +934,11 @@ describe('PageService page operations with non-public pages', () => {
       const newPathForPage3 = '/np_rename4_destination/np_rename5/np_rename6';
       let isThrown = false;
       try {
-        await renamePage(_page2, newPathForPage2, dummyUser1, {});
+        await renamePage(_page2, newPathForPage2, dummyUser1, {}, {
+          ip: '::ffff:127.0.0.1',
+          endpoint: '/_api/v3/pages/rename',
+          activityId: '62e291bc10e0ab61bd691794',
+        });
       }
       catch (err) {
         isThrown = true;
@@ -958,7 +966,11 @@ describe('PageService page operations with non-public pages', () => {
 
       const newPathForPage2 = '/np_rename7_destination/np_rename8';
       const newpathForPage3 = '/np_rename7_destination/np_rename8/np_rename9';
-      await renamePage(_page2, newPathForPage2, npDummyUser1, { isRecursively: true });
+      await renamePage(_page2, newPathForPage2, npDummyUser1, { isRecursively: true }, {
+        ip: '::ffff:127.0.0.1',
+        endpoint: '/_api/v3/pages/rename',
+        activityId: '62e291bc10e0ab61bd691794',
+      });
 
       const page2 = await Page.findOne({ path: _path2 }); // not exist
       const page3 = await Page.findOne({ path: _path3 }); // not renamed thus exist
@@ -1092,10 +1104,10 @@ describe('PageService page operations with non-public pages', () => {
   });
   describe('Delete', () => {
 
-    const deletePage = async(page, user, options, isRecursively) => {
+    const deletePage = async(page, user, options, isRecursively, activityParameters?) => {
       const mockedDeleteRecursivelyMainOperation = jest.spyOn(crowi.pageService, 'deleteRecursivelyMainOperation').mockReturnValue(null);
 
-      const deletedPage = await crowi.pageService.deletePage(page, user, options, isRecursively);
+      const deletedPage = await crowi.pageService.deletePage(page, user, options, isRecursively, activityParameters);
 
       const argsForDeleteRecursivelyMainOperation = mockedDeleteRecursivelyMainOperation.mock.calls[0];
 
@@ -1114,7 +1126,10 @@ describe('PageService page operations with non-public pages', () => {
         expect(_pageT).toBeTruthy();
 
         const isRecursively = false;
-        await deletePage(_pageT, dummyUser1, {}, isRecursively);
+        await deletePage(_pageT, dummyUser1, {}, isRecursively, {
+          ip: '::ffff:127.0.0.1',
+          endpoint: '/_api/v3/pages/rename',
+        });
 
         const pageT = await Page.findOne({ path: `/trash${_pathT}` });
         const pageN = await Page.findOne({ path: _pathT }); // should not exist
@@ -1131,7 +1146,10 @@ describe('PageService page operations with non-public pages', () => {
         expect(_page1).toBeTruthy();
 
         const isRecursively = false;
-        await deletePage(_page1, npDummyUser1, {}, isRecursively);
+        await deletePage(_page1, npDummyUser1, {}, isRecursively, {
+          ip: '::ffff:127.0.0.1',
+          endpoint: '/_api/v3/pages/rename',
+        });
 
         const pageN = await Page.findOne({ path: _path, grantedGroup: groupIdA });
         const page1 = await Page.findOne({ path: `/trash${_path}`, grantedGroup: groupIdA });
@@ -1157,7 +1175,10 @@ describe('PageService page operations with non-public pages', () => {
         expect(_pageR).toBeTruthy();
 
         const isRecursively = true;
-        await deletePage(_pageT, npDummyUser1, {}, isRecursively);
+        await deletePage(_pageT, npDummyUser1, {}, isRecursively, {
+          ip: '::ffff:127.0.0.1',
+          endpoint: '/_api/v3/pages/rename',
+        });
 
         const pageTNotExist = await Page.findOne({ path: _pathT, grant: Page.GRANT_USER_GROUP, grantedGroup: groupIdA }); // A should not exist
         const page1NotExist = await Page.findOne({ path: _path1, grant: Page.GRANT_USER_GROUP, grantedGroup: groupIdB }); // B should not exist
@@ -1187,10 +1208,10 @@ describe('PageService page operations with non-public pages', () => {
 
   });
   describe('Delete completely', () => {
-    const deleteCompletely = async(page, user, options = {}, isRecursively = false, preventEmitting = false) => {
+    const deleteCompletely = async(page, user, options = {}, isRecursively = false, preventEmitting = false, activityParameters?) => {
       const mockedDeleteCompletelyRecursivelyMainOperation = jest.spyOn(crowi.pageService, 'deleteCompletelyRecursivelyMainOperation').mockReturnValue(null);
 
-      await crowi.pageService.deleteCompletely(page, user, options, isRecursively, preventEmitting);
+      await crowi.pageService.deleteCompletely(page, user, options, isRecursively, preventEmitting, activityParameters);
 
       const argsForDeleteCompletelyRecursivelyMainOperation = mockedDeleteCompletelyRecursivelyMainOperation.mock.calls[0];
 
@@ -1209,7 +1230,10 @@ describe('PageService page operations with non-public pages', () => {
         const _page = await Page.findOne({ path: _path, grant: Page.GRANT_RESTRICTED });
         expect(_page).toBeTruthy();
 
-        await deleteCompletely(_page, dummyUser1, {}, false);
+        await deleteCompletely(_page, dummyUser1, {}, false, false, {
+          ip: '::ffff:127.0.0.1',
+          endpoint: '/_api/v3/pages/rename',
+        });
 
         const page = await Page.findOne({ path: _path, grant: Page.GRANT_RESTRICTED });
         expect(page).toBeNull();
@@ -1221,7 +1245,10 @@ describe('PageService page operations with non-public pages', () => {
         const _page = await Page.findOne({ path: _path, grant: Page.GRANT_USER_GROUP, grantedGroup: groupIdA });
         expect(_page).toBeTruthy();
 
-        await deleteCompletely(_page, npDummyUser1, {}, false);
+        await deleteCompletely(_page, npDummyUser1, {}, false, false, {
+          ip: '::ffff:127.0.0.1',
+          endpoint: '/_api/v3/pages/rename',
+        });
 
         const page = await Page.findOne({ path: _path, grant: Page.GRANT_USER_GROUP, grantedGroup: groupIdA });
         expect(page).toBeNull();
@@ -1241,7 +1268,10 @@ describe('PageService page operations with non-public pages', () => {
         expect(_page3).toBeTruthy();
         expect(_page4).toBeTruthy();
 
-        await deleteCompletely(_page1, npDummyUser1, {}, true);
+        await deleteCompletely(_page1, npDummyUser1, {}, true, false, {
+          ip: '::ffff:127.0.0.1',
+          endpoint: '/_api/v3/pages/rename',
+        });
 
         const page1 = await Page.findOne({ path: _path1, grant: Page.GRANT_USER_GROUP, grantedGroup: groupIdA });
         const page2 = await Page.findOne({ path: _path2, grant: Page.GRANT_USER_GROUP, grantedGroup: groupIdB });
@@ -1256,10 +1286,10 @@ describe('PageService page operations with non-public pages', () => {
     });
   });
   describe('revert', () => {
-    const revertDeletedPage = async(page, user, options = {}, isRecursively = false) => {
+    const revertDeletedPage = async(page, user, options = {}, isRecursively = false, activityParameters?) => {
       // mock return value
       const mockedRevertRecursivelyMainOperation = jest.spyOn(crowi.pageService, 'revertRecursivelyMainOperation').mockReturnValue(null);
-      const revertedPage = await crowi.pageService.revertDeletedPage(page, user, options, isRecursively);
+      const revertedPage = await crowi.pageService.revertDeletedPage(page, user, options, isRecursively, activityParameters);
 
       const argsForRecursivelyMainOperation = mockedRevertRecursivelyMainOperation.mock.calls[0];
 
@@ -1282,7 +1312,10 @@ describe('PageService page operations with non-public pages', () => {
       expect(tag).toBeTruthy();
       expect(deletedPageTagRelation).toBeTruthy();
 
-      await revertDeletedPage(trashedPage, dummyUser1, {}, false);
+      await revertDeletedPage(trashedPage, dummyUser1, {}, false, {
+        ip: '::ffff:127.0.0.1',
+        endpoint: '/_api/v3/pages/rename',
+      });
 
       const revertedPage = await Page.findOne({ path: '/np_revert1' });
       const deltedPageBeforeRevert = await Page.findOne({ path: '/trash/np_revert1' });
@@ -1309,7 +1342,10 @@ describe('PageService page operations with non-public pages', () => {
       expect(tag).toBeTruthy();
       expect(deletedPageTagRelation).toBeTruthy();
 
-      await revertDeletedPage(trashedPage, user1, {}, false);
+      await revertDeletedPage(trashedPage, user1, {}, false, {
+        ip: '::ffff:127.0.0.1',
+        endpoint: '/_api/v3/pages/revert',
+      });
 
       const revertedPage = await Page.findOne({ path: '/np_revert2' });
       const trashedPageBR = await Page.findOne({ path: beforeRevertPath });
@@ -1337,7 +1373,10 @@ describe('PageService page operations with non-public pages', () => {
       expect(revision1).toBeTruthy();
       expect(revision2).toBeTruthy();
 
-      await revertDeletedPage(trashedPage1, npDummyUser2, {}, true);
+      await revertDeletedPage(trashedPage1, npDummyUser2, {}, true, {
+        ip: '::ffff:127.0.0.1',
+        endpoint: '/_api/v3/pages/revert',
+      });
 
       const revertedPage = await Page.findOne({ path: '/np_revert3' });
       const middlePage = await Page.findOne({ path: '/np_revert3/middle' });
@@ -1376,7 +1415,10 @@ describe('PageService page operations with non-public pages', () => {
       expect(user).toBeTruthy();
       expect(nonExistantPage3).toBeNull();
 
-      await revertDeletedPage(trashedPage1, user, {}, true);
+      await revertDeletedPage(trashedPage1, user, {}, true, {
+        ip: '::ffff:127.0.0.1',
+        endpoint: '/_api/v3/pages/revert',
+      });
       const revertedPage1 = await Page.findOne({ path: '/np_revert5' });
       const newlyCreatedPage = await Page.findOne({ path: '/np_revert5/middle' });
       const revertedPage2 = await Page.findOne({ path: '/np_revert5/middle/np_revert6' });

+ 14 - 4
packages/app/test/integration/service/v5.page.test.ts

@@ -489,9 +489,9 @@ describe('Test page service methods', () => {
   });
 
   describe('restart renameOperation', () => {
-    const resumeRenameSubOperation = async(renamePage, pageOp) => {
+    const resumeRenameSubOperation = async(renamePage, pageOp, activity?) => {
       const mockedPathsAndDescendantCountOfAncestors = jest.spyOn(crowi.pageService, 'fixPathsAndDescendantCountOfAncestors').mockReturnValue(null);
-      await crowi.pageService.resumeRenameSubOperation(renamePage, pageOp);
+      await crowi.pageService.resumeRenameSubOperation(renamePage, pageOp, activity);
 
       const argsForRenameSubOperation = mockedPathsAndDescendantCountOfAncestors.mock.calls[0];
 
@@ -512,6 +512,9 @@ describe('Test page service methods', () => {
       const path2 = '/resume_rename_0/resume_rename_1/resume_rename_2';
       const path3 = '/resume_rename_0/resume_rename_1/resume_rename_2/resume_rename_3';
 
+      // activity options
+      const activity = 'randomActivityId';
+
       // page
       const _page0 = await Page.findOne({ path: _path0 });
       const _page1 = await Page.findOne({ path: _path1 });
@@ -536,7 +539,7 @@ describe('Test page service methods', () => {
       expect(_pageOperation).toBeTruthy();
 
       // rename
-      await resumeRenameSubOperation(_page1, _pageOperation);
+      await resumeRenameSubOperation(_page1, _pageOperation, activity);
 
       // page
       const page0 = await Page.findById(_page0._id);
@@ -573,6 +576,13 @@ describe('Test page service methods', () => {
       const path1 = '/resume_rename_8/resume_rename_9';
       const path2 = '/resume_rename_8/resume_rename_9/resume_rename_10';
 
+      // activity options
+      const activityParameters = {
+        ip: '::ffff:127.0.0.1',
+        endpoint: '/_api/v3/pages/rename',
+        activityId: '62e291bc10e0ab61bd691794',
+      };
+
       // page
       const _page0 = await Page.findOne({ path: _path0 });
       const _page1 = await Page.findOne({ path: _path1 });
@@ -594,7 +604,7 @@ describe('Test page service methods', () => {
       expect(_pageOperation).toBeTruthy();
 
       // rename
-      await resumeRenameSubOperation(_page1, _pageOperation);
+      await resumeRenameSubOperation(_page1, _pageOperation, activityParameters);
 
       // page
       const page0 = await Page.findById(_page0._id);

+ 127 - 36
packages/app/test/integration/service/v5.public-page.test.ts

@@ -429,6 +429,10 @@ describe('PageService page operations with only public pages', () => {
           createRedirectPage: false,
           updateMetadata: true,
         },
+        activityParameters: {
+          ip: '::ffff:127.0.0.1',
+          endpoint: '/_api/v3/pages/rename',
+        },
         unprocessableExpiryDate: null,
       },
     ]);
@@ -1139,10 +1143,10 @@ describe('PageService page operations with only public pages', () => {
 
   describe('Rename', () => {
 
-    const renamePage = async(page, newPagePath, user, options) => {
+    const renamePage = async(page, newPagePath, user, options, activityParameters?) => {
       // mock return value
       const mockedRenameSubOperation = jest.spyOn(crowi.pageService, 'renameSubOperation').mockReturnValue(null);
-      const renamedPage = await crowi.pageService.renamePage(page, newPagePath, user, options);
+      const renamedPage = await crowi.pageService.renamePage(page, newPagePath, user, options, activityParameters);
 
       // retrieve the arguments passed when calling method renameSubOperation inside renamePage method
       const argsForRenameSubOperation = mockedRenameSubOperation.mock.calls[0];
@@ -1159,7 +1163,7 @@ describe('PageService page operations with only public pages', () => {
     /**
      * This function only execute renameMainOperation. renameSubOperation is basically omitted(only return null)
      */
-    const renameMainOperation = async(page, newPagePath, user, options) => {
+    const renameMainOperation = async(page, newPagePath, user, options, activityParameters?) => {
       // create page operation from target page
       const pageOp = await PageOperation.create({
         actionType: PageActionType.Rename,
@@ -1173,7 +1177,7 @@ describe('PageService page operations with only public pages', () => {
 
       // mock return value
       const mockedRenameSubOperation = jest.spyOn(crowi.pageService, 'renameSubOperation').mockReturnValue(null);
-      const renamedPage = await crowi.pageService.renameMainOperation(page, newPagePath, user, options, pageOp._id);
+      const renamedPage = await crowi.pageService.renameMainOperation(page, newPagePath, user, options, pageOp._id, activityParameters);
 
       // restores the original implementation
       mockedRenameSubOperation.mockRestore();
@@ -1185,7 +1189,10 @@ describe('PageService page operations with only public pages', () => {
       expect(rootPage).toBeTruthy();
       let isThrown = false;
       try {
-        await crowi.pageService.renamePage(rootPage, '/new_root', dummyUser1, {});
+        await crowi.pageService.renamePage(rootPage, '/new_root', dummyUser1, {}, {
+          ip: '::ffff:127.0.0.1',
+          endpoint: '/_api/v3/pages/rename',
+        });
       }
       catch (err) {
         isThrown = true;
@@ -1201,7 +1208,10 @@ describe('PageService page operations with only public pages', () => {
       expect(parentPage).toBeTruthy();
 
       const newPath = '/v5_ParentForRename1/renamedChildForRename1';
-      const renamedPage = await renamePage(childPage, newPath, dummyUser1, {});
+      const renamedPage = await renamePage(childPage, newPath, dummyUser1, {}, {
+        ip: '::ffff:127.0.0.1',
+        endpoint: '/_api/v3/pages/rename',
+      });
       const childPageBeforeRename = await Page.findOne({ path: '/v5_ChildForRename1' });
 
       expect(xssSpy).toHaveBeenCalled();
@@ -1219,7 +1229,10 @@ describe('PageService page operations with only public pages', () => {
       expect(parentPage.isEmpty).toBe(true);
 
       const newPath = '/v5_ParentForRename2/renamedChildForRename2';
-      const renamedPage = await renamePage(childPage, newPath, dummyUser1, {});
+      const renamedPage = await renamePage(childPage, newPath, dummyUser1, {}, {
+        ip: '::ffff:127.0.0.1',
+        endpoint: '/_api/v3/pages/rename',
+      });
       const childPageBeforeRename = await Page.findOne({ path: '/v5_ChildForRename2' });
 
       expect(xssSpy).toHaveBeenCalled();
@@ -1238,7 +1251,10 @@ describe('PageService page operations with only public pages', () => {
 
       const newPath = '/v5_ParentForRename3/renamedChildForRename3';
       const oldUpdateAt = childPage.updatedAt;
-      const renamedPage = await renamePage(childPage, newPath, dummyUser2, { updateMetadata: true });
+      const renamedPage = await renamePage(childPage, newPath, dummyUser2, { updateMetadata: true }, {
+        ip: '::ffff:127.0.0.1',
+        endpoint: '/_api/v3/pages/rename',
+      });
 
       expect(xssSpy).toHaveBeenCalled();
       expect(renamedPage.path).toBe(newPath);
@@ -1255,7 +1271,10 @@ describe('PageService page operations with only public pages', () => {
 
       const oldPath = childPage.path;
       const newPath = '/v5_ParentForRename4/renamedChildForRename4';
-      const renamedPage = await renamePage(childPage, newPath, dummyUser2, { createRedirectPage: true });
+      const renamedPage = await renamePage(childPage, newPath, dummyUser2, { createRedirectPage: true }, {
+        ip: '::ffff:127.0.0.1',
+        endpoint: '/_api/v3/pages/rename',
+      });
       const pageRedirect = await PageRedirect.findOne({ fromPath: oldPath, toPath: renamedPage.path });
 
       expect(xssSpy).toHaveBeenCalled();
@@ -1274,7 +1293,10 @@ describe('PageService page operations with only public pages', () => {
       expect(grandchild).toBeTruthy();
 
       const newPath = '/v5_ParentForRename5/renamedChildForRename5';
-      const renamedPage = await renamePage(childPage, newPath, dummyUser1, {});
+      const renamedPage = await renamePage(childPage, newPath, dummyUser1, {}, {
+        ip: '::ffff:127.0.0.1',
+        endpoint: '/_api/v3/pages/rename',
+      });
       // find child of renamed page
       const renamedGrandchild = await Page.findOne({ parent: renamedPage._id });
       const childPageBeforeRename = await Page.findOne({ path: '/v5_ChildForRename5' });
@@ -1300,7 +1322,10 @@ describe('PageService page operations with only public pages', () => {
       expect(grandchild).toBeTruthy();
 
       const newPath = '/v5_ParentForRename7/renamedChildForRename7';
-      const renamedPage = await renamePage(childPage, newPath, dummyUser1, {});
+      const renamedPage = await renamePage(childPage, newPath, dummyUser1, {}, {
+        ip: '::ffff:127.0.0.1',
+        endpoint: '/_api/v3/pages/rename',
+      });
       const grandchildAfterRename = await Page.findOne({ parent: renamedPage._id });
       const grandchildBeforeRename = await Page.findOne({ path: '/v5_ChildForRename7/v5_GrandchildForRename7' });
 
@@ -1320,7 +1345,10 @@ describe('PageService page operations with only public pages', () => {
       const newPath = '/v5_ParentForRename9';
       let isThrown;
       try {
-        await renamePage(page, newPath, dummyUser1, {});
+        await renamePage(page, newPath, dummyUser1, {}, {
+          ip: '::ffff:127.0.0.1',
+          endpoint: '/_api/v3/pages/rename',
+        });
       }
       catch (err) {
         isThrown = true;
@@ -1339,7 +1367,10 @@ describe('PageService page operations with only public pages', () => {
 
       const newParentalPath = '/v5_pageForRename17/v5_pageForRename18';
       const newPath = newParentalPath + page1.path;
-      await renamePage(page1, newPath, dummyUser1, {});
+      await renamePage(page1, newPath, dummyUser1, {}, {
+        ip: '::ffff:127.0.0.1',
+        endpoint: '/_api/v3/pages/rename',
+      });
 
       const renamedPage = await Page.findOne({ path: newParentalPath + initialPathForPage1 });
       const renamedPageChild = await Page.findOne({ path: newParentalPath + initialPathForPage2 });
@@ -1376,7 +1407,10 @@ describe('PageService page operations with only public pages', () => {
 
       const newParentalPath = '/v5_pageForRename19/v5_pageForRename20';
       const newPath = newParentalPath + page1.path;
-      await renamePage(page1, newPath, dummyUser1, {});
+      await renamePage(page1, newPath, dummyUser1, {}, {
+        ip: '::ffff:127.0.0.1',
+        endpoint: '/_api/v3/pages/rename',
+      });
 
       const renamedPage = await Page.findOne({ path: newParentalPath + initialPathForPage1 });
       const renamedPageChild = await Page.findOne({ path: newParentalPath + initialPathForPage2 });
@@ -1417,7 +1451,10 @@ describe('PageService page operations with only public pages', () => {
       const newParentalPath = '/v5_pageForRename21/v5_pageForRename22/v5_pageForRename23';
       const newPath = newParentalPath + page1.path;
 
-      await renamePage(page1, newPath, dummyUser1, {});
+      await renamePage(page1, newPath, dummyUser1, {}, {
+        ip: '::ffff:127.0.0.1',
+        endpoint: '/_api/v3/pages/rename',
+      });
 
       const renamedPage = await Page.findOne({ path: newParentalPath + initialPathForPage1 });
       const renamedPageChild = await Page.findOne({ path: newParentalPath + initialPathForPage2 });
@@ -1519,7 +1556,11 @@ describe('PageService page operations with only public pages', () => {
       expect(_page1.descendantCount).toBe(0);
 
       // renameSubOperation only
-      await crowi.pageService.renameSubOperation(_page1, newPath, dummyUser1, {}, _page1, pageOperation._id);
+      await crowi.pageService.renameSubOperation(_page1, newPath, dummyUser1, {}, _page1, pageOperation._id, {
+        ip: '::ffff:127.0.0.1',
+        endpoint: '/_api/v3/pages/rename',
+        activityId: '62e291bc10e0ab61bd691794',
+      });
 
       // page
       const page0 = await Page.findById(_page0._id); // new parent
@@ -1561,7 +1602,10 @@ describe('PageService page operations with only public pages', () => {
       expect(_page1.descendantCount).toBe(1);
       expect(_page2.descendantCount).toBe(0);
 
-      await renamePage(_page1, newPath, dummyUser1, {});
+      await renamePage(_page1, newPath, dummyUser1, {}, {
+        ip: '::ffff:127.0.0.1',
+        endpoint: '/_api/v3/pages/rename',
+      });
 
       const page0 = await Page.findById(_page0._id); // new parent
       const page1 = await Page.findById(_page1._id); // renamed
@@ -1768,10 +1812,10 @@ describe('PageService page operations with only public pages', () => {
     });
   });
   describe('Delete', () => {
-    const deletePage = async(page, user, options, isRecursively) => {
+    const deletePage = async(page, user, options, isRecursively, activityParameters?) => {
       const mockedDeleteRecursivelyMainOperation = jest.spyOn(crowi.pageService, 'deleteRecursivelyMainOperation').mockReturnValue(null);
 
-      const deletedPage = await crowi.pageService.deletePage(page, user, options, isRecursively);
+      const deletedPage = await crowi.pageService.deletePage(page, user, options, isRecursively, activityParameters);
 
       const argsForDeleteRecursivelyMainOperation = mockedDeleteRecursivelyMainOperation.mock.calls[0];
 
@@ -1787,7 +1831,12 @@ describe('PageService page operations with only public pages', () => {
     test('Should NOT delete root page', async() => {
       let isThrown;
       expect(rootPage).toBeTruthy();
-      try { await deletePage(rootPage, dummyUser1, {}, false) }
+      try {
+        await deletePage(rootPage, dummyUser1, {}, false, {
+          ip: '::ffff:127.0.0.1',
+          endpoint: '/_api/v3/pages/delete',
+        });
+      }
       catch (err) { isThrown = true }
 
       const page = await Page.findOne({ path: '/' });
@@ -1801,7 +1850,12 @@ describe('PageService page operations with only public pages', () => {
       expect(trashedPage).toBeTruthy();
 
       let isThrown;
-      try { await deletePage(trashedPage, dummyUser1, {}, false) }
+      try {
+        await deletePage(trashedPage, dummyUser1, {}, false, {
+          ip: '::ffff:127.0.0.1',
+          endpoint: '/_api/v3/pages/delete',
+        });
+      }
       catch (err) { isThrown = true }
 
       const page = await Page.findOne({ path: '/trash/v5_PageForDelete1' });
@@ -1814,7 +1868,12 @@ describe('PageService page operations with only public pages', () => {
       const dummyUser1Page = await Page.findOne({ path: '/user/v5DummyUser1' });
       expect(dummyUser1Page).toBeTruthy();
       let isThrown;
-      try { await deletePage(dummyUser1Page, dummyUser1, {}, false) }
+      try {
+        await deletePage(dummyUser1Page, dummyUser1, {}, false, {
+          ip: '::ffff:127.0.0.1',
+          endpoint: '/_api/v3/pages/delete',
+        });
+      }
       catch (err) { isThrown = true }
 
       const page = await Page.findOne({ path: '/user/v5DummyUser1' });
@@ -1826,7 +1885,10 @@ describe('PageService page operations with only public pages', () => {
     test('Should delete single page', async() => {
       const pageToDelete = await Page.findOne({ path: '/v5_PageForDelete2' });
       expect(pageToDelete).toBeTruthy();
-      const deletedPage = await deletePage(pageToDelete, dummyUser1, {}, false);
+      const deletedPage = await deletePage(pageToDelete, dummyUser1, {}, false, {
+        ip: '::ffff:127.0.0.1',
+        endpoint: '/_api/v3/pages/delete',
+      });
       const page = await Page.findOne({ path: '/v5_PageForDelete2' });
 
       expect(page).toBeNull();
@@ -1842,7 +1904,10 @@ describe('PageService page operations with only public pages', () => {
       expect(parentPage).toBeTruthy();
       expect(childPage).toBeTruthy();
       expect(grandchildPage).toBeTruthy();
-      const deletedParentPage = await deletePage(parentPage, dummyUser1, {}, true);
+      const deletedParentPage = await deletePage(parentPage, dummyUser1, {}, true, {
+        ip: '::ffff:127.0.0.1',
+        endpoint: '/_api/v3/pages/delete',
+      });
       const deletedChildPage = await Page.findOne({ path: '/trash/v5_PageForDelete3/v5_PageForDelete4' });
       const deletedGrandchildPage = await Page.findOne({ path: '/trash/v5_PageForDelete3/v5_PageForDelete4/v5_PageForDelete5' });
 
@@ -1869,7 +1934,10 @@ describe('PageService page operations with only public pages', () => {
       expect(tag2).toBeTruthy();
       expect(pageRelation1).toBeTruthy();
       expect(pageRelation2).toBeTruthy();
-      const deletedPage = await deletePage(pageToDelete, dummyUser1, {}, false);
+      const deletedPage = await deletePage(pageToDelete, dummyUser1, {}, false, {
+        ip: '::ffff:127.0.0.1',
+        endpoint: '/_api/v3/pages/delete',
+      });
       const page = await Page.findOne({ path: '/v5_PageForDelete6' });
       const deletedTagRelation1 = await PageTagRelation.findOne({ _id: pageRelation1._id });
       const deletedTagRelation2 = await PageTagRelation.findOne({ _id: pageRelation2._id });
@@ -1881,10 +1949,10 @@ describe('PageService page operations with only public pages', () => {
     });
   });
   describe('Delete completely', () => {
-    const deleteCompletely = async(page, user, options = {}, isRecursively = false, preventEmitting = false) => {
+    const deleteCompletely = async(page, user, options = {}, isRecursively = false, preventEmitting = false, activityParameters?) => {
       const mockedDeleteCompletelyRecursivelyMainOperation = jest.spyOn(crowi.pageService, 'deleteCompletelyRecursivelyMainOperation').mockReturnValue(null);
 
-      await crowi.pageService.deleteCompletely(page, user, options, isRecursively, preventEmitting);
+      await crowi.pageService.deleteCompletely(page, user, options, isRecursively, preventEmitting, activityParameters);
 
       const argsForDeleteCompletelyRecursivelyMainOperation = mockedDeleteCompletelyRecursivelyMainOperation.mock.calls[0];
 
@@ -1900,7 +1968,12 @@ describe('PageService page operations with only public pages', () => {
     test('Should NOT completely delete root page', async() => {
       expect(rootPage).toBeTruthy();
       let isThrown;
-      try { await deleteCompletely(rootPage, dummyUser1, {}, false) }
+      try {
+        await deleteCompletely(rootPage, dummyUser1, {}, false, false, {
+          ip: '::ffff:127.0.0.1',
+          endpoint: '/_api/v3/pages/deletecompletely',
+        });
+      }
       catch (err) { isThrown = true }
       const page = await Page.findOne({ path: '/' });
       expect(page).toBeTruthy();
@@ -1910,7 +1983,10 @@ describe('PageService page operations with only public pages', () => {
       const page = await Page.findOne({ path: '/v5_PageForDeleteCompletely1' });
       expect(page).toBeTruthy();
 
-      await deleteCompletely(page, dummyUser1, {}, false);
+      await deleteCompletely(page, dummyUser1, {}, false, false, {
+        ip: '::ffff:127.0.0.1',
+        endpoint: '/_api/v3/pages/deletecompletely',
+      });
       const deletedPage = await Page.findOne({ _id: page._id, path: '/v5_PageForDeleteCompletely1' });
 
       expect(deletedPage).toBeNull();
@@ -1943,7 +2019,10 @@ describe('PageService page operations with only public pages', () => {
       expect(shareLink1).toBeTruthy();
       expect(shareLink2).toBeTruthy();
 
-      await deleteCompletely(parentPage, dummyUser1, {}, true);
+      await deleteCompletely(parentPage, dummyUser1, {}, true, false, {
+        ip: '::ffff:127.0.0.1',
+        endpoint: '/_api/v3/pages/deletecompletely',
+      });
       const deletedPages = await Page.find({ _id: { $in: [parentPage._id, childPage._id, grandchildPage._id] } });
       const deletedRevisions = await Revision.find({ pageId: { $in: [parentPage._id, grandchildPage._id] } });
       const tags = await Tag.find({ _id: { $in: [tag1?._id, tag2?._id] } });
@@ -1975,7 +2054,10 @@ describe('PageService page operations with only public pages', () => {
       const revision = await Revision.findOne({ pageId: page._id });
       expect(page).toBeTruthy();
       expect(revision).toBeTruthy();
-      await deleteCompletely(page, dummyUser1, {}, false);
+      await deleteCompletely(page, dummyUser1, {}, false, false, {
+        ip: '::ffff:127.0.0.1',
+        endpoint: '/_api/v3/pages/deletecompletely',
+      });
       const deltedPage = await Page.findOne({ _id: page._id });
       const deltedRevision = await Revision.findOne({ _id: revision._id });
 
@@ -1990,7 +2072,10 @@ describe('PageService page operations with only public pages', () => {
       expect(childPage).toBeTruthy();
       expect(grandchildPage).toBeTruthy();
 
-      await deleteCompletely(childPage, dummyUser1, {}, false);
+      await deleteCompletely(childPage, dummyUser1, {}, false, false, {
+        ip: '::ffff:127.0.0.1',
+        endpoint: '/_api/v3/pages/deletecompletely',
+      });
       const parentPageAfterDelete = await Page.findOne({ path: '/v5_PageForDeleteCompletely6' });
       const childPageAfterDelete = await Page.findOne({ path: '/v5_PageForDeleteCompletely6/v5_PageForDeleteCompletely7' });
       const grandchildPageAfterDelete = await Page.findOne({ path: '/v5_PageForDeleteCompletely6/v5_PageForDeleteCompletely7/v5_PageForDeleteCompletely8' });
@@ -2008,10 +2093,10 @@ describe('PageService page operations with only public pages', () => {
     });
   });
   describe('revert', () => {
-    const revertDeletedPage = async(page, user, options = {}, isRecursively = false) => {
+    const revertDeletedPage = async(page, user, options = {}, isRecursively = false, activityParameters?) => {
       // mock return value
       const mockedRevertRecursivelyMainOperation = jest.spyOn(crowi.pageService, 'revertRecursivelyMainOperation').mockReturnValue(null);
-      const revertedPage = await crowi.pageService.revertDeletedPage(page, user, options, isRecursively);
+      const revertedPage = await crowi.pageService.revertDeletedPage(page, user, options, isRecursively, activityParameters);
 
       const argsForRecursivelyMainOperation = mockedRevertRecursivelyMainOperation.mock.calls[0];
 
@@ -2035,7 +2120,10 @@ describe('PageService page operations with only public pages', () => {
       expect(tag).toBeTruthy();
       expect(deletedPageTagRelation).toBeTruthy();
 
-      const revertedPage = await revertDeletedPage(deletedPage, dummyUser1, {}, false);
+      const revertedPage = await revertDeletedPage(deletedPage, dummyUser1, {}, false, {
+        ip: '::ffff:127.0.0.1',
+        endpoint: '/_api/v3/pages/revert',
+      });
       const pageTagRelation = await PageTagRelation.findOne({ relatedPage: deletedPage._id, relatedTag: tag?._id });
 
       expect(revertedPage.parent).toStrictEqual(rootPage._id);
@@ -2055,7 +2143,10 @@ describe('PageService page operations with only public pages', () => {
       expect(revision1).toBeTruthy();
       expect(revision2).toBeTruthy();
 
-      const revertedPage1 = await revertDeletedPage(deletedPage1, dummyUser1, {}, true);
+      const revertedPage1 = await revertDeletedPage(deletedPage1, dummyUser1, {}, true, {
+        ip: '::ffff:127.0.0.1',
+        endpoint: '/_api/v3/pages/revert',
+      });
       const revertedPage2 = await Page.findOne({ _id: deletedPage2._id });
       const newlyCreatedPage = await Page.findOne({ path: '/v5_revert2/v5_revert3' });
 

+ 15 - 0
packages/core/src/interfaces/subscription.ts

@@ -1,6 +1,21 @@
+import { Ref } from './common';
+import { IPage } from './page';
+import { IUser } from './user';
+
 export const SubscriptionStatusType = {
   SUBSCRIBE: 'SUBSCRIBE',
   UNSUBSCRIBE: 'UNSUBSCRIBE',
 } as const;
 export const AllSubscriptionStatusType = Object.values(SubscriptionStatusType);
 export type SubscriptionStatusType = typeof SubscriptionStatusType[keyof typeof SubscriptionStatusType];
+
+export interface ISubscription {
+  user: Ref<IUser>
+  targetModel: string
+  target: Ref<IPage>
+  status: string
+  createdAt: Date
+
+  isSubscribing(): boolean
+  isUnsubscribing(): boolean
+}

+ 3 - 1
packages/plugin-lsx/src/components/LsxPageList/LsxListView.jsx

@@ -7,6 +7,8 @@ import { LsxContext } from '../lsx-context';
 
 import { LsxPage } from './LsxPage';
 
+import styles from './LsxListView.module.scss';
+
 export class LsxListView extends React.Component {
 
   render() {
@@ -35,7 +37,7 @@ export class LsxListView extends React.Component {
     }
 
     return (
-      <div className="page-list lsx">
+      <div className={`page-list ${styles['page-list']} lsx`}>
         <ul className="page-list-ul">
           {listView}
         </ul>

+ 1 - 0
packages/plugin-lsx/src/components/LsxPageList/LsxListView.module.scss

@@ -0,0 +1 @@
+@use '~/styles/molecules/page_list';