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

Merge branch 'dev/7.4.x' into fix/176020-prevent-submission-during-text-composition-in-page-tree

Shun Miyazawa 3 месяцев назад
Родитель
Сommit
e90a544f8f
100 измененных файлов с 2427 добавлено и 1258 удалено
  1. 2 0
      apps/app/.eslintrc.js
  2. 2 0
      apps/app/config/logger/config.dev.js
  3. 1 0
      apps/app/package.json
  4. 34 0
      apps/app/public/images/customize-settings/collapsed-dark.svg
  5. 4 1
      apps/app/public/images/customize-settings/collapsed-light.svg
  6. 0 31
      apps/app/public/images/customize-settings/drawer-dark.svg
  7. 3 6
      apps/app/public/static/locales/en_US/admin.json
  8. 3 6
      apps/app/public/static/locales/fr_FR/admin.json
  9. 3 6
      apps/app/public/static/locales/ja_JP/admin.json
  10. 3 6
      apps/app/public/static/locales/ko_KR/admin.json
  11. 3 6
      apps/app/public/static/locales/zh_CN/admin.json
  12. 11 19
      apps/app/src/client/components/Admin/App/AppSetting.jsx
  13. 5 40
      apps/app/src/client/components/Admin/Customize/CustomizeSidebarSetting.tsx
  14. 1 0
      apps/app/src/client/components/LoginForm/LoginForm.tsx
  15. 0 4
      apps/app/src/client/components/Navbar/GrowiContextualSubNavigation.tsx
  16. 0 4
      apps/app/src/client/components/PageAccessoriesModal/PageAccessoriesModal.tsx
  17. 4 0
      apps/app/src/client/components/PageAccessoriesModal/dynamic.tsx
  18. 1 11
      apps/app/src/client/components/PageControls/PageControls.tsx
  19. 6 11
      apps/app/src/client/services/AdminAppContainer.js
  20. 26 27
      apps/app/src/client/services/AdminCustomizeContainer.js
  21. 9 10
      apps/app/src/client/services/AdminExternalAccountsContainer.js
  22. 123 70
      apps/app/src/client/services/AdminGeneralSecurityContainer.js
  23. 27 18
      apps/app/src/client/services/AdminGitHubSecurityContainer.js
  24. 27 20
      apps/app/src/client/services/AdminGoogleSecurityContainer.js
  25. 4 8
      apps/app/src/client/services/AdminHomeContainer.js
  26. 0 2
      apps/app/src/client/services/AdminImportContainer.js
  27. 41 36
      apps/app/src/client/services/AdminLdapSecurityContainer.js
  28. 28 23
      apps/app/src/client/services/AdminLocalSecurityContainer.js
  29. 8 9
      apps/app/src/client/services/AdminMarkDownContainer.js
  30. 49 24
      apps/app/src/client/services/AdminNotificationContainer.js
  31. 67 52
      apps/app/src/client/services/AdminOidcSecurityContainer.js
  32. 51 38
      apps/app/src/client/services/AdminSamlSecurityContainer.js
  33. 5 5
      apps/app/src/client/services/AdminSlackIntegrationLegacyContainer.js
  34. 0 1
      apps/app/src/client/services/AdminSocketIoContainer.js
  35. 20 16
      apps/app/src/client/services/AdminUsersContainer.js
  36. 7 2
      apps/app/src/client/services/create-page/create-page.ts
  37. 95 81
      apps/app/src/client/services/create-page/use-create-page.tsx
  38. 21 18
      apps/app/src/client/services/create-page/use-create-template-page.ts
  39. 8 3
      apps/app/src/client/services/g2g-transfer.ts
  40. 0 1
      apps/app/src/client/services/maintenance-mode.ts
  41. 91 49
      apps/app/src/client/services/page-operation.ts
  42. 111 83
      apps/app/src/client/services/renderer/renderer.tsx
  43. 107 62
      apps/app/src/client/services/side-effects/drawio-modal-launcher-for-view.ts
  44. 117 64
      apps/app/src/client/services/side-effects/handsontable-modal-launcher-for-view.ts
  45. 4 6
      apps/app/src/client/services/side-effects/hash-changed.ts
  46. 65 41
      apps/app/src/client/services/side-effects/page-updated.ts
  47. 1 1
      apps/app/src/client/services/side-effects/use-sticky.ts
  48. 3 2
      apps/app/src/client/services/update-page/conflict.tsx
  49. 7 2
      apps/app/src/client/services/update-page/update-page.ts
  50. 16 10
      apps/app/src/client/services/update-page/use-update-page.tsx
  51. 25 12
      apps/app/src/client/services/upload-attachments/upload-attachments.ts
  52. 4 4
      apps/app/src/client/services/use-print-mode.ts
  53. 23 22
      apps/app/src/client/services/use-start-editing.tsx
  54. 13 10
      apps/app/src/client/services/use-toastr-on-error.tsx
  55. 16 5
      apps/app/src/client/services/user-ui-settings.ts
  56. 26 9
      apps/app/src/client/util/apiv1-client.ts
  57. 29 10
      apps/app/src/client/util/apiv3-client.ts
  58. 48 12
      apps/app/src/client/util/bookmark-utils.ts
  59. 14 7
      apps/app/src/client/util/scope-util.test.ts
  60. 26 16
      apps/app/src/client/util/scope-util.ts
  61. 10 9
      apps/app/src/client/util/t-with-opt.ts
  62. 13 5
      apps/app/src/client/util/toastr.ts
  63. 36 30
      apps/app/src/client/util/use-input-validator.ts
  64. 1 0
      apps/app/src/interfaces/file-uploader.ts
  65. 0 1
      apps/app/src/interfaces/sidebar-config.ts
  66. 4 1
      apps/app/src/migrations/20211227060705-revision-path-to-page-id-schema-migration--fixed-8998.js
  67. 11 15
      apps/app/src/pages/[[...path]]/page-data-props.ts
  68. 36 2
      apps/app/src/pages/[[...path]]/server-side-props.ts
  69. 0 3
      apps/app/src/pages/basic-layout-page/get-server-side-props/sidebar-configurations.ts
  70. 5 0
      apps/app/src/server/models/user.js
  71. 7 4
      apps/app/src/server/routes/apiv3/app-settings/index.ts
  72. 4 18
      apps/app/src/server/routes/apiv3/customize-setting.js
  73. 1 1
      apps/app/src/server/routes/apiv3/forgot-password.js
  74. 28 3
      apps/app/src/server/routes/apiv3/g2g-transfer.ts
  75. 1 1
      apps/app/src/server/routes/apiv3/page/index.ts
  76. 1 1
      apps/app/src/server/routes/apiv3/page/update-page.ts
  77. 7 26
      apps/app/src/server/routes/apiv3/revisions.js
  78. 1 1
      apps/app/src/server/routes/attachment/api.js
  79. 4 4
      apps/app/src/server/routes/comment.js
  80. 4 2
      apps/app/src/server/routes/tag.js
  81. 5 13
      apps/app/src/server/service/config-manager/config-definition.ts
  82. 0 3
      apps/app/src/server/service/config-manager/config-manager.spec.ts
  83. 5 9
      apps/app/src/server/service/file-uploader/file-uploader.ts
  84. 129 0
      apps/app/src/server/service/file-uploader/none.ts
  85. 2 3
      apps/app/src/server/service/g2g-transfer.ts
  86. 123 0
      apps/app/src/server/service/growi-bridge/index.spec.ts
  87. 9 5
      apps/app/src/server/service/growi-bridge/index.ts
  88. 0 1
      apps/app/src/server/service/installer.ts
  89. 36 0
      apps/app/src/server/service/page/events/seen.ts
  90. 24 8
      apps/app/src/server/service/page/index.ts
  91. 117 2
      apps/app/src/server/service/revision/normalize-latest-revision-if-broken.integ.ts
  92. 87 1
      apps/app/src/server/service/revision/normalize-latest-revision-if-broken.ts
  93. 1 1
      apps/app/src/server/service/yjs/sync-ydoc.ts
  94. 1 1
      apps/app/src/server/service/yjs/yjs.ts
  95. 169 0
      apps/app/src/server/util/safe-path-utils.spec.ts
  96. 69 0
      apps/app/src/server/util/safe-path-utils.ts
  97. 11 11
      apps/app/src/states/page/hooks.ts
  98. 0 2
      apps/app/src/states/page/use-fetch-current-page.ts
  99. 1 6
      apps/app/src/states/ui/page-abilities.ts
  100. 16 23
      apps/app/src/states/ui/sidebar/hydrate.ts

+ 2 - 0
apps/app/.eslintrc.js

@@ -88,6 +88,8 @@ module.exports = {
     'src/server/service/page/**',
     'src/client/interfaces/**',
     'src/client/models/**',
+    'src/client/services/**',
+    'src/client/util/**',
   ],
   settings: {
     // resolve path aliases by eslint-import-resolver-typescript

+ 2 - 0
apps/app/config/logger/config.dev.js

@@ -15,6 +15,7 @@ module.exports = {
   'growi:routes:login': 'debug',
   'growi:routes:login-passport': 'debug',
   'growi:middleware:safe-redirect': 'debug',
+  'growi:services:page': 'debug',
   'growi:service:PassportService': 'debug',
   'growi:service:s2s-messaging:*': 'debug',
   'growi:service:yjs': 'debug',
@@ -31,6 +32,7 @@ module.exports = {
   'growi:service:g2g-transfer': 'debug',
 
   'growi:migration:add-installed-date-to-config': 'debug',
+  'growi:events:page:seen': 'debug',
 
   /*
    * configure level for client

+ 1 - 0
apps/app/package.json

@@ -191,6 +191,7 @@
     "passport-ldapauth": "^3.0.1",
     "passport-local": "^1.0.0",
     "passport-saml": "^3.2.0",
+    "pathe": "^2.0.3",
     "prop-types": "^15.8.1",
     "qs": "^6.11.1",
     "rate-limiter-flexible": "^2.3.7",

+ 34 - 0
apps/app/public/images/customize-settings/collapsed-dark.svg

@@ -0,0 +1,34 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="249" height="160" viewBox="0 0 249 160">
+  <g transform="translate(17766 9529)">
+    <rect width="249" height="160" rx="2" transform="translate(-17766 -9529)" fill="#2a2d33"/>
+    <g transform="translate(-17700 -9500)">
+      <rect width="170" height="5" transform="translate(0 10)" fill="#8e9aa7"/>
+      <rect width="42.646" height="5" fill="#8e9aa7"/>
+      <rect width="170" height="5" transform="translate(0 20)" fill="#8e9aa7"/>
+      <rect width="170" height="5" transform="translate(0 30)" fill="#8e9aa7"/>
+    </g>
+    <g transform="translate(-17700 -9435)">
+      <rect width="170" height="5" transform="translate(0 10)" fill="#8e9aa7"/>
+      <rect width="42.646" height="5" fill="#8e9aa7"/>
+      <rect width="170" height="5" transform="translate(0 20)" fill="#8e9aa7"/>
+      <rect width="170" height="5" transform="translate(0 30)" fill="#8e9aa7"/>
+    </g>
+    <g transform="translate(-217 -20)">
+      <path d="M2,160H-2V0H2Z" transform="translate(-17461 -9509)" fill="#209fd8"/>
+      <rect width="86" height="160" transform="translate(-17549 -9509)" fill="#343a40"/>
+    </g>
+    <g transform="translate(-217 -9)">
+      <rect width="47" height="5" transform="translate(-17530 -9431)" fill="#8e9aa7"/>
+      <rect width="47" height="5" transform="translate(-17530 -9441)" fill="#8e9aa7"/>
+      <rect width="47" height="5" transform="translate(-17530 -9451)" fill="#8e9aa7"/>
+      <rect width="47" height="5" transform="translate(-17530 -9461)" fill="#8e9aa7"/>
+      <rect width="47" height="5" transform="translate(-17530 -9471)" fill="#8e9aa7"/>
+      <rect width="47" height="5" transform="translate(-17530 -9481)" fill="#8e9aa7"/>
+      <rect width="11.787" height="5" transform="translate(-17530 -9491)" fill="#8e9aa7"/>
+    </g>
+    <g transform="translate(-37 186)" fill="#2a2d33" stroke-linecap="round" stroke-linejoin="round">
+      <path d="M -17689.794921875 -9624.6689453125 L -17689.794921875 -9628.078125 L -17689.794921875 -9645.810546875 L -17689.794921875 -9649.60546875 L -17687.201171875 -9646.8359375 L -17675.93359375 -9634.8095703125 L -17674.029296875 -9632.7783203125 L -17676.7734375 -9632.3056640625 L -17682.544921875 -9631.3115234375 L -17687.28125 -9626.9716796875 L -17689.794921875 -9624.6689453125 Z" stroke="none"/>
+      <path d="M -17688.294921875 -9645.810546875 L -17688.294921875 -9628.078125 L -17683.234375 -9632.71484375 L -17677.02734375 -9633.7841796875 L -17688.294921875 -9645.810546875 M -17688.294921875 -9648.810546875 C -17687.482421875 -9648.810546875 -17686.68359375 -9648.4794921875 -17686.10546875 -9647.861328125 L -17674.837890625 -9635.8349609375 C -17674.083984375 -9635.0302734375 -17673.83203125 -9633.8759765625 -17674.18359375 -9632.830078125 C -17674.533203125 -9631.7841796875 -17675.431640625 -9631.0146484375 -17676.517578125 -9630.828125 L -17681.857421875 -9629.908203125 L -17686.267578125 -9625.8662109375 C -17687.14453125 -9625.0634765625 -17688.4140625 -9624.8525390625 -17689.50390625 -9625.33203125 C -17690.591796875 -9625.8115234375 -17691.294921875 -9626.888671875 -17691.294921875 -9628.078125 L -17691.294921875 -9645.810546875 C -17691.294921875 -9647.0419921875 -17690.54296875 -9648.1474609375 -17689.3984375 -9648.6005859375 C -17689.0390625 -9648.7421875 -17688.666015625 -9648.810546875 -17688.294921875 -9648.810546875 Z" stroke="none" fill="#fff"/>
+    </g>
+  </g>
+</svg>

+ 4 - 1
apps/app/public/images/customize-settings/drawer-light.svg → apps/app/public/images/customize-settings/collapsed-light.svg

@@ -13,7 +13,6 @@
       <rect width="170" height="5" transform="translate(0 20)" fill="#abb4bd"/>
       <rect width="170" height="5" transform="translate(0 30)" fill="#abb4bd"/>
     </g>
-    <rect width="249" height="160" transform="translate(-17766 -9529)" fill="#25272f" opacity="0.271"/>
     <g transform="translate(-217 -20)">
       <path d="M2,160H-2V0H2Z" transform="translate(-17461 -9509)" fill="#209fd8"/>
       <rect width="86" height="160" transform="translate(-17549 -9509)" fill="#f3f7fc"/>
@@ -27,5 +26,9 @@
       <rect width="47" height="5" transform="translate(-17530 -9481)" fill="#abb4bd"/>
       <rect width="11.787" height="5" transform="translate(-17530 -9491)" fill="#abb4bd"/>
     </g>
+    <g transform="translate(-37 186)" fill="#2a2d33" stroke-linecap="round" stroke-linejoin="round">
+      <path d="M -17689.794921875 -9624.6689453125 L -17689.794921875 -9628.078125 L -17689.794921875 -9645.810546875 L -17689.794921875 -9649.60546875 L -17687.201171875 -9646.8359375 L -17675.93359375 -9634.8095703125 L -17674.029296875 -9632.7783203125 L -17676.7734375 -9632.3056640625 L -17682.544921875 -9631.3115234375 L -17687.28125 -9626.9716796875 L -17689.794921875 -9624.6689453125 Z" stroke="none"/>
+      <path d="M -17688.294921875 -9645.810546875 L -17688.294921875 -9628.078125 L -17683.234375 -9632.71484375 L -17677.02734375 -9633.7841796875 L -17688.294921875 -9645.810546875 M -17688.294921875 -9648.810546875 C -17687.482421875 -9648.810546875 -17686.68359375 -9648.4794921875 -17686.10546875 -9647.861328125 L -17674.837890625 -9635.8349609375 C -17674.083984375 -9635.0302734375 -17673.83203125 -9633.8759765625 -17674.18359375 -9632.830078125 C -17674.533203125 -9631.7841796875 -17675.431640625 -9631.0146484375 -17676.517578125 -9630.828125 L -17681.857421875 -9629.908203125 L -17686.267578125 -9625.8662109375 C -17687.14453125 -9625.0634765625 -17688.4140625 -9624.8525390625 -17689.50390625 -9625.33203125 C -17690.591796875 -9625.8115234375 -17691.294921875 -9626.888671875 -17691.294921875 -9628.078125 L -17691.294921875 -9645.810546875 C -17691.294921875 -9647.0419921875 -17690.54296875 -9648.1474609375 -17689.3984375 -9648.6005859375 C -17689.0390625 -9648.7421875 -17688.666015625 -9648.810546875 -17688.294921875 -9648.810546875 Z" stroke="none" fill="#fff"/>
+    </g>
   </g>
 </svg>

+ 0 - 31
apps/app/public/images/customize-settings/drawer-dark.svg

@@ -1,31 +0,0 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="249" height="160" viewBox="0 0 249 160">
-  <g transform="translate(17766 9529)">
-    <rect width="249" height="160" rx="2" transform="translate(-17766 -9529)" fill="#2a2d33"/>
-    <g transform="translate(-17700 -9500)">
-      <rect width="170" height="5" transform="translate(0 10)" fill="#8e9aa7" stroke="rgba(0,0,0,0)" stroke-width="1"/>
-      <rect width="42.646" height="5" fill="#8e9aa7" stroke="rgba(0,0,0,0)" stroke-width="1"/>
-      <rect width="170" height="5" transform="translate(0 20)" fill="#8e9aa7" stroke="rgba(0,0,0,0)" stroke-width="1"/>
-      <rect width="170" height="5" transform="translate(0 30)" fill="#8e9aa7" stroke="rgba(0,0,0,0)" stroke-width="1"/>
-    </g>
-    <g transform="translate(-17700 -9435)">
-      <rect width="170" height="5" transform="translate(0 10)" fill="#8e9aa7" stroke="rgba(0,0,0,0)" stroke-width="1"/>
-      <rect width="42.646" height="5" fill="#8e9aa7" stroke="rgba(0,0,0,0)" stroke-width="1"/>
-      <rect width="170" height="5" transform="translate(0 20)" fill="#8e9aa7" stroke="rgba(0,0,0,0)" stroke-width="1"/>
-      <rect width="170" height="5" transform="translate(0 30)" fill="#8e9aa7" stroke="rgba(0,0,0,0)" stroke-width="1"/>
-    </g>
-    <rect width="249" height="160" transform="translate(-17766 -9529)" fill="#16171d" opacity="0.586"/>
-    <g transform="translate(-217 -20)">
-      <path d="M2,160H-2V0H2Z" transform="translate(-17461 -9509)" fill="#209fd8"/>
-      <rect width="86" height="160" transform="translate(-17549 -9509)" fill="#343a40"/>
-    </g>
-    <g transform="translate(-217 -9)">
-      <rect width="47" height="5" transform="translate(-17530 -9431)" fill="#8e9aa7" stroke="rgba(0,0,0,0)" stroke-width="1"/>
-      <rect width="47" height="5" transform="translate(-17530 -9441)" fill="#8e9aa7" stroke="rgba(0,0,0,0)" stroke-width="1"/>
-      <rect width="47" height="5" transform="translate(-17530 -9451)" fill="#8e9aa7" stroke="rgba(0,0,0,0)" stroke-width="1"/>
-      <rect width="47" height="5" transform="translate(-17530 -9461)" fill="#8e9aa7" stroke="rgba(0,0,0,0)" stroke-width="1"/>
-      <rect width="47" height="5" transform="translate(-17530 -9471)" fill="#8e9aa7" stroke="rgba(0,0,0,0)" stroke-width="1"/>
-      <rect width="47" height="5" transform="translate(-17530 -9481)" fill="#8e9aa7" stroke="rgba(0,0,0,0)" stroke-width="1"/>
-      <rect width="11.787" height="5" transform="translate(-17530 -9491)" fill="#8e9aa7" stroke="rgba(0,0,0,0)" stroke-width="1"/>
-    </g>
-  </g>
-</svg>

+ 3 - 6
apps/app/public/static/locales/en_US/admin.json

@@ -356,9 +356,9 @@
     "confidential_example": "ex): internal use only",
     "default_language": "Default language for new users",
     "default_mail_visibility": "Disclose e-mail for new users",
+    "default_read_only_for_new_user": "Editing Restrictions for New Users",
+    "set_read_only_for_new_user": "Set new users to read-only mode",
     "file_uploading": "File uploading",
-    "enable_files_except_image": "Enabling this option will allow upload of any file type. Without this option, only image file upload is supported.",
-    "attach_enable": "You can attach files other than image files if you enable this option.",
     "page_bulk_export_settings": "Page Bulk Export Settings",
     "enable_page_bulk_export": "Enable bulk export",
     "page_bulk_export_explanation": "Enables a feature that allows all users to export a page and all it's child pages at once from the menu. Exported data will be automatically deleted after the storage period has passed.",
@@ -448,10 +448,7 @@
     "customize_settings": "Customize",
     "default_sidebar_mode": {
       "title": "Default sidebar mode",
-      "desc": "You can set the sidebar mode for new users and guests visiting the page.",
-      "dock_mode_default_desc": "You can set the initial state of the sidebar when Dock Mode is selected.",
-      "dock_mode_default_open": "Open the page as it was opened from the beginning",
-      "dock_mode_default_close": "Open the page as it was closed from the beginning"
+      "desc": "You can set the sidebar mode for new users and guests visiting the page."
     },
     "layout": "Layout",
     "layout_options": {

+ 3 - 6
apps/app/public/static/locales/fr_FR/admin.json

@@ -356,9 +356,9 @@
     "confidential_example": "ex): usage interne seulement",
     "default_language": "Langue par défaut",
     "default_mail_visibility": "Mode d'affichage de l'adresse courriel",
+    "default_read_only_for_new_user": "Restriction d'édition pour les nouveaux utilisateurs",
+    "set_read_only_for_new_user": "Rendre les nouveaux utilisateurs en lecture seule",
     "file_uploading": "Téléversement de fichiers",
-    "enable_files_except_image": "Autoriser tout les types de fichiers",
-    "attach_enable": "Autorise le téléversement de tout les types de fichiers.",
     "page_bulk_export_settings": "Paramètres d'exportation de pages par lots",
     "enable_page_bulk_export": "Activer l'exportation groupée",
     "page_bulk_export_explanation": "Active une fonctionnalité qui permet à tous les utilisateurs d'exporter simultanément toutes les pages sélectionnées dans le menu des pages et leurs pages subordonnées. Les données exportées seront automatiquement supprimées une fois la période de conservation écoulée.",
@@ -448,10 +448,7 @@
     "customize_settings": "Interface",
     "default_sidebar_mode": {
       "title": "Barre latérale",
-      "desc": "Mode d'affichage et comportement par défaut de la barre latérale.",
-      "dock_mode_default_desc": "État initial de la barre latérale lorsque le mode Dock est sélectionné.",
-      "dock_mode_default_open": "Afficher la page comme si elle était ouverte",
-      "dock_mode_default_close": "Afficher la page comme si elle était fermée"
+      "desc": "Mode d'affichage et comportement par défaut de la barre latérale."
     },
     "layout": "Largeur du contenu",
     "layout_options": {

+ 3 - 6
apps/app/public/static/locales/ja_JP/admin.json

@@ -365,9 +365,9 @@
     "confidential_example": "例: 社外秘",
     "default_language": "新規ユーザーのデフォルト設定言語",
     "default_mail_visibility": "新規ユーザーの初期メール公開設定",
+    "default_read_only_for_new_user": "新規ユーザーの編集制限",
+    "set_read_only_for_new_user": "新規ユーザーを閲覧専用にする",
     "file_uploading": "ファイルアップロード",
-    "enable_files_except_image": "画像以外のファイルアップロードを許可",
-    "attach_enable": "許可をしている場合、画像以外のファイルをページに添付可能になります。",
     "page_bulk_export_settings": "ページ一括エクスポート設定",
     "enable_page_bulk_export": "一括エクスポートを有効にする",
     "page_bulk_export_explanation": "すべてのユーザーが、ページメニューから選択したページとその配下ページをまとめてエクスポートできる機能を有効化します。エクスポートされたデータは保存期間経過後に自動的に削除されます。",
@@ -457,10 +457,7 @@
     "customize_settings": "カスタマイズ",
     "default_sidebar_mode": {
       "title": "デフォルトのサイドバーモード",
-      "desc": "新規ユーザー、ページを訪れたゲストのサイドバーモードを設定できます。",
-      "dock_mode_default_desc": "Dock Mode選択時のサイドバーの初期状態を設定できます。",
-      "dock_mode_default_open": "初めから開いた状態でページを開く",
-      "dock_mode_default_close": "初めから閉じた状態でページを開く"
+      "desc": "新規ユーザー、ページを訪れたゲストのサイドバーモードを設定できます。"
     },
     "layout": "レイアウト",
     "layout_options": {

+ 3 - 6
apps/app/public/static/locales/ko_KR/admin.json

@@ -356,9 +356,9 @@
     "confidential_example": "예): 내부 전용",
     "default_language": "새 사용자를 위한 기본 언어",
     "default_mail_visibility": "새 사용자를 위한 이메일 공개",
+    "default_read_only_for_new_user": "신규 사용자의 편집 제한",
+    "set_read_only_for_new_user": "신규 사용자를 열람 전용으로 설정",
     "file_uploading": "파일 업로드",
-    "enable_files_except_image": "이 옵션을 활성화하면 모든 파일 형식을 업로드할 수 있습니다. 이 옵션이 없으면 이미지 파일 업로드만 지원됩니다.",
-    "attach_enable": "이 옵션을 활성화하면 이미지 파일 외의 파일을 첨부할 수 있습니다.",
     "page_bulk_export_settings": "페이지 대량 내보내기 설정",
     "enable_page_bulk_export": "대량 내보내기 활성화",
     "page_bulk_export_explanation": "모든 사용자가 메뉴에서 한 번에 페이지와 모든 하위 페이지를 내보낼 수 있는 기능을 활성화합니다. 내보낸 데이터는 저장 기간이 지나면 자동으로 삭제됩니다.",
@@ -448,10 +448,7 @@
     "customize_settings": "사용자 지정",
     "default_sidebar_mode": {
       "title": "기본 사이드바 모드",
-      "desc": "새 사용자 및 페이지를 방문하는 게스트를 위한 사이드바 모드를 설정할 수 있습니다.",
-      "dock_mode_default_desc": "독 모드가 선택되었을 때 사이드바의 초기 상태를 설정할 수 있습니다.",
-      "dock_mode_default_open": "처음부터 열린 상태로 페이지 열기",
-      "dock_mode_default_close": "처음부터 닫힌 상태로 페이지 열기"
+      "desc": "새 사용자 및 페이지를 방문하는 게스트를 위한 사이드바 모드를 설정할 수 있습니다."
     },
     "layout": "레이아웃",
     "layout_options": {

+ 3 - 6
apps/app/public/static/locales/zh_CN/admin.json

@@ -365,9 +365,9 @@
     "confidential_example": "ex):仅供内部使用",
     "default_language": "新用户的默认语言",
     "default_mail_visibility": "新用户的默认电子邮件可见性",
+    "default_read_only_for_new_user": "新用戶編輯限制",
+    "set_read_only_for_new_user": "將新用戶設為僅限瀏覽",
     "file_uploading": "文件上传",
-    "enable_files_except_image": "启用此选项将允许上传任何文件类型。如果没有此选项,则仅支持图像文件上载。",
-    "attach_enable": "如果启用此选项,则可以附加图像文件以外的文件。",
     "page_bulk_export_settings": "页面批量导出设置",
     "enable_page_bulk_export": "启用批量导出",
     "page_bulk_export_explanation": "启用一项功能,允许所有用户一次性导出从页面菜单中选择的所有页面及其下级页面。保留期限过后,导出的数据将自动删除。",
@@ -457,10 +457,7 @@
     "customize_settings": "页面定制",
     "default_sidebar_mode": {
       "title": "默认的侧边栏模式",
-      "desc": "你可以为新用户和访问该网页的客人设置侧边栏模式。",
-      "dock_mode_default_desc": "当选择Dock模式时,可以设置侧边栏的初始状态。",
-      "dock_mode_default_open": "从头开始翻页",
-      "dock_mode_default_close": "从头开始打开关闭的页面"
+      "desc": "你可以为新用户和访问该网页的客人设置侧边栏模式。"
     },
     "layout": "布局",
     "layout_options": {

+ 11 - 19
apps/app/src/client/components/Admin/App/AppSetting.jsx

@@ -35,14 +35,14 @@ const AppSetting = (props) => {
       globalLang: adminAppContainer.state.globalLang || 'en-US',
       // Convert boolean to string for radio button value
       isEmailPublishedForNewUser: String(adminAppContainer.state.isEmailPublishedForNewUser ?? true),
-      fileUpload: adminAppContainer.state.fileUpload ?? false,
+      isReadOnlyForNewUser: adminAppContainer.state.isReadOnlyForNewUser ?? false,
     });
   }, [
     adminAppContainer.state.title,
     adminAppContainer.state.confidential,
     adminAppContainer.state.globalLang,
     adminAppContainer.state.isEmailPublishedForNewUser,
-    adminAppContainer.state.fileUpload,
+    adminAppContainer.state.isReadOnlyForNewUser,
     reset,
   ]);
 
@@ -57,7 +57,7 @@ const AppSetting = (props) => {
       // Convert string 'true'/'false' to boolean
       const isEmailPublished = data.isEmailPublishedForNewUser === 'true' || data.isEmailPublishedForNewUser === true;
       await adminAppContainer.changeIsEmailPublishedForNewUserShow(isEmailPublished);
-      await adminAppContainer.changeFileUpload(data.fileUpload);
+      await adminAppContainer.changeIsReadOnlyForNewUserShow(data.isReadOnlyForNewUser);
 
       await adminAppContainer.updateAppSettingHandler();
       toastSuccess(t('commons:toaster.update_successed', { target: t('commons:headers.app_settings') }));
@@ -163,31 +163,23 @@ const AppSetting = (props) => {
         </div>
       </div>
 
-      <div className="row mb-2">
+      <div className="row mb-5">
         <label
           className="text-start text-md-end col-md-3 col-form-label"
         >
-          {/* {t('admin:app_setting.file_uploading')} */}
+          {t('admin:app_setting.default_read_only_for_new_user')}
         </label>
-        <div className="col-md-6">
-          <div className="form-check form-check-info">
+        <div className="col-md-6 py-2">
+
+          <div className="form-check form-check-inline">
             <input
               type="checkbox"
-              id="cbFileUpload"
+              id="checkbox-read-only-for-new-user"
               className="form-check-input"
-              {...register('fileUpload')}
+              {...register('isReadOnlyForNewUser')}
             />
-            <label
-              className="form-label form-check-label"
-              htmlFor="cbFileUpload"
-            >
-              {t('admin:app_setting.enable_files_except_image')}
-            </label>
+            <label className="form-label form-check-label" htmlFor="checkbox-read-only-for-new-user">{t('admin:app_setting.set_read_only_for_new_user')}</label>
           </div>
-
-          <p className="form-text text-muted">
-            {t('admin:app_setting.attach_enable')}
-          </p>
         </div>
       </div>
 

+ 5 - 40
apps/app/src/client/components/Admin/Customize/CustomizeSidebarSetting.tsx

@@ -12,11 +12,11 @@ const CustomizeSidebarsetting = (): JSX.Element => {
   const { t } = useTranslation(['admin', 'commons']);
 
   const {
-    data, update, setIsSidebarCollapsedMode, setIsSidebarClosedAtDockMode,
+    data, update, setIsSidebarCollapsedMode,
   } = useSWRxSidebarConfig();
 
   const { resolvedTheme } = useNextThemes();
-  const drawerIconFileName = `/images/customize-settings/drawer-${resolvedTheme}.svg`;
+  const collapsedIconFileName = `/images/customize-settings/collapsed-${resolvedTheme}.svg`;
   const dockIconFileName = `/images/customize-settings/dock-${resolvedTheme}.svg`;
 
   const onClickSubmit = useCallback(async() => {
@@ -33,7 +33,7 @@ const CustomizeSidebarsetting = (): JSX.Element => {
     return <LoadingSpinner />;
   }
 
-  const { isSidebarCollapsedMode, isSidebarClosedAtDockMode } = data;
+  const { isSidebarCollapsedMode } = data;
 
   return (
     <React.Fragment>
@@ -57,9 +57,9 @@ const CustomizeSidebarsetting = (): JSX.Element => {
                   role="button"
                 >
                   {/* eslint-disable-next-line @next/next/no-img-element */}
-                  <img src={drawerIconFileName} alt="Drawer Mode" />
+                  <img src={collapsedIconFileName} alt="Collapsed Mode" />
                   <div className="card-body text-center">
-                    Drawer Mode
+                    Collapsed Mode
                   </div>
                 </div>
               </div>
@@ -79,41 +79,6 @@ const CustomizeSidebarsetting = (): JSX.Element => {
             </div>
           </div>
 
-          <Card className="card custom-card bg-body-tertiary my-5">
-            <CardBody className="px-0 py-2">
-              {t('customize_settings.default_sidebar_mode.dock_mode_default_desc')}
-            </CardBody>
-          </Card>
-
-          <div className="px-3">
-            <div className="form-check my-3">
-              <input
-                type="radio"
-                id="is-open"
-                className="form-check-input"
-                checked={isSidebarCollapsedMode === false && isSidebarClosedAtDockMode === false}
-                disabled={isSidebarCollapsedMode}
-                onChange={() => setIsSidebarClosedAtDockMode(false)}
-              />
-              <label className="form-label form-check-label" htmlFor="is-open">
-                {t('customize_settings.default_sidebar_mode.dock_mode_default_open')}
-              </label>
-            </div>
-            <div className="form-check my-3">
-              <input
-                type="radio"
-                id="is-closed"
-                className="form-check-input"
-                checked={isSidebarCollapsedMode === false && isSidebarClosedAtDockMode === true}
-                disabled={isSidebarCollapsedMode}
-                onChange={() => setIsSidebarClosedAtDockMode(true)}
-              />
-              <label className="form-label form-check-label" htmlFor="is-closed">
-                {t('customize_settings.default_sidebar_mode.dock_mode_default_close')}
-              </label>
-            </div>
-          </div>
-
           <div className="row my-3">
             <div className="mx-auto">
               <button type="button" onClick={onClickSubmit} className="btn btn-primary">{ t('Update') }</button>

+ 1 - 0
apps/app/src/client/components/LoginForm/LoginForm.tsx

@@ -282,6 +282,7 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
 
       setIsSuccessToRagistration(true);
       resetRegisterErrors();
+      setIsLoading(false);
 
       const { redirectTo } = res.data;
 

+ 0 - 4
apps/app/src/client/components/Navbar/GrowiContextualSubNavigation.tsx

@@ -292,8 +292,6 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
   const [isStickyActive, setStickyActive] = useState(false);
 
   const path = currentPage?.path ?? currentPathname;
-  // const grant = currentPage?.grant ?? grantData?.grant;
-  // const grantUserGroupId = currentPage?.grantedGroup?._id ?? grantData?.grantedGroup?.id;
 
   const [isPageTemplateModalShown, setIsPageTempleteModalShown] = useState(false);
 
@@ -425,8 +423,6 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
                 editorMode={editorMode}
                 isBtnDisabled={!!isGuestUser || !!isReadOnlyUser}
                 path={path}
-              // grant={grant}
-              // grantUserGroupId={grantUserGroupId}
               />
             )}
 

+ 0 - 4
apps/app/src/client/components/PageAccessoriesModal/PageAccessoriesModal.tsx

@@ -18,8 +18,6 @@ import { CustomNavDropdown, CustomNavTab } from '../CustomNavigation/CustomNav';
 import CustomTabContent from '../CustomNavigation/CustomTabContent';
 import ExpandOrContractButton from '../ExpandOrContractButton';
 
-import { useAutoOpenModalByQueryParam } from './hooks';
-
 import styles from './PageAccessoriesModal.module.scss';
 
 
@@ -45,8 +43,6 @@ const PageAccessoriesModalSubstance = ({ isWindowExpanded, setIsWindowExpanded }
   const status = usePageAccessoriesModalStatus();
   const { close, selectContents } = usePageAccessoriesModalActions();
 
-  useAutoOpenModalByQueryParam();
-
   // Memoize heavy navTabMapping calculation
   const navTabMapping = useMemo(() => {
     return {

+ 4 - 0
apps/app/src/client/components/PageAccessoriesModal/dynamic.tsx

@@ -3,11 +3,15 @@ import type { JSX } from 'react';
 import { useLazyLoader } from '~/components/utils/use-lazy-loader';
 import { usePageAccessoriesModalStatus } from '~/states/ui/modal/page-accessories';
 
+import { useAutoOpenModalByQueryParam } from './hooks';
+
 type PageAccessoriesModalProps = Record<string, unknown>;
 
 export const PageAccessoriesModalLazyLoaded = (): JSX.Element => {
   const status = usePageAccessoriesModalStatus();
 
+  useAutoOpenModalByQueryParam();
+
   const PageAccessoriesModal = useLazyLoader<PageAccessoriesModalProps>(
     'page-accessories-modal',
     () => import('./PageAccessoriesModal').then(mod => ({ default: mod.PageAccessoriesModal })),

+ 1 - 11
apps/app/src/client/components/PageControls/PageControls.tsx

@@ -10,9 +10,7 @@ import {
 
   isIPageInfoForEntity, isIPageInfoForOperation,
 } from '@growi/core';
-import { pagePathUtils } from '@growi/core/dist/utils';
 import { useRect } from '@growi/ui/dist/utils';
-import { useAtomValue } from 'jotai';
 import { useTranslation } from 'next-i18next';
 import { DropdownItem } from 'reactstrap';
 
@@ -23,7 +21,6 @@ import { toastError } from '~/client/util/toastr';
 import OpenDefaultAiAssistantButton from '~/features/openai/client/components/AiAssistant/OpenDefaultAiAssistantButton';
 import { useIsGuestUser, useIsReadOnlyUser, useIsSearchPage } from '~/states/context';
 import { useCurrentPagePath } from '~/states/page';
-import { isUsersHomepageDeletionEnabledAtom } from '~/states/server-configurations';
 import { useDeviceLargerThanMd } from '~/states/ui/device';
 import {
   EditorMode, useEditorMode,
@@ -140,11 +137,8 @@ const PageControlsSubstance = (props: PageControlsSubstanceProps): JSX.Element =
   const { editorMode } = useEditorMode();
   const [isDeviceLargerThanMd] = useDeviceLargerThanMd();
   const isSearchPage = useIsSearchPage();
-  const isUsersHomepageDeletionEnabled = useAtomValue(isUsersHomepageDeletionEnabledAtom);
   const currentPagePath = useCurrentPagePath();
 
-  const isUsersHomepage = currentPagePath == null ? false : pagePathUtils.isUsersHomepage(currentPagePath);
-
   const { mutate: mutatePageInfo } = useSWRxPageInfo(pageId, shareLinkId);
 
   const likerIds = isIPageInfoForEntity(pageInfo) ? (pageInfo.likerIds ?? []).slice(0, 15) : [];
@@ -275,12 +269,8 @@ const PageControlsSubstance = (props: PageControlsSubstanceProps): JSX.Element =
       return false;
     }
 
-    if (isUsersHomepage && !isUsersHomepageDeletionEnabled) {
-      return false;
-    }
-
     return true;
-  }, [currentPagePath, isGuestUser, isUsersHomepage, isUsersHomepageDeletionEnabled]);
+  }, [currentPagePath, isGuestUser]);
 
   const additionalMenuItemOnTopRenderer = useMemo(() => {
     if (!isIPageInfoForEntity(pageInfo)) {

+ 6 - 11
apps/app/src/client/services/AdminAppContainer.js

@@ -8,7 +8,6 @@ import { apiv3Get, apiv3Post, apiv3Put } from '../util/apiv3-client';
  * @extends {Container} unstated Container
  */
 export default class AdminAppContainer extends Container {
-
   constructor() {
     super();
 
@@ -22,7 +21,7 @@ export default class AdminAppContainer extends Container {
       confidential: '',
       globalLang: '',
       isEmailPublishedForNewUser: true,
-      fileUpload: '',
+      isReadOnlyForNewUser: false,
 
       isV5Compatible: null,
       siteUrl: '',
@@ -42,7 +41,6 @@ export default class AdminAppContainer extends Container {
 
       isMaintenanceMode: false,
     };
-
   }
 
   /**
@@ -64,7 +62,7 @@ export default class AdminAppContainer extends Container {
       confidential: appSettingsParams.confidential,
       globalLang: appSettingsParams.globalLang,
       isEmailPublishedForNewUser: appSettingsParams.isEmailPublishedForNewUser,
-      fileUpload: appSettingsParams.fileUpload,
+      isReadOnlyForNewUser: appSettingsParams.isReadOnlyForNewUser,
       isV5Compatible: appSettingsParams.isV5Compatible,
       siteUrl: appSettingsParams.siteUrl,
       siteUrlUseOnlyEnvVars: appSettingsParams.siteUrlUseOnlyEnvVars,
@@ -113,10 +111,10 @@ export default class AdminAppContainer extends Container {
   }
 
   /**
-   * Change fileUpload
+   * Change isReadOnlyForNewUser
    */
-  changeFileUpload(fileUpload) {
-    this.setState({ fileUpload });
+  changeIsReadOnlyForNewUserShow(isReadOnlyForNewUser) {
+    this.setState({ isReadOnlyForNewUser });
   }
 
   /**
@@ -133,7 +131,6 @@ export default class AdminAppContainer extends Container {
     this.setState({ siteUrl });
   }
 
-
   /**
    * Change from address
    */
@@ -201,13 +198,12 @@ export default class AdminAppContainer extends Container {
       confidential: this.state.confidential,
       globalLang: this.state.globalLang,
       isEmailPublishedForNewUser: this.state.isEmailPublishedForNewUser,
-      fileUpload: this.state.fileUpload,
+      isReadOnlyForNewUser: this.state.isReadOnlyForNewUser,
     });
     const { appSettingParams } = response.data;
     return appSettingParams;
   }
 
-
   /**
    * Update site url setting
    * @memberOf AdminAppContainer
@@ -294,5 +290,4 @@ export default class AdminAppContainer extends Container {
   async endMaintenanceMode() {
     await apiv3Post('/app-settings/maintenance-mode', { flag: false });
   }
-
 }

+ 26 - 27
apps/app/src/client/services/AdminCustomizeContainer.js

@@ -14,7 +14,6 @@ const logger = loggerFactory('growi:services:AdminCustomizeContainer');
  * @extends {Container} unstated Container
  */
 export default class AdminCustomizeContainer extends Container {
-
   constructor() {
     super();
 
@@ -45,9 +44,9 @@ export default class AdminCustomizeContainer extends Container {
     this.switchPageListLimitationS = this.switchPageListLimitationS.bind(this);
     this.switchPageListLimitationM = this.switchPageListLimitationM.bind(this);
     this.switchPageListLimitationL = this.switchPageListLimitationL.bind(this);
-    this.switchPageListLimitationXL = this.switchPageListLimitationXL.bind(this);
+    this.switchPageListLimitationXL =
+      this.switchPageListLimitationXL.bind(this);
     this.switchShowPageSideAuthors = this.switchShowPageSideAuthors.bind(this);
-
   }
 
   /**
@@ -74,7 +73,8 @@ export default class AdminCustomizeContainer extends Container {
         pageLimitationXL: customizeParams.pageLimitationXL,
         isEnabledStaleNotification: customizeParams.isEnabledStaleNotification,
         isAllReplyShown: customizeParams.isAllReplyShown,
-        isSearchScopeChildrenAsDefault: customizeParams.isSearchScopeChildrenAsDefault,
+        isSearchScopeChildrenAsDefault:
+          customizeParams.isSearchScopeChildrenAsDefault,
         isEnabledMarp: customizeParams.isEnabledMarp,
         currentCustomizeTitle: customizeParams.customizeTitle,
         currentCustomizeNoscript: customizeParams.customizeNoscript,
@@ -82,30 +82,29 @@ export default class AdminCustomizeContainer extends Container {
         currentCustomizeScript: customizeParams.customizeScript,
         showPageSideAuthors: customizeParams.showPageSideAuthors,
       });
-    }
-    catch (err) {
+    } catch (err) {
       this.setState({ retrieveError: err });
       logger.error(err);
       throw new Error('Failed to fetch data');
     }
   }
 
-
   /**
    * Switch enabledTimeLine
    */
   switchEnableTimeline() {
-    this.setState({ isEnabledTimeline:  !this.state.isEnabledTimeline });
+    this.setState({ isEnabledTimeline: !this.state.isEnabledTimeline });
   }
 
   /**
    * Switch enabledAttachTitleHeader
    */
   switchEnabledAttachTitleHeader() {
-    this.setState({ isEnabledAttachTitleHeader:  !this.state.isEnabledAttachTitleHeader });
+    this.setState({
+      isEnabledAttachTitleHeader: !this.state.isEnabledAttachTitleHeader,
+    });
   }
 
-
   /**
    * S: Switch pageListLimitationS
    */
@@ -138,7 +137,9 @@ export default class AdminCustomizeContainer extends Container {
    * Switch enabledStaleNotification
    */
   switchEnableStaleNotification() {
-    this.setState({ isEnabledStaleNotification:  !this.state.isEnabledStaleNotification });
+    this.setState({
+      isEnabledStaleNotification: !this.state.isEnabledStaleNotification,
+    });
   }
 
   /**
@@ -152,7 +153,10 @@ export default class AdminCustomizeContainer extends Container {
    * Switch isSearchScopeChildrenAsDefault
    */
   switchIsSearchScopeChildrenAsDefault() {
-    this.setState({ isSearchScopeChildrenAsDefault: !this.state.isSearchScopeChildrenAsDefault });
+    this.setState({
+      isSearchScopeChildrenAsDefault:
+        !this.state.isSearchScopeChildrenAsDefault,
+    });
   }
 
   /**
@@ -212,7 +216,8 @@ export default class AdminCustomizeContainer extends Container {
         pageLimitationXL: this.state.pageLimitationXL,
         isEnabledStaleNotification: this.state.isEnabledStaleNotification,
         isAllReplyShown: this.state.isAllReplyShown,
-        isSearchScopeChildrenAsDefault: this.state.isSearchScopeChildrenAsDefault,
+        isSearchScopeChildrenAsDefault:
+          this.state.isSearchScopeChildrenAsDefault,
         showPageSideAuthors: this.state.showPageSideAuthors,
       });
       const { customizedParams } = response.data;
@@ -225,11 +230,11 @@ export default class AdminCustomizeContainer extends Container {
         pageLimitationXL: customizedParams.pageLimitationXL,
         isEnabledStaleNotification: customizedParams.isEnabledStaleNotification,
         isAllReplyShown: customizedParams.isAllReplyShown,
-        isSearchScopeChildrenAsDefault: customizedParams.isSearchScopeChildrenAsDefault,
+        isSearchScopeChildrenAsDefault:
+          customizedParams.isSearchScopeChildrenAsDefault,
         showPageSideAuthors: customizedParams.showPageSideAuthors,
       });
-    }
-    catch (err) {
+    } catch (err) {
       logger.error(err);
       throw new Error('Failed to update data');
     }
@@ -248,8 +253,7 @@ export default class AdminCustomizeContainer extends Container {
       this.setState({
         isEnabledMarp: customizedParams.isEnabledMarp,
       });
-    }
-    catch (err) {
+    } catch (err) {
       logger.error(err);
       throw new Error('Failed to update data');
     }
@@ -268,8 +272,7 @@ export default class AdminCustomizeContainer extends Container {
       this.setState({
         customizeTitle: customizedParams.customizeTitle,
       });
-    }
-    catch (err) {
+    } catch (err) {
       logger.error(err);
       throw new Error('Failed to update data');
     }
@@ -284,8 +287,7 @@ export default class AdminCustomizeContainer extends Container {
       this.setState({
         currentCustomizeNoscript: customizedParams.customizeNoscript,
       });
-    }
-    catch (err) {
+    } catch (err) {
       logger.error(err);
       throw new Error('Failed to update data');
     }
@@ -304,8 +306,7 @@ export default class AdminCustomizeContainer extends Container {
       this.setState({
         currentCustomizeCss: customizedParams.customizeCss,
       });
-    }
-    catch (err) {
+    } catch (err) {
       logger.error(err);
       throw new Error('Failed to update data');
     }
@@ -325,11 +326,9 @@ export default class AdminCustomizeContainer extends Container {
       this.setState({
         currentCustomizeScript: customizedParams.customizeScript,
       });
-    }
-    catch (err) {
+    } catch (err) {
       logger.error(err);
       throw new Error('Failed to update data');
     }
   }
-
 }

+ 9 - 10
apps/app/src/client/services/AdminExternalAccountsContainer.js

@@ -5,7 +5,6 @@ import loggerFactory from '~/utils/logger';
 
 import { apiv3Delete, apiv3Get } from '../util/apiv3-client';
 
-
 // eslint-disable-next-line no-unused-vars
 const logger = loggerFactory('growi:services:AdminexternalaccountsContainer');
 
@@ -14,7 +13,6 @@ const logger = loggerFactory('growi:services:AdminexternalaccountsContainer');
  * @extends {Container} unstated Container
  */
 export default class AdminExternalAccountsContainer extends Container {
-
   constructor() {
     super();
 
@@ -28,7 +26,6 @@ export default class AdminExternalAccountsContainer extends Container {
       activePage: 1,
       pagingLimit: Infinity,
     };
-
   }
 
   /**
@@ -38,28 +35,29 @@ export default class AdminExternalAccountsContainer extends Container {
     return 'AdminExternalAccountsContainer';
   }
 
-
   /**
    * syncExternalAccounts of selectedPage
    * @memberOf AdminExternalAccountsContainer
    * @param {number} selectedPage
    */
   async retrieveExternalAccountsByPagingNum(selectedPage) {
-
     const params = { page: selectedPage };
     const { data } = await apiv3Get('/users/external-accounts', params);
 
     if (data.paginateResult == null) {
-      throw new Error('data must conclude \'paginateResult\' property.');
+      throw new Error("data must conclude 'paginateResult' property.");
     }
-    const { docs: externalAccounts, totalDocs: totalAccounts, limit: pagingLimit } = data.paginateResult;
+    const {
+      docs: externalAccounts,
+      totalDocs: totalAccounts,
+      limit: pagingLimit,
+    } = data.paginateResult;
     this.setState({
       externalAccounts,
       totalAccounts,
       pagingLimit,
       activePage: selectedPage,
     });
-
   }
 
   /**
@@ -69,10 +67,11 @@ export default class AdminExternalAccountsContainer extends Container {
    * @param {string} externalAccountId id of the External Account to be removed
    */
   async removeExternalAccountById(externalAccountId) {
-    const res = await apiv3Delete(`/users/external-accounts/${externalAccountId}/remove`);
+    const res = await apiv3Delete(
+      `/users/external-accounts/${externalAccountId}/remove`,
+    );
     const deletedUserData = res.data.externalAccount;
     await this.retrieveExternalAccountsByPagingNum(this.state.activePage);
     return deletedUserData.accountId;
   }
-
 }

+ 123 - 70
apps/app/src/client/services/AdminGeneralSecurityContainer.js

@@ -2,8 +2,10 @@ import { isServer } from '@growi/core/dist/utils';
 import { Container } from 'unstated';
 
 import {
-  PageSingleDeleteConfigValue, PageSingleDeleteCompConfigValue,
-  PageRecursiveDeleteConfigValue, PageRecursiveDeleteCompConfigValue,
+  PageRecursiveDeleteCompConfigValue,
+  PageRecursiveDeleteConfigValue,
+  PageSingleDeleteCompConfigValue,
+  PageSingleDeleteConfigValue,
 } from '~/interfaces/page-delete-config';
 import { removeNullPropertyFromObject } from '~/utils/object-utils';
 
@@ -15,7 +17,6 @@ import { toastError } from '../util/toastr';
  * @extends {Container} unstated Container
  */
 export default class AdminGeneralSecurityContainer extends Container {
-
   constructor(appContainer) {
     super();
 
@@ -29,9 +30,12 @@ export default class AdminGeneralSecurityContainer extends Container {
       wikiMode: '',
       currentRestrictGuestMode: '',
       currentPageDeletionAuthority: PageSingleDeleteConfigValue.AdminOnly,
-      currentPageRecursiveDeletionAuthority: PageRecursiveDeleteConfigValue.Inherit,
-      currentPageCompleteDeletionAuthority: PageSingleDeleteCompConfigValue.AdminOnly,
-      currentPageRecursiveCompleteDeletionAuthority: PageRecursiveDeleteCompConfigValue.Inherit,
+      currentPageRecursiveDeletionAuthority:
+        PageRecursiveDeleteConfigValue.Inherit,
+      currentPageCompleteDeletionAuthority:
+        PageSingleDeleteCompConfigValue.AdminOnly,
+      currentPageRecursiveCompleteDeletionAuthority:
+        PageRecursiveDeleteCompConfigValue.Inherit,
       currentGroupRestrictionDisplayMode: 'Hidden',
       currentOwnerRestrictionDisplayMode: 'Hidden',
       isAllGroupMembershipRequiredForPageCompleteDeletion: true,
@@ -57,33 +61,49 @@ export default class AdminGeneralSecurityContainer extends Container {
       shareLinksActivePage: 1,
     };
 
-    this.changeOwnerRestrictionDisplayMode = this.changeOwnerRestrictionDisplayMode.bind(this);
-    this.changeGroupRestrictionDisplayMode = this.changeGroupRestrictionDisplayMode.bind(this);
-    this.changePageDeletionAuthority = this.changePageDeletionAuthority.bind(this);
-    this.changePageCompleteDeletionAuthority = this.changePageCompleteDeletionAuthority.bind(this);
-    this.changePageRecursiveDeletionAuthority = this.changePageRecursiveDeletionAuthority.bind(this);
-    this.changePageRecursiveCompleteDeletionAuthority = this.changePageRecursiveCompleteDeletionAuthority.bind(this);
-    this.changePreviousPageRecursiveDeletionAuthority = this.changePreviousPageRecursiveDeletionAuthority.bind(this);
-    this.changePreviousPageRecursiveCompleteDeletionAuthority = this.changePreviousPageRecursiveCompleteDeletionAuthority.bind(this);
-
+    this.changeOwnerRestrictionDisplayMode =
+      this.changeOwnerRestrictionDisplayMode.bind(this);
+    this.changeGroupRestrictionDisplayMode =
+      this.changeGroupRestrictionDisplayMode.bind(this);
+    this.changePageDeletionAuthority =
+      this.changePageDeletionAuthority.bind(this);
+    this.changePageCompleteDeletionAuthority =
+      this.changePageCompleteDeletionAuthority.bind(this);
+    this.changePageRecursiveDeletionAuthority =
+      this.changePageRecursiveDeletionAuthority.bind(this);
+    this.changePageRecursiveCompleteDeletionAuthority =
+      this.changePageRecursiveCompleteDeletionAuthority.bind(this);
+    this.changePreviousPageRecursiveDeletionAuthority =
+      this.changePreviousPageRecursiveDeletionAuthority.bind(this);
+    this.changePreviousPageRecursiveCompleteDeletionAuthority =
+      this.changePreviousPageRecursiveCompleteDeletionAuthority.bind(this);
   }
 
   async retrieveSecurityData() {
     await this.retrieveSetupStratedies();
     const response = await apiv3Get('/security-setting/');
-    const { generalSetting, shareLinkSetting, generalAuth } = response.data.securityParams;
+    const { generalSetting, shareLinkSetting, generalAuth } =
+      response.data.securityParams;
     this.setState({
       currentRestrictGuestMode: generalSetting.restrictGuestMode,
       currentPageDeletionAuthority: generalSetting.pageDeletionAuthority,
-      currentPageCompleteDeletionAuthority: generalSetting.pageCompleteDeletionAuthority,
-      currentPageRecursiveDeletionAuthority: generalSetting.pageRecursiveDeletionAuthority,
-      currentPageRecursiveCompleteDeletionAuthority: generalSetting.pageRecursiveCompleteDeletionAuthority,
-      isAllGroupMembershipRequiredForPageCompleteDeletion: generalSetting.isAllGroupMembershipRequiredForPageCompleteDeletion,
+      currentPageCompleteDeletionAuthority:
+        generalSetting.pageCompleteDeletionAuthority,
+      currentPageRecursiveDeletionAuthority:
+        generalSetting.pageRecursiveDeletionAuthority,
+      currentPageRecursiveCompleteDeletionAuthority:
+        generalSetting.pageRecursiveCompleteDeletionAuthority,
+      isAllGroupMembershipRequiredForPageCompleteDeletion:
+        generalSetting.isAllGroupMembershipRequiredForPageCompleteDeletion,
       // Set display to 'Hidden' if hideRestrictedByOwner is anything but false.
-      currentOwnerRestrictionDisplayMode: generalSetting.hideRestrictedByOwner === false ? 'Displayed' : 'Hidden',
-      currentGroupRestrictionDisplayMode: generalSetting.hideRestrictedByGroup === false ? 'Displayed' : 'Hidden',
-      isUsersHomepageDeletionEnabled: generalSetting.isUsersHomepageDeletionEnabled,
-      isForceDeleteUserHomepageOnUserDeletion: generalSetting.isForceDeleteUserHomepageOnUserDeletion,
+      currentOwnerRestrictionDisplayMode:
+        generalSetting.hideRestrictedByOwner === false ? 'Displayed' : 'Hidden',
+      currentGroupRestrictionDisplayMode:
+        generalSetting.hideRestrictedByGroup === false ? 'Displayed' : 'Hidden',
+      isUsersHomepageDeletionEnabled:
+        generalSetting.isUsersHomepageDeletionEnabled,
+      isForceDeleteUserHomepageOnUserDeletion:
+        generalSetting.isForceDeleteUserHomepageOnUserDeletion,
       isRomUserAllowedToComment: generalSetting.isRomUserAllowedToComment,
       sessionMaxAge: generalSetting.sessionMaxAge,
       wikiMode: generalSetting.wikiMode,
@@ -97,7 +117,6 @@ export default class AdminGeneralSecurityContainer extends Container {
     });
   }
 
-
   /**
    * Workaround for the mangling in production build to break constructor.name
    */
@@ -110,7 +129,9 @@ export default class AdminGeneralSecurityContainer extends Container {
    * @return {bool} isWikiModeForced
    */
   get isWikiModeForced() {
-    return this.state.wikiMode === 'public' || this.state.wikiMode === 'private';
+    return (
+      this.state.wikiMode === 'public' || this.state.wikiMode === 'private'
+    );
   }
 
   /**
@@ -180,7 +201,10 @@ export default class AdminGeneralSecurityContainer extends Container {
    * Switch isAllGroupMembershipRequiredForPageCompleteDeletion
    */
   switchIsAllGroupMembershipRequiredForPageCompleteDeletion() {
-    this.setState({ isAllGroupMembershipRequiredForPageCompleteDeletion: !this.state.isAllGroupMembershipRequiredForPageCompleteDeletion });
+    this.setState({
+      isAllGroupMembershipRequiredForPageCompleteDeletion:
+        !this.state.isAllGroupMembershipRequiredForPageCompleteDeletion,
+    });
   }
 
   /**
@@ -190,7 +214,6 @@ export default class AdminGeneralSecurityContainer extends Container {
     this.setState({ previousPageRecursiveDeletionAuthority: val });
   }
 
-
   /**
    * Change previousPageRecursiveCompleteDeletionAuthority
    */
@@ -216,14 +239,20 @@ export default class AdminGeneralSecurityContainer extends Container {
    * Switch isUsersHomepageDeletionEnabled
    */
   switchIsUsersHomepageDeletionEnabled() {
-    this.setState({ isUsersHomepageDeletionEnabled: !this.state.isUsersHomepageDeletionEnabled });
+    this.setState({
+      isUsersHomepageDeletionEnabled:
+        !this.state.isUsersHomepageDeletionEnabled,
+    });
   }
 
   /**
    * Switch isForceDeleteUserHomepageOnUserDeletion
    */
   switchIsForceDeleteUserHomepageOnUserDeletion() {
-    this.setState({ isForceDeleteUserHomepageOnUserDeletion: !this.state.isForceDeleteUserHomepageOnUserDeletion });
+    this.setState({
+      isForceDeleteUserHomepageOnUserDeletion:
+        !this.state.isForceDeleteUserHomepageOnUserDeletion,
+    });
   }
 
   /**
@@ -233,44 +262,62 @@ export default class AdminGeneralSecurityContainer extends Container {
     this.setState({ isRomUserAllowedToComment: bool });
   }
 
-
   /**
    * Update restrictGuestMode
    * @memberOf AdminGeneralSecuritySContainer
    * @return {string} Appearance
    */
   async updateGeneralSecuritySetting(formData) {
-
-    let requestParams = formData != null ? {
-      sessionMaxAge: formData.sessionMaxAge,
-      restrictGuestMode: formData.restrictGuestMode,
-      pageDeletionAuthority: formData.pageDeletionAuthority,
-      pageCompleteDeletionAuthority: formData.pageCompleteDeletionAuthority,
-      pageRecursiveDeletionAuthority: formData.pageRecursiveDeletionAuthority,
-      pageRecursiveCompleteDeletionAuthority: formData.pageRecursiveCompleteDeletionAuthority,
-      isAllGroupMembershipRequiredForPageCompleteDeletion: formData.isAllGroupMembershipRequiredForPageCompleteDeletion,
-      hideRestrictedByGroup: formData.hideRestrictedByGroup,
-      hideRestrictedByOwner: formData.hideRestrictedByOwner,
-      isUsersHomepageDeletionEnabled: formData.isUsersHomepageDeletionEnabled,
-      isForceDeleteUserHomepageOnUserDeletion: formData.isForceDeleteUserHomepageOnUserDeletion,
-      isRomUserAllowedToComment: formData.isRomUserAllowedToComment,
-    } : {
-      sessionMaxAge: this.state.sessionMaxAge,
-      restrictGuestMode: this.state.currentRestrictGuestMode,
-      pageDeletionAuthority: this.state.currentPageDeletionAuthority,
-      pageCompleteDeletionAuthority: this.state.currentPageCompleteDeletionAuthority,
-      pageRecursiveDeletionAuthority: this.state.currentPageRecursiveDeletionAuthority,
-      pageRecursiveCompleteDeletionAuthority: this.state.currentPageRecursiveCompleteDeletionAuthority,
-      isAllGroupMembershipRequiredForPageCompleteDeletion: this.state.isAllGroupMembershipRequiredForPageCompleteDeletion,
-      hideRestrictedByGroup: this.state.currentGroupRestrictionDisplayMode === 'Hidden',
-      hideRestrictedByOwner: this.state.currentOwnerRestrictionDisplayMode === 'Hidden',
-      isUsersHomepageDeletionEnabled: this.state.isUsersHomepageDeletionEnabled,
-      isForceDeleteUserHomepageOnUserDeletion: this.state.isForceDeleteUserHomepageOnUserDeletion,
-      isRomUserAllowedToComment: this.state.isRomUserAllowedToComment,
-    };
+    let requestParams =
+      formData != null
+        ? {
+            sessionMaxAge: formData.sessionMaxAge,
+            restrictGuestMode: formData.restrictGuestMode,
+            pageDeletionAuthority: formData.pageDeletionAuthority,
+            pageCompleteDeletionAuthority:
+              formData.pageCompleteDeletionAuthority,
+            pageRecursiveDeletionAuthority:
+              formData.pageRecursiveDeletionAuthority,
+            pageRecursiveCompleteDeletionAuthority:
+              formData.pageRecursiveCompleteDeletionAuthority,
+            isAllGroupMembershipRequiredForPageCompleteDeletion:
+              formData.isAllGroupMembershipRequiredForPageCompleteDeletion,
+            hideRestrictedByGroup: formData.hideRestrictedByGroup,
+            hideRestrictedByOwner: formData.hideRestrictedByOwner,
+            isUsersHomepageDeletionEnabled:
+              formData.isUsersHomepageDeletionEnabled,
+            isForceDeleteUserHomepageOnUserDeletion:
+              formData.isForceDeleteUserHomepageOnUserDeletion,
+            isRomUserAllowedToComment: formData.isRomUserAllowedToComment,
+          }
+        : {
+            sessionMaxAge: this.state.sessionMaxAge,
+            restrictGuestMode: this.state.currentRestrictGuestMode,
+            pageDeletionAuthority: this.state.currentPageDeletionAuthority,
+            pageCompleteDeletionAuthority:
+              this.state.currentPageCompleteDeletionAuthority,
+            pageRecursiveDeletionAuthority:
+              this.state.currentPageRecursiveDeletionAuthority,
+            pageRecursiveCompleteDeletionAuthority:
+              this.state.currentPageRecursiveCompleteDeletionAuthority,
+            isAllGroupMembershipRequiredForPageCompleteDeletion:
+              this.state.isAllGroupMembershipRequiredForPageCompleteDeletion,
+            hideRestrictedByGroup:
+              this.state.currentGroupRestrictionDisplayMode === 'Hidden',
+            hideRestrictedByOwner:
+              this.state.currentOwnerRestrictionDisplayMode === 'Hidden',
+            isUsersHomepageDeletionEnabled:
+              this.state.isUsersHomepageDeletionEnabled,
+            isForceDeleteUserHomepageOnUserDeletion:
+              this.state.isForceDeleteUserHomepageOnUserDeletion,
+            isRomUserAllowedToComment: this.state.isRomUserAllowedToComment,
+          };
 
     requestParams = await removeNullPropertyFromObject(requestParams);
-    const response = await apiv3Put('/security-setting/general-setting', requestParams);
+    const response = await apiv3Put(
+      '/security-setting/general-setting',
+      requestParams,
+    );
     const { securitySettingParams } = response.data;
     return securitySettingParams;
   }
@@ -282,7 +329,10 @@ export default class AdminGeneralSecurityContainer extends Container {
     const requestParams = {
       disableLinkSharing: !this.state.disableLinkSharing,
     };
-    const response = await apiv3Put('/security-setting/share-link-setting', requestParams);
+    const response = await apiv3Put(
+      '/security-setting/share-link-setting',
+      requestParams,
+    );
     this.setDisableLinkSharing(!this.state.disableLinkSharing);
     return response;
   }
@@ -299,8 +349,7 @@ export default class AdminGeneralSecurityContainer extends Container {
       });
       await this.retrieveSetupStratedies();
       this.setState({ [stateVariableName]: isEnabled });
-    }
-    catch (err) {
+    } catch (err) {
       toastError(err);
     }
   }
@@ -313,8 +362,7 @@ export default class AdminGeneralSecurityContainer extends Container {
       const response = await apiv3Get('/security-setting/authentication');
       const { setupStrategies } = response.data;
       this.setState({ setupStrategies });
-    }
-    catch (err) {
+    } catch (err) {
       toastError(err);
     }
   }
@@ -323,18 +371,24 @@ export default class AdminGeneralSecurityContainer extends Container {
    * Retrieve All Sharelinks
    */
   async retrieveShareLinksByPagingNum(page) {
-
     const params = {
       page,
     };
 
-    const { data } = await apiv3Get('/security-setting/all-share-links', params);
+    const { data } = await apiv3Get(
+      '/security-setting/all-share-links',
+      params,
+    );
 
     if (data.paginateResult == null) {
-      throw new Error('data must conclude \'paginateResult\' property.');
+      throw new Error("data must conclude 'paginateResult' property.");
     }
 
-    const { docs: shareLinks, totalDocs: totalshareLinks, limit: shareLinksPagingLimit } = data.paginateResult;
+    const {
+      docs: shareLinks,
+      totalDocs: totalshareLinks,
+      limit: shareLinksPagingLimit,
+    } = data.paginateResult;
 
     this.setState({
       shareLinks,
@@ -385,5 +439,4 @@ export default class AdminGeneralSecurityContainer extends Container {
   async switchIsGitHubOAuthEnabled() {
     this.switchAuthentication('isGitHubEnabled', 'github');
   }
-
 }

+ 27 - 18
apps/app/src/client/services/AdminGitHubSecurityContainer.js

@@ -13,7 +13,6 @@ const logger = loggerFactory('growi:security:AdminGitHubSecurityContainer');
  * @extends {Container} unstated Container
  */
 export default class AdminGitHubSecurityContainer extends Container {
-
   constructor(appContainer) {
     super();
 
@@ -31,7 +30,6 @@ export default class AdminGitHubSecurityContainer extends Container {
       githubClientSecret: '',
       isSameUsernameTreatedAsIdenticalUser: false,
     };
-
   }
 
   /**
@@ -44,10 +42,10 @@ export default class AdminGitHubSecurityContainer extends Container {
       this.setState({
         githubClientId: githubOAuth.githubClientId,
         githubClientSecret: githubOAuth.githubClientSecret,
-        isSameUsernameTreatedAsIdenticalUser: githubOAuth.isSameUsernameTreatedAsIdenticalUser,
+        isSameUsernameTreatedAsIdenticalUser:
+          githubOAuth.isSameUsernameTreatedAsIdenticalUser,
       });
-    }
-    catch (err) {
+    } catch (err) {
       this.setState({ retrieveError: err });
       logger.error(err);
       throw new Error('Failed to fetch data');
@@ -65,33 +63,44 @@ export default class AdminGitHubSecurityContainer extends Container {
    * Switch isSameUsernameTreatedAsIdenticalUser
    */
   switchIsSameUsernameTreatedAsIdenticalUser() {
-    this.setState({ isSameUsernameTreatedAsIdenticalUser: !this.state.isSameUsernameTreatedAsIdenticalUser });
+    this.setState({
+      isSameUsernameTreatedAsIdenticalUser:
+        !this.state.isSameUsernameTreatedAsIdenticalUser,
+    });
   }
 
   /**
    * Update githubSetting
    */
   async updateGitHubSetting(formData) {
-    let requestParams = formData != null ? {
-      githubClientId: formData.githubClientId,
-      githubClientSecret: formData.githubClientSecret,
-      isSameUsernameTreatedAsIdenticalUser: formData.isSameUsernameTreatedAsIdenticalUser,
-    } : {
-      githubClientId: this.state.githubClientId,
-      githubClientSecret: this.state.githubClientSecret,
-      isSameUsernameTreatedAsIdenticalUser: this.state.isSameUsernameTreatedAsIdenticalUser,
-    };
+    let requestParams =
+      formData != null
+        ? {
+            githubClientId: formData.githubClientId,
+            githubClientSecret: formData.githubClientSecret,
+            isSameUsernameTreatedAsIdenticalUser:
+              formData.isSameUsernameTreatedAsIdenticalUser,
+          }
+        : {
+            githubClientId: this.state.githubClientId,
+            githubClientSecret: this.state.githubClientSecret,
+            isSameUsernameTreatedAsIdenticalUser:
+              this.state.isSameUsernameTreatedAsIdenticalUser,
+          };
 
     requestParams = await removeNullPropertyFromObject(requestParams);
-    const response = await apiv3Put('/security-setting/github-oauth', requestParams);
+    const response = await apiv3Put(
+      '/security-setting/github-oauth',
+      requestParams,
+    );
     const { securitySettingParams } = response.data;
 
     this.setState({
       githubClientId: securitySettingParams.githubClientId,
       githubClientSecret: securitySettingParams.githubClientSecret,
-      isSameUsernameTreatedAsIdenticalUser: securitySettingParams.isSameUsernameTreatedAsIdenticalUser,
+      isSameUsernameTreatedAsIdenticalUser:
+        securitySettingParams.isSameUsernameTreatedAsIdenticalUser,
     });
     return response;
   }
-
 }

+ 27 - 20
apps/app/src/client/services/AdminGoogleSecurityContainer.js

@@ -13,7 +13,6 @@ const logger = loggerFactory('growi:security:AdminGoogleSecurityContainer');
  * @extends {Container} unstated Container
  */
 export default class AdminGoogleSecurityContainer extends Container {
-
   constructor(appContainer) {
     super();
 
@@ -31,8 +30,6 @@ export default class AdminGoogleSecurityContainer extends Container {
       googleClientSecret: '',
       isSameEmailTreatedAsIdenticalUser: false,
     };
-
-
   }
 
   /**
@@ -45,10 +42,10 @@ export default class AdminGoogleSecurityContainer extends Container {
       this.setState({
         googleClientId: googleOAuth.googleClientId,
         googleClientSecret: googleOAuth.googleClientSecret,
-        isSameEmailTreatedAsIdenticalUser: googleOAuth.isSameEmailTreatedAsIdenticalUser,
+        isSameEmailTreatedAsIdenticalUser:
+          googleOAuth.isSameEmailTreatedAsIdenticalUser,
       });
-    }
-    catch (err) {
+    } catch (err) {
       this.setState({ retrieveError: err });
       logger.error(err);
       throw new Error('Failed to fetch data');
@@ -66,34 +63,44 @@ export default class AdminGoogleSecurityContainer extends Container {
    * Switch isSameEmailTreatedAsIdenticalUser
    */
   switchIsSameEmailTreatedAsIdenticalUser() {
-    this.setState({ isSameEmailTreatedAsIdenticalUser: !this.state.isSameEmailTreatedAsIdenticalUser });
+    this.setState({
+      isSameEmailTreatedAsIdenticalUser:
+        !this.state.isSameEmailTreatedAsIdenticalUser,
+    });
   }
 
-
   /**
    * Update googleSetting
    */
   async updateGoogleSetting(formData) {
-    let requestParams = formData != null ? {
-      googleClientId: formData.googleClientId,
-      googleClientSecret: formData.googleClientSecret,
-      isSameEmailTreatedAsIdenticalUser: formData.isSameEmailTreatedAsIdenticalUser,
-    } : {
-      googleClientId: this.state.googleClientId,
-      googleClientSecret: this.state.googleClientSecret,
-      isSameEmailTreatedAsIdenticalUser: this.state.isSameEmailTreatedAsIdenticalUser,
-    };
+    let requestParams =
+      formData != null
+        ? {
+            googleClientId: formData.googleClientId,
+            googleClientSecret: formData.googleClientSecret,
+            isSameEmailTreatedAsIdenticalUser:
+              formData.isSameEmailTreatedAsIdenticalUser,
+          }
+        : {
+            googleClientId: this.state.googleClientId,
+            googleClientSecret: this.state.googleClientSecret,
+            isSameEmailTreatedAsIdenticalUser:
+              this.state.isSameEmailTreatedAsIdenticalUser,
+          };
 
     requestParams = await removeNullPropertyFromObject(requestParams);
-    const response = await apiv3Put('/security-setting/google-oauth', requestParams);
+    const response = await apiv3Put(
+      '/security-setting/google-oauth',
+      requestParams,
+    );
     const { securitySettingParams } = response.data;
 
     this.setState({
       googleClientId: securitySettingParams.googleClientId,
       googleClientSecret: securitySettingParams.googleClientSecret,
-      isSameEmailTreatedAsIdenticalUser: securitySettingParams.isSameEmailTreatedAsIdenticalUser,
+      isSameEmailTreatedAsIdenticalUser:
+        securitySettingParams.isSameEmailTreatedAsIdenticalUser,
     });
     return response;
   }
-
 }

+ 4 - 8
apps/app/src/client/services/AdminHomeContainer.js

@@ -13,7 +13,6 @@ const logger = loggerFactory('growi:services:AdminHomeContainer');
  * @extends {Container} unstated Container
  */
 export default class AdminHomeContainer extends Container {
-
   constructor() {
     super();
 
@@ -37,7 +36,6 @@ export default class AdminHomeContainer extends Container {
       isV5Compatible: null,
       isMaintenanceMode: null,
     };
-
   }
 
   /**
@@ -59,7 +57,7 @@ export default class AdminHomeContainer extends Container {
       const response = await apiv3Get('/admin-home/');
       const { adminHomeParams } = response.data;
 
-      this.setState(prevState => ({
+      this.setState((prevState) => ({
         ...prevState,
         growiVersion: adminHomeParams.growiVersion,
         nodeVersion: adminHomeParams.nodeVersion,
@@ -69,8 +67,7 @@ export default class AdminHomeContainer extends Container {
         isV5Compatible: adminHomeParams.isV5Compatible,
         isMaintenanceMode: adminHomeParams.isMaintenanceMode,
       }));
-    }
-    catch (err) {
+    } catch (err) {
       logger.error(err);
       throw new Error('Failed to retrive AdminHome data');
     }
@@ -80,13 +77,13 @@ export default class AdminHomeContainer extends Container {
    * sets button text when copying system information
    */
   onCopyPrefilledHostInformation() {
-    this.setState(prevState => ({
+    this.setState((prevState) => ({
       ...prevState,
       copyState: this.copyStateValues.DONE,
     }));
 
     this.timer = setTimeout(() => {
-      this.setState(prevState => ({
+      this.setState((prevState) => ({
         ...prevState,
         copyState: this.copyStateValues.DEFAULT,
       }));
@@ -111,5 +108,4 @@ export default class AdminHomeContainer extends Container {
 
 *(Accessing https://{GROWI_HOST}/admin helps you to fill in above versions)*`;
   }
-
 }

+ 0 - 2
apps/app/src/client/services/AdminImportContainer.js

@@ -6,7 +6,6 @@ import { Container } from 'unstated';
  * @extends {Container} unstated Container
  */
 export default class AdminImportContainer extends Container {
-
   constructor(appContainer) {
     super();
 
@@ -27,5 +26,4 @@ export default class AdminImportContainer extends Container {
   static getClassName() {
     return 'AdminImportContainer';
   }
-
 }

+ 41 - 36
apps/app/src/client/services/AdminLdapSecurityContainer.js

@@ -13,7 +13,6 @@ const logger = loggerFactory('growi:services:AdminLdapSecurityContainer');
  * @extends {Container} unstated Container
  */
 export default class AdminLdapSecurityContainer extends Container {
-
   constructor(appContainer) {
     super();
 
@@ -38,7 +37,6 @@ export default class AdminLdapSecurityContainer extends Container {
       ldapGroupSearchFilter: '',
       ldapGroupDnProperty: '',
     };
-
   }
 
   /**
@@ -55,22 +53,21 @@ export default class AdminLdapSecurityContainer extends Container {
         ldapBindDNPassword: ldapAuth.ldapBindDNPassword,
         ldapSearchFilter: ldapAuth.ldapSearchFilter,
         ldapAttrMapUsername: ldapAuth.ldapAttrMapUsername,
-        isSameUsernameTreatedAsIdenticalUser: ldapAuth.isSameUsernameTreatedAsIdenticalUser,
+        isSameUsernameTreatedAsIdenticalUser:
+          ldapAuth.isSameUsernameTreatedAsIdenticalUser,
         ldapAttrMapMail: ldapAuth.ldapAttrMapMail,
         ldapAttrMapName: ldapAuth.ldapAttrMapName,
         ldapGroupSearchBase: ldapAuth.ldapGroupSearchBase,
         ldapGroupSearchFilter: ldapAuth.ldapGroupSearchFilter,
         ldapGroupDnProperty: ldapAuth.ldapGroupDnProperty,
       });
-    }
-    catch (err) {
+    } catch (err) {
       this.setState({ retrieveError: err });
       logger.error(err);
       throw new Error('Failed to fetch data');
     }
   }
 
-
   /**
    * Workaround for the mangling in production build to break constructor.name
    */
@@ -90,40 +87,48 @@ export default class AdminLdapSecurityContainer extends Container {
    * Switch is same username treated as identical user
    */
   switchIsSameUsernameTreatedAsIdenticalUser() {
-    this.setState({ isSameUsernameTreatedAsIdenticalUser: !this.state.isSameUsernameTreatedAsIdenticalUser });
+    this.setState({
+      isSameUsernameTreatedAsIdenticalUser:
+        !this.state.isSameUsernameTreatedAsIdenticalUser,
+    });
   }
 
   /**
    * Update ldap option
    */
   async updateLdapSetting(formData) {
-    let requestParams = formData != null ? {
-      serverUrl: formData.serverUrl,
-      isUserBind: formData.isUserBind,
-      ldapBindDN: formData.ldapBindDN,
-      ldapBindDNPassword: formData.ldapBindDNPassword,
-      ldapSearchFilter: formData.ldapSearchFilter,
-      ldapAttrMapUsername: formData.ldapAttrMapUsername,
-      isSameUsernameTreatedAsIdenticalUser: formData.isSameUsernameTreatedAsIdenticalUser,
-      ldapAttrMapMail: formData.ldapAttrMapMail,
-      ldapAttrMapName: formData.ldapAttrMapName,
-      ldapGroupSearchBase: formData.ldapGroupSearchBase,
-      ldapGroupSearchFilter: formData.ldapGroupSearchFilter,
-      ldapGroupDnProperty: formData.ldapGroupDnProperty,
-    } : {
-      serverUrl: this.state.serverUrl,
-      isUserBind: this.state.isUserBind,
-      ldapBindDN: this.state.ldapBindDN,
-      ldapBindDNPassword: this.state.ldapBindDNPassword,
-      ldapSearchFilter: this.state.ldapSearchFilter,
-      ldapAttrMapUsername: this.state.ldapAttrMapUsername,
-      isSameUsernameTreatedAsIdenticalUser: this.state.isSameUsernameTreatedAsIdenticalUser,
-      ldapAttrMapMail: this.state.ldapAttrMapMail,
-      ldapAttrMapName: this.state.ldapAttrMapName,
-      ldapGroupSearchBase: this.state.ldapGroupSearchBase,
-      ldapGroupSearchFilter: this.state.ldapGroupSearchFilter,
-      ldapGroupDnProperty: this.state.ldapGroupDnProperty,
-    };
+    let requestParams =
+      formData != null
+        ? {
+            serverUrl: formData.serverUrl,
+            isUserBind: formData.isUserBind,
+            ldapBindDN: formData.ldapBindDN,
+            ldapBindDNPassword: formData.ldapBindDNPassword,
+            ldapSearchFilter: formData.ldapSearchFilter,
+            ldapAttrMapUsername: formData.ldapAttrMapUsername,
+            isSameUsernameTreatedAsIdenticalUser:
+              formData.isSameUsernameTreatedAsIdenticalUser,
+            ldapAttrMapMail: formData.ldapAttrMapMail,
+            ldapAttrMapName: formData.ldapAttrMapName,
+            ldapGroupSearchBase: formData.ldapGroupSearchBase,
+            ldapGroupSearchFilter: formData.ldapGroupSearchFilter,
+            ldapGroupDnProperty: formData.ldapGroupDnProperty,
+          }
+        : {
+            serverUrl: this.state.serverUrl,
+            isUserBind: this.state.isUserBind,
+            ldapBindDN: this.state.ldapBindDN,
+            ldapBindDNPassword: this.state.ldapBindDNPassword,
+            ldapSearchFilter: this.state.ldapSearchFilter,
+            ldapAttrMapUsername: this.state.ldapAttrMapUsername,
+            isSameUsernameTreatedAsIdenticalUser:
+              this.state.isSameUsernameTreatedAsIdenticalUser,
+            ldapAttrMapMail: this.state.ldapAttrMapMail,
+            ldapAttrMapName: this.state.ldapAttrMapName,
+            ldapGroupSearchBase: this.state.ldapGroupSearchBase,
+            ldapGroupSearchFilter: this.state.ldapGroupSearchFilter,
+            ldapGroupDnProperty: this.state.ldapGroupDnProperty,
+          };
 
     requestParams = await removeNullPropertyFromObject(requestParams);
     const response = await apiv3Put('/security-setting/ldap', requestParams);
@@ -136,7 +141,8 @@ export default class AdminLdapSecurityContainer extends Container {
       ldapBindDNPassword: securitySettingParams.ldapBindDNPassword,
       ldapSearchFilter: securitySettingParams.ldapSearchFilter,
       ldapAttrMapUsername: securitySettingParams.ldapAttrMapUsername,
-      isSameUsernameTreatedAsIdenticalUser: securitySettingParams.isSameUsernameTreatedAsIdenticalUser,
+      isSameUsernameTreatedAsIdenticalUser:
+        securitySettingParams.isSameUsernameTreatedAsIdenticalUser,
       ldapAttrMapMail: securitySettingParams.ldapAttrMapMail,
       ldapAttrMapName: securitySettingParams.ldapAttrMapName,
       ldapGroupSearchBase: securitySettingParams.ldapGroupSearchBase,
@@ -145,5 +151,4 @@ export default class AdminLdapSecurityContainer extends Container {
     });
     return response;
   }
-
 }

+ 28 - 23
apps/app/src/client/services/AdminLocalSecurityContainer.js

@@ -12,7 +12,6 @@ const logger = loggerFactory('growi:services:AdminLocalSecurityContainer');
  * @extends {Container} unstated Container
  */
 export default class AdminLocalSecurityContainer extends Container {
-
   constructor(appContainer) {
     super();
 
@@ -33,7 +32,6 @@ export default class AdminLocalSecurityContainer extends Container {
       isPasswordResetEnabled: false,
       isEmailAuthenticationEnabled: false,
     };
-
   }
 
   async retrieveSecurityData() {
@@ -47,13 +45,11 @@ export default class AdminLocalSecurityContainer extends Container {
         isPasswordResetEnabled: localSetting.isPasswordResetEnabled,
         isEmailAuthenticationEnabled: localSetting.isEmailAuthenticationEnabled,
       });
-    }
-    catch (err) {
+    } catch (err) {
       this.setState({ retrieveError: err });
       logger.error(err);
       throw new Error('Failed to fetch data');
     }
-
   }
 
   /**
@@ -63,7 +59,6 @@ export default class AdminLocalSecurityContainer extends Container {
     return 'AdminLocalSecurityContainer';
   }
 
-
   /**
    * Change registration mode
    */
@@ -75,32 +70,43 @@ export default class AdminLocalSecurityContainer extends Container {
    * Switch password reset enabled
    */
   switchIsPasswordResetEnabled() {
-    this.setState({ isPasswordResetEnabled: !this.state.isPasswordResetEnabled });
+    this.setState({
+      isPasswordResetEnabled: !this.state.isPasswordResetEnabled,
+    });
   }
 
   /**
    * Switch email authentication enabled
    */
   switchIsEmailAuthenticationEnabled() {
-    this.setState({ isEmailAuthenticationEnabled: !this.state.isEmailAuthenticationEnabled });
+    this.setState({
+      isEmailAuthenticationEnabled: !this.state.isEmailAuthenticationEnabled,
+    });
   }
 
   /**
    * update local security setting
    */
   async updateLocalSecuritySetting(formData) {
-    const requestParams = formData != null ? {
-      registrationMode: formData.registrationMode,
-      registrationWhitelist: formData.registrationWhitelist,
-      isPasswordResetEnabled: formData.isPasswordResetEnabled,
-      isEmailAuthenticationEnabled: formData.isEmailAuthenticationEnabled,
-    } : {
-      registrationMode: this.state.registrationMode,
-      registrationWhitelist: this.state.registrationWhitelist,
-      isPasswordResetEnabled: this.state.isPasswordResetEnabled,
-      isEmailAuthenticationEnabled: this.state.isEmailAuthenticationEnabled,
-    };
-    const response = await apiv3Put('/security-setting/local-setting', requestParams);
+    const requestParams =
+      formData != null
+        ? {
+            registrationMode: formData.registrationMode,
+            registrationWhitelist: formData.registrationWhitelist,
+            isPasswordResetEnabled: formData.isPasswordResetEnabled,
+            isEmailAuthenticationEnabled: formData.isEmailAuthenticationEnabled,
+          }
+        : {
+            registrationMode: this.state.registrationMode,
+            registrationWhitelist: this.state.registrationWhitelist,
+            isPasswordResetEnabled: this.state.isPasswordResetEnabled,
+            isEmailAuthenticationEnabled:
+              this.state.isEmailAuthenticationEnabled,
+          };
+    const response = await apiv3Put(
+      '/security-setting/local-setting',
+      requestParams,
+    );
 
     const { localSettingParams } = response.data;
 
@@ -108,11 +114,10 @@ export default class AdminLocalSecurityContainer extends Container {
       registrationMode: localSettingParams.registrationMode,
       registrationWhitelist: localSettingParams.registrationWhitelist,
       isPasswordResetEnabled: localSettingParams.isPasswordResetEnabled,
-      isEmailAuthenticationEnabled: localSettingParams.isEmailAuthenticationEnabled,
+      isEmailAuthenticationEnabled:
+        localSettingParams.isEmailAuthenticationEnabled,
     });
 
     return localSettingParams;
   }
-
-
 }

+ 8 - 9
apps/app/src/client/services/AdminMarkDownContainer.js

@@ -8,7 +8,6 @@ import { apiv3Get, apiv3Put } from '../util/apiv3-client';
  * @extends {Container} unstated Container
  */
 export default class AdminMarkDownContainer extends Container {
-
   constructor(appContainer) {
     super();
 
@@ -31,7 +30,8 @@ export default class AdminMarkDownContainer extends Container {
     };
 
     this.switchEnableXss = this.switchEnableXss.bind(this);
-    this.setAdminPreferredIndentSize = this.setAdminPreferredIndentSize.bind(this);
+    this.setAdminPreferredIndentSize =
+      this.setAdminPreferredIndentSize.bind(this);
   }
 
   /**
@@ -50,7 +50,8 @@ export default class AdminMarkDownContainer extends Container {
 
     this.setState({
       isEnabledLinebreaks: markdownParams.isEnabledLinebreaks,
-      isEnabledLinebreaksInComments: markdownParams.isEnabledLinebreaksInComments,
+      isEnabledLinebreaksInComments:
+        markdownParams.isEnabledLinebreaksInComments,
       adminPreferredIndentSize: markdownParams.adminPreferredIndentSize,
       isIndentSizeForced: markdownParams.isIndentSizeForced,
       isEnabledXss: markdownParams.isEnabledXss,
@@ -75,7 +76,6 @@ export default class AdminMarkDownContainer extends Container {
    * Update LineBreak Setting
    */
   async updateLineBreakSetting() {
-
     const response = await apiv3Put('/markdown-setting/lineBreak', {
       isEnabledLinebreaks: this.state.isEnabledLinebreaks,
       isEnabledLinebreaksInComments: this.state.isEnabledLinebreaksInComments,
@@ -88,7 +88,6 @@ export default class AdminMarkDownContainer extends Container {
    * Update
    */
   async updateIndentSetting() {
-
     const response = await apiv3Put('/markdown-setting/indent', {
       adminPreferredIndentSize: this.state.adminPreferredIndentSize,
       isIndentSizeForced: this.state.isIndentSizeForced,
@@ -104,13 +103,14 @@ export default class AdminMarkDownContainer extends Container {
     let { tagWhitelist = '' } = this.state;
     const { attrWhitelist = '{}' } = this.state;
 
-    tagWhitelist = Array.isArray(tagWhitelist) ? tagWhitelist : tagWhitelist.split(',');
+    tagWhitelist = Array.isArray(tagWhitelist)
+      ? tagWhitelist
+      : tagWhitelist.split(',');
 
     try {
       // Check if parsing is possible
       JSON.parse(attrWhitelist);
-    }
-    catch (err) {
+    } catch (err) {
       throw Error(`attrWhitelist parsing error occured: ${err.message}`);
     }
 
@@ -121,5 +121,4 @@ export default class AdminMarkDownContainer extends Container {
       attrWhitelist,
     });
   }
-
 }

+ 49 - 24
apps/app/src/client/services/AdminNotificationContainer.js

@@ -2,7 +2,10 @@ import { isServer } from '@growi/core/dist/utils';
 import { Container } from 'unstated';
 
 import {
-  apiv3Delete, apiv3Get, apiv3Post, apiv3Put,
+  apiv3Delete,
+  apiv3Get,
+  apiv3Post,
+  apiv3Put,
 } from '../util/apiv3-client';
 
 /**
@@ -10,7 +13,6 @@ import {
  * @extends {Container} unstated Container
  */
 export default class AdminNotificationContainer extends Container {
-
   constructor(appContainer) {
     super();
 
@@ -32,7 +34,6 @@ export default class AdminNotificationContainer extends Container {
       isNotificationForGroupPageEnabled: false,
       globalNotifications: [],
     };
-
   }
 
   /**
@@ -55,8 +56,10 @@ export default class AdminNotificationContainer extends Container {
       currentBotType: notificationParams.currentBotType,
 
       userNotifications: notificationParams.userNotifications,
-      isNotificationForOwnerPageEnabled: notificationParams.isNotificationForOwnerPageEnabled,
-      isNotificationForGroupPageEnabled: notificationParams.isNotificationForGroupPageEnabled,
+      isNotificationForOwnerPageEnabled:
+        notificationParams.isNotificationForOwnerPageEnabled,
+      isNotificationForGroupPageEnabled:
+        notificationParams.isNotificationForGroupPageEnabled,
       globalNotifications: notificationParams.globalNotifications,
     });
   }
@@ -66,11 +69,14 @@ export default class AdminNotificationContainer extends Container {
    * @memberOf SlackAppConfiguration
    */
   async updateSlackAppConfiguration() {
-    const response = await apiv3Put('/notification-setting/slack-configuration', {
-      webhookUrl: this.state.webhookUrl,
-      isIncomingWebhookPrioritized: this.state.isIncomingWebhookPrioritized,
-      slackToken: this.state.slackToken,
-    });
+    const response = await apiv3Put(
+      '/notification-setting/slack-configuration',
+      {
+        webhookUrl: this.state.webhookUrl,
+        isIncomingWebhookPrioritized: this.state.isIncomingWebhookPrioritized,
+        slackToken: this.state.slackToken,
+      },
+    );
 
     return response;
   }
@@ -80,19 +86,26 @@ export default class AdminNotificationContainer extends Container {
    * @memberOf SlackAppConfiguration
    */
   async addNotificationPattern(pathPattern, channel) {
-    const response = await apiv3Post('/notification-setting/user-notification', {
-      pathPattern,
-      channel,
-    });
+    const response = await apiv3Post(
+      '/notification-setting/user-notification',
+      {
+        pathPattern,
+        channel,
+      },
+    );
 
-    this.setState({ userNotifications: response.data.responseParams.userNotifications });
+    this.setState({
+      userNotifications: response.data.responseParams.userNotifications,
+    });
   }
 
   /**
    * Delete user trigger notification pattern
    */
   async deleteUserTriggerNotificationPattern(notificatiionId) {
-    const response = await apiv3Delete(`/notification-setting/user-notification/${notificatiionId}`);
+    const response = await apiv3Delete(
+      `/notification-setting/user-notification/${notificatiionId}`,
+    );
     const deletedNotificaton = response.data;
     await this.retrieveNotificationData();
     return deletedNotificaton;
@@ -102,14 +115,20 @@ export default class AdminNotificationContainer extends Container {
    * Switch isNotificationForOwnerPageEnabled
    */
   switchIsNotificationForOwnerPageEnabled() {
-    this.setState({ isNotificationForOwnerPageEnabled: !this.state.isNotificationForOwnerPageEnabled });
+    this.setState({
+      isNotificationForOwnerPageEnabled:
+        !this.state.isNotificationForOwnerPageEnabled,
+    });
   }
 
   /**
    * Switch isNotificationForGroupPageEnabled
    */
   switchIsNotificationForGroupPageEnabled() {
-    this.setState({ isNotificationForGroupPageEnabled: !this.state.isNotificationForGroupPageEnabled });
+    this.setState({
+      isNotificationForGroupPageEnabled:
+        !this.state.isNotificationForGroupPageEnabled,
+    });
   }
 
   /**
@@ -117,10 +136,15 @@ export default class AdminNotificationContainer extends Container {
    * @memberOf SlackAppConfiguration
    */
   async updateGlobalNotificationForPages() {
-    const response = await apiv3Put('/notification-setting/notify-for-page-grant/', {
-      isNotificationForOwnerPageEnabled: this.state.isNotificationForOwnerPageEnabled,
-      isNotificationForGroupPageEnabled: this.state.isNotificationForGroupPageEnabled,
-    });
+    const response = await apiv3Put(
+      '/notification-setting/notify-for-page-grant/',
+      {
+        isNotificationForOwnerPageEnabled:
+          this.state.isNotificationForOwnerPageEnabled,
+        isNotificationForGroupPageEnabled:
+          this.state.isNotificationForGroupPageEnabled,
+      },
+    );
 
     return response;
   }
@@ -129,10 +153,11 @@ export default class AdminNotificationContainer extends Container {
    * Delete global notification pattern
    */
   async deleteGlobalNotificationPattern(notificatiionId) {
-    const response = await apiv3Delete(`/notification-setting/global-notification/${notificatiionId}`);
+    const response = await apiv3Delete(
+      `/notification-setting/global-notification/${notificatiionId}`,
+    );
     const deletedNotificaton = response.data;
     await this.retrieveNotificationData();
     return deletedNotificaton;
   }
-
 }

+ 67 - 52
apps/app/src/client/services/AdminOidcSecurityContainer.js

@@ -13,7 +13,6 @@ const logger = loggerFactory('growi:services:AdminLdapSecurityContainer');
  * @extends {Container} unstated Container
  */
 export default class AdminOidcSecurityContainer extends Container {
-
   constructor(appContainer) {
     super();
 
@@ -44,7 +43,6 @@ export default class AdminOidcSecurityContainer extends Container {
       isSameUsernameTreatedAsIdenticalUser: false,
       isSameEmailTreatedAsIdenticalUser: false,
     };
-
   }
 
   /**
@@ -71,11 +69,12 @@ export default class AdminOidcSecurityContainer extends Container {
         oidcAttrMapUserName: oidcAuth.oidcAttrMapUserName,
         oidcAttrMapName: oidcAuth.oidcAttrMapName,
         oidcAttrMapEmail: oidcAuth.oidcAttrMapEmail,
-        isSameUsernameTreatedAsIdenticalUser: oidcAuth.isSameUsernameTreatedAsIdenticalUser,
-        isSameEmailTreatedAsIdenticalUser: oidcAuth.isSameEmailTreatedAsIdenticalUser,
+        isSameUsernameTreatedAsIdenticalUser:
+          oidcAuth.isSameUsernameTreatedAsIdenticalUser,
+        isSameEmailTreatedAsIdenticalUser:
+          oidcAuth.isSameEmailTreatedAsIdenticalUser,
       });
-    }
-    catch (err) {
+    } catch (err) {
       this.setState({ retrieveError: err });
       logger.error(err);
       throw new Error('Failed to fetch data');
@@ -93,59 +92,72 @@ export default class AdminOidcSecurityContainer extends Container {
    * Switch sameUsernameTreatedAsIdenticalUser
    */
   switchIsSameUsernameTreatedAsIdenticalUser() {
-    this.setState({ isSameUsernameTreatedAsIdenticalUser: !this.state.isSameUsernameTreatedAsIdenticalUser });
+    this.setState({
+      isSameUsernameTreatedAsIdenticalUser:
+        !this.state.isSameUsernameTreatedAsIdenticalUser,
+    });
   }
 
   /**
    * Switch sameEmailTreatedAsIdenticalUser
    */
   switchIsSameEmailTreatedAsIdenticalUser() {
-    this.setState({ isSameEmailTreatedAsIdenticalUser: !this.state.isSameEmailTreatedAsIdenticalUser });
+    this.setState({
+      isSameEmailTreatedAsIdenticalUser:
+        !this.state.isSameEmailTreatedAsIdenticalUser,
+    });
   }
 
   /**
    * Update OpenID Connect
    */
   async updateOidcSetting(formData) {
-    let requestParams = formData != null ? {
-      oidcProviderName: formData.oidcProviderName,
-      oidcIssuerHost: formData.oidcIssuerHost,
-      oidcAuthorizationEndpoint: formData.oidcAuthorizationEndpoint,
-      oidcTokenEndpoint: formData.oidcTokenEndpoint,
-      oidcRevocationEndpoint: formData.oidcRevocationEndpoint,
-      oidcIntrospectionEndpoint: formData.oidcIntrospectionEndpoint,
-      oidcUserInfoEndpoint: formData.oidcUserInfoEndpoint,
-      oidcEndSessionEndpoint: formData.oidcEndSessionEndpoint,
-      oidcRegistrationEndpoint: formData.oidcRegistrationEndpoint,
-      oidcJWKSUri: formData.oidcJWKSUri,
-      oidcClientId: formData.oidcClientId,
-      oidcClientSecret: formData.oidcClientSecret,
-      oidcAttrMapId: formData.oidcAttrMapId,
-      oidcAttrMapUserName: formData.oidcAttrMapUserName,
-      oidcAttrMapName: formData.oidcAttrMapName,
-      oidcAttrMapEmail: formData.oidcAttrMapEmail,
-      isSameUsernameTreatedAsIdenticalUser: formData.isSameUsernameTreatedAsIdenticalUser,
-      isSameEmailTreatedAsIdenticalUser: formData.isSameEmailTreatedAsIdenticalUser,
-    } : {
-      oidcProviderName: this.state.oidcProviderName,
-      oidcIssuerHost: this.state.oidcIssuerHost,
-      oidcAuthorizationEndpoint: this.state.oidcAuthorizationEndpoint,
-      oidcTokenEndpoint: this.state.oidcTokenEndpoint,
-      oidcRevocationEndpoint: this.state.oidcRevocationEndpoint,
-      oidcIntrospectionEndpoint: this.state.oidcIntrospectionEndpoint,
-      oidcUserInfoEndpoint: this.state.oidcUserInfoEndpoint,
-      oidcEndSessionEndpoint: this.state.oidcEndSessionEndpoint,
-      oidcRegistrationEndpoint: this.state.oidcRegistrationEndpoint,
-      oidcJWKSUri: this.state.oidcJWKSUri,
-      oidcClientId: this.state.oidcClientId,
-      oidcClientSecret: this.state.oidcClientSecret,
-      oidcAttrMapId: this.state.oidcAttrMapId,
-      oidcAttrMapUserName: this.state.oidcAttrMapUserName,
-      oidcAttrMapName: this.state.oidcAttrMapName,
-      oidcAttrMapEmail: this.state.oidcAttrMapEmail,
-      isSameUsernameTreatedAsIdenticalUser: this.state.isSameUsernameTreatedAsIdenticalUser,
-      isSameEmailTreatedAsIdenticalUser: this.state.isSameEmailTreatedAsIdenticalUser,
-    };
+    let requestParams =
+      formData != null
+        ? {
+            oidcProviderName: formData.oidcProviderName,
+            oidcIssuerHost: formData.oidcIssuerHost,
+            oidcAuthorizationEndpoint: formData.oidcAuthorizationEndpoint,
+            oidcTokenEndpoint: formData.oidcTokenEndpoint,
+            oidcRevocationEndpoint: formData.oidcRevocationEndpoint,
+            oidcIntrospectionEndpoint: formData.oidcIntrospectionEndpoint,
+            oidcUserInfoEndpoint: formData.oidcUserInfoEndpoint,
+            oidcEndSessionEndpoint: formData.oidcEndSessionEndpoint,
+            oidcRegistrationEndpoint: formData.oidcRegistrationEndpoint,
+            oidcJWKSUri: formData.oidcJWKSUri,
+            oidcClientId: formData.oidcClientId,
+            oidcClientSecret: formData.oidcClientSecret,
+            oidcAttrMapId: formData.oidcAttrMapId,
+            oidcAttrMapUserName: formData.oidcAttrMapUserName,
+            oidcAttrMapName: formData.oidcAttrMapName,
+            oidcAttrMapEmail: formData.oidcAttrMapEmail,
+            isSameUsernameTreatedAsIdenticalUser:
+              formData.isSameUsernameTreatedAsIdenticalUser,
+            isSameEmailTreatedAsIdenticalUser:
+              formData.isSameEmailTreatedAsIdenticalUser,
+          }
+        : {
+            oidcProviderName: this.state.oidcProviderName,
+            oidcIssuerHost: this.state.oidcIssuerHost,
+            oidcAuthorizationEndpoint: this.state.oidcAuthorizationEndpoint,
+            oidcTokenEndpoint: this.state.oidcTokenEndpoint,
+            oidcRevocationEndpoint: this.state.oidcRevocationEndpoint,
+            oidcIntrospectionEndpoint: this.state.oidcIntrospectionEndpoint,
+            oidcUserInfoEndpoint: this.state.oidcUserInfoEndpoint,
+            oidcEndSessionEndpoint: this.state.oidcEndSessionEndpoint,
+            oidcRegistrationEndpoint: this.state.oidcRegistrationEndpoint,
+            oidcJWKSUri: this.state.oidcJWKSUri,
+            oidcClientId: this.state.oidcClientId,
+            oidcClientSecret: this.state.oidcClientSecret,
+            oidcAttrMapId: this.state.oidcAttrMapId,
+            oidcAttrMapUserName: this.state.oidcAttrMapUserName,
+            oidcAttrMapName: this.state.oidcAttrMapName,
+            oidcAttrMapEmail: this.state.oidcAttrMapEmail,
+            isSameUsernameTreatedAsIdenticalUser:
+              this.state.isSameUsernameTreatedAsIdenticalUser,
+            isSameEmailTreatedAsIdenticalUser:
+              this.state.isSameEmailTreatedAsIdenticalUser,
+          };
 
     requestParams = await removeNullPropertyFromObject(requestParams);
     const response = await apiv3Put('/security-setting/oidc', requestParams);
@@ -154,10 +166,12 @@ export default class AdminOidcSecurityContainer extends Container {
     this.setState({
       oidcProviderName: securitySettingParams.oidcProviderName,
       oidcIssuerHost: securitySettingParams.oidcIssuerHost,
-      oidcAuthorizationEndpoint: securitySettingParams.oidcAuthorizationEndpoint,
+      oidcAuthorizationEndpoint:
+        securitySettingParams.oidcAuthorizationEndpoint,
       oidcTokenEndpoint: securitySettingParams.oidcTokenEndpoint,
       oidcRevocationEndpoint: securitySettingParams.oidcRevocationEndpoint,
-      oidcIntrospectionEndpoint: securitySettingParams.oidcIntrospectionEndpoint,
+      oidcIntrospectionEndpoint:
+        securitySettingParams.oidcIntrospectionEndpoint,
       oidcUserInfoEndpoint: securitySettingParams.oidcUserInfoEndpoint,
       oidcEndSessionEndpoint: securitySettingParams.oidcEndSessionEndpoint,
       oidcRegistrationEndpoint: securitySettingParams.oidcRegistrationEndpoint,
@@ -168,10 +182,11 @@ export default class AdminOidcSecurityContainer extends Container {
       oidcAttrMapUserName: securitySettingParams.oidcAttrMapUserName,
       oidcAttrMapName: securitySettingParams.oidcAttrMapName,
       oidcAttrMapEmail: securitySettingParams.oidcAttrMapEmail,
-      isSameUsernameTreatedAsIdenticalUser: securitySettingParams.isSameUsernameTreatedAsIdenticalUser,
-      isSameEmailTreatedAsIdenticalUser: securitySettingParams.isSameEmailTreatedAsIdenticalUser,
+      isSameUsernameTreatedAsIdenticalUser:
+        securitySettingParams.isSameUsernameTreatedAsIdenticalUser,
+      isSameEmailTreatedAsIdenticalUser:
+        securitySettingParams.isSameEmailTreatedAsIdenticalUser,
     });
     return response;
   }
-
 }

+ 51 - 38
apps/app/src/client/services/AdminSamlSecurityContainer.js

@@ -13,7 +13,6 @@ const logger = loggerFactory('growi:security:AdminSamlSecurityContainer');
  * @extends {Container} unstated Container
  */
 export default class AdminSamlSecurityContainer extends Container {
-
   constructor(appContainer) {
     super();
 
@@ -49,7 +48,6 @@ export default class AdminSamlSecurityContainer extends Container {
       envAttrMapLastName: '',
       envABLCRule: '',
     };
-
   }
 
   /**
@@ -70,8 +68,10 @@ export default class AdminSamlSecurityContainer extends Container {
         samlAttrMapMail: samlAuth.samlAttrMapMail,
         samlAttrMapFirstName: samlAuth.samlAttrMapFirstName,
         samlAttrMapLastName: samlAuth.samlAttrMapLastName,
-        isSameUsernameTreatedAsIdenticalUser: samlAuth.isSameUsernameTreatedAsIdenticalUser,
-        isSameEmailTreatedAsIdenticalUser: samlAuth.isSameEmailTreatedAsIdenticalUser,
+        isSameUsernameTreatedAsIdenticalUser:
+          samlAuth.isSameUsernameTreatedAsIdenticalUser,
+        isSameEmailTreatedAsIdenticalUser:
+          samlAuth.isSameEmailTreatedAsIdenticalUser,
         samlABLCRule: samlAuth.samlABLCRule,
         envEntryPoint: samlAuth.samlEnvVarEntryPoint,
         envIssuer: samlAuth.samlEnvVarIssuer,
@@ -83,8 +83,7 @@ export default class AdminSamlSecurityContainer extends Container {
         envAttrMapLastName: samlAuth.samlEnvVarAttrMapLastName,
         envABLCRule: samlAuth.samlEnvVarABLCRule,
       });
-    }
-    catch (err) {
+    } catch (err) {
       this.setState({ retrieveError: err });
       logger.error(err);
       throw new Error('Failed to fetch data');
@@ -102,53 +101,66 @@ export default class AdminSamlSecurityContainer extends Container {
    * Switch isSameUsernameTreatedAsIdenticalUser
    */
   switchIsSameUsernameTreatedAsIdenticalUser() {
-    this.setState({ isSameUsernameTreatedAsIdenticalUser: !this.state.isSameUsernameTreatedAsIdenticalUser });
+    this.setState({
+      isSameUsernameTreatedAsIdenticalUser:
+        !this.state.isSameUsernameTreatedAsIdenticalUser,
+    });
   }
 
   /**
    * Switch isSameEmailTreatedAsIdenticalUser
    */
   switchIsSameEmailTreatedAsIdenticalUser() {
-    this.setState({ isSameEmailTreatedAsIdenticalUser: !this.state.isSameEmailTreatedAsIdenticalUser });
+    this.setState({
+      isSameEmailTreatedAsIdenticalUser:
+        !this.state.isSameEmailTreatedAsIdenticalUser,
+    });
   }
 
   /**
    * Update saml option
    */
   async updateSamlSetting(formData) {
-
-    let requestParams = formData != null ? {
-      entryPoint: formData.samlEntryPoint,
-      issuer: formData.samlIssuer,
-      cert: formData.samlCert,
-      attrMapId: formData.samlAttrMapId,
-      attrMapUsername: formData.samlAttrMapUsername,
-      attrMapMail: formData.samlAttrMapMail,
-      attrMapFirstName: formData.samlAttrMapFirstName,
-      attrMapLastName: formData.samlAttrMapLastName,
-      isSameUsernameTreatedAsIdenticalUser: formData.isSameUsernameTreatedAsIdenticalUser,
-      isSameEmailTreatedAsIdenticalUser: formData.isSameEmailTreatedAsIdenticalUser,
-      ABLCRule: formData.samlABLCRule,
-    } : {
-      entryPoint: this.state.samlEntryPoint,
-      issuer: this.state.samlIssuer,
-      cert: this.state.samlCert,
-      attrMapId: this.state.samlAttrMapId,
-      attrMapUsername: this.state.samlAttrMapUsername,
-      attrMapMail: this.state.samlAttrMapMail,
-      attrMapFirstName: this.state.samlAttrMapFirstName,
-      attrMapLastName: this.state.samlAttrMapLastName,
-      isSameUsernameTreatedAsIdenticalUser: this.state.isSameUsernameTreatedAsIdenticalUser,
-      isSameEmailTreatedAsIdenticalUser: this.state.isSameEmailTreatedAsIdenticalUser,
-      ABLCRule: this.state.samlABLCRule,
-    };
+    let requestParams =
+      formData != null
+        ? {
+            entryPoint: formData.samlEntryPoint,
+            issuer: formData.samlIssuer,
+            cert: formData.samlCert,
+            attrMapId: formData.samlAttrMapId,
+            attrMapUsername: formData.samlAttrMapUsername,
+            attrMapMail: formData.samlAttrMapMail,
+            attrMapFirstName: formData.samlAttrMapFirstName,
+            attrMapLastName: formData.samlAttrMapLastName,
+            isSameUsernameTreatedAsIdenticalUser:
+              formData.isSameUsernameTreatedAsIdenticalUser,
+            isSameEmailTreatedAsIdenticalUser:
+              formData.isSameEmailTreatedAsIdenticalUser,
+            ABLCRule: formData.samlABLCRule,
+          }
+        : {
+            entryPoint: this.state.samlEntryPoint,
+            issuer: this.state.samlIssuer,
+            cert: this.state.samlCert,
+            attrMapId: this.state.samlAttrMapId,
+            attrMapUsername: this.state.samlAttrMapUsername,
+            attrMapMail: this.state.samlAttrMapMail,
+            attrMapFirstName: this.state.samlAttrMapFirstName,
+            attrMapLastName: this.state.samlAttrMapLastName,
+            isSameUsernameTreatedAsIdenticalUser:
+              this.state.isSameUsernameTreatedAsIdenticalUser,
+            isSameEmailTreatedAsIdenticalUser:
+              this.state.isSameEmailTreatedAsIdenticalUser,
+            ABLCRule: this.state.samlABLCRule,
+          };
 
     requestParams = await removeNullPropertyFromObject(requestParams);
     const response = await apiv3Put('/security-setting/saml', requestParams);
     const { securitySettingParams } = response.data;
 
     this.setState({
-      missingMandatoryConfigKeys: securitySettingParams.missingMandatoryConfigKeys,
+      missingMandatoryConfigKeys:
+        securitySettingParams.missingMandatoryConfigKeys,
       samlEntryPoint: securitySettingParams.samlEntryPoint,
       samlIssuer: securitySettingParams.samlIssuer,
       samlCert: securitySettingParams.samlCert,
@@ -157,11 +169,12 @@ export default class AdminSamlSecurityContainer extends Container {
       samlAttrMapMail: securitySettingParams.samlAttrMapMail,
       samlAttrMapFirstName: securitySettingParams.samlAttrMapFirstName,
       samlAttrMapLastName: securitySettingParams.samlAttrMapLastName,
-      isSameUsernameTreatedAsIdenticalUser: securitySettingParams.isSameUsernameTreatedAsIdenticalUser,
-      isSameEmailTreatedAsIdenticalUser: securitySettingParams.isSameEmailTreatedAsIdenticalUser,
+      isSameUsernameTreatedAsIdenticalUser:
+        securitySettingParams.isSameUsernameTreatedAsIdenticalUser,
+      isSameEmailTreatedAsIdenticalUser:
+        securitySettingParams.isSameEmailTreatedAsIdenticalUser,
       samlABLCRule: securitySettingParams.samlABLCRule,
     });
     return response;
   }
-
 }

+ 5 - 5
apps/app/src/client/services/AdminSlackIntegrationLegacyContainer.js

@@ -8,7 +8,6 @@ import { apiv3Get, apiv3Put } from '../util/apiv3-client';
  * @extends {Container} unstated Container
  */
 export default class AdminSlackIntegrationLegacyContainer extends Container {
-
   constructor(appContainer) {
     super();
 
@@ -26,7 +25,6 @@ export default class AdminSlackIntegrationLegacyContainer extends Container {
       isIncomingWebhookPrioritized: false,
       slackToken: '',
     };
-
   }
 
   /**
@@ -46,7 +44,8 @@ export default class AdminSlackIntegrationLegacyContainer extends Container {
     this.setState({
       isSlackbotConfigured: slackIntegrationParams.isSlackbotConfigured,
       webhookUrl: slackIntegrationParams.webhookUrl,
-      isIncomingWebhookPrioritized: slackIntegrationParams.isIncomingWebhookPrioritized,
+      isIncomingWebhookPrioritized:
+        slackIntegrationParams.isIncomingWebhookPrioritized,
       slackToken: slackIntegrationParams.slackToken,
     });
   }
@@ -69,7 +68,9 @@ export default class AdminSlackIntegrationLegacyContainer extends Container {
    * Switch incomingWebhookPrioritized
    */
   switchIsIncomingWebhookPrioritized() {
-    this.setState({ isIncomingWebhookPrioritized: !this.state.isIncomingWebhookPrioritized });
+    this.setState({
+      isIncomingWebhookPrioritized: !this.state.isIncomingWebhookPrioritized,
+    });
   }
 
   /**
@@ -92,5 +93,4 @@ export default class AdminSlackIntegrationLegacyContainer extends Container {
 
     return response;
   }
-
 }

+ 0 - 1
apps/app/src/client/services/AdminSocketIoContainer.js

@@ -1,2 +1 @@
-
 export default class AdminSocketIoContainer {}

+ 20 - 16
apps/app/src/client/services/AdminUsersContainer.js

@@ -3,16 +3,17 @@ import { debounce } from 'throttle-debounce';
 import { Container } from 'unstated';
 
 import {
-  apiv3Delete, apiv3Get, apiv3Post, apiv3Put,
+  apiv3Delete,
+  apiv3Get,
+  apiv3Post,
+  apiv3Put,
 } from '../util/apiv3-client';
 
-
 /**
  * Service container for admin users page (Users.jsx)
  * @extends {Container} unstated Container
  */
 export default class AdminUsersContainer extends Container {
-
   constructor(appContainer) {
     super();
 
@@ -41,7 +42,9 @@ export default class AdminUsersContainer extends Container {
     this.hidePasswordResetModal = this.hidePasswordResetModal.bind(this);
     this.toggleUserInviteModal = this.toggleUserInviteModal.bind(this);
 
-    this.handleChangeSearchTextDebouce = debounce(3000, () => this.retrieveUsersByPagingNum(1));
+    this.handleChangeSearchTextDebouce = debounce(3000, () =>
+      this.retrieveUsersByPagingNum(1),
+    );
   }
 
   /**
@@ -62,12 +65,10 @@ export default class AdminUsersContainer extends Container {
     const all = 'all';
     if (this.isSelected(statusType)) {
       this.deleteStatusFromList(statusType);
-    }
-    else {
+    } else {
       if (statusType === all) {
         this.clearStatusList();
-      }
-      else {
+      } else {
         this.deleteStatusFromList(all);
       }
       this.addStatusToList(statusType);
@@ -132,7 +133,6 @@ export default class AdminUsersContainer extends Container {
    * @param {number} selectedPage
    */
   async retrieveUsersByPagingNum(selectedPage) {
-
     const params = {
       page: selectedPage,
       sort: this.state.sort,
@@ -145,10 +145,14 @@ export default class AdminUsersContainer extends Container {
     const { data } = await apiv3Get('/users', params);
 
     if (data.paginateResult == null) {
-      throw new Error('data must conclude \'paginateResult\' property.');
+      throw new Error("data must conclude 'paginateResult' property.");
     }
 
-    const { docs: users, totalDocs: totalUsers, limit: pagingLimit } = data.paginateResult;
+    const {
+      docs: users,
+      totalDocs: totalUsers,
+      limit: pagingLimit,
+    } = data.paginateResult;
 
     this.setState({
       users,
@@ -156,12 +160,11 @@ export default class AdminUsersContainer extends Container {
       pagingLimit,
       activePage: selectedPage,
     });
-
   }
 
   /**
- * retrieve user statistics
- */
+   * retrieve user statistics
+   */
   async retrieveUserStatistics() {
     const statsRes = await apiv3Get('/statistics/user');
     const userStatistics = statsRes.data.data;
@@ -211,7 +214,9 @@ export default class AdminUsersContainer extends Container {
    * @memberOf AdminUsersContainer
    */
   async toggleUserInviteModal() {
-    await this.setState({ isUserInviteModalShown: !this.state.isUserInviteModalShown });
+    await this.setState({
+      isUserInviteModalShown: !this.state.isUserInviteModalShown,
+    });
   }
 
   /**
@@ -304,5 +309,4 @@ export default class AdminUsersContainer extends Container {
     await this.retrieveUsersByPagingNum(this.state.activePage);
     return removedUserData;
   }
-
 }

+ 7 - 2
apps/app/src/client/services/create-page/create-page.ts

@@ -1,7 +1,12 @@
 import { apiv3Post } from '~/client/util/apiv3-client';
-import type { IApiv3PageCreateParams, IApiv3PageCreateResponse } from '~/interfaces/apiv3';
+import type {
+  IApiv3PageCreateParams,
+  IApiv3PageCreateResponse,
+} from '~/interfaces/apiv3';
 
-export const createPage = async(params: IApiv3PageCreateParams): Promise<IApiv3PageCreateResponse> => {
+export const createPage = async (
+  params: IApiv3PageCreateParams,
+): Promise<IApiv3PageCreateResponse> => {
   const res = await apiv3Post<IApiv3PageCreateResponse>('/page', params);
   return res.data;
 };

+ 95 - 81
apps/app/src/client/services/create-page/use-create-page.tsx

@@ -1,13 +1,15 @@
 import { useCallback, useState } from 'react';
-
 import { useRouter } from 'next/router';
 import { useTranslation } from 'react-i18next';
 
-import { exist, getIsNonUserRelatedGroupsGranted } from '~/client/services/page-operation';
+import {
+  exist,
+  getIsNonUserRelatedGroupsGranted,
+} from '~/client/services/page-operation';
 import { toastWarning } from '~/client/util/toastr';
 import type { IApiv3PageCreateParams } from '~/interfaces/apiv3';
 import { useCurrentPagePath, useSetIsUntitledPage } from '~/states/page';
-import { useEditorMode, EditorMode } from '~/states/ui/editor';
+import { EditorMode, useEditorMode } from '~/states/ui/editor';
 import { useGrantedGroupsInheritanceSelectModalActions } from '~/states/ui/modal/granted-groups-inheritance-select';
 
 import { createPage } from './create-page';
@@ -26,13 +28,13 @@ type OnAborted = () => void;
 type OnTerminated = () => void;
 
 export type CreatePageOpts = {
-  skipPageExistenceCheck?: boolean,
-  skipTransition?: boolean,
-  onCreationStart?: OnCreated,
-  onCreated?: OnCreated,
-  onAborted?: OnAborted,
-  onTerminated?: OnTerminated,
-}
+  skipPageExistenceCheck?: boolean;
+  skipTransition?: boolean;
+  onCreationStart?: OnCreated;
+  onCreated?: OnCreated;
+  onAborted?: OnAborted;
+  onTerminated?: OnTerminated;
+};
 
 type CreatePage = (
   params: IApiv3PageCreateParams,
@@ -40,101 +42,113 @@ type CreatePage = (
 ) => Promise<void>;
 
 type UseCreatePage = () => {
-  isCreating: boolean,
-  create: CreatePage,
+  isCreating: boolean;
+  create: CreatePage;
 };
 
 export const useCreatePage: UseCreatePage = () => {
-
   const router = useRouter();
   const { t } = useTranslation();
 
   const currentPagePath = useCurrentPagePath();
   const { setEditorMode } = useEditorMode();
   const setIsUntitledPage = useSetIsUntitledPage();
-  const { open: openGrantedGroupsInheritanceSelectModal, close: closeGrantedGroupsInheritanceSelectModal } = useGrantedGroupsInheritanceSelectModalActions();
+  const {
+    open: openGrantedGroupsInheritanceSelectModal,
+    close: closeGrantedGroupsInheritanceSelectModal,
+  } = useGrantedGroupsInheritanceSelectModalActions();
 
   const [isCreating, setCreating] = useState(false);
 
-  const create: CreatePage = useCallback(async (params, opts = {}) => {
-    const {
-      onCreationStart, onCreated, onAborted, onTerminated,
-    } = opts;
-    const skipPageExistenceCheck = opts.skipPageExistenceCheck ?? false;
-    const skipTransition = opts.skipTransition ?? false;
-
-    // check the page existence
-    if (!skipPageExistenceCheck && params.path != null) {
-      const pagePath = params.path;
-
-      try {
-        const { isExist } = await exist(pagePath);
-
-        if (isExist) {
-          if (!skipTransition) {
-            // routing
-            if (pagePath !== currentPagePath) {
-              await router.push(`${pagePath}#edit`);
+  const create: CreatePage = useCallback(
+    async (params, opts = {}) => {
+      const { onCreationStart, onCreated, onAborted, onTerminated } = opts;
+      const skipPageExistenceCheck = opts.skipPageExistenceCheck ?? false;
+      const skipTransition = opts.skipTransition ?? false;
+
+      // check the page existence
+      if (!skipPageExistenceCheck && params.path != null) {
+        const pagePath = params.path;
+
+        try {
+          const { isExist } = await exist(pagePath);
+
+          if (isExist) {
+            if (!skipTransition) {
+              // routing
+              if (pagePath !== currentPagePath) {
+                await router.push(`${pagePath}#edit`);
+              }
+              setEditorMode(EditorMode.Editor);
+            } else {
+              toastWarning(
+                t('duplicated_page_alert.same_page_name_exists', {
+                  pageName: pagePath,
+                }),
+              );
             }
-            setEditorMode(EditorMode.Editor);
-          }
-          else {
-            toastWarning(t('duplicated_page_alert.same_page_name_exists', { pageName: pagePath }));
+            onAborted?.();
+            return;
           }
-          onAborted?.();
-          return;
+        } catch (err) {
+          throw err;
+        } finally {
+          onTerminated?.();
         }
       }
-      catch (err) {
-        throw err;
-      }
-      finally {
-        onTerminated?.();
-      }
-    }
 
-    const _create = async (onlyInheritUserRelatedGrantedGroups?: boolean) => {
-      try {
-        setCreating(true);
-        onCreationStart?.();
+      const _create = async (onlyInheritUserRelatedGrantedGroups?: boolean) => {
+        try {
+          setCreating(true);
+          onCreationStart?.();
 
-        params.onlyInheritUserRelatedGrantedGroups = onlyInheritUserRelatedGrantedGroups;
-        const response = await createPage(params);
+          params.onlyInheritUserRelatedGrantedGroups =
+            onlyInheritUserRelatedGrantedGroups;
+          const response = await createPage(params);
 
-        closeGrantedGroupsInheritanceSelectModal();
+          closeGrantedGroupsInheritanceSelectModal();
 
-        if (!skipTransition) {
-          await router.push(`/${response.page._id}#edit`);
-          setEditorMode(EditorMode.Editor);
-        }
+          if (!skipTransition) {
+            await router.push(`/${response.page._id}#edit`);
+            setEditorMode(EditorMode.Editor);
+          }
 
-        if (params.path == null) {
-          setIsUntitledPage(true);
-        }
+          if (params.path == null) {
+            setIsUntitledPage(true);
+          }
 
-        onCreated?.();
-      }
-      catch (err) {
-        throw err;
-      }
-      finally {
-        onTerminated?.();
-        setCreating(false);
-      }
-    };
-
-    // If parent page is granted to non-user-related groups, let the user select whether or not to inherit them.
-    if (params.parentPath != null) {
-      const { isNonUserRelatedGroupsGranted } = await getIsNonUserRelatedGroupsGranted(params.parentPath);
-      if (isNonUserRelatedGroupsGranted) {
-        // create and transit request will be made from modal
-        openGrantedGroupsInheritanceSelectModal(_create);
-        return;
+          onCreated?.();
+        } catch (err) {
+          throw err;
+        } finally {
+          onTerminated?.();
+          setCreating(false);
+        }
+      };
+
+      // If parent page is granted to non-user-related groups, let the user select whether or not to inherit them.
+      if (params.parentPath != null) {
+        const { isNonUserRelatedGroupsGranted } =
+          await getIsNonUserRelatedGroupsGranted(params.parentPath);
+        if (isNonUserRelatedGroupsGranted) {
+          // create and transit request will be made from modal
+          openGrantedGroupsInheritanceSelectModal(_create);
+          return;
+        }
       }
-    }
 
-    await _create();
-  }, [currentPagePath, setEditorMode, router, t, closeGrantedGroupsInheritanceSelectModal, setIsUntitledPage, openGrantedGroupsInheritanceSelectModal]);
+      await _create();
+    },
+    [
+      currentPagePath,
+      setEditorMode,
+      router,
+      t,
+      closeGrantedGroupsInheritanceSelectModal,
+      setIsUntitledPage,
+      openGrantedGroupsInheritanceSelectModal,
+    ],
+  );
 
   return {
     isCreating,

+ 21 - 18
apps/app/src/client/services/create-page/use-create-template-page.ts

@@ -1,5 +1,4 @@
 import { useCallback } from 'react';
-
 import { Origin } from '@growi/core';
 import { isCreatablePage } from '@growi/core/dist/utils/page-path-utils';
 import { normalizePath } from '@growi/core/dist/utils/path-utils';
@@ -7,31 +6,35 @@ import { normalizePath } from '@growi/core/dist/utils/path-utils';
 import type { LabelType } from '~/interfaces/template';
 import { useCurrentPagePath } from '~/states/page';
 
-
 import { useCreatePage } from './use-create-page';
 
 type UseCreateTemplatePage = () => {
-  isCreatable: boolean,
-  isCreating: boolean,
-  createTemplate?: (label: LabelType) => Promise<void>,
-}
+  isCreatable: boolean;
+  isCreating: boolean;
+  createTemplate?: (label: LabelType) => Promise<void>;
+};
 
 export const useCreateTemplatePage: UseCreateTemplatePage = () => {
-
   const currentPagePath = useCurrentPagePath();
 
   const { isCreating, create } = useCreatePage();
-  const isCreatable = currentPagePath != null && isCreatablePage(normalizePath(`${currentPagePath}/_template`));
-
-  const createTemplate = useCallback(async(label: LabelType) => {
-    if (currentPagePath == null || !isCreatable) return;
-
-    return create(
-      {
-        path: normalizePath(`${currentPagePath}/${label}`), parentPath: currentPagePath, wip: false, origin: Origin.View,
-      },
-    );
-  }, [currentPagePath, isCreatable, create]);
+  const isCreatable =
+    currentPagePath != null &&
+    isCreatablePage(normalizePath(`${currentPagePath}/_template`));
+
+  const createTemplate = useCallback(
+    async (label: LabelType) => {
+      if (currentPagePath == null || !isCreatable) return;
+
+      return create({
+        path: normalizePath(`${currentPagePath}/${label}`),
+        parentPath: currentPagePath,
+        wip: false,
+        origin: Origin.View,
+      });
+    },
+    [currentPagePath, isCreatable, create],
+  );
 
   return {
     isCreatable,

+ 8 - 3
apps/app/src/client/services/g2g-transfer.ts

@@ -2,11 +2,16 @@ import { useCallback, useState } from 'react';
 
 import { apiv3Post } from '~/client/util/apiv3-client';
 
-export const useGenerateTransferKey = (): {transferKey: string, generateTransferKey: () => Promise<void>} => {
+export const useGenerateTransferKey = (): {
+  transferKey: string;
+  generateTransferKey: () => Promise<void>;
+} => {
   const [transferKey, setTransferKey] = useState('');
 
-  const generateTransferKey = useCallback(async() => {
-    const response = await apiv3Post('/g2g-transfer/generate-key', { appSiteUrl: window.location.origin });
+  const generateTransferKey = useCallback(async () => {
+    const response = await apiv3Post('/g2g-transfer/generate-key', {
+      appSiteUrl: window.location.origin,
+    });
     const { transferKey } = response.data;
     setTransferKey(transferKey);
   }, []);

+ 0 - 1
apps/app/src/client/services/maintenance-mode.ts

@@ -1,5 +1,4 @@
 import { useCallback } from 'react';
-
 import { useSetAtom } from 'jotai';
 
 import { _atomsForMaintenanceMode } from '../../states/global';

+ 91 - 49
apps/app/src/client/services/page-operation.ts

@@ -1,5 +1,4 @@
 import { useCallback } from 'react';
-
 import type { IPageHasId } from '@growi/core';
 import { SubscriptionStatusType } from '@growi/core';
 import urljoin from 'url-join';
@@ -17,67 +16,81 @@ import { toastError } from '../util/toastr';
 
 const logger = loggerFactory('growi:services:page-operation');
 
-
-export const toggleSubscribe = async(pageId: string, currentStatus: SubscriptionStatusType | undefined): Promise<void> => {
+export const toggleSubscribe = async (
+  pageId: string,
+  currentStatus: SubscriptionStatusType | undefined,
+): Promise<void> => {
   try {
-    const newStatus = currentStatus === SubscriptionStatusType.SUBSCRIBE
-      ? SubscriptionStatusType.UNSUBSCRIBE
-      : SubscriptionStatusType.SUBSCRIBE;
+    const newStatus =
+      currentStatus === SubscriptionStatusType.SUBSCRIBE
+        ? SubscriptionStatusType.UNSUBSCRIBE
+        : SubscriptionStatusType.SUBSCRIBE;
 
     await apiv3Put('/page/subscribe', { pageId, status: newStatus });
-  }
-  catch (err) {
+  } catch (err) {
     toastError(err);
   }
 };
 
-export const toggleLike = async(pageId: string, currentValue?: boolean): Promise<void> => {
+export const toggleLike = async (
+  pageId: string,
+  currentValue?: boolean,
+): Promise<void> => {
   try {
     await apiv3Put('/page/likes', { pageId, bool: !currentValue });
-  }
-  catch (err) {
+  } catch (err) {
     toastError(err);
   }
 };
 
-export const toggleBookmark = async(pageId: string, currentValue?: boolean): Promise<void> => {
+export const toggleBookmark = async (
+  pageId: string,
+  currentValue?: boolean,
+): Promise<void> => {
   try {
     await apiv3Put('/bookmarks', { pageId, bool: !currentValue });
-  }
-  catch (err) {
+  } catch (err) {
     toastError(err);
   }
 };
 
-export const updateContentWidth = async(pageId: string, newValue: boolean): Promise<void> => {
+export const updateContentWidth = async (
+  pageId: string,
+  newValue: boolean,
+): Promise<void> => {
   try {
-    await apiv3Put(`/page/${pageId}/content-width`, { expandContentWidth: newValue });
-  }
-  catch (err) {
+    await apiv3Put(`/page/${pageId}/content-width`, {
+      expandContentWidth: newValue,
+    });
+  } catch (err) {
     toastError(err);
   }
 };
 
-export const bookmark = async(pageId: string): Promise<void> => {
+export const bookmark = async (pageId: string): Promise<void> => {
   try {
     await apiv3Put('/bookmarks', { pageId, bool: true });
-  }
-  catch (err) {
+  } catch (err) {
     toastError(err);
   }
 };
 
-export const unbookmark = async(pageId: string): Promise<void> => {
+export const unbookmark = async (pageId: string): Promise<void> => {
   try {
     await apiv3Put('/bookmarks', { pageId, bool: false });
-  }
-  catch (err) {
+  } catch (err) {
     toastError(err);
   }
 };
 
-export const exportAsMarkdown = (pageId: string, revisionId: string, format: string): void => {
-  const url = new URL(urljoin(window.location.origin, '_api/v3/page/export', pageId));
+export const exportAsMarkdown = (
+  pageId: string,
+  revisionId: string,
+  format: string,
+): void => {
+  const url = new URL(
+    urljoin(window.location.origin, '_api/v3/page/export', pageId),
+  );
   url.searchParams.append('format', format);
   url.searchParams.append('revisionId', revisionId);
   window.location.href = url.href;
@@ -86,34 +99,46 @@ export const exportAsMarkdown = (pageId: string, revisionId: string, format: str
 /**
  * send request to fix broken paths caused by unexpected events such as server shutdown while renaming page paths
  */
-export const resumeRenameOperation = async(pageId: string): Promise<void> => {
+export const resumeRenameOperation = async (pageId: string): Promise<void> => {
   await apiv3Post('/pages/resume-rename', { pageId });
 };
 
 export type UpdateStateAfterSaveOption = {
-  supressEditingMarkdownMutation: boolean,
-}
+  supressEditingMarkdownMutation: boolean;
+};
 
-export const useUpdateStateAfterSave = (pageId: string|undefined|null, opts?: UpdateStateAfterSaveOption): (() => Promise<void>) | undefined => {
+export const useUpdateStateAfterSave = (
+  pageId: string | undefined | null,
+  opts?: UpdateStateAfterSaveOption,
+): (() => Promise<void>) | undefined => {
   const isGuestUser = useIsGuestUser();
   const { fetchCurrentPage } = useFetchCurrentPage();
   const setRemoteLatestPageData = useSetRemoteLatestPageData();
 
   const setEditingMarkdown = useSetEditingMarkdown();
-  const { mutate: mutateCurrentGrantData } = useSWRxCurrentGrantData(isGuestUser ? null : pageId);
-  const { mutate: mutateApplicableGrant } = useSWRxApplicableGrant(isGuestUser ? null : pageId);
+  const { mutate: mutateCurrentGrantData } = useSWRxCurrentGrantData(
+    isGuestUser ? null : pageId,
+  );
+  const { mutate: mutateApplicableGrant } = useSWRxApplicableGrant(
+    isGuestUser ? null : pageId,
+  );
 
   // update swr 'currentPageId', 'currentPage', remote states
-  return useCallback(async() => {
-    if (pageId == null) { return }
+  return useCallback(async () => {
+    if (pageId == null) {
+      return;
+    }
 
     const updatedPage = await fetchCurrentPage({ pageId, force: true });
 
-    if (updatedPage == null || updatedPage.revision == null) { return }
+    if (updatedPage == null || updatedPage.revision == null) {
+      return;
+    }
 
     // supress to mutate only when updated from built-in editor
     // and see: https://github.com/growilabs/growi/pull/7118
-    const supressEditingMarkdownMutation = opts?.supressEditingMarkdownMutation ?? false;
+    const supressEditingMarkdownMutation =
+      opts?.supressEditingMarkdownMutation ?? false;
     if (!supressEditingMarkdownMutation) {
       setEditingMarkdown(updatedPage.revision.body);
     }
@@ -129,44 +154,61 @@ export const useUpdateStateAfterSave = (pageId: string|undefined|null, opts?: Up
     };
 
     setRemoteLatestPageData(remoterevisionData);
-  },
-  [pageId, fetchCurrentPage, opts?.supressEditingMarkdownMutation, mutateCurrentGrantData, mutateApplicableGrant, setRemoteLatestPageData, setEditingMarkdown]);
+  }, [
+    pageId,
+    fetchCurrentPage,
+    opts?.supressEditingMarkdownMutation,
+    mutateCurrentGrantData,
+    mutateApplicableGrant,
+    setRemoteLatestPageData,
+    setEditingMarkdown,
+  ]);
 };
 
-export const unlink = async(path: string): Promise<void> => {
+export const unlink = async (path: string): Promise<void> => {
   await apiPost('/pages.unlink', { path });
 };
 
-
 interface PageExistResponse {
-  isExist: boolean,
+  isExist: boolean;
 }
 
-export const exist = async(path: string): Promise<PageExistResponse> => {
+export const exist = async (path: string): Promise<PageExistResponse> => {
   const res = await apiv3Get<PageExistResponse>('/page/exist', { path });
   return res.data;
 };
 
 interface NonUserRelatedGroupsGrantedResponse {
-  isNonUserRelatedGroupsGranted: boolean,
+  isNonUserRelatedGroupsGranted: boolean;
 }
 
-export const getIsNonUserRelatedGroupsGranted = async(path: string): Promise<NonUserRelatedGroupsGrantedResponse> => {
-  const res = await apiv3Get<NonUserRelatedGroupsGrantedResponse>('/page/non-user-related-groups-granted', { path });
+export const getIsNonUserRelatedGroupsGranted = async (
+  path: string,
+): Promise<NonUserRelatedGroupsGrantedResponse> => {
+  const res = await apiv3Get<NonUserRelatedGroupsGrantedResponse>(
+    '/page/non-user-related-groups-granted',
+    { path },
+  );
   return res.data;
 };
 
-export const publish = async(pageId: string): Promise<IPageHasId> => {
+export const publish = async (pageId: string): Promise<IPageHasId> => {
   const res = await apiv3Put(`/page/${pageId}/publish`);
   return res.data;
 };
 
-export const unpublish = async(pageId: string): Promise<IPageHasId> => {
+export const unpublish = async (pageId: string): Promise<IPageHasId> => {
   const res = await apiv3Put(`/page/${pageId}/unpublish`);
   return res.data;
 };
 
-export const syncLatestRevisionBody = async(pageId: string, editingMarkdownLength?: number): Promise<SyncLatestRevisionBody> => {
-  const res = await apiv3Put(`/page/${pageId}/sync-latest-revision-body-to-yjs-draft`, { editingMarkdownLength });
+export const syncLatestRevisionBody = async (
+  pageId: string,
+  editingMarkdownLength?: number,
+): Promise<SyncLatestRevisionBody> => {
+  const res = await apiv3Put(
+    `/page/${pageId}/sync-latest-revision-body-to-yjs-draft`,
+    { editingMarkdownLength },
+  );
   return res.data;
 };

+ 111 - 83
apps/app/src/client/services/renderer/renderer.tsx

@@ -1,10 +1,9 @@
-import assert from 'assert';
-
 import { isClient } from '@growi/core/dist/utils/browser-utils';
 import * as presentation from '@growi/presentation/dist/client/services/sanitize-option';
 import * as refsGrowiDirective from '@growi/remark-attachment-refs/dist/client';
 import * as drawio from '@growi/remark-drawio';
 import * as lsxGrowiDirective from '@growi/remark-lsx/dist/client';
+import assert from 'assert';
 import katex from 'rehype-katex';
 import sanitize from 'rehype-sanitize';
 import slug from 'rehype-slug';
@@ -24,7 +23,7 @@ import * as callout from '~/features/callout';
 import * as mermaid from '~/features/mermaid';
 import * as plantuml from '~/features/plantuml';
 import type { RendererOptions } from '~/interfaces/renderer-options';
-import { type RendererConfigExt } from '~/interfaces/services/renderer';
+import type { RendererConfigExt } from '~/interfaces/services/renderer';
 import * as addLineNumberAttribute from '~/services/renderer/rehype-plugins/add-line-number-attribute';
 import * as keywordHighlighter from '~/services/renderer/rehype-plugins/keyword-highlighter';
 import * as relocateToc from '~/services/renderer/rehype-plugins/relocate-toc';
@@ -32,29 +31,26 @@ import * as attachment from '~/services/renderer/remark-plugins/attachment';
 import * as codeBlock from '~/services/renderer/remark-plugins/codeblock';
 import * as xsvToTable from '~/services/renderer/remark-plugins/xsv-to-table';
 import {
-  getCommonSanitizeOption, generateCommonOptions, verifySanitizePlugin,
+  generateCommonOptions,
+  getCommonSanitizeOption,
+  verifySanitizePlugin,
 } from '~/services/renderer/renderer';
 import loggerFactory from '~/utils/logger';
 
 // import EasyGrid from './PreProcessor/EasyGrid';
 
-
 import '@growi/remark-lsx/dist/client/style.css';
 import '@growi/remark-attachment-refs/dist/client/style.css';
 
-
 const logger = loggerFactory('growi:cli:services:renderer');
 
-
 assert(isClient(), 'This module must be loaded only from client modules.');
 
-
 export const generateViewOptions = (
-    pagePath: string,
-    config: RendererConfigExt,
-    storeTocNode: (toc: HtmlElementNode) => void,
+  pagePath: string,
+  config: RendererConfigExt,
+  storeTocNode: (toc: HtmlElementNode) => void,
 ): RendererOptions => {
-
   const options = generateCommonOptions(pagePath);
 
   const { remarkPlugins, rehypePlugins, components } = options;
@@ -62,7 +58,10 @@ export const generateViewOptions = (
   // add remark plugins
   remarkPlugins.push(
     math,
-    [plantuml.remarkPlugin, { plantumlUri: config.plantumlUri, isDarkMode: config.isDarkMode }],
+    [
+      plantuml.remarkPlugin,
+      { plantumlUri: config.plantumlUri, isDarkMode: config.isDarkMode },
+    ],
     [drawio.remarkPlugin, { isDarkMode: config.isDarkMode }],
     mermaid.remarkPlugin,
     xsvToTable.remarkPlugin,
@@ -76,24 +75,31 @@ export const generateViewOptions = (
     remarkPlugins.push(breaks);
   }
 
-  const rehypeSanitizePlugin: Pluggable | (() => void) = config.isEnabledXssPrevention
-    ? [sanitize, deepmerge(
-      getCommonSanitizeOption(config),
-      presentation.sanitizeOption,
-      drawio.sanitizeOption,
-      mermaid.sanitizeOption,
-      callout.sanitizeOption,
-      attachment.sanitizeOption,
-      lsxGrowiDirective.sanitizeOption,
-      refsGrowiDirective.sanitizeOption,
-      codeBlock.sanitizeOption,
-    )]
-    : () => {};
+  const rehypeSanitizePlugin: Pluggable | (() => void) =
+    config.isEnabledXssPrevention
+      ? [
+          sanitize,
+          deepmerge(
+            getCommonSanitizeOption(config),
+            presentation.sanitizeOption,
+            drawio.sanitizeOption,
+            mermaid.sanitizeOption,
+            callout.sanitizeOption,
+            attachment.sanitizeOption,
+            lsxGrowiDirective.sanitizeOption,
+            refsGrowiDirective.sanitizeOption,
+            codeBlock.sanitizeOption,
+          ),
+        ]
+      : () => {};
 
   // add rehype plugins
   rehypePlugins.push(
     slug,
-    [lsxGrowiDirective.rehypePlugin, { pagePath, isSharedPage: config.isSharedPage }],
+    [
+      lsxGrowiDirective.rehypePlugin,
+      { pagePath, isSharedPage: config.isSharedPage },
+    ],
     [refsGrowiDirective.rehypePlugin, { pagePath }],
     rehypeSanitizePlugin,
     katex,
@@ -128,8 +134,10 @@ export const generateViewOptions = (
   return options;
 };
 
-export const generateTocOptions = (config: RendererConfigExt, tocNode: HtmlElementNode | undefined): RendererOptions => {
-
+export const generateTocOptions = (
+  config: RendererConfigExt,
+  tocNode: HtmlElementNode | undefined,
+): RendererOptions => {
   const options = generateCommonOptions(undefined);
 
   const { rehypePlugins } = options;
@@ -137,12 +145,13 @@ export const generateTocOptions = (config: RendererConfigExt, tocNode: HtmlEleme
   // add remark plugins
   // remarkPlugins.push();
 
-  const rehypeSanitizePlugin: Pluggable | (() => void) = config.isEnabledXssPrevention
-    ? [sanitize, deepmerge(
-      getCommonSanitizeOption(config),
-      codeBlock.sanitizeOption,
-    )]
-    : () => {};
+  const rehypeSanitizePlugin: Pluggable | (() => void) =
+    config.isEnabledXssPrevention
+      ? [
+          sanitize,
+          deepmerge(getCommonSanitizeOption(config), codeBlock.sanitizeOption),
+        ]
+      : () => {};
 
   // add rehype plugins
   rehypePlugins.push(
@@ -158,10 +167,10 @@ export const generateTocOptions = (config: RendererConfigExt, tocNode: HtmlEleme
 };
 
 export const generateSimpleViewOptions = (
-    config: RendererConfigExt,
-    pagePath: string,
-    highlightKeywords?: string | string[],
-    overrideIsEnabledLinebreaks?: boolean,
+  config: RendererConfigExt,
+  pagePath: string,
+  highlightKeywords?: string | string[],
+  overrideIsEnabledLinebreaks?: boolean,
 ): RendererOptions => {
   const options = generateCommonOptions(pagePath);
 
@@ -170,7 +179,10 @@ export const generateSimpleViewOptions = (
   // add remark plugins
   remarkPlugins.push(
     math,
-    [plantuml.remarkPlugin, { plantumlUri: config.plantumlUri, isDarkMode: config.isDarkMode }],
+    [
+      plantuml.remarkPlugin,
+      { plantumlUri: config.plantumlUri, isDarkMode: config.isDarkMode },
+    ],
     [drawio.remarkPlugin, { isDarkMode: config.isDarkMode }],
     mermaid.remarkPlugin,
     xsvToTable.remarkPlugin,
@@ -181,29 +193,37 @@ export const generateSimpleViewOptions = (
     refsGrowiDirective.remarkPlugin,
   );
 
-  const isEnabledLinebreaks = overrideIsEnabledLinebreaks ?? config.isEnabledLinebreaks;
+  const isEnabledLinebreaks =
+    overrideIsEnabledLinebreaks ?? config.isEnabledLinebreaks;
 
   if (isEnabledLinebreaks) {
     remarkPlugins.push(breaks);
   }
 
-  const rehypeSanitizePlugin: Pluggable | (() => void) = config.isEnabledXssPrevention
-    ? [sanitize, deepmerge(
-      getCommonSanitizeOption(config),
-      presentation.sanitizeOption,
-      drawio.sanitizeOption,
-      mermaid.sanitizeOption,
-      callout.sanitizeOption,
-      attachment.sanitizeOption,
-      lsxGrowiDirective.sanitizeOption,
-      refsGrowiDirective.sanitizeOption,
-      codeBlock.sanitizeOption,
-    )]
-    : () => {};
+  const rehypeSanitizePlugin: Pluggable | (() => void) =
+    config.isEnabledXssPrevention
+      ? [
+          sanitize,
+          deepmerge(
+            getCommonSanitizeOption(config),
+            presentation.sanitizeOption,
+            drawio.sanitizeOption,
+            mermaid.sanitizeOption,
+            callout.sanitizeOption,
+            attachment.sanitizeOption,
+            lsxGrowiDirective.sanitizeOption,
+            refsGrowiDirective.sanitizeOption,
+            codeBlock.sanitizeOption,
+          ),
+        ]
+      : () => {};
 
   // add rehype plugins
   rehypePlugins.push(
-    [lsxGrowiDirective.rehypePlugin, { pagePath, isSharedPage: config.isSharedPage }],
+    [
+      lsxGrowiDirective.rehypePlugin,
+      { pagePath, isSharedPage: config.isSharedPage },
+    ],
     [refsGrowiDirective.rehypePlugin, { pagePath }],
     [keywordHighlighter.rehypePlugin, { keywords: highlightKeywords }],
     rehypeSanitizePlugin,
@@ -232,26 +252,21 @@ export const generateSimpleViewOptions = (
 };
 
 export const generatePresentationViewOptions = (
-    config: RendererConfigExt,
-    pagePath: string,
+  config: RendererConfigExt,
+  pagePath: string,
 ): RendererOptions => {
   // based on simple view options
   const options = generateSimpleViewOptions(config, pagePath);
 
   const { rehypePlugins } = options;
 
-
-  const rehypeSanitizePlugin: Pluggable | (() => void) = config.isEnabledXssPrevention
-    ? [sanitize, deepmerge(
-      addLineNumberAttribute.sanitizeOption,
-    )]
-    : () => {};
+  const rehypeSanitizePlugin: Pluggable | (() => void) =
+    config.isEnabledXssPrevention
+      ? [sanitize, deepmerge(addLineNumberAttribute.sanitizeOption)]
+      : () => {};
 
   // add rehype plugins
-  rehypePlugins.push(
-    addLineNumberAttribute.rehypePlugin,
-    rehypeSanitizePlugin,
-  );
+  rehypePlugins.push(addLineNumberAttribute.rehypePlugin, rehypeSanitizePlugin);
 
   if (config.isEnabledXssPrevention) {
     verifySanitizePlugin(options, false);
@@ -259,7 +274,10 @@ export const generatePresentationViewOptions = (
   return options;
 };
 
-export const generatePreviewOptions = (config: RendererConfigExt, pagePath: string): RendererOptions => {
+export const generatePreviewOptions = (
+  config: RendererConfigExt,
+  pagePath: string,
+): RendererOptions => {
   const options = generateCommonOptions(pagePath);
 
   const { remarkPlugins, rehypePlugins, components } = options;
@@ -267,7 +285,10 @@ export const generatePreviewOptions = (config: RendererConfigExt, pagePath: stri
   // add remark plugins
   remarkPlugins.push(
     math,
-    [plantuml.remarkPlugin, { plantumlUri: config.plantumlUri, isDarkMode: config.isDarkMode }],
+    [
+      plantuml.remarkPlugin,
+      { plantumlUri: config.plantumlUri, isDarkMode: config.isDarkMode },
+    ],
     [drawio.remarkPlugin, { isDarkMode: config.isDarkMode }],
     mermaid.remarkPlugin,
     xsvToTable.remarkPlugin,
@@ -281,23 +302,30 @@ export const generatePreviewOptions = (config: RendererConfigExt, pagePath: stri
     remarkPlugins.push(breaks);
   }
 
-  const rehypeSanitizePlugin: Pluggable | (() => void) = config.isEnabledXssPrevention
-    ? [sanitize, deepmerge(
-      getCommonSanitizeOption(config),
-      drawio.sanitizeOption,
-      mermaid.sanitizeOption,
-      callout.sanitizeOption,
-      attachment.sanitizeOption,
-      lsxGrowiDirective.sanitizeOption,
-      refsGrowiDirective.sanitizeOption,
-      addLineNumberAttribute.sanitizeOption,
-      codeBlock.sanitizeOption,
-    )]
-    : () => {};
+  const rehypeSanitizePlugin: Pluggable | (() => void) =
+    config.isEnabledXssPrevention
+      ? [
+          sanitize,
+          deepmerge(
+            getCommonSanitizeOption(config),
+            drawio.sanitizeOption,
+            mermaid.sanitizeOption,
+            callout.sanitizeOption,
+            attachment.sanitizeOption,
+            lsxGrowiDirective.sanitizeOption,
+            refsGrowiDirective.sanitizeOption,
+            addLineNumberAttribute.sanitizeOption,
+            codeBlock.sanitizeOption,
+          ),
+        ]
+      : () => {};
 
   // add rehype plugins
   rehypePlugins.push(
-    [lsxGrowiDirective.rehypePlugin, { pagePath, isSharedPage: config.isSharedPage }],
+    [
+      lsxGrowiDirective.rehypePlugin,
+      { pagePath, isSharedPage: config.isSharedPage },
+    ],
     [refsGrowiDirective.rehypePlugin, { pagePath }],
     addLineNumberAttribute.rehypePlugin,
     rehypeSanitizePlugin,

+ 107 - 62
apps/app/src/client/services/side-effects/drawio-modal-launcher-for-view.ts

@@ -1,98 +1,141 @@
 import { useCallback, useEffect } from 'react';
-
 import { Origin } from '@growi/core';
 import { globalEventTarget } from '@growi/core/dist/utils';
 import type { DrawioEditByViewerProps } from '@growi/remark-drawio';
 
 import { replaceDrawioInMarkdown } from '~/client/components/Page/markdown-drawio-util-for-view';
-import { extractRemoteRevisionDataFromErrorObj, useUpdatePage } from '~/client/services/update-page';
-import { useCurrentPageData, useSetRemoteLatestPageData } from '~/states/page';
+import {
+  extractRemoteRevisionDataFromErrorObj,
+  useUpdatePage,
+} from '~/client/services/update-page';
 import type { RemoteRevisionData } from '~/states/page';
+import { useCurrentPageData, useSetRemoteLatestPageData } from '~/states/page';
 import { useShareLinkId } from '~/states/page/hooks';
 import { useConflictDiffModalActions } from '~/states/ui/modal/conflict-diff';
 import { useDrawioModalActions } from '~/states/ui/modal/drawio';
 import loggerFactory from '~/utils/logger';
 
-
-const logger = loggerFactory('growi:cli:side-effects:useDrawioModalLauncherForView');
-
+const logger = loggerFactory(
+  'growi:cli:side-effects:useDrawioModalLauncherForView',
+);
 
 export const useDrawioModalLauncherForView = (opts?: {
-  onSaveSuccess?: () => void,
-  onSaveError?: (error: any) => void,
+  onSaveSuccess?: () => void;
+  onSaveError?: (error: any) => void;
 }): void => {
-
   const shareLinkId = useShareLinkId();
 
   const currentPage = useCurrentPageData();
 
   const { open: openDrawioModal } = useDrawioModalActions();
 
-  const { open: openConflictDiffModal, close: closeConflictDiffModal } = useConflictDiffModalActions();
+  const { open: openConflictDiffModal, close: closeConflictDiffModal } =
+    useConflictDiffModalActions();
 
   const _updatePage = useUpdatePage();
 
   const setRemoteLatestPageData = useSetRemoteLatestPageData();
 
   // eslint-disable-next-line max-len
-  const updatePage = useCallback(async(revisionId:string, newMarkdown: string, onConflict: (conflictData: RemoteRevisionData, newMarkdown: string) => void) => {
-    if (currentPage == null || currentPage.revision == null || shareLinkId != null) {
-      return;
-    }
-
-    // There are cases where "revisionId" is not required for revision updates
-    // See: https://dev.growi.org/651a6f4a008fee2f99187431#origin-%E3%81%AE%E5%BC%B7%E5%BC%B1
-    try {
-      await _updatePage({
-        pageId: currentPage._id,
-        revisionId,
-        body: newMarkdown,
-        origin: Origin.View,
-      });
-
-      closeConflictDiffModal();
-      opts?.onSaveSuccess?.();
-    }
-    catch (error) {
-      const remoteRevidsionData = extractRemoteRevisionDataFromErrorObj(error);
-      if (remoteRevidsionData != null) {
-        onConflict(remoteRevidsionData, newMarkdown);
+  const updatePage = useCallback(
+    async (
+      revisionId: string,
+      newMarkdown: string,
+      onConflict: (
+        conflictData: RemoteRevisionData,
+        newMarkdown: string,
+      ) => void,
+    ) => {
+      if (
+        currentPage == null ||
+        currentPage.revision == null ||
+        shareLinkId != null
+      ) {
+        return;
       }
 
-      logger.error('failed to save', error);
-      opts?.onSaveError?.(error);
-    }
-  }, [_updatePage, closeConflictDiffModal, currentPage, opts, shareLinkId]);
+      // There are cases where "revisionId" is not required for revision updates
+      // See: https://dev.growi.org/651a6f4a008fee2f99187431#origin-%E3%81%AE%E5%BC%B7%E5%BC%B1
+      try {
+        await _updatePage({
+          pageId: currentPage._id,
+          revisionId,
+          body: newMarkdown,
+          origin: Origin.View,
+        });
+
+        closeConflictDiffModal();
+        opts?.onSaveSuccess?.();
+      } catch (error) {
+        const remoteRevidsionData =
+          extractRemoteRevisionDataFromErrorObj(error);
+        if (remoteRevidsionData != null) {
+          onConflict(remoteRevidsionData, newMarkdown);
+        }
+
+        logger.error('failed to save', error);
+        opts?.onSaveError?.(error);
+      }
+    },
+    [_updatePage, closeConflictDiffModal, currentPage, opts, shareLinkId],
+  );
 
   // eslint-disable-next-line max-len
-  const generateResolveConflictHandler = useCallback((revisionId: string, onConflict: (conflictData: RemoteRevisionData, newMarkdown: string) => void) => {
-    return async(newMarkdown: string) => {
-      await updatePage(revisionId, newMarkdown, onConflict);
-    };
-  }, [updatePage]);
-
-  const onConflictHandler = useCallback((remoteRevidsionData: RemoteRevisionData, newMarkdown: string) => {
-    setRemoteLatestPageData(remoteRevidsionData);
-
-    const resolveConflictHandler = generateResolveConflictHandler(remoteRevidsionData.remoteRevisionId, onConflictHandler);
-    if (resolveConflictHandler == null) {
-      return;
-    }
-
-    openConflictDiffModal(newMarkdown, resolveConflictHandler);
-  }, [generateResolveConflictHandler, openConflictDiffModal, setRemoteLatestPageData]);
+  const generateResolveConflictHandler = useCallback(
+    (
+      revisionId: string,
+      onConflict: (
+        conflictData: RemoteRevisionData,
+        newMarkdown: string,
+      ) => void,
+    ) => {
+      return async (newMarkdown: string) => {
+        await updatePage(revisionId, newMarkdown, onConflict);
+      };
+    },
+    [updatePage],
+  );
+
+  const onConflictHandler = useCallback(
+    (remoteRevidsionData: RemoteRevisionData, newMarkdown: string) => {
+      setRemoteLatestPageData(remoteRevidsionData);
+
+      const resolveConflictHandler = generateResolveConflictHandler(
+        remoteRevidsionData.remoteRevisionId,
+        onConflictHandler,
+      );
+      if (resolveConflictHandler == null) {
+        return;
+      }
 
-  const saveByDrawioModal = useCallback(async(drawioMxFile: string, bol: number, eol: number) => {
-    if (currentPage == null || currentPage.revision == null) {
-      return;
-    }
+      openConflictDiffModal(newMarkdown, resolveConflictHandler);
+    },
+    [
+      generateResolveConflictHandler,
+      openConflictDiffModal,
+      setRemoteLatestPageData,
+    ],
+  );
+
+  const saveByDrawioModal = useCallback(
+    async (drawioMxFile: string, bol: number, eol: number) => {
+      if (currentPage == null || currentPage.revision == null) {
+        return;
+      }
 
-    const currentRevisionId = currentPage.revision._id;
-    const currentMarkdown = currentPage.revision.body;
-    const newMarkdown = replaceDrawioInMarkdown(drawioMxFile, currentMarkdown, bol, eol);
+      const currentRevisionId = currentPage.revision._id;
+      const currentMarkdown = currentPage.revision.body;
+      const newMarkdown = replaceDrawioInMarkdown(
+        drawioMxFile,
+        currentMarkdown,
+        bol,
+        eol,
+      );
 
-    await updatePage(currentRevisionId, newMarkdown, onConflictHandler);
-  }, [currentPage, onConflictHandler, updatePage]);
+      await updatePage(currentRevisionId, newMarkdown, onConflictHandler);
+    },
+    [currentPage, onConflictHandler, updatePage],
+  );
 
   // set handler to open DrawioModal
   useEffect(() => {
@@ -103,7 +146,9 @@ export const useDrawioModalLauncherForView = (opts?: {
 
     const handler = (evt: CustomEvent<DrawioEditByViewerProps>) => {
       const data = evt.detail;
-      openDrawioModal(data.drawioMxFile, drawioMxFile => saveByDrawioModal(drawioMxFile, data.bol, data.eol));
+      openDrawioModal(data.drawioMxFile, (drawioMxFile) =>
+        saveByDrawioModal(drawioMxFile, data.bol, data.eol),
+      );
     };
     globalEventTarget.addEventListener('launchDrawioModal', handler);
 

+ 117 - 64
apps/app/src/client/services/side-effects/handsontable-modal-launcher-for-view.ts

@@ -1,99 +1,145 @@
 import { useCallback, useEffect } from 'react';
-
 import { Origin } from '@growi/core';
 import { globalEventTarget } from '@growi/core/dist/utils';
 import type { MarkdownTable } from '@growi/editor';
 
-import { getMarkdownTableFromLine, replaceMarkdownTableInMarkdown } from '~/client/components/Page/markdown-table-util-for-view';
+import {
+  getMarkdownTableFromLine,
+  replaceMarkdownTableInMarkdown,
+} from '~/client/components/Page/markdown-table-util-for-view';
 import type { LaunchHandsonTableModalEventDetail } from '~/client/interfaces/handsontable-modal';
-import { extractRemoteRevisionDataFromErrorObj, useUpdatePage } from '~/client/services/update-page';
-import { useCurrentPageData, useSetRemoteLatestPageData } from '~/states/page';
+import {
+  extractRemoteRevisionDataFromErrorObj,
+  useUpdatePage,
+} from '~/client/services/update-page';
 import type { RemoteRevisionData } from '~/states/page';
+import { useCurrentPageData, useSetRemoteLatestPageData } from '~/states/page';
 import { useShareLinkId } from '~/states/page/hooks';
 import { useConflictDiffModalActions } from '~/states/ui/modal/conflict-diff';
 import { useHandsontableModalActions } from '~/states/ui/modal/handsontable';
 import loggerFactory from '~/utils/logger';
 
-
-const logger = loggerFactory('growi:cli:side-effects:useHandsontableModalLauncherForView');
-
+const logger = loggerFactory(
+  'growi:cli:side-effects:useHandsontableModalLauncherForView',
+);
 
 export const useHandsontableModalLauncherForView = (opts?: {
-  onSaveSuccess?: () => void,
-  onSaveError?: (error: any) => void,
+  onSaveSuccess?: () => void;
+  onSaveError?: (error: any) => void;
 }): void => {
-
   const shareLinkId = useShareLinkId();
 
   const currentPage = useCurrentPageData();
 
   const { open: openHandsontableModal } = useHandsontableModalActions();
 
-  const { open: openConflictDiffModal, close: closeConflictDiffModal } = useConflictDiffModalActions();
+  const { open: openConflictDiffModal, close: closeConflictDiffModal } =
+    useConflictDiffModalActions();
 
   const _updatePage = useUpdatePage();
 
   const setRemoteLatestPageData = useSetRemoteLatestPageData();
 
   // eslint-disable-next-line max-len
-  const updatePage = useCallback(async(revisionId:string, newMarkdown: string, onConflict: (conflictData: RemoteRevisionData, newMarkdown: string) => void) => {
-    if (currentPage == null || currentPage.revision == null || shareLinkId != null) {
-      return;
-    }
-
-    try {
-      // There are cases where "revisionId" is not required for revision updates
-      // See: https://dev.growi.org/651a6f4a008fee2f99187431#origin-%E3%81%AE%E5%BC%B7%E5%BC%B1
-      await _updatePage({
-        pageId: currentPage._id,
-        revisionId,
-        body: newMarkdown,
-        origin: Origin.View,
-      });
-
-      closeConflictDiffModal();
-      opts?.onSaveSuccess?.();
-    }
-    catch (error) {
-      const remoteRevidsionData = extractRemoteRevisionDataFromErrorObj(error);
-      if (remoteRevidsionData != null) {
-        onConflict?.(remoteRevidsionData, newMarkdown);
+  const updatePage = useCallback(
+    async (
+      revisionId: string,
+      newMarkdown: string,
+      onConflict: (
+        conflictData: RemoteRevisionData,
+        newMarkdown: string,
+      ) => void,
+    ) => {
+      if (
+        currentPage == null ||
+        currentPage.revision == null ||
+        shareLinkId != null
+      ) {
+        return;
       }
 
-      logger.error('failed to save', error);
-      opts?.onSaveError?.(error);
-    }
-  }, [_updatePage, closeConflictDiffModal, currentPage, opts, shareLinkId]);
+      try {
+        // There are cases where "revisionId" is not required for revision updates
+        // See: https://dev.growi.org/651a6f4a008fee2f99187431#origin-%E3%81%AE%E5%BC%B7%E5%BC%B1
+        await _updatePage({
+          pageId: currentPage._id,
+          revisionId,
+          body: newMarkdown,
+          origin: Origin.View,
+        });
+
+        closeConflictDiffModal();
+        opts?.onSaveSuccess?.();
+      } catch (error) {
+        const remoteRevidsionData =
+          extractRemoteRevisionDataFromErrorObj(error);
+        if (remoteRevidsionData != null) {
+          onConflict?.(remoteRevidsionData, newMarkdown);
+        }
+
+        logger.error('failed to save', error);
+        opts?.onSaveError?.(error);
+      }
+    },
+    [_updatePage, closeConflictDiffModal, currentPage, opts, shareLinkId],
+  );
 
   // eslint-disable-next-line max-len
-  const generateResolveConflictHandler = useCallback((revisionId: string, onConflict: (conflictData: RemoteRevisionData, newMarkdown: string) => void) => {
-    return async(newMarkdown: string) => {
-      await updatePage(revisionId, newMarkdown, onConflict);
-    };
-  }, [updatePage]);
-
-  const onConflictHandler = useCallback((remoteRevidsionData: RemoteRevisionData, newMarkdown: string) => {
-    setRemoteLatestPageData(remoteRevidsionData);
-
-    const resolveConflictHandler = generateResolveConflictHandler(remoteRevidsionData.remoteRevisionId, onConflictHandler);
-    if (resolveConflictHandler == null) {
-      return;
-    }
-
-    openConflictDiffModal(newMarkdown, resolveConflictHandler);
-  }, [generateResolveConflictHandler, openConflictDiffModal, setRemoteLatestPageData]);
+  const generateResolveConflictHandler = useCallback(
+    (
+      revisionId: string,
+      onConflict: (
+        conflictData: RemoteRevisionData,
+        newMarkdown: string,
+      ) => void,
+    ) => {
+      return async (newMarkdown: string) => {
+        await updatePage(revisionId, newMarkdown, onConflict);
+      };
+    },
+    [updatePage],
+  );
+
+  const onConflictHandler = useCallback(
+    (remoteRevidsionData: RemoteRevisionData, newMarkdown: string) => {
+      setRemoteLatestPageData(remoteRevidsionData);
+
+      const resolveConflictHandler = generateResolveConflictHandler(
+        remoteRevidsionData.remoteRevisionId,
+        onConflictHandler,
+      );
+      if (resolveConflictHandler == null) {
+        return;
+      }
 
-  const saveByHandsontableModal = useCallback(async(table: MarkdownTable, bol: number, eol: number) => {
-    if (currentPage == null || currentPage.revision == null) {
-      return;
-    }
+      openConflictDiffModal(newMarkdown, resolveConflictHandler);
+    },
+    [
+      generateResolveConflictHandler,
+      openConflictDiffModal,
+      setRemoteLatestPageData,
+    ],
+  );
+
+  const saveByHandsontableModal = useCallback(
+    async (table: MarkdownTable, bol: number, eol: number) => {
+      if (currentPage == null || currentPage.revision == null) {
+        return;
+      }
 
-    const currentRevisionId = currentPage.revision._id;
-    const currentMarkdown = currentPage.revision.body;
-    const newMarkdown = replaceMarkdownTableInMarkdown(table, currentMarkdown, bol, eol);
+      const currentRevisionId = currentPage.revision._id;
+      const currentMarkdown = currentPage.revision.body;
+      const newMarkdown = replaceMarkdownTableInMarkdown(
+        table,
+        currentMarkdown,
+        bol,
+        eol,
+      );
 
-    await updatePage(currentRevisionId, newMarkdown, onConflictHandler);
-  }, [currentPage, onConflictHandler, updatePage]);
+      await updatePage(currentRevisionId, newMarkdown, onConflictHandler);
+    },
+    [currentPage, onConflictHandler, updatePage],
+  );
 
   // set handler to open HandsonTableModal
   useEffect(() => {
@@ -107,12 +153,19 @@ export const useHandsontableModalLauncherForView = (opts?: {
       const markdown = currentPage.revision.body;
       const { bol, eol } = evt.detail;
       const currentMarkdownTable = getMarkdownTableFromLine(markdown, bol, eol);
-      openHandsontableModal(currentMarkdownTable, false, table => saveByHandsontableModal(table, bol, eol));
+      openHandsontableModal(currentMarkdownTable, false, (table) =>
+        saveByHandsontableModal(table, bol, eol),
+      );
     };
     globalEventTarget.addEventListener('launchHandsonTableModal', handler);
 
     return function cleanup() {
       globalEventTarget.removeEventListener('launchHandsonTableModal', handler);
     };
-  }, [currentPage, openHandsontableModal, saveByHandsontableModal, shareLinkId]);
+  }, [
+    currentPage,
+    openHandsontableModal,
+    saveByHandsontableModal,
+    shareLinkId,
+  ]);
 };

+ 4 - 6
apps/app/src/client/services/side-effects/hash-changed.ts

@@ -1,9 +1,8 @@
 import { useCallback, useEffect } from 'react';
-
 import { useRouter } from 'next/router';
 
 import { useIsEditable } from '~/states/page';
-import { useEditorMode, determineEditorModeByHash } from '~/states/ui/editor';
+import { determineEditorModeByHash, useEditorMode } from '~/states/ui/editor';
 
 /**
  * Change editorMode by browser forward/back operation
@@ -34,13 +33,12 @@ export const useHashChangedEffect = (): void => {
     return function cleanup() {
       window.removeEventListener('hashchange', hashchangeHandler);
     };
-
   }, [hashchangeHandler, isEditable]);
 
   /*
-  * Route changes by Next Router
-  * https://nextjs.org/docs/api-reference/next/router
-  */
+   * Route changes by Next Router
+   * https://nextjs.org/docs/api-reference/next/router
+   */
   useEffect(() => {
     router.events.on('routeChangeComplete', hashchangeHandler);
 

+ 65 - 41
apps/app/src/client/services/side-effects/page-updated.ts

@@ -1,71 +1,95 @@
 import { useCallback, useEffect } from 'react';
 
 import { SocketEventName } from '~/interfaces/websocket';
-import { useCurrentPageData, useFetchCurrentPage, useSetRemoteLatestPageData } from '~/states/page';
 import type { RemoteRevisionData } from '~/states/page';
+import {
+  useCurrentPageData,
+  useFetchCurrentPage,
+  useSetRemoteLatestPageData,
+} from '~/states/page';
 import { useGlobalSocket } from '~/states/socket-io';
-import { useEditorMode, EditorMode } from '~/states/ui/editor';
+import { EditorMode, useEditorMode } from '~/states/ui/editor';
 import { usePageStatusAlertActions } from '~/states/ui/modal/page-status-alert';
 import { useSWRxPageInfo } from '~/stores/page';
 
-
 export const usePageUpdatedEffect = (): void => {
-
   const setRemoteLatestPageData = useSetRemoteLatestPageData();
 
   const socket = useGlobalSocket();
   const { editorMode } = useEditorMode();
   const currentPage = useCurrentPageData();
   const { fetchCurrentPage } = useFetchCurrentPage();
-  const { open: openPageStatusAlert, close: closePageStatusAlert } = usePageStatusAlertActions();
+  const { open: openPageStatusAlert, close: closePageStatusAlert } =
+    usePageStatusAlertActions();
 
   const { mutate: mutatePageInfo } = useSWRxPageInfo(currentPage?._id);
 
-  const remotePageDataUpdateHandler = useCallback((data) => {
-    // Set remote page data
-    const { s2cMessagePageUpdated } = data;
-
-    const remoteData: RemoteRevisionData = {
-      remoteRevisionId: s2cMessagePageUpdated.revisionId,
-      remoteRevisionBody: s2cMessagePageUpdated.revisionBody,
-      remoteRevisionLastUpdateUser: s2cMessagePageUpdated.remoteLastUpdateUser,
-      remoteRevisionLastUpdatedAt: s2cMessagePageUpdated.revisionUpdateAt,
-    };
-
-    if (currentPage?._id != null && currentPage._id === s2cMessagePageUpdated.pageId) {
-      setRemoteLatestPageData(remoteData);
-
-      // Update PageInfo cache
-      mutatePageInfo();
-
-      // Open PageStatusAlert
-      const currentRevisionId = currentPage?.revision?._id;
-      const remoteRevisionId = s2cMessagePageUpdated.revisionId;
-      const isRevisionOutdated = (currentRevisionId != null || remoteRevisionId != null) && currentRevisionId !== remoteRevisionId;
-
-      // !!CAUTION!! Timing of calling openPageStatusAlert may clash with components/PageEditor/conflict.tsx
-      if (isRevisionOutdated && editorMode === EditorMode.View) {
-        openPageStatusAlert({ hideEditorMode: EditorMode.Editor, onRefleshPage: () => fetchCurrentPage({ force: true }) });
-      }
-
-      // Clear cache
-      if (!isRevisionOutdated) {
-        closePageStatusAlert();
+  const remotePageDataUpdateHandler = useCallback(
+    (data) => {
+      // Set remote page data
+      const { s2cMessagePageUpdated } = data;
+
+      const remoteData: RemoteRevisionData = {
+        remoteRevisionId: s2cMessagePageUpdated.revisionId,
+        remoteRevisionBody: s2cMessagePageUpdated.revisionBody,
+        remoteRevisionLastUpdateUser:
+          s2cMessagePageUpdated.remoteLastUpdateUser,
+        remoteRevisionLastUpdatedAt: s2cMessagePageUpdated.revisionUpdateAt,
+      };
+
+      if (
+        currentPage?._id != null &&
+        currentPage._id === s2cMessagePageUpdated.pageId
+      ) {
+        setRemoteLatestPageData(remoteData);
+
+        // Update PageInfo cache
+        mutatePageInfo();
+
+        // Open PageStatusAlert
+        const currentRevisionId = currentPage?.revision?._id;
+        const remoteRevisionId = s2cMessagePageUpdated.revisionId;
+        const isRevisionOutdated =
+          (currentRevisionId != null || remoteRevisionId != null) &&
+          currentRevisionId !== remoteRevisionId;
+
+        // !!CAUTION!! Timing of calling openPageStatusAlert may clash with components/PageEditor/conflict.tsx
+        if (isRevisionOutdated && editorMode === EditorMode.View) {
+          openPageStatusAlert({
+            hideEditorMode: EditorMode.Editor,
+            onRefleshPage: () => fetchCurrentPage({ force: true }),
+          });
+        }
+
+        // Clear cache
+        if (!isRevisionOutdated) {
+          closePageStatusAlert();
+        }
       }
-    }
-  // eslint-disable-next-line max-len
-  }, [currentPage?._id, currentPage?.revision?._id, setRemoteLatestPageData, mutatePageInfo, editorMode, openPageStatusAlert, fetchCurrentPage, closePageStatusAlert]);
+      // eslint-disable-next-line max-len
+    },
+    [
+      currentPage?._id,
+      currentPage?.revision?._id,
+      setRemoteLatestPageData,
+      mutatePageInfo,
+      editorMode,
+      openPageStatusAlert,
+      fetchCurrentPage,
+      closePageStatusAlert,
+    ],
+  );
 
   // listen socket for someone updating this page
   useEffect(() => {
-
-    if (socket == null) { return }
+    if (socket == null) {
+      return;
+    }
 
     socket.on(SocketEventName.PageUpdated, remotePageDataUpdateHandler);
 
     return () => {
       socket.off(SocketEventName.PageUpdated, remotePageDataUpdateHandler);
     };
-
   }, [remotePageDataUpdateHandler, socket]);
 };

+ 1 - 1
apps/app/src/client/services/side-effects/use-sticky.ts

@@ -1,4 +1,4 @@
-import { useState, useEffect } from 'react';
+import { useEffect, useState } from 'react';
 
 // Custom hook that accepts a selector string as an argument
 // and returns a boolean indicating whether the selected element is currently sticky.

+ 3 - 2
apps/app/src/client/services/update-page/conflict.tsx

@@ -3,10 +3,11 @@ import type { ErrorV3 } from '@growi/core/dist/models';
 import { PageUpdateErrorCode } from '~/interfaces/apiv3';
 import type { RemoteRevisionData } from '~/states/page';
 
-export const extractRemoteRevisionDataFromErrorObj = (errors: Array<ErrorV3>): RemoteRevisionData | undefined => {
+export const extractRemoteRevisionDataFromErrorObj = (
+  errors: Array<ErrorV3>,
+): RemoteRevisionData | undefined => {
   for (const error of errors) {
     if (error.code === PageUpdateErrorCode.CONFLICT) {
-
       const latestRevision = error.args.returnLatestRevision;
 
       const remoteRevidsionData = {

+ 7 - 2
apps/app/src/client/services/update-page/update-page.ts

@@ -1,7 +1,12 @@
 import { apiv3Put } from '~/client/util/apiv3-client';
-import type { IApiv3PageUpdateParams, IApiv3PageUpdateResponse } from '~/interfaces/apiv3';
+import type {
+  IApiv3PageUpdateParams,
+  IApiv3PageUpdateResponse,
+} from '~/interfaces/apiv3';
 
-export const updatePage = async(params: IApiv3PageUpdateParams): Promise<IApiv3PageUpdateResponse> => {
+export const updatePage = async (
+  params: IApiv3PageUpdateParams,
+): Promise<IApiv3PageUpdateResponse> => {
   const res = await apiv3Put<IApiv3PageUpdateResponse>('/page', params);
   return res.data;
 };

+ 16 - 10
apps/app/src/client/services/update-page/use-update-page.tsx

@@ -1,25 +1,31 @@
 import { useCallback } from 'react';
 
-import type { IApiv3PageUpdateParams, IApiv3PageUpdateResponse } from '~/interfaces/apiv3';
+import type {
+  IApiv3PageUpdateParams,
+  IApiv3PageUpdateResponse,
+} from '~/interfaces/apiv3';
 import { useSetIsUntitledPage } from '~/states/page';
 
 import { updatePage } from './update-page';
 
-
-type UseUpdatePage = (params: IApiv3PageUpdateParams) => Promise<IApiv3PageUpdateResponse>;
-
+type UseUpdatePage = (
+  params: IApiv3PageUpdateParams,
+) => Promise<IApiv3PageUpdateResponse>;
 
 export const useUpdatePage = (): UseUpdatePage => {
   const setIsUntitledPage = useSetIsUntitledPage();
 
-  const updatePageExt: UseUpdatePage = useCallback(async (params) => {
-    const result = await updatePage(params);
+  const updatePageExt: UseUpdatePage = useCallback(
+    async (params) => {
+      const result = await updatePage(params);
 
-    // set false to isUntitledPage
-    setIsUntitledPage(false);
+      // set false to isUntitledPage
+      setIsUntitledPage(false);
 
-    return result;
-  }, [setIsUntitledPage]);
+      return result;
+    },
+    [setIsUntitledPage],
+  );
 
   return updatePageExt;
 };

+ 25 - 12
apps/app/src/client/services/upload-attachments/upload-attachments.ts

@@ -1,23 +1,33 @@
 import type { IAttachment } from '@growi/core';
 
 import { apiv3Get, apiv3PostForm } from '~/client/util/apiv3-client';
-import type { IApiv3GetAttachmentLimitParams, IApiv3GetAttachmentLimitResponse, IApiv3PostAttachmentResponse } from '~/interfaces/apiv3/attachment';
+import type {
+  IApiv3GetAttachmentLimitParams,
+  IApiv3GetAttachmentLimitResponse,
+  IApiv3PostAttachmentResponse,
+} from '~/interfaces/apiv3/attachment';
 import loggerFactory from '~/utils/logger';
 
-
 const logger = loggerFactory('growi:client:services:upload-attachment');
 
-
 type UploadOpts = {
-  onUploaded?: (attachment: IAttachment) => void,
-  onError?: (error: Error, file: File) => void,
-}
+  onUploaded?: (attachment: IAttachment) => void;
+  onError?: (error: Error, file: File) => void;
+};
 
-export const uploadAttachments = async(pageId: string, files: File[], opts?: UploadOpts): Promise<void> => {
-  files.forEach(async(file) => {
+export const uploadAttachments = async (
+  pageId: string,
+  files: File[],
+  opts?: UploadOpts,
+): Promise<void> => {
+  files.forEach(async (file) => {
     try {
       const params: IApiv3GetAttachmentLimitParams = { fileSize: file.size };
-      const { data: resLimit } = await apiv3Get<IApiv3GetAttachmentLimitResponse>('/attachment/limit', params);
+      const { data: resLimit } =
+        await apiv3Get<IApiv3GetAttachmentLimitResponse>(
+          '/attachment/limit',
+          params,
+        );
 
       if (!resLimit.isUploadable) {
         throw new Error(resLimit.errorMessage);
@@ -27,11 +37,14 @@ export const uploadAttachments = async(pageId: string, files: File[], opts?: Upl
       formData.append('file', file);
       formData.append('page_id', pageId);
 
-      const { data: resAdd } = await apiv3PostForm<IApiv3PostAttachmentResponse>('/attachment', formData);
+      const { data: resAdd } =
+        await apiv3PostForm<IApiv3PostAttachmentResponse>(
+          '/attachment',
+          formData,
+        );
 
       opts?.onUploaded?.(resAdd.attachment);
-    }
-    catch (e) {
+    } catch (e) {
       logger.error('failed to upload', e);
       opts?.onError?.(e, file);
     }

+ 4 - 4
apps/app/src/client/services/use-print-mode.ts

@@ -1,5 +1,4 @@
 import { useEffect, useState } from 'react';
-
 import { flushSync } from 'react-dom';
 
 export const usePrintMode = (): boolean => {
@@ -7,9 +6,10 @@ export const usePrintMode = (): boolean => {
 
   useEffect(() => {
     // force re-render on beforeprint
-    const handleBeforePrint = () => flushSync(() => {
-      setIsPrinting(true);
-    });
+    const handleBeforePrint = () =>
+      flushSync(() => {
+        setIsPrinting(true);
+      });
 
     const handleAfterPrint = () => {
       setIsPrinting(false);

+ 23 - 22
apps/app/src/client/services/use-start-editing.tsx

@@ -1,11 +1,10 @@
 import { useCallback } from 'react';
-
 import { Origin } from '@growi/core';
 import { getParentPath } from '@growi/core/dist/utils/path-utils';
 
 import { useCreatePage } from '~/client/services/create-page';
 import { usePageNotFound } from '~/states/page';
-import { useEditorMode, EditorMode } from '~/states/ui/editor';
+import { EditorMode, useEditorMode } from '~/states/ui/editor';
 
 import { shouldCreateWipPage } from '../../utils/should-create-wip-page';
 
@@ -14,25 +13,27 @@ export const useStartEditing = (): ((path?: string) => Promise<void>) => {
   const { setEditorMode } = useEditorMode();
   const { create } = useCreatePage();
 
-  return useCallback(async (path?: string) => {
-    if (!isNotFound) {
-      setEditorMode(EditorMode.Editor);
-      return;
-    }
-    // Create a new page if it does not exist and transit to the editor mode
-    try {
-      const parentPath = path != null ? getParentPath(path) : undefined; // does not have to exist
-      await create(
-        {
-          path, parentPath, wip: shouldCreateWipPage(path), origin: Origin.View,
-        },
-      );
-
-      setEditorMode(EditorMode.Editor);
-    }
-    catch (err) {
-      throw new Error(err);
-    }
-  }, [create, isNotFound, setEditorMode]);
+  return useCallback(
+    async (path?: string) => {
+      if (!isNotFound) {
+        setEditorMode(EditorMode.Editor);
+        return;
+      }
+      // Create a new page if it does not exist and transit to the editor mode
+      try {
+        const parentPath = path != null ? getParentPath(path) : undefined; // does not have to exist
+        await create({
+          path,
+          parentPath,
+          wip: shouldCreateWipPage(path),
+          origin: Origin.View,
+        });
 
+        setEditorMode(EditorMode.Editor);
+      } catch (err) {
+        throw new Error(err);
+      }
+    },
+    [create, isNotFound, setEditorMode],
+  );
 };

+ 13 - 10
apps/app/src/client/services/use-toastr-on-error.tsx

@@ -1,18 +1,21 @@
 import { useCallback } from 'react';
-
 import { useTranslation } from 'react-i18next';
 
 import { toastError } from '~/client/util/toastr';
 
-export const useToastrOnError = <P, R>(method?: (param?: P) => Promise<R|undefined>): (param?: P) => Promise<R|undefined> => {
+export const useToastrOnError = <P, R>(
+  method?: (param?: P) => Promise<R | undefined>,
+): ((param?: P) => Promise<R | undefined>) => {
   const { t } = useTranslation('commons');
 
-  return useCallback(async(param) => {
-    try {
-      return await method?.(param);
-    }
-    catch (err) {
-      toastError(t('toaster.create_failed', { target: 'a page' }));
-    }
-  }, [method, t]);
+  return useCallback(
+    async (param) => {
+      try {
+        return await method?.(param);
+      } catch (err) {
+        toastError(t('toaster.create_failed', { target: 'a page' }));
+      }
+    },
+    [method, t],
+  );
 };

+ 16 - 5
apps/app/src/client/services/user-ui-settings.ts

@@ -6,8 +6,12 @@ import { apiv3Put } from '~/client/util/apiv3-client';
 import type { IUserUISettings } from '~/interfaces/user-ui-settings';
 
 let settingsForBulk: Partial<IUserUISettings> = {};
-const _putUserUISettingsInBulk = (): Promise<AxiosResponse<IUserUISettings>> => {
-  const result = apiv3Put<IUserUISettings>('/user-ui-settings', { settings: settingsForBulk });
+const _putUserUISettingsInBulk = (): Promise<
+  AxiosResponse<IUserUISettings>
+> => {
+  const result = apiv3Put<IUserUISettings>('/user-ui-settings', {
+    settings: settingsForBulk,
+  });
 
   // clear partial
   settingsForBulk = {};
@@ -15,7 +19,10 @@ const _putUserUISettingsInBulk = (): Promise<AxiosResponse<IUserUISettings>> =>
   return result;
 };
 
-const _putUserUISettingsInBulkDebounced = debounce(1500, _putUserUISettingsInBulk);
+const _putUserUISettingsInBulkDebounced = debounce(
+  1500,
+  _putUserUISettingsInBulk,
+);
 
 export const scheduleToPut = (settings: Partial<IUserUISettings>): void => {
   settingsForBulk = {
@@ -26,8 +33,12 @@ export const scheduleToPut = (settings: Partial<IUserUISettings>): void => {
   _putUserUISettingsInBulkDebounced();
 };
 
-export const updateUserUISettings = async(settings: Partial<IUserUISettings>): Promise<AxiosResponse<IUserUISettings>> => {
-  const result = await apiv3Put<IUserUISettings>('/user-ui-settings', { settings });
+export const updateUserUISettings = async (
+  settings: Partial<IUserUISettings>,
+): Promise<AxiosResponse<IUserUISettings>> => {
+  const result = await apiv3Put<IUserUISettings>('/user-ui-settings', {
+    settings,
+  });
 
   return result;
 };

+ 26 - 9
apps/app/src/client/util/apiv1-client.ts

@@ -5,7 +5,6 @@ import axios from '~/utils/axios';
 const apiv1Root = '/_api';
 
 class Apiv1ErrorHandler extends Error {
-
   code;
 
   data;
@@ -16,12 +15,14 @@ class Apiv1ErrorHandler extends Error {
     this.message = message;
     this.code = code;
     this.data = data;
-
   }
-
 }
 
-export async function apiRequest<T>(method: string, path: string, params: unknown): Promise<T> {
+export async function apiRequest<T>(
+  method: string,
+  path: string,
+  params: unknown,
+): Promise<T> {
   const res = await axios[method](urljoin(apiv1Root, path), params);
 
   if (res.data.ok) {
@@ -30,25 +31,41 @@ export async function apiRequest<T>(method: string, path: string, params: unknow
 
   // Return error code if code is exist
   if (res.data.code != null) {
-    const error = new Apiv1ErrorHandler(res.data.error, res.data.code, res.data.data);
+    const error = new Apiv1ErrorHandler(
+      res.data.error,
+      res.data.code,
+      res.data.data,
+    );
     throw error;
   }
 
   throw new Error(res.data.error);
 }
 
-export async function apiGet<T>(path: string, params: unknown = {}): Promise<T> {
+export async function apiGet<T>(
+  path: string,
+  params: unknown = {},
+): Promise<T> {
   return apiRequest<T>('get', path, { params });
 }
 
-export async function apiPost<T>(path: string, params: unknown = {}): Promise<T> {
+export async function apiPost<T>(
+  path: string,
+  params: unknown = {},
+): Promise<T> {
   return apiRequest<T>('post', path, params);
 }
 
-export async function apiPostForm<T>(path: string, formData: FormData): Promise<T> {
+export async function apiPostForm<T>(
+  path: string,
+  formData: FormData,
+): Promise<T> {
   return apiRequest<T>('postForm', path, formData);
 }
 
-export async function apiDelete<T>(path: string, params: unknown = {}): Promise<T> {
+export async function apiDelete<T>(
+  path: string,
+  params: unknown = {},
+): Promise<T> {
   return apiRequest<T>('delete', path, { data: params });
 }

+ 29 - 10
apps/app/src/client/util/apiv3-client.ts

@@ -10,12 +10,13 @@ const apiv3Root = '/_api/v3';
 
 const logger = loggerFactory('growi:apiv3');
 
-
 const apiv3ErrorHandler = (_err: any): any[] => {
   // extract api errors from general 400 err
   const err = axios.isAxiosError(_err) ? _err.response?.data.errors : _err;
   const errs = toArrayIfNot(err);
-  const errorInfo = axios.isAxiosError(_err) ? _err.response?.data.info : undefined;
+  const errorInfo = axios.isAxiosError(_err)
+    ? _err.response?.data.info
+    : undefined;
 
   for (const err of errs) {
     logger.error(err.message);
@@ -28,33 +29,51 @@ const apiv3ErrorHandler = (_err: any): any[] => {
 };
 
 // eslint-disable-next-line @typescript-eslint/no-explicit-any
-export async function apiv3Request<T = any>(method: string, path: string, params: unknown): Promise<AxiosResponse<T>> {
+export async function apiv3Request<T = any>(
+  method: string,
+  path: string,
+  params: unknown,
+): Promise<AxiosResponse<T>> {
   try {
     const res = await axios[method](urljoin(apiv3Root, path), params);
     return res;
-  }
-  catch (err) {
+  } catch (err) {
     const errors = apiv3ErrorHandler(err);
     throw errors;
   }
 }
 
-export async function apiv3Get<T = any>(path: string, params: unknown = {}): Promise<AxiosResponse<T>> {
+export async function apiv3Get<T = any>(
+  path: string,
+  params: unknown = {},
+): Promise<AxiosResponse<T>> {
   return apiv3Request('get', path, { params });
 }
 
-export async function apiv3Post<T = any>(path: string, params: unknown = {}): Promise<AxiosResponse<T>> {
+export async function apiv3Post<T = any>(
+  path: string,
+  params: unknown = {},
+): Promise<AxiosResponse<T>> {
   return apiv3Request('post', path, params);
 }
 
-export async function apiv3PostForm<T = any>(path: string, formData: FormData): Promise<AxiosResponse<T>> {
+export async function apiv3PostForm<T = any>(
+  path: string,
+  formData: FormData,
+): Promise<AxiosResponse<T>> {
   return apiv3Request('postForm', path, formData);
 }
 
-export async function apiv3Put<T = any>(path: string, params: unknown = {}): Promise<AxiosResponse<T>> {
+export async function apiv3Put<T = any>(
+  path: string,
+  params: unknown = {},
+): Promise<AxiosResponse<T>> {
   return apiv3Request('put', path, params);
 }
 
-export async function apiv3Delete<T = any>(path: string, params: unknown = {}): Promise<AxiosResponse<T>> {
+export async function apiv3Delete<T = any>(
+  path: string,
+  params: unknown = {},
+): Promise<AxiosResponse<T>> {
   return apiv3Request('delete', path, { params });
 }

+ 48 - 12
apps/app/src/client/util/bookmark-utils.ts

@@ -1,44 +1,80 @@
 import type { IRevision, Ref } from '@growi/core';
 
-import type { BookmarkFolderItems, BookmarkedPage } from '~/interfaces/bookmark-info';
+import type {
+  BookmarkedPage,
+  BookmarkFolderItems,
+} from '~/interfaces/bookmark-info';
 
 import { apiv3Delete, apiv3Post, apiv3Put } from './apiv3-client';
 
 // Check if bookmark folder item has childFolder or bookmarks
-export const hasChildren = ({ childFolder, bookmarks }: { childFolder?: BookmarkFolderItems[], bookmarks?: BookmarkedPage[] }): boolean => {
-  return !!((childFolder && childFolder.length > 0) || (bookmarks && bookmarks.length > 0));
+export const hasChildren = ({
+  childFolder,
+  bookmarks,
+}: {
+  childFolder?: BookmarkFolderItems[];
+  bookmarks?: BookmarkedPage[];
+}): boolean => {
+  return !!(
+    (childFolder && childFolder.length > 0) ||
+    (bookmarks && bookmarks.length > 0)
+  );
 };
 
 // Add new folder helper
-export const addNewFolder = async(name: string, parent: string | null): Promise<void> => {
+export const addNewFolder = async (
+  name: string,
+  parent: string | null,
+): Promise<void> => {
   await apiv3Post('/bookmark-folder', { name, parent });
 };
 
 // Put bookmark to a folder
-export const addBookmarkToFolder = async(pageId: string, folderId: string | null): Promise<void> => {
-  await apiv3Post('/bookmark-folder/add-bookmark-to-folder', { pageId, folderId });
+export const addBookmarkToFolder = async (
+  pageId: string,
+  folderId: string | null,
+): Promise<void> => {
+  await apiv3Post('/bookmark-folder/add-bookmark-to-folder', {
+    pageId,
+    folderId,
+  });
 };
 
 // Delete bookmark folder
-export const deleteBookmarkFolder = async(bookmarkFolderId: string): Promise<void> => {
+export const deleteBookmarkFolder = async (
+  bookmarkFolderId: string,
+): Promise<void> => {
   await apiv3Delete(`/bookmark-folder/${bookmarkFolderId}`);
 };
 
 // Rename page from bookmark item control
-export const renamePage = async(pageId: string, revisionId: Ref<IRevision> | undefined, newPagePath: string): Promise<void> => {
+export const renamePage = async (
+  pageId: string,
+  revisionId: Ref<IRevision> | undefined,
+  newPagePath: string,
+): Promise<void> => {
   await apiv3Put('/pages/rename', { pageId, revisionId, newPagePath });
 };
 
 // Update bookmark by isBookmarked status
-export const toggleBookmark = async(pageId: string, status: boolean): Promise<void> => {
+export const toggleBookmark = async (
+  pageId: string,
+  status: boolean,
+): Promise<void> => {
   await apiv3Put('/bookmark-folder/update-bookmark', { pageId, status });
 };
 
 // Update Bookmark folder
-export const updateBookmarkFolder = async(
-    bookmarkFolderId: string, name: string, parent: string | null, childFolder: BookmarkFolderItems[],
+export const updateBookmarkFolder = async (
+  bookmarkFolderId: string,
+  name: string,
+  parent: string | null,
+  childFolder: BookmarkFolderItems[],
 ): Promise<void> => {
   await apiv3Put('/bookmark-folder', {
-    bookmarkFolderId, name, parent, childFolder,
+    bookmarkFolderId,
+    name,
+    parent,
+    childFolder,
   });
 };

+ 14 - 7
apps/app/src/client/util/scope-util.test.ts

@@ -1,10 +1,9 @@
 import { SCOPE } from '@growi/core/dist/interfaces';
-import { describe, it, expect } from 'vitest';
+import { describe, expect, it } from 'vitest';
 
-import { parseScopes, getDisabledScopes, extractScopes } from './scope-util';
+import { extractScopes, getDisabledScopes, parseScopes } from './scope-util';
 
 describe('scope-util', () => {
-
   const mockScopes = {
     READ: {
       USER: 'read:user',
@@ -45,8 +44,12 @@ describe('scope-util', () => {
     expect(result.ALL).toBeDefined();
 
     // Check admin settings
-    expect(result.ADMIN['admin:setting']['read:admin:setting']).toBe('read:admin:setting');
-    expect(result.ADMIN['admin:setting']['write:admin:setting']).toBe('write:admin:setting');
+    expect(result.ADMIN['admin:setting']['read:admin:setting']).toBe(
+      'read:admin:setting',
+    );
+    expect(result.ADMIN['admin:setting']['write:admin:setting']).toBe(
+      'write:admin:setting',
+    );
 
     // Check ALL category
     expect(result.ALL['read:all']).toBe('read:all');
@@ -79,8 +82,12 @@ describe('scope-util', () => {
   it('should handle multiple wildcard selections', () => {
     const selectedScopes = [SCOPE.READ.ALL, SCOPE.WRITE.ALL];
     const availableScopes = [
-      SCOPE.READ.FEATURES.PAGE, SCOPE.READ.FEATURES.ATTACHMENT, SCOPE.READ.ALL,
-      SCOPE.WRITE.FEATURES.PAGE, SCOPE.WRITE.FEATURES.ATTACHMENT, SCOPE.WRITE.ALL,
+      SCOPE.READ.FEATURES.PAGE,
+      SCOPE.READ.FEATURES.ATTACHMENT,
+      SCOPE.READ.ALL,
+      SCOPE.WRITE.FEATURES.PAGE,
+      SCOPE.WRITE.FEATURES.ATTACHMENT,
+      SCOPE.WRITE.ALL,
     ];
 
     const result = getDisabledScopes(selectedScopes, availableScopes);

+ 26 - 16
apps/app/src/client/util/scope-util.ts

@@ -1,6 +1,5 @@
 import { ALL_SIGN, type Scope } from '@growi/core/dist/interfaces';
 
-
 // Data structure for the final merged scopes
 interface ScopeMap {
   [key: string]: Scope | ScopeMap;
@@ -9,17 +8,17 @@ interface ScopeMap {
 // Input object with arbitrary action keys (e.g., READ, WRITE)
 type ScopesInput = Record<string, any>;
 
-
 function parseSubScope(
-    parentKey: string,
-    subObjForActions: Record<string, any>,
-    actions: string[],
+  parentKey: string,
+  subObjForActions: Record<string, any>,
+  actions: string[],
 ): ScopeMap {
   const result: ScopeMap = {};
 
   for (const action of actions) {
     if (typeof subObjForActions[action] === 'string') {
-      result[`${action.toLowerCase()}:${parentKey.toLowerCase()}`] = subObjForActions[action];
+      result[`${action.toLowerCase()}:${parentKey.toLowerCase()}`] =
+        subObjForActions[action];
       subObjForActions[action] = undefined;
     }
   }
@@ -28,7 +27,9 @@ function parseSubScope(
   for (const action of actions) {
     const obj = subObjForActions[action];
     if (obj && typeof obj === 'object') {
-      Object.keys(obj).forEach(k => childKeys.add(k));
+      Object.keys(obj).forEach((k) => {
+        childKeys.add(k);
+      });
     }
   }
 
@@ -37,7 +38,8 @@ function parseSubScope(
       for (const action of actions) {
         const val = subObjForActions[action]?.[ck];
         if (typeof val === 'string') {
-          result[`${action.toLowerCase()}:${parentKey.toLowerCase()}:all`] = val as Scope;
+          result[`${action.toLowerCase()}:${parentKey.toLowerCase()}:all`] =
+            val as Scope;
         }
       }
       continue;
@@ -55,13 +57,21 @@ function parseSubScope(
   return result;
 }
 
-export function parseScopes({ scopes, isAdmin = false }: { scopes: ScopesInput ; isAdmin?: boolean }): ScopeMap {
+export function parseScopes({
+  scopes,
+  isAdmin = false,
+}: {
+  scopes: ScopesInput;
+  isAdmin?: boolean;
+}): ScopeMap {
   const actions = Object.keys(scopes);
   const topKeys = new Set<string>();
 
   // Collect all top-level keys (e.g., ALL, ADMIN, USER) across all actions
   for (const action of actions) {
-    Object.keys(scopes[action] || {}).forEach(k => topKeys.add(k));
+    Object.keys(scopes[action] || {}).forEach((k) => {
+      topKeys.add(k);
+    });
   }
 
   const result: ScopeMap = {};
@@ -81,8 +91,7 @@ export function parseScopes({ scopes, isAdmin = false }: { scopes: ScopesInput ;
         }
       }
       result.ALL = allObj;
-    }
-    else {
+    } else {
       const subObjForActions: Record<string, any> = {};
       for (const action of actions) {
         subObjForActions[action] = scopes[action]?.[key];
@@ -97,10 +106,12 @@ export function parseScopes({ scopes, isAdmin = false }: { scopes: ScopesInput ;
 /**
  * Determines which scopes should be disabled based on wildcard selections
  */
-export function getDisabledScopes(selectedScopes: Scope[], availableScopes: string[]): Set<Scope> {
+export function getDisabledScopes(
+  selectedScopes: Scope[],
+  availableScopes: string[],
+): Set<Scope> {
   const disabledSet = new Set<Scope>();
 
-
   // If no selected scopes, return empty set
   if (!selectedScopes || selectedScopes.length === 0) {
     return disabledSet;
@@ -133,8 +144,7 @@ export function extractScopes(obj: Record<string, any>): string[] {
   Object.values(obj).forEach((value) => {
     if (typeof value === 'string') {
       result.push(value);
-    }
-    else if (typeof value === 'object' && !Array.isArray(value)) {
+    } else if (typeof value === 'object' && !Array.isArray(value)) {
       result = result.concat(extractScopes(value));
     }
   });

+ 10 - 9
apps/app/src/client/util/t-with-opt.ts

@@ -1,15 +1,16 @@
 import { useCallback } from 'react';
-
 import { useTranslation } from 'next-i18next';
 
-export const useTWithOpt = (): (key: string, opt?: any) => string => {
-
+export const useTWithOpt = (): ((key: string, opt?: any) => string) => {
   const { t } = useTranslation();
 
-  return useCallback((key, opt) => {
-    if (typeof opt === 'object') {
-      return t(key, opt).toString();
-    }
-    return t(key);
-  }, [t]);
+  return useCallback(
+    (key, opt) => {
+      if (typeof opt === 'object') {
+        return t(key, opt).toString();
+      }
+      return t(key);
+    },
+    [t],
+  );
 };

+ 13 - 5
apps/app/src/client/util/toastr.ts

@@ -3,12 +3,14 @@ import { toast } from 'react-toastify';
 
 import { toArrayIfNot } from '~/utils/array-utils';
 
-
 export const toastErrorOption: ToastOptions = {
   autoClose: false,
   closeButton: true,
 };
-export const toastError = (err: string | Error | Error[], option: ToastOptions = toastErrorOption): void => {
+export const toastError = (
+  err: string | Error | Error[],
+  option: ToastOptions = toastErrorOption,
+): void => {
   const errs = toArrayIfNot(err);
 
   if (errs.length === 0) {
@@ -16,7 +18,7 @@ export const toastError = (err: string | Error | Error[], option: ToastOptions =
   }
 
   for (const err of errs) {
-    const message = (typeof err === 'string') ? err : err.message;
+    const message = typeof err === 'string' ? err : err.message;
     toast.error(message, option);
   }
 };
@@ -25,7 +27,10 @@ export const toastSuccessOption: ToastOptions = {
   autoClose: 2000,
   closeButton: true,
 };
-export const toastSuccess = (content: ToastContent, option: ToastOptions = toastSuccessOption): void => {
+export const toastSuccess = (
+  content: ToastContent,
+  option: ToastOptions = toastSuccessOption,
+): void => {
   toast.success(content, option);
 };
 
@@ -33,6 +38,9 @@ export const toastWarningOption: ToastOptions = {
   autoClose: 5000,
   closeButton: true,
 };
-export const toastWarning = (content: ToastContent, option: ToastOptions = toastWarningOption): void => {
+export const toastWarning = (
+  content: ToastContent,
+  option: ToastOptions = toastWarningOption,
+): void => {
   toast.warning(content, option);
 };

+ 36 - 30
apps/app/src/client/util/use-input-validator.ts

@@ -1,5 +1,4 @@
 import { useCallback } from 'react';
-
 import { useTranslation } from 'next-i18next';
 
 export const AlertType = {
@@ -7,7 +6,7 @@ export const AlertType = {
   ERROR: 'Error',
 } as const;
 
-export type AlertType = typeof AlertType[keyof typeof AlertType];
+export type AlertType = (typeof AlertType)[keyof typeof AlertType];
 
 export const ValidationTarget = {
   FOLDER: 'folder_name',
@@ -15,42 +14,49 @@ export const ValidationTarget = {
   DEFAULT: 'field',
 };
 
-export type ValidationTarget = typeof ValidationTarget[keyof typeof ValidationTarget];
+export type ValidationTarget =
+  (typeof ValidationTarget)[keyof typeof ValidationTarget];
 
 export type AlertInfo = {
-  type?: AlertType
-  message?: string,
-  target?: string
-}
-
+  type?: AlertType;
+  message?: string;
+  target?: string;
+};
 
 export type InputValidationResult = {
-  type: AlertType
-  typeLabel: string,
-  message: string,
-  target: string
-}
+  type: AlertType;
+  typeLabel: string;
+  message: string;
+  target: string;
+};
 
-export type InputValidator = (input?: string, alertType?: AlertType) => InputValidationResult | void;
+export type InputValidator = (
+  input?: string,
+  alertType?: AlertType,
+) => InputValidationResult | void;
 
-export const useInputValidator = (validationTarget: ValidationTarget = ValidationTarget.DEFAULT): InputValidator => {
+export const useInputValidator = (
+  validationTarget: ValidationTarget = ValidationTarget.DEFAULT,
+): InputValidator => {
   const { t } = useTranslation();
 
-  const inputValidator: InputValidator = useCallback((input?, alertType = AlertType.WARNING) => {
-    if ((input ?? '').trim() === '') {
-      return {
-        target: validationTarget,
-        type: alertType,
-        typeLabel: t(alertType),
-        message: t(
-          'input_validation.message.field_required',
-          { target: t(`input_validation.target.${validationTarget}`) },
-        ),
-      };
-    }
-
-    return;
-  }, [t, validationTarget]);
+  const inputValidator: InputValidator = useCallback(
+    (input?, alertType = AlertType.WARNING) => {
+      if ((input ?? '').trim() === '') {
+        return {
+          target: validationTarget,
+          type: alertType,
+          typeLabel: t(alertType),
+          message: t('input_validation.message.field_required', {
+            target: t(`input_validation.target.${validationTarget}`),
+          }),
+        };
+      }
+
+      return;
+    },
+    [t, validationTarget],
+  );
 
   return inputValidator;
 };

+ 1 - 0
apps/app/src/interfaces/file-uploader.ts

@@ -13,6 +13,7 @@ export type FileUploadType =
 // file upload type strings you can specify in the env variable
 export const FileUploadTypeForEnvVar = {
   ...FileUploadType,
+  none: 'none',
   mongo: 'mongo',
   mongodb: 'mongodb',
   gcp: 'gcp',

+ 0 - 1
apps/app/src/interfaces/sidebar-config.ts

@@ -1,4 +1,3 @@
 export interface ISidebarConfig {
   isSidebarCollapsedMode: boolean;
-  isSidebarClosedAtDockMode?: boolean;
 }

+ 4 - 1
apps/app/src/migrations/20211227060705-revision-path-to-page-id-schema-migration--fixed-7549.js → apps/app/src/migrations/20211227060705-revision-path-to-page-id-schema-migration--fixed-8998.js

@@ -13,11 +13,14 @@ import {
 import loggerFactory from '~/utils/logger';
 
 const logger = loggerFactory(
-  'growi:migrate:revision-path-to-page-id-schema-migration--fixed-7549',
+  'growi:migrate:revision-path-to-page-id-schema-migration--fixed-8998',
 );
 
 const LIMIT = 300;
 
+/**
+ * @see https://dev.growi.org/69301054963f68dfcf2b7111
+ */
 module.exports = {
   // path => pageId
   async up(db, client) {

+ 11 - 15
apps/app/src/pages/[[...path]]/page-data-props.ts

@@ -15,7 +15,7 @@ import assert from 'assert';
 import type { HydratedDocument, model } from 'mongoose';
 
 import type { CrowiRequest } from '~/interfaces/crowi-request';
-import type { PageModel } from '~/server/models/page';
+import type { PageDocument, PageModel } from '~/server/models/page';
 import type {
   IPageRedirect,
   PageRedirectModel,
@@ -193,11 +193,6 @@ export async function getPageDataForInitial(
       };
     }
 
-    // Add user to seen users
-    if (user != null) {
-      await page.seen(user);
-    }
-
     // Handle existing page with valid meta that is not IPageNotFoundInfo
     page.initLatestRevisionField(revisionId);
     const ssrMaxRevisionBodyLength = configManager.getConfig(
@@ -250,15 +245,13 @@ export async function getPageDataForInitial(
 // Page data retrieval for same-route navigation
 export async function getPageDataForSameRoute(
   context: GetServerSidePropsContext,
-): Promise<
-  GetServerSidePropsResult<
-    Pick<CommonEachProps, 'currentPathname'> &
-      Pick<
-        EachProps,
-        'currentPathname' | 'isIdenticalPathPage' | 'redirectFrom'
-      >
-  >
-> {
+): Promise<{
+  props: Pick<CommonEachProps, 'currentPathname'> &
+    Pick<EachProps, 'currentPathname' | 'isIdenticalPathPage' | 'redirectFrom'>;
+  internalProps?: {
+    pageId?: string;
+  };
+}> {
   const req: CrowiRequest = context.req as CrowiRequest;
   const { user } = req;
 
@@ -298,5 +291,8 @@ export async function getPageDataForSameRoute(
       isIdenticalPathPage: false,
       redirectFrom,
     },
+    internalProps: {
+      pageId: basicPageInfo?._id?.toString(),
+    },
   };
 }

+ 36 - 2
apps/app/src/pages/[[...path]]/server-side-props.ts

@@ -1,5 +1,7 @@
 import type { GetServerSidePropsContext, GetServerSidePropsResult } from 'next';
 
+import type { CrowiRequest } from '~/interfaces/crowi-request';
+
 import { getServerSideBasicLayoutProps } from '../basic-layout-page';
 import {
   getServerSideCommonInitialProps,
@@ -26,6 +28,30 @@ const nextjsRoutingProps = {
   },
 };
 
+/**
+ * Emit page seen event
+ * @param context - Next.js server-side context
+ * @param pageId - Page ID to mark as seen
+ */
+function emitPageSeenEvent(
+  context: GetServerSidePropsContext,
+  pageId?: string,
+): void {
+  if (pageId == null) {
+    return;
+  }
+
+  const req = context.req as CrowiRequest;
+  const { user, crowi } = req;
+
+  if (user == null) {
+    return;
+  }
+
+  const pageEvent = crowi.event('page');
+  pageEvent.emit('seen', pageId, user);
+}
+
 export async function getServerSidePropsForInitial(
   context: GetServerSidePropsContext,
 ): Promise<GetServerSidePropsResult<Stage2InitialProps>> {
@@ -75,6 +101,9 @@ export async function getServerSidePropsForInitial(
     throw new Error('Invalid merged props structure');
   }
 
+  // Add user to seen users
+  emitPageSeenEvent(context, mergedProps.pageWithMeta?.data?._id);
+
   // -- TODO: persist activity
   // await addActivity(context, getActivityAction(mergedProps));
   return mergedResult;
@@ -85,16 +114,21 @@ export async function getServerSidePropsForSameRoute(
 ): Promise<GetServerSidePropsResult<Stage2EachProps>> {
   // -- TODO: :https://redmine.weseek.co.jp/issues/174725
   // Remove getServerSideI18nProps from getServerSidePropsForSameRoute for performance improvement
-  const [i18nPropsResult, pageDataResult] = await Promise.all([
+  const [i18nPropsResult, pageDataForSameRouteResult] = await Promise.all([
     getServerSideI18nProps(context, ['translation']),
     getPageDataForSameRoute(context),
   ]);
 
+  const { props: pageDataProps, internalProps } = pageDataForSameRouteResult;
+
+  // Add user to seen users
+  emitPageSeenEvent(context, internalProps?.pageId);
+
   // -- TODO: persist activity
   // const mergedProps = await mergedResult.props;
   // await addActivity(context, getActivityAction(mergedProps));
   const mergedResult = mergeGetServerSidePropsResults(
-    pageDataResult,
+    { props: pageDataProps },
     i18nPropsResult,
   );
 

+ 0 - 3
apps/app/src/pages/basic-layout-page/get-server-side-props/sidebar-configurations.ts

@@ -17,9 +17,6 @@ export const getServerSideSidebarConfigProps: GetServerSideProps<
         isSidebarCollapsedMode: configManager.getConfig(
           'customize:isSidebarCollapsedMode',
         ),
-        isSidebarClosedAtDockMode: configManager.getConfig(
-          'customize:isSidebarClosedAtDockMode',
-        ),
       },
     },
   };

+ 5 - 0
apps/app/src/server/models/user.js

@@ -294,6 +294,7 @@ const factory = (crowi) => {
     this.isEmailPublished = configManager.getConfig(
       'customize:isEmailPublishedForNewUser',
     );
+    this.readOnly = configManager.getConfig('app:isReadOnlyForNewUser');
 
     this.save((err, userData) => {
       userEvent.emit('activated', userData);
@@ -613,6 +614,8 @@ const factory = (crowi) => {
       newUser.lang = globalLang;
     }
 
+    newUser.readOnly = configManager.getConfig('app:isReadOnlyForNewUser');
+
     try {
       const newUserData = await newUser.save();
       return {
@@ -703,6 +706,8 @@ const factory = (crowi) => {
       'customize:isEmailPublishedForNewUser',
     );
 
+    newUser.readOnly = configManager.getConfig('app:isReadOnlyForNewUser');
+
     const globalLang = configManager.getConfig('app:globalLang');
     if (globalLang != null) {
       newUser.lang = globalLang;

+ 7 - 4
apps/app/src/server/routes/apiv3/app-settings/index.ts

@@ -327,7 +327,6 @@ module.exports = (crowi) => {
       body('confidential'),
       body('globalLang').isIn(i18n.locales),
       body('isEmailPublishedForNewUser').isBoolean(),
-      body('fileUpload').isBoolean(),
     ],
     siteUrlSetting: [
       // https://regex101.com/r/5Xef8V/1
@@ -401,7 +400,9 @@ module.exports = (crowi) => {
         isEmailPublishedForNewUser: configManager.getConfig(
           'customize:isEmailPublishedForNewUser',
         ),
-        fileUpload: configManager.getConfig('app:fileUpload'),
+        isReadOnlyForNewUser: configManager.getConfig(
+          'app:isReadOnlyForNewUser',
+        ),
         useOnlyEnvVarsForIsBulkExportPagesEnabled: configManager.getConfig(
           'env:useOnlyEnvVars:app:isBulkExportPagesEnabled',
         ),
@@ -564,7 +565,7 @@ module.exports = (crowi) => {
         'app:globalLang': req.body.globalLang,
         'customize:isEmailPublishedForNewUser':
           req.body.isEmailPublishedForNewUser,
-        'app:fileUpload': req.body.fileUpload,
+        'app:isReadOnlyForNewUser': req.body.isReadOnlyForNewUser,
       };
 
       try {
@@ -576,7 +577,9 @@ module.exports = (crowi) => {
           isEmailPublishedForNewUser: configManager.getConfig(
             'customize:isEmailPublishedForNewUser',
           ),
-          fileUpload: configManager.getConfig('app:fileUpload'),
+          isReadOnlyForNewUser: configManager.getConfig(
+            'app:isReadOnlyForNewUser',
+          ),
         };
 
         const parameters = {

+ 4 - 18
apps/app/src/server/routes/apiv3/customize-setting.js

@@ -173,9 +173,6 @@ const router = express.Router();
  *          isSidebarCollapsedMode:
  *            type: boolean
  *            description: The flag whether sidebar is collapsed mode or not.
- *          isSidebarClosedAtDockMode:
- *            type: boolean
- *            description: The flag whether sidebar is closed at dock mode or not.
  *      CustomizePresentation:
  *        description: Customize Presentation
  *        type: object
@@ -206,10 +203,7 @@ module.exports = (crowi) => {
   const validator = {
     layout: [body('isContainerFluid').isBoolean()],
     theme: [body('theme').isString()],
-    sidebar: [
-      body('isSidebarCollapsedMode').isBoolean(),
-      body('isSidebarClosedAtDockMode').optional().isBoolean(),
-    ],
+    sidebar: [body('isSidebarCollapsedMode').isBoolean()],
     function: [
       body('isEnabledTimeline').isBoolean(),
       body('isEnabledAttachTitleHeader').isBoolean(),
@@ -560,13 +554,10 @@ module.exports = (crowi) => {
     adminRequired,
     async (req, res) => {
       try {
-        const isSidebarCollapsedMode = await configManager.getConfig(
+        const isSidebarCollapsedMode = configManager.getConfig(
           'customize:isSidebarCollapsedMode',
         );
-        const isSidebarClosedAtDockMode = await configManager.getConfig(
-          'customize:isSidebarClosedAtDockMode',
-        );
-        return res.apiv3({ isSidebarCollapsedMode, isSidebarClosedAtDockMode });
+        return res.apiv3({ isSidebarCollapsedMode });
       } catch (err) {
         const msg = 'Error occurred in getting sidebar';
         logger.error('Error', err);
@@ -613,19 +604,14 @@ module.exports = (crowi) => {
     async (req, res) => {
       const requestParams = {
         'customize:isSidebarCollapsedMode': req.body.isSidebarCollapsedMode,
-        'customize:isSidebarClosedAtDockMode':
-          req.body.isSidebarClosedAtDockMode,
       };
 
       try {
         await configManager.updateConfigs(requestParams);
         const customizedParams = {
-          isSidebarCollapsedMode: await configManager.getConfig(
+          isSidebarCollapsedMode: configManager.getConfig(
             'customize:isSidebarCollapsedMode',
           ),
-          isSidebarClosedAtDockMode: await configManager.getConfig(
-            'customize:isSidebarClosedAtDockMode',
-          ),
         };
 
         activityEvent.emit('update', res.locals.activity._id, {

+ 1 - 1
apps/app/src/server/routes/apiv3/forgot-password.js

@@ -148,7 +148,7 @@ module.exports = (crowi) => {
       const appUrl = growiInfoService.getSiteUrl();
 
       try {
-        const user = await User.findOne({ email });
+        const user = await User.findOne({ email: { $eq: email } });
 
         // when the user is not found or active
         if (user == null || user.status !== 2) {

+ 28 - 3
apps/app/src/server/routes/apiv3/g2g-transfer.ts

@@ -1,11 +1,11 @@
+import { createReadStream } from 'node:fs';
 import { SCOPE } from '@growi/core/dist/interfaces';
 import { ErrorV3 } from '@growi/core/dist/models';
 import type { NextFunction, Request, Router } from 'express';
 import express from 'express';
 import { body } from 'express-validator';
-import { createReadStream } from 'fs';
 import multer from 'multer';
-import path from 'path';
+import path from 'pathe';
 
 import type { GrowiArchiveImportOption } from '~/models/admin/growi-archive-import-option';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
@@ -22,6 +22,7 @@ import { TransferKey } from '~/utils/vo/transfer-key';
 import type Crowi from '../../crowi';
 import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
 import { Attachment } from '../../models/attachment';
+import { isPathWithinBase } from '../../util/safe-path-utils';
 import type { ApiV3Response } from './interfaces/apiv3-response';
 
 interface AuthorizedRequest extends Request {
@@ -511,7 +512,31 @@ module.exports = (crowi: Crowi): Router => {
         );
       }
 
-      const fileStream = createReadStream(file.path, {
+      // Validate file path to prevent path traversal attack
+      const importService = getImportService();
+      if (importService == null) {
+        return res.apiv3Err(
+          new ErrorV3(
+            'Import service is not available.',
+            'service_unavailable',
+          ),
+          500,
+        );
+      }
+      // Normalize the path to prevent path traversal attacks
+      const resolvedFilePath = path.resolve(file.path);
+      if (!isPathWithinBase(resolvedFilePath, importService.baseDir)) {
+        logger.error('Path traversal attack detected', {
+          filePath: resolvedFilePath,
+          baseDir: importService.baseDir,
+        });
+        return res.apiv3Err(
+          new ErrorV3('Invalid file path.', 'invalid_path'),
+          400,
+        );
+      }
+
+      const fileStream = createReadStream(resolvedFilePath, {
         flags: 'r',
         mode: 0o666,
         autoClose: true,

+ 1 - 1
apps/app/src/server/routes/apiv3/page/index.ts

@@ -1041,7 +1041,7 @@ module.exports = (crowi: Crowi) => {
         return res.apiv3Err(err, 500);
       }
 
-      // Normalize the latest revision which was borken by the migration script '20211227060705-revision-path-to-page-id-schema-migration--fixed-7549.js'
+      // Normalize the latest revision which was borken by the migration script '20211227060705-revision-path-to-page-id-schema-migration--fixed-7549.js' provided by v6.1.0 - v7.0.15
       try {
         await normalizeLatestRevisionIfBroken(pageId);
       } catch (err) {

+ 1 - 1
apps/app/src/server/routes/apiv3/page/update-page.ts

@@ -238,7 +238,7 @@ export const updatePageHandlersFactory: UpdatePageHandlersFactory = (crowi) => {
       }
 
       if (currentPage != null) {
-        // Normalize the latest revision which was borken by the migration script '20211227060705-revision-path-to-page-id-schema-migration--fixed-7549.js'
+        // Normalize the latest revision which was borken by the migration script '20211227060705-revision-path-to-page-id-schema-migration--fixed-7549.js' provided by v6.1.0 - v7.0.15
         try {
           await normalizeLatestRevisionIfBroken(pageId);
         } catch (err) {

+ 7 - 26
apps/app/src/server/routes/apiv3/revisions.js

@@ -2,11 +2,13 @@ import { SCOPE } from '@growi/core/dist/interfaces';
 import { ErrorV3 } from '@growi/core/dist/models';
 import { serializeUserSecurely } from '@growi/core/dist/models/serializers';
 import express from 'express';
-import { connection } from 'mongoose';
 
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { Revision } from '~/server/models/revision';
-import { normalizeLatestRevisionIfBroken } from '~/server/service/revision/normalize-latest-revision-if-broken';
+import {
+  getAppliedAtForRevisionFilter,
+  normalizeLatestRevisionIfBroken,
+} from '~/server/service/revision/normalize-latest-revision-if-broken';
 import loggerFactory from '~/utils/logger';
 
 import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
@@ -17,9 +19,6 @@ const { query, param } = require('express-validator');
 
 const router = express.Router();
 
-const MIGRATION_FILE_NAME =
-  '20211227060705-revision-path-to-page-id-schema-migration--fixed-7549';
-
 /**
  * @swagger
  * components:
@@ -85,24 +84,6 @@ module.exports = (crowi) => {
     ],
   };
 
-  let cachedAppliedAt = null;
-
-  const getAppliedAtOfTheMigrationFile = async () => {
-    if (cachedAppliedAt != null) {
-      return cachedAppliedAt;
-    }
-
-    const migrationCollection = connection.collection('migrations');
-    const migration = await migrationCollection.findOne({
-      fileName: { $regex: `^${MIGRATION_FILE_NAME}` },
-    });
-    const appliedAt = migration.appliedAt;
-
-    cachedAppliedAt = appliedAt;
-
-    return appliedAt;
-  };
-
   /**
    * @swagger
    *
@@ -176,7 +157,7 @@ module.exports = (crowi) => {
         );
       }
 
-      // Normalize the latest revision which was borken by the migration script '20211227060705-revision-path-to-page-id-schema-migration--fixed-7549.js'
+      // Normalize the latest revision which was borken by the migration script '20211227060705-revision-path-to-page-id-schema-migration--fixed-7549.js' provided by v6.1.0 - v7.0.15
       try {
         await normalizeLatestRevisionIfBroken(pageId);
       } catch (err) {
@@ -186,7 +167,7 @@ module.exports = (crowi) => {
       try {
         const page = await Page.findOne({ _id: pageId });
 
-        const appliedAt = await getAppliedAtOfTheMigrationFile();
+        const appliedAt = await getAppliedAtForRevisionFilter();
 
         const queryOpts = {
           offset,
@@ -202,7 +183,7 @@ module.exports = (crowi) => {
 
         const queryCondition = {
           pageId: page._id,
-          createdAt: { $gt: appliedAt },
+          ...(appliedAt != null && { createdAt: { $gt: appliedAt } }),
         };
 
         // https://redmine.weseek.co.jp/issues/151652

+ 1 - 1
apps/app/src/server/routes/attachment/api.js

@@ -318,7 +318,7 @@ export const routesFactory = (crowi) => {
   api.remove = async (req, res) => {
     const id = req.body.attachment_id;
 
-    const attachment = await Attachment.findById(id);
+    const attachment = await Attachment.findOne({ _id: { $eq: id } });
 
     if (attachment == null) {
       return res.json(ApiResponse.error('attachment not found'));

+ 4 - 4
apps/app/src/server/routes/comment.js

@@ -279,7 +279,7 @@ module.exports = (crowi, app) => {
     }
     // update page
     const page = await Page.findOneAndUpdate(
-      { _id: pageId },
+      { _id: { $eq: pageId } },
       {
         lastUpdateUser: req.user,
         updatedAt: new Date(),
@@ -422,7 +422,7 @@ module.exports = (crowi, app) => {
 
     let updatedComment;
     try {
-      const comment = await Comment.findById(commentId).exec();
+      const comment = await Comment.findOne({ _id: { $eq: commentId } }).exec();
 
       if (comment == null) {
         throw new Error('This comment does not exist.');
@@ -442,7 +442,7 @@ module.exports = (crowi, app) => {
       }
 
       updatedComment = await Comment.findOneAndUpdate(
-        { _id: commentId },
+        { _id: { $eq: commentId } },
         { $set: { comment: commentStr, revision } },
       );
       commentEvent.emit(CommentEvent.UPDATE, updatedComment);
@@ -506,7 +506,7 @@ module.exports = (crowi, app) => {
 
     try {
       /** @type {import('mongoose').HydratedDocument<import('~/interfaces/comment').IComment>} */
-      const comment = await Comment.findById(commentId).exec();
+      const comment = await Comment.findOne({ _id: { $eq: commentId } }).exec();
 
       if (comment == null) {
         throw new Error('This comment does not exist.');

+ 4 - 2
apps/app/src/server/routes/tag.js

@@ -128,7 +128,7 @@ module.exports = (crowi, app) => {
     const result = {};
     try {
       // TODO GC-1921 consider permission
-      const page = await Page.findById(pageId);
+      const page = await Page.findOne({ _id: { $eq: pageId } });
       const user = await User.findById(userId);
 
       if (!(await Page.isAccessiblePageByViewer(page._id, user))) {
@@ -137,7 +137,9 @@ module.exports = (crowi, app) => {
         );
       }
 
-      const previousRevision = await Revision.findById(revisionId);
+      const previousRevision = await Revision.findOne({
+        _id: { $eq: revisionId },
+      });
       result.savedPage = await crowi.pageService.updatePage(
         page,
         previousRevision.body,

+ 5 - 13
apps/app/src/server/service/config-manager/config-definition.ts

@@ -45,7 +45,6 @@ export const CONFIG_KEYS = [
   'app:title',
   'app:timezone',
   'app:globalLang',
-  'app:fileUpload',
   'app:fileUploadType',
   'app:plantumlUri',
   'app:drawioUri',
@@ -56,7 +55,6 @@ export const CONFIG_KEYS = [
   'app:maxFileSize',
   'app:fileUploadTimeout',
   'app:fileUploadTotalLimit',
-  'app:fileUploadDisabled',
   'app:elasticsearchVersion',
   'app:elasticsearchUri',
   'app:elasticsearchRequestTimeout',
@@ -79,6 +77,7 @@ export const CONFIG_KEYS = [
   'app:wipPageExpirationSeconds',
   'app:openaiThreadDeletionCronMaxMinutesUntilRequest',
   'app:openaiVectorStoreFileDeletionCronMaxMinutesUntilRequest',
+  'app:isReadOnlyForNewUser',
 
   // Security Settings
   'security:wikiMode',
@@ -225,7 +224,6 @@ export const CONFIG_KEYS = [
   'customize:showPageSideAuthors',
   'customize:isEnabledMarp',
   'customize:isSidebarCollapsedMode',
-  'customize:isSidebarClosedAtDockMode',
 
   // Markdown Settings
   'markdown:xss:tagWhitelist',
@@ -388,12 +386,6 @@ export const CONFIG_DEFINITIONS = {
   'app:globalLang': defineConfig<string>({
     defaultValue: 'en_US',
   }),
-  'app:fileUpload': defineConfig<boolean>({
-    defaultValue: false,
-  }),
-  'app:fileUploadDisabled': defineConfig<boolean>({
-    defaultValue: false,
-  }),
   'app:fileUploadType': defineConfig<AttachmentMethodType>({
     envVarName: 'FILE_UPLOAD',
     defaultValue: AttachmentMethodType.aws,
@@ -524,6 +516,10 @@ export const CONFIG_DEFINITIONS = {
         'OPENAI_VECTOR_STORE_FILE_DELETION_CRON_MAX_MINUTES_UNTIL_REQUEST',
       defaultValue: 30,
     }),
+  'app:isReadOnlyForNewUser': defineConfig<boolean>({
+    envVarName: 'DEFAULT_USER_READONLY',
+    defaultValue: false,
+  }),
 
   // Security Settings
   'security:wikiMode': defineConfig<string | undefined>({
@@ -1014,10 +1010,6 @@ export const CONFIG_DEFINITIONS = {
   'customize:isSidebarCollapsedMode': defineConfig<boolean>({
     defaultValue: false,
   }),
-  'customize:isSidebarClosedAtDockMode': defineConfig<boolean>({
-    defaultValue: false,
-  }),
-
   // Markdown Settings
   'markdown:xss:tagWhitelist': defineConfig<string[]>({
     defaultValue: [],

+ 0 - 3
apps/app/src/server/service/config-manager/config-manager.spec.ts

@@ -303,13 +303,11 @@ describe('ConfigManager test', () => {
       const dbConfig: Partial<TestConfigData> = {
         'app:title': { value: undefined }, // db value is explicitly undefined
         'app:siteUrl': { value: undefined }, // another undefined value
-        'app:fileUpload': undefined, // db config entry itself is undefined
         'app:fileUploadType': { value: 'gridfs' }, // db has valid value
       };
       const envConfig: Partial<TestConfigData> = {
         'app:title': { value: 'GROWI' },
         'app:siteUrl': { value: 'https://example.com' },
-        'app:fileUpload': { value: true },
         'app:fileUploadType': { value: 'aws' },
         // Add control flags for env vars
         'env:useOnlyEnvVars:app:siteUrl': { value: false },
@@ -322,7 +320,6 @@ describe('ConfigManager test', () => {
       expect(configManager.getConfig('app:siteUrl')).toBe(
         'https://example.com',
       ); // Should fallback to env when db value is undefined
-      expect(configManager.getConfig('app:fileUpload')).toBe(true); // Should fallback to env when db config is undefined
       expect(configManager.getConfig('app:fileUploadType')).toBe('gridfs'); // Should use db value when valid
     });
   });

+ 5 - 9
apps/app/src/server/service/file-uploader/file-uploader.ts

@@ -79,10 +79,10 @@ export abstract class AbstractFileUploader implements FileUploader {
   }
 
   getIsUploadable() {
-    return (
-      !configManager.getConfig('app:fileUploadDisabled') &&
-      this.isValidUploadSettings()
-    );
+    const isFileUploadDisabled =
+      configManager.getConfig('app:fileUploadType') === 'none';
+
+    return !isFileUploadDisabled && this.isValidUploadSettings();
   }
 
   /**
@@ -117,11 +117,7 @@ export abstract class AbstractFileUploader implements FileUploader {
   abstract isValidUploadSettings(): boolean;
 
   getFileUploadEnabled() {
-    if (!this.getIsUploadable()) {
-      return false;
-    }
-
-    return !!configManager.getConfig('app:fileUpload');
+    return this.getIsUploadable();
   }
 
   abstract listFiles();

+ 129 - 0
apps/app/src/server/service/file-uploader/none.ts

@@ -0,0 +1,129 @@
+import type { Response } from 'express';
+import type { Readable } from 'stream';
+
+import type { ICheckLimitResult } from '~/interfaces/attachment';
+import type Crowi from '~/server/crowi';
+import type { RespondOptions } from '~/server/interfaces/attachment';
+import type { IAttachmentDocument } from '~/server/models/attachment';
+
+import {
+  AbstractFileUploader,
+  type SaveFileParam,
+  type TemporaryUrl,
+} from './file-uploader';
+
+/**
+ * NoneFileUploader is a placeholder uploader when file upload is disabled.
+ * All write operations are disabled, but read operations return empty results.
+ */
+class NoneFileUploader extends AbstractFileUploader {
+  /**
+   * @inheritdoc
+   */
+  override getIsUploadable(): boolean {
+    return false;
+  }
+
+  /**
+   * @inheritdoc
+   */
+  override isValidUploadSettings(): boolean {
+    return false;
+  }
+
+  /**
+   * @inheritdoc
+   */
+  override async isWritable(): Promise<boolean> {
+    return false;
+  }
+
+  /**
+   * @inheritdoc
+   */
+  override getIsReadable(): boolean {
+    return false;
+  }
+
+  /**
+   * @inheritdoc
+   */
+  override listFiles(): [] {
+    return [];
+  }
+
+  /**
+   * @inheritdoc
+   */
+  override saveFile(_param: SaveFileParam): Promise<void> {
+    throw new Error('File upload is disabled');
+  }
+
+  /**
+   * @inheritdoc
+   */
+  override deleteFile(_attachment: IAttachmentDocument): void {
+    throw new Error('File upload is disabled');
+  }
+
+  /**
+   * @inheritdoc
+   */
+  override deleteFiles(_attachments: IAttachmentDocument[]): void {
+    throw new Error('File upload is disabled');
+  }
+
+  /**
+   * @inheritdoc
+   */
+  override checkLimit(_uploadFileSize: number): Promise<ICheckLimitResult> {
+    return Promise.resolve({
+      isUploadable: false,
+      errorMessage: 'File upload is disabled',
+    });
+  }
+
+  /**
+   * @inheritdoc
+   */
+  override async uploadAttachment(
+    _readable: Readable,
+    _attachment: IAttachmentDocument,
+  ): Promise<void> {
+    throw new Error('File upload is disabled');
+  }
+
+  /**
+   * @inheritdoc
+   */
+  override respond(
+    res: Response,
+    _attachment: IAttachmentDocument,
+    _opts?: RespondOptions,
+  ): void {
+    res.status(404).send('File upload is disabled');
+  }
+
+  /**
+   * @inheritdoc
+   */
+  override findDeliveryFile(
+    _attachment: IAttachmentDocument,
+  ): Promise<NodeJS.ReadableStream> {
+    throw new Error('File upload is disabled');
+  }
+
+  /**
+   * @inheritdoc
+   */
+  override generateTemporaryUrl(
+    _attachment: IAttachmentDocument,
+    _opts?: RespondOptions,
+  ): Promise<TemporaryUrl> {
+    throw new Error('File upload is disabled');
+  }
+}
+
+module.exports = (crowi: Crowi) => {
+  return new NoneFileUploader(crowi);
+};

+ 2 - 3
apps/app/src/server/service/g2g-transfer.ts

@@ -618,9 +618,8 @@ export class G2GTransferReceiverService implements Receiver {
     const { fileUploadService } = this.crowi;
     const version = getGrowiVersion();
     const userUpperLimit = configManager.getConfig('security:userUpperLimit');
-    const fileUploadDisabled = configManager.getConfig(
-      'app:fileUploadDisabled',
-    );
+    const fileUploadDisabled =
+      configManager.getConfig('app:fileUploadType') === 'none';
     const fileUploadTotalLimit = fileUploadService.getFileUploadTotalLimit();
     const isWritable = await fileUploadService.isWritable();
 

+ 123 - 0
apps/app/src/server/service/growi-bridge/index.spec.ts

@@ -0,0 +1,123 @@
+import fs from 'node:fs';
+import path from 'pathe';
+import { mock } from 'vitest-mock-extended';
+
+import type Crowi from '~/server/crowi';
+
+import { GrowiBridgeService } from './index';
+
+vi.mock('fs');
+
+describe('GrowiBridgeService', () => {
+  let growiBridgeService: GrowiBridgeService;
+
+  beforeEach(() => {
+    vi.clearAllMocks();
+    const crowiMock = mock<Crowi>();
+    growiBridgeService = new GrowiBridgeService(crowiMock);
+  });
+
+  describe('getFile', () => {
+    const baseDir = '/tmp/growi-export';
+
+    beforeEach(() => {
+      // Mock fs.accessSync to not throw (file exists)
+      vi.mocked(fs.accessSync).mockImplementation(() => undefined);
+    });
+
+    describe('valid file paths', () => {
+      test('should return resolved path for a simple filename', () => {
+        const fileName = 'test.json';
+        const result = growiBridgeService.getFile(fileName, baseDir);
+
+        expect(result).toBe(path.resolve(baseDir, fileName));
+        expect(fs.accessSync).toHaveBeenCalledWith(
+          path.resolve(baseDir, fileName),
+        );
+      });
+
+      test('should return resolved path for a filename in subdirectory', () => {
+        const fileName = 'subdir/test.json';
+        const result = growiBridgeService.getFile(fileName, baseDir);
+
+        expect(result).toBe(path.resolve(baseDir, fileName));
+      });
+
+      test('should handle baseDir with trailing slash', () => {
+        const fileName = 'test.json';
+        const baseDirWithSlash = '/tmp/growi-export/';
+        const result = growiBridgeService.getFile(fileName, baseDirWithSlash);
+
+        expect(result).toBe(path.resolve(baseDirWithSlash, fileName));
+      });
+    });
+
+    describe('path traversal attack prevention', () => {
+      test('should throw error for path traversal with ../', () => {
+        const fileName = '../etc/passwd';
+
+        expect(() => {
+          growiBridgeService.getFile(fileName, baseDir);
+        }).toThrow('Invalid file path: path traversal detected');
+      });
+
+      test('should throw error for path traversal with multiple ../', () => {
+        const fileName = '../../etc/passwd';
+
+        expect(() => {
+          growiBridgeService.getFile(fileName, baseDir);
+        }).toThrow('Invalid file path: path traversal detected');
+      });
+
+      test('should throw error for path traversal in middle of path', () => {
+        const fileName = 'subdir/../../../etc/passwd';
+
+        expect(() => {
+          growiBridgeService.getFile(fileName, baseDir);
+        }).toThrow('Invalid file path: path traversal detected');
+      });
+
+      test('should throw error for absolute path outside baseDir', () => {
+        const fileName = '/etc/passwd';
+
+        expect(() => {
+          growiBridgeService.getFile(fileName, baseDir);
+        }).toThrow('Invalid file path: path traversal detected');
+      });
+
+      test('should throw error for path traversal at the end of path', () => {
+        // e.g., trying to access sibling directory
+        const fileName = 'subdir/../../other-dir/file.json';
+
+        expect(() => {
+          growiBridgeService.getFile(fileName, baseDir);
+        }).toThrow('Invalid file path: path traversal detected');
+      });
+    });
+
+    describe('file access check', () => {
+      test('should throw error if file does not exist', () => {
+        const fileName = 'nonexistent.json';
+        vi.mocked(fs.accessSync).mockImplementation(() => {
+          throw new Error('ENOENT: no such file or directory');
+        });
+
+        expect(() => {
+          growiBridgeService.getFile(fileName, baseDir);
+        }).toThrow('ENOENT: no such file or directory');
+      });
+    });
+  });
+
+  describe('getEncoding', () => {
+    test('should return utf-8', () => {
+      expect(growiBridgeService.getEncoding()).toBe('utf-8');
+    });
+  });
+
+  describe('getMetaFileName', () => {
+    test('should return meta.json', () => {
+      expect(growiBridgeService.getMetaFileName()).toBe('meta.json');
+    });
+  });
+});

+ 9 - 5
apps/app/src/server/service/growi-bridge/index.ts

@@ -1,12 +1,13 @@
-import fs from 'fs';
-import path from 'path';
-import { pipeline } from 'stream';
-import { finished } from 'stream/promises';
+import fs from 'node:fs';
+import { pipeline } from 'node:stream';
+import { finished } from 'node:stream/promises';
+import path from 'pathe';
 import unzipStream, { type Entry } from 'unzip-stream';
 
 import type Crowi from '~/server/crowi';
 import loggerFactory from '~/utils/logger';
 
+import { assertFileNameSafeForBaseDir } from '../../util/safe-path-utils';
 import type { ZipFileStat } from '../interfaces/export';
 import { tapStreamDataByPromise } from './unzip-stream-utils';
 
@@ -55,7 +56,10 @@ export class GrowiBridgeService {
    * @memberOf GrowiBridgeService
    */
   getFile(fileName: string, baseDir: string): string {
-    const jsonFile = path.join(baseDir, fileName);
+    // Prevent path traversal attack
+    assertFileNameSafeForBaseDir(fileName, baseDir);
+
+    const jsonFile = path.resolve(baseDir, fileName);
 
     // throws err if the file does not exist
     fs.accessSync(jsonFile);

+ 0 - 1
apps/app/src/server/service/installer.ts

@@ -127,7 +127,6 @@ export class InstallerService {
     await configManager.updateConfigs(
       {
         'app:installed': true,
-        'app:fileUpload': true,
         'app:isV5Compatible': true,
         'app:globalLang': globalLang,
       },

+ 36 - 0
apps/app/src/server/service/page/events/seen.ts

@@ -0,0 +1,36 @@
+import type { IUserHasId } from '@growi/core/dist/interfaces';
+import type { HydratedDocument } from 'mongoose';
+import mongoose from 'mongoose';
+
+import type { PageDocument, PageModel } from '~/server/models/page';
+import loggerFactory from '~/utils/logger';
+
+const logger = loggerFactory('growi:events:page:seen');
+
+export const onSeen = async (
+  pageId: string,
+  user: IUserHasId,
+): Promise<void> => {
+  if (pageId == null || user == null) {
+    logger.warn('onSeen: pageId or user is null');
+    return;
+  }
+
+  try {
+    const Page = mongoose.model<HydratedDocument<PageDocument>, PageModel>(
+      'Page',
+    );
+
+    const page = await Page.findById(pageId);
+
+    if (page == null) {
+      logger.warn('onSeen: page not found', { pageId });
+      return;
+    }
+
+    await page.seen(user);
+    logger.debug('onSeen: successfully marked page as seen', { pageId });
+  } catch (err) {
+    logger.error('onSeen: failed to mark page as seen', err);
+  }
+};

+ 24 - 8
apps/app/src/server/service/page/index.ts

@@ -89,6 +89,7 @@ import type { IPageGrantService } from '../page-grant';
 import { preNotifyService } from '../pre-notify';
 import { getYjsService } from '../yjs';
 import { BULK_REINDEX_SIZE, LIMIT_FOR_MULTIPLE_PAGE_OP } from './consts';
+import { onSeen } from './events/seen';
 import type { IPageService } from './page-service';
 import { shouldUseV4Process } from './should-use-v4-process';
 
@@ -230,6 +231,9 @@ class PageService implements IPageService {
     // createMany
     this.pageEvent.on('createMany', this.pageEvent.onCreateMany);
     this.pageEvent.on('addSeenUsers', this.pageEvent.onAddSeenUsers);
+
+    // seen - mark page as seen by user
+    this.pageEvent.on('seen', onSeen);
   }
 
   getEventEmitter(): EventEmitter {
@@ -613,14 +617,26 @@ class PageService implements IPageService {
     const userRelatedGroups =
       await this.pageGrantService.getUserRelatedGroups(user);
 
-    const isDeletable = this.canDelete(page, creatorId, user, false);
-    const isAbleToDeleteCompletely = this.canDeleteCompletely(
-      page,
-      creatorId,
-      user,
-      false,
-      userRelatedGroups,
-    ); // use normal delete config
+    const canDeleteUserHomepage = await (async () => {
+      // Not a user homepage
+      if (!pagePathUtils.isUsersHomepage(page.path)) {
+        return true;
+      }
+
+      if (!this.canDeleteUserHomepageByConfig()) {
+        return false;
+      }
+
+      return await this.isUsersHomepageOwnerAbsent(page.path);
+    })();
+
+    const isDeletable =
+      canDeleteUserHomepage && this.canDelete(page, creatorId, user, false);
+
+    const isAbleToDeleteCompletely =
+      canDeleteUserHomepage &&
+      this.canDeleteCompletely(page, creatorId, user, false, userRelatedGroups); // use normal delete config
+
     const isBookmarked: boolean = isGuestUser
       ? false
       : (await Bookmark.findByPageIdAndUserId(pageId, user._id)) != null;

+ 117 - 2
apps/app/src/server/service/revision/normalize-latest-revision-if-broken.integ.ts

@@ -1,16 +1,37 @@
 import { getIdStringForRef } from '@growi/core';
 import type { HydratedDocument } from 'mongoose';
-import mongoose, { Types } from 'mongoose';
+import mongoose, { connection, Types } from 'mongoose';
 
 import type { PageDocument, PageModel } from '~/server/models/page';
 import PageModelFactory from '~/server/models/page';
 import { Revision } from '~/server/models/revision';
 
-import { normalizeLatestRevisionIfBroken } from './normalize-latest-revision-if-broken';
+import {
+  __resetCacheForTesting,
+  getAppliedAtForRevisionFilter,
+  normalizeLatestRevisionIfBroken,
+} from './normalize-latest-revision-if-broken';
+
+const OLD_MIGRATION_FILE_NAME =
+  '20211227060705-revision-path-to-page-id-schema-migration--fixed-7549';
+const NEW_MIGRATION_FILE_NAME =
+  '20211227060705-revision-path-to-page-id-schema-migration--fixed-8998';
 
 describe('normalizeLatestRevisionIfBroken', () => {
   beforeAll(async () => {
     await PageModelFactory(null);
+    // Insert migration record to simulate affected instance
+    const migrationCollection = connection.collection('migrations');
+    await migrationCollection.insertOne({
+      fileName: OLD_MIGRATION_FILE_NAME,
+      appliedAt: new Date('2024-01-01'),
+    });
+  });
+
+  afterAll(async () => {
+    // Clean up migration record
+    const migrationCollection = connection.collection('migrations');
+    await migrationCollection.deleteOne({ fileName: OLD_MIGRATION_FILE_NAME });
   });
 
   test('should update the latest revision', async () => {
@@ -131,3 +152,97 @@ describe('normalizeLatestRevisionIfBroken', () => {
     });
   });
 });
+
+describe('getAppliedAtForRevisionFilter', () => {
+  const migrationCollection = () => connection.collection('migrations');
+
+  beforeEach(() => {
+    __resetCacheForTesting();
+  });
+
+  afterEach(async () => {
+    // Clean up all migration records
+    await migrationCollection().deleteMany({
+      fileName: { $in: [OLD_MIGRATION_FILE_NAME, NEW_MIGRATION_FILE_NAME] },
+    });
+  });
+
+  test('should return null when only new migration exists (fresh installation)', async () => {
+    // Arrange
+    await migrationCollection().insertOne({
+      fileName: NEW_MIGRATION_FILE_NAME,
+      appliedAt: new Date('2024-06-01'),
+    });
+
+    // Act
+    const result = await getAppliedAtForRevisionFilter();
+
+    // Assert
+    expect(result).toBeNull();
+  });
+
+  test('should return appliedAt when old migration exists (affected instance)', async () => {
+    // Arrange
+    const appliedAt = new Date('2024-01-01');
+    await migrationCollection().insertOne({
+      fileName: OLD_MIGRATION_FILE_NAME,
+      appliedAt,
+    });
+
+    // Act
+    const result = await getAppliedAtForRevisionFilter();
+
+    // Assert
+    expect(result).toEqual(appliedAt);
+  });
+
+  test('should return appliedAt when both migrations exist (upgraded instance)', async () => {
+    // Arrange
+    const oldAppliedAt = new Date('2024-01-01');
+    await migrationCollection().insertOne({
+      fileName: OLD_MIGRATION_FILE_NAME,
+      appliedAt: oldAppliedAt,
+    });
+    await migrationCollection().insertOne({
+      fileName: NEW_MIGRATION_FILE_NAME,
+      appliedAt: new Date('2024-06-01'),
+    });
+
+    // Act
+    const result = await getAppliedAtForRevisionFilter();
+
+    // Assert
+    expect(result).toEqual(oldAppliedAt);
+  });
+
+  test('should return null when neither migration exists', async () => {
+    // Arrange - no migrations inserted
+
+    // Act
+    const result = await getAppliedAtForRevisionFilter();
+
+    // Assert
+    expect(result).toBeNull();
+  });
+
+  test('should cache the result', async () => {
+    // Arrange
+    const appliedAt = new Date('2024-01-01');
+    await migrationCollection().insertOne({
+      fileName: OLD_MIGRATION_FILE_NAME,
+      appliedAt,
+    });
+
+    // Act - call twice
+    const result1 = await getAppliedAtForRevisionFilter();
+    // Remove the migration record
+    await migrationCollection().deleteOne({
+      fileName: OLD_MIGRATION_FILE_NAME,
+    });
+    const result2 = await getAppliedAtForRevisionFilter();
+
+    // Assert - both should return the same cached value
+    expect(result1).toEqual(appliedAt);
+    expect(result2).toEqual(appliedAt);
+  });
+});

+ 87 - 1
apps/app/src/server/service/revision/normalize-latest-revision-if-broken.ts

@@ -9,14 +9,100 @@ const logger = loggerFactory(
   'growi:service:revision:normalize-latest-revision',
 );
 
+const OLD_MIGRATION_FILE_NAME =
+  '20211227060705-revision-path-to-page-id-schema-migration--fixed-7549';
+const NEW_MIGRATION_FILE_NAME =
+  '20211227060705-revision-path-to-page-id-schema-migration--fixed-8998';
+
+let cachedAppliedAt: Date | null | undefined;
+let cachedIsAffected: boolean | undefined;
+
+/**
+ * Reset the cache for testing purposes.
+ * @internal This function is only for testing.
+ */
+export const __resetCacheForTesting = (): void => {
+  cachedAppliedAt = undefined;
+  cachedIsAffected = undefined;
+};
+
 /**
- * Normalize the latest revision which was borken by the migration script '20211227060705-revision-path-to-page-id-schema-migration--fixed-7549.js'
+ * Check if this instance went through the problematic migration (v6.1.0 - v7.0.15).
+ *
+ * Condition logic:
+ * - If old migration (fixed-7549) does NOT exist AND new migration (fixed-8998) exists
+ *   → Return false (fresh installation, not affected)
+ * - If old migration (fixed-7549) exists
+ *   → Return true (went through problematic version, affected)
+ * - If neither migration exists
+ *   → Log warning and return false (not affected)
+ *
+ * @returns true if affected by the problematic migration, false otherwise
+ *
+ * @see https://dev.growi.org/69301054963f68dfcf2b7111
+ */
+const isAffectedByProblematicMigration = async (): Promise<boolean> => {
+  if (cachedIsAffected !== undefined) {
+    return cachedIsAffected;
+  }
+
+  const migrationCollection = mongoose.connection.collection('migrations');
+
+  const oldMigration = await migrationCollection.findOne({
+    fileName: { $regex: `^${OLD_MIGRATION_FILE_NAME}` },
+  });
+  const newMigration = await migrationCollection.findOne({
+    fileName: { $regex: `^${NEW_MIGRATION_FILE_NAME}` },
+  });
+
+  // Case: fresh installation (new migration only) → not affected
+  if (oldMigration == null && newMigration != null) {
+    cachedIsAffected = false;
+    cachedAppliedAt = null;
+    return false;
+  }
+
+  // Case: went through problematic version (old migration exists) → affected
+  if (oldMigration != null) {
+    cachedIsAffected = true;
+    cachedAppliedAt = oldMigration.appliedAt;
+    return true;
+  }
+
+  // Case: neither migration exists (unexpected, but handle gracefully) → not affected
+  logger.warn(
+    'Neither old nor new migration file found in migrations collection. This may indicate an incomplete migration state.',
+  );
+  cachedIsAffected = false;
+  cachedAppliedAt = null;
+  return false;
+};
+
+/**
+ * Get the appliedAt date for filtering revisions created before the problematic migration.
+ *
+ * @returns appliedAt date to filter revisions, or null if no filter is needed
+ *
+ * @see https://dev.growi.org/69301054963f68dfcf2b7111
+ */
+export const getAppliedAtForRevisionFilter = async (): Promise<Date | null> => {
+  await isAffectedByProblematicMigration();
+  return cachedAppliedAt ?? null;
+};
+
+/**
+ * Normalize the latest revision which was borken by the migration script '20211227060705-revision-path-to-page-id-schema-migration--fixed-7549.js' provided by v6.1.0 - v7.0.15
  *
  * @ref https://github.com/growilabs/growi/pull/8998
  */
 export const normalizeLatestRevisionIfBroken = async (
   pageId: string | Types.ObjectId,
 ): Promise<void> => {
+  // Skip if not affected by the problematic migration
+  if (!(await isAffectedByProblematicMigration())) {
+    return;
+  }
+
   if (await Revision.exists({ pageId: { $eq: pageId } })) {
     return;
   }

+ 1 - 1
apps/app/src/server/service/yjs/sync-ydoc.ts

@@ -27,7 +27,7 @@ export const syncYDoc = async (
 ): Promise<void> => {
   const pageId = doc.name;
 
-  // Normalize the latest revision which was borken by the migration script '20211227060705-revision-path-to-page-id-schema-migration--fixed-7549.js'
+  // Normalize the latest revision which was borken by the migration script '20211227060705-revision-path-to-page-id-schema-migration--fixed-7549.js' provided by v6.1.0 - v7.0.15
   await normalizeLatestRevisionIfBroken(pageId);
 
   const revision = await Revision.findOne(

+ 1 - 1
apps/app/src/server/service/yjs/yjs.ts

@@ -143,7 +143,7 @@ class YjsService implements IYjsService {
       );
     };
 
-    // Normalize the latest revision which was borken by the migration script '20211227060705-revision-path-to-page-id-schema-migration--fixed-7549.js'
+    // Normalize the latest revision which was borken by the migration script '20211227060705-revision-path-to-page-id-schema-migration--fixed-7549.js' provided by v6.1.0 - v7.0.15
     await normalizeLatestRevisionIfBroken(pageId);
 
     // get the latest revision createdAt

+ 169 - 0
apps/app/src/server/util/safe-path-utils.spec.ts

@@ -0,0 +1,169 @@
+import path from 'pathe';
+
+import {
+  assertFileNameSafeForBaseDir,
+  isFileNameSafeForBaseDir,
+  isPathWithinBase,
+} from './safe-path-utils';
+
+describe('path-utils', () => {
+  describe('isPathWithinBase', () => {
+    const baseDir = '/tmp/growi-export';
+
+    describe('valid paths', () => {
+      test('should return true for file directly in baseDir', () => {
+        const filePath = '/tmp/growi-export/test.json';
+        expect(isPathWithinBase(filePath, baseDir)).toBe(true);
+      });
+
+      test('should return true for file in subdirectory', () => {
+        const filePath = '/tmp/growi-export/subdir/test.json';
+        expect(isPathWithinBase(filePath, baseDir)).toBe(true);
+      });
+
+      test('should return true for baseDir itself', () => {
+        expect(isPathWithinBase(baseDir, baseDir)).toBe(true);
+      });
+
+      test('should handle relative paths correctly', () => {
+        const filePath = path.join(baseDir, 'test.json');
+        expect(isPathWithinBase(filePath, baseDir)).toBe(true);
+      });
+    });
+
+    describe('invalid paths (path traversal attacks)', () => {
+      test('should return false for path outside baseDir', () => {
+        const filePath = '/etc/passwd';
+        expect(isPathWithinBase(filePath, baseDir)).toBe(false);
+      });
+
+      test('should return false for path traversal with ../', () => {
+        const filePath = '/tmp/growi-export/../etc/passwd';
+        expect(isPathWithinBase(filePath, baseDir)).toBe(false);
+      });
+
+      test('should return false for sibling directory', () => {
+        const filePath = '/tmp/other-dir/test.json';
+        expect(isPathWithinBase(filePath, baseDir)).toBe(false);
+      });
+
+      test('should return false for directory with similar prefix', () => {
+        // /tmp/growi-export-evil should not match /tmp/growi-export
+        const filePath = '/tmp/growi-export-evil/test.json';
+        expect(isPathWithinBase(filePath, baseDir)).toBe(false);
+      });
+    });
+  });
+
+  describe('isFileNameSafeForBaseDir', () => {
+    const baseDir = '/tmp/growi-export';
+
+    describe('valid file names', () => {
+      test('should return true for simple filename', () => {
+        expect(isFileNameSafeForBaseDir('test.json', baseDir)).toBe(true);
+      });
+
+      test('should return true for filename in subdirectory', () => {
+        expect(isFileNameSafeForBaseDir('subdir/test.json', baseDir)).toBe(
+          true,
+        );
+      });
+
+      test('should return true for deeply nested file', () => {
+        expect(isFileNameSafeForBaseDir('a/b/c/d/test.json', baseDir)).toBe(
+          true,
+        );
+      });
+    });
+
+    describe('path traversal attacks', () => {
+      test('should return false for ../etc/passwd', () => {
+        expect(isFileNameSafeForBaseDir('../etc/passwd', baseDir)).toBe(false);
+      });
+
+      test('should return false for ../../etc/passwd', () => {
+        expect(isFileNameSafeForBaseDir('../../etc/passwd', baseDir)).toBe(
+          false,
+        );
+      });
+
+      test('should return false for subdir/../../../etc/passwd', () => {
+        expect(
+          isFileNameSafeForBaseDir('subdir/../../../etc/passwd', baseDir),
+        ).toBe(false);
+      });
+
+      test('should return false for absolute path outside baseDir', () => {
+        expect(isFileNameSafeForBaseDir('/etc/passwd', baseDir)).toBe(false);
+      });
+
+      test('should return false for path escaping to sibling directory', () => {
+        expect(
+          isFileNameSafeForBaseDir('subdir/../../other-dir/file.json', baseDir),
+        ).toBe(false);
+      });
+    });
+
+    describe('edge cases', () => {
+      test('should handle empty filename', () => {
+        // Empty filename resolves to baseDir itself, which is valid
+        expect(isFileNameSafeForBaseDir('', baseDir)).toBe(true);
+      });
+
+      test('should handle . (current directory)', () => {
+        expect(isFileNameSafeForBaseDir('.', baseDir)).toBe(true);
+      });
+
+      test('should handle ./filename', () => {
+        expect(isFileNameSafeForBaseDir('./test.json', baseDir)).toBe(true);
+      });
+    });
+  });
+
+  describe('assertFileNameSafeForBaseDir', () => {
+    const baseDir = '/tmp/growi-export';
+
+    describe('valid file names (should not throw)', () => {
+      test('should not throw for simple filename', () => {
+        expect(() => {
+          assertFileNameSafeForBaseDir('test.json', baseDir);
+        }).not.toThrow();
+      });
+
+      test('should not throw for filename in subdirectory', () => {
+        expect(() => {
+          assertFileNameSafeForBaseDir('subdir/test.json', baseDir);
+        }).not.toThrow();
+      });
+    });
+
+    describe('path traversal attacks (should throw)', () => {
+      test('should throw for ../etc/passwd', () => {
+        expect(() => {
+          assertFileNameSafeForBaseDir('../etc/passwd', baseDir);
+        }).toThrow('Invalid file path: path traversal detected');
+      });
+
+      test('should throw for ../../etc/passwd', () => {
+        expect(() => {
+          assertFileNameSafeForBaseDir('../../etc/passwd', baseDir);
+        }).toThrow('Invalid file path: path traversal detected');
+      });
+
+      test('should throw for absolute path outside baseDir', () => {
+        expect(() => {
+          assertFileNameSafeForBaseDir('/etc/passwd', baseDir);
+        }).toThrow('Invalid file path: path traversal detected');
+      });
+
+      test('should throw for path escaping to sibling directory', () => {
+        expect(() => {
+          assertFileNameSafeForBaseDir(
+            'subdir/../../other-dir/file.json',
+            baseDir,
+          );
+        }).toThrow('Invalid file path: path traversal detected');
+      });
+    });
+  });
+});

+ 69 - 0
apps/app/src/server/util/safe-path-utils.ts

@@ -0,0 +1,69 @@
+import path from 'pathe';
+
+/**
+ * Validates that the given file path is within the base directory.
+ * This prevents path traversal attacks where an attacker could use sequences
+ * like '../' to access files outside the intended directory.
+ *
+ * @param filePath - The file path to validate
+ * @param baseDir - The base directory that the file path should be within
+ * @returns true if the path is valid, false otherwise
+ */
+export function isPathWithinBase(filePath: string, baseDir: string): boolean {
+  const resolvedBaseDir = path.resolve(baseDir);
+  const resolvedFilePath = path.resolve(filePath);
+
+  // Check if the resolved path starts with the base directory
+  // We add path.sep to ensure we're checking a directory boundary
+  // (e.g., /tmp/foo should not match /tmp/foobar)
+  return (
+    resolvedFilePath.startsWith(resolvedBaseDir + path.sep) ||
+    resolvedFilePath === resolvedBaseDir
+  );
+}
+
+/**
+ * Validates that joining baseDir with fileName results in a path within baseDir.
+ * This is useful for validating user-provided file names before using them.
+ *
+ * @param fileName - The file name to validate
+ * @param baseDir - The base directory
+ * @returns true if the resulting path is valid, false otherwise
+ * @throws Error if path traversal is detected
+ */
+export function assertFileNameSafeForBaseDir(
+  fileName: string,
+  baseDir: string,
+): void {
+  const resolvedBaseDir = path.resolve(baseDir);
+  const resolvedFilePath = path.resolve(baseDir, fileName);
+
+  const isValid =
+    resolvedFilePath.startsWith(resolvedBaseDir + path.sep) ||
+    resolvedFilePath === resolvedBaseDir;
+
+  if (!isValid) {
+    throw new Error('Invalid file path: path traversal detected');
+  }
+}
+
+/**
+ * Validates that joining baseDir with fileName results in a path within baseDir.
+ * This is useful for validating user-provided file names before using them.
+ *
+ * @param fileName - The file name to validate
+ * @param baseDir - The base directory
+ * @returns true if the resulting path is valid, false otherwise
+ */
+export function isFileNameSafeForBaseDir(
+  fileName: string,
+  baseDir: string,
+): boolean {
+  const resolvedBaseDir = path.resolve(baseDir);
+  const resolvedFilePath = path.resolve(baseDir, fileName);
+
+  return (
+    resolvedFilePath.startsWith(resolvedBaseDir + path.sep) ||
+    resolvedFilePath === resolvedBaseDir
+  );
+}

+ 11 - 11
apps/app/src/states/page/hooks.ts

@@ -121,24 +121,24 @@ export const useIsEditable = () => {
   const isGuestUser = useIsGuestUser();
   const isReadOnlyUser = useIsReadOnlyUser();
   const isNotCreatable = useIsNotCreatable();
-
-  const getCombinedConditions = useAtomCallback(
-    useCallback((get) => {
-      const isForbidden = get(isForbiddenAtom);
-      const isIdenticalPath = get(isIdenticalPathAtom);
-
-      return !isForbidden && !isIdenticalPath;
-    }, []),
-  );
+  const isForbidden = useAtomValue(isForbiddenAtom);
+  const isIdenticalPath = useAtomValue(isIdenticalPathAtom);
 
   return useMemo(() => {
     return (
       !isGuestUser &&
       !isReadOnlyUser &&
       !isNotCreatable &&
-      getCombinedConditions()
+      !isForbidden &&
+      !isIdenticalPath
     );
-  }, [getCombinedConditions, isGuestUser, isReadOnlyUser, isNotCreatable]);
+  }, [
+    isGuestUser,
+    isReadOnlyUser,
+    isNotCreatable,
+    isForbidden,
+    isIdenticalPath,
+  ]);
 };
 
 /**

+ 0 - 2
apps/app/src/states/page/use-fetch-current-page.ts

@@ -241,8 +241,6 @@ export const useFetchCurrentPage = (): {
           const { data } = await apiv3Get<FetchedPageResult>('/page', params);
           const { page: newData, meta } = data;
 
-          console.log('Fetched page data:', { newData, meta });
-
           set(currentPageDataAtom, newData ?? undefined);
           set(currentPageEntityIdAtom, newData?._id);
           set(

+ 1 - 6
apps/app/src/states/ui/page-abilities.ts

@@ -110,12 +110,7 @@ export const useIsAbleToChangeEditorMode = (): boolean => {
   const isEditable = useIsEditable();
   const isSharedUser = useIsSharedUser();
 
-  const includesUndefined = [isEditable, isSharedUser].some(
-    (v) => v === undefined,
-  );
-  if (includesUndefined) return false;
-
-  return !!isEditable && !isSharedUser;
+  return isEditable && !isSharedUser;
 };
 
 /**

+ 16 - 23
apps/app/src/states/ui/sidebar/hydrate.ts

@@ -20,29 +20,22 @@ export const useHydrateSidebarAtoms = (
   sidebarConfig?: ISidebarConfig,
   userUISettings?: IUserUISettings,
 ): void => {
-  useHydrateAtoms(
-    sidebarConfig == null || userUISettings == null
-      ? []
-      : [
-          // Use user preference from DB if available, otherwise use system default
-          [
-            preferCollapsedModeAtom,
-            userUISettings?.preferCollapsedModeByUser ??
-              sidebarConfig?.isSidebarCollapsedMode ??
-              false,
-          ],
+  useHydrateAtoms([
+    // Use user preference from DB if available, otherwise use system default
+    [
+      preferCollapsedModeAtom,
+      userUISettings?.preferCollapsedModeByUser ??
+        sidebarConfig?.isSidebarCollapsedMode ??
+        false,
+    ],
 
-          // Sidebar contents type (with default fallback)
-          [
-            currentSidebarContentsAtom,
-            userUISettings?.currentSidebarContents ?? SidebarContentsType.TREE,
-          ],
+    // Sidebar contents type (with default fallback)
+    [
+      currentSidebarContentsAtom,
+      userUISettings?.currentSidebarContents ?? SidebarContentsType.TREE,
+    ],
 
-          // Product navigation width (with default fallback)
-          [
-            currentProductNavWidthAtom,
-            userUISettings?.currentProductNavWidth ?? 320,
-          ],
-        ],
-  );
+    // Product navigation width (with default fallback)
+    [currentProductNavWidthAtom, userUISettings?.currentProductNavWidth ?? 320],
+  ]);
 };

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