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

Merge pull request #8235 from weseek/master

Release v6.3.0
Yuki Takei 2 лет назад
Родитель
Сommit
aab8308ed4
100 измененных файлов с 4488 добавлено и 755 удалено
  1. 22 1
      CHANGELOG.md
  2. 1 1
      apps/app/.env.test
  3. 2 7
      apps/app/docker/README.md
  4. 13 4
      apps/app/package.json
  5. 62 3
      apps/app/public/static/locales/en_US/admin.json
  6. 63 3
      apps/app/public/static/locales/ja_JP/admin.json
  7. 64 4
      apps/app/public/static/locales/zh_CN/admin.json
  8. 1 1
      apps/app/resource/search/mappings-es7.json
  9. 1 1
      apps/app/resource/search/mappings-es8-for-ci.json
  10. 1 1
      apps/app/resource/search/mappings-es8.json
  11. 78 0
      apps/app/src/client/services/AdminAppContainer.js
  12. 10 0
      apps/app/src/client/services/AdminGeneralSecurityContainer.js
  13. 8 2
      apps/app/src/client/services/side-effects/drawio-modal-launcher-for-view.ts
  14. 8 2
      apps/app/src/client/services/side-effects/handsontable-modal-launcher-for-view.ts
  15. 211 0
      apps/app/src/components/Admin/App/AzureSetting.tsx
  16. 75 2
      apps/app/src/components/Admin/App/FileUploadSetting.tsx
  17. 12 0
      apps/app/src/components/Admin/App/MaskedInput.module.scss
  18. 43 0
      apps/app/src/components/Admin/App/MaskedInput.tsx
  19. 3 3
      apps/app/src/components/Admin/Customize/CustomizePresentationSetting.tsx
  20. 3 2
      apps/app/src/components/Admin/ElasticsearchManagement/RebuildIndexControls.jsx
  21. 2 2
      apps/app/src/components/Admin/Security/LdapSecuritySettingContents.jsx
  22. 1 1
      apps/app/src/components/Admin/Security/OidcSecuritySettingContents.jsx
  23. 14 1
      apps/app/src/components/Admin/Security/SecuritySetting.jsx
  24. 22 10
      apps/app/src/components/Admin/UserGroup/UserGroupForm.tsx
  25. 10 1
      apps/app/src/components/Admin/UserGroup/UserGroupModal.tsx
  26. 6 0
      apps/app/src/components/Admin/UserGroup/UserGroupPage.tsx
  27. 41 23
      apps/app/src/components/Admin/UserGroup/UserGroupTable.tsx
  28. 63 38
      apps/app/src/components/Admin/UserGroupDetail/UserGroupDetailPage.tsx
  29. 35 36
      apps/app/src/components/Admin/UserGroupDetail/UserGroupUserTable.tsx
  30. 50 0
      apps/app/src/components/Admin/UserGroupDetail/use-user-group-resource.ts
  31. 5 3
      apps/app/src/components/Common/Dropdown/PageItemControl.tsx
  32. 10 8
      apps/app/src/components/PageAlert/FixPageGrantAlert.tsx
  33. 6 1
      apps/app/src/components/PageAlert/PageGrantAlert.tsx
  34. 4 3
      apps/app/src/components/PageEditor.tsx
  35. 4 2
      apps/app/src/components/PageEditorByHackmd.tsx
  36. 3 4
      apps/app/src/components/SavePageControls.tsx
  37. 27 37
      apps/app/src/components/SavePageControls/GrantSelector/GrantSelector.tsx
  38. 1 0
      apps/app/src/components/SavePageControls/GrantSelector/index.ts
  39. 38 0
      apps/app/src/components/SavePageControls/GrantSelector/use-my-user-groups.ts
  40. 1 1
      apps/app/src/components/Sidebar/PageTree/Item.tsx
  41. 181 0
      apps/app/src/features/external-user-group/client/components/ExternalUserGroup/ExternalUserGroupManagement.tsx
  42. 21 0
      apps/app/src/features/external-user-group/client/components/ExternalUserGroup/KeycloakGroupManagement.tsx
  43. 241 0
      apps/app/src/features/external-user-group/client/components/ExternalUserGroup/KeycloakGroupSyncSettingsForm.tsx
  44. 67 0
      apps/app/src/features/external-user-group/client/components/ExternalUserGroup/LdapGroupManagement.tsx
  45. 247 0
      apps/app/src/features/external-user-group/client/components/ExternalUserGroup/LdapGroupSyncSettingsForm.tsx
  46. 172 0
      apps/app/src/features/external-user-group/client/components/ExternalUserGroup/SyncExecution.tsx
  47. 106 0
      apps/app/src/features/external-user-group/client/stores/external-user-group.ts
  48. 62 0
      apps/app/src/features/external-user-group/interfaces/external-user-group.ts
  49. 126 0
      apps/app/src/features/external-user-group/server/models/external-user-group-relation.integ.ts
  50. 54 0
      apps/app/src/features/external-user-group/server/models/external-user-group-relation.ts
  51. 73 0
      apps/app/src/features/external-user-group/server/models/external-user-group.integ.ts
  52. 64 0
      apps/app/src/features/external-user-group/server/models/external-user-group.ts
  53. 55 0
      apps/app/src/features/external-user-group/server/routes/apiv3/external-user-group-relation.ts
  54. 393 0
      apps/app/src/features/external-user-group/server/routes/apiv3/external-user-group.ts
  55. 224 0
      apps/app/src/features/external-user-group/server/service/external-user-group-sync.ts
  56. 210 0
      apps/app/src/features/external-user-group/server/service/keycloak-user-group-sync.integ.ts
  57. 168 0
      apps/app/src/features/external-user-group/server/service/keycloak-user-group-sync.ts
  58. 156 0
      apps/app/src/features/external-user-group/server/service/ldap-user-group-sync.ts
  59. 6 6
      apps/app/src/features/growi-plugin/client/components/Admin/PluginsExtensionPageContents/PluginCard.tsx
  60. 1 1
      apps/app/src/features/growi-plugin/client/components/Admin/PluginsExtensionPageContents/PluginsExtensionPageContents.tsx
  61. 1 0
      apps/app/src/features/questionnaire/interfaces/growi-info.ts
  62. 8 4
      apps/app/src/interfaces/crowi-request.ts
  63. 9 2
      apps/app/src/interfaces/page-grant.ts
  64. 3 2
      apps/app/src/interfaces/page-operation.ts
  65. 5 4
      apps/app/src/interfaces/page.ts
  66. 14 0
      apps/app/src/interfaces/res/admin/app-settings.ts
  67. 12 11
      apps/app/src/interfaces/user-group-response.ts
  68. 23 0
      apps/app/src/interfaces/websocket.ts
  69. 1 1
      apps/app/src/migrations/20200402160380-remove-deleteduser-from-relationgroup.js
  70. 2 3
      apps/app/src/migrations/20220613064207-add-attachment-type-to-existing-attachments.js
  71. 160 0
      apps/app/src/migrations/20230723061824-granted-group-to-array-of-objects.js
  72. 16 20
      apps/app/src/pages/[[...path]].page.tsx
  73. 1 1
      apps/app/src/pages/_document.page.tsx
  74. 1 1
      apps/app/src/pages/_private-legacy-pages.page.tsx
  75. 1 1
      apps/app/src/pages/_search.page.tsx
  76. 4 2
      apps/app/src/pages/admin/user-group-detail/[userGroupId].page.tsx
  77. 1 1
      apps/app/src/pages/invited.page.tsx
  78. 1 1
      apps/app/src/pages/maintenance.page.tsx
  79. 2 3
      apps/app/src/pages/me/[[...path]].page.tsx
  80. 1 1
      apps/app/src/pages/share/[[...path]].page.tsx
  81. 1 1
      apps/app/src/pages/tags.page.tsx
  82. 1 1
      apps/app/src/pages/trash.page.tsx
  83. 1 1
      apps/app/src/pages/utils/commons.ts
  84. 102 84
      apps/app/src/server/crowi/index.js
  85. 20 18
      apps/app/src/server/events/user.ts
  86. 24 0
      apps/app/src/server/interfaces/attachment.ts
  87. 2 5
      apps/app/src/server/middlewares/certify-shared-page-attachment/certify-shared-page-attachment.ts
  88. 2 2
      apps/app/src/server/middlewares/certify-shared-page-attachment/retrieve-valid-share-link.ts
  89. 1 1
      apps/app/src/server/middlewares/certify-shared-page-attachment/validate-referer/retrieve-site-url.ts
  90. 5 2
      apps/app/src/server/middlewares/certify-shared-page-attachment/validate-referer/validate-referer.ts
  91. 0 103
      apps/app/src/server/models/attachment.js
  92. 115 0
      apps/app/src/server/models/attachment.ts
  93. 8 1
      apps/app/src/server/models/config.ts
  94. 0 187
      apps/app/src/server/models/external-account.js
  95. 151 0
      apps/app/src/server/models/external-account.ts
  96. 12 6
      apps/app/src/server/models/index.js
  97. 4 4
      apps/app/src/server/models/interfaces/page-operation.ts
  98. 48 49
      apps/app/src/server/models/obsolete-page.js
  99. 23 2
      apps/app/src/server/models/page-operation.ts
  100. 51 15
      apps/app/src/server/models/page.ts

+ 22 - 1
CHANGELOG.md

@@ -1,9 +1,30 @@
 # Changelog
 # Changelog
 
 
-## [Unreleased](https://github.com/weseek/growi/compare/v6.2.3...HEAD)
+## [Unreleased](https://github.com/weseek/growi/compare/v6.2.5...HEAD)
 
 
 *Please do not manually update this file. We've automated the process.*
 *Please do not manually update this file. We've automated the process.*
 
 
+## [v6.2.5](https://github.com/weseek/growi/compare/v6.2.4...v6.2.5) - 2023-12-14
+
+### 🐛 Bug Fixes
+
+* fix: Update deleteCompletelyUserHomeBySystem for v4 process (#8289) @jam411
+
+## [v6.2.4](https://github.com/weseek/growi/compare/v6.2.3...v6.2.4) - 2023-11-29
+
+### 💎 Features
+* feat: Show create date in Attachment Data list (#8229) @sakazuki
+
+### 🚀 Improvement
+
+* imprv: Add Marp preset template for ja_JP and zh_CN (#8179) @AikaHiyama
+* imprv: Allow deletion of user homepage when the user is deleted (#8224) @jam411
+
+### 🧰 Maintenance
+
+* support: Refactor deleteCompletelyUserHomeBySystem (#8262) @jam411
+
+
 ## [v6.2.3](https://github.com/weseek/growi/compare/v6.2.2...v6.2.3) - 2023-11-13
 ## [v6.2.3](https://github.com/weseek/growi/compare/v6.2.2...v6.2.3) - 2023-11-13
 
 
 ### 🚀 Improvement
 ### 🚀 Improvement

+ 1 - 1
apps/app/.env.test

@@ -5,5 +5,5 @@
 ## > To prevent accidentally leaking env variables to the client, only variables prefixed with
 ## > To prevent accidentally leaking env variables to the client, only variables prefixed with
 ## > VITE_ are exposed to your Vite-processed code. e.g. for the following env variables:
 ## > VITE_ are exposed to your Vite-processed code. e.g. for the following env variables:
 ##
 ##
-VITE_MONGOMS_VERSION="6.0.6"
+VITE_MONGOMS_VERSION="6.0.9"
 # VITE_MONGOMS_DEBUG=1
 # VITE_MONGOMS_DEBUG=1

+ 2 - 7
apps/app/docker/README.md

@@ -10,13 +10,8 @@ GROWI Official docker image
 Supported tags and respective Dockerfile links
 Supported tags and respective Dockerfile links
 ------------------------------------------------
 ------------------------------------------------
 
 
-* [`6.2.3`, `6.2`, `6`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v6.2.3/apps/app/docker/Dockerfile)
-* [`6.1.15`, `6.1` (Dockerfile)](https://github.com/weseek/growi/blob/v6.1.15/apps/app/docker/Dockerfile)
-* [`6.0.15`, `6.0` (Dockerfile)](https://github.com/weseek/growi/blob/v6.0.15/packages/app/docker/Dockerfile)
-* [`5.1.7`, `5.1`, `5` (Dockerfile)](https://github.com/weseek/growi/blob/v5.1.7/packages/app/docker/Dockerfile)
-* [`5.1.7-nocdn`, `5.1-nocdn`, `5-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v5.1.7/packages/app/docker/Dockerfile)
-* [`4.5.23`, `4.5`, `4` (Dockerfile)](https://github.com/weseek/growi/blob/v4.5.23/packages/app/docker/Dockerfile)
-* [`4.5.23-nocdn`, `4.5-nocdn`, `4-nocdn`, `latest-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v4.5.23/packages/app/docker/Dockerfile)
+* [`6.3.0`, `6.3`, `6`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v6.3.0/apps/app/docker/Dockerfile)
+* [`6.2.4`, `6.2` (Dockerfile)](https://github.com/weseek/growi/blob/v6.2.4/apps/app/docker/Dockerfile)
 
 
 
 
 What is GROWI?
 What is GROWI?

+ 13 - 4
apps/app/package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "@growi/app",
   "name": "@growi/app",
-  "version": "6.2.3",
+  "version": "6.3.0-RC.0",
   "license": "MIT",
   "license": "MIT",
   "scripts": {
   "scripts": {
     "//// for production": "",
     "//// for production": "",
@@ -40,6 +40,7 @@
     "reg:run": "reg-suit run",
     "reg:run": "reg-suit run",
     "vitest:run": "vitest run config src --coverage",
     "vitest:run": "vitest run config src --coverage",
     "vitest:run:integ": "vitest run -c vitest.config.integ.ts src --coverage",
     "vitest:run:integ": "vitest run -c vitest.config.integ.ts src --coverage",
+    "previtest:run:integ": "vitest run -c test-with-vite/download-mongo-binary/vitest.config.ts test-with-vite/download-mongo-binary",
     "//// misc": "",
     "//// misc": "",
     "console": "yarn cross-env NODE_ENV=development yarn ts-node --experimental-repl-await src/server/console.js",
     "console": "yarn cross-env NODE_ENV=development yarn ts-node --experimental-repl-await src/server/console.js",
     "swagger-jsdoc": "swagger-jsdoc -o tmp/swagger.json -d config/swagger-definition.js",
     "swagger-jsdoc": "swagger-jsdoc -o tmp/swagger.json -d config/swagger-definition.js",
@@ -50,14 +51,18 @@
     "version": "yarn version --no-git-tag-version --preid=RC"
     "version": "yarn version --no-git-tag-version --preid=RC"
   },
   },
   "// comments for dependencies": {
   "// comments for dependencies": {
+    "@aws-skd/*": "fix version above 3.186.0 that is required by mongodb@4.16.0",
+    "@keycloak/keycloak-admin-client": "19.0.0 or above exports only ESM.",
     "escape-string-regexp": "5.0.0 or above exports only ESM",
     "escape-string-regexp": "5.0.0 or above exports only ESM",
     "string-width": "5.0.0 or above exports only ESM.",
     "string-width": "5.0.0 or above exports only ESM.",
     "remark-wiki-link": "!!DO NOT REMOVE!! including 'mdast-util-wiki-link' and 'micromark-extension-wiki-link' required by pukiwiki-like-linker"
     "remark-wiki-link": "!!DO NOT REMOVE!! including 'mdast-util-wiki-link' and 'micromark-extension-wiki-link' required by pukiwiki-like-linker"
   },
   },
   "dependencies": {
   "dependencies": {
     "@akebifiky/remark-simple-plantuml": "^1.0.2",
     "@akebifiky/remark-simple-plantuml": "^1.0.2",
-    "@aws-sdk/client-s3": "^3.58.0",
-    "@aws-sdk/s3-request-presigner": "^3.58.0",
+    "@aws-sdk/client-s3": "3.454.0",
+    "@aws-sdk/s3-request-presigner": "3.454.0",
+    "@azure/identity": "^3.3.2",
+    "@azure/storage-blob": "^12.16.0",
     "@browser-bunyan/console-formatted-stream": "^1.8.0",
     "@browser-bunyan/console-formatted-stream": "^1.8.0",
     "@elastic/elasticsearch7": "npm:@elastic/elasticsearch@^7.17.0",
     "@elastic/elasticsearch7": "npm:@elastic/elasticsearch@^7.17.0",
     "@elastic/elasticsearch8": "npm:@elastic/elasticsearch@^8.7.0",
     "@elastic/elasticsearch8": "npm:@elastic/elasticsearch@^8.7.0",
@@ -73,9 +78,11 @@
     "@growi/remark-growi-directive": "link:../../packages/remark-growi-directive",
     "@growi/remark-growi-directive": "link:../../packages/remark-growi-directive",
     "@growi/remark-lsx": "link:../../packages/remark-lsx",
     "@growi/remark-lsx": "link:../../packages/remark-lsx",
     "@growi/slack": "link:../../packages/slack",
     "@growi/slack": "link:../../packages/slack",
+    "@keycloak/keycloak-admin-client": "^18.0.0",
     "@slack/web-api": "^6.2.4",
     "@slack/web-api": "^6.2.4",
     "@slack/webhook": "^6.0.0",
     "@slack/webhook": "^6.0.0",
     "@types/jest": "^29.5.2",
     "@types/jest": "^29.5.2",
+    "@types/ldapjs": "^2.2.5",
     "JSONStream": "^1.3.5",
     "JSONStream": "^1.3.5",
     "archiver": "^5.3.0",
     "archiver": "^5.3.0",
     "array.prototype.flatmap": "^1.2.2",
     "array.prototype.flatmap": "^1.2.2",
@@ -121,6 +128,7 @@
     "i18next-localstorage-backend": "^4.0.0",
     "i18next-localstorage-backend": "^4.0.0",
     "is-absolute-url": "^4.0.1",
     "is-absolute-url": "^4.0.1",
     "is-iso-date": "^0.0.1",
     "is-iso-date": "^0.0.1",
+    "ldapjs": "^3.0.2",
     "lucene-query-parser": "^1.2.0",
     "lucene-query-parser": "^1.2.0",
     "markdown-table": "^1.1.1",
     "markdown-table": "^1.1.1",
     "md5": "^2.2.1",
     "md5": "^2.2.1",
@@ -218,6 +226,7 @@
     "@types/express": "^4.17.11",
     "@types/express": "^4.17.11",
     "@types/jest": "^29.5.2",
     "@types/jest": "^29.5.2",
     "@types/react-scroll": "^1.8.4",
     "@types/react-scroll": "^1.8.4",
+    "@vitest/coverage-v8": "^0.34.6",
     "autoprefixer": "^9.0.0",
     "autoprefixer": "^9.0.0",
     "babel-loader": "^8.2.5",
     "babel-loader": "^8.2.5",
     "bootstrap": "^4.6.1",
     "bootstrap": "^4.6.1",
@@ -239,7 +248,7 @@
     "jquery": "^3.7.0",
     "jquery": "^3.7.0",
     "load-css-file": "^1.0.0",
     "load-css-file": "^1.0.0",
     "material-icons": "^1.11.3",
     "material-icons": "^1.11.3",
-    "mongodb-memory-server": "^8.15.1",
+    "mongodb-memory-server-core": "^9.1.1",
     "morgan": "^1.10.0",
     "morgan": "^1.10.0",
     "null-loader": "^4.0.1",
     "null-loader": "^4.0.1",
     "penpal": "^4.0.0",
     "penpal": "^4.0.0",

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

@@ -9,6 +9,7 @@
   "specified_users": "Specified users",
   "specified_users": "Specified users",
   "only_me": "Only me",
   "only_me": "Only me",
   "only_inside_the_group": "Only inside the group",
   "only_inside_the_group": "Only inside the group",
+  "optional": "Optional",
   "security_settings": {
   "security_settings": {
     "security_settings": "Security Settings",
     "security_settings": "Security Settings",
     "scope_of_page_disclosure": "Scope of page disclosure",
     "scope_of_page_disclosure": "Scope of page disclosure",
@@ -47,8 +48,9 @@
     "anyone": "Anyone",
     "anyone": "Anyone",
     "user_homepage_deletion": {
     "user_homepage_deletion": {
       "user_homepage_deletion": "User homepage deletion",
       "user_homepage_deletion": "User homepage deletion",
-      "enable_user_homepage_deletion": "Complete deletion of user homepage, when user deletion",
-      "desc": "When deleting a user, the user homepage and its sub pages are also completely deleted."
+      "enable_user_homepage_deletion": "Enable user homepage deletion",
+      "enable_force_delete_user_homepage_on_user_deletion": "When you delete a user, the user's homepage and all its sub pages will be completely deleted",
+      "desc": "You will be able to delete a deleted user's homepage."
     },
     },
     "session": "Session",
     "session": "Session",
     "max_age": "Max age (msec)",
     "max_age": "Max age (msec)",
@@ -94,7 +96,6 @@
     "enable_link_sharing": "Enable link sharing",
     "enable_link_sharing": "Enable link sharing",
     "all_share_links": "All share links",
     "all_share_links": "All share links",
     "configuration": " Configuration",
     "configuration": " Configuration",
-    "optional": "Optional",
     "Treat username matching as identical": "Automatically bind external accounts newly logged in to local accounts when <code>username</code> match",
     "Treat username matching as identical": "Automatically bind external accounts newly logged in to local accounts when <code>username</code> match",
     "Treat username matching as identical_warn": "WARNING: Be aware of security because the system treats the same user as a match of <code>username</code>.",
     "Treat username matching as identical_warn": "WARNING: Be aware of security because the system treats the same user as a match of <code>username</code>.",
     "Treat email matching as identical": "Automatically bind external accounts newly logged in to local accounts when <code>email</code> match",
     "Treat email matching as identical": "Automatically bind external accounts newly logged in to local accounts when <code>email</code> match",
@@ -381,6 +382,13 @@
     "aws_label": "AWS(S3)",
     "aws_label": "AWS(S3)",
     "local_label": "Local",
     "local_label": "Local",
     "gridfs_label": "MongoDB(GridFS)",
     "gridfs_label": "MongoDB(GridFS)",
+    "azure_label": "Azure(Blob)",
+    "azure_tenant_id": "Tenant ID",
+    "azure_client_id": "Client ID",
+    "azure_client_secret": "Client Secret",
+    "azure_storage_account_name": "Storage Account Name",
+    "azure_storage_container_name": "Container Name",
+    "azure_note_for_the_only_env_option": "The Azure Settings is limited by the value of environment variable.<br>To change this setting, please change to false or delete the value of the environment variable <code>{{env}}</code> .",
     "file_upload": "This is for uploading file settings. If you complete file upload settings, file upload function, profile picture function etc will be enabled.",
     "file_upload": "This is for uploading file settings. If you complete file upload settings, file upload function, profile picture function etc will be enabled.",
     "test_connection": "Test connection to mail",
     "test_connection": "Test connection to mail",
     "change_setting": "Caution:if you change this setting not completed, you will not be able to access files you have uploaded so far.",
     "change_setting": "Caution:if you change this setting not completed, you will not be able to access files you have uploaded so far.",
@@ -1057,6 +1065,57 @@
     "error_generate_growi_archive": "Failed to generate GROWI archive file",
     "error_generate_growi_archive": "Failed to generate GROWI archive file",
     "error_send_growi_archive": "Failed to send GROWI archive file to the destination GROWI"
     "error_send_growi_archive": "Failed to send GROWI archive file to the destination GROWI"
   },
   },
+  "external_user_group": {
+    "management": "External Group Management",
+    "execute_sync": "Execute Sync",
+    "sync": "Sync",
+    "invalid_sync_settings": "Invalid sync settings",
+    "update_sync_settings_failed": "Failed to update sync settings",
+    "description_form_detail": "Please note that edited value will be overwritten on next sync if description mapper is set in sync settings",
+    "only_description_edit_allowed": "Only description can be edited for external user groups",
+    "sync_being_executed": "There is a running external group sync process started by you or another user. The next sync cannot be executed until this finishes.",
+    "sync_succeeded": "External group sync succeeded",
+    "sync_failed": "External group sync failed",
+    "provider": "Provider",
+    "confirmation_before_sync": "Confirmation before sync",
+    "execution_time_warning": "If the number of groups or users is large, it might take a while until sync finishes",
+    "parallel_sync_forbidden": "While sync is executing, you cannot execute a different external group sync",
+    "ldap": {
+      "group_sync_settings": "LDAP Group Sync Settings",
+      "group_search_base_DN": "Group Search Base DN",
+      "group_search_base_dn_detail": "The base DN for searching groups. The value set in security settings will be used if not set here.",
+      "membership_attribute": "Membership Attribute",
+      "membership_attribute_detail": "Attribute of the group object which indicates user membership info",
+      "membership_attribute_type": "Membership Attribute Type",
+      "membership_attribute_type_detail": "Whether membership attribute value is of type DN or UID",
+      "child_group_attribute": "Child Group Attribute",
+      "child_group_attribute_detail": "Attribute of the group object which indicates child group info. The attribute value needs to be the DN of the child group.",
+      "preserve_deleted_ldap_groups": "Preserve Deleted LDAP Groups",
+      "name_mapper_detail": "Attribute to map as group name",
+      "updated_group_sync_settings": "Updated LDAP group sync settings",
+      "password": "Password",
+      "password_detail": "Login password is necessary because Bind type is set to User Bind",
+      "auth_not_set": "Enable and configure LDAP auth in security settings before sync"
+    },
+    "keycloak": {
+      "group_sync_settings": "Keycloak Group Sync Settings",
+      "host": "Host",
+      "host_detail": "Keycloak host URL",
+      "group_realm": "Group Realm",
+      "group_realm_detail": "Realm that contains the groups to sync",
+      "group_sync_client_realm": "Realm of client used to request to Admin API",
+      "group_sync_client_realm_detail": "Realm that contains the client used to authenticate to request to Keycloak admin API",
+      "group_sync_client_id": "Client ID",
+      "group_sync_client_id_detail": "Id of the client used to authenticate to request to Keycloak admin API",
+      "group_sync_client_secret": "Client Secret",
+      "group_sync_client_secret_detail": "Id of the secret used to authenticate to request to Keycloak admin API",
+      "updated_group_sync_settings": "Updated Keycloak group sync settings",
+      "preserve_deleted_keycloak_groups": "Preserve Deleted Keycloak Groups",
+      "auth_not_set": "Enable and configure OIDC or SAML with Keycloak in security settings before sync"
+    },
+    "auto_generate_user_on_sync": "Auto Generate User on Sync",
+    "description_mapper_detail": "Attribute to map as group description. Description can be edited after sync. However, when a mapper is set, the edited value can possibly be overwritten by the next sync."
+  },
   "toaster": {
   "toaster": {
     "grant_user_admin": "Succeeded to grant {{username}} admin",
     "grant_user_admin": "Succeeded to grant {{username}} admin",
     "revoke_user_admin": "Succeeded to revoke {{username}} admin",
     "revoke_user_admin": "Succeeded to revoke {{username}} admin",

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

@@ -10,6 +10,7 @@
   "Created": "作成日",
   "Created": "作成日",
   "Edit": "編集",
   "Edit": "編集",
   "Description": "説明",
   "Description": "説明",
+  "Execute": "実行",
   "last_login": "最終ログイン",
   "last_login": "最終ログイン",
   "wiki_management_homepage": "Wiki管理トップ",
   "wiki_management_homepage": "Wiki管理トップ",
   "public": "公開",
   "public": "公開",
@@ -17,6 +18,7 @@
   "specified_users": "特定ユーザーのみ",
   "specified_users": "特定ユーザーのみ",
   "only_me": "自分のみ",
   "only_me": "自分のみ",
   "only_inside_the_group": "特定グループのみ",
   "only_inside_the_group": "特定グループのみ",
+  "optional": "オプション",
   "security_settings": {
   "security_settings": {
     "security_settings": "セキュリティ設定",
     "security_settings": "セキュリティ設定",
     "scope_of_page_disclosure": "ページの公開範囲",
     "scope_of_page_disclosure": "ページの公開範囲",
@@ -55,8 +57,9 @@
     "anyone": "誰でも可能",
     "anyone": "誰でも可能",
     "user_homepage_deletion": {
     "user_homepage_deletion": {
       "user_homepage_deletion": "ユーザーホームページの削除",
       "user_homepage_deletion": "ユーザーホームページの削除",
-      "enable_user_homepage_deletion": "ユーザー削除時にユーザーホームページを完全削除する",
-      "desc": "ユーザーを削除する際に、ユーザーホームページとその配下のページも完全削除されます。"
+      "enable_user_homepage_deletion": "ユーザーホームページの削除を有効化",
+      "enable_force_delete_user_homepage_on_user_deletion": "ユーザーを削除したとき、ユーザーホームページとその配下のページを完全削除する",
+      "desc": "削除済みユーザーのユーザーホームページを削除できるようになります。"
     },
     },
     "session": "セッション",
     "session": "セッション",
     "max_age": "有効期間 (ミリ秒)",
     "max_age": "有効期間 (ミリ秒)",
@@ -102,7 +105,6 @@
     "enable_link_sharing": "リンクのシェアを許可",
     "enable_link_sharing": "リンクのシェアを許可",
     "all_share_links": "全てのシェアリンク",
     "all_share_links": "全てのシェアリンク",
     "configuration": "設定",
     "configuration": "設定",
-    "optional": "オプション",
     "Treat username matching as identical": "新規ログイン時、<code>username</code> が一致したローカルアカウントが存在した場合は自動的に紐付ける",
     "Treat username matching as identical": "新規ログイン時、<code>username</code> が一致したローカルアカウントが存在した場合は自動的に紐付ける",
     "Treat username matching as identical_warn": "警告: <code>username</code> の一致を以て同一ユーザーであるとみなすので、セキュリティに注意してください",
     "Treat username matching as identical_warn": "警告: <code>username</code> の一致を以て同一ユーザーであるとみなすので、セキュリティに注意してください",
     "Treat email matching as identical": "新規ログイン時、<code>email</code> が一致したローカルアカウントが存在した場合は自動的に紐付ける",
     "Treat email matching as identical": "新規ログイン時、<code>email</code> が一致したローカルアカウントが存在した場合は自動的に紐付ける",
@@ -388,6 +390,13 @@
     "aws_label": "AWS(S3)",
     "aws_label": "AWS(S3)",
     "local_label": "Local",
     "local_label": "Local",
     "gridfs_label": "MongoDB(GridFS)",
     "gridfs_label": "MongoDB(GridFS)",
+    "azure_label": "Azure(Blob)",
+    "azure_tenant_id": "テナントID",
+    "azure_client_id": "クライアントID",
+    "azure_client_secret": "クライアントシークレット",
+    "azure_storage_account_name": "ストレージアカウント名",
+    "azure_storage_container_name": "コンテナ名",
+    "azure_note_for_the_only_env_option": "現在Azure設定は環境変数の値によって制限されています<br>この設定を変更する場合は環境変数 <code>{{env}}</code> の値をfalseに変更もしくは削除してください",
     "fixed_by_env_var": "環境変数 <code>FILE_UPLOAD={{fileUploadType}}</code> により固定されています。",
     "fixed_by_env_var": "環境変数 <code>FILE_UPLOAD={{fileUploadType}}</code> により固定されています。",
     "file_upload": "ファイルをアップロードするための設定を行います。ファイルアップロードの設定を完了させると、ファイルアップロード機能、プロフィール写真機能などが有効になります。",
     "file_upload": "ファイルをアップロードするための設定を行います。ファイルアップロードの設定を完了させると、ファイルアップロード機能、プロフィール写真機能などが有効になります。",
     "test_connection": "接続テスト",
     "test_connection": "接続テスト",
@@ -1066,6 +1075,57 @@
     "error_generate_growi_archive": "GROWI アーカイブファイルの作成に失敗しました",
     "error_generate_growi_archive": "GROWI アーカイブファイルの作成に失敗しました",
     "error_send_growi_archive": "GROWI アーカイブファイルの送信に失敗しました"
     "error_send_growi_archive": "GROWI アーカイブファイルの送信に失敗しました"
   },
   },
+  "external_user_group": {
+    "management": "外部グループ管理",
+    "execute_sync": "同期実行",
+    "sync": "同期",
+    "invalid_sync_settings": "同期設定に誤りがあります",
+    "update_sync_settings_failed": "同期設定の更新が失敗しました",
+    "description_form_detail": "同期設定で「説明」の mapper が設定されている場合、編集内容は再同期によって上書きされることに注意してください",
+    "only_description_edit_allowed": "外部グループは説明の編集のみが可能です",
+    "sync_being_executed": "自身または他のユーザが実行した外部グループ同期が終了するまで次の実行ができません",
+    "sync_succeeded": "外部グループ同期に成功しました",
+    "sync_failed": "外部グループ同期に失敗しました",
+    "provider": "プロバイダ",
+    "confirmation_before_sync": "同期実行前の確認",
+    "execution_time_warning": "同期するグループやユーザが多い場合、同期が完了するまでに時間を要します",
+    "parallel_sync_forbidden": "同期実行中は、他の外部グループ同期は実行できません",
+    "ldap": {
+      "group_sync_settings": "LDAP グループ同期設定",
+      "group_search_base_DN": "グループ検索ベース DN",
+      "group_search_base_dn_detail": "グループ検索をするベース DN。設定されていない場合、セキュリティ設定で設定されたものが利用されます。",
+      "membership_attribute": "所属メンバーを表す LDAP 属性",
+      "membership_attribute_detail": "グループの所属メンバーを表すグループオブジェクトの属性",
+      "membership_attribute_type": "「所属メンバーを表す LDAP 属性」値の種類",
+      "membership_attribute_type_detail": "グループの所属メンバーを表すグループオブジェクトの属性値は DN か UID か",
+      "child_group_attribute": "子グループを表す LDAP 属性",
+      "child_group_attribute_detail": "グループに所属する子グループを表すグループオブジェクトの属性。属性値は DN である必要があります。",
+      "preserve_deleted_ldap_groups": "LDAP から削除されたグループを GROWI に残す",
+      "name_mapper_detail": "グループの「名前」として読み込む属性",
+      "updated_group_sync_settings": "LDAP グループ同期設定を更新しました",
+      "password": "パスワード",
+      "password_detail": "認証設定がユーザ Bind のため、ログイン時のパスワードの入力が必要となります",
+      "auth_not_set": "同期実行前にセキュリティ設定で LDAP 認証を有効にし、設定してください"
+    },
+    "keycloak": {
+      "group_sync_settings": "Keycloak グループ同期設定",
+      "host": "Host",
+      "host_detail": "Keycloak ホスト URL",
+      "group_realm": "Group Realm",
+      "group_realm_detail": "同期対象のグループがある realm",
+      "group_sync_client_realm": "Admin API にリクエストするための client がある realm",
+      "group_sync_client_realm_detail": "Keycloak admin API にリクエストするための認証に使う client がある realm",
+      "group_sync_client_id": "Client の ID",
+      "group_sync_client_id_detail": "Keycloak admin API にリクエストするための認証に使う client の Client ID",
+      "group_sync_client_secret": "Client の Secret",
+      "group_sync_client_secret_detail": "Keycloak admin API にリクエストするための認証に使う client の secret",
+      "updated_group_sync_settings": "Keycloak グループ同期設定を更新しました",
+      "preserve_deleted_keycloak_groups": "Keycloak から削除されたグループを GROWI に残す",
+      "auth_not_set": "同期実行前にセキュリティ設定で Keycloak を使った OIDC または SAML 認証を有効にし、設定してください"
+    },
+    "auto_generate_user_on_sync": "作成されていない GROWI アカウントを自動生成する",
+    "description_mapper_detail": "グループの「説明」として読み込む属性。「説明」は同期後に編集可能です。ただし、mapper が設定されている場合、編集内容は再同期によって上書きされます。"
+  },
   "toaster": {
   "toaster": {
     "grant_user_admin": "{{username}}を管理者に設定しました",
     "grant_user_admin": "{{username}}を管理者に設定しました",
     "revoke_user_admin": "{{username}}を管理者から外しました",
     "revoke_user_admin": "{{username}}を管理者から外しました",

+ 64 - 4
apps/app/public/static/locales/zh_CN/admin.json

@@ -10,6 +10,7 @@
   "Page": "页面",
   "Page": "页面",
   "Edit": "编辑",
   "Edit": "编辑",
   "Description": "描述",
   "Description": "描述",
+  "Execute": "执行",
   "last_login": "上次登录",
   "last_login": "上次登录",
   "wiki_management_homepage": "Wiki管理首页",
   "wiki_management_homepage": "Wiki管理首页",
   "public": "公共",
   "public": "公共",
@@ -17,6 +18,7 @@
   "specified_users": "仅指定用户",
   "specified_users": "仅指定用户",
   "only_me": "只有我",
   "only_me": "只有我",
   "only_inside_the_group": "仅组内",
   "only_inside_the_group": "仅组内",
+  "optional": "可选的",
   "security_settings": {
   "security_settings": {
     "security_settings": "安全设置",
     "security_settings": "安全设置",
     "scope_of_page_disclosure": "页面公开范围",
     "scope_of_page_disclosure": "页面公开范围",
@@ -54,9 +56,10 @@
 		"admin_and_author": "管理员|作者",
 		"admin_and_author": "管理员|作者",
 		"anyone": "任何人",
 		"anyone": "任何人",
     "user_homepage_deletion": {
     "user_homepage_deletion": {
-      "user_homepage_deletion": "删除用户页面",
-      "enable_user_homepage_deletion": "用户删除时,完全删除用户主页",
-      "desc": "删除用户时,用户主页及其下属页面也会被完全删除。"
+      "user_homepage_deletion": "删除用户主页",
+      "enable_user_homepage_deletion": "启用用户主页删除功能",
+      "enable_force_delete_user_homepage_on_user_deletion": "删除用户时,该用户的主页及其所有子页面将被完全删除",
+      "desc": "您可以删除已删除用户的主页。"
     },
     },
     "session": "会议",
     "session": "会议",
     "max_age": "有效期间  (msec)",
     "max_age": "有效期间  (msec)",
@@ -102,7 +105,6 @@
     "enable_link_sharing": "启用链接共享",
     "enable_link_sharing": "启用链接共享",
     "all_share_links": "所有共享链接",
     "all_share_links": "所有共享链接",
 		"configuration": " 配置",
 		"configuration": " 配置",
-		"optional": "可选的",
 		"Treat username matching as identical": "Automatically bind external accounts newly logged in to local accounts when <code>username</code> match",
 		"Treat username matching as identical": "Automatically bind external accounts newly logged in to local accounts when <code>username</code> match",
 		"Treat username matching as identical_warn": "WARNING: Be aware of security because the system treats the same user as a match of <code>username</code>.",
 		"Treat username matching as identical_warn": "WARNING: Be aware of security because the system treats the same user as a match of <code>username</code>.",
 		"Treat email matching as identical": "Automatically bind external accounts newly logged in to local accounts when <code>email</code> match",
 		"Treat email matching as identical": "Automatically bind external accounts newly logged in to local accounts when <code>email</code> match",
@@ -388,6 +390,13 @@
     "aws_label": "AWS(S3)",
     "aws_label": "AWS(S3)",
     "local_label": "Local",
     "local_label": "Local",
     "gridfs_label": "MongoDB(GridFS)",
     "gridfs_label": "MongoDB(GridFS)",
+    "azure_label": "Azure(Blob)",
+    "azure_tenant_id": "Tenant ID",
+    "azure_client_id": "Client ID",
+    "azure_client_secret": "Client Secret",
+    "azure_storage_account_name": "Storage Account Name",
+    "azure_storage_container_name": "Container Name",
+    "azure_note_for_the_only_env_option": "The Azure Settings is limited by the value of environment variable.<br>To change this setting, please change to false or delete the value of the environment variable <code>{{env}}</code> .",
     "fixed_by_env_var": "这是由env var 修复的 <code>{{key}}={{value}}</code>.",
     "fixed_by_env_var": "这是由env var 修复的 <code>{{key}}={{value}}</code>.",
     "file_upload": "This is for uploading file settings. If you complete file upload settings, file upload function, profile picture function etc will be enabled.",
     "file_upload": "This is for uploading file settings. If you complete file upload settings, file upload function, profile picture function etc will be enabled.",
     "test_connection": "测试邮件服务器连接",
     "test_connection": "测试邮件服务器连接",
@@ -1065,6 +1074,57 @@
     "error_generate_growi_archive": "Failed to generate GROWI archive file",
     "error_generate_growi_archive": "Failed to generate GROWI archive file",
     "error_send_growi_archive": "Failed to send GROWI archive file to the destination GROWI"
     "error_send_growi_archive": "Failed to send GROWI archive file to the destination GROWI"
   },
   },
+  "external_user_group": {
+    "management": "External Group Management",
+    "execute_sync": "Execute Sync",
+    "sync": "Sync",
+    "invalid_sync_settings": "Invalid sync settings",
+    "update_sync_settings_failed": "Failed to update sync settings",
+    "description_form_detail": "Please note that edited value will be overwritten on next sync if description mapper is set in sync settings",
+    "only_description_edit_allowed": "Only description can be edited for external user groups",
+    "sync_being_executed": "There is a running external group sync process started by you or another user. The next sync cannot be executed until this finishes.",
+    "sync_succeeded": "External group sync succeeded",
+    "sync_failed": "External group sync failed",
+    "provider": "Provider",
+    "confirmation_before_sync": "Confirmation before sync",
+    "execution_time_warning": "If the number of groups or users is large, it might take a while until sync finishes",
+    "parallel_sync_forbidden": "While sync is executing, you cannot execute a different external group sync",
+    "ldap": {
+      "group_sync_settings": "LDAP Group Sync Settings",
+      "group_search_base_DN": "Group Search Base DN",
+      "group_search_base_dn_detail": "The base DN for searching groups. The value set in security settings will be used if not set here.",
+      "membership_attribute": "Membership Attribute",
+      "membership_attribute_detail": "Attribute of the group object which indicates user membership info",
+      "membership_attribute_type": "Membership Attribute Type",
+      "membership_attribute_type_detail": "Whether membership attribute value is of type DN or UID",
+      "child_group_attribute": "Child Group Attribute",
+      "child_group_attribute_detail": "Attribute of the group object which indicates child group info. The attribute value needs to be the DN of the child group.",
+      "preserve_deleted_ldap_groups": "Preserve Deleted LDAP Groups",
+      "name_mapper_detail": "Attribute to map as group name",
+      "updated_group_sync_settings": "Updated LDAP group sync settings",
+      "password": "Password",
+      "password_detail": "Login password is necessary because Bind type is set to User Bind",
+      "auth_not_set": "Enable and configure LDAP auth in security settings before sync"
+    },
+    "keycloak": {
+      "group_sync_settings": "Keycloak Group Sync Settings",
+      "host": "Host",
+      "host_detail": "Keycloak host URL",
+      "group_realm": "Group Realm",
+      "group_realm_detail": "Realm that contains the groups to sync",
+      "group_sync_client_realm": "Realm of client used to request to Admin API",
+      "group_sync_client_realm_detail": "Realm that contains the client used to authenticate to request to Keycloak admin API",
+      "group_sync_client_id": "Client ID",
+      "group_sync_client_id_detail": "Id of the client used to authenticate to request to Keycloak admin API",
+      "group_sync_client_secret": "Client Secret",
+      "group_sync_client_secret_detail": "Id of the secret used to authenticate to request to Keycloak admin API",
+      "updated_group_sync_settings": "Updated Keycloak group sync settings",
+      "preserve_deleted_keycloak_groups": "Preserve Deleted Keycloak Groups",
+      "auth_not_set": "Enable and configure OIDC or SAML with Keycloak in security settings before sync"
+    },
+    "auto_generate_user_on_sync": "Auto Generate User on Sync",
+    "description_mapper_detail": "Attribute to map as group description. Description can be edited after sync. However, when a mapper is set, the edited value can possibly be overwritten by the next sync."
+  },
   "toaster": {
   "toaster": {
     "grant_user_admin": "Succeeded to grant {{username}} admin",
     "grant_user_admin": "Succeeded to grant {{username}} admin",
     "revoke_user_admin": "Succeeded to revoke {{username}} admin",
     "revoke_user_admin": "Succeeded to revoke {{username}} admin",

+ 1 - 1
apps/app/resource/search/mappings-es7.json

@@ -96,7 +96,7 @@
       "granted_users": {
       "granted_users": {
         "type": "keyword"
         "type": "keyword"
       },
       },
-      "granted_group": {
+      "granted_groups": {
         "type": "keyword"
         "type": "keyword"
       },
       },
       "created_at": {
       "created_at": {

+ 1 - 1
apps/app/resource/search/mappings-es8-for-ci.json

@@ -99,7 +99,7 @@
       "granted_users": {
       "granted_users": {
         "type": "keyword"
         "type": "keyword"
       },
       },
-      "granted_group": {
+      "granted_groups": {
         "type": "keyword"
         "type": "keyword"
       },
       },
       "created_at": {
       "created_at": {

+ 1 - 1
apps/app/resource/search/mappings-es8.json

@@ -96,7 +96,7 @@
       "granted_users": {
       "granted_users": {
         "type": "keyword"
         "type": "keyword"
       },
       },
-      "granted_group": {
+      "granted_groups": {
         "type": "keyword"
         "type": "keyword"
       },
       },
       "created_at": {
       "created_at": {

+ 78 - 0
apps/app/src/client/services/AdminAppContainer.js

@@ -60,6 +60,19 @@ export default class AdminAppContainer extends Container {
       s3SecretAccessKey: '',
       s3SecretAccessKey: '',
       s3ReferenceFileWithRelayMode: false,
       s3ReferenceFileWithRelayMode: false,
 
 
+      azureReferenceFileWithRelayMode: false,
+      azureUseOnlyEnvVars: false,
+      azureTenantId: '',
+      azureClientId: '',
+      azureClientSecret: '',
+      azureStorageAccountName: '',
+      azureStorageContainerName: '',
+      envAzureTenantId: '',
+      envAzureClientId: '',
+      envAzureClientSecret: '',
+      envAzureStorageAccountName: '',
+      envAzureStorageContainerName: '',
+
       isEnabledPlugins: true,
       isEnabledPlugins: true,
 
 
       isMaintenanceMode: false,
       isMaintenanceMode: false,
@@ -120,6 +133,20 @@ export default class AdminAppContainer extends Container {
       envGcsApiKeyJsonPath: appSettingsParams.envGcsApiKeyJsonPath,
       envGcsApiKeyJsonPath: appSettingsParams.envGcsApiKeyJsonPath,
       envGcsBucket: appSettingsParams.envGcsBucket,
       envGcsBucket: appSettingsParams.envGcsBucket,
       envGcsUploadNamespace: appSettingsParams.envGcsUploadNamespace,
       envGcsUploadNamespace: appSettingsParams.envGcsUploadNamespace,
+
+      azureUseOnlyEnvVars: appSettingsParams.azureUseOnlyEnvVars,
+      azureTenantId: appSettingsParams.azureTenantId,
+      azureClientId: appSettingsParams.azureClientId,
+      azureClientSecret: appSettingsParams.azureClientSecret,
+      azureStorageAccountName: appSettingsParams.azureStorageAccountName,
+      azureStorageContainerName: appSettingsParams.azureStorageContainerName,
+      azureReferenceFileWithRelayMode: appSettingsParams.azureReferenceFileWithRelayMode,
+      envAzureTenantId: appSettingsParams.envAzureTenantId,
+      envAzureClientId: appSettingsParams.envAzureClientId,
+      envAzureClientSecret: appSettingsParams.envAzureClientSecret,
+      envAzureStorageAccountName: appSettingsParams.envAzureStorageAccountName,
+      envAzureStorageContainerName: appSettingsParams.envAzureStorageContainerName,
+
       isEnabledPlugins: appSettingsParams.isEnabledPlugins,
       isEnabledPlugins: appSettingsParams.isEnabledPlugins,
       isMaintenanceMode: appSettingsParams.isMaintenanceMode,
       isMaintenanceMode: appSettingsParams.isMaintenanceMode,
     });
     });
@@ -316,6 +343,48 @@ export default class AdminAppContainer extends Container {
     this.setState({ gcsReferenceFileWithRelayMode });
     this.setState({ gcsReferenceFileWithRelayMode });
   }
   }
 
 
+  /**
+   * Change azureReferenceFileWithRelayMode
+   */
+  changeAzureReferenceFileWithRelayMode(azureReferenceFileWithRelayMode) {
+    this.setState({ azureReferenceFileWithRelayMode });
+  }
+
+  /**
+   * Change azureTenantId
+   */
+  changeAzureTenantId(azureTenantId) {
+    this.setState({ azureTenantId });
+  }
+
+  /**
+   * Change azureClientId
+   */
+  changeAzureClientId(azureClientId) {
+    this.setState({ azureClientId });
+  }
+
+  /**
+   * Change azureClientSecret
+   */
+  changeAzureClientSecret(azureClientSecret) {
+    this.setState({ azureClientSecret });
+  }
+
+  /**
+   * Change azureStorageAccountName
+   */
+  changeAzureStorageAccountName(azureStorageAccountName) {
+    this.setState({ azureStorageAccountName });
+  }
+
+  /**
+   * Change azureStorageContainerName
+   */
+  changeAzureStorageContainerName(azureStorageContainerName) {
+    this.setState({ azureStorageContainerName });
+  }
+
   /**
   /**
    * Update app setting
    * Update app setting
    * @memberOf AdminAppContainer
    * @memberOf AdminAppContainer
@@ -430,6 +499,15 @@ export default class AdminAppContainer extends Container {
       requestParams.s3ReferenceFileWithRelayMode = this.state.s3ReferenceFileWithRelayMode;
       requestParams.s3ReferenceFileWithRelayMode = this.state.s3ReferenceFileWithRelayMode;
     }
     }
 
 
+    if (fileUploadType === 'azure') {
+      requestParams.azureTenantId = this.state.azureTenantId;
+      requestParams.azureClientId = this.state.azureClientId;
+      requestParams.azureClientSecret = this.state.azureClientSecret;
+      requestParams.azureStorageAccountName = this.state.azureStorageAccountName;
+      requestParams.azureStorageContainerName = this.state.azureStorageContainerName;
+      requestParams.azureReferenceFileWithRelayMode = this.state.azureReferenceFileWithRelayMode;
+    }
+
     const response = await apiv3Put('/app-settings/file-upload-setting', requestParams);
     const response = await apiv3Put('/app-settings/file-upload-setting', requestParams);
     const { responseParams } = response.data;
     const { responseParams } = response.data;
     return this.setState(responseParams);
     return this.setState(responseParams);

+ 10 - 0
apps/app/src/client/services/AdminGeneralSecurityContainer.js

@@ -39,6 +39,7 @@ export default class AdminGeneralSecurityContainer extends Container {
       isShowRestrictedByOwner: false,
       isShowRestrictedByOwner: false,
       isShowRestrictedByGroup: false,
       isShowRestrictedByGroup: false,
       isUsersHomepageDeletionEnabled: false,
       isUsersHomepageDeletionEnabled: false,
+      isForceDeleteUserHomepageOnUserDeletion: false,
       isLocalEnabled: false,
       isLocalEnabled: false,
       isLdapEnabled: false,
       isLdapEnabled: false,
       isSamlEnabled: false,
       isSamlEnabled: false,
@@ -75,6 +76,7 @@ export default class AdminGeneralSecurityContainer extends Container {
       isShowRestrictedByOwner: !generalSetting.hideRestrictedByOwner,
       isShowRestrictedByOwner: !generalSetting.hideRestrictedByOwner,
       isShowRestrictedByGroup: !generalSetting.hideRestrictedByGroup,
       isShowRestrictedByGroup: !generalSetting.hideRestrictedByGroup,
       isUsersHomepageDeletionEnabled: generalSetting.isUsersHomepageDeletionEnabled,
       isUsersHomepageDeletionEnabled: generalSetting.isUsersHomepageDeletionEnabled,
+      isForceDeleteUserHomepageOnUserDeletion: generalSetting.isForceDeleteUserHomepageOnUserDeletion,
       sessionMaxAge: generalSetting.sessionMaxAge,
       sessionMaxAge: generalSetting.sessionMaxAge,
       wikiMode: generalSetting.wikiMode,
       wikiMode: generalSetting.wikiMode,
       disableLinkSharing: shareLinkSetting.disableLinkSharing,
       disableLinkSharing: shareLinkSetting.disableLinkSharing,
@@ -202,6 +204,13 @@ export default class AdminGeneralSecurityContainer extends Container {
     this.setState({ isUsersHomepageDeletionEnabled: !this.state.isUsersHomepageDeletionEnabled });
     this.setState({ isUsersHomepageDeletionEnabled: !this.state.isUsersHomepageDeletionEnabled });
   }
   }
 
 
+  /**
+   * Switch isForceDeleteUserHomepageOnUserDeletion
+   */
+  switchIsForceDeleteUserHomepageOnUserDeletion() {
+    this.setState({ isForceDeleteUserHomepageOnUserDeletion: !this.state.isForceDeleteUserHomepageOnUserDeletion });
+  }
+
   /**
   /**
    * Update restrictGuestMode
    * Update restrictGuestMode
    * @memberOf AdminGeneralSecuritySContainer
    * @memberOf AdminGeneralSecuritySContainer
@@ -219,6 +228,7 @@ export default class AdminGeneralSecurityContainer extends Container {
       hideRestrictedByGroup: !this.state.isShowRestrictedByGroup,
       hideRestrictedByGroup: !this.state.isShowRestrictedByGroup,
       hideRestrictedByOwner: !this.state.isShowRestrictedByOwner,
       hideRestrictedByOwner: !this.state.isShowRestrictedByOwner,
       isUsersHomepageDeletionEnabled: this.state.isUsersHomepageDeletionEnabled,
       isUsersHomepageDeletionEnabled: this.state.isUsersHomepageDeletionEnabled,
+      isForceDeleteUserHomepageOnUserDeletion: this.state.isForceDeleteUserHomepageOnUserDeletion,
     };
     };
 
 
     requestParams = await removeNullPropertyFromObject(requestParams);
     requestParams = await removeNullPropertyFromObject(requestParams);

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

@@ -44,12 +44,18 @@ export const useDrawioModalLauncherForView = (opts?: {
     const currentMarkdown = currentPage.revision.body;
     const currentMarkdown = currentPage.revision.body;
     const newMarkdown = mdu.replaceDrawioInMarkdown(drawioMxFile, currentMarkdown, bol, eol);
     const newMarkdown = mdu.replaceDrawioInMarkdown(drawioMxFile, currentMarkdown, bol, eol);
 
 
+    const grantUserGroupIds = currentPage.grantedGroups.map((g) => {
+      return {
+        type: g.type,
+        item: g.item._id,
+      };
+    });
+
     const optionsToSave: OptionsToSave = {
     const optionsToSave: OptionsToSave = {
       isSlackEnabled: false,
       isSlackEnabled: false,
       slackChannels: '',
       slackChannels: '',
       grant: currentPage.grant,
       grant: currentPage.grant,
-      grantUserGroupId: currentPage.grantedGroup?._id,
-      grantUserGroupName: currentPage.grantedGroup?.name,
+      grantUserGroupIds,
       pageTags: tagsInfo.tags,
       pageTags: tagsInfo.tags,
     };
     };
 
 

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

@@ -43,12 +43,18 @@ export const useHandsontableModalLauncherForView = (opts?: {
     const currentMarkdown = currentPage.revision.body;
     const currentMarkdown = currentPage.revision.body;
     const newMarkdown = mtu.replaceMarkdownTableInMarkdown(table, currentMarkdown, bol, eol);
     const newMarkdown = mtu.replaceMarkdownTableInMarkdown(table, currentMarkdown, bol, eol);
 
 
+    const grantUserGroupIds = currentPage.grantedGroups.map((g) => {
+      return {
+        type: g.type,
+        item: g.item._id,
+      };
+    });
+
     const optionsToSave: OptionsToSave = {
     const optionsToSave: OptionsToSave = {
       isSlackEnabled: false,
       isSlackEnabled: false,
       slackChannels: '',
       slackChannels: '',
       grant: currentPage.grant,
       grant: currentPage.grant,
-      grantUserGroupId: currentPage.grantedGroup?._id,
-      grantUserGroupName: currentPage.grantedGroup?.name,
+      grantUserGroupIds,
       pageTags: tagsInfo.tags,
       pageTags: tagsInfo.tags,
     };
     };
 
 

+ 211 - 0
apps/app/src/components/Admin/App/AzureSetting.tsx

@@ -0,0 +1,211 @@
+import { useTranslation } from 'next-i18next';
+
+import MaskedInput from './MaskedInput';
+
+export type AzureSettingMoleculeProps = {
+  azureReferenceFileWithRelayMode
+  azureUseOnlyEnvVars
+  azureTenantId
+  azureClientId
+  azureClientSecret
+  azureStorageAccountName
+  azureStorageContainerName
+  envAzureTenantId?
+  envAzureClientId?
+  envAzureClientSecret?
+  envAzureStorageAccountName?
+  envAzureStorageContainerName?
+  onChangeAzureReferenceFileWithRelayMode: (val: boolean) => void
+  onChangeAzureTenantId: (val: string) => void
+  onChangeAzureClientId: (val: string) => void
+  onChangeAzureClientSecret: (val: string) => void
+  onChangeAzureStorageAccountName: (val: string) => void
+  onChangeAzureStorageContainerName: (val: string) => void
+};
+
+export const AzureSettingMolecule = (props: AzureSettingMoleculeProps): JSX.Element => {
+  const { t } = useTranslation();
+
+  const {
+    azureReferenceFileWithRelayMode,
+    azureUseOnlyEnvVars,
+    azureTenantId,
+    azureClientId,
+    azureClientSecret,
+    azureStorageAccountName,
+    envAzureTenantId,
+    envAzureClientId,
+    envAzureClientSecret,
+    envAzureStorageAccountName,
+    azureStorageContainerName,
+    envAzureStorageContainerName,
+  } = props;
+
+  return (
+    <>
+
+      <div className="row form-group my-3">
+        <label className="text-left text-md-right col-md-3 col-form-label">
+          {t('admin:app_setting.file_delivery_method')}
+        </label>
+
+        <div className="col-md-6">
+          <div className="dropdown">
+            <button
+              className="btn btn-outline-secondary dropdown-toggle"
+              type="button"
+              id="ddAzureReferenceFileWithRelayMode"
+              data-toggle="dropdown"
+              aria-haspopup="true"
+              aria-expanded="true"
+            >
+              {azureReferenceFileWithRelayMode && t('admin:app_setting.file_delivery_method_relay')}
+              {!azureReferenceFileWithRelayMode && t('admin:app_setting.file_delivery_method_redirect')}
+            </button>
+            <div className="dropdown-menu" aria-labelledby="ddAzureReferenceFileWithRelayMode">
+              <button
+                className="dropdown-item"
+                type="button"
+                onClick={() => { props?.onChangeAzureReferenceFileWithRelayMode(true) }}
+              >
+                {t('admin:app_setting.file_delivery_method_relay')}
+              </button>
+              <button
+                className="dropdown-item"
+                type="button"
+                onClick={() => { props?.onChangeAzureReferenceFileWithRelayMode(false) }}
+              >
+                { t('admin:app_setting.file_delivery_method_redirect')}
+              </button>
+            </div>
+
+            <p className="form-text text-muted small">
+              {t('admin:app_setting.file_delivery_method_redirect_info')}
+              <br />
+              {t('admin:app_setting.file_delivery_method_relay_info')}
+            </p>
+          </div>
+        </div>
+      </div>
+
+      {azureUseOnlyEnvVars && (
+        <p
+          className="alert alert-info"
+          // eslint-disable-next-line react/no-danger
+          dangerouslySetInnerHTML={{ __html: t('admin:app_setting.azure_note_for_the_only_env_option', { env: 'AZURE_USES_ONLY_ENV_VARS_FOR_SOME_OPTIONS' }) }}
+        />
+      )}
+      <table className={`table settings-table ${azureUseOnlyEnvVars && 'use-only-env-vars'}`}>
+        <colgroup>
+          <col className="item-name" />
+          <col className="from-db" />
+          <col className="from-env-vars" />
+        </colgroup>
+        <thead>
+          <tr>
+            <th></th>
+            <th>Database</th>
+            <th>Environment variables</th>
+          </tr>
+        </thead>
+        <tbody>
+          <tr>
+            <th>{t('admin:app_setting.azure_tenant_id')}</th>
+            <td>
+              <MaskedInput
+                name="azureTenantId"
+                readOnly={azureUseOnlyEnvVars}
+                defaultValue={azureTenantId}
+                onChange={e => props?.onChangeAzureTenantId(e.target.value)}
+              />
+            </td>
+            <td>
+              <MaskedInput name="envAzureTenantId" defaultValue={envAzureTenantId || ''} readOnly tabIndex={-1} />
+              <p className="form-text text-muted">
+                {/* eslint-disable-next-line react/no-danger */}
+                <small dangerouslySetInnerHTML={{ __html: t('admin:app_setting.use_env_var_if_empty', { variable: 'AZURE_TENANT_ID' }) }} />
+              </p>
+            </td>
+          </tr>
+          <tr>
+            <th>{t('admin:app_setting.azure_client_id')}</th>
+            <td>
+              <MaskedInput
+                name="azureClientId"
+                readOnly={azureUseOnlyEnvVars}
+                defaultValue={azureClientId}
+                onChange={e => props?.onChangeAzureClientId(e.target.value)}
+              />
+            </td>
+            <td>
+              <MaskedInput name="envAzureClientId" defaultValue={envAzureClientId || ''} readOnly tabIndex={-1} />
+              <p className="form-text text-muted">
+                {/* eslint-disable-next-line react/no-danger */}
+                <small dangerouslySetInnerHTML={{ __html: t('admin:app_setting.use_env_var_if_empty', { variable: 'AZURE_CLIENT_ID' }) }} />
+              </p>
+            </td>
+          </tr>
+          <tr>
+            <th>{t('admin:app_setting.azure_client_secret')}</th>
+            <td>
+              <MaskedInput
+                name="azureClientSecret"
+                readOnly={azureUseOnlyEnvVars}
+                defaultValue={azureClientSecret}
+                onChange={e => props?.onChangeAzureClientSecret(e.target.value)}
+              />
+            </td>
+            <td>
+              <MaskedInput name="envAzureClientSecret" defaultValue={envAzureClientSecret || ''} readOnly tabIndex={-1} />
+              <p className="form-text text-muted">
+                {/* eslint-disable-next-line react/no-danger */}
+                <small dangerouslySetInnerHTML={{ __html: t('admin:app_setting.use_env_var_if_empty', { variable: 'AZURE_CLIENT_SECRET' }) }} />
+              </p>
+            </td>
+          </tr>
+          <tr>
+            <th>{t('admin:app_setting.azure_storage_account_name')}</th>
+            <td>
+              <input
+                className="form-control"
+                type="text"
+                name="azureStorageAccountName"
+                readOnly={azureUseOnlyEnvVars}
+                defaultValue={azureStorageAccountName}
+                onChange={e => props?.onChangeAzureStorageAccountName(e.target.value)}
+              />
+            </td>
+            <td>
+              <input className="form-control" type="text" value={envAzureStorageAccountName || ''} readOnly tabIndex={-1} />
+              <p className="form-text text-muted">
+                {/* eslint-disable-next-line react/no-danger */}
+                <small dangerouslySetInnerHTML={{ __html: t('admin:app_setting.use_env_var_if_empty', { variable: 'AZURE_STORAGE_ACCOUNT_NAME' }) }} />
+              </p>
+            </td>
+          </tr>
+          <tr>
+            <th>{t('admin:app_setting.azure_storage_container_name')}</th>
+            <td>
+              <input
+                className="form-control"
+                type="text"
+                name="azureStorageContainerName"
+                readOnly={azureUseOnlyEnvVars}
+                defaultValue={azureStorageContainerName}
+                onChange={e => props?.onChangeAzureStorageContainerName(e.target.value)}
+              />
+            </td>
+            <td>
+              <input className="form-control" type="text" value={envAzureStorageContainerName || ''} readOnly tabIndex={-1} />
+              <p className="form-text text-muted">
+                {/* eslint-disable-next-line react/no-danger */}
+                <small dangerouslySetInnerHTML={{ __html: t('admin:app_setting.use_env_var_if_empty', { variable: 'AZURE_STORAGE_CONTAINER_NAME' }) }} />
+              </p>
+            </td>
+          </tr>
+        </tbody>
+      </table>
+
+    </>
+  );
+};

+ 75 - 2
apps/app/src/components/Admin/App/FileUploadSetting.tsx

@@ -10,17 +10,20 @@ import AdminUpdateButtonRow from '../Common/AdminUpdateButtonRow';
 
 
 import { AwsSettingMolecule } from './AwsSetting';
 import { AwsSettingMolecule } from './AwsSetting';
 import type { AwsSettingMoleculeProps } from './AwsSetting';
 import type { AwsSettingMoleculeProps } from './AwsSetting';
+import { AzureSettingMolecule } from './AzureSetting';
+import type { AzureSettingMoleculeProps } from './AzureSetting';
 import { GcsSettingMolecule } from './GcsSetting';
 import { GcsSettingMolecule } from './GcsSetting';
 import type { GcsSettingMoleculeProps } from './GcsSetting';
 import type { GcsSettingMoleculeProps } from './GcsSetting';
 
 
-const fileUploadTypes = ['aws', 'gcs', 'gridfs', 'local'] as const;
+
+const fileUploadTypes = ['aws', 'gcs', 'azure', 'gridfs', 'local'] as const;
 
 
 type FileUploadSettingMoleculeProps = {
 type FileUploadSettingMoleculeProps = {
   fileUploadType: string
   fileUploadType: string
   isFixedFileUploadByEnvVar: boolean
   isFixedFileUploadByEnvVar: boolean
   envFileUploadType?: string
   envFileUploadType?: string
   onChangeFileUploadType: (e: ChangeEvent, type: string) => void
   onChangeFileUploadType: (e: ChangeEvent, type: string) => void
-} & AwsSettingMoleculeProps & GcsSettingMoleculeProps;
+} & AwsSettingMoleculeProps & GcsSettingMoleculeProps & AzureSettingMoleculeProps;
 
 
 export const FileUploadSettingMolecule = React.memo((props: FileUploadSettingMoleculeProps): JSX.Element => {
 export const FileUploadSettingMolecule = React.memo((props: FileUploadSettingMoleculeProps): JSX.Element => {
   const { t } = useTranslation(['admin', 'commons']);
   const { t } = useTranslation(['admin', 'commons']);
@@ -102,6 +105,28 @@ export const FileUploadSettingMolecule = React.memo((props: FileUploadSettingMol
           onChangeGcsUploadNamespace={props.onChangeGcsUploadNamespace}
           onChangeGcsUploadNamespace={props.onChangeGcsUploadNamespace}
         />
         />
       )}
       )}
+      {props.fileUploadType === 'azure' && (
+        <AzureSettingMolecule
+          azureReferenceFileWithRelayMode={props.azureReferenceFileWithRelayMode}
+          azureUseOnlyEnvVars={props.azureUseOnlyEnvVars}
+          azureTenantId={props.azureTenantId}
+          azureClientId={props.azureClientId}
+          azureClientSecret={props.azureClientSecret}
+          azureStorageAccountName={props.azureStorageAccountName}
+          azureStorageContainerName={props.azureStorageContainerName}
+          envAzureStorageAccountName={props.envAzureStorageAccountName}
+          envAzureStorageContainerName={props.envAzureStorageContainerName}
+          envAzureTenantId={props.envAzureTenantId}
+          envAzureClientId={props.envAzureClientId}
+          envAzureClientSecret={props.envAzureClientSecret}
+          onChangeAzureReferenceFileWithRelayMode={props.onChangeAzureReferenceFileWithRelayMode}
+          onChangeAzureTenantId={props.onChangeAzureTenantId}
+          onChangeAzureClientId={props.onChangeAzureClientId}
+          onChangeAzureClientSecret={props.onChangeAzureClientSecret}
+          onChangeAzureStorageAccountName={props.onChangeAzureStorageAccountName}
+          onChangeAzureStorageContainerName={props.onChangeAzureStorageContainerName}
+        />
+      )}
     </>
     </>
   );
   );
 });
 });
@@ -124,6 +149,11 @@ const FileUploadSetting = (props: FileUploadSettingProps): JSX.Element => {
     gcsReferenceFileWithRelayMode, gcsUseOnlyEnvVars,
     gcsReferenceFileWithRelayMode, gcsUseOnlyEnvVars,
     gcsApiKeyJsonPath, envGcsApiKeyJsonPath, gcsBucket,
     gcsApiKeyJsonPath, envGcsApiKeyJsonPath, gcsBucket,
     envGcsBucket, gcsUploadNamespace, envGcsUploadNamespace,
     envGcsBucket, gcsUploadNamespace, envGcsUploadNamespace,
+    azureReferenceFileWithRelayMode, azureUseOnlyEnvVars,
+    azureTenantId, azureClientId, azureClientSecret,
+    azureStorageAccountName, azureStorageContainerName,
+    envAzureTenantId, envAzureClientId, envAzureClientSecret,
+    envAzureStorageAccountName, envAzureStorageContainerName,
   } = adminAppContainer.state;
   } = adminAppContainer.state;
 
 
   const submitHandler = useCallback(async() => {
   const submitHandler = useCallback(async() => {
@@ -182,6 +212,31 @@ const FileUploadSetting = (props: FileUploadSettingProps): JSX.Element => {
     adminAppContainer.changeGcsUploadNamespace(val);
     adminAppContainer.changeGcsUploadNamespace(val);
   }, [adminAppContainer]);
   }, [adminAppContainer]);
 
 
+  // Azure
+  const onChangeAzureReferenceFileWithRelayModeHandler = useCallback((val: boolean) => {
+    adminAppContainer.changeAzureReferenceFileWithRelayMode(val);
+  }, [adminAppContainer]);
+
+  const onChangeAzureTenantIdHandler = useCallback((val: string) => {
+    adminAppContainer.changeAzureTenantId(val);
+  }, [adminAppContainer]);
+
+  const onChangeAzureClientIdHandler = useCallback((val: string) => {
+    adminAppContainer.changeAzureClientId(val);
+  }, [adminAppContainer]);
+
+  const onChangeAzureClientSecretHandler = useCallback((val: string) => {
+    adminAppContainer.changeAzureClientSecret(val);
+  }, [adminAppContainer]);
+
+  const onChangeAzureStorageAccountNameHandler = useCallback((val: string) => {
+    adminAppContainer.changeAzureStorageAccountName(val);
+  }, [adminAppContainer]);
+
+  const onChangeAzureStorageContainerNameHandler = useCallback((val: string) => {
+    adminAppContainer.changeAzureStorageContainerName(val);
+  }, [adminAppContainer]);
+
   return (
   return (
     <>
     <>
       <FileUploadSettingMolecule
       <FileUploadSettingMolecule
@@ -213,6 +268,24 @@ const FileUploadSetting = (props: FileUploadSettingProps): JSX.Element => {
         onChangeGcsApiKeyJsonPath={onChangeGcsApiKeyJsonPathHandler}
         onChangeGcsApiKeyJsonPath={onChangeGcsApiKeyJsonPathHandler}
         onChangeGcsBucket={onChangeGcsBucketHandler}
         onChangeGcsBucket={onChangeGcsBucketHandler}
         onChangeGcsUploadNamespace={onChangeGcsUploadNamespaceHandler}
         onChangeGcsUploadNamespace={onChangeGcsUploadNamespaceHandler}
+        azureReferenceFileWithRelayMode={azureReferenceFileWithRelayMode}
+        azureUseOnlyEnvVars={azureUseOnlyEnvVars}
+        azureTenantId={azureTenantId}
+        azureClientId={azureClientId}
+        azureClientSecret={azureClientSecret}
+        azureStorageAccountName={azureStorageAccountName}
+        azureStorageContainerName={azureStorageContainerName}
+        envAzureTenantId={envAzureTenantId}
+        envAzureClientId={envAzureClientId}
+        envAzureClientSecret={envAzureClientSecret}
+        envAzureStorageAccountName={envAzureStorageAccountName}
+        envAzureStorageContainerName={envAzureStorageContainerName}
+        onChangeAzureReferenceFileWithRelayMode={onChangeAzureReferenceFileWithRelayModeHandler}
+        onChangeAzureTenantId={onChangeAzureTenantIdHandler}
+        onChangeAzureClientId={onChangeAzureClientIdHandler}
+        onChangeAzureClientSecret={onChangeAzureClientSecretHandler}
+        onChangeAzureStorageAccountName={onChangeAzureStorageAccountNameHandler}
+        onChangeAzureStorageContainerName={onChangeAzureStorageContainerNameHandler}
       />
       />
       <AdminUpdateButtonRow onClick={submitHandler} disabled={retrieveError != null} />
       <AdminUpdateButtonRow onClick={submitHandler} disabled={retrieveError != null} />
     </>
     </>

+ 12 - 0
apps/app/src/components/Admin/App/MaskedInput.module.scss

@@ -0,0 +1,12 @@
+.MaskedInput {
+  position: relative;
+  display: flex;
+}
+
+.PasswordReveal {
+  position: absolute;
+  top: 0rem;
+  right: 0.5rem;
+  left: auto;
+  font-size: 1.4rem;
+}

+ 43 - 0
apps/app/src/components/Admin/App/MaskedInput.tsx

@@ -0,0 +1,43 @@
+import { useState } from 'react';
+
+import styles from './MaskedInput.module.scss';
+
+type Props = {
+  name: string
+  readOnly: boolean
+  defaultValue: string
+  onChange?: (e: any) => void
+  tabIndex?: number | undefined
+};
+
+export default function MaskedInput(props: Props): JSX.Element {
+  const [passwordShown, setPasswordShown] = useState(false);
+  const togglePassword = () => {
+    setPasswordShown(!passwordShown);
+  };
+
+  const {
+    name, readOnly, defaultValue, onChange, tabIndex,
+  } = props;
+
+  return (
+    <div className={styles.MaskedInput}>
+      <input
+        className="form-control"
+        type={passwordShown ? 'text' : 'password'}
+        name={name}
+        readOnly={readOnly}
+        defaultValue={defaultValue}
+        onChange={onChange}
+        tabIndex={tabIndex}
+      />
+      <span onClick={togglePassword} className={styles.PasswordReveal}>
+        {passwordShown ? (
+          <i className="fa fa-eye" />
+        ) : (
+          <i className="fa fa-eye-slash" />
+        )}
+      </span>
+    </div>
+  );
+}

+ 3 - 3
apps/app/src/components/Admin/Customize/CustomizePresentationSetting.tsx

@@ -17,9 +17,8 @@ type Props = {
 const CustomizePresentationSetting = (props: Props): JSX.Element => {
 const CustomizePresentationSetting = (props: Props): JSX.Element => {
   const { adminCustomizeContainer } = props;
   const { adminCustomizeContainer } = props;
 
 
-  console.log(adminCustomizeContainer);
-
   const { t } = useTranslation();
   const { t } = useTranslation();
+
   const onClickSubmit = useCallback(async() => {
   const onClickSubmit = useCallback(async() => {
     try {
     try {
       await adminCustomizeContainer.updateCustomizePresentation();
       await adminCustomizeContainer.updateCustomizePresentation();
@@ -28,7 +27,8 @@ const CustomizePresentationSetting = (props: Props): JSX.Element => {
     catch (err) {
     catch (err) {
       toastError(err);
       toastError(err);
     }
     }
-  }, [adminCustomizeContainer]);
+  }, [adminCustomizeContainer, t]);
+
   return (
   return (
     <React.Fragment>
     <React.Fragment>
       <h2 className="admin-setting-header">{t('admin:customize_settings.custom_presentation')}</h2>
       <h2 className="admin-setting-header">{t('admin:customize_settings.custom_presentation')}</h2>

+ 3 - 2
apps/app/src/components/Admin/ElasticsearchManagement/RebuildIndexControls.jsx

@@ -3,6 +3,7 @@ import React from 'react';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
 
 
+import { SocketEventName } from '~/interfaces/websocket';
 import { useAdminSocket } from '~/stores/socket-io';
 import { useAdminSocket } from '~/stores/socket-io';
 
 
 import LabeledProgressBar from '../Common/LabeledProgressBar';
 import LabeledProgressBar from '../Common/LabeledProgressBar';
@@ -27,7 +28,7 @@ class RebuildIndexControls extends React.Component {
     const { socket } = this.props;
     const { socket } = this.props;
 
 
     if (socket != null) {
     if (socket != null) {
-      socket.on('addPageProgress', (data) => {
+      socket.on(SocketEventName.AddPageProgress, (data) => {
         this.setState({
         this.setState({
           total: data.totalCount,
           total: data.totalCount,
           current: data.count,
           current: data.count,
@@ -35,7 +36,7 @@ class RebuildIndexControls extends React.Component {
         });
         });
       });
       });
 
 
-      socket.on('finishAddPage', (data) => {
+      socket.on(SocketEventName.FinishAddPage, (data) => {
         this.setState({
         this.setState({
           total: data.totalCount,
           total: data.totalCount,
           current: data.count,
           current: data.count,

+ 2 - 2
apps/app/src/components/Admin/Security/LdapSecuritySettingContents.jsx

@@ -235,7 +235,7 @@ class LdapSecuritySettingContents extends React.Component {
             </div>
             </div>
 
 
             <h3 className="alert-anchor border-bottom">
             <h3 className="alert-anchor border-bottom">
-              Attribute Mapping ({t('security_settings.optional')})
+              Attribute Mapping ({t('optional')})
             </h3>
             </h3>
 
 
             <div className="form-group row">
             <div className="form-group row">
@@ -325,7 +325,7 @@ class LdapSecuritySettingContents extends React.Component {
 
 
 
 
             <h3 className="alert-anchor border-bottom">
             <h3 className="alert-anchor border-bottom">
-              {t('security_settings.ldap.group_search_filter')} ({t('security_settings.optional')})
+              {t('security_settings.ldap.group_search_filter')} ({t('optional')})
             </h3>
             </h3>
 
 
             <div className="form-group row">
             <div className="form-group row">

+ 1 - 1
apps/app/src/components/Admin/Security/OidcSecuritySettingContents.jsx

@@ -296,7 +296,7 @@ class OidcSecurityManagementContents extends React.Component {
             </div>
             </div>
 
 
             <h3 className="alert-anchor border-bottom">
             <h3 className="alert-anchor border-bottom">
-              Attribute Mapping ({t('security_settings.optional')})
+              Attribute Mapping ({t('optional')})
             </h3>
             </h3>
 
 
             <div className="row mb-5 form-group">
             <div className="row mb-5 form-group">

+ 14 - 1
apps/app/src/components/Admin/Security/SecuritySetting.jsx

@@ -468,8 +468,21 @@ class SecuritySetting extends React.Component {
                 {t('security_settings.user_homepage_deletion.enable_user_homepage_deletion')}
                 {t('security_settings.user_homepage_deletion.enable_user_homepage_deletion')}
               </label>
               </label>
             </div>
             </div>
+            <div className="custom-control custom-switch custom-checkbox-success mt-2">
+              <input
+                type="checkbox"
+                className="form-check-input"
+                id="is-force-delete-user-homepage-on-user-deletion"
+                checked={adminGeneralSecurityContainer.state.isForceDeleteUserHomepageOnUserDeletion}
+                onChange={() => { adminGeneralSecurityContainer.switchIsForceDeleteUserHomepageOnUserDeletion() }}
+                disabled={!adminGeneralSecurityContainer.state.isUsersHomepageDeletionEnabled}
+              />
+              <label className="form-check-label" htmlFor="is-force-delete-user-homepage-on-user-deletion">
+                {t('security_settings.user_homepage_deletion.enable_force_delete_user_homepage_on_user_deletion')}
+              </label>
+            </div>
             <p
             <p
-              className="form-text text-muted small"
+              className="form-text text-muted small mt-2"
               dangerouslySetInnerHTML={{ __html: t('security_settings.user_homepage_deletion.desc') }}
               dangerouslySetInnerHTML={{ __html: t('security_settings.user_homepage_deletion.desc') }}
             />
             />
           </div>
           </div>

+ 22 - 10
apps/app/src/components/Admin/UserGroup/UserGroupForm.tsx

@@ -1,4 +1,6 @@
-import React, { FC, useCallback, useState } from 'react';
+import React, {
+  FC, useCallback, useEffect, useState,
+} from 'react';
 
 
 import type { IUserGroupHasId } from '@growi/core';
 import type { IUserGroupHasId } from '@growi/core';
 import dateFnsFormat from 'date-fns/format';
 import dateFnsFormat from 'date-fns/format';
@@ -10,6 +12,7 @@ type Props = {
   selectableParentUserGroups?: IUserGroupHasId[],
   selectableParentUserGroups?: IUserGroupHasId[],
   submitButtonLabel: string;
   submitButtonLabel: string;
   onSubmit: (targetGroup: IUserGroupHasId, userGroupData: Partial<IUserGroupHasId>) => Promise<void>
   onSubmit: (targetGroup: IUserGroupHasId, userGroupData: Partial<IUserGroupHasId>) => Promise<void>
+  isExternalGroup?: boolean
 };
 };
 
 
 export const UserGroupForm: FC<Props> = (props: Props) => {
 export const UserGroupForm: FC<Props> = (props: Props) => {
@@ -17,14 +20,14 @@ export const UserGroupForm: FC<Props> = (props: Props) => {
   const { t } = useTranslation('admin');
   const { t } = useTranslation('admin');
 
 
   const {
   const {
-    userGroup, parentUserGroup, selectableParentUserGroups, submitButtonLabel, onSubmit,
+    userGroup, parentUserGroup, selectableParentUserGroups, submitButtonLabel, onSubmit, isExternalGroup = false,
   } = props;
   } = props;
   /*
   /*
    * State
    * State
    */
    */
   const [currentName, setName] = useState<string>(userGroup.name);
   const [currentName, setName] = useState<string>(userGroup.name);
   const [currentDescription, setDescription] = useState<string>(userGroup.description);
   const [currentDescription, setDescription] = useState<string>(userGroup.description);
-  const [selectedParent, setSelectedParent] = useState<IUserGroupHasId | undefined>(parentUserGroup);
+  const [selectedParent, setSelectedParent] = useState<IUserGroupHasId | undefined>();
   /*
   /*
    * Function
    * Function
    */
    */
@@ -36,12 +39,16 @@ export const UserGroupForm: FC<Props> = (props: Props) => {
     setDescription(e.target.value);
     setDescription(e.target.value);
   }, []);
   }, []);
 
 
-  const onChangeParerentButtonHandler = useCallback((userGroup: IUserGroupHasId) => {
+  const onChangeParentButtonHandler = useCallback((userGroup: IUserGroupHasId) => {
     if (userGroup._id !== selectedParent?._id) {
     if (userGroup._id !== selectedParent?._id) {
       setSelectedParent(userGroup);
       setSelectedParent(userGroup);
     }
     }
   }, [selectedParent, setSelectedParent]);
   }, [selectedParent, setSelectedParent]);
 
 
+  useEffect(() => {
+    setSelectedParent(parentUserGroup);
+  }, [parentUserGroup]);
+
   const isSelectableParentUserGroups = selectableParentUserGroups != null && selectableParentUserGroups.length > 0;
   const isSelectableParentUserGroups = selectableParentUserGroups != null && selectableParentUserGroups.length > 0;
 
 
   const isChildUserGroup = parentUserGroup !== undefined;
   const isChildUserGroup = parentUserGroup !== undefined;
@@ -60,7 +67,12 @@ export const UserGroupForm: FC<Props> = (props: Props) => {
 
 
       <fieldset>
       <fieldset>
         <h2 className="admin-setting-header">{t('user_group_management.basic_info')}</h2>
         <h2 className="admin-setting-header">{t('user_group_management.basic_info')}</h2>
-
+        {isExternalGroup
+        && (
+          <div className="mb-3">
+            <small className="text-muted">{t('external_user_group.only_description_edit_allowed')}</small>
+          </div>
+        )}
         {
         {
           userGroup?.createdAt != null && (
           userGroup?.createdAt != null && (
             <div className="form-group row">
             <div className="form-group row">
@@ -74,7 +86,7 @@ export const UserGroupForm: FC<Props> = (props: Props) => {
           <label htmlFor="name" className="col-md-2 col-form-label">
           <label htmlFor="name" className="col-md-2 col-form-label">
             {t('user_group_management.group_name')}
             {t('user_group_management.group_name')}
           </label>
           </label>
-          <div className="col-md-4">
+          <div className="col-md-4 my-auto">
             <input
             <input
               className="form-control"
               className="form-control"
               type="text"
               type="text"
@@ -83,6 +95,7 @@ export const UserGroupForm: FC<Props> = (props: Props) => {
               value={currentName}
               value={currentName}
               onChange={onChangeNameHandler}
               onChange={onChangeNameHandler}
               required
               required
+              disabled={isExternalGroup}
             />
             />
           </div>
           </div>
         </div>
         </div>
@@ -105,9 +118,8 @@ export const UserGroupForm: FC<Props> = (props: Props) => {
               type="button"
               type="button"
               id="dropdownMenuButton"
               id="dropdownMenuButton"
               data-toggle="dropdown"
               data-toggle="dropdown"
-              className={`
-                btn btn-outline-secondary dropdown-toggle mb-3 ${isSelectableParentUserGroups ? '' : 'disabled'}
-              `}
+              className="btn btn-outline-secondary dropdown-toggle mb-3"
+              disabled={isExternalGroup || !isSelectableParentUserGroups}
             >
             >
               {selectedParent?.name ?? messageAtReleaseParentGroup}
               {selectedParent?.name ?? messageAtReleaseParentGroup}
             </button>
             </button>
@@ -121,7 +133,7 @@ export const UserGroupForm: FC<Props> = (props: Props) => {
                           key={userGroup._id}
                           key={userGroup._id}
                           type="button"
                           type="button"
                           className={`dropdown-item ${selectedParent?._id === userGroup._id ? 'active' : ''}`}
                           className={`dropdown-item ${selectedParent?._id === userGroup._id ? 'active' : ''}`}
-                          onClick={() => onChangeParerentButtonHandler(userGroup)}
+                          onClick={() => onChangeParentButtonHandler(userGroup)}
                         >
                         >
                           {userGroup.name}
                           {userGroup.name}
                         </button>
                         </button>

+ 10 - 1
apps/app/src/components/Admin/UserGroup/UserGroupModal.tsx

@@ -14,6 +14,7 @@ type Props = {
   onClickSubmit?: (userGroupData: Partial<IUserGroupHasId>) => Promise<IUserGroupHasId | void>
   onClickSubmit?: (userGroupData: Partial<IUserGroupHasId>) => Promise<IUserGroupHasId | void>
   isShow?: boolean
   isShow?: boolean
   onHide?: () => Promise<void> | void
   onHide?: () => Promise<void> | void
+  isExternalGroup?: boolean
 };
 };
 
 
 export const UserGroupModal: FC<Props> = (props: Props) => {
 export const UserGroupModal: FC<Props> = (props: Props) => {
@@ -21,7 +22,7 @@ export const UserGroupModal: FC<Props> = (props: Props) => {
   const { t } = useTranslation('admin');
   const { t } = useTranslation('admin');
 
 
   const {
   const {
-    userGroup, buttonLabel, onClickSubmit, isShow, onHide,
+    userGroup, buttonLabel, onClickSubmit, isShow, onHide, isExternalGroup = false,
   } = props;
   } = props;
 
 
   /*
   /*
@@ -86,6 +87,7 @@ export const UserGroupModal: FC<Props> = (props: Props) => {
               value={currentName}
               value={currentName}
               onChange={onChangeNameHandler}
               onChange={onChangeNameHandler}
               required
               required
+              disabled={isExternalGroup}
             />
             />
           </div>
           </div>
 
 
@@ -94,6 +96,13 @@ export const UserGroupModal: FC<Props> = (props: Props) => {
               {t('Description')}
               {t('Description')}
             </label>
             </label>
             <textarea className="form-control" name="description" value={currentDescription} onChange={onChangeDescriptionHandler} />
             <textarea className="form-control" name="description" value={currentDescription} onChange={onChangeDescriptionHandler} />
+            {isExternalGroup && (
+              <p className="form-text text-muted">
+                <small>
+                  {t('external_user_group.description_form_detail')}
+                </small>
+              </p>
+            )}
           </div>
           </div>
 
 
           {/* TODO 90732: Add a drop-down to show selectable parents */}
           {/* TODO 90732: Add a drop-down to show selectable parents */}

+ 6 - 0
apps/app/src/components/Admin/UserGroup/UserGroupPage.tsx

@@ -6,9 +6,11 @@ import { useTranslation } from 'react-i18next';
 
 
 import { apiv3Delete, apiv3Post, apiv3Put } from '~/client/util/apiv3-client';
 import { apiv3Delete, apiv3Post, apiv3Put } from '~/client/util/apiv3-client';
 import { toastSuccess, toastError } from '~/client/util/toastr';
 import { toastSuccess, toastError } from '~/client/util/toastr';
+import { ExternalGroupManagement } from '~/features/external-user-group/client/components/ExternalUserGroup/ExternalUserGroupManagement';
 import { useIsAclEnabled } from '~/stores/context';
 import { useIsAclEnabled } from '~/stores/context';
 import { useSWRxUserGroupList, useSWRxChildUserGroupList, useSWRxUserGroupRelationList } from '~/stores/user-group';
 import { useSWRxUserGroupList, useSWRxChildUserGroupList, useSWRxUserGroupRelationList } from '~/stores/user-group';
 
 
+
 const UserGroupDeleteModal = dynamic(() => import('./UserGroupDeleteModal').then(mod => mod.UserGroupDeleteModal), { ssr: false });
 const UserGroupDeleteModal = dynamic(() => import('./UserGroupDeleteModal').then(mod => mod.UserGroupDeleteModal), { ssr: false });
 const UserGroupModal = dynamic(() => import('./UserGroupModal').then(mod => mod.UserGroupModal), { ssr: false });
 const UserGroupModal = dynamic(() => import('./UserGroupModal').then(mod => mod.UserGroupModal), { ssr: false });
 const UserGroupTable = dynamic(() => import('./UserGroupTable').then(mod => mod.UserGroupTable), { ssr: false });
 const UserGroupTable = dynamic(() => import('./UserGroupTable').then(mod => mod.UserGroupTable), { ssr: false });
@@ -146,6 +148,7 @@ export const UserGroupPage: FC = () => {
 
 
   return (
   return (
     <div data-testid="admin-user-groups">
     <div data-testid="admin-user-groups">
+      <h2 className="border-bottom">{t('admin:user_group_management.user_group_management')}</h2>
       {
       {
         isAclEnabled ? (
         isAclEnabled ? (
           <div className="mb-3">
           <div className="mb-3">
@@ -190,6 +193,9 @@ export const UserGroupPage: FC = () => {
         isShow={isDeleteModalShown}
         isShow={isDeleteModalShown}
         onHide={hideDeleteModal}
         onHide={hideDeleteModal}
       />
       />
+      <div className="mt-5">
+        <ExternalGroupManagement />
+      </div>
     </div>
     </div>
   );
   );
 };
 };

+ 41 - 23
apps/app/src/components/Admin/UserGroup/UserGroupTable.tsx

@@ -7,6 +7,8 @@ import dateFnsFormat from 'date-fns/format';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 import Link from 'next/link';
 import Link from 'next/link';
 
 
+import { IExternalUserGroupHasId } from '~/features/external-user-group/interfaces/external-user-group';
+
 
 
 type Props = {
 type Props = {
   headerLabel?: string,
   headerLabel?: string,
@@ -17,6 +19,7 @@ type Props = {
   onEdit?: (userGroup: IUserGroupHasId) => void | Promise<void>,
   onEdit?: (userGroup: IUserGroupHasId) => void | Promise<void>,
   onRemove?: (userGroup: IUserGroupHasId) => void | Promise<void>,
   onRemove?: (userGroup: IUserGroupHasId) => void | Promise<void>,
   onDelete?: (userGroup: IUserGroupHasId) => void | Promise<void>,
   onDelete?: (userGroup: IUserGroupHasId) => void | Promise<void>,
+  isExternalGroup?: boolean
 };
 };
 
 
 /*
 /*
@@ -53,27 +56,37 @@ const generateGroupIdToChildGroupsMap = (childUserGroups: IUserGroupHasId[]): Re
 };
 };
 
 
 
 
-export const UserGroupTable: FC<Props> = (props: Props) => {
+export const UserGroupTable: FC<Props> = ({
+  headerLabel,
+  userGroups,
+  userGroupRelations,
+  childUserGroups,
+  isAclEnabled,
+  onEdit,
+  onRemove,
+  onDelete,
+  isExternalGroup = false,
+}: Props) => {
   const { t } = useTranslation('admin');
   const { t } = useTranslation('admin');
 
 
   /*
   /*
    * State
    * State
    */
    */
-  const [groupIdToUsersMap, setGroupIdToUsersMap] = useState(generateGroupIdToUsersMap(props.userGroupRelations));
-  const [groupIdToChildGroupsMap, setGroupIdToChildGroupsMap] = useState(generateGroupIdToChildGroupsMap(props.childUserGroups));
+  const [groupIdToUsersMap, setGroupIdToUsersMap] = useState(generateGroupIdToUsersMap(userGroupRelations));
+  const [groupIdToChildGroupsMap, setGroupIdToChildGroupsMap] = useState(generateGroupIdToChildGroupsMap(childUserGroups));
 
 
   /*
   /*
    * Function
    * Function
    */
    */
   const findUserGroup = (e: React.ChangeEvent<HTMLInputElement>): IUserGroupHasId | undefined => {
   const findUserGroup = (e: React.ChangeEvent<HTMLInputElement>): IUserGroupHasId | undefined => {
     const groupId = e.target.getAttribute('data-user-group-id');
     const groupId = e.target.getAttribute('data-user-group-id');
-    return props.userGroups.find((group) => {
+    return userGroups.find((group) => {
       return group._id === groupId;
       return group._id === groupId;
     });
     });
   };
   };
 
 
   const onClickEdit = async(e) => {
   const onClickEdit = async(e) => {
-    if (props.onEdit == null) {
+    if (onEdit == null) {
       return;
       return;
     }
     }
 
 
@@ -82,11 +95,11 @@ export const UserGroupTable: FC<Props> = (props: Props) => {
       return;
       return;
     }
     }
 
 
-    props.onEdit(userGroup);
+    onEdit(userGroup);
   };
   };
 
 
   const onClickRemove = async(e) => {
   const onClickRemove = async(e) => {
-    if (props.onRemove == null) {
+    if (onRemove == null) {
       return;
       return;
     }
     }
 
 
@@ -96,7 +109,7 @@ export const UserGroupTable: FC<Props> = (props: Props) => {
     }
     }
 
 
     try {
     try {
-      await props.onRemove(userGroup);
+      await onRemove(userGroup);
       userGroup.parent = null;
       userGroup.parent = null;
     }
     }
     catch {
     catch {
@@ -105,7 +118,7 @@ export const UserGroupTable: FC<Props> = (props: Props) => {
   };
   };
 
 
   const onClickDelete = (e) => { // no preventDefault
   const onClickDelete = (e) => { // no preventDefault
-    if (props.onDelete == null) {
+    if (onDelete == null) {
       return;
       return;
     }
     }
 
 
@@ -114,24 +127,25 @@ export const UserGroupTable: FC<Props> = (props: Props) => {
       return;
       return;
     }
     }
 
 
-    props.onDelete(userGroup);
+    onDelete(userGroup);
   };
   };
 
 
   /*
   /*
    * useEffect
    * useEffect
    */
    */
   useEffect(() => {
   useEffect(() => {
-    setGroupIdToUsersMap(generateGroupIdToUsersMap(props.userGroupRelations));
-    setGroupIdToChildGroupsMap(generateGroupIdToChildGroupsMap(props.childUserGroups));
-  }, [props.userGroupRelations, props.childUserGroups]);
+    setGroupIdToUsersMap(generateGroupIdToUsersMap(userGroupRelations));
+    setGroupIdToChildGroupsMap(generateGroupIdToChildGroupsMap(childUserGroups));
+  }, [userGroupRelations, childUserGroups]);
 
 
   return (
   return (
     <div data-testid="grw-user-group-table">
     <div data-testid="grw-user-group-table">
-      <h2>{props.headerLabel}</h2>
+      <h3>{headerLabel}</h3>
 
 
       <table className="table table-bordered table-user-list">
       <table className="table table-bordered table-user-list">
         <thead>
         <thead>
           <tr>
           <tr>
+            {isExternalGroup && <th>{t('external_user_group.provider')}</th>}
             <th>{t('Name')}</th>
             <th>{t('Name')}</th>
             <th>{t('Description')}</th>
             <th>{t('Description')}</th>
             <th>{t('User')}</th>
             <th>{t('User')}</th>
@@ -141,14 +155,15 @@ export const UserGroupTable: FC<Props> = (props: Props) => {
           </tr>
           </tr>
         </thead>
         </thead>
         <tbody>
         <tbody>
-          {props.userGroups.map((group) => {
+          {userGroups.map((group) => {
             const users = groupIdToUsersMap[group._id];
             const users = groupIdToUsersMap[group._id];
 
 
             return (
             return (
               <tr key={group._id}>
               <tr key={group._id}>
-                {props.isAclEnabled
+                {isExternalGroup && <td>{(group as IExternalUserGroupHasId).provider}</td>}
+                {isAclEnabled
                   ? (
                   ? (
-                    <td><Link href={`/admin/user-group-detail/${group._id}`}>{group.name}</Link></td>
+                    <td><Link href={`/admin/user-group-detail/${group._id}?isExternalGroup=${isExternalGroup}`}>{group.name}</Link></td>
                   )
                   )
                   : (
                   : (
                     <td>{group.name}</td>
                     <td>{group.name}</td>
@@ -167,9 +182,9 @@ export const UserGroupTable: FC<Props> = (props: Props) => {
                     {groupIdToChildGroupsMap[group._id] != null && groupIdToChildGroupsMap[group._id].map((group) => {
                     {groupIdToChildGroupsMap[group._id] != null && groupIdToChildGroupsMap[group._id].map((group) => {
                       return (
                       return (
                         <li key={group._id} className="list-inline-item badge badge-success">
                         <li key={group._id} className="list-inline-item badge badge-success">
-                          {props.isAclEnabled
+                          {isAclEnabled
                             ? (
                             ? (
-                              <Link href={`/admin/user-group-detail/${group._id}`}>{group.name}</Link>
+                              <Link href={`/admin/user-group-detail/${group._id}?isExternalGroup=${isExternalGroup}`}>{group.name}</Link>
                             )
                             )
                             : (
                             : (
                               <p>{group.name}</p>
                               <p>{group.name}</p>
@@ -181,7 +196,7 @@ export const UserGroupTable: FC<Props> = (props: Props) => {
                   </ul>
                   </ul>
                 </td>
                 </td>
                 <td>{dateFnsFormat(new Date(group.createdAt), 'yyyy-MM-dd')}</td>
                 <td>{dateFnsFormat(new Date(group.createdAt), 'yyyy-MM-dd')}</td>
-                {props.isAclEnabled
+                {isAclEnabled
                   ? (
                   ? (
                     <td>
                     <td>
                       <div className="btn-group admin-group-menu">
                       <div className="btn-group admin-group-menu">
@@ -197,9 +212,12 @@ export const UserGroupTable: FC<Props> = (props: Props) => {
                           <button className="dropdown-item" type="button" role="button" onClick={onClickEdit} data-user-group-id={group._id}>
                           <button className="dropdown-item" type="button" role="button" onClick={onClickEdit} data-user-group-id={group._id}>
                             <i className="icon-fw icon-note"></i> {t('Edit')}
                             <i className="icon-fw icon-note"></i> {t('Edit')}
                           </button>
                           </button>
-                          <button className="dropdown-item" type="button" role="button" onClick={onClickRemove} data-user-group-id={group._id}>
-                            <i className="icon-fw fa fa-chain-broken"></i> {t('admin:user_group_management.remove_child_group')}
-                          </button>
+                          {onRemove != null
+                          && (
+                            <button className="dropdown-item" type="button" role="button" onClick={onClickRemove} data-user-group-id={group._id}>
+                              <i className="icon-fw fa fa-chain-broken"></i> {t('admin:user_group_management.remove_child_group')}
+                            </button>
+                          )}
                           <button className="dropdown-item" type="button" role="button" onClick={onClickDelete} data-user-group-id={group._id}>
                           <button className="dropdown-item" type="button" role="button" onClick={onClickDelete} data-user-group-id={group._id}>
                             <i className="icon-fw icon-fire text-danger"></i> {t('Delete')}
                             <i className="icon-fw icon-fire text-danger"></i> {t('Delete')}
                           </button>
                           </button>

+ 63 - 38
apps/app/src/components/Admin/UserGroupDetail/UserGroupDetailPage.tsx

@@ -13,22 +13,25 @@ import {
   apiv3Get, apiv3Put, apiv3Delete, apiv3Post,
   apiv3Get, apiv3Put, apiv3Delete, apiv3Post,
 } from '~/client/util/apiv3-client';
 } from '~/client/util/apiv3-client';
 import { toastSuccess, toastError } from '~/client/util/toastr';
 import { toastSuccess, toastError } from '~/client/util/toastr';
+import { IExternalUserGroupHasId } from '~/features/external-user-group/interfaces/external-user-group';
 import { SearchTypes, SearchType } from '~/interfaces/user-group';
 import { SearchTypes, SearchType } from '~/interfaces/user-group';
 import Xss from '~/services/xss';
 import Xss from '~/services/xss';
 import { useIsAclEnabled } from '~/stores/context';
 import { useIsAclEnabled } from '~/stores/context';
 import { useUpdateUserGroupConfirmModal } from '~/stores/modal';
 import { useUpdateUserGroupConfirmModal } from '~/stores/modal';
-import {
-  useSWRxUserGroupPages, useSWRxUserGroupRelationList, useSWRxChildUserGroupList, useSWRxUserGroup,
-  useSWRxSelectableParentUserGroups, useSWRxSelectableChildUserGroups, useSWRxAncestorUserGroups, useSWRxUserGroupRelations,
-} from '~/stores/user-group';
+import { useSWRxUserGroupPages, useSWRxSelectableParentUserGroups, useSWRxSelectableChildUserGroups } from '~/stores/user-group';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
+import {
+  useAncestorUserGroups,
+  useChildUserGroupList, useUserGroup, useUserGroupRelationList, useUserGroupRelations,
+} from './use-user-group-resource';
+
 import styles from './UserGroupDetailPage.module.scss';
 import styles from './UserGroupDetailPage.module.scss';
 
 
 const logger = loggerFactory('growi:services:AdminCustomizeContainer');
 const logger = loggerFactory('growi:services:AdminCustomizeContainer');
 
 
 const UserGroupPageList = dynamic(() => import('./UserGroupPageList'), { ssr: false });
 const UserGroupPageList = dynamic(() => import('./UserGroupPageList'), { ssr: false });
-const UserGroupUserTable = dynamic(() => import('./UserGroupUserTable'), { ssr: false });
+const UserGroupUserTable = dynamic(() => import('./UserGroupUserTable').then(mod => mod.UserGroupUserTable), { ssr: false });
 
 
 const UserGroupUserModal = dynamic(() => import('./UserGroupUserModal'), { ssr: false });
 const UserGroupUserModal = dynamic(() => import('./UserGroupUserModal'), { ssr: false });
 
 
@@ -42,15 +45,16 @@ const UpdateParentConfirmModal = dynamic(() => import('./UpdateParentConfirmModa
 
 
 type Props = {
 type Props = {
   userGroupId: string,
   userGroupId: string,
+  isExternalGroup: boolean,
 }
 }
 
 
 const UserGroupDetailPage = (props: Props): JSX.Element => {
 const UserGroupDetailPage = (props: Props): JSX.Element => {
   const { t } = useTranslation('admin');
   const { t } = useTranslation('admin');
   const router = useRouter();
   const router = useRouter();
   const xss = useMemo(() => new Xss(), []);
   const xss = useMemo(() => new Xss(), []);
-  const { userGroupId: currentUserGroupId } = props;
+  const { userGroupId: currentUserGroupId, isExternalGroup } = props;
 
 
-  const { data: currentUserGroup } = useSWRxUserGroup(currentUserGroupId);
+  const { data: currentUserGroup } = useUserGroup(currentUserGroupId, isExternalGroup);
 
 
   const [searchType, setSearchType] = useState<SearchType>(SearchTypes.PARTIAL);
   const [searchType, setSearchType] = useState<SearchType>(SearchTypes.PARTIAL);
   const [isAlsoMailSearched, setAlsoMailSearched] = useState<boolean>(false);
   const [isAlsoMailSearched, setAlsoMailSearched] = useState<boolean>(false);
@@ -76,26 +80,36 @@ const UserGroupDetailPage = (props: Props): JSX.Element => {
    */
    */
   const { data: userGroupPages } = useSWRxUserGroupPages(currentUserGroupId, 10, 0);
   const { data: userGroupPages } = useSWRxUserGroupPages(currentUserGroupId, 10, 0);
 
 
-  const { data: userGroupRelations, mutate: mutateUserGroupRelations } = useSWRxUserGroupRelations(currentUserGroupId);
+  const { data: userGroupRelations, mutate: mutateUserGroupRelations } = useUserGroupRelations(currentUserGroupId, isExternalGroup);
 
 
-  const { data: childUserGroupsList, mutate: mutateChildUserGroups } = useSWRxChildUserGroupList(currentUserGroupId ? [currentUserGroupId] : [], true);
+  const { data: childUserGroupsList, mutate: mutateChildUserGroups, updateChild } = useChildUserGroupList(currentUserGroupId, isExternalGroup);
   const childUserGroups = childUserGroupsList != null ? childUserGroupsList.childUserGroups : [];
   const childUserGroups = childUserGroupsList != null ? childUserGroupsList.childUserGroups : [];
   const grandChildUserGroups = childUserGroupsList != null ? childUserGroupsList.grandChildUserGroups : [];
   const grandChildUserGroups = childUserGroupsList != null ? childUserGroupsList.grandChildUserGroups : [];
   const childUserGroupIds = childUserGroups.map(group => group._id);
   const childUserGroupIds = childUserGroups.map(group => group._id);
 
 
-  const { data: userGroupRelationList, mutate: mutateUserGroupRelationList } = useSWRxUserGroupRelationList(childUserGroupIds);
+  const { data: userGroupRelationList, mutate: mutateUserGroupRelationList } = useUserGroupRelationList(childUserGroupIds, isExternalGroup);
   const childUserGroupRelations = userGroupRelationList != null ? userGroupRelationList : [];
   const childUserGroupRelations = userGroupRelationList != null ? userGroupRelationList : [];
 
 
-  const { data: selectableParentUserGroups, mutate: mutateSelectableParentUserGroups } = useSWRxSelectableParentUserGroups(currentUserGroupId);
-  const { data: selectableChildUserGroups, mutate: mutateSelectableChildUserGroups } = useSWRxSelectableChildUserGroups(currentUserGroupId);
+  const { data: selectableParentUserGroups, mutate: mutateSelectableParentUserGroups } = useSWRxSelectableParentUserGroups(
+    isExternalGroup ? null : currentUserGroupId,
+  );
+  const { data: selectableChildUserGroups, mutate: mutateSelectableChildUserGroups } = useSWRxSelectableChildUserGroups(
+    isExternalGroup ? null : currentUserGroupId,
+  );
 
 
-  const { data: ancestorUserGroups, mutate: mutateAncestorUserGroups } = useSWRxAncestorUserGroups(currentUserGroupId);
+  const { data: ancestorUserGroups, mutate: mutateAncestorUserGroups } = useAncestorUserGroups(currentUserGroupId, isExternalGroup);
 
 
   const { data: isAclEnabled } = useIsAclEnabled();
   const { data: isAclEnabled } = useIsAclEnabled();
 
 
   const { open: openUpdateParentConfirmModal } = useUpdateUserGroupConfirmModal();
   const { open: openUpdateParentConfirmModal } = useUpdateUserGroupConfirmModal();
 
 
-  const parentUserGroup = selectableParentUserGroups?.find(selectableParentUserGroup => selectableParentUserGroup._id === currentUserGroup?.parent);
+  const parentUserGroup = (() => {
+    if (isExternalGroup) {
+      return ancestorUserGroups != null && ancestorUserGroups.length > 1
+        ? ancestorUserGroups[ancestorUserGroups.length - 2] : undefined;
+    }
+    return selectableParentUserGroups?.find(selectableParentUserGroup => selectableParentUserGroup._id === currentUserGroup?.parent);
+  })();
   /*
   /*
    * Function
    * Function
    */
    */
@@ -113,19 +127,26 @@ const UserGroupDetailPage = (props: Props): JSX.Element => {
 
 
   const updateUserGroup = useCallback(async(userGroup: IUserGroupHasId, update: Partial<IUserGroupHasId>, forceUpdateParents: boolean) => {
   const updateUserGroup = useCallback(async(userGroup: IUserGroupHasId, update: Partial<IUserGroupHasId>, forceUpdateParents: boolean) => {
     const parentId = typeof update.parent === 'string' ? update.parent : update.parent?._id;
     const parentId = typeof update.parent === 'string' ? update.parent : update.parent?._id;
-    await apiv3Put<{ userGroup: IUserGroupHasId }>(`/user-groups/${userGroup._id}`, {
-      name: update.name,
-      description: update.description,
-      parentId: parentId ?? null,
-      forceUpdateParents,
-    });
+    if (isExternalGroup) {
+      await apiv3Put<{ userGroup: IExternalUserGroupHasId }>(`/external-user-groups/${userGroup._id}`, {
+        description: update.description,
+      });
+    }
+    else {
+      await apiv3Put<{ userGroup: IUserGroupHasId }>(`/user-groups/${userGroup._id}`, {
+        name: update.name,
+        description: update.description,
+        parentId: parentId ?? null,
+        forceUpdateParents,
+      });
+    }
 
 
     // mutate
     // mutate
     mutateChildUserGroups();
     mutateChildUserGroups();
     mutateAncestorUserGroups();
     mutateAncestorUserGroups();
     mutateSelectableChildUserGroups();
     mutateSelectableChildUserGroups();
     mutateSelectableParentUserGroups();
     mutateSelectableParentUserGroups();
-  }, [mutateAncestorUserGroups, mutateChildUserGroups, mutateSelectableChildUserGroups, mutateSelectableParentUserGroups]);
+  }, [mutateAncestorUserGroups, mutateChildUserGroups, mutateSelectableChildUserGroups, mutateSelectableParentUserGroups, isExternalGroup]);
 
 
   const onSubmitUpdateGroup = useCallback(
   const onSubmitUpdateGroup = useCallback(
     async(targetGroup: IUserGroupHasId, userGroupData: Partial<IUserGroupHasId>, forceUpdateParents: boolean): Promise<void> => {
     async(targetGroup: IUserGroupHasId, userGroupData: Partial<IUserGroupHasId>, forceUpdateParents: boolean): Promise<void> => {
@@ -213,23 +234,16 @@ const UserGroupDetailPage = (props: Props): JSX.Element => {
 
 
   const updateChildUserGroup = useCallback(async(userGroupData: IUserGroupHasId) => {
   const updateChildUserGroup = useCallback(async(userGroupData: IUserGroupHasId) => {
     try {
     try {
-      await apiv3Put(`/user-groups/${userGroupData._id}`, {
-        name: userGroupData.name,
-        description: userGroupData.description,
-        parentId: userGroupData.parent,
-      });
+      updateChild(userGroupData);
 
 
       toastSuccess(t('toaster.update_successed', { target: t('UserGroup'), ns: 'commons' }));
       toastSuccess(t('toaster.update_successed', { target: t('UserGroup'), ns: 'commons' }));
 
 
-      // mutate
-      mutateChildUserGroups();
-
       hideUpdateModal();
       hideUpdateModal();
     }
     }
     catch (err) {
     catch (err) {
       toastError(err);
       toastError(err);
     }
     }
-  }, [t, mutateChildUserGroups, hideUpdateModal]);
+  }, [t, updateChild, hideUpdateModal]);
 
 
   const onClickAddExistingUserGroupButtonHandler = useCallback(async(selectedChild: IUserGroupHasId): Promise<void> => {
   const onClickAddExistingUserGroupButtonHandler = useCallback(async(selectedChild: IUserGroupHasId): Promise<void> => {
     // show confirm modal before submiting
     // show confirm modal before submiting
@@ -283,8 +297,9 @@ const UserGroupDetailPage = (props: Props): JSX.Element => {
   }, [setSelectedUserGroup, setDeleteModalShown]);
   }, [setSelectedUserGroup, setDeleteModalShown]);
 
 
   const deleteChildUserGroupById = useCallback(async(deleteGroupId: string, actionName: string, transferToUserGroupId: string) => {
   const deleteChildUserGroupById = useCallback(async(deleteGroupId: string, actionName: string, transferToUserGroupId: string) => {
+    const url = isExternalGroup ? `/external-user-groups/${deleteGroupId}` : `/user-groups/${deleteGroupId}`;
     try {
     try {
-      const res = await apiv3Delete(`/user-groups/${deleteGroupId}`, {
+      const res = await apiv3Delete(url, {
         actionName,
         actionName,
         transferToUserGroupId,
         transferToUserGroupId,
       });
       });
@@ -300,7 +315,7 @@ const UserGroupDetailPage = (props: Props): JSX.Element => {
     catch (err) {
     catch (err) {
       toastError(new Error('Unable to delete the groups'));
       toastError(new Error('Unable to delete the groups'));
     }
     }
-  }, [mutateChildUserGroups, setSelectedUserGroup, setDeleteModalShown]);
+  }, [mutateChildUserGroups, setSelectedUserGroup, setDeleteModalShown, isExternalGroup]);
 
 
   const removeChildUserGroup = useCallback(async(userGroupData: IUserGroupHasId) => {
   const removeChildUserGroup = useCallback(async(userGroupData: IUserGroupHasId) => {
     try {
     try {
@@ -348,7 +363,11 @@ const UserGroupDetailPage = (props: Props): JSX.Element => {
                 { ancestorUserGroup._id === currentUserGroupId ? (
                 { ancestorUserGroup._id === currentUserGroupId ? (
                   <span>{ancestorUserGroup.name}</span>
                   <span>{ancestorUserGroup.name}</span>
                 ) : (
                 ) : (
-                  <Link href={`/admin/user-group-detail/${ancestorUserGroup._id}`}>
+                  <Link href={{
+                    pathname: `/admin/user-group-detail/${ancestorUserGroup._id}`,
+                    query: { isExternalGroup: 'true' },
+                  }}
+                  >
                     {ancestorUserGroup.name}
                     {ancestorUserGroup.name}
                   </Link>
                   </Link>
                 ) }
                 ) }
@@ -366,6 +385,7 @@ const UserGroupDetailPage = (props: Props): JSX.Element => {
           selectableParentUserGroups={selectableParentUserGroups}
           selectableParentUserGroups={selectableParentUserGroups}
           submitButtonLabel={t('Update')}
           submitButtonLabel={t('Update')}
           onSubmit={onClickSubmitForm}
           onSubmit={onClickSubmitForm}
+          isExternalGroup={isExternalGroup}
         />
         />
       </div>
       </div>
       <h2 className="admin-setting-header mt-4">{t('user_group_management.user_list')}</h2>
       <h2 className="admin-setting-header mt-4">{t('user_group_management.user_list')}</h2>
@@ -373,6 +393,7 @@ const UserGroupDetailPage = (props: Props): JSX.Element => {
         userGroupRelations={userGroupRelations}
         userGroupRelations={userGroupRelations}
         onClickPlusBtn={() => setIsUserGroupUserModalShown(true)}
         onClickPlusBtn={() => setIsUserGroupUserModalShown(true)}
         onClickRemoveUserBtn={removeUserByUsername}
         onClickRemoveUserBtn={removeUserByUsername}
+        isExternalGroup={isExternalGroup}
       />
       />
       <UserGroupUserModal
       <UserGroupUserModal
         isOpen={isUserGroupUserModalShown}
         isOpen={isUserGroupUserModalShown}
@@ -389,11 +410,13 @@ const UserGroupDetailPage = (props: Props): JSX.Element => {
       />
       />
 
 
       <h2 className="admin-setting-header mt-4">{t('user_group_management.child_group_list')}</h2>
       <h2 className="admin-setting-header mt-4">{t('user_group_management.child_group_list')}</h2>
-      <UserGroupDropdown
-        selectableUserGroups={selectableChildUserGroups}
-        onClickAddExistingUserGroupButton={onClickAddExistingUserGroupButtonHandler}
-        onClickCreateUserGroupButton={showCreateModal}
-      />
+      {!isExternalGroup && (
+        <UserGroupDropdown
+          selectableUserGroups={selectableChildUserGroups}
+          onClickAddExistingUserGroupButton={onClickAddExistingUserGroupButtonHandler}
+          onClickCreateUserGroupButton={showCreateModal}
+        />
+      )}
 
 
       <UserGroupModal
       <UserGroupModal
         userGroup={selectedUserGroup}
         userGroup={selectedUserGroup}
@@ -401,6 +424,7 @@ const UserGroupDetailPage = (props: Props): JSX.Element => {
         onClickSubmit={updateChildUserGroup}
         onClickSubmit={updateChildUserGroup}
         isShow={isUpdateModalShown}
         isShow={isUpdateModalShown}
         onHide={hideUpdateModal}
         onHide={hideUpdateModal}
+        isExternalGroup={isExternalGroup}
       />
       />
 
 
       <UserGroupModal
       <UserGroupModal
@@ -420,6 +444,7 @@ const UserGroupDetailPage = (props: Props): JSX.Element => {
         onRemove={removeChildUserGroup}
         onRemove={removeChildUserGroup}
         onDelete={showDeleteModal}
         onDelete={showDeleteModal}
         userGroupRelations={childUserGroupRelations}
         userGroupRelations={childUserGroupRelations}
+        isExternalGroup={isExternalGroup}
       />
       />
 
 
       <UserGroupDeleteModal
       <UserGroupDeleteModal

+ 35 - 36
apps/app/src/components/Admin/UserGroupDetail/UserGroupUserTable.tsx

@@ -10,15 +10,12 @@ type Props = {
   userGroupRelations: IUserGroupRelationHasIdPopulatedUser[] | undefined,
   userGroupRelations: IUserGroupRelationHasIdPopulatedUser[] | undefined,
   onClickRemoveUserBtn: (username: string) => Promise<void>,
   onClickRemoveUserBtn: (username: string) => Promise<void>,
   onClickPlusBtn: () => void,
   onClickPlusBtn: () => void,
+  isExternalGroup?: boolean
 }
 }
 
 
 export const UserGroupUserTable = (props: Props): JSX.Element => {
 export const UserGroupUserTable = (props: Props): JSX.Element => {
   const { t } = useTranslation('admin');
   const { t } = useTranslation('admin');
 
 
-  const {
-    userGroupRelations, onClickRemoveUserBtn, onClickPlusBtn,
-  } = props;
-
   return (
   return (
     <table className="table table-bordered table-user-list">
     <table className="table table-bordered table-user-list">
       <thead>
       <thead>
@@ -30,11 +27,11 @@ export const UserGroupUserTable = (props: Props): JSX.Element => {
           <th>{t('Name')}</th>
           <th>{t('Name')}</th>
           <th style={{ width: '100px' }}>{t('Created')}</th>
           <th style={{ width: '100px' }}>{t('Created')}</th>
           <th style={{ width: '160px' }}>{t('last_login')}</th>
           <th style={{ width: '160px' }}>{t('last_login')}</th>
-          <th style={{ width: '70px' }}></th>
+          {!props.isExternalGroup && <th style={{ width: '70px' }}></th>}
         </tr>
         </tr>
       </thead>
       </thead>
       <tbody>
       <tbody>
-        {userGroupRelations != null && userGroupRelations.map((relation) => {
+        {props.userGroupRelations != null && props.userGroupRelations.map((relation) => {
           const { relatedUser } = relation;
           const { relatedUser } = relation;
 
 
           return (
           return (
@@ -48,47 +45,49 @@ export const UserGroupUserTable = (props: Props): JSX.Element => {
               <td>{relatedUser.name}</td>
               <td>{relatedUser.name}</td>
               <td>{relatedUser.createdAt ? dateFnsFormat(new Date(relatedUser.createdAt), 'yyyy-MM-dd') : ''}</td>
               <td>{relatedUser.createdAt ? dateFnsFormat(new Date(relatedUser.createdAt), 'yyyy-MM-dd') : ''}</td>
               <td>{relatedUser.lastLoginAt ? dateFnsFormat(new Date(relatedUser.lastLoginAt), 'yyyy-MM-dd HH:mm:ss') : ''}</td>
               <td>{relatedUser.lastLoginAt ? dateFnsFormat(new Date(relatedUser.lastLoginAt), 'yyyy-MM-dd HH:mm:ss') : ''}</td>
-              <td>
-                <div className="btn-group admin-user-menu">
-                  <button
-                    type="button"
-                    id={`admin-group-menu-button-${relatedUser._id}`}
-                    className="btn btn-outline-secondary btn-sm dropdown-toggle"
-                    data-toggle="dropdown"
-                  >
-                    <i className="icon-settings"></i>
-                  </button>
-                  <div className="dropdown-menu" role="menu" aria-labelledby={`admin-group-menu-button-${relatedUser._id}`}>
+              {!props.isExternalGroup && (
+                <td>
+                  <div className="btn-group admin-user-menu">
                     <button
                     <button
-                      className="dropdown-item"
                       type="button"
                       type="button"
-                      onClick={() => onClickRemoveUserBtn(relatedUser.username)}
+                      id={`admin-group-menu-button-${relatedUser._id}`}
+                      className="btn btn-outline-secondary btn-sm dropdown-toggle"
+                      data-toggle="dropdown"
                     >
                     >
-                      <i className="icon-fw icon-user-unfollow"></i> {t('admin:user_group_management.remove_from_group')}
+                      <i className="icon-settings"></i>
                     </button>
                     </button>
+                    <div className="dropdown-menu" role="menu" aria-labelledby={`admin-group-menu-button-${relatedUser._id}`}>
+                      <button
+                        className="dropdown-item"
+                        type="button"
+                        onClick={() => props.onClickRemoveUserBtn(relatedUser.username)}
+                      >
+                        <i className="icon-fw icon-user-unfollow"></i> {t('admin:user_group_management.remove_from_group')}
+                      </button>
+                    </div>
                   </div>
                   </div>
-                </div>
-              </td>
+                </td>
+              )}
             </tr>
             </tr>
           );
           );
         })}
         })}
 
 
-        <tr>
-          <td></td>
-          <td className="text-center">
-            <button className="btn btn-outline-secondary" type="button" onClick={onClickPlusBtn}>
-              <i className="ti ti-plus"></i>
-            </button>
-          </td>
-          <td></td>
-          <td></td>
-          <td></td>
-          <td></td>
-        </tr>
+        {!props.isExternalGroup && (
+          <tr>
+            <td></td>
+            <td className="text-center">
+              <button className="btn btn-outline-secondary" type="button" onClick={props.onClickPlusBtn}>
+                <i className="ti ti-plus"></i>
+              </button>
+            </td>
+            <td></td>
+            <td></td>
+            <td></td>
+            <td></td>
+          </tr>
+        )}
 
 
       </tbody>
       </tbody>
     </table>
     </table>
   );
   );
 };
 };
-
-export default UserGroupUserTable;

+ 50 - 0
apps/app/src/components/Admin/UserGroupDetail/use-user-group-resource.ts

@@ -0,0 +1,50 @@
+import {
+  useSWRxAncestorExternalUserGroups,
+  useSWRxChildExternalUserGroupList,
+  useSWRxExternalUserGroup,
+  useSWRxExternalUserGroupRelationList,
+  useSWRxExternalUserGroupRelations,
+} from '~/features/external-user-group/client/stores/external-user-group';
+import {
+  useSWRxAncestorUserGroups,
+  useSWRxChildUserGroupList, useSWRxUserGroup, useSWRxUserGroupRelationList, useSWRxUserGroupRelations,
+} from '~/stores/user-group';
+
+// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
+export const useUserGroup = (userGroupId: string, isExternalGroup: boolean) => {
+  const userGroupRes = useSWRxUserGroup(isExternalGroup ? null : userGroupId);
+  const externalUserGroupRes = useSWRxExternalUserGroup(isExternalGroup ? userGroupId : null);
+  return isExternalGroup ? externalUserGroupRes : userGroupRes;
+};
+
+// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
+export const useUserGroupRelations = (userGroupId: string, isExternalGroup: boolean) => {
+  const userGroupRes = useSWRxUserGroupRelations(isExternalGroup ? null : userGroupId);
+  const externalUserGroupRes = useSWRxExternalUserGroupRelations(isExternalGroup ? userGroupId : null);
+  return isExternalGroup ? externalUserGroupRes : userGroupRes;
+};
+
+// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
+export const useChildUserGroupList = (userGroupId: string, isExternalGroup: boolean) => {
+  const userGroupRes = useSWRxChildUserGroupList(
+    !isExternalGroup ? [userGroupId] : [], true,
+  );
+  const externalUserGroupRes = useSWRxChildExternalUserGroupList(
+    isExternalGroup ? [userGroupId] : [], true,
+  );
+  return isExternalGroup ? externalUserGroupRes : userGroupRes;
+};
+
+// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
+export const useUserGroupRelationList = (userGroupIds: string[], isExternalGroup: boolean) => {
+  const userGroupRes = useSWRxUserGroupRelationList(isExternalGroup ? null : userGroupIds);
+  const externalUserGroupRes = useSWRxExternalUserGroupRelationList(isExternalGroup ? userGroupIds : null);
+  return isExternalGroup ? externalUserGroupRes : userGroupRes;
+};
+
+// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
+export const useAncestorUserGroups = (userGroupId: string, isExternalGroup: boolean) => {
+  const userGroupRes = useSWRxAncestorUserGroups(isExternalGroup ? null : userGroupId);
+  const externalUserGroupRes = useSWRxAncestorExternalUserGroups(isExternalGroup ? userGroupId : null);
+  return isExternalGroup ? externalUserGroupRes : userGroupRes;
+};

+ 5 - 3
apps/app/src/components/Common/Dropdown/PageItemControl.tsx

@@ -86,7 +86,8 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
     if (onClickRenameMenuItem == null) {
     if (onClickRenameMenuItem == null) {
       return;
       return;
     }
     }
-    if (!pageInfo?.isMovable) {
+
+    if (!pageInfo?.isDeletable) {
       logger.warn('This page could not be renamed.');
       logger.warn('This page could not be renamed.');
       return;
       return;
     }
     }
@@ -177,9 +178,10 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
         ) }
         ) }
 
 
         {/* Move/Rename */}
         {/* Move/Rename */}
-        { !forceHideMenuItems?.includes(MenuItemType.RENAME) && isEnableActions && !isReadOnlyUser && pageInfo.isMovable && (
+        { !forceHideMenuItems?.includes(MenuItemType.RENAME) && isEnableActions && !isReadOnlyUser && (
           <DropdownItem
           <DropdownItem
             onClick={renameItemClickedHandler}
             onClick={renameItemClickedHandler}
+            disabled={!pageInfo.isDeletable}
             data-testid="open-page-move-rename-modal-btn"
             data-testid="open-page-move-rename-modal-btn"
             className="grw-page-control-dropdown-item"
             className="grw-page-control-dropdown-item"
           >
           >
@@ -231,7 +233,7 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
 
 
         {/* divider */}
         {/* divider */}
         {/* Delete */}
         {/* Delete */}
-        { !forceHideMenuItems?.includes(MenuItemType.DELETE) && isEnableActions && !isReadOnlyUser && pageInfo.isMovable && (
+        { !forceHideMenuItems?.includes(MenuItemType.DELETE) && isEnableActions && !isReadOnlyUser && (
           <>
           <>
             { showDeviderBeforeDelete && <DropdownItem divider /> }
             { showDeviderBeforeDelete && <DropdownItem divider /> }
             <DropdownItem
             <DropdownItem

+ 10 - 8
apps/app/src/components/PageAlert/FixPageGrantAlert.tsx

@@ -9,7 +9,7 @@ import {
 import { apiv3Put } from '~/client/util/apiv3-client';
 import { apiv3Put } from '~/client/util/apiv3-client';
 import { toastError, toastSuccess } from '~/client/util/toastr';
 import { toastError, toastSuccess } from '~/client/util/toastr';
 import { IPageGrantData } from '~/interfaces/page';
 import { IPageGrantData } from '~/interfaces/page';
-import { IRecordApplicableGrant, IResIsGrantNormalizedGrantData } from '~/interfaces/page-grant';
+import { PopulatedGrantedGroup, IRecordApplicableGrant, IResIsGrantNormalizedGrantData } from '~/interfaces/page-grant';
 import { useCurrentUser } from '~/stores/context';
 import { useCurrentUser } from '~/stores/context';
 import { useSWRxApplicableGrant, useSWRxIsGrantNormalized, useSWRxCurrentPage } from '~/stores/page';
 import { useSWRxApplicableGrant, useSWRxIsGrantNormalized, useSWRxCurrentPage } from '~/stores/page';
 
 
@@ -29,7 +29,9 @@ const FixPageGrantModal = (props: ModalProps): JSX.Element => {
   } = props;
   } = props;
 
 
   const [selectedGrant, setSelectedGrant] = useState<PageGrant>(PageGrant.GRANT_RESTRICTED);
   const [selectedGrant, setSelectedGrant] = useState<PageGrant>(PageGrant.GRANT_RESTRICTED);
-  const [selectedGroup, setSelectedGroup] = useState<{_id: string, name: string} | undefined>(undefined); // TODO: Typescriptize model
+
+  const [isGroupSelectModalShown, setIsGroupSelectModalShown] = useState(false);
+  const [selectedGroup, setSelectedGroup] = useState<PopulatedGrantedGroup | undefined>(undefined);
 
 
   // Alert message state
   // Alert message state
   const [shouldShowModalAlert, setShowModalAlert] = useState<boolean>(false);
   const [shouldShowModalAlert, setShowModalAlert] = useState<boolean>(false);
@@ -57,7 +59,7 @@ const FixPageGrantModal = (props: ModalProps): JSX.Element => {
     try {
     try {
       await apiv3Put(`/page/${pageId}/grant`, {
       await apiv3Put(`/page/${pageId}/grant`, {
         grant: selectedGrant,
         grant: selectedGrant,
-        grantedGroup: selectedGroup?._id,
+        grantedGroups: selectedGroup?.item._id != null ? [{ item: selectedGroup?.item._id, type: selectedGroup.type }] : null,
       });
       });
 
 
       toastSuccess(t('Successfully updated'));
       toastSuccess(t('Successfully updated'));
@@ -86,10 +88,10 @@ const FixPageGrantModal = (props: ModalProps): JSX.Element => {
     }
     }
 
 
     if (grantData.grant === 5) {
     if (grantData.grant === 5) {
-      if (grantData.grantedGroup == null) {
+      if (grantData.grantedGroups == null || grantData.grantedGroups.length === 0) {
         return t('fix_page_grant.modal.grant_label.isForbidden');
         return t('fix_page_grant.modal.grant_label.isForbidden');
       }
       }
-      return `${t('fix_page_grant.modal.radio_btn.grant_group')}: (${grantData.grantedGroup.name})`;
+      return `${t('fix_page_grant.modal.radio_btn.grant_group')}: (${grantData.grantedGroups[0].name})`;
     }
     }
 
 
     throw Error('cannot get grant label'); // this error can't be throwed
     throw Error('cannot get grant label'); // this error can't be throwed
@@ -185,7 +187,7 @@ const FixPageGrantModal = (props: ModalProps): JSX.Element => {
                       {
                       {
                         selectedGroup == null
                         selectedGroup == null
                           ? t('fix_page_grant.modal.select_group_default_text')
                           ? t('fix_page_grant.modal.select_group_default_text')
-                          : selectedGroup.name
+                          : selectedGroup.item.name
                       }
                       }
                     </span>
                     </span>
                   </button>
                   </button>
@@ -193,12 +195,12 @@ const FixPageGrantModal = (props: ModalProps): JSX.Element => {
                     {
                     {
                       applicableGroups != null && applicableGroups.map(g => (
                       applicableGroups != null && applicableGroups.map(g => (
                         <button
                         <button
-                          key={g._id}
+                          key={g.item._id}
                           className="dropdown-item"
                           className="dropdown-item"
                           type="button"
                           type="button"
                           onClick={() => setSelectedGroup(g)}
                           onClick={() => setSelectedGroup(g)}
                         >
                         >
-                          {g.name}
+                          {g.item.name}
                         </button>
                         </button>
                       ))
                       ))
                     }
                     }

+ 6 - 1
apps/app/src/components/PageAlert/PageGrantAlert.tsx

@@ -1,5 +1,6 @@
 import React from 'react';
 import React from 'react';
 
 
+import { isPopulated } from '@growi/core';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 
 
 import { useSWRxCurrentPage } from '~/stores/page';
 import { useSWRxCurrentPage } from '~/stores/page';
@@ -32,7 +33,11 @@ export const PageGrantAlert = (): JSX.Element => {
       if (pageData.grant === 5) {
       if (pageData.grant === 5) {
         return (
         return (
           <>
           <>
-            <i className="icon-fw icon-organization"></i><strong>{pageData.grantedGroup.name}</strong>
+            <i className="icon-fw icon-organization"></i>
+            <strong>{
+              isPopulated(pageData.grantedGroups[0].item) && pageData.grantedGroups[0].item.name
+            }
+            </strong>
           </>
           </>
         );
         );
       }
       }

+ 4 - 3
apps/app/src/components/PageEditor.tsx

@@ -5,7 +5,6 @@ import React, {
 import EventEmitter from 'events';
 import EventEmitter from 'events';
 import nodePath from 'path';
 import nodePath from 'path';
 
 
-
 import type { IPageHasId } from '@growi/core';
 import type { IPageHasId } from '@growi/core';
 import { pathUtils } from '@growi/core/dist/utils';
 import { pathUtils } from '@growi/core/dist/utils';
 import detectIndent from 'detect-indent';
 import detectIndent from 'detect-indent';
@@ -180,13 +179,15 @@ const PageEditor = React.memo((): JSX.Element => {
     if (grantData == null) {
     if (grantData == null) {
       return;
       return;
     }
     }
+    const grantedGroups = grantData.grantedGroups?.map((group) => {
+      return { item: group.id, type: group.type };
+    });
     const optionsToSave = {
     const optionsToSave = {
       isSlackEnabled: isSlackEnabled ?? false,
       isSlackEnabled: isSlackEnabled ?? false,
       slackChannels: '', // set in save method by opts in SavePageControlls.tsx
       slackChannels: '', // set in save method by opts in SavePageControlls.tsx
       grant: grantData.grant,
       grant: grantData.grant,
       pageTags: pageTags ?? [],
       pageTags: pageTags ?? [],
-      grantUserGroupId: grantData.grantedGroup?.id,
-      grantUserGroupName: grantData.grantedGroup?.name,
+      grantUserGroupIds: grantedGroups,
     };
     };
     return optionsToSave;
     return optionsToSave;
   }, [grantData, isSlackEnabled, pageTags]);
   }, [grantData, isSlackEnabled, pageTags]);

+ 4 - 2
apps/app/src/components/PageEditorByHackmd.tsx

@@ -98,13 +98,15 @@ export const PageEditorByHackmd = (): JSX.Element => {
     if (grantData == null) {
     if (grantData == null) {
       return;
       return;
     }
     }
+    const grantedGroups = grantData.grantedGroups?.map((group) => {
+      return { item: group.id, type: group.type };
+    });
     const optionsToSave = {
     const optionsToSave = {
       isSlackEnabled: isSlackEnabled ?? false,
       isSlackEnabled: isSlackEnabled ?? false,
       slackChannels: '', // set in save method by opts in SavePageControlls.tsx
       slackChannels: '', // set in save method by opts in SavePageControlls.tsx
       grant: grantData.grant,
       grant: grantData.grant,
       pageTags: pageTags ?? [],
       pageTags: pageTags ?? [],
-      grantUserGroupId: grantData.grantedGroup?.id,
-      grantUserGroupName: grantData.grantedGroup?.name,
+      grantUserGroupIds: grantedGroups,
     };
     };
     return optionsToSave;
     return optionsToSave;
   }, [grantData, isSlackEnabled, pageTags]);
   }, [grantData, isSlackEnabled, pageTags]);

+ 3 - 4
apps/app/src/components/SavePageControls.tsx

@@ -18,7 +18,7 @@ import { useSWRxCurrentPage } from '~/stores/page';
 import { useSelectedGrant } from '~/stores/ui';
 import { useSelectedGrant } from '~/stores/ui';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
-import GrantSelector from './SavePageControls/GrantSelector';
+import { GrantSelector } from './SavePageControls/GrantSelector';
 
 
 
 
 declare global {
 declare global {
@@ -67,7 +67,7 @@ export const SavePageControls = (props: SavePageControlsProps): JSX.Element | nu
     return null;
     return null;
   }
   }
 
 
-  const { grant, grantedGroup } = grantData;
+  const { grant, grantedGroups } = grantData;
 
 
   const isGrantSelectorDisabledPage = isTopPage(currentPage?.path ?? '') || isUsersProtectedPages(currentPage?.path ?? '');
   const isGrantSelectorDisabledPage = isTopPage(currentPage?.path ?? '') || isUsersProtectedPages(currentPage?.path ?? '');
   const labelSubmitButton = (currentPage != null && !currentPage.isEmpty) ? t('Update') : t('Create');
   const labelSubmitButton = (currentPage != null && !currentPage.isEmpty) ? t('Update') : t('Create');
@@ -82,8 +82,7 @@ export const SavePageControls = (props: SavePageControlsProps): JSX.Element | nu
             <GrantSelector
             <GrantSelector
               grant={grant}
               grant={grant}
               disabled={isGrantSelectorDisabledPage}
               disabled={isGrantSelectorDisabledPage}
-              grantGroupId={grantedGroup?.id}
-              grantGroupName={grantedGroup?.name}
+              grantedGroups={grantedGroups}
               onUpdateGrant={updateGrantHandler}
               onUpdateGrant={updateGrantHandler}
             />
             />
           </div>
           </div>

+ 27 - 37
apps/app/src/components/SavePageControls/GrantSelector.tsx → apps/app/src/components/SavePageControls/GrantSelector/GrantSelector.tsx

@@ -1,7 +1,6 @@
 import React, { useCallback, useState } from 'react';
 import React, { useCallback, useState } from 'react';
 
 
-import { isPopulated } from '@growi/core';
-import type { IUserGroupHasId } from '@growi/core';
+import { isPopulated, GroupType, type IGrantedGroup } from '@growi/core';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 import {
 import {
   UncontrolledDropdown,
   UncontrolledDropdown,
@@ -12,8 +11,8 @@ import {
 
 
 import type { IPageGrantData } from '~/interfaces/page';
 import type { IPageGrantData } from '~/interfaces/page';
 import { useCurrentUser } from '~/stores/context';
 import { useCurrentUser } from '~/stores/context';
-import { useSWRxMyUserGroupRelations } from '~/stores/user-group';
 
 
+import { useMyUserGroups } from './use-my-user-groups';
 
 
 const AVAILABLE_GRANTS = [
 const AVAILABLE_GRANTS = [
   {
   {
@@ -35,8 +34,11 @@ const AVAILABLE_GRANTS = [
 type Props = {
 type Props = {
   disabled?: boolean,
   disabled?: boolean,
   grant: number,
   grant: number,
-  grantGroupId?: string,
-  grantGroupName?: string,
+  grantedGroups?: {
+    id: string,
+    name: string,
+    type: GroupType,
+  }[]
 
 
   onUpdateGrant?: (grantData: IPageGrantData) => void,
   onUpdateGrant?: (grantData: IPageGrantData) => void,
 }
 }
@@ -44,15 +46,14 @@ type Props = {
 /**
 /**
  * Page grant select component
  * Page grant select component
  */
  */
-const GrantSelector = (props: Props): JSX.Element => {
+export const GrantSelector = (props: Props): JSX.Element => {
   const { t } = useTranslation();
   const { t } = useTranslation();
 
 
   const {
   const {
     disabled,
     disabled,
-    grantGroupName,
+    grantedGroups,
     onUpdateGrant,
     onUpdateGrant,
     grant: currentGrant,
     grant: currentGrant,
-    grantGroupId,
   } = props;
   } = props;
 
 
 
 
@@ -61,12 +62,12 @@ const GrantSelector = (props: Props): JSX.Element => {
   const { data: currentUser } = useCurrentUser();
   const { data: currentUser } = useCurrentUser();
 
 
   const shouldFetch = isSelectGroupModalShown;
   const shouldFetch = isSelectGroupModalShown;
-  const { data: myUserGroupRelations, mutate: mutateMyUserGroupRelations } = useSWRxMyUserGroupRelations(shouldFetch);
+  const { data: myUserGroups, update: updateMyUserGroups } = useMyUserGroups(shouldFetch);
 
 
   const showSelectGroupModal = useCallback(() => {
   const showSelectGroupModal = useCallback(() => {
-    mutateMyUserGroupRelations();
+    updateMyUserGroups();
     setIsSelectGroupModalShown(true);
     setIsSelectGroupModalShown(true);
-  }, [mutateMyUserGroupRelations]);
+  }, [updateMyUserGroups]);
 
 
   /**
   /**
    * change event handler for grant selector
    * change event handler for grant selector
@@ -79,13 +80,13 @@ const GrantSelector = (props: Props): JSX.Element => {
     }
     }
 
 
     if (onUpdateGrant != null) {
     if (onUpdateGrant != null) {
-      onUpdateGrant({ grant, grantedGroup: undefined });
+      onUpdateGrant({ grant, grantedGroups: undefined });
     }
     }
   }, [onUpdateGrant, showSelectGroupModal]);
   }, [onUpdateGrant, showSelectGroupModal]);
 
 
-  const groupListItemClickHandler = useCallback((grantGroup: IUserGroupHasId) => {
-    if (onUpdateGrant != null) {
-      onUpdateGrant({ grant: 5, grantedGroup: { id: grantGroup._id, name: grantGroup.name } });
+  const groupListItemClickHandler = useCallback((grantGroup: IGrantedGroup) => {
+    if (onUpdateGrant != null && isPopulated(grantGroup.item)) {
+      onUpdateGrant({ grant: 5, grantedGroups: [{ id: grantGroup.item._id, name: grantGroup.item.name, type: grantGroup.type }] });
     }
     }
 
 
     // hide modal
     // hide modal
@@ -101,7 +102,7 @@ const GrantSelector = (props: Props): JSX.Element => {
     let dropdownToggleLabelElm;
     let dropdownToggleLabelElm;
 
 
     const dropdownMenuElems = AVAILABLE_GRANTS.map((opt) => {
     const dropdownMenuElems = AVAILABLE_GRANTS.map((opt) => {
-      const label = ((opt.grant === 5 && opt.reselectLabel != null) && grantGroupId != null)
+      const label = ((opt.grant === 5 && opt.reselectLabel != null) && grantedGroups != null && grantedGroups.length > 0)
         ? opt.reselectLabel // when grantGroup is selected
         ? opt.reselectLabel // when grantGroup is selected
         : opt.label;
         : opt.label;
 
 
@@ -122,11 +123,11 @@ const GrantSelector = (props: Props): JSX.Element => {
     });
     });
 
 
     // add specified group option
     // add specified group option
-    if (grantGroupId != null) {
+    if (grantedGroups != null && grantedGroups.length > 0) {
       const labelElm = (
       const labelElm = (
         <span>
         <span>
           <i className="icon icon-fw icon-organization"></i>
           <i className="icon icon-fw icon-organization"></i>
-          <span className="label">{grantGroupName}</span>
+          <span className="label">{grantedGroups[0].name}</span>
         </span>
         </span>
       );
       );
 
 
@@ -148,7 +149,7 @@ const GrantSelector = (props: Props): JSX.Element => {
         </UncontrolledDropdown>
         </UncontrolledDropdown>
       </div>
       </div>
     );
     );
-  }, [changeGrantHandler, currentGrant, disabled, grantGroupId, grantGroupName, t]);
+  }, [changeGrantHandler, currentGrant, disabled, grantedGroups, t]);
 
 
   /**
   /**
    * Render select grantgroup modal.
    * Render select grantgroup modal.
@@ -159,7 +160,7 @@ const GrantSelector = (props: Props): JSX.Element => {
     }
     }
 
 
     // show spinner
     // show spinner
-    if (myUserGroupRelations == null) {
+    if (myUserGroups == null) {
       return (
       return (
         <div className="my-3 text-center">
         <div className="my-3 text-center">
           <i className="fa fa-lg fa-spinner fa-pulse mx-auto text-muted"></i>
           <i className="fa fa-lg fa-spinner fa-pulse mx-auto text-muted"></i>
@@ -167,16 +168,7 @@ const GrantSelector = (props: Props): JSX.Element => {
       );
       );
     }
     }
 
 
-    // extract IUserGroupHasId
-    const userRelatedGroups: IUserGroupHasId[] = myUserGroupRelations
-      .map((relation) => {
-        // relation.relatedGroup should be populated by server
-        return isPopulated(relation.relatedGroup) ? relation.relatedGroup : undefined;
-      })
-      // exclude undefined elements
-      .filter((elem): elem is IUserGroupHasId => elem != null);
-
-    if (userRelatedGroups.length === 0) {
+    if (myUserGroups.length === 0) {
       return (
       return (
         <div>
         <div>
           <h4>{t('user_group.belonging_to_no_group')}</h4>
           <h4>{t('user_group.belonging_to_no_group')}</h4>
@@ -189,10 +181,11 @@ const GrantSelector = (props: Props): JSX.Element => {
 
 
     return (
     return (
       <div className="list-group">
       <div className="list-group">
-        { userRelatedGroups.map((group) => {
+        { myUserGroups.map((group) => {
           return (
           return (
-            <button key={group._id} type="button" className="list-group-item list-group-item-action" onClick={() => groupListItemClickHandler(group)}>
-              <h5>{group.name}</h5>
+            <button key={group.item._id} type="button" className="list-group-item list-group-item-action" onClick={() => groupListItemClickHandler(group)}>
+              <h5 className="d-inline-block">{group.item.name}</h5>
+              {group.type === GroupType.externalUserGroup && <span className="ml-2 badge badge-pill badge-info">{group.item.provider}</span>}
               {/* TODO: Replace <div className="small">(TBD) List group members</div> */}
               {/* TODO: Replace <div className="small">(TBD) List group members</div> */}
             </button>
             </button>
           );
           );
@@ -200,7 +193,7 @@ const GrantSelector = (props: Props): JSX.Element => {
       </div>
       </div>
     );
     );
 
 
-  }, [currentUser?.admin, groupListItemClickHandler, myUserGroupRelations, shouldFetch, t]);
+  }, [currentUser?.admin, groupListItemClickHandler, myUserGroups, shouldFetch, t]);
 
 
   return (
   return (
     <>
     <>
@@ -223,7 +216,4 @@ const GrantSelector = (props: Props): JSX.Element => {
       ) }
       ) }
     </>
     </>
   );
   );
-
 };
 };
-
-export default GrantSelector;

+ 1 - 0
apps/app/src/components/SavePageControls/GrantSelector/index.ts

@@ -0,0 +1 @@
+export * from './GrantSelector';

+ 38 - 0
apps/app/src/components/SavePageControls/GrantSelector/use-my-user-groups.ts

@@ -0,0 +1,38 @@
+import { GroupType } from '@growi/core';
+
+import { useSWRxMyExternalUserGroups } from '~/features/external-user-group/client/stores/external-user-group';
+import { useSWRxMyUserGroups } from '~/stores/user-group';
+
+// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
+export const useMyUserGroups = (shouldFetch: boolean) => {
+  const { data: myUserGroups, mutate: mutateMyUserGroups } = useSWRxMyUserGroups(shouldFetch);
+  const { data: myExternalUserGroups, mutate: mutateMyExternalUserGroups } = useSWRxMyExternalUserGroups(shouldFetch);
+
+  const update = () => {
+    mutateMyUserGroups();
+    mutateMyExternalUserGroups();
+  };
+
+  if (myUserGroups == null || myExternalUserGroups == null) {
+    return { data: null, update };
+  }
+
+  const myUserGroupsData = myUserGroups
+    .map((group) => {
+      return {
+        item: group,
+        type: GroupType.userGroup,
+      };
+    });
+  const myExternalUserGroupsData = myExternalUserGroups
+    .map((group) => {
+      return {
+        item: group,
+        type: GroupType.externalUserGroup,
+      };
+    });
+
+  const data = [...myUserGroupsData, ...myExternalUserGroupsData];
+
+  return { data, update };
+};

+ 1 - 1
apps/app/src/components/Sidebar/PageTree/Item.tsx

@@ -355,7 +355,7 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
         path: newPagePath,
         path: newPagePath,
         body: undefined,
         body: undefined,
         grant: page.grant,
         grant: page.grant,
-        grantUserGroupId: page.grantedGroup,
+        grantUserGroupIds: page.grantedGroups,
       });
       });
 
 
       mutateChildren();
       mutateChildren();

+ 181 - 0
apps/app/src/features/external-user-group/client/components/ExternalUserGroup/ExternalUserGroupManagement.tsx

@@ -0,0 +1,181 @@
+import {
+  FC, useCallback, useMemo, useState,
+} from 'react';
+
+import { useTranslation } from 'react-i18next';
+import { TabContent, TabPane } from 'reactstrap';
+
+import { apiv3Delete, apiv3Put } from '~/client/util/apiv3-client';
+import { toastError, toastSuccess } from '~/client/util/toastr';
+import { UserGroupDeleteModal } from '~/components/Admin/UserGroup/UserGroupDeleteModal';
+import { UserGroupModal } from '~/components/Admin/UserGroup/UserGroupModal';
+import { UserGroupTable } from '~/components/Admin/UserGroup/UserGroupTable';
+import CustomNav from '~/components/CustomNavigation/CustomNav';
+import { IExternalUserGroupHasId } from '~/features/external-user-group/interfaces/external-user-group';
+import { useIsAclEnabled } from '~/stores/context';
+
+import { useSWRxChildExternalUserGroupList, useSWRxExternalUserGroupList, useSWRxExternalUserGroupRelationList } from '../../stores/external-user-group';
+
+import { KeycloakGroupManagement } from './KeycloakGroupManagement';
+import { LdapGroupManagement } from './LdapGroupManagement';
+
+export const ExternalGroupManagement: FC = () => {
+  const { data: externalUserGroupList, mutate: mutateExternalUserGroups } = useSWRxExternalUserGroupList();
+  const externalUserGroups = externalUserGroupList != null ? externalUserGroupList : [];
+  const externalUserGroupIds = externalUserGroups.map(group => group._id);
+
+  const { data: externalUserGroupRelationList } = useSWRxExternalUserGroupRelationList(externalUserGroupIds);
+  const externalUserGroupRelations = externalUserGroupRelationList != null ? externalUserGroupRelationList : [];
+
+  const { data: childExternalUserGroupsList } = useSWRxChildExternalUserGroupList(externalUserGroupIds);
+  const childExternalUserGroups = childExternalUserGroupsList?.childUserGroups != null ? childExternalUserGroupsList.childUserGroups : [];
+
+  const { data: isAclEnabled } = useIsAclEnabled();
+
+  const [activeTab, setActiveTab] = useState('ldap');
+  const [activeComponents, setActiveComponents] = useState(new Set(['ldap']));
+  const [selectedExternalUserGroup, setSelectedExternalUserGroup] = useState<IExternalUserGroupHasId | undefined>(undefined); // not null but undefined (to use defaultProps in UserGroupDeleteModal)
+  const [isUpdateModalShown, setUpdateModalShown] = useState<boolean>(false);
+  const [isDeleteModalShown, setDeleteModalShown] = useState<boolean>(false);
+
+  const { t } = useTranslation('admin');
+
+  const showUpdateModal = useCallback((group: IExternalUserGroupHasId) => {
+    setUpdateModalShown(true);
+    setSelectedExternalUserGroup(group);
+  }, [setUpdateModalShown]);
+
+  const hideUpdateModal = useCallback(() => {
+    setUpdateModalShown(false);
+    setSelectedExternalUserGroup(undefined);
+  }, [setUpdateModalShown]);
+
+  const syncUserGroupAndRelations = useCallback(async() => {
+    try {
+      await mutateExternalUserGroups();
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }, [mutateExternalUserGroups]);
+
+  const showDeleteModal = useCallback(async(group: IExternalUserGroupHasId) => {
+    try {
+      await syncUserGroupAndRelations();
+
+      setSelectedExternalUserGroup(group);
+      setDeleteModalShown(true);
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }, [syncUserGroupAndRelations]);
+
+  const hideDeleteModal = useCallback(() => {
+    setSelectedExternalUserGroup(undefined);
+    setDeleteModalShown(false);
+  }, []);
+
+  const updateExternalUserGroup = useCallback(async(userGroupData: IExternalUserGroupHasId) => {
+    try {
+      await apiv3Put(`/external-user-groups/${userGroupData._id}`, {
+        description: userGroupData.description,
+      });
+
+      toastSuccess(t('toaster.update_successed', { target: t('ExternalUserGroup'), ns: 'commons' }));
+
+      await mutateExternalUserGroups();
+
+      hideUpdateModal();
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }, [t, mutateExternalUserGroups, hideUpdateModal]);
+
+  const deleteExternalUserGroupById = useCallback(async(deleteGroupId: string, actionName: string, transferToUserGroupId: string) => {
+    try {
+      await apiv3Delete(`/external-user-groups/${deleteGroupId}`, {
+        actionName,
+        transferToUserGroupId,
+      });
+
+      // sync
+      await mutateExternalUserGroups();
+
+      hideDeleteModal();
+
+      toastSuccess(`Deleted ${selectedExternalUserGroup?.name} group.`);
+    }
+    catch (err) {
+      toastError(new Error('Unable to delete the groups'));
+    }
+  }, [mutateExternalUserGroups, selectedExternalUserGroup, hideDeleteModal]);
+
+  const switchActiveTab = (selectedTab) => {
+    setActiveTab(selectedTab);
+    setActiveComponents(activeComponents.add(selectedTab));
+  };
+
+  const navTabMapping = useMemo(() => {
+    return {
+      ldap: {
+        Icon: () => <i className="fa fa-sitemap" />,
+        i18n: 'LDAP',
+      },
+      keycloak: {
+        Icon: () => <i className="fa fa-key" />,
+        i18n: 'Keycloak',
+      },
+    };
+  }, []);
+
+  return (
+    <>
+      <h2 className="border-bottom">{t('external_user_group.management')}</h2>
+      <UserGroupTable
+        headerLabel={t('admin:user_group_management.group_list')}
+        userGroups={externalUserGroups}
+        childUserGroups={childExternalUserGroups}
+        isAclEnabled={isAclEnabled ?? false}
+        onEdit={showUpdateModal}
+        onDelete={showDeleteModal}
+        userGroupRelations={externalUserGroupRelations}
+        isExternalGroup
+      />
+
+      <UserGroupModal
+        userGroup={selectedExternalUserGroup}
+        buttonLabel={t('Update')}
+        onClickSubmit={updateExternalUserGroup}
+        isShow={isUpdateModalShown}
+        onHide={hideUpdateModal}
+        isExternalGroup
+      />
+
+      <UserGroupDeleteModal
+        userGroups={externalUserGroups}
+        deleteUserGroup={selectedExternalUserGroup}
+        onDelete={deleteExternalUserGroupById}
+        isShow={isDeleteModalShown}
+        onHide={hideDeleteModal}
+      />
+
+      <CustomNav
+        activeTab={activeTab}
+        navTabMapping={navTabMapping}
+        onNavSelected={switchActiveTab}
+        hideBorderBottom
+        breakpointToSwitchDropdownDown="md"
+      />
+      <TabContent activeTab={activeTab} className="p-5">
+        <TabPane tabId="ldap">
+          {activeComponents.has('ldap') && <LdapGroupManagement />}
+        </TabPane>
+        <TabPane tabId="keycloak">
+          {activeComponents.has('keycloak') && <KeycloakGroupManagement />}
+        </TabPane>
+      </TabContent>
+    </>
+  );
+};

+ 21 - 0
apps/app/src/features/external-user-group/client/components/ExternalUserGroup/KeycloakGroupManagement.tsx

@@ -0,0 +1,21 @@
+import { FC, useCallback } from 'react';
+
+import { apiv3Put } from '~/client/util/apiv3-client';
+import { ExternalGroupProviderType } from '~/features/external-user-group/interfaces/external-user-group';
+
+import { KeycloakGroupSyncSettingsForm } from './KeycloakGroupSyncSettingsForm';
+import { SyncExecution } from './SyncExecution';
+
+export const KeycloakGroupManagement: FC = () => {
+
+  const requestSyncAPI = useCallback(async() => {
+    await apiv3Put('/external-user-groups/keycloak/sync');
+  }, []);
+
+  return (
+    <>
+      <KeycloakGroupSyncSettingsForm />
+      <SyncExecution provider={ExternalGroupProviderType.keycloak} requestSyncAPI={requestSyncAPI} />
+    </>
+  );
+};

+ 241 - 0
apps/app/src/features/external-user-group/client/components/ExternalUserGroup/KeycloakGroupSyncSettingsForm.tsx

@@ -0,0 +1,241 @@
+import {
+  FC, useCallback, useEffect, useState,
+} from 'react';
+
+import { useTranslation } from 'react-i18next';
+
+import { apiv3Put } from '~/client/util/apiv3-client';
+import { toastError, toastSuccess } from '~/client/util/toastr';
+import { useSWRxKeycloakGroupSyncSettings } from '~/features/external-user-group/client/stores/external-user-group';
+import { KeycloakGroupSyncSettings } from '~/features/external-user-group/interfaces/external-user-group';
+
+export const KeycloakGroupSyncSettingsForm: FC = () => {
+  const { t } = useTranslation('admin');
+
+  const { data: keycloakGroupSyncSettings } = useSWRxKeycloakGroupSyncSettings();
+
+  const [formValues, setFormValues] = useState<KeycloakGroupSyncSettings>({
+    keycloakHost: '',
+    keycloakGroupRealm: '',
+    keycloakGroupSyncClientRealm: '',
+    keycloakGroupSyncClientID: '',
+    keycloakGroupSyncClientSecret: '',
+    autoGenerateUserOnKeycloakGroupSync: false,
+    preserveDeletedKeycloakGroups: false,
+    keycloakGroupDescriptionAttribute: '',
+  });
+
+  useEffect(() => {
+    if (keycloakGroupSyncSettings != null) {
+      setFormValues(keycloakGroupSyncSettings);
+    }
+  }, [keycloakGroupSyncSettings, setFormValues]);
+
+  const submitHandler = useCallback(async(e) => {
+    e.preventDefault();
+    try {
+      await apiv3Put('/external-user-groups/keycloak/sync-settings', formValues);
+      toastSuccess(t('external_user_group.keycloak.updated_group_sync_settings'));
+    }
+    catch (errs) {
+      toastError(t(errs[0]?.message));
+    }
+  }, [formValues, t]);
+
+  return (
+    <>
+      <h3 className="border-bottom mb-3">{t('external_user_group.keycloak.group_sync_settings')}</h3>
+      <form onSubmit={submitHandler}>
+        <div className="row form-group">
+          <label
+            htmlFor="keycloakHost"
+            className="text-left text-md-right col-md-3 col-form-label"
+          >
+            {t('external_user_group.keycloak.host')}
+          </label>
+          <div className="col-md-6">
+            <input
+              className="form-control"
+              type="text"
+              name="keycloakHost"
+              id="keycloakHost"
+              value={formValues.keycloakHost}
+              onChange={e => setFormValues({ ...formValues, keycloakHost: e.target.value })}
+            />
+            <p className="form-text text-muted">
+              <small>{t('external_user_group.keycloak.host_detail')}</small>
+            </p>
+          </div>
+        </div>
+        <div className="row form-group">
+          <label htmlFor="keycloakGroupRealm" className="text-left text-md-right col-md-3 col-form-label">
+            {t('external_user_group.keycloak.group_realm')}
+          </label>
+          <div className="col-md-6">
+            <input
+              className="form-control"
+              required
+              type="text"
+              name="keycloakGroupRealm"
+              id="keycloakGroupRealm"
+              value={formValues.keycloakGroupRealm}
+              onChange={e => setFormValues({ ...formValues, keycloakGroupRealm: e.target.value })}
+            />
+            <p className="form-text text-muted">
+              <small>
+                {t('external_user_group.keycloak.group_realm_detail')} <br />
+              </small>
+            </p>
+          </div>
+        </div>
+        <div className="row form-group">
+          <label htmlFor="keycloakGroupSyncClientRealm" className="text-left text-md-right col-md-3 col-form-label">
+            {t('external_user_group.keycloak.group_sync_client_realm')}
+          </label>
+          <div className="col-md-6">
+            <input
+              className="form-control"
+              required
+              type="text"
+              name="keycloakGroupSyncClientRealm"
+              id="keycloakGroupSyncClientRealm"
+              value={formValues.keycloakGroupSyncClientRealm}
+              onChange={e => setFormValues({ ...formValues, keycloakGroupSyncClientRealm: e.target.value })}
+            />
+            <p className="form-text text-muted">
+              <small>
+                {t('external_user_group.keycloak.group_sync_client_realm_detail')} <br />
+              </small>
+            </p>
+          </div>
+        </div>
+        <div className="row form-group">
+          <label htmlFor="keycloakGroupSyncClientID" className="text-left text-md-right col-md-3 col-form-label">
+            {t('external_user_group.keycloak.group_sync_client_id')}
+          </label>
+          <div className="col-md-6">
+            <input
+              className="form-control"
+              required
+              type="text"
+              name="keycloakGroupSyncClientID"
+              id="keycloakGroupSyncClientID"
+              value={formValues.keycloakGroupSyncClientID}
+              onChange={e => setFormValues({ ...formValues, keycloakGroupSyncClientID: e.target.value })}
+            />
+            <p className="form-text text-muted">
+              <small>
+                {t('external_user_group.keycloak.group_sync_client_id_detail')} <br />
+              </small>
+            </p>
+          </div>
+        </div>
+        <div className="row form-group">
+          <label htmlFor="keycloakGroupSyncClientSecret" className="text-left text-md-right col-md-3 col-form-label">
+            {t('external_user_group.keycloak.group_sync_client_secret')}
+          </label>
+          <div className="col-md-6">
+            <input
+              className="form-control"
+              required
+              type="text"
+              name="keycloakGroupSyncClientSecret"
+              id="keycloakGroupSyncClientSecret"
+              value={formValues.keycloakGroupSyncClientSecret}
+              onChange={e => setFormValues({ ...formValues, keycloakGroupSyncClientSecret: e.target.value })}
+            />
+            <p className="form-text text-muted">
+              <small>
+                {t('external_user_group.keycloak.group_sync_client_secret_detail')} <br />
+              </small>
+            </p>
+          </div>
+        </div>
+        <div className="row form-group">
+          <label
+            className="text-left text-md-right col-md-3 col-form-label"
+          >
+            {/* {t('external_user_group.auto_generate_user_on_sync')} */}
+          </label>
+          <div className="col-md-6">
+            <div className="custom-control custom-checkbox custom-checkbox-info">
+              <input
+                type="checkbox"
+                className="custom-control-input"
+                name="autoGenerateUserOnKeycloakGroupSync"
+                id="autoGenerateUserOnKeycloakGroupSync"
+                checked={formValues.autoGenerateUserOnKeycloakGroupSync}
+                onChange={() => setFormValues({ ...formValues, autoGenerateUserOnKeycloakGroupSync: !formValues.autoGenerateUserOnKeycloakGroupSync })}
+              />
+              <label
+                className="custom-control-label"
+                htmlFor="autoGenerateUserOnKeycloakGroupSync"
+              >
+                {t('external_user_group.auto_generate_user_on_sync')}
+              </label>
+            </div>
+          </div>
+        </div>
+        <div className="row form-group">
+          <label
+            className="text-left text-md-right col-md-3 col-form-label"
+          >
+            {/* {t('external_user_group.keycloak.preserve_deleted_keycloak_groups')} */}
+          </label>
+          <div className="col-md-6">
+            <div className="custom-control custom-checkbox custom-checkbox-info">
+              <input
+                type="checkbox"
+                className="custom-control-input"
+                name="preserveDeletedKeycloakGroups"
+                id="preserveDeletedKeycloakGroups"
+                checked={formValues.preserveDeletedKeycloakGroups}
+                onChange={() => setFormValues({ ...formValues, preserveDeletedKeycloakGroups: !formValues.preserveDeletedKeycloakGroups })}
+              />
+              <label
+                className="custom-control-label"
+                htmlFor="preserveDeletedKeycloakGroups"
+              >
+                {t('external_user_group.keycloak.preserve_deleted_keycloak_groups')}
+              </label>
+            </div>
+          </div>
+        </div>
+        <div className="px-5">
+          <h4 className="border-bottom mb-3">Attribute Mapping ({t('optional')})</h4>
+        </div>
+        <div className="row form-group">
+          <label htmlFor="keycloakGroupDescriptionAttribute" className="text-left text-md-right col-md-3 col-form-label">
+            {t('Description')}
+          </label>
+          <div className="col-md-6">
+            <input
+              className="form-control"
+              type="text"
+              name="keycloakGroupDescriptionAttribute"
+              id="keycloakGroupDescriptionAttribute"
+              value={formValues.keycloakGroupDescriptionAttribute || ''}
+              onChange={e => setFormValues({ ...formValues, keycloakGroupDescriptionAttribute: e.target.value })}
+            />
+            <p className="form-text text-muted">
+              <small>
+                {t('external_user_group.description_mapper_detail')}
+              </small>
+            </p>
+          </div>
+        </div>
+
+        <div className="row my-3">
+          <div className="offset-3 col-5">
+            <button
+              type="submit"
+              className="btn btn-primary"
+            >
+              {t('Update')}
+            </button>
+          </div>
+        </div>
+      </form>
+    </>
+  );
+};

+ 67 - 0
apps/app/src/features/external-user-group/client/components/ExternalUserGroup/LdapGroupManagement.tsx

@@ -0,0 +1,67 @@
+import {
+  FC, useCallback, useEffect, useState,
+} from 'react';
+
+import { useTranslation } from 'react-i18next';
+
+import { apiv3Get, apiv3Put } from '~/client/util/apiv3-client';
+import { toastError } from '~/client/util/toastr';
+import { ExternalGroupProviderType } from '~/features/external-user-group/interfaces/external-user-group';
+
+import { LdapGroupSyncSettingsForm } from './LdapGroupSyncSettingsForm';
+import { SyncExecution } from './SyncExecution';
+
+export const LdapGroupManagement: FC = () => {
+  const [isUserBind, setIsUserBind] = useState(false);
+  const { t } = useTranslation('admin');
+
+  useEffect(() => {
+    const getIsUserBind = async() => {
+      try {
+        const response = await apiv3Get('/security-setting/');
+        const { ldapAuth } = response.data.securityParams;
+        setIsUserBind(ldapAuth.isUserBind);
+      }
+      catch (e) {
+        toastError(e);
+      }
+    };
+    getIsUserBind();
+  }, []);
+
+  const requestSyncAPI = useCallback(async(e) => {
+    if (isUserBind) {
+      const password = e.target.password?.value;
+      await apiv3Put('/external-user-groups/ldap/sync', { password });
+    }
+    else {
+      await apiv3Put('/external-user-groups/ldap/sync');
+    }
+  }, [isUserBind]);
+
+  const AdditionalForm = (): JSX.Element => {
+    return isUserBind ? (
+      <div className="row form-group">
+        <label htmlFor="ldapGroupSyncPassword" className="text-left text-md-right col-md-3 col-form-label">{t('external_user_group.ldap.password')}</label>
+        <div className="col-md-6">
+          <input
+            className="form-control"
+            type="password"
+            name="password"
+            id="ldapGroupSyncPassword"
+          />
+          <p className="form-text text-muted">
+            <small>{t('external_user_group.ldap.password_detail')}</small>
+          </p>
+        </div>
+      </div>
+    ) : <></>;
+  };
+
+  return (
+    <>
+      <LdapGroupSyncSettingsForm />
+      <SyncExecution provider={ExternalGroupProviderType.ldap} requestSyncAPI={requestSyncAPI} AdditionalForm={AdditionalForm} />
+    </>
+  );
+};

+ 247 - 0
apps/app/src/features/external-user-group/client/components/ExternalUserGroup/LdapGroupSyncSettingsForm.tsx

@@ -0,0 +1,247 @@
+import {
+  FC, useCallback, useEffect, useState,
+} from 'react';
+
+import { useTranslation } from 'react-i18next';
+
+import { apiv3Put } from '~/client/util/apiv3-client';
+import { toastError, toastSuccess } from '~/client/util/toastr';
+import { useSWRxLdapGroupSyncSettings } from '~/features/external-user-group/client/stores/external-user-group';
+import { LdapGroupMembershipAttributeType, LdapGroupSyncSettings } from '~/features/external-user-group/interfaces/external-user-group';
+
+export const LdapGroupSyncSettingsForm: FC = () => {
+  const { t } = useTranslation('admin');
+
+  const { data: ldapGroupSyncSettings } = useSWRxLdapGroupSyncSettings();
+
+  const [formValues, setFormValues] = useState<LdapGroupSyncSettings>({
+    ldapGroupSearchBase: '',
+    ldapGroupMembershipAttribute: '',
+    ldapGroupMembershipAttributeType: LdapGroupMembershipAttributeType.dn,
+    ldapGroupChildGroupAttribute: '',
+    autoGenerateUserOnLdapGroupSync: false,
+    preserveDeletedLdapGroups: false,
+    ldapGroupNameAttribute: '',
+    ldapGroupDescriptionAttribute: '',
+  });
+
+  useEffect(() => {
+    if (ldapGroupSyncSettings != null) {
+      setFormValues(ldapGroupSyncSettings);
+    }
+  }, [ldapGroupSyncSettings, setFormValues]);
+
+  const submitHandler = useCallback(async(e) => {
+    e.preventDefault();
+    try {
+      await apiv3Put('/external-user-groups/ldap/sync-settings', formValues);
+      toastSuccess(t('external_user_group.ldap.updated_group_sync_settings'));
+    }
+    catch (errs) {
+      toastError(t(errs[0]?.code));
+    }
+  }, [formValues, t]);
+
+  return (
+    <>
+      <h3 className="border-bottom mb-3">{t('external_user_group.ldap.group_sync_settings')}</h3>
+      <form onSubmit={submitHandler}>
+        <div className="row form-group">
+          <label
+            htmlFor="ldapGroupSearchBase"
+            className="text-left text-md-right col-md-3 col-form-label"
+          >
+            {t('external_user_group.ldap.group_search_base_DN')}
+          </label>
+          <div className="col-md-6">
+            <input
+              className="form-control"
+              type="text"
+              name="ldapGroupSearchBase"
+              id="ldapGroupSearchBase"
+              value={formValues.ldapGroupSearchBase}
+              onChange={e => setFormValues({ ...formValues, ldapGroupSearchBase: e.target.value })}
+            />
+            <p className="form-text text-muted">
+              <small>{t('external_user_group.ldap.group_search_base_dn_detail')}</small>
+            </p>
+          </div>
+        </div>
+        <div className="row form-group">
+          <label htmlFor="ldapGroupMembershipAttribute" className="text-left text-md-right col-md-3 col-form-label">
+            {t('external_user_group.ldap.membership_attribute')}
+          </label>
+          <div className="col-md-6">
+            <input
+              className="form-control"
+              required
+              type="text"
+              name="ldapGroupMembershipAttribute"
+              id="ldapGroupMembershipAttribute"
+              value={formValues.ldapGroupMembershipAttribute}
+              onChange={e => setFormValues({ ...formValues, ldapGroupMembershipAttribute: e.target.value })}
+            />
+            <p className="form-text text-muted">
+              <small>
+                {t('external_user_group.ldap.membership_attribute_detail')} <br />
+                e.g.) <code>member</code>, <code>memberUid</code>
+              </small>
+            </p>
+          </div>
+        </div>
+        <div className="row form-group">
+          <label htmlFor="ldapGroupMembershipAttributeType" className="text-left text-md-right col-md-3 col-form-label">
+            {t('external_user_group.ldap.membership_attribute_type')}
+          </label>
+          <div className="col-md-6">
+            <select
+              className="form-control"
+              required
+              name="ldapGroupMembershipAttributeType"
+              id="ldapGroupMembershipAttributeType"
+              value={formValues.ldapGroupMembershipAttributeType}
+              onChange={(e) => {
+                if (e.target.value === LdapGroupMembershipAttributeType.dn || e.target.value === LdapGroupMembershipAttributeType.uid) {
+                  setFormValues({ ...formValues, ldapGroupMembershipAttributeType: e.target.value });
+                }
+              }}
+            >
+              <option value="DN">DN</option>
+              <option value="UID">UID</option>
+            </select>
+            <p className="form-text text-muted">
+              <small>
+                {t('external_user_group.ldap.membership_attribute_type_detail')}
+              </small>
+            </p>
+          </div>
+        </div>
+        <div className="row form-group">
+          <label htmlFor="ldapGroupChildGroupAttribute" className="text-left text-md-right col-md-3 col-form-label">
+            {t('external_user_group.ldap.child_group_attribute')}
+          </label>
+          <div className="col-md-6">
+            <input
+              className="form-control"
+              required
+              type="text"
+              name="ldapGroupChildGroupAttribute"
+              id="ldapGroupChildGroupAttribute"
+              value={formValues.ldapGroupChildGroupAttribute}
+              onChange={e => setFormValues({ ...formValues, ldapGroupChildGroupAttribute: e.target.value })}
+            />
+            <p className="form-text text-muted">
+              <small>
+                {t('external_user_group.ldap.child_group_attribute_detail')}<br />
+                e.g.) <code>member</code>
+              </small>
+            </p>
+          </div>
+        </div>
+        <div className="row form-group">
+          <label
+            className="text-left text-md-right col-md-3 col-form-label"
+          >
+            {/* {t('external_user_group.auto_generate_user_on_sync')} */}
+          </label>
+          <div className="col-md-6">
+            <div className="custom-control custom-checkbox custom-checkbox-info">
+              <input
+                type="checkbox"
+                className="custom-control-input"
+                name="autoGenerateUserOnLdapGroupSync"
+                id="autoGenerateUserOnLdapGroupSync"
+                checked={formValues.autoGenerateUserOnLdapGroupSync}
+                onChange={() => setFormValues({ ...formValues, autoGenerateUserOnLdapGroupSync: !formValues.autoGenerateUserOnLdapGroupSync })}
+              />
+              <label
+                className="custom-control-label"
+                htmlFor="autoGenerateUserOnLdapGroupSync"
+              >
+                {t('external_user_group.auto_generate_user_on_sync')}
+              </label>
+            </div>
+          </div>
+        </div>
+        <div className="row form-group">
+          <label
+            className="text-left text-md-right col-md-3 col-form-label"
+          >
+            {/* {t('external_user_group.ldap.preserve_deleted_ldap_groups')} */}
+          </label>
+          <div className="col-md-6">
+            <div className="custom-control custom-checkbox custom-checkbox-info">
+              <input
+                type="checkbox"
+                className="custom-control-input"
+                name="preserveDeletedLdapGroups"
+                id="preserveDeletedLdapGroups"
+                checked={formValues.preserveDeletedLdapGroups}
+                onChange={() => setFormValues({ ...formValues, preserveDeletedLdapGroups: !formValues.preserveDeletedLdapGroups })}
+              />
+              <label
+                className="custom-control-label"
+                htmlFor="preserveDeletedLdapGroups"
+              >
+                {t('external_user_group.ldap.preserve_deleted_ldap_groups')}
+              </label>
+            </div>
+          </div>
+        </div>
+        <div className="px-5">
+          <h4 className="border-bottom mb-3">Attribute Mapping ({t('optional')})</h4>
+        </div>
+        <div className="row form-group">
+          <label htmlFor="ldapGroupNameAttribute" className="text-left text-md-right col-md-3 col-form-label">{t('Name')}</label>
+          <div className="col-md-6">
+            <input
+              className="form-control"
+              type="text"
+              name="ldapGroupNameAttribute"
+              id="ldapGroupNameAttribute"
+              value={formValues.ldapGroupNameAttribute}
+              onChange={e => setFormValues({ ...formValues, ldapGroupNameAttribute: e.target.value })}
+              placeholder="Default: cn"
+            />
+            <p className="form-text text-muted">
+              <small>
+                {t('external_user_group.ldap.name_mapper_detail')}
+              </small>
+            </p>
+          </div>
+        </div>
+        <div className="row form-group">
+          <label htmlFor="ldapGroupDescriptionAttribute" className="text-left text-md-right col-md-3 col-form-label">
+            {t('Description')}
+          </label>
+          <div className="col-md-6">
+            <input
+              className="form-control"
+              type="text"
+              name="ldapGroupDescriptionAttribute"
+              id="ldapGroupDescriptionAttribute"
+              value={formValues.ldapGroupDescriptionAttribute || ''}
+              onChange={e => setFormValues({ ...formValues, ldapGroupDescriptionAttribute: e.target.value })}
+            />
+            <p className="form-text text-muted">
+              <small>
+                {t('external_user_group.description_mapper_detail')}
+              </small>
+            </p>
+          </div>
+        </div>
+
+        <div className="row my-3">
+          <div className="offset-3 col-5">
+            <button
+              type="submit"
+              className="btn btn-primary"
+            >
+              {t('Update')}
+            </button>
+          </div>
+        </div>
+      </form>
+    </>
+  );
+};

+ 172 - 0
apps/app/src/features/external-user-group/client/components/ExternalUserGroup/SyncExecution.tsx

@@ -0,0 +1,172 @@
+import {
+  FC, useCallback, useEffect, useState,
+} from 'react';
+
+import { useTranslation } from 'react-i18next';
+import { Modal, ModalHeader, ModalBody } from 'reactstrap';
+
+import { apiv3Get } from '~/client/util/apiv3-client';
+import { toastError, toastSuccess } from '~/client/util/toastr';
+import LabeledProgressBar from '~/components/Admin/Common/LabeledProgressBar';
+import { ExternalGroupProviderType } from '~/features/external-user-group/interfaces/external-user-group';
+import { SocketEventName } from '~/interfaces/websocket';
+import { useAdminSocket } from '~/stores/socket-io';
+
+import { useSWRxExternalUserGroupList } from '../../stores/external-user-group';
+
+type SyncExecutionProps = {
+  provider: ExternalGroupProviderType
+  requestSyncAPI: (e?: React.FormEvent<HTMLFormElement>) => Promise<void>
+  AdditionalForm?: FC
+}
+
+enum SyncStatus {
+  beforeSync,
+  syncExecuting,
+  syncCompleted,
+  syncFailed,
+}
+
+export const SyncExecution = ({
+  provider,
+  requestSyncAPI,
+  AdditionalForm = () => <></>,
+}: SyncExecutionProps): JSX.Element => {
+  const { t } = useTranslation('admin');
+  const { data: socket } = useAdminSocket();
+  const { mutate: mutateExternalUserGroups } = useSWRxExternalUserGroupList();
+  const [syncStatus, setSyncStatus] = useState<SyncStatus>(SyncStatus.beforeSync);
+  const [progress, setProgress] = useState({
+    total: 0,
+    current: 0,
+  });
+  const [isAlertModalOpen, setIsAlertModalOpen] = useState(false);
+  // value to propagate the submit event of form to submit confirm modal
+  const [currentSubmitEvent, setCurrentSubmitEvent] = useState<React.FormEvent<HTMLFormElement>>();
+
+  useEffect(() => {
+    if (socket == null) return;
+
+    const eventName = SocketEventName.externalUserGroup[provider];
+
+    socket.on(eventName.GroupSyncProgress, (data) => {
+      setSyncStatus(SyncStatus.syncExecuting);
+      setProgress({
+        total: data.totalCount,
+        current: data.count,
+      });
+    });
+
+    socket.on(eventName.GroupSyncCompleted, () => {
+      setSyncStatus(SyncStatus.syncCompleted);
+      mutateExternalUserGroups();
+      toastSuccess(t('external_user_group.sync_succeeded'));
+    });
+
+    socket.on(eventName.GroupSyncFailed, () => {
+      setSyncStatus(SyncStatus.syncFailed);
+      mutateExternalUserGroups();
+      toastError(t('external_user_group.sync_failed'));
+    });
+
+    return () => {
+      socket.off(eventName.GroupSyncProgress);
+      socket.off(eventName.GroupSyncCompleted);
+      socket.off(eventName.GroupSyncFailed);
+    };
+  }, [socket, mutateExternalUserGroups, t, provider]);
+
+  // get sync status on load, since next socket data may take a while
+  useEffect(() => {
+    const getSyncStatus = async() => {
+      const res = await apiv3Get(`/external-user-groups/${provider}/sync-status`);
+      if (res.data.isExecutingSync) {
+        setSyncStatus(SyncStatus.syncExecuting);
+        setProgress({ total: res.data.totalCount, current: res.data.count });
+      }
+    };
+    getSyncStatus();
+  }, [provider]);
+
+  const onSyncBtnClick = (e: React.FormEvent<HTMLFormElement>) => {
+    e.preventDefault();
+    setCurrentSubmitEvent(e);
+    setIsAlertModalOpen(true);
+  };
+
+  const onSyncExecConfirmBtnClick = useCallback(async() => {
+    setIsAlertModalOpen(false);
+    try {
+      // set sync status before requesting to API, so that setting to syncFailed does not get overwritten
+      setSyncStatus(SyncStatus.syncExecuting);
+      setProgress({ total: 0, current: 0 });
+      await requestSyncAPI(currentSubmitEvent);
+    }
+    catch (errs) {
+      setSyncStatus(SyncStatus.syncFailed);
+      toastError(t(errs[0]?.code));
+    }
+  }, [t, requestSyncAPI, currentSubmitEvent]);
+
+  const renderProgressBar = () => {
+    if (syncStatus === SyncStatus.beforeSync) return null;
+
+    let header;
+    if (syncStatus === SyncStatus.syncExecuting) {
+      header = 'Processing..';
+    }
+    else if (syncStatus === SyncStatus.syncCompleted) {
+      header = 'Completed';
+    }
+    else {
+      header = 'Failed';
+    }
+
+    return (
+      <LabeledProgressBar
+        header={header}
+        currentCount={progress.current}
+        totalCount={progress.total}
+      />
+    );
+  };
+
+  return (
+    <>
+      <h3 className="border-bottom mb-3">{t('external_user_group.execute_sync')}</h3>
+      <div className="row">
+        <div className="col-md-3"></div>
+        <div className="col-md-9">
+          {renderProgressBar()}
+        </div>
+      </div>
+      <form onSubmit={onSyncBtnClick}>
+        <AdditionalForm />
+        <div className="row">
+          <div className="col-md-3"></div>
+          <div className="col-md-6"><button className="btn btn-primary" type="submit">{t('external_user_group.sync')}</button></div>
+        </div>
+      </form>
+
+      <Modal
+        className="select-grant-group"
+        isOpen={isAlertModalOpen}
+        toggle={() => setIsAlertModalOpen(false)}
+      >
+        <ModalHeader tag="h4" toggle={() => setIsAlertModalOpen(false)} className="bg-purple text-light">
+          <i className="icon-fw icon-exclamation align-middle"></i>
+          <span className="align-middle">{t('external_user_group.confirmation_before_sync')}</span>
+        </ModalHeader>
+        <ModalBody>
+          <ul>
+            <li>{t('external_user_group.execution_time_warning')}</li>
+            <li>{t('external_user_group.parallel_sync_forbidden')}</li>
+          </ul>
+          <div className="text-center">
+            <button className="btn btn-primary" type="button" onClick={onSyncExecConfirmBtnClick}>{t('Execute')}</button>
+          </div>
+        </ModalBody>
+      </Modal>
+    </>
+  );
+};

+ 106 - 0
apps/app/src/features/external-user-group/client/stores/external-user-group.ts

@@ -0,0 +1,106 @@
+import { type SWRResponseWithUtils, withUtils } from '@growi/core/dist/swr';
+import useSWR, { SWRResponse } from 'swr';
+import useSWRImmutable from 'swr/immutable';
+
+import { apiv3Get, apiv3Put } from '~/client/util/apiv3-client';
+import {
+  IExternalUserGroupHasId, IExternalUserGroupRelationHasId, KeycloakGroupSyncSettings, LdapGroupSyncSettings,
+} from '~/features/external-user-group/interfaces/external-user-group';
+import {
+  ChildUserGroupListResult, IUserGroupRelationHasIdPopulatedUser, UserGroupListResult, UserGroupRelationListResult,
+} from '~/interfaces/user-group-response';
+
+export const useSWRxLdapGroupSyncSettings = (): SWRResponse<LdapGroupSyncSettings, Error> => {
+  return useSWR(
+    '/external-user-groups/ldap/sync-settings',
+    endpoint => apiv3Get(endpoint).then((response) => {
+      return response.data;
+    }),
+  );
+};
+
+export const useSWRxKeycloakGroupSyncSettings = (): SWRResponse<KeycloakGroupSyncSettings, Error> => {
+  return useSWR(
+    '/external-user-groups/keycloak/sync-settings',
+    endpoint => apiv3Get(endpoint).then((response) => {
+      return response.data;
+    }),
+  );
+};
+
+export const useSWRxMyExternalUserGroups = (shouldFetch: boolean): SWRResponse<IExternalUserGroupHasId[], Error> => {
+  return useSWR(
+    shouldFetch ? '/me/external-user-groups' : null,
+    endpoint => apiv3Get<UserGroupListResult<IExternalUserGroupHasId>>(endpoint).then(result => result.data.userGroups),
+  );
+};
+
+export const useSWRxExternalUserGroup = (groupId: string | null): SWRResponse<IExternalUserGroupHasId, Error> => {
+  return useSWRImmutable(
+    groupId != null ? `/external-user-groups/${groupId}` : null,
+    endpoint => apiv3Get(endpoint).then(result => result.data.userGroup),
+  );
+};
+
+export const useSWRxExternalUserGroupList = (initialData?: IExternalUserGroupHasId[]): SWRResponse<IExternalUserGroupHasId[], Error> => {
+  return useSWRImmutable(
+    '/external-user-groups',
+    endpoint => apiv3Get(endpoint, { pagination: false }).then(result => result.data.userGroups),
+    {
+      fallbackData: initialData,
+    },
+  );
+};
+
+type ChildExternalUserGroupListUtils = {
+  updateChild(childGroupData: IExternalUserGroupHasId): Promise<void>, // update one child and refresh list
+}
+export const useSWRxChildExternalUserGroupList = (
+    parentIds?: string[], includeGrandChildren?: boolean,
+): SWRResponseWithUtils<ChildExternalUserGroupListUtils, ChildUserGroupListResult<IExternalUserGroupHasId>, Error> => {
+  const shouldFetch = parentIds != null && parentIds.length > 0;
+
+  const swrResponse = useSWRImmutable(
+    shouldFetch ? ['/external-user-groups/children', parentIds, includeGrandChildren] : null,
+    ([endpoint, parentIds, includeGrandChildren]) => apiv3Get<ChildUserGroupListResult<IExternalUserGroupHasId>>(
+      endpoint, { parentIds, includeGrandChildren },
+    ).then((result => result.data)),
+  );
+
+  const updateChild = async(childGroupData: IExternalUserGroupHasId) => {
+    await apiv3Put(`/external-user-groups/${childGroupData._id}`, {
+      description: childGroupData.description,
+    });
+    swrResponse.mutate();
+  };
+
+  return withUtils(swrResponse, { updateChild });
+};
+
+export const useSWRxExternalUserGroupRelations = (groupId: string | null): SWRResponse<IUserGroupRelationHasIdPopulatedUser[], Error> => {
+  return useSWRImmutable(
+    groupId != null ? `/external-user-groups/${groupId}/external-user-group-relations` : null,
+    endpoint => apiv3Get(endpoint).then(result => result.data.userGroupRelations),
+  );
+};
+
+export const useSWRxExternalUserGroupRelationList = (
+    groupIds: string[] | null, childGroupIds?: string[], initialData?: IExternalUserGroupRelationHasId[],
+): SWRResponse<IExternalUserGroupRelationHasId[], Error> => {
+  return useSWRImmutable(
+    groupIds != null ? ['/external-user-group-relations', groupIds, childGroupIds] : null,
+    ([endpoint, groupIds, childGroupIds]) => apiv3Get<UserGroupRelationListResult<IExternalUserGroupRelationHasId>>(
+      endpoint, { groupIds, childGroupIds },
+    ).then(result => result.data.userGroupRelations),
+    {
+      fallbackData: initialData,
+    },
+  );
+};
+
+export const useSWRxAncestorExternalUserGroups = (groupId: string | null): SWRResponse<IExternalUserGroupHasId[], Error> => {
+  return useSWRImmutable(
+    groupId != null ? ['/external-user-groups/ancestors', groupId] : null,
+    ([endpoint, groupId]) => apiv3Get(endpoint, { groupId }).then(result => result.data.ancestorUserGroups),
+  );
+};

+ 62 - 0
apps/app/src/features/external-user-group/interfaces/external-user-group.ts

@@ -0,0 +1,62 @@
+import type {
+  HasObjectId, IUserGroupRelation, Ref, IUserGroup,
+} from '@growi/core';
+
+
+export const ExternalGroupProviderType = { ldap: 'ldap', keycloak: 'keycloak' } as const;
+export type ExternalGroupProviderType = typeof ExternalGroupProviderType[keyof typeof ExternalGroupProviderType];
+
+export interface IExternalUserGroup extends Omit<IUserGroup, 'parent'> {
+  parent: Ref<IExternalUserGroup> | null
+  externalId: string // identifier used in external app/server
+  provider: ExternalGroupProviderType
+}
+
+export type IExternalUserGroupHasId = IExternalUserGroup & HasObjectId;
+
+export interface IExternalUserGroupRelation extends Omit<IUserGroupRelation, 'relatedGroup'> {
+  relatedGroup: Ref<IExternalUserGroup>
+}
+
+export type IExternalUserGroupRelationHasId = IExternalUserGroupRelation & HasObjectId;
+
+export const LdapGroupMembershipAttributeType = { dn: 'DN', uid: 'UID' } as const;
+type LdapGroupMembershipAttributeType = typeof LdapGroupMembershipAttributeType[keyof typeof LdapGroupMembershipAttributeType];
+
+export interface LdapGroupSyncSettings {
+  ldapGroupSearchBase: string
+  ldapGroupMembershipAttribute: string
+  ldapGroupMembershipAttributeType: LdapGroupMembershipAttributeType
+  ldapGroupChildGroupAttribute: string
+  autoGenerateUserOnLdapGroupSync: boolean
+  preserveDeletedLdapGroups: boolean
+  ldapGroupNameAttribute: string
+  ldapGroupDescriptionAttribute?: string
+}
+
+export interface KeycloakGroupSyncSettings {
+  keycloakHost: string
+  keycloakGroupRealm: string
+  keycloakGroupSyncClientRealm: string
+  keycloakGroupSyncClientID: string
+  keycloakGroupSyncClientSecret: string
+  autoGenerateUserOnKeycloakGroupSync: boolean
+  preserveDeletedKeycloakGroups: boolean
+  keycloakGroupDescriptionAttribute?: string
+}
+
+export type ExternalUserInfo = {
+  id: string, // external user id
+  username: string,
+  name?: string,
+  email?: string,
+}
+
+// Data structure to express the tree structure of external groups, before converting to ExternalUserGroup model
+export interface ExternalUserGroupTreeNode {
+  id: string // external group id
+  userInfos: ExternalUserInfo[]
+  childGroupNodes: ExternalUserGroupTreeNode[]
+  name: string
+  description?: string
+}

+ 126 - 0
apps/app/src/features/external-user-group/server/models/external-user-group-relation.integ.ts

@@ -0,0 +1,126 @@
+import mongoose from 'mongoose';
+
+import ExternalUserGroup from './external-user-group';
+import ExternalUserGroupRelation from './external-user-group-relation';
+
+// TODO: use actual user model after ~/server/models/user.js becomes importable in vitest
+// ref: https://github.com/vitest-dev/vitest/issues/846
+const userSchema = new mongoose.Schema({
+  name: { type: String },
+  username: { type: String, required: true, unique: true },
+  email: { type: String, unique: true, sparse: true },
+}, {
+  timestamps: true,
+});
+const User = mongoose.model('User', userSchema);
+
+describe('ExternalUserGroupRelation model', () => {
+  let user1;
+  const userId1 = new mongoose.Types.ObjectId();
+
+  let user2;
+  const userId2 = new mongoose.Types.ObjectId();
+
+  const groupId1 = new mongoose.Types.ObjectId();
+  const groupId2 = new mongoose.Types.ObjectId();
+  const groupId3 = new mongoose.Types.ObjectId();
+
+  beforeAll(async() => {
+    user1 = await User.create({
+      _id: userId1, name: 'user1', username: 'user1', email: 'user1@example.com',
+    });
+
+    user2 = await User.create({
+      _id: userId2, name: 'user2', username: 'user2', email: 'user2@example.com',
+    });
+
+    await ExternalUserGroup.insertMany([
+      {
+        _id: groupId1, name: 'test group 1', externalId: 'testExternalId', provider: 'testProvider',
+      },
+      {
+        _id: groupId2, name: 'test group 2', externalId: 'testExternalId2', provider: 'testProvider',
+      },
+      {
+        _id: groupId3, name: 'test group 3', externalId: 'testExternalId3', provider: 'testProvider',
+      },
+    ]);
+  });
+
+  afterEach(async() => {
+    await ExternalUserGroupRelation.deleteMany();
+  });
+
+  describe('createRelations', () => {
+    it('creates relation for user', async() => {
+      await ExternalUserGroupRelation.createRelations([groupId1, groupId2], user1);
+      const relations = await ExternalUserGroupRelation.find();
+      const idCombinations = relations.map((relation) => {
+        return [relation.relatedGroup, relation.relatedUser];
+      });
+      expect(idCombinations).toStrictEqual([[groupId1, userId1], [groupId2, userId1]]);
+    });
+  });
+
+  describe('removeAllInvalidRelations', () => {
+    beforeAll(async() => {
+      const nonExistentGroupId1 = new mongoose.Types.ObjectId();
+      const nonExistentGroupId2 = new mongoose.Types.ObjectId();
+      await ExternalUserGroupRelation.createRelations([nonExistentGroupId1, nonExistentGroupId2], user1);
+    });
+
+    it('removes invalid relations', async() => {
+      const relationsBeforeRemoval = await ExternalUserGroupRelation.find();
+      expect(relationsBeforeRemoval.length).not.toBe(0);
+
+      await ExternalUserGroupRelation.removeAllInvalidRelations();
+
+      const relationsAfterRemoval = await ExternalUserGroupRelation.find();
+      expect(relationsAfterRemoval.length).toBe(0);
+    });
+  });
+
+  describe('findAllUserIdsForUserGroups', () => {
+    beforeAll(async() => {
+      await ExternalUserGroupRelation.createRelations([groupId1, groupId2], user1);
+      await ExternalUserGroupRelation.create({ relatedGroup: groupId3, relatedUser: user2._id });
+    });
+
+    it('finds all unique user ids for specified user groups', async() => {
+      const userIds = await ExternalUserGroupRelation.findAllUserIdsForUserGroups([groupId1, groupId2, groupId3]);
+      expect(userIds).toStrictEqual([userId1.toString(), user2._id.toString()]);
+    });
+  });
+
+  describe('findAllUserGroupIdsRelatedToUser', () => {
+    beforeAll(async() => {
+      await ExternalUserGroupRelation.createRelations([groupId1, groupId2], user1);
+      await ExternalUserGroupRelation.create({ relatedGroup: groupId3, relatedUser: user2._id });
+    });
+
+    it('finds all group ids related to user', async() => {
+      const groupIds = await ExternalUserGroupRelation.findAllUserGroupIdsRelatedToUser(user1);
+      expect(groupIds).toStrictEqual([groupId1, groupId2]);
+
+      const groupIds2 = await ExternalUserGroupRelation.findAllUserGroupIdsRelatedToUser(user2);
+      expect(groupIds2).toStrictEqual([groupId3]);
+    });
+  });
+
+  describe('findAllGroupsForUser', () => {
+    beforeAll(async() => {
+      await ExternalUserGroupRelation.createRelations([groupId1, groupId2], user1);
+      await ExternalUserGroupRelation.create({ relatedGroup: groupId3, relatedUser: user2._id });
+    });
+
+    it('finds all groups related to user', async() => {
+      const groups = await ExternalUserGroupRelation.findAllGroupsForUser(user1);
+      const groupIds = groups.map(group => group._id);
+      expect(groupIds).toStrictEqual([groupId1, groupId2]);
+
+      const groups2 = await ExternalUserGroupRelation.findAllGroupsForUser(user2);
+      const groupIds2 = groups2.map(group => group._id);
+      expect(groupIds2).toStrictEqual([groupId3]);
+    });
+  });
+});

+ 54 - 0
apps/app/src/features/external-user-group/server/models/external-user-group-relation.ts

@@ -0,0 +1,54 @@
+import { Schema, Model, Document } from 'mongoose';
+
+import { ObjectIdLike } from '~/server/interfaces/mongoose-utils';
+import UserGroupRelation from '~/server/models/user-group-relation';
+
+import { getOrCreateModel } from '../../../../server/util/mongoose-utils';
+import { IExternalUserGroupRelation } from '../../interfaces/external-user-group';
+
+import { ExternalUserGroupDocument } from './external-user-group';
+
+export interface ExternalUserGroupRelationDocument extends IExternalUserGroupRelation, Document {}
+
+export interface ExternalUserGroupRelationModel extends Model<ExternalUserGroupRelationDocument> {
+  [x:string]: any, // for old methods
+
+  PAGE_ITEMS: 50,
+
+  removeAllByUserGroups: (groupsToDelete: ExternalUserGroupDocument[]) => Promise<any>,
+
+  findAllUserIdsForUserGroups: (userGroupIds: ObjectIdLike[]) => Promise<string[]>,
+
+  findGroupsWithDescendantsByGroupAndUser: (group: ExternalUserGroupDocument, user) => Promise<ExternalUserGroupDocument[]>,
+
+  countByGroupIdsAndUser: (userGroupIds: ObjectIdLike[], userData) => Promise<number>
+
+  findAllGroupsForUser: (user) => Promise<ExternalUserGroupDocument[]>
+}
+
+const schema = new Schema<ExternalUserGroupRelationDocument, ExternalUserGroupRelationModel>({
+  relatedGroup: { type: Schema.Types.ObjectId, ref: 'ExternalUserGroup', required: true },
+  relatedUser: { type: Schema.Types.ObjectId, ref: 'User', required: true },
+}, {
+  timestamps: { createdAt: true, updatedAt: false },
+});
+
+schema.statics.createRelations = UserGroupRelation.createRelations;
+
+schema.statics.removeAllByUserGroups = UserGroupRelation.removeAllByUserGroups;
+
+schema.statics.findAllRelation = UserGroupRelation.findAllRelation;
+
+schema.statics.removeAllInvalidRelations = UserGroupRelation.removeAllInvalidRelations;
+
+schema.statics.findGroupsWithDescendantsByGroupAndUser = UserGroupRelation.findGroupsWithDescendantsByGroupAndUser;
+
+schema.statics.countByGroupIdsAndUser = UserGroupRelation.countByGroupIdsAndUser;
+
+schema.statics.findAllUserIdsForUserGroups = UserGroupRelation.findAllUserIdsForUserGroups;
+
+schema.statics.findAllUserGroupIdsRelatedToUser = UserGroupRelation.findAllUserGroupIdsRelatedToUser;
+
+schema.statics.findAllGroupsForUser = UserGroupRelation.findAllGroupsForUser;
+
+export default getOrCreateModel<ExternalUserGroupRelationDocument, ExternalUserGroupRelationModel>('ExternalUserGroupRelation', schema);

+ 73 - 0
apps/app/src/features/external-user-group/server/models/external-user-group.integ.ts

@@ -0,0 +1,73 @@
+import mongoose from 'mongoose';
+
+import ExternalUserGroup from './external-user-group';
+
+describe('ExternalUserGroup model', () => {
+  describe('findAndUpdateOrCreateGroup', () => {
+    const groupId = new mongoose.Types.ObjectId();
+    beforeAll(async() => {
+      await ExternalUserGroup.create({
+        _id: groupId, name: 'test group', externalId: 'testExternalId', provider: 'testProvider',
+      });
+    });
+
+    it('finds and updates existing group', async() => {
+      const group = await ExternalUserGroup.findAndUpdateOrCreateGroup('edited test group', 'testExternalId', 'testProvider');
+      expect(group.id).toBe(groupId.toString());
+      expect(group.name).toBe('edited test group');
+    });
+
+    it('creates new group with parent', async() => {
+      expect(await ExternalUserGroup.count()).toBe(1);
+      const newGroup = await ExternalUserGroup.findAndUpdateOrCreateGroup(
+        'new group', 'nonExistentExternalId', 'testProvider', undefined, groupId.toString(),
+      );
+      expect(await ExternalUserGroup.count()).toBe(2);
+      expect(newGroup.parent.toString()).toBe(groupId.toString());
+    });
+
+    it('throws error when parent does not exist', async() => {
+      try {
+        await ExternalUserGroup.findAndUpdateOrCreateGroup(
+          'new group', 'nonExistentExternalId', 'testProvider', undefined, new mongoose.Types.ObjectId(),
+        );
+      }
+      catch (e) {
+        expect(e.message).toBe('Parent does not exist.');
+      }
+    });
+  });
+
+  describe('findGroupsWithAncestorsRecursively', () => {
+    const childGroupId = new mongoose.Types.ObjectId();
+    const parentGroupId = new mongoose.Types.ObjectId();
+    const grandParentGroupId = new mongoose.Types.ObjectId();
+
+    beforeAll(async() => {
+      await ExternalUserGroup.deleteMany();
+      await ExternalUserGroup.create({
+        _id: grandParentGroupId, name: 'grand parent group', externalId: 'grandParentExternalId', provider: 'testProvider',
+      });
+      await ExternalUserGroup.create({
+        _id: parentGroupId, name: 'parent group', externalId: 'parentExternalId', provider: 'testProvider', parent: grandParentGroupId,
+      });
+      await ExternalUserGroup.create({
+        _id: childGroupId, name: 'child group', externalId: 'childExternalId', provider: 'testProvider', parent: parentGroupId,
+      });
+    });
+
+    it('finds ancestors for child', async() => {
+      const childGroup = await ExternalUserGroup.findById(childGroupId);
+      const groups = await ExternalUserGroup.findGroupsWithAncestorsRecursively(childGroup);
+      const groupIds = groups.map(group => group.id);
+      expect(groupIds).toStrictEqual([grandParentGroupId.toString(), parentGroupId.toString(), childGroupId.toString()]);
+    });
+
+    it('finds ancestors for child, excluding child', async() => {
+      const childGroup = await ExternalUserGroup.findById(childGroupId);
+      const groups = await ExternalUserGroup.findGroupsWithAncestorsRecursively(childGroup, []);
+      const groupIds = groups.map(group => group.id);
+      expect(groupIds).toStrictEqual([grandParentGroupId.toString(), parentGroupId.toString()]);
+    });
+  });
+});

+ 64 - 0
apps/app/src/features/external-user-group/server/models/external-user-group.ts

@@ -0,0 +1,64 @@
+import { Schema, Model, Document } from 'mongoose';
+import mongoosePaginate from 'mongoose-paginate-v2';
+
+import { IExternalUserGroup } from '~/features/external-user-group/interfaces/external-user-group';
+import UserGroup from '~/server/models/user-group';
+import { getOrCreateModel } from '~/server/util/mongoose-utils';
+
+export interface ExternalUserGroupDocument extends IExternalUserGroup, Document {}
+
+export interface ExternalUserGroupModel extends Model<ExternalUserGroupDocument> {
+  [x:string]: any, // for old methods
+
+  PAGE_ITEMS: 10,
+
+  findGroupsWithDescendantsRecursively: (groups, descendants?) => any,
+}
+
+const schema = new Schema<ExternalUserGroupDocument, ExternalUserGroupModel>({
+  name: { type: String, required: true },
+  parent: { type: Schema.Types.ObjectId, ref: 'ExternalUserGroup', index: true },
+  description: { type: String, default: '' },
+  externalId: { type: String, required: true, unique: true },
+  provider: { type: String, required: true },
+}, {
+  timestamps: true,
+});
+schema.plugin(mongoosePaginate);
+// group name should be unique for each provider
+schema.index({ name: 1, provider: 1 }, { unique: true });
+
+/**
+ * Find group that has specified externalId and update, or create one if it doesn't exist.
+ * @param name ExternalUserGroup name
+ * @param name ExternalUserGroup externalId
+ * @param name ExternalUserGroup provider
+ * @param name ExternalUserGroup description
+ * @param name ExternalUserGroup parentId
+ * @returns ExternalUserGroupDocument[]
+ */
+schema.statics.findAndUpdateOrCreateGroup = async function(name: string, externalId: string, provider: string, description?: string, parentId?: string) {
+  let parent: ExternalUserGroupDocument | null = null;
+  if (parentId != null) {
+    parent = await this.findOne({ _id: parentId });
+    if (parent == null) {
+      throw Error('Parent does not exist.');
+    }
+  }
+
+  return this.findOneAndUpdate({ externalId }, {
+    name, description, provider, parent,
+  }, { upsert: true, new: true });
+};
+
+schema.statics.findWithPagination = UserGroup.findWithPagination;
+
+schema.statics.findChildrenByParentIds = UserGroup.findChildrenByParentIds;
+
+schema.statics.findGroupsWithAncestorsRecursively = UserGroup.findGroupsWithAncestorsRecursively;
+
+schema.statics.findGroupsWithDescendantsRecursively = UserGroup.findGroupsWithDescendantsRecursively;
+
+schema.statics.findGroupsWithDescendantsById = UserGroup.findGroupsWithDescendantsById;
+
+export default getOrCreateModel<ExternalUserGroupDocument, ExternalUserGroupModel>('ExternalUserGroup', schema);

+ 55 - 0
apps/app/src/features/external-user-group/server/routes/apiv3/external-user-group-relation.ts

@@ -0,0 +1,55 @@
+import { ErrorV3 } from '@growi/core/dist/models';
+import { Router, Request } from 'express';
+
+import { IExternalUserGroupRelationHasId } from '~/features/external-user-group/interfaces/external-user-group';
+import ExternalUserGroupRelation from '~/features/external-user-group/server/models/external-user-group-relation';
+import Crowi from '~/server/crowi';
+import loggerFactory from '~/utils/logger';
+
+import { ApiV3Response } from '../../../../../server/routes/apiv3/interfaces/apiv3-response';
+
+const logger = loggerFactory('growi:routes:apiv3:user-group-relation'); // eslint-disable-line no-unused-vars
+
+const express = require('express');
+const { query } = require('express-validator');
+
+const { serializeUserGroupRelationSecurely } = require('~/server/models/serializers/user-group-relation-serializer');
+
+const router = express.Router();
+
+module.exports = (crowi: Crowi): Router => {
+  const loginRequiredStrictly = require('~/server/middlewares/login-required')(crowi);
+  const adminRequired = require('~/server/middlewares/admin-required')(crowi);
+
+  const validators = {
+    list: [
+      query('groupIds').isArray(),
+      query('childGroupIds').optional().isArray(),
+    ],
+  };
+
+  router.get('/', loginRequiredStrictly, adminRequired, validators.list, async(req: Request, res: ApiV3Response) => {
+    const { query } = req;
+
+    try {
+      const relations = await ExternalUserGroupRelation.find({ relatedGroup: { $in: query.groupIds } }).populate('relatedUser');
+
+      let relationsOfChildGroups: IExternalUserGroupRelationHasId[] | null = null;
+      if (Array.isArray(query.childGroupIds)) {
+        const _relationsOfChildGroups = await ExternalUserGroupRelation.find({ relatedGroup: { $in: query.childGroupIds } }).populate('relatedUser');
+        relationsOfChildGroups = _relationsOfChildGroups.map(relation => serializeUserGroupRelationSecurely(relation)); // serialize
+      }
+
+      const serialized = relations.map(relation => serializeUserGroupRelationSecurely(relation));
+
+      return res.apiv3({ userGroupRelations: serialized, relationsOfChildGroups });
+    }
+    catch (err) {
+      const msg = 'Error occurred in fetching user group relations';
+      logger.error('Error', err);
+      return res.apiv3Err(new ErrorV3(msg));
+    }
+  });
+
+  return router;
+};

+ 393 - 0
apps/app/src/features/external-user-group/server/routes/apiv3/external-user-group.ts

@@ -0,0 +1,393 @@
+import { GroupType } from '@growi/core';
+import { ErrorV3 } from '@growi/core/dist/models';
+import { Router, Request } from 'express';
+import {
+  body, param, query, validationResult,
+} from 'express-validator';
+
+import ExternalUserGroup from '~/features/external-user-group/server/models/external-user-group';
+import ExternalUserGroupRelation from '~/features/external-user-group/server/models/external-user-group-relation';
+import { SupportedAction } from '~/interfaces/activity';
+import Crowi from '~/server/crowi';
+import { generateAddActivityMiddleware } from '~/server/middlewares/add-activity';
+import { apiV3FormValidator } from '~/server/middlewares/apiv3-form-validator';
+import { serializeUserGroupRelationSecurely } from '~/server/models/serializers/user-group-relation-serializer';
+import { ApiV3Response } from '~/server/routes/apiv3/interfaces/apiv3-response';
+import { configManager } from '~/server/service/config-manager';
+import UserGroupService from '~/server/service/user-group';
+import loggerFactory from '~/utils/logger';
+
+const logger = loggerFactory('growi:routes:apiv3:external-user-group');
+
+const router = Router();
+
+interface AuthorizedRequest extends Request {
+  user?: any
+}
+
+module.exports = (crowi: Crowi): Router => {
+  const loginRequiredStrictly = require('~/server/middlewares/login-required')(crowi);
+  const adminRequired = require('~/server/middlewares/admin-required')(crowi);
+  const addActivity = generateAddActivityMiddleware(crowi);
+
+  const activityEvent = crowi.event('activity');
+
+  const isExecutingSync = () => {
+    return crowi.ldapUserGroupSyncService?.syncStatus?.isExecutingSync || crowi.keycloakUserGroupSyncService?.syncStatus?.isExecutingSync || false;
+  };
+
+  const validators = {
+    ldapSyncSettings: [
+      body('ldapGroupSearchBase').optional({ nullable: true }).isString(),
+      body('ldapGroupMembershipAttribute').exists({ checkFalsy: true }).isString(),
+      body('ldapGroupMembershipAttributeType').exists({ checkFalsy: true }).isString(),
+      body('ldapGroupChildGroupAttribute').exists({ checkFalsy: true }).isString(),
+      body('autoGenerateUserOnLdapGroupSync').exists().isBoolean(),
+      body('preserveDeletedLdapGroups').exists().isBoolean(),
+      body('ldapGroupNameAttribute').optional({ nullable: true }).isString(),
+      body('ldapGroupDescriptionAttribute').optional({ nullable: true }).isString(),
+    ],
+    keycloakSyncSettings: [
+      body('keycloakHost').exists({ checkFalsy: true }).isString(),
+      body('keycloakGroupRealm').exists({ checkFalsy: true }).isString(),
+      body('keycloakGroupSyncClientRealm').exists({ checkFalsy: true }).isString(),
+      body('keycloakGroupSyncClientID').exists({ checkFalsy: true }).isString(),
+      body('keycloakGroupSyncClientSecret').exists({ checkFalsy: true }).isString(),
+      body('autoGenerateUserOnKeycloakGroupSync').exists().isBoolean(),
+      body('preserveDeletedKeycloakGroups').exists().isBoolean(),
+      body('keycloakGroupDescriptionAttribute').optional({ nullable: true }).isString(),
+    ],
+    listChildren: [
+      query('parentIds').optional().isArray(),
+      query('includeGrandChildren').optional().isBoolean(),
+    ],
+    ancestorGroup: [
+      query('groupId').isString(),
+    ],
+    update: [
+      body('description').optional().isString(),
+    ],
+    delete: [
+      param('id').trim().exists({ checkFalsy: true }),
+      query('actionName').trim().exists({ checkFalsy: true }),
+      query('transferToUserGroupId').trim(),
+    ],
+    detail: [
+      param('id').isString(),
+    ],
+  };
+
+  router.get('/', loginRequiredStrictly, adminRequired, async(req: AuthorizedRequest, res: ApiV3Response) => {
+    const { query } = req;
+
+    try {
+      const page = query.page != null ? parseInt(query.page as string) : undefined;
+      const limit = query.limit != null ? parseInt(query.limit as string) : undefined;
+      const offset = query.offset != null ? parseInt(query.offset as string) : undefined;
+      const pagination = query.pagination != null ? query.pagination !== 'false' : undefined;
+
+      const result = await ExternalUserGroup.findWithPagination({
+        page, limit, offset, pagination,
+      });
+      const { docs: userGroups, totalDocs: totalUserGroups, limit: pagingLimit } = result;
+      return res.apiv3({ userGroups, totalUserGroups, pagingLimit });
+    }
+    catch (err) {
+      const msg = 'Error occurred in fetching external user group list';
+      logger.error('Error', err);
+      return res.apiv3Err(new ErrorV3(msg));
+    }
+  });
+
+  router.get('/ancestors', loginRequiredStrictly, adminRequired, validators.ancestorGroup, apiV3FormValidator, async(req, res: ApiV3Response) => {
+    const { groupId } = req.query;
+
+    try {
+      const userGroup = await ExternalUserGroup.findOne({ _id: { $eq: groupId } });
+      const ancestorUserGroups = await ExternalUserGroup.findGroupsWithAncestorsRecursively(userGroup);
+      return res.apiv3({ ancestorUserGroups });
+    }
+    catch (err) {
+      const msg = 'Error occurred while searching user groups';
+      logger.error(msg, err);
+      return res.apiv3Err(new ErrorV3(msg));
+    }
+  });
+
+  router.get('/children', loginRequiredStrictly, adminRequired, validators.listChildren, async(req, res) => {
+    try {
+      const { parentIds, includeGrandChildren = false } = req.query;
+
+      const externalUserGroupsResult = await ExternalUserGroup.findChildrenByParentIds(parentIds, includeGrandChildren);
+      return res.apiv3({
+        childUserGroups: externalUserGroupsResult.childUserGroups,
+        grandChildUserGroups: externalUserGroupsResult.grandChildUserGroups,
+      });
+    }
+    catch (err) {
+      const msg = 'Error occurred in fetching child user group list';
+      logger.error(msg, err);
+      return res.apiv3Err(new ErrorV3(msg));
+    }
+  });
+
+  router.get('/:id', loginRequiredStrictly, adminRequired, validators.detail, async(req, res: ApiV3Response) => {
+    const { id } = req.params;
+
+    try {
+      const userGroup = await ExternalUserGroup.findById(id);
+      return res.apiv3({ userGroup });
+    }
+    catch (err) {
+      const msg = 'Error occurred while getting external user group';
+      logger.error(msg, err);
+      return res.apiv3Err(new ErrorV3(msg));
+    }
+  });
+
+  router.delete('/:id', loginRequiredStrictly, adminRequired, validators.delete, apiV3FormValidator, addActivity,
+    async(req: AuthorizedRequest, res: ApiV3Response) => {
+      const { id: deleteGroupId } = req.params;
+      const { actionName, transferToUserGroupId } = req.query;
+
+      const transferGroupInfo = transferToUserGroupId != null ? {
+        item: transferToUserGroupId as string,
+        type: GroupType.externalUserGroup,
+      } : undefined;
+
+      try {
+        const userGroups = await (crowi.userGroupService as UserGroupService)
+          .removeCompletelyByRootGroupId(deleteGroupId, actionName, req.user, transferGroupInfo, ExternalUserGroup, ExternalUserGroupRelation);
+
+        const parameters = { action: SupportedAction.ACTION_ADMIN_USER_GROUP_DELETE };
+        activityEvent.emit('update', res.locals.activity._id, parameters);
+
+        return res.apiv3({ userGroups });
+      }
+      catch (err) {
+        const msg = 'Error occurred while deleting user groups';
+        logger.error(msg, err);
+        return res.apiv3Err(new ErrorV3(msg));
+      }
+    });
+
+  router.put('/:id', loginRequiredStrictly, adminRequired, validators.update, apiV3FormValidator, addActivity, async(req, res: ApiV3Response) => {
+    const { id } = req.params;
+    const {
+      description,
+    } = req.body;
+
+    try {
+      const userGroup = await ExternalUserGroup.findOneAndUpdate({ _id: id }, { $set: { description } });
+
+      const parameters = { action: SupportedAction.ACTION_ADMIN_USER_GROUP_UPDATE };
+      activityEvent.emit('update', res.locals.activity._id, parameters);
+
+      return res.apiv3({ userGroup });
+    }
+    catch (err) {
+      const msg = 'Error occurred in updating an external user group';
+      logger.error(msg, err);
+      return res.apiv3Err(new ErrorV3(msg));
+    }
+  });
+
+  router.get('/:id/external-user-group-relations', loginRequiredStrictly, adminRequired, async(req, res: ApiV3Response) => {
+    const { id } = req.params;
+
+    try {
+      const externalUserGroup = await ExternalUserGroup.findById(id);
+      const userGroupRelations = await ExternalUserGroupRelation.find({ relatedGroup: externalUserGroup })
+        .populate('relatedUser');
+      const serialized = userGroupRelations.map(relation => serializeUserGroupRelationSecurely(relation));
+      return res.apiv3({ userGroupRelations: serialized });
+    }
+    catch (err) {
+      const msg = `Error occurred in fetching user group relations for external user group: ${id}`;
+      logger.error(msg, err);
+      return res.apiv3Err(new ErrorV3(msg));
+    }
+  });
+
+  router.get('/ldap/sync-settings', loginRequiredStrictly, adminRequired, (req: AuthorizedRequest, res: ApiV3Response) => {
+    const settings = {
+      ldapGroupSearchBase: configManager?.getConfig('crowi', 'external-user-group:ldap:groupSearchBase'),
+      ldapGroupMembershipAttribute: configManager?.getConfig('crowi', 'external-user-group:ldap:groupMembershipAttribute'),
+      ldapGroupMembershipAttributeType: configManager?.getConfig('crowi', 'external-user-group:ldap:groupMembershipAttributeType'),
+      ldapGroupChildGroupAttribute: configManager?.getConfig('crowi', 'external-user-group:ldap:groupChildGroupAttribute'),
+      autoGenerateUserOnLdapGroupSync: configManager?.getConfig('crowi', 'external-user-group:ldap:autoGenerateUserOnGroupSync'),
+      preserveDeletedLdapGroups: configManager?.getConfig('crowi', 'external-user-group:ldap:preserveDeletedGroups'),
+      ldapGroupNameAttribute: configManager?.getConfig('crowi', 'external-user-group:ldap:groupNameAttribute'),
+      ldapGroupDescriptionAttribute: configManager?.getConfig('crowi', 'external-user-group:ldap:groupDescriptionAttribute'),
+    };
+
+    return res.apiv3(settings);
+  });
+
+  router.get('/keycloak/sync-settings', loginRequiredStrictly, adminRequired, (req: AuthorizedRequest, res: ApiV3Response) => {
+    const settings = {
+      keycloakHost: configManager?.getConfig('crowi', 'external-user-group:keycloak:host'),
+      keycloakGroupRealm: configManager?.getConfig('crowi', 'external-user-group:keycloak:groupRealm'),
+      keycloakGroupSyncClientRealm: configManager?.getConfig('crowi', 'external-user-group:keycloak:groupSyncClientRealm'),
+      keycloakGroupSyncClientID: configManager?.getConfig('crowi', 'external-user-group:keycloak:groupSyncClientID'),
+      keycloakGroupSyncClientSecret: configManager?.getConfig('crowi', 'external-user-group:keycloak:groupSyncClientSecret'),
+      autoGenerateUserOnKeycloakGroupSync: configManager?.getConfig('crowi', 'external-user-group:keycloak:autoGenerateUserOnGroupSync'),
+      preserveDeletedKeycloakGroups: configManager?.getConfig('crowi', 'external-user-group:keycloak:preserveDeletedGroups'),
+      keycloakGroupDescriptionAttribute: configManager?.getConfig('crowi', 'external-user-group:keycloak:groupDescriptionAttribute'),
+    };
+
+    return res.apiv3(settings);
+  });
+
+  router.put('/ldap/sync-settings', loginRequiredStrictly, adminRequired, validators.ldapSyncSettings, async(req: AuthorizedRequest, res: ApiV3Response) => {
+    const errors = validationResult(req);
+    if (!errors.isEmpty()) {
+      return res.apiv3Err(
+        new ErrorV3('Invalid sync settings', 'external_user_group.invalid_sync_settings'), 400,
+      );
+    }
+
+    const params = {
+      'external-user-group:ldap:groupSearchBase': req.body.ldapGroupSearchBase,
+      'external-user-group:ldap:groupMembershipAttribute': req.body.ldapGroupMembershipAttribute,
+      'external-user-group:ldap:groupMembershipAttributeType': req.body.ldapGroupMembershipAttributeType,
+      'external-user-group:ldap:groupChildGroupAttribute': req.body.ldapGroupChildGroupAttribute,
+      'external-user-group:ldap:autoGenerateUserOnGroupSync': req.body.autoGenerateUserOnLdapGroupSync,
+      'external-user-group:ldap:preserveDeletedGroups': req.body.preserveDeletedLdapGroups,
+      'external-user-group:ldap:groupNameAttribute': req.body.ldapGroupNameAttribute,
+      'external-user-group:ldap:groupDescriptionAttribute': req.body.ldapGroupDescriptionAttribute,
+    };
+
+    if (params['external-user-group:ldap:groupNameAttribute'] == null || params['external-user-group:ldap:groupNameAttribute'] === '') {
+      // default is cn
+      params['external-user-group:ldap:groupNameAttribute'] = 'cn';
+    }
+
+    try {
+      await configManager.updateConfigsInTheSameNamespace('crowi', params, true);
+      return res.apiv3({}, 204);
+    }
+    catch (err) {
+      logger.error(err);
+      return res.apiv3Err(
+        new ErrorV3('Sync settings update failed', 'external_user_group.update_sync_settings_failed'), 500,
+      );
+    }
+  });
+
+  router.put('/keycloak/sync-settings', loginRequiredStrictly, adminRequired, validators.keycloakSyncSettings,
+    async(req: AuthorizedRequest, res: ApiV3Response) => {
+      const errors = validationResult(req);
+      if (!errors.isEmpty()) {
+        return res.apiv3Err(
+          new ErrorV3('Invalid sync settings', 'external_user_group.invalid_sync_settings'), 400,
+        );
+      }
+
+      const params = {
+        'external-user-group:keycloak:host': req.body.keycloakHost,
+        'external-user-group:keycloak:groupRealm': req.body.keycloakGroupRealm,
+        'external-user-group:keycloak:groupSyncClientRealm': req.body.keycloakGroupSyncClientRealm,
+        'external-user-group:keycloak:groupSyncClientID': req.body.keycloakGroupSyncClientID,
+        'external-user-group:keycloak:groupSyncClientSecret': req.body.keycloakGroupSyncClientSecret,
+        'external-user-group:keycloak:autoGenerateUserOnGroupSync': req.body.autoGenerateUserOnKeycloakGroupSync,
+        'external-user-group:keycloak:preserveDeletedGroups': req.body.preserveDeletedKeycloakGroups,
+        'external-user-group:keycloak:groupDescriptionAttribute': req.body.keycloakGroupDescriptionAttribute,
+      };
+
+      try {
+        await configManager.updateConfigsInTheSameNamespace('crowi', params, true);
+        return res.apiv3({}, 204);
+      }
+      catch (err) {
+        logger.error(err);
+        return res.apiv3Err(
+          new ErrorV3('Sync settings update failed', 'external_user_group.update_sync_settings_failed'), 500,
+        );
+      }
+    });
+
+  router.put('/ldap/sync', loginRequiredStrictly, adminRequired, async(req: AuthorizedRequest, res: ApiV3Response) => {
+    if (isExecutingSync()) {
+      return res.apiv3Err(
+        new ErrorV3('There is an ongoing sync process', 'external_user_group.sync_being_executed'), 409,
+      );
+    }
+
+    const isLdapEnabled = await configManager.getConfig('crowi', 'security:passport-ldap:isEnabled');
+    if (!isLdapEnabled) {
+      return res.apiv3Err(
+        new ErrorV3('Authentication using ldap is not set', 'external_user_group.ldap.auth_not_set'), 422,
+      );
+    }
+
+    try {
+      await crowi.ldapUserGroupSyncService?.init(req.user.name, req.body.password);
+    }
+    catch (e) {
+      return res.apiv3Err(
+        new ErrorV3('LDAP group sync failed', 'external_user_group.sync_failed'), 500,
+      );
+    }
+
+    // Do not await for sync to finish. Result (completed, failed) will be notified to the client by socket-io.
+    crowi.ldapUserGroupSyncService?.syncExternalUserGroups();
+
+    return res.apiv3({}, 202);
+  });
+
+  router.put('/keycloak/sync', loginRequiredStrictly, adminRequired, async(req: AuthorizedRequest, res: ApiV3Response) => {
+    if (isExecutingSync()) {
+      return res.apiv3Err(
+        new ErrorV3('There is an ongoing sync process', 'external_user_group.sync_being_executed'), 409,
+      );
+    }
+
+    const getAuthProviderType = () => {
+      const kcHost = configManager?.getConfig('crowi', 'external-user-group:keycloak:host');
+      const kcGroupRealm = configManager?.getConfig('crowi', 'external-user-group:keycloak:groupRealm');
+
+      // starts with kcHost, contains kcGroupRealm in path
+      // see: https://regex101.com/r/3ihDmf/1
+      const regex = new RegExp(`^${kcHost}/.*/${kcGroupRealm}(/|$).*`);
+
+      const isOidcEnabled = configManager.getConfig('crowi', 'security:passport-oidc:isEnabled');
+      const oidcIssuerHost = configManager.getConfig('crowi', 'security:passport-oidc:issuerHost');
+
+      if (isOidcEnabled && regex.test(oidcIssuerHost)) return 'oidc';
+
+      const isSamlEnabled = configManager.getConfig('crowi', 'security:passport-saml:isEnabled');
+      const samlEntryPoint = configManager.getConfig('crowi', 'security:passport-saml:entryPoint');
+
+      if (isSamlEnabled && regex.test(samlEntryPoint)) return 'saml';
+
+      return null;
+    };
+
+    const authProviderType = getAuthProviderType();
+    if (authProviderType == null) {
+      return res.apiv3Err(
+        new ErrorV3('Authentication using keycloak is not set', 'external_user_group.keycloak.auth_not_set'), 422,
+      );
+    }
+
+    crowi.keycloakUserGroupSyncService?.init(authProviderType);
+    // Do not await for sync to finish. Result (completed, failed) will be notified to the client by socket-io.
+    crowi.keycloakUserGroupSyncService?.syncExternalUserGroups();
+
+    return res.apiv3({}, 202);
+  });
+
+  router.get('/ldap/sync-status', loginRequiredStrictly, adminRequired, (req: AuthorizedRequest, res: ApiV3Response) => {
+    const syncStatus = crowi.ldapUserGroupSyncService?.syncStatus;
+    return res.apiv3({ ...syncStatus });
+  });
+
+  router.get('/keycloak/sync-status', loginRequiredStrictly, adminRequired, (req: AuthorizedRequest, res: ApiV3Response) => {
+    const syncStatus = crowi.keycloakUserGroupSyncService?.syncStatus;
+    return res.apiv3({ ...syncStatus });
+  });
+
+  return router;
+
+};

+ 224 - 0
apps/app/src/features/external-user-group/server/service/external-user-group-sync.ts

@@ -0,0 +1,224 @@
+import type { IUserHasId } from '@growi/core';
+
+import { SocketEventName } from '~/interfaces/websocket';
+import ExternalAccount from '~/server/models/external-account';
+import S2sMessage from '~/server/models/vo/s2s-message';
+import { S2sMessagingService } from '~/server/service/s2s-messaging/base';
+import { S2sMessageHandlable } from '~/server/service/s2s-messaging/handlable';
+import { excludeTestIdsFromTargetIds } from '~/server/util/compare-objectId';
+import loggerFactory from '~/utils/logger';
+import { batchProcessPromiseAll } from '~/utils/promise';
+
+import { configManager } from '../../../../server/service/config-manager';
+import { externalAccountService } from '../../../../server/service/external-account';
+import {
+  ExternalGroupProviderType, ExternalUserGroupTreeNode, ExternalUserInfo, IExternalUserGroupHasId,
+} from '../../interfaces/external-user-group';
+import ExternalUserGroup from '../models/external-user-group';
+import ExternalUserGroupRelation from '../models/external-user-group-relation';
+
+const logger = loggerFactory('growi:service:external-user-group-sync-service');
+
+// When d = max depth of group trees
+// Max space complexity of syncExternalUserGroups will be:
+// O(TREES_BATCH_SIZE * d * USERS_BATCH_SIZE)
+const TREES_BATCH_SIZE = 10;
+const USERS_BATCH_SIZE = 30;
+
+type SyncStatus = { isExecutingSync: boolean, totalCount: number, count: number }
+
+class ExternalUserGroupSyncS2sMessage extends S2sMessage {
+
+  syncStatus: SyncStatus;
+
+}
+
+abstract class ExternalUserGroupSyncService implements S2sMessageHandlable {
+
+  groupProviderType: ExternalGroupProviderType; // name of external service that contains user group info (e.g: ldap, keycloak)
+
+  authProviderType: string | null; // auth provider type (e.g: ldap, oidc). Has to be set before syncExternalUserGroups execution.
+
+  socketIoService: any;
+
+  s2sMessagingService: S2sMessagingService | null;
+
+  syncStatus: SyncStatus = { isExecutingSync: false, totalCount: 0, count: 0 };
+
+  // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
+  constructor(groupProviderType: ExternalGroupProviderType, s2sMessagingService: S2sMessagingService | null, socketIoService) {
+    this.groupProviderType = groupProviderType;
+    this.s2sMessagingService = s2sMessagingService;
+    this.socketIoService = socketIoService;
+  }
+
+  /**
+   * @inheritdoc
+   */
+  shouldHandleS2sMessage(s2sMessage: ExternalUserGroupSyncS2sMessage): boolean {
+    return s2sMessage.eventName === 'switchExternalUserGroupExecSyncStatus';
+  }
+
+  /**
+   * @inheritdoc
+   */
+  async handleS2sMessage(s2sMessage: ExternalUserGroupSyncS2sMessage): Promise<void> {
+    logger.info('Update syncStatus by pubsub notification');
+    this.syncStatus = s2sMessage.syncStatus;
+  }
+
+  async setSyncStatus(syncStatus: SyncStatus): Promise<void> {
+    this.syncStatus = syncStatus;
+
+    if (this.s2sMessagingService != null) {
+      const s2sMessage = new ExternalUserGroupSyncS2sMessage('switchExternalUserGroupExecSyncStatus', {
+        syncStatus: this.syncStatus,
+      });
+
+      try {
+        await this.s2sMessagingService.publish(s2sMessage);
+      }
+      catch (e) {
+        logger.error('Failed to publish update message with S2sMessagingService: ', e.message);
+      }
+    }
+  }
+
+  /** External user group tree sync method
+   * 1. Generate external user group tree
+   * 2. Use createUpdateExternalUserGroup on each node in the tree using DFS
+   * 3. If preserveDeletedLDAPGroups is false、delete all ExternalUserGroups that were not found during tree search
+  */
+  async syncExternalUserGroups(): Promise<void> {
+    if (this.authProviderType == null) throw new Error('auth provider type is not set');
+    if (this.syncStatus.isExecutingSync) throw new Error('External user group sync is already being executed');
+
+    const preserveDeletedLdapGroups: boolean = configManager?.getConfig('crowi', `external-user-group:${this.groupProviderType}:preserveDeletedGroups`);
+    const existingExternalUserGroupIds: string[] = [];
+
+    const socket = this.socketIoService?.getAdminSocket();
+
+    const syncNode = async(node: ExternalUserGroupTreeNode, parentId?: string) => {
+      const externalUserGroup = await this.createUpdateExternalUserGroup(node, parentId);
+      existingExternalUserGroupIds.push(externalUserGroup._id);
+      await this.setSyncStatus({ isExecutingSync: true, totalCount: this.syncStatus.totalCount, count:  this.syncStatus.count + 1 });
+      socket?.emit(SocketEventName.externalUserGroup[this.groupProviderType].GroupSyncProgress, {
+        totalCount: this.syncStatus.totalCount, count: this.syncStatus.count,
+      });
+      // Do not use Promise.all, because the number of promises processed can
+      // exponentially grow when group tree is enormous
+      for await (const childNode of node.childGroupNodes) {
+        await syncNode(childNode, externalUserGroup._id);
+      }
+    };
+
+    try {
+      const trees = await this.generateExternalUserGroupTrees();
+      const totalCount = trees.map(tree => this.getGroupCountOfTree(tree))
+        .reduce((sum, current) => sum + current);
+
+      await this.setSyncStatus({ isExecutingSync: true, totalCount, count: 0 });
+
+      await batchProcessPromiseAll(trees, TREES_BATCH_SIZE, async(tree) => {
+        return syncNode(tree);
+      });
+
+      if (!preserveDeletedLdapGroups) {
+        await ExternalUserGroup.deleteMany({
+          _id: { $nin: existingExternalUserGroupIds },
+          groupProviderType: this.groupProviderType,
+          provider: this.groupProviderType,
+        });
+        await ExternalUserGroupRelation.removeAllInvalidRelations();
+      }
+      socket?.emit(SocketEventName.externalUserGroup[this.groupProviderType].GroupSyncCompleted);
+    }
+    catch (e) {
+      logger.error(e.message);
+      socket?.emit(SocketEventName.externalUserGroup[this.groupProviderType].GroupSyncFailed);
+    }
+    finally {
+      await this.setSyncStatus({ isExecutingSync: false, totalCount: 0, count: 0 });
+    }
+  }
+
+  /** External user group node sync method
+   * 1. Create/Update ExternalUserGroup from using information of ExternalUserGroupTreeNode
+   * 2. For every element in node.userInfos, call getMemberUser and create an ExternalUserGroupRelation with ExternalUserGroup if it does not have one
+   * 3. Retrun ExternalUserGroup
+   * @param {string} node Node of external group tree
+   * @param {string} parentId Parent group id (id in GROWI) of the group we want to create/update
+   * @returns {Promise<IExternalUserGroupHasId>} ExternalUserGroup that was created/updated
+  */
+  private async createUpdateExternalUserGroup(node: ExternalUserGroupTreeNode, parentId?: string): Promise<IExternalUserGroupHasId> {
+    const externalUserGroup = await ExternalUserGroup.findAndUpdateOrCreateGroup(
+      node.name, node.id, this.groupProviderType, node.description, parentId,
+    );
+    await batchProcessPromiseAll(node.userInfos, USERS_BATCH_SIZE, async(userInfo) => {
+      const user = await this.getMemberUser(userInfo);
+
+      if (user != null) {
+        const userGroups = await ExternalUserGroup.findGroupsWithAncestorsRecursively(externalUserGroup);
+        const userGroupIds = userGroups.map(g => g._id);
+
+        // remove existing relations from list to create
+        const existingRelations = await ExternalUserGroupRelation.find({ relatedGroup: { $in: userGroupIds }, relatedUser: user._id });
+        const existingGroupIds = existingRelations.map(r => r.relatedGroup.toString());
+        const groupIdsToCreateRelation = excludeTestIdsFromTargetIds(userGroupIds, existingGroupIds);
+
+        await ExternalUserGroupRelation.createRelations(groupIdsToCreateRelation, user);
+      }
+    });
+
+    return externalUserGroup;
+  }
+
+  /** Method to get group member GROWI user
+   * 1. Search for GROWI user based on user info of 1, and return user
+   * 2. If autoGenerateUserOnHogeGroupSync is true and GROWI user is not found, create new GROWI user
+   * @param {ExternalUserInfo} externalUserInfo Search external app/server using this identifier
+   * @returns {Promise<IUserHasId | null>} User when found or created, null when neither
+   */
+  private async getMemberUser(userInfo: ExternalUserInfo): Promise<IUserHasId | null> {
+    const authProviderType = this.authProviderType;
+    if (authProviderType == null) throw new Error('auth provider type is not set');
+
+    const autoGenerateUserOnGroupSync = configManager?.getConfig('crowi', `external-user-group:${this.groupProviderType}:autoGenerateUserOnGroupSync`);
+
+    const getExternalAccount = async() => {
+      if (autoGenerateUserOnGroupSync && externalAccountService != null) {
+        return externalAccountService.getOrCreateUser({
+          id: userInfo.id, username: userInfo.username, name: userInfo.name, email: userInfo.email,
+        }, authProviderType);
+      }
+      return ExternalAccount.findOne({ providerType: this.groupProviderType, accountId: userInfo.id });
+    };
+
+    const externalAccount = await getExternalAccount();
+
+    if (externalAccount != null) {
+      return (await externalAccount.populate<{user: IUserHasId | null}>('user')).user;
+    }
+    return null;
+  }
+
+  getGroupCountOfTree(tree: ExternalUserGroupTreeNode): number {
+    if (tree.childGroupNodes.length === 0) return 1;
+
+    let count = 1;
+    tree.childGroupNodes.forEach((childGroup) => {
+      count += this.getGroupCountOfTree(childGroup);
+    });
+    return count;
+  }
+
+  /** Method to generate external group tree structure
+   * 1. Fetch user group info from external app/server
+   * 2. Convert each group tree structure to ExternalUserGroupTreeNode
+   * 3. Return the root node of each tree
+  */
+  abstract generateExternalUserGroupTrees(): Promise<ExternalUserGroupTreeNode[]>
+
+}
+
+export default ExternalUserGroupSyncService;

+ 210 - 0
apps/app/src/features/external-user-group/server/service/keycloak-user-group-sync.integ.ts

@@ -0,0 +1,210 @@
+import { configManager } from '~/server/service/config-manager';
+
+import { KeycloakUserGroupSyncService } from './keycloak-user-group-sync';
+
+vi.mock('@keycloak/keycloak-admin-client', () => {
+  return {
+    default: class {
+
+      auth() {}
+
+      groups = {
+        // mock group search on Keycloak
+        find: () => {
+          return [
+            // root node
+            {
+              id: 'groupId1',
+              name: 'grandParentGroup',
+              subGroups: [
+                {
+                  id: 'groupId2',
+                  name: 'parentGroup',
+                  subGroups: [
+                    {
+                      id: 'groupId3',
+                      name: 'childGroup',
+                    },
+                  ],
+                },
+              ],
+            },
+            // another root node
+            {
+              id: 'groupId4',
+              name: 'rootGroup',
+            },
+          ];
+        },
+
+        // mock group detail
+        findOne: (payload) => {
+          if (payload?.id === 'groupId1') {
+            return Promise.resolve(
+              {
+                id: 'groupId1',
+                name: 'grandParentGroup',
+                attributes: {
+                  description: ['this is a grand parent group'],
+                },
+              },
+            );
+          }
+          if (payload?.id === 'groupId2') {
+            return Promise.resolve(
+              {
+                id: 'groupId2',
+                name: 'parentGroup',
+                attributes: {
+                  description: ['this is a parent group'],
+                },
+              },
+            );
+          }
+          if (payload?.id === 'groupId3') {
+            return Promise.resolve(
+              {
+                id: 'groupId3',
+                name: 'childGroup',
+                attributes: {
+                  description: ['this is a child group'],
+                },
+              },
+            );
+          }
+          if (payload?.id === 'groupId4') {
+            return Promise.resolve(
+              {
+                id: 'groupId3',
+                name: 'childGroup',
+                attributes: {
+                  description: ['this is a root group'],
+                },
+              },
+            );
+          }
+          return Promise.reject(new Error('not found'));
+        },
+
+        // mock group users
+        listMembers: (payload) => {
+          // set 'first' condition to 0 (the first member request to server) or else it will result in infinite loop
+          if (payload?.id === 'groupId1' && payload?.first === 0) {
+            return Promise.resolve([
+              {
+                id: 'userId1',
+                username: 'grandParentGroupUser',
+                email: 'user@grandParentGroup.com',
+              },
+            ]);
+          }
+          if (payload?.id === 'groupId2' && payload?.first === 0) {
+            return Promise.resolve([
+              {
+                id: 'userId2',
+                username: 'parentGroupUser',
+                email: 'user@parentGroup.com',
+              },
+            ]);
+          }
+          if (payload?.id === 'groupId3' && payload?.first === 0) {
+            return Promise.resolve([
+              {
+                id: 'userId3',
+                username: 'childGroupUser',
+                email: 'user@childGroup.com',
+              },
+            ]);
+          }
+          if (payload?.id === 'groupId4' && payload?.first === 0) {
+            return Promise.resolve([
+              {
+                id: 'userId4',
+                username: 'rootGroupUser',
+                email: 'user@rootGroup.com',
+              },
+            ]);
+          }
+          return Promise.resolve([]);
+        },
+      };
+
+    },
+  };
+});
+
+describe('KeycloakUserGroupSyncService.generateExternalUserGroupTrees', () => {
+  let keycloakUserGroupSyncService: KeycloakUserGroupSyncService;
+
+  const configParams = {
+    'external-user-group:keycloak:host': 'http://dummy-keycloak-host.com',
+    'external-user-group:keycloak:groupRealm': 'myrealm',
+    'external-user-group:keycloak:groupSyncClientRealm': 'myrealm',
+    'external-user-group:keycloak:groupDescriptionAttribute': 'description',
+    'external-user-group:keycloak:groupSyncClientID': 'admin-cli',
+    'external-user-group:keycloak:groupSyncClientSecret': '123456',
+  };
+
+  beforeAll(async() => {
+    await configManager.updateConfigsInTheSameNamespace('crowi', configParams, true);
+    keycloakUserGroupSyncService = new KeycloakUserGroupSyncService(null, null);
+  });
+
+  it('creates ExternalUserGroupTrees', async() => {
+    const rootNodes = await keycloakUserGroupSyncService?.generateExternalUserGroupTrees();
+
+    expect(rootNodes?.length).toBe(2);
+
+    // check grandParentGroup
+    const grandParentNode = rootNodes?.find(node => node.id === 'groupId1');
+    const expectedChildNode = {
+      id: 'groupId3',
+      userInfos: [{
+        id: 'userId3',
+        username: 'childGroupUser',
+        email: 'user@childGroup.com',
+      }],
+      childGroupNodes: [],
+      name: 'childGroup',
+      description: 'this is a child group',
+    };
+    const expectedParentNode = {
+      id: 'groupId2',
+      userInfos: [{
+        id: 'userId2',
+        username: 'parentGroupUser',
+        email: 'user@parentGroup.com',
+      }],
+      childGroupNodes: [expectedChildNode],
+      name: 'parentGroup',
+      description: 'this is a parent group',
+    };
+    const expectedGrandParentNode = {
+      id: 'groupId1',
+      userInfos: [{
+        id: 'userId1',
+        username: 'grandParentGroupUser',
+        email: 'user@grandParentGroup.com',
+      }],
+      childGroupNodes: [expectedParentNode],
+      name: 'grandParentGroup',
+      description: 'this is a grand parent group',
+    };
+    expect(grandParentNode).toStrictEqual(expectedGrandParentNode);
+
+    // check rootGroup
+    const rootNode = rootNodes?.find(node => node.id === 'groupId4');
+    const expectedRootNode = {
+      id: 'groupId4',
+      userInfos: [{
+        id: 'userId4',
+        username: 'rootGroupUser',
+        email: 'user@rootGroup.com',
+      }],
+      childGroupNodes: [],
+      name: 'rootGroup',
+      description: 'this is a root group',
+    };
+    expect(rootNode).toStrictEqual(expectedRootNode);
+  });
+});

+ 168 - 0
apps/app/src/features/external-user-group/server/service/keycloak-user-group-sync.ts

@@ -0,0 +1,168 @@
+import KeycloakAdminClient from '@keycloak/keycloak-admin-client';
+import GroupRepresentation from '@keycloak/keycloak-admin-client/lib/defs/groupRepresentation';
+import UserRepresentation from '@keycloak/keycloak-admin-client/lib/defs/userRepresentation';
+
+import { configManager } from '~/server/service/config-manager';
+import { S2sMessagingService } from '~/server/service/s2s-messaging/base';
+import loggerFactory from '~/utils/logger';
+import { batchProcessPromiseAll } from '~/utils/promise';
+
+import { ExternalGroupProviderType, ExternalUserGroupTreeNode, ExternalUserInfo } from '../../interfaces/external-user-group';
+
+import ExternalUserGroupSyncService from './external-user-group-sync';
+
+const logger = loggerFactory('growi:service:keycloak-user-group-sync-service');
+
+// When d = max depth of group trees
+// Max space complexity of generateExternalUserGroupTrees will be:
+// O(TREES_BATCH_SIZE * d)
+const TREES_BATCH_SIZE = 10;
+
+export class KeycloakUserGroupSyncService extends ExternalUserGroupSyncService {
+
+  kcAdminClient: KeycloakAdminClient;
+
+  realm: string; // realm that contains the groups
+
+  groupDescriptionAttribute: string; // attribute to map to group description
+
+  isInitialized = false;
+
+  // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
+  constructor(s2sMessagingService: S2sMessagingService | null, socketIoService) {
+    const kcHost = configManager?.getConfig('crowi', 'external-user-group:keycloak:host');
+    const kcGroupRealm = configManager?.getConfig('crowi', 'external-user-group:keycloak:groupRealm');
+    const kcGroupSyncClientRealm = configManager?.getConfig('crowi', 'external-user-group:keycloak:groupSyncClientRealm');
+    const kcGroupDescriptionAttribute = configManager?.getConfig('crowi', 'external-user-group:keycloak:groupDescriptionAttribute');
+
+    super(ExternalGroupProviderType.keycloak, s2sMessagingService, socketIoService);
+    this.kcAdminClient = new KeycloakAdminClient({ baseUrl: kcHost, realmName: kcGroupSyncClientRealm });
+    this.realm = kcGroupRealm;
+    this.groupDescriptionAttribute = kcGroupDescriptionAttribute;
+  }
+
+  init(authProviderType: 'oidc' | 'saml'): void {
+    this.authProviderType = authProviderType;
+    this.isInitialized = true;
+  }
+
+  override syncExternalUserGroups(): Promise<void> {
+    if (!this.isInitialized) {
+      const msg = 'Service not initialized';
+      logger.error(msg);
+      throw new Error(msg);
+    }
+    return super.syncExternalUserGroups();
+  }
+
+  override async generateExternalUserGroupTrees(): Promise<ExternalUserGroupTreeNode[]> {
+    await this.auth();
+
+    // Type is 'GroupRepresentation', but 'find' does not return 'attributes' field. Hence, attribute for description is not present.
+    logger.info('Get groups from keycloak server');
+    const rootGroups = await this.kcAdminClient.groups.find({ realm: this.realm });
+
+    return (await batchProcessPromiseAll(rootGroups, TREES_BATCH_SIZE, group => this.groupRepresentationToTreeNode(group)))
+      .filter((node): node is NonNullable<ExternalUserGroupTreeNode> => node != null);
+  }
+
+  /**
+   * Authenticate to group sync client using client credentials grant type
+   */
+  private async auth(): Promise<void> {
+    const kcGroupSyncClientID: string = configManager.getConfig('crowi', 'external-user-group:keycloak:groupSyncClientID');
+    const kcGroupSyncClientSecret: string = configManager.getConfig('crowi', 'external-user-group:keycloak:groupSyncClientSecret');
+
+    await this.kcAdminClient.auth({
+      grantType: 'client_credentials',
+      clientId: kcGroupSyncClientID,
+      clientSecret: kcGroupSyncClientSecret,
+    });
+  }
+
+  /**
+   * Convert GroupRepresentation response returned from Keycloak to ExternalUserGroupTreeNode
+   */
+  private async groupRepresentationToTreeNode(group: GroupRepresentation): Promise<ExternalUserGroupTreeNode | null> {
+    if (group.id == null || group.name == null) return null;
+
+    logger.info('Get users from keycloak server');
+    const userRepresentations = await this.getMembers(group.id);
+
+    const userInfos = userRepresentations != null ? this.userRepresentationsToExternalUserInfos(userRepresentations) : [];
+    const description = await this.getGroupDescription(group.id) || undefined;
+    const childGroups = group.subGroups;
+
+    const childGroupNodesWithNull: (ExternalUserGroupTreeNode | null)[] = [];
+    if (childGroups != null) {
+      // Do not use Promise.all, because the number of promises processed can
+      // exponentially grow when group tree is enormous
+      for await (const childGroup of childGroups) {
+        childGroupNodesWithNull.push(await this.groupRepresentationToTreeNode(childGroup));
+      }
+    }
+    const childGroupNodes: ExternalUserGroupTreeNode[] = childGroupNodesWithNull
+      .filter((node): node is NonNullable<ExternalUserGroupTreeNode> => node != null);
+
+    return {
+      id: group.id,
+      userInfos,
+      childGroupNodes,
+      name: group.name,
+      description,
+    };
+  }
+
+  private async getMembers(groupId: string): Promise<UserRepresentation[]> {
+    let allUsers: UserRepresentation[] = [];
+
+    const fetchUsersWithOffset = async(offset: number) => {
+      await this.auth();
+      const response = await this.kcAdminClient.groups.listMembers({
+        id: groupId, realm: this.realm, first: offset,
+      });
+
+      if (response != null && response.length > 0) {
+        allUsers = allUsers.concat(response);
+        return fetchUsersWithOffset(offset + response.length);
+      }
+    };
+
+    await fetchUsersWithOffset(0);
+
+    return allUsers;
+  }
+
+
+  /**
+   * Fetch group detail from Keycloak and return group description
+   */
+  private async getGroupDescription(groupId: string): Promise<string | null> {
+    if (this.groupDescriptionAttribute == null) return null;
+
+    await this.auth();
+    const groupDetail = await this.kcAdminClient.groups.findOne({ id: groupId, realm: this.realm });
+
+    const description = groupDetail?.attributes?.[this.groupDescriptionAttribute]?.[0];
+    return typeof description === 'string' ? description : null;
+  }
+
+  /**
+   * Convert UserRepresentation array response returned from Keycloak to ExternalUserInfo
+   */
+  private userRepresentationsToExternalUserInfos(userRepresentations: UserRepresentation[]): ExternalUserInfo[] {
+    const externalUserGroupsWithNull: (ExternalUserInfo | null)[] = userRepresentations.map((userRepresentation) => {
+      if (userRepresentation.id != null && userRepresentation.username != null) {
+        return {
+          id: userRepresentation.id,
+          username: userRepresentation.username,
+          email: userRepresentation.email,
+        };
+      }
+      return null;
+    });
+
+    return externalUserGroupsWithNull.filter((node): node is NonNullable<ExternalUserInfo> => node != null);
+  }
+
+}

+ 156 - 0
apps/app/src/features/external-user-group/server/service/ldap-user-group-sync.ts

@@ -0,0 +1,156 @@
+import { configManager } from '~/server/service/config-manager';
+import { ldapService, SearchResultEntry } from '~/server/service/ldap';
+import PassportService from '~/server/service/passport';
+import { S2sMessagingService } from '~/server/service/s2s-messaging/base';
+import loggerFactory from '~/utils/logger';
+import { batchProcessPromiseAll } from '~/utils/promise';
+
+import {
+  ExternalGroupProviderType, ExternalUserGroupTreeNode, ExternalUserInfo, LdapGroupMembershipAttributeType,
+} from '../../interfaces/external-user-group';
+
+import ExternalUserGroupSyncService from './external-user-group-sync';
+
+const logger = loggerFactory('growi:service:ldap-user-group-sync-service');
+
+// When d = max depth of group trees
+// Max space complexity of generateExternalUserGroupTrees will be:
+// O(TREES_BATCH_SIZE * d * USERS_BATCH_SIZE)
+const TREES_BATCH_SIZE = 10;
+const USERS_BATCH_SIZE = 30;
+
+export class LdapUserGroupSyncService extends ExternalUserGroupSyncService {
+
+  passportService: PassportService;
+
+  isInitialized = false;
+
+  // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
+  constructor(passportService: PassportService, s2sMessagingService: S2sMessagingService, socketIoService) {
+    super(ExternalGroupProviderType.ldap, s2sMessagingService, socketIoService);
+    this.authProviderType = 'ldap';
+    this.passportService = passportService;
+  }
+
+  async init(userBindUsername?: string, userBindPassword?: string): Promise<void> {
+    await ldapService.initClient(userBindUsername, userBindPassword);
+    this.isInitialized = true;
+  }
+
+  override syncExternalUserGroups(): Promise<void> {
+    if (!this.isInitialized) {
+      const msg = 'Service not initialized';
+      logger.error(msg);
+      throw new Error(msg);
+    }
+    return super.syncExternalUserGroups();
+  }
+
+  override async generateExternalUserGroupTrees(): Promise<ExternalUserGroupTreeNode[]> {
+    const groupChildGroupAttribute: string = configManager.getConfig('crowi', 'external-user-group:ldap:groupChildGroupAttribute');
+    const groupMembershipAttribute: string = configManager.getConfig('crowi', 'external-user-group:ldap:groupMembershipAttribute');
+    const groupNameAttribute: string = configManager.getConfig('crowi', 'external-user-group:ldap:groupNameAttribute');
+    const groupDescriptionAttribute: string = configManager.getConfig('crowi', 'external-user-group:ldap:groupDescriptionAttribute');
+    const groupBase: string = ldapService.getGroupSearchBase();
+
+    const groupEntries = await ldapService.searchGroupDir();
+
+    const getChildGroupDnsFromGroupEntry = (groupEntry: SearchResultEntry) => {
+      // groupChildGroupAttribute and groupMembershipAttribute may be the same,
+      // so filter values of groupChildGroupAttribute to ones that include groupBase
+      return ldapService.getArrayValFromSearchResultEntry(groupEntry, groupChildGroupAttribute).filter(attr => attr.includes(groupBase));
+    };
+    const getUserIdsFromGroupEntry = (groupEntry: SearchResultEntry) => {
+      // groupChildGroupAttribute and groupMembershipAttribute may be the same,
+      // so filter values of groupMembershipAttribute to ones that does not include groupBase
+      return ldapService.getArrayValFromSearchResultEntry(groupEntry, groupMembershipAttribute).filter(attr => !attr.includes(groupBase));
+    };
+
+    const convert = async(entry: SearchResultEntry, converted: string[]): Promise<ExternalUserGroupTreeNode | null> => {
+      const name = ldapService.getStringValFromSearchResultEntry(entry, groupNameAttribute);
+      if (name == null) return null;
+
+      if (converted.includes(entry.objectName)) {
+        throw Error('Circular reference inside LDAP group tree');
+      }
+      converted.push(entry.objectName);
+
+      const userIds = getUserIdsFromGroupEntry(entry);
+
+      const userInfos = (await batchProcessPromiseAll(userIds, USERS_BATCH_SIZE, (id) => {
+        return this.getUserInfo(id);
+      })).filter((info): info is NonNullable<ExternalUserInfo> => info != null);
+      const description = ldapService.getStringValFromSearchResultEntry(entry, groupDescriptionAttribute);
+      const childGroupDNs = getChildGroupDnsFromGroupEntry(entry);
+
+      const childGroupNodesWithNull: (ExternalUserGroupTreeNode | null)[] = [];
+      // Do not use Promise.all, because the number of promises processed can
+      // exponentially grow when group tree is enormous
+      for await (const dn of childGroupDNs) {
+        const childEntry = groupEntries.find(ge => ge.objectName === dn);
+        childGroupNodesWithNull.push(childEntry != null ? await convert(childEntry, converted) : null);
+      }
+      const childGroupNodes: ExternalUserGroupTreeNode[] = childGroupNodesWithNull
+        .filter((node): node is NonNullable<ExternalUserGroupTreeNode> => node != null);
+
+      return {
+        id: entry.objectName,
+        userInfos,
+        childGroupNodes,
+        name,
+        description,
+      };
+    };
+
+    // all the DNs of groups that are not a root of a tree
+    const allChildGroupDNs = new Set(groupEntries.flatMap((entry) => {
+      return getChildGroupDnsFromGroupEntry(entry);
+    }));
+
+    // root of every tree
+    const rootEntries = groupEntries.filter((entry) => {
+      return !allChildGroupDNs.has(entry.objectName);
+    });
+
+    return (await batchProcessPromiseAll(rootEntries, TREES_BATCH_SIZE, entry => convert(entry, [])))
+      .filter((node): node is NonNullable<ExternalUserGroupTreeNode> => node != null);
+  }
+
+  private async getUserInfo(userId: string): Promise<ExternalUserInfo | null> {
+    const groupMembershipAttributeType = configManager?.getConfig('crowi', 'external-user-group:ldap:groupMembershipAttributeType');
+    const attrMapUsername = this.passportService.getLdapAttrNameMappedToUsername();
+    const attrMapName = this.passportService.getLdapAttrNameMappedToName();
+    const attrMapMail = this.passportService.getLdapAttrNameMappedToMail();
+
+    // get full user info from LDAP server using externalUserInfo (DN or UID)
+    const getUserEntries = async() => {
+      if (groupMembershipAttributeType === LdapGroupMembershipAttributeType.dn) {
+        return ldapService.search(undefined, userId, 'base');
+      }
+      if (groupMembershipAttributeType === LdapGroupMembershipAttributeType.uid) {
+        return ldapService.search(`(uid=${userId})`, undefined);
+      }
+    };
+
+    const userEntries = await getUserEntries();
+
+    if (userEntries != null && userEntries.length > 0) {
+      const userEntry = userEntries[0];
+      const uid = ldapService.getStringValFromSearchResultEntry(userEntry, 'uid');
+      if (uid != null) {
+        const usernameToBeRegistered = attrMapUsername === 'uid' ? uid : ldapService.getStringValFromSearchResultEntry(userEntry, attrMapUsername);
+        const nameToBeRegistered = ldapService.getStringValFromSearchResultEntry(userEntry, attrMapName);
+        const mailToBeRegistered = ldapService.getStringValFromSearchResultEntry(userEntry, attrMapMail);
+
+        return usernameToBeRegistered != null ? {
+          id: uid,
+          username: usernameToBeRegistered,
+          name: nameToBeRegistered,
+          email: mailToBeRegistered,
+        } : null;
+      }
+    }
+    return null;
+  }
+
+}

+ 6 - 6
apps/app/src/features/growi-plugin/client/components/Admin/PluginsExtensionPageContents/PluginCard.tsx

@@ -12,7 +12,7 @@ type Props = {
   id: string,
   id: string,
   name: string,
   name: string,
   url: string,
   url: string,
-  isEnalbed: boolean,
+  isEnabled: boolean,
   desc?: string,
   desc?: string,
   onDelete: () => void,
   onDelete: () => void,
 }
 }
@@ -20,27 +20,27 @@ type Props = {
 export const PluginCard = (props: Props): JSX.Element => {
 export const PluginCard = (props: Props): JSX.Element => {
 
 
   const {
   const {
-    id, name, url, isEnalbed, desc,
+    id, name, url, isEnabled, desc,
   } = props;
   } = props;
 
 
   const { t } = useTranslation('admin');
   const { t } = useTranslation('admin');
 
 
   const PluginCardButton = (): JSX.Element => {
   const PluginCardButton = (): JSX.Element => {
-    const [isEnabled, setState] = useState<boolean>(isEnalbed);
+    const [_isEnabled, setIsEnabled] = useState<boolean>(isEnabled);
 
 
     const onChangeHandler = async() => {
     const onChangeHandler = async() => {
       try {
       try {
-        if (isEnabled) {
+        if (_isEnabled) {
           const reqUrl = `/plugins/${id}/deactivate`;
           const reqUrl = `/plugins/${id}/deactivate`;
           const res = await apiv3Put(reqUrl);
           const res = await apiv3Put(reqUrl);
-          setState(!isEnabled);
+          setIsEnabled(!_isEnabled);
           const pluginName = res.data.pluginName;
           const pluginName = res.data.pluginName;
           toastSuccess(t('toaster.deactivate_plugin_success', { pluginName }));
           toastSuccess(t('toaster.deactivate_plugin_success', { pluginName }));
         }
         }
         else {
         else {
           const reqUrl = `/plugins/${id}/activate`;
           const reqUrl = `/plugins/${id}/activate`;
           const res = await apiv3Put(reqUrl);
           const res = await apiv3Put(reqUrl);
-          setState(!isEnabled);
+          setIsEnabled(!_isEnabled);
           const pluginName = res.data.pluginName;
           const pluginName = res.data.pluginName;
           toastSuccess(t('toaster.activate_plugin_success', { pluginName }));
           toastSuccess(t('toaster.activate_plugin_success', { pluginName }));
         }
         }

+ 1 - 1
apps/app/src/features/growi-plugin/client/components/Admin/PluginsExtensionPageContents/PluginsExtensionPageContents.tsx

@@ -54,7 +54,7 @@ export const PluginsExtensionPageContents = (): JSX.Element => {
                     id={plugin._id}
                     id={plugin._id}
                     name={plugin.meta.name}
                     name={plugin.meta.name}
                     url={plugin.origin.url}
                     url={plugin.origin.url}
-                    isEnalbed={plugin.isEnabled}
+                    isEnabled={plugin.isEnabled}
                     desc={plugin.meta.desc}
                     desc={plugin.meta.desc}
                     onDelete={() => openPluginDeleteModal(plugin)}
                     onDelete={() => openPluginDeleteModal(plugin)}
                   />
                   />

+ 1 - 0
apps/app/src/features/questionnaire/interfaces/growi-info.ts

@@ -13,6 +13,7 @@ export const GrowiAttachmentType = {
   aws: 'aws',
   aws: 'aws',
   gcs: 'gcs',
   gcs: 'gcs',
   gcp: 'gcp',
   gcp: 'gcp',
+  azure: 'azure',
   gridfs: 'gridfs',
   gridfs: 'gridfs',
   mongo: 'mongo',
   mongo: 'mongo',
   mongodb: 'mongodb',
   mongodb: 'mongodb',

+ 8 - 4
apps/app/src/interfaces/crowi-request.ts

@@ -1,9 +1,11 @@
-import type { IUser, IUserHasId } from '@growi/core';
-import { Request } from 'express';
+import type { IUser } from '@growi/core';
+import type { Request } from 'express';
+import type { Document } from 'mongoose';
 
 
-export interface CrowiRequest<U extends IUser = IUserHasId> extends Request {
 
 
-  user?: U,
+export interface CrowiProperties {
+
+  user?: IUser & Document,
 
 
   // eslint-disable-next-line @typescript-eslint/no-explicit-any
   // eslint-disable-next-line @typescript-eslint/no-explicit-any
   crowi: any,
   crowi: any,
@@ -14,3 +16,5 @@ export interface CrowiRequest<U extends IUser = IUserHasId> extends Request {
   csrfToken: () => string,
   csrfToken: () => string,
 
 
 }
 }
+
+export interface CrowiRequest extends CrowiProperties, Request {}

+ 9 - 2
apps/app/src/interfaces/page-grant.ts

@@ -1,9 +1,16 @@
-import { PageGrant } from '@growi/core';
+import { PageGrant, GroupType } from '@growi/core';
+
+import { ExternalUserGroupDocument } from '~/features/external-user-group/server/models/external-user-group';
+import { UserGroupDocument } from '~/server/models/user-group';
 
 
 import { IPageGrantData } from './page';
 import { IPageGrantData } from './page';
 
 
+
+type UserGroupType = typeof GroupType.userGroup;
+type ExternalUserGroupType = typeof GroupType.externalUserGroup;
+export type PopulatedGrantedGroup = {type: UserGroupType, item: UserGroupDocument } | {type: ExternalUserGroupType, item: ExternalUserGroupDocument }
 export type IDataApplicableGroup = {
 export type IDataApplicableGroup = {
-  applicableGroups?: {_id: string, name: string}[] // TODO: Typescriptize model
+  applicableGroups?: PopulatedGrantedGroup[]
 }
 }
 
 
 export type IDataApplicableGrant = null | IDataApplicableGroup;
 export type IDataApplicableGrant = null | IDataApplicableGroup;

+ 3 - 2
apps/app/src/interfaces/page-operation.ts

@@ -1,3 +1,5 @@
+import type { IGrantedGroup } from '@growi/core';
+
 export const PageActionType = {
 export const PageActionType = {
   Create: 'Create',
   Create: 'Create',
   Update: 'Update',
   Update: 'Update',
@@ -32,7 +34,6 @@ export type OptionsToSave = {
   slackChannels: string;
   slackChannels: string;
   grant: number;
   grant: number;
   pageTags: string[] | null;
   pageTags: string[] | null;
-  grantUserGroupId?: string | null;
-  grantUserGroupName?: string | null;
+  grantUserGroupIds?: IGrantedGroup[];
   isSyncRevisionToHackmd?: boolean;
   isSyncRevisionToHackmd?: boolean;
 };
 };

+ 5 - 4
apps/app/src/interfaces/page.ts

@@ -1,4 +1,4 @@
-import type { IPageHasId, Nullable } from '@growi/core';
+import type { GroupType, IPageHasId, Nullable } from '@growi/core';
 
 
 import type { IPageOperationProcessData } from './page-operation';
 import type { IPageOperationProcessData } from './page-operation';
 
 
@@ -10,10 +10,11 @@ export type IPageForItem = Partial<IPageHasId & {isTarget?: boolean, processData
 
 
 export type IPageGrantData = {
 export type IPageGrantData = {
   grant: number,
   grant: number,
-  grantedGroup?: {
+  grantedGroups?: {
     id: string,
     id: string,
-    name: string
-  }
+    name: string,
+    type: GroupType,
+  }[]
 }
 }
 
 
 export type IDeleteSinglePageApiv1Result = {
 export type IDeleteSinglePageApiv1Result = {

+ 14 - 0
apps/app/src/interfaces/res/admin/app-settings.ts

@@ -39,6 +39,20 @@ export type IResAppSettings = {
   envGcsBucket: string,
   envGcsBucket: string,
   envGcsUploadNamespace: string,
   envGcsUploadNamespace: string,
 
 
+  azureUseOnlyEnvVars: boolean,
+  azureTenantId: string,
+  azureClientId: string,
+  azureClientSecret: string,
+  azureStorageAccountName: string,
+  azureStorageContainerName: string,
+  azureReferenceFileWithRelayMode: string,
+
+  envAzureTenantId: string,
+  envAzureClientId: string,
+  envAzureClientSecret: string,
+  envAzureStorageAccountName: string,
+  envAzureStorageContainerName: string,
+
   isEnabledPlugins: boolean,
   isEnabledPlugins: boolean,
 
 
   isQuestionnaireEnabled: boolean,
   isQuestionnaireEnabled: boolean,

+ 12 - 11
apps/app/src/interfaces/user-group-response.ts

@@ -1,29 +1,30 @@
 import type {
 import type {
   HasObjectId, Ref,
   HasObjectId, Ref,
   IPageHasId,
   IPageHasId,
-  IUser, IUserGroup, IUserGroupHasId, IUserGroupRelationHasId,
+  IUserGroup, IUserGroupHasId, IUserGroupRelationHasId, IUserHasId,
 } from '@growi/core';
 } from '@growi/core';
 
 
+
 export type UserGroupResult = {
 export type UserGroupResult = {
   userGroup: IUserGroupHasId,
   userGroup: IUserGroupHasId,
 }
 }
 
 
-export type UserGroupListResult = {
-  userGroups: IUserGroupHasId[],
+export type UserGroupListResult<TUSERGROUP extends IUserGroupHasId = IUserGroupHasId> = {
+  userGroups: TUSERGROUP[],
 };
 };
 
 
-export type ChildUserGroupListResult = {
-  childUserGroups: IUserGroupHasId[],
-  grandChildUserGroups: IUserGroupHasId[],
+export type ChildUserGroupListResult<TUSERGROUP extends IUserGroupHasId = IUserGroupHasId> = {
+  childUserGroups: TUSERGROUP[],
+  grandChildUserGroups: TUSERGROUP[],
 };
 };
 
 
-export type UserGroupRelationListResult = {
-  userGroupRelations: IUserGroupRelationHasId[],
+export type UserGroupRelationListResult<TUSERGROUPRELATION extends IUserGroupRelationHasId = IUserGroupRelationHasId> = {
+  userGroupRelations: TUSERGROUPRELATION[],
 };
 };
 
 
-export type IUserGroupRelationHasIdPopulatedUser = {
-  relatedGroup: Ref<IUserGroup>,
-  relatedUser: IUser & HasObjectId,
+export type IUserGroupRelationHasIdPopulatedUser<TUSERGROUP extends IUserGroup = IUserGroup> = {
+  relatedGroup: Ref<TUSERGROUP>,
+  relatedUser: IUserHasId,
   createdAt: Date,
   createdAt: Date,
 } & HasObjectId;
 } & HasObjectId;
 
 

+ 23 - 0
apps/app/src/interfaces/websocket.ts

@@ -1,3 +1,23 @@
+import { ExternalGroupProviderType } from '~/features/external-user-group/interfaces/external-user-group';
+
+const generateGroupSyncEvents = () => {
+  const events = {};
+  Object.values(ExternalGroupProviderType).forEach((provider) => {
+    events[provider] = {
+      GroupSyncProgress: `${provider}:groupSyncProgress`,
+      GroupSyncCompleted: `${provider}:groupSyncCompleted`,
+      GroupSyncFailed: `${provider}:groupSyncFailed`,
+    };
+  });
+  return events as {
+    [key in ExternalGroupProviderType]: {
+      GroupSyncProgress: string,
+      GroupSyncCompleted: string,
+      GroupSyncFailed: string,
+    }
+  };
+};
+
 export const SocketEventName = {
 export const SocketEventName = {
   // Update descendantCount
   // Update descendantCount
   UpdateDescCount: 'UpdateDescCount',
   UpdateDescCount: 'UpdateDescCount',
@@ -17,6 +37,9 @@ export const SocketEventName = {
   FinishAddPage: 'finishAddPage',
   FinishAddPage: 'finishAddPage',
   RebuildingFailed: 'rebuildingFailed',
   RebuildingFailed: 'rebuildingFailed',
 
 
+  // External user group sync
+  externalUserGroup: generateGroupSyncEvents(),
+
   // Page Operation
   // Page Operation
   PageCreated: 'page:create',
   PageCreated: 'page:create',
   PageUpdated: 'page:update',
   PageUpdated: 'page:update',

+ 1 - 1
apps/app/src/migrations/20200402160380-remove-deleteduser-from-relationgroup.js

@@ -1,5 +1,6 @@
 import mongoose from 'mongoose';
 import mongoose from 'mongoose';
 
 
+import UserGroupRelation from '~/server/models/user-group-relation';
 import { getModelSafely, getMongoUri, mongoOptions } from '~/server/util/mongoose-utils';
 import { getModelSafely, getMongoUri, mongoOptions } from '~/server/util/mongoose-utils';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
@@ -11,7 +12,6 @@ module.exports = {
     mongoose.connect(getMongoUri(), mongoOptions);
     mongoose.connect(getMongoUri(), mongoOptions);
 
 
     const User = getModelSafely('User') || require('~/server/models/user')();
     const User = getModelSafely('User') || require('~/server/models/user')();
-    const UserGroupRelation = getModelSafely('UserGroupRelation') || require('~/server/models/user-group-relation')();
 
 
     const deletedUsers = await User.find({ status: 4 }); // deleted user
     const deletedUsers = await User.find({ status: 4 }); // deleted user
     const requests = await UserGroupRelation.remove({ relatedUser: deletedUsers });
     const requests = await UserGroupRelation.remove({ relatedUser: deletedUsers });

+ 2 - 3
apps/app/src/migrations/20220613064207-add-attachment-type-to-existing-attachments.js

@@ -1,8 +1,8 @@
 import mongoose from 'mongoose';
 import mongoose from 'mongoose';
 
 
 import { AttachmentType } from '~/server/interfaces/attachment';
 import { AttachmentType } from '~/server/interfaces/attachment';
-import attachmentModel from '~/server/models/attachment';
-import { getModelSafely, getMongoUri, mongoOptions } from '~/server/util/mongoose-utils';
+import { Attachment } from '~/server/models';
+import { getMongoUri, mongoOptions } from '~/server/util/mongoose-utils';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
 const logger = loggerFactory('growi:migrate:add-attachment-type-to-existing-attachments');
 const logger = loggerFactory('growi:migrate:add-attachment-type-to-existing-attachments');
@@ -11,7 +11,6 @@ module.exports = {
   async up(db) {
   async up(db) {
     logger.info('Apply migration');
     logger.info('Apply migration');
     mongoose.connect(getMongoUri(), mongoOptions);
     mongoose.connect(getMongoUri(), mongoOptions);
-    const Attachment = getModelSafely('Attachment') || attachmentModel();
 
 
     // Add attachmentType for wiki page
     // Add attachmentType for wiki page
     // Filter pages where "attachmentType" doesn't exist and "page" is not null
     // Filter pages where "attachmentType" doesn't exist and "page" is not null

+ 160 - 0
apps/app/src/migrations/20230723061824-granted-group-to-array-of-objects.js

@@ -0,0 +1,160 @@
+import loggerFactory from '~/utils/logger';
+
+const logger = loggerFactory('growi:remove-basic-auth-related-config');
+
+module.exports = {
+  async up(db, client) {
+    logger.info('Apply migration');
+
+    const pageCollection = await db.collection('pages');
+    const pageOperationCollection = await db.collection('pageoperations');
+    // Convert grantedGroup to array
+    // Set the model type of grantedGroup to UserGroup for Pages that were created before ExternalUserGroup was introduced
+    await pageCollection.updateMany(
+      { grantedGroup: { $ne: null } },
+      [
+        {
+          $set: {
+            grantedGroup: [
+              {
+                type: 'UserGroup',
+                item: '$grantedGroup',
+              },
+            ],
+          },
+        },
+      ],
+    );
+
+    await pageOperationCollection.updateMany(
+      { 'options.grantUserGroupId': { $ne: null } },
+      [
+        {
+          $set: {
+            'options.grantUserGroupId': [
+              {
+                type: 'UserGroup',
+                item: '$options.grantUserGroupId',
+              },
+            ],
+          },
+        },
+      ],
+    );
+
+    await pageOperationCollection.updateMany(
+      { 'page.grantedGroup': { $ne: null } },
+      [
+        {
+          $set: {
+            'page.grantedGroup': [
+              {
+                type: 'UserGroup',
+                item: '$page.grantedGroup',
+              },
+            ],
+          },
+        },
+      ],
+    );
+
+    await pageOperationCollection.updateMany(
+      { 'exPage.grantedGroup': { $ne: null } },
+      [
+        {
+          $set: {
+            'exPage.grantedGroup': [
+              {
+                type: 'UserGroup',
+                item: '$exPage.grantedGroup',
+              },
+            ],
+          },
+        },
+      ],
+    );
+
+    // rename fields
+    await pageCollection.updateMany({}, {
+      $rename: {
+        grantedGroup: 'grantedGroups',
+      },
+    });
+    await pageOperationCollection.updateMany({}, {
+      $rename: {
+        'options.grantUserGroupId': 'options.grantUserGroupIds',
+        'page.grantedGroup': 'page.grantedGroups',
+        'exPage.grantedGroup': 'exPage.grantedGroups',
+      },
+    });
+
+    logger.info('Migration has successfully applied');
+  },
+
+  async down(db, client) {
+    logger.info('Rollback migration');
+
+    const pageCollection = await db.collection('pages');
+    const pageOperationCollection = await db.collection('pageoperations');
+
+    await pageCollection.updateMany(
+      { grantedGroups: { $exists: true } },
+      [
+        {
+          $set: {
+            grantedGroups: { $arrayElemAt: ['$grantedGroups.item', 0] },
+          },
+        },
+      ],
+    );
+    await pageOperationCollection.updateMany(
+      { 'options.grantUserGroupIds': { $exists: true } },
+      [
+        {
+          $set: {
+            'options.grantUserGroupIds': { $arrayElemAt: ['options.grantUserGroupIds.item', 0] },
+          },
+        },
+      ],
+    );
+    await pageOperationCollection.updateMany(
+      { 'page.grantedGroups': { $exists: true } },
+      [
+        {
+          $set: {
+            'page.grantedGroups': { $arrayElemAt: ['page.grantedGroups.item', 0] },
+          },
+        },
+      ],
+    );
+    await pageOperationCollection.updateMany(
+      { 'exPage.grantedGroups': { $exists: true } },
+      [
+        {
+          $set: {
+            'exPage.grantedGroups': { $arrayElemAt: ['exPage.grantedGroups.item', 0] },
+          },
+        },
+      ],
+    );
+
+    // rename fields
+    await pageCollection.updateMany(
+      { grantedGroups: { $exists: true } },
+      {
+        $rename: {
+          grantedGroups: 'grantedGroup',
+        },
+      },
+    );
+    await pageOperationCollection.updateMany({}, {
+      $rename: {
+        'options.grantUserGroupIds': 'options.grantUserGroupId',
+        'page.grantedGroups': 'page.grantedGroup',
+        'exPage.grantedGroups': 'exPage.grantedGroup',
+      },
+    });
+
+    logger.info('Migration has been successfully rollbacked');
+  },
+};

+ 16 - 20
apps/app/src/pages/[[...path]].page.tsx

@@ -5,7 +5,7 @@ import EventEmitter from 'events';
 
 
 import { isIPageInfoForEntity } from '@growi/core';
 import { isIPageInfoForEntity } from '@growi/core';
 import type {
 import type {
-  IDataWithMeta, IPageInfoForEntity, IPagePopulatedToShowRevision, IUserHasId,
+  IDataWithMeta, IPageInfoForEntity, IPagePopulatedToShowRevision,
 } from '@growi/core';
 } from '@growi/core';
 import {
 import {
   isClient, pagePathUtils, pathUtils,
   isClient, pagePathUtils, pathUtils,
@@ -22,7 +22,8 @@ import superjson from 'superjson';
 
 
 import { useCurrentGrowiLayoutFluidClassName, useEditorModeClassName } from '~/client/services/layout';
 import { useCurrentGrowiLayoutFluidClassName, useEditorModeClassName } from '~/client/services/layout';
 import { PageView } from '~/components/Page/PageView';
 import { PageView } from '~/components/Page/PageView';
-import { DrawioViewerScript } from '~/components/Script/DrawioViewerScript'; import type { CrowiRequest } from '~/interfaces/crowi-request';
+import { DrawioViewerScript } from '~/components/Script/DrawioViewerScript';
+import type { CrowiRequest } from '~/interfaces/crowi-request';
 import type { EditorConfig } from '~/interfaces/editor-settings';
 import type { EditorConfig } from '~/interfaces/editor-settings';
 import type { IPageGrantData } from '~/interfaces/page';
 import type { IPageGrantData } from '~/interfaces/page';
 import type { RendererConfig } from '~/interfaces/services/renderer';
 import type { RendererConfig } from '~/interfaces/services/renderer';
@@ -417,21 +418,6 @@ class MultiplePagesHitsError extends ExtensibleCustomError {
 
 
 }
 }
 
 
-// apply parent page grant fot creating page
-async function applyGrantToPage(props: Props, ancestor: any) {
-  await ancestor.populate('grantedGroup');
-  const grant = {
-    grant: ancestor.grant,
-  };
-  const grantedGroup = ancestor.grantedGroup ? {
-    grantedGroup: {
-      id: ancestor.grantedGroup.id,
-      name: ancestor.grantedGroup.name,
-    },
-  } : {};
-  props.grantData = Object.assign(grant, grantedGroup);
-}
-
 async function injectPageData(context: GetServerSidePropsContext, props: Props): Promise<void> {
 async function injectPageData(context: GetServerSidePropsContext, props: Props): Promise<void> {
   const { model: mongooseModel } = await import('mongoose');
   const { model: mongooseModel } = await import('mongoose');
 
 
@@ -492,10 +478,20 @@ async function injectPageData(context: GetServerSidePropsContext, props: Props):
       props.templateBodyData = templateData.templateBody as string;
       props.templateBodyData = templateData.templateBody as string;
     }
     }
 
 
-    // apply pagrent page grant
+    // apply parent page grant, without groups that user isn't related to
     const ancestor = await Page.findAncestorByPathAndViewer(currentPathname, user);
     const ancestor = await Page.findAncestorByPathAndViewer(currentPathname, user);
     if (ancestor != null) {
     if (ancestor != null) {
-      await applyGrantToPage(props, ancestor);
+      const userRelatedGrantedGroups = await pageService.getUserRelatedGrantedGroups(ancestor, user);
+      props.grantData = {
+        grant: ancestor.grant,
+        grantedGroups: userRelatedGrantedGroups.map((group) => {
+          return {
+            id: group.item._id,
+            name: group.item.name,
+            type: group.type,
+          };
+        }),
+      };
     }
     }
   }
   }
 
 
@@ -623,7 +619,7 @@ async function injectNextI18NextConfigurations(context: GetServerSidePropsContex
 }
 }
 
 
 export const getServerSideProps: GetServerSideProps = async(context: GetServerSidePropsContext) => {
 export const getServerSideProps: GetServerSideProps = async(context: GetServerSidePropsContext) => {
-  const req = context.req as CrowiRequest<IUserHasId & any>;
+  const req = context.req as CrowiRequest;
   const { user } = req;
   const { user } = req;
 
 
   const result = await getServerSideCommonProps(context);
   const result = await getServerSideCommonProps(context);

+ 1 - 1
apps/app/src/pages/_document.page.tsx

@@ -48,7 +48,7 @@ class GrowiDocument extends Document<GrowiDocumentInitialProps> {
 
 
   static override async getInitialProps(ctx: DocumentContext): Promise<GrowiDocumentInitialProps> {
   static override async getInitialProps(ctx: DocumentContext): Promise<GrowiDocumentInitialProps> {
     const initialProps: DocumentInitialProps = await Document.getInitialProps(ctx);
     const initialProps: DocumentInitialProps = await Document.getInitialProps(ctx);
-    const { crowi } = ctx.req as CrowiRequest<any>;
+    const { crowi } = ctx.req as CrowiRequest;
     const { customizeService } = crowi;
     const { customizeService } = crowi;
 
 
     const { themeHref } = customizeService;
     const { themeHref } = customizeService;

+ 1 - 1
apps/app/src/pages/_private-legacy-pages.page.tsx

@@ -124,7 +124,7 @@ async function injectNextI18NextConfigurations(context: GetServerSidePropsContex
 }
 }
 
 
 export const getServerSideProps: GetServerSideProps = async(context: GetServerSidePropsContext) => {
 export const getServerSideProps: GetServerSideProps = async(context: GetServerSidePropsContext) => {
-  const req = context.req as CrowiRequest<IUserHasId & any>;
+  const req = context.req as CrowiRequest;
   const { user } = req;
   const { user } = req;
 
 
   const result = await getServerSideCommonProps(context);
   const result = await getServerSideCommonProps(context);

+ 1 - 1
apps/app/src/pages/_search.page.tsx

@@ -152,7 +152,7 @@ async function injectNextI18NextConfigurations(context: GetServerSidePropsContex
 }
 }
 
 
 export const getServerSideProps: GetServerSideProps = async(context: GetServerSidePropsContext) => {
 export const getServerSideProps: GetServerSideProps = async(context: GetServerSidePropsContext) => {
-  const req = context.req as CrowiRequest<IUserHasId & any>;
+  const req = context.req as CrowiRequest;
   const { user } = req;
   const { user } = req;
 
 
   const result = await getServerSideCommonProps(context);
   const result = await getServerSideCommonProps(context);

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

@@ -26,13 +26,15 @@ const AdminUserGroupDetailPage: NextPage<Props> = (props: Props) => {
   useIsMaintenanceMode(props.isMaintenanceMode);
   useIsMaintenanceMode(props.isMaintenanceMode);
   useCurrentUser(props.currentUser ?? null);
   useCurrentUser(props.currentUser ?? null);
   const router = useRouter();
   const router = useRouter();
-  const { userGroupId } = router.query;
+  const { userGroupId, isExternalGroup } = router.query;
 
 
   const title = t('user_group_management.user_group_management');
   const title = t('user_group_management.user_group_management');
   const customTitle = generateCustomTitle(props, title);
   const customTitle = generateCustomTitle(props, title);
 
 
   const currentUserGroupId = Array.isArray(userGroupId) ? userGroupId[0] : userGroupId;
   const currentUserGroupId = Array.isArray(userGroupId) ? userGroupId[0] : userGroupId;
 
 
+  const isExternalGroupBool = isExternalGroup === 'true';
+
   useIsAclEnabled(props.isAclEnabled);
   useIsAclEnabled(props.isAclEnabled);
 
 
   if (props.isAccessDeniedForNonAdminUser) {
   if (props.isAccessDeniedForNonAdminUser) {
@@ -46,7 +48,7 @@ const AdminUserGroupDetailPage: NextPage<Props> = (props: Props) => {
       </Head>
       </Head>
       {
       {
         currentUserGroupId != null && router.isReady
         currentUserGroupId != null && router.isReady
-      && <UserGroupDetailPage userGroupId={currentUserGroupId} />
+      && <UserGroupDetailPage userGroupId={currentUserGroupId} isExternalGroup={isExternalGroupBool} />
       }
       }
     </AdminLayout>
     </AdminLayout>
   );
   );

+ 1 - 1
apps/app/src/pages/invited.page.tsx

@@ -71,7 +71,7 @@ async function injectNextI18NextConfigurations(context: GetServerSidePropsContex
 }
 }
 
 
 export const getServerSideProps: GetServerSideProps = async(context: GetServerSidePropsContext) => {
 export const getServerSideProps: GetServerSideProps = async(context: GetServerSidePropsContext) => {
-  const req = context.req as CrowiRequest<IUserHasId & any>;
+  const req = context.req as CrowiRequest;
   const { user } = req;
   const { user } = req;
   const result = await getServerSideCommonProps(context);
   const result = await getServerSideCommonProps(context);
 
 

+ 1 - 1
apps/app/src/pages/maintenance.page.tsx

@@ -79,7 +79,7 @@ async function injectNextI18NextConfigurations(context: GetServerSidePropsContex
 }
 }
 
 
 export const getServerSideProps: GetServerSideProps = async(context: GetServerSidePropsContext) => {
 export const getServerSideProps: GetServerSideProps = async(context: GetServerSidePropsContext) => {
-  const req = context.req as CrowiRequest<IUserHasId & any>;
+  const req = context.req as CrowiRequest;
 
 
   const result = await getServerSideCommonProps(context);
   const result = await getServerSideCommonProps(context);
 
 

+ 2 - 3
apps/app/src/pages/me/[[...path]].page.tsx

@@ -1,6 +1,5 @@
 import React, { useMemo } from 'react';
 import React, { useMemo } from 'react';
 
 
-import type { IUserHasId } from '@growi/core';
 import {
 import {
   GetServerSideProps, GetServerSidePropsContext,
   GetServerSideProps, GetServerSidePropsContext,
 } from 'next';
 } from 'next';
@@ -194,7 +193,7 @@ async function injectNextI18NextConfigurations(context: GetServerSidePropsContex
 }
 }
 
 
 export const getServerSideProps: GetServerSideProps = async(context: GetServerSidePropsContext) => {
 export const getServerSideProps: GetServerSideProps = async(context: GetServerSidePropsContext) => {
-  const req = context.req as CrowiRequest<IUserHasId & any>;
+  const req = context.req as CrowiRequest;
   const { user, crowi } = req;
   const { user, crowi } = req;
 
 
   const result = await getServerSideCommonProps(context);
   const result = await getServerSideCommonProps(context);
@@ -209,7 +208,7 @@ export const getServerSideProps: GetServerSideProps = async(context: GetServerSi
 
 
   if (user != null) {
   if (user != null) {
     const User = crowi.model('User');
     const User = crowi.model('User');
-    const userData = await User.findById(req.user.id).populate({ path: 'imageAttachment', select: 'filePathProxied' });
+    const userData = await User.findById(user.id).populate({ path: 'imageAttachment', select: 'filePathProxied' });
     props.currentUser = userData.toObject();
     props.currentUser = userData.toObject();
   }
   }
 
 

+ 1 - 1
apps/app/src/pages/share/[[...path]].page.tsx

@@ -224,7 +224,7 @@ async function addActivity(context: GetServerSidePropsContext, action: Supported
 }
 }
 
 
 export const getServerSideProps: GetServerSideProps = async(context: GetServerSidePropsContext) => {
 export const getServerSideProps: GetServerSideProps = async(context: GetServerSidePropsContext) => {
-  const req = context.req as CrowiRequest<IUserHasId & any>;
+  const req = context.req as CrowiRequest;
   const { crowi, params } = req;
   const { crowi, params } = req;
   const result = await getServerSideCommonProps(context);
   const result = await getServerSideCommonProps(context);
 
 

+ 1 - 1
apps/app/src/pages/tags.page.tsx

@@ -152,7 +152,7 @@ async function injectNextI18NextConfigurations(context: GetServerSidePropsContex
 }
 }
 
 
 export const getServerSideProps: GetServerSideProps = async(context: GetServerSidePropsContext) => {
 export const getServerSideProps: GetServerSideProps = async(context: GetServerSidePropsContext) => {
-  const req = context.req as CrowiRequest<IUserHasId & any>;
+  const req = context.req as CrowiRequest;
   const { user } = req;
   const { user } = req;
   const result = await getServerSideCommonProps(context);
   const result = await getServerSideCommonProps(context);
 
 

+ 1 - 1
apps/app/src/pages/trash.page.tsx

@@ -141,7 +141,7 @@ async function injectNextI18NextConfigurations(context: GetServerSidePropsContex
 }
 }
 
 
 export const getServerSideProps: GetServerSideProps = async(context: GetServerSidePropsContext) => {
 export const getServerSideProps: GetServerSideProps = async(context: GetServerSidePropsContext) => {
-  const req = context.req as CrowiRequest<IUserHasId & any>;
+  const req = context.req as CrowiRequest;
   const { user } = req;
   const { user } = req;
   const result = await getServerSideCommonProps(context);
   const result = await getServerSideCommonProps(context);
 
 

+ 1 - 1
apps/app/src/pages/utils/commons.ts

@@ -42,7 +42,7 @@ export type CommonProps = {
 export const getServerSideCommonProps: GetServerSideProps<CommonProps> = async(context: GetServerSidePropsContext) => {
 export const getServerSideCommonProps: GetServerSideProps<CommonProps> = async(context: GetServerSidePropsContext) => {
   const getModelSafely = await import('~/server/util/mongoose-utils').then(mod => mod.getModelSafely);
   const getModelSafely = await import('~/server/util/mongoose-utils').then(mod => mod.getModelSafely);
 
 
-  const req = context.req as CrowiRequest<IUserHasId & any>;
+  const req = context.req as CrowiRequest;
   const { crowi, user } = req;
   const { crowi, user } = req;
   const {
   const {
     appService, configManager, customizeService, attachmentService,
     appService, configManager, customizeService, attachmentService,

+ 102 - 84
apps/app/src/server/crowi/index.js

@@ -10,6 +10,8 @@ import next from 'next';
 
 
 import pkg from '^/package.json';
 import pkg from '^/package.json';
 
 
+import { KeycloakUserGroupSyncService } from '~/features/external-user-group/server/service/keycloak-user-group-sync';
+import { LdapUserGroupSyncService } from '~/features/external-user-group/server/service/ldap-user-group-sync';
 import QuestionnaireService from '~/features/questionnaire/server/service/questionnaire';
 import QuestionnaireService from '~/features/questionnaire/server/service/questionnaire';
 import QuestionnaireCronService from '~/features/questionnaire/server/service/questionnaire-cron';
 import QuestionnaireCronService from '~/features/questionnaire/server/service/questionnaire-cron';
 import CdnResourcesService from '~/services/cdn-resources-service';
 import CdnResourcesService from '~/services/cdn-resources-service';
@@ -18,93 +20,102 @@ import loggerFactory from '~/utils/logger';
 import { projectRoot } from '~/utils/project-dir-utils';
 import { projectRoot } from '~/utils/project-dir-utils';
 
 
 import UserEvent from '../events/user';
 import UserEvent from '../events/user';
-import Activity from '../models/activity';
-import PageRedirect from '../models/page-redirect';
-import ShareLink from '../models/share-link';
-import Tag from '../models/tag';
-import UserGroup from '../models/user-group';
+import { modelsDependsOnCrowi } from '../models';
 import { aclService as aclServiceSingletonInstance } from '../service/acl';
 import { aclService as aclServiceSingletonInstance } from '../service/acl';
 import AppService from '../service/app';
 import AppService from '../service/app';
 import AttachmentService from '../service/attachment';
 import AttachmentService from '../service/attachment';
 import { configManager as configManagerSingletonInstance } from '../service/config-manager';
 import { configManager as configManagerSingletonInstance } from '../service/config-manager';
+import { instanciate as instanciateExternalAccountService } from '../service/external-account';
+import { FileUploader, getUploader } from '../service/file-uploader'; // eslint-disable-line no-unused-vars
 import { G2GTransferPusherService, G2GTransferReceiverService } from '../service/g2g-transfer';
 import { G2GTransferPusherService, G2GTransferReceiverService } from '../service/g2g-transfer';
 import { InstallerService } from '../service/installer';
 import { InstallerService } from '../service/installer';
 import PageService from '../service/page';
 import PageService from '../service/page';
 import PageGrantService from '../service/page-grant';
 import PageGrantService from '../service/page-grant';
 import PageOperationService from '../service/page-operation';
 import PageOperationService from '../service/page-operation';
+import PassportService from '../service/passport';
 import SearchService from '../service/search';
 import SearchService from '../service/search';
 import { SlackIntegrationService } from '../service/slack-integration';
 import { SlackIntegrationService } from '../service/slack-integration';
+import UserGroupService from '../service/user-group';
 import { UserNotificationService } from '../service/user-notification';
 import { UserNotificationService } from '../service/user-notification';
 import { getMongoUri, mongoOptions } from '../util/mongoose-utils';
 import { getMongoUri, mongoOptions } from '../util/mongoose-utils';
 
 
 const logger = loggerFactory('growi:crowi');
 const logger = loggerFactory('growi:crowi');
 const httpErrorHandler = require('../middlewares/http-error-handler');
 const httpErrorHandler = require('../middlewares/http-error-handler');
-const models = require('../models');
 
 
 const sep = path.sep;
 const sep = path.sep;
 
 
-function Crowi() {
-  this.version = pkg.version;
-  this.runtimeVersions = undefined; // initialized by scanRuntimeVersions()
-
-  this.publicDir = path.join(projectRoot, 'public') + sep;
-  this.resourceDir = path.join(projectRoot, 'resource') + sep;
-  this.localeDir = path.join(this.resourceDir, 'locales') + sep;
-  this.viewsDir = path.resolve(__dirname, '../views') + sep;
-  this.tmpDir = path.join(projectRoot, 'tmp') + sep;
-  this.cacheDir = path.join(this.tmpDir, 'cache');
-
-  this.express = null;
-
-  this.config = {};
-  this.configManager = null;
-  this.s2sMessagingService = null;
-  this.g2gTransferPusherService = null;
-  this.g2gTransferReceiverService = null;
-  this.mailService = null;
-  this.passportService = null;
-  this.globalNotificationService = null;
-  this.userNotificationService = null;
-  this.xssService = null;
-  this.aclService = null;
-  this.appService = null;
-  this.fileUploadService = null;
-  this.restQiitaAPIService = null;
-  this.growiBridgeService = null;
-  this.exportService = null;
-  this.importService = null;
-  this.pluginService = null;
-  this.searchService = null;
-  this.socketIoService = null;
-  this.pageService = null;
-  this.syncPageStatusService = null;
-  this.cdnResourcesService = new CdnResourcesService();
-  this.slackIntegrationService = null;
-  this.inAppNotificationService = null;
-  this.activityService = null;
-  this.commentService = null;
-  this.xss = new Xss();
-  this.questionnaireService = null;
-  this.questionnaireCronService = null;
-
-  this.tokens = null;
-
-  this.models = {};
-
-  this.env = process.env;
-  this.node_env = this.env.NODE_ENV || 'development';
-
-  this.port = this.env.PORT || 3000;
-
-  this.events = {
-    user: new UserEvent(this),
-    page: new (require('../events/page'))(this),
-    activity: new (require('../events/activity'))(this),
-    bookmark: new (require('../events/bookmark'))(this),
-    comment: new (require('../events/comment'))(this),
-    tag: new (require('../events/tag'))(this),
-    admin: new (require('../events/admin'))(this),
-  };
+class Crowi {
+
+  /** @type {AppService} */
+  appService;
+
+  /** @type {FileUploader} */
+  fileUploadService;
+
+  constructor() {
+    this.version = pkg.version;
+    this.runtimeVersions = undefined; // initialized by scanRuntimeVersions()
+
+    this.publicDir = path.join(projectRoot, 'public') + sep;
+    this.resourceDir = path.join(projectRoot, 'resource') + sep;
+    this.localeDir = path.join(this.resourceDir, 'locales') + sep;
+    this.viewsDir = path.resolve(__dirname, '../views') + sep;
+    this.tmpDir = path.join(projectRoot, 'tmp') + sep;
+    this.cacheDir = path.join(this.tmpDir, 'cache');
+
+    this.express = null;
+
+    this.config = {};
+    this.configManager = null;
+    this.s2sMessagingService = null;
+    this.g2gTransferPusherService = null;
+    this.g2gTransferReceiverService = null;
+    this.mailService = null;
+    this.passportService = null;
+    this.globalNotificationService = null;
+    this.userNotificationService = null;
+    this.xssService = null;
+    this.aclService = null;
+    this.appService = null;
+    this.fileUploadService = null;
+    this.restQiitaAPIService = null;
+    this.growiBridgeService = null;
+    this.exportService = null;
+    this.importService = null;
+    this.pluginService = null;
+    this.searchService = null;
+    this.socketIoService = null;
+    this.pageService = null;
+    this.syncPageStatusService = null;
+    this.cdnResourcesService = new CdnResourcesService();
+    this.slackIntegrationService = null;
+    this.inAppNotificationService = null;
+    this.activityService = null;
+    this.commentService = null;
+    this.xss = new Xss();
+    this.questionnaireService = null;
+    this.questionnaireCronService = null;
+
+    this.tokens = null;
+
+    this.models = {};
+
+    this.env = process.env;
+    this.node_env = this.env.NODE_ENV || 'development';
+
+    this.port = this.env.PORT || 3000;
+
+    this.events = {
+      user: new UserEvent(this),
+      page: new (require('../events/page'))(this),
+      activity: new (require('../events/activity'))(this),
+      bookmark: new (require('../events/bookmark'))(this),
+      comment: new (require('../events/comment'))(this),
+      tag: new (require('../events/tag'))(this),
+      admin: new (require('../events/admin'))(this),
+    };
+  }
+
 }
 }
 
 
 Crowi.prototype.init = async function() {
 Crowi.prototype.init = async function() {
@@ -152,10 +163,13 @@ Crowi.prototype.init = async function() {
     this.setUpCustomize(), // depends on pluginService
     this.setUpCustomize(), // depends on pluginService
   ]);
   ]);
 
 
-  // globalNotification depends on slack and mailer
   await Promise.all([
   await Promise.all([
+    // globalNotification depends on slack and mailer
     this.setUpGlobalNotification(),
     this.setUpGlobalNotification(),
     this.setUpUserNotification(),
     this.setUpUserNotification(),
+    // depends on passport service
+    this.setupExternalAccountService(),
+    this.setupExternalUserGroupSyncService(),
   ]);
   ]);
 
 
   await this.autoInstall();
   await this.autoInstall();
@@ -294,20 +308,15 @@ Crowi.prototype.setupSocketIoService = async function() {
 };
 };
 
 
 Crowi.prototype.setupModels = async function() {
 Crowi.prototype.setupModels = async function() {
-  let allModels = {};
+  Object.keys(modelsDependsOnCrowi).forEach((key) => {
+    const factory = modelsDependsOnCrowi[key];
 
 
-  // include models that dependent on crowi
-  allModels = models;
-
-  // include models that independent from crowi
-  allModels.Activity = Activity;
-  allModels.Tag = Tag;
-  allModels.UserGroup = UserGroup;
-  allModels.PageRedirect = PageRedirect;
-  allModels.ShareLink = ShareLink;
+    if (!(factory instanceof Function)) {
+      logger.warn(`modelsDependsOnCrowi['${key}'] is not a function. skipped.`);
+      return;
+    }
 
 
-  Object.keys(allModels).forEach((key) => {
-    return this.model(key, models[key](this));
+    return this.model(key, modelsDependsOnCrowi[key](this));
   });
   });
 
 
 };
 };
@@ -360,7 +369,6 @@ Crowi.prototype.setupPassport = async function() {
   logger.debug('Passport is enabled');
   logger.debug('Passport is enabled');
 
 
   // initialize service
   // initialize service
-  const PassportService = require('../service/passport');
   if (this.passportService == null) {
   if (this.passportService == null) {
     this.passportService = new PassportService(this);
     this.passportService = new PassportService(this);
   }
   }
@@ -633,7 +641,7 @@ Crowi.prototype.setUpApp = async function() {
  */
  */
 Crowi.prototype.setUpFileUpload = async function(isForceUpdate = false) {
 Crowi.prototype.setUpFileUpload = async function(isForceUpdate = false) {
   if (this.fileUploadService == null || isForceUpdate) {
   if (this.fileUploadService == null || isForceUpdate) {
-    this.fileUploadService = require('../service/file-uploader')(this);
+    this.fileUploadService = getUploader(this);
   }
   }
 };
 };
 
 
@@ -669,7 +677,6 @@ Crowi.prototype.setUpRestQiitaAPI = async function() {
 };
 };
 
 
 Crowi.prototype.setupUserGroupService = async function() {
 Crowi.prototype.setupUserGroupService = async function() {
-  const UserGroupService = require('../service/user-group');
   if (this.userGroupService == null) {
   if (this.userGroupService == null) {
     this.userGroupService = new UserGroupService(this);
     this.userGroupService = new UserGroupService(this);
     return this.userGroupService.init();
     return this.userGroupService.init();
@@ -772,4 +779,15 @@ Crowi.prototype.setupG2GTransferService = async function() {
   }
   }
 };
 };
 
 
+// execute after setupPassport
+Crowi.prototype.setupExternalAccountService = function() {
+  instanciateExternalAccountService(this.passportService);
+};
+
+// execute after setupPassport, s2sMessagingService, socketIoService
+Crowi.prototype.setupExternalUserGroupSyncService = function() {
+  this.ldapUserGroupSyncService = new LdapUserGroupSyncService(this.passportService, this.s2sMessagingService, this.socketIoService);
+  this.keycloakUserGroupSyncService = new KeycloakUserGroupSyncService(this.s2sMessagingService, this.socketIoService);
+};
+
 export default Crowi;
 export default Crowi;

+ 20 - 18
apps/app/src/server/events/user.ts

@@ -1,10 +1,14 @@
 import EventEmitter from 'events';
 import EventEmitter from 'events';
 
 
-import type { IUserHasId } from '@growi/core';
+import type { IPage, IUserHasId } from '@growi/core';
 import { pagePathUtils } from '@growi/core/dist/utils';
 import { pagePathUtils } from '@growi/core/dist/utils';
+import mongoose from 'mongoose';
 
 
+import type { PageModel } from '~/server/models/page';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
+import { deleteCompletelyUserHomeBySystem } from '../service/page/delete-completely-user-home-by-system';
+
 const logger = loggerFactory('growi:events:user');
 const logger = loggerFactory('growi:events:user');
 
 
 class UserEvent extends EventEmitter {
 class UserEvent extends EventEmitter {
@@ -18,31 +22,29 @@ class UserEvent extends EventEmitter {
   }
   }
 
 
   async onActivated(user: IUserHasId): Promise<void> {
   async onActivated(user: IUserHasId): Promise<void> {
-    if (this.crowi.pageService === null) {
-      logger.warn('crowi pageService is null');
-      return;
-    }
-
-    const Page = this.crowi.model('Page');
+    const Page = mongoose.model<IPage, PageModel>('Page');
     const userHomepagePath = pagePathUtils.userHomepagePath(user);
     const userHomepagePath = pagePathUtils.userHomepagePath(user);
 
 
-    let page = await Page.findByPath(userHomepagePath, true);
+    try {
+      let page = await Page.findByPath(userHomepagePath, true);
 
 
-    if (page != null && page.creator != null && page.creator.toString() !== user._id.toString()) {
-      await this.crowi.pageService.deleteCompletelyUserHomeBySystem(userHomepagePath);
-      page = null;
-    }
+      // TODO: Make it more type safe
+      // Since the type of page.creator is 'any', we resort to the following comparison,
+      // checking if page.creator.toString() is not equal to user._id.toString(). Our code covers null, string, or object types.
+      if (page != null && page.creator != null && page.creator.toString() !== user._id.toString()) {
+        await deleteCompletelyUserHomeBySystem(userHomepagePath, this.crowi.pageService);
+        page = null;
+      }
 
 
-    if (page == null) {
-      const body = `# ${user.username}\nThis is ${user.username}'s page`;
+      if (page == null) {
+        const body = `# ${user.username}\nThis is ${user.username}'s page`;
 
 
-      try {
         await this.crowi.pageService.create(userHomepagePath, body, user, {});
         await this.crowi.pageService.create(userHomepagePath, body, user, {});
         logger.debug('User page created', page);
         logger.debug('User page created', page);
       }
       }
-      catch (err) {
-        logger.error('Failed to create user page', err);
-      }
+    }
+    catch (err) {
+      logger.error('Failed to create user page', err);
     }
     }
   }
   }
 
 

+ 24 - 0
apps/app/src/server/interfaces/attachment.ts

@@ -5,3 +5,27 @@ export const AttachmentType = {
 } as const;
 } as const;
 
 
 export type AttachmentType = typeof AttachmentType[keyof typeof AttachmentType];
 export type AttachmentType = typeof AttachmentType[keyof typeof AttachmentType];
+
+
+export type ExpressHttpHeader<Field = string> = {
+  field: Field,
+  value: string | string[]
+};
+
+export type IContentHeaders = {
+  contentType?: ExpressHttpHeader<'Content-Type'>;
+  contentLength?: ExpressHttpHeader<'Content-Length'>;
+  contentSecurityPolicy?: ExpressHttpHeader<'Content-Security-Policy'>;
+  contentDisposition?: ExpressHttpHeader<'Content-Disposition'>;
+}
+
+export type RespondOptions = {
+  download?: boolean,
+}
+
+export const ResponseMode = {
+  RELAY: 'relay',
+  REDIRECT: 'redirect',
+  DELEGATE: 'delegate',
+} as const;
+export type ResponseMode = typeof ResponseMode[keyof typeof ResponseMode];

+ 2 - 5
apps/app/src/server/middlewares/certify-shared-page-attachment/certify-shared-page-attachment.ts

@@ -26,20 +26,17 @@ export const certifySharedPageAttachmentMiddleware = async(req: RequestToAllowSh
 
 
   const validReferer = validateReferer(referer);
   const validReferer = validateReferer(referer);
   if (!validReferer) {
   if (!validReferer) {
-    logger.info('invalid referer.');
     return next();
     return next();
   }
   }
 
 
-  logger.info('referer is valid.');
-
   const shareLink = await retrieveValidShareLinkByReferer(validReferer);
   const shareLink = await retrieveValidShareLinkByReferer(validReferer);
   if (shareLink == null) {
   if (shareLink == null) {
-    logger.info(`No valid ShareLink document found by the referer (${validReferer.referer}})`);
+    logger.warn(`No valid ShareLink document found by the referer (${validReferer.referer}})`);
     return next();
     return next();
   }
   }
 
 
   if (!(await validateAttachment(fileId, shareLink))) {
   if (!(await validateAttachment(fileId, shareLink))) {
-    logger.info(`No valid ShareLink document found by the fileId (${fileId}) and referer (${validReferer.referer}})`);
+    logger.warn(`No valid ShareLink document found by the fileId (${fileId}) and referer (${validReferer.referer}})`);
     return next();
     return next();
   }
   }
 
 

+ 2 - 2
apps/app/src/server/middlewares/certify-shared-page-attachment/retrieve-valid-share-link.ts

@@ -15,9 +15,9 @@ export const retrieveValidShareLinkByReferer = async(referer: ValidReferer): Pro
     return null;
     return null;
   }
   }
 
 
-  const shareLinkId = referer;
+  const { shareLinkId } = referer;
   const shareLink = await ShareLink.findOne({
   const shareLink = await ShareLink.findOne({
-    id: shareLinkId,
+    _id: shareLinkId,
   });
   });
   if (shareLink == null || shareLink.isExpired()) {
   if (shareLink == null || shareLink.isExpired()) {
     logger.info(`ShareLink ('${shareLinkId}') is not found or has already expired.`);
     logger.info(`ShareLink ('${shareLinkId}') is not found or has already expired.`);

+ 1 - 1
apps/app/src/server/middlewares/certify-shared-page-attachment/validate-referer/retrieve-site-url.ts

@@ -2,7 +2,7 @@ import { configManager } from '~/server/service/config-manager';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
 
 
-const logger = loggerFactory('growi:middlewares:certify-shared-file:validate-referer:retrieve-site-url');
+const logger = loggerFactory('growi:middlewares:certify-shared-page-attachment:validate-referer:retrieve-site-url');
 
 
 
 
 export const retrieveSiteUrl = (): URL | null => {
 export const retrieveSiteUrl = (): URL | null => {

+ 5 - 2
apps/app/src/server/middlewares/certify-shared-page-attachment/validate-referer/validate-referer.ts

@@ -7,7 +7,7 @@ import { ValidReferer } from '../interfaces';
 import { retrieveSiteUrl } from './retrieve-site-url';
 import { retrieveSiteUrl } from './retrieve-site-url';
 
 
 
 
-const logger = loggerFactory('growi:middlewares:certify-shared-file:validate-referer');
+const logger = loggerFactory('growi:middlewares:certify-shared-page-attachment:validate-referer');
 
 
 
 
 export const validateReferer = (referer: string | undefined): ValidReferer | false => {
 export const validateReferer = (referer: string | undefined): ValidReferer | false => {
@@ -51,7 +51,10 @@ export const validateReferer = (referer: string | undefined): ValidReferer | fal
   // validate pathname
   // validate pathname
   // https://regex101.com/r/M5Bp6E/1
   // https://regex101.com/r/M5Bp6E/1
   const match = refererUrl.pathname.match(/^\/share\/(?<shareLinkId>[a-f0-9]{24})$/i);
   const match = refererUrl.pathname.match(/^\/share\/(?<shareLinkId>[a-f0-9]{24})$/i);
-  if (match == null || match.groups?.shareLinkId == null) {
+  if (match == null) {
+    return false;
+  }
+  if (match.groups?.shareLinkId == null) {
     logger.warn(`The pathname ('${refererUrl.pathname}') is invalid.`, match);
     logger.warn(`The pathname ('${refererUrl.pathname}') is invalid.`, match);
     return false;
     return false;
   }
   }

+ 0 - 103
apps/app/src/server/models/attachment.js

@@ -1,103 +0,0 @@
-import path from 'path';
-
-import loggerFactory from '~/utils/logger';
-
-import { AttachmentType } from '../interfaces/attachment';
-
-
-const { addSeconds } = require('date-fns');
-const mongoose = require('mongoose');
-const mongoosePaginate = require('mongoose-paginate-v2');
-const uniqueValidator = require('mongoose-unique-validator');
-
-// eslint-disable-next-line no-unused-vars
-const logger = loggerFactory('growi:models:attachment');
-
-const ObjectId = mongoose.Schema.Types.ObjectId;
-
-module.exports = function(crowi) {
-  function generateFileHash(fileName) {
-    const hash = require('crypto').createHash('md5');
-    hash.update(`${fileName}_${Date.now()}`);
-
-    return hash.digest('hex');
-  }
-
-  const attachmentSchema = new mongoose.Schema({
-    page: { type: ObjectId, ref: 'Page', index: true },
-    creator: { type: ObjectId, ref: 'User', index: true },
-    filePath: { type: String }, // DEPRECATED: remains for backward compatibility for v3.3.x or below
-    fileName: { type: String, required: true, unique: true },
-    originalName: { type: String },
-    fileFormat: { type: String, required: true },
-    fileSize: { type: Number, default: 0 },
-    temporaryUrlCached: { type: String },
-    temporaryUrlExpiredAt: { type: Date },
-    attachmentType: {
-      type: String,
-      enum: AttachmentType,
-      required: true,
-    },
-  }, {
-    timestamps: { createdAt: true, updatedAt: false },
-  });
-  attachmentSchema.plugin(uniqueValidator);
-  attachmentSchema.plugin(mongoosePaginate);
-
-  attachmentSchema.virtual('filePathProxied').get(function() {
-    return `/attachment/${this._id}`;
-  });
-
-  attachmentSchema.virtual('downloadPathProxied').get(function() {
-    return `/download/${this._id}`;
-  });
-
-  attachmentSchema.set('toObject', { virtuals: true });
-  attachmentSchema.set('toJSON', { virtuals: true });
-
-
-  attachmentSchema.statics.createWithoutSave = function(pageId, user, fileStream, originalName, fileFormat, fileSize, attachmentType) {
-    // eslint-disable-next-line @typescript-eslint/no-this-alias
-    const Attachment = this;
-
-    const extname = path.extname(originalName);
-    let fileName = generateFileHash(originalName);
-    if (extname.length > 1) { // ignore if empty or '.' only
-      fileName = `${fileName}${extname}`;
-    }
-
-    const attachment = new Attachment();
-    attachment.page = pageId;
-    attachment.creator = user._id;
-    attachment.originalName = originalName;
-    attachment.fileName = fileName;
-    attachment.fileFormat = fileFormat;
-    attachment.fileSize = fileSize;
-    attachment.attachmentType = attachmentType;
-    return attachment;
-  };
-
-
-  attachmentSchema.methods.getValidTemporaryUrl = function() {
-    if (this.temporaryUrlExpiredAt == null) {
-      return null;
-    }
-    // return null when expired url
-    if (this.temporaryUrlExpiredAt.getTime() < new Date().getTime()) {
-      return null;
-    }
-    return this.temporaryUrlCached;
-  };
-
-  attachmentSchema.methods.cashTemporaryUrlByProvideSec = function(temporaryUrl, provideSec) {
-    if (temporaryUrl == null) {
-      throw new Error('url is required.');
-    }
-    this.temporaryUrlCached = temporaryUrl;
-    this.temporaryUrlExpiredAt = addSeconds(new Date(), provideSec);
-
-    return this.save();
-  };
-
-  return mongoose.model('Attachment', attachmentSchema);
-};

+ 115 - 0
apps/app/src/server/models/attachment.ts

@@ -0,0 +1,115 @@
+import path from 'path';
+
+import type { IAttachment } from '@growi/core';
+import { addSeconds } from 'date-fns';
+import {
+  Schema, type Model, type Document, Types,
+} from 'mongoose';
+import mongoosePaginate from 'mongoose-paginate-v2';
+import uniqueValidator from 'mongoose-unique-validator';
+
+import loggerFactory from '~/utils/logger';
+
+import { AttachmentType } from '../interfaces/attachment';
+import { getOrCreateModel } from '../util/mongoose-utils';
+
+// eslint-disable-next-line no-unused-vars
+const logger = loggerFactory('growi:models:attachment');
+
+
+function generateFileHash(fileName) {
+  const hash = require('crypto').createHash('md5');
+  hash.update(`${fileName}_${Date.now()}`);
+
+  return hash.digest('hex');
+}
+
+type GetValidTemporaryUrl = () => string | null | undefined;
+type CashTemporaryUrlByProvideSec = (temporaryUrl: string, lifetimeSec: number) => Promise<IAttachmentDocument>;
+
+export interface IAttachmentDocument extends IAttachment, Document {
+  getValidTemporaryUrl: GetValidTemporaryUrl
+  cashTemporaryUrlByProvideSec: CashTemporaryUrlByProvideSec,
+}
+export interface IAttachmentModel extends Model<IAttachmentDocument> {
+  createWithoutSave
+}
+
+const attachmentSchema = new Schema({
+  page: { type: Types.ObjectId, ref: 'Page', index: true },
+  creator: { type: Types.ObjectId, ref: 'User', index: true },
+  fileName: { type: String, required: true, unique: true },
+  fileFormat: { type: String, required: true },
+  fileSize: { type: Number, default: 0 },
+  originalName: { type: String },
+  temporaryUrlCached: { type: String },
+  temporaryUrlExpiredAt: { type: Date },
+  attachmentType: {
+    type: String,
+    enum: AttachmentType,
+    required: true,
+  },
+}, {
+  timestamps: { createdAt: true, updatedAt: false },
+});
+attachmentSchema.plugin(uniqueValidator);
+attachmentSchema.plugin(mongoosePaginate);
+
+// virtual
+attachmentSchema.virtual('filePathProxied').get(function() {
+  return `/attachment/${this._id}`;
+});
+
+attachmentSchema.virtual('downloadPathProxied').get(function() {
+  return `/download/${this._id}`;
+});
+
+attachmentSchema.set('toObject', { virtuals: true });
+attachmentSchema.set('toJSON', { virtuals: true });
+
+
+attachmentSchema.statics.createWithoutSave = function(pageId, user, fileStream, originalName, fileFormat, fileSize, attachmentType) {
+  // eslint-disable-next-line @typescript-eslint/no-this-alias
+  const Attachment = this;
+
+  const extname = path.extname(originalName);
+  let fileName = generateFileHash(originalName);
+  if (extname.length > 1) { // ignore if empty or '.' only
+    fileName = `${fileName}${extname}`;
+  }
+
+  const attachment = new Attachment();
+  attachment.page = pageId;
+  attachment.creator = user._id;
+  attachment.originalName = originalName;
+  attachment.fileName = fileName;
+  attachment.fileFormat = fileFormat;
+  attachment.fileSize = fileSize;
+  attachment.attachmentType = attachmentType;
+  return attachment;
+};
+
+const getValidTemporaryUrl: GetValidTemporaryUrl = function(this: IAttachmentDocument) {
+  if (this.temporaryUrlExpiredAt == null) {
+    return null;
+  }
+  // return null when expired url
+  if (this.temporaryUrlExpiredAt.getTime() < new Date().getTime()) {
+    return null;
+  }
+  return this.temporaryUrlCached;
+};
+attachmentSchema.methods.getValidTemporaryUrl = getValidTemporaryUrl;
+
+const cashTemporaryUrlByProvideSec: CashTemporaryUrlByProvideSec = function(this: IAttachmentDocument, temporaryUrl, lifetimeSec) {
+  if (temporaryUrl == null) {
+    throw new Error('url is required.');
+  }
+  this.temporaryUrlCached = temporaryUrl;
+  this.temporaryUrlExpiredAt = addSeconds(new Date(), lifetimeSec);
+
+  return this.save();
+};
+attachmentSchema.methods.cashTemporaryUrlByProvideSec = cashTemporaryUrlByProvideSec;
+
+export const Attachment = getOrCreateModel<IAttachmentDocument, IAttachmentModel>('Attachment', attachmentSchema);

+ 8 - 1
apps/app/src/server/models/config.ts

@@ -71,7 +71,8 @@ export const defaultCrowiConfigs: { [key: string]: any } = {
   'security:pageRecursiveDeletionAuthority' : undefined,
   'security:pageRecursiveDeletionAuthority' : undefined,
   'security:pageRecursiveCompleteDeletionAuthority' : undefined,
   'security:pageRecursiveCompleteDeletionAuthority' : undefined,
   'security:disableLinkSharing' : false,
   'security:disableLinkSharing' : false,
-  'security:isUsersHomepageDeletionEnabled': false,
+  'security:user-homepage-deletion:isEnabled': false,
+  'security:user-homepage-deletion:isForceDeleteUserHomepageOnUserDeletion': false,
 
 
   'security:passport-local:isEnabled' : true,
   'security:passport-local:isEnabled' : true,
   'security:passport-ldap:isEnabled' : false,
   'security:passport-ldap:isEnabled' : false,
@@ -143,6 +144,12 @@ export const defaultCrowiConfigs: { [key: string]: any } = {
   'importer:esa:access_token': undefined,
   'importer:esa:access_token': undefined,
   'importer:qiita:team_name': undefined,
   'importer:qiita:team_name': undefined,
   'importer:qiita:access_token': undefined,
   'importer:qiita:access_token': undefined,
+
+  'external-user-group:ldap:groupMembershipAttributeType': 'DN',
+  'external-user-group:ldap:autoGenerateUserOnGroupSync': false,
+  'external-user-group:ldap:preserveDeletedGroups': false,
+  'external-user-group:keycloak:autoGenerateUserOnGroupSync': false,
+  'external-user-group:keycloak:preserveDeletedGroups': false,
   /* eslint-enable key-spacing */
   /* eslint-enable key-spacing */
 };
 };
 
 

+ 0 - 187
apps/app/src/server/models/external-account.js

@@ -1,187 +0,0 @@
-// disable no-return-await for model functions
-/* eslint-disable no-return-await */
-import { NullUsernameToBeRegisteredError } from '~/server/models/errors';
-
-const debug = require('debug')('growi:models:external-account');
-const mongoose = require('mongoose');
-const mongoosePaginate = require('mongoose-paginate-v2');
-const uniqueValidator = require('mongoose-unique-validator');
-
-const ObjectId = mongoose.Schema.Types.ObjectId;
-
-/*
- * define schema
- */
-const schema = new mongoose.Schema({
-  providerType: { type: String, required: true },
-  accountId: { type: String, required: true },
-  user: { type: ObjectId, ref: 'User', required: true },
-}, {
-  timestamps: { createdAt: true, updatedAt: false },
-});
-// compound index
-schema.index({ providerType: 1, accountId: 1 }, { unique: true });
-// apply plugins
-schema.plugin(mongoosePaginate);
-schema.plugin(uniqueValidator);
-
-/**
- * The Exception class thrown when User.username is duplicated when creating user
- *
- * @class DuplicatedUsernameException
- */
-class DuplicatedUsernameException {
-
-  constructor(message, user) {
-    this.name = this.constructor.name;
-    this.message = message;
-    this.user = user;
-  }
-
-}
-
-/**
- * ExternalAccount Class
- *
- * @class ExternalAccount
- */
-class ExternalAccount {
-
-  /**
-   * limit items num for pagination
-   *
-   * @readonly
-   * @static
-   * @memberof ExternalAccount
-   */
-  static get DEFAULT_LIMIT() {
-    return 50;
-  }
-
-  static set crowi(crowi) {
-    this._crowi = crowi;
-  }
-
-  static get crowi() {
-    return this._crowi;
-  }
-
-  /**
-   * get the populated user entity
-   *
-   * @returns Promise<User>
-   * @memberof ExternalAccount
-   */
-  getPopulatedUser() {
-    return this.populate('user')
-      .then((account) => {
-        return account.user;
-      });
-  }
-
-  /**
-   * find an account or register if not found
-   *
-   * @static
-   * @param {string} providerType
-   * @param {string} accountId
-   * @param {object} usernameToBeRegistered the username of User entity that will be created when accountId is not found
-   * @param {object} nameToBeRegistered the name of User entity that will be created when accountId is not found
-   * @param {object} mailToBeRegistered the mail of User entity that will be created when accountId is not found
-   * @param {boolean} isSameUsernameTreatedAsIdenticalUser
-   * @param {boolean} isSameEmailTreatedAsIdenticalUser
-   * @returns {Promise<ExternalAccount>}
-   * @memberof ExternalAccount
-   */
-  static findOrRegister(providerType, accountId,
-      usernameToBeRegistered, nameToBeRegistered, mailToBeRegistered,
-      isSameUsernameTreatedAsIdenticalUser, isSameEmailTreatedAsIdenticalUser) {
-    //
-    return this.findOne({ providerType, accountId })
-      .then((account) => {
-        // ExternalAccount is found
-        if (account != null) {
-          debug(`ExternalAccount '${accountId}' is found `, account);
-          return account;
-        }
-
-        if (usernameToBeRegistered == null) {
-          throw new NullUsernameToBeRegisteredError('username_should_not_be_null');
-        }
-
-        const User = ExternalAccount.crowi.model('User');
-
-        let promise = User.findOne({ username: usernameToBeRegistered });
-        if (isSameUsernameTreatedAsIdenticalUser && isSameEmailTreatedAsIdenticalUser) {
-          promise = promise
-            .then((user) => {
-              if (user == null) { return User.findOne({ email: mailToBeRegistered }) }
-              return user;
-            });
-        }
-        else if (isSameEmailTreatedAsIdenticalUser) {
-          promise = User.findOne({ email: mailToBeRegistered });
-        }
-
-        return promise
-          .then((user) => {
-            // when the User that have the same `username` exists
-            if (user != null) {
-              throw new DuplicatedUsernameException(`User '${usernameToBeRegistered}' already exists`, user);
-            }
-            if (nameToBeRegistered == null) {
-              // eslint-disable-next-line no-param-reassign
-              nameToBeRegistered = '';
-            }
-
-            // create a new User with STATUS_ACTIVE
-            debug(`ExternalAccount '${accountId}' is not found, it is going to be registered.`);
-            return User.createUser(nameToBeRegistered, usernameToBeRegistered, mailToBeRegistered, undefined, undefined, User.STATUS_ACTIVE);
-          })
-          .then((newUser) => {
-            return this.associate(providerType, accountId, newUser);
-          });
-      });
-  }
-
-  /**
-   * Create ExternalAccount document and associate to existing User
-   *
-   * @param {string} providerType
-   * @param {string} accountId
-   * @param {object} user
-   */
-  static associate(providerType, accountId, user) {
-    return this.create({ providerType, accountId, user: user._id });
-  }
-
-  /**
-   * find all entities with pagination
-   *
-   * @see https://github.com/edwardhotchkiss/mongoose-paginate
-   *
-   * @static
-   * @param {any} opts mongoose-paginate options object
-   * @returns {Promise<any>} mongoose-paginate result object
-   * @memberof ExternalAccount
-   */
-  static findAllWithPagination(opts) {
-    const query = {};
-    const options = Object.assign({ populate: 'user' }, opts);
-    if (options.sort == null) {
-      options.sort = { accountId: 1, createdAt: 1 };
-    }
-    if (options.limit == null) {
-      options.limit = ExternalAccount.DEFAULT_LIMIT;
-    }
-
-    return this.paginate(query, options);
-  }
-
-}
-
-module.exports = function(crowi) {
-  ExternalAccount.crowi = crowi;
-  schema.loadClass(ExternalAccount);
-  return mongoose.model('ExternalAccount', schema);
-};

+ 151 - 0
apps/app/src/server/models/external-account.ts

@@ -0,0 +1,151 @@
+// disable no-return-await for model functions
+/* eslint-disable no-return-await */
+import type { IExternalAccount, IExternalAccountHasId, IUserHasId } from '@growi/core';
+import { Schema, Model, Document } from 'mongoose';
+
+import { NullUsernameToBeRegisteredError } from '~/server/models/errors';
+
+import { getOrCreateModel } from '../util/mongoose-utils';
+
+const debug = require('debug')('growi:models:external-account');
+const mongoose = require('mongoose');
+const mongoosePaginate = require('mongoose-paginate-v2');
+const uniqueValidator = require('mongoose-unique-validator');
+
+const ObjectId = mongoose.Schema.Types.ObjectId;
+
+export interface ExternalAccountDocument extends IExternalAccount, Document {}
+
+export interface ExternalAccountModel extends Model<ExternalAccountDocument> {
+  [x:string]: any, // for old methods
+}
+
+const schema = new Schema<ExternalAccountDocument, ExternalAccountModel>({
+  providerType: { type: String, required: true },
+  accountId: { type: String, required: true },
+  user: { type: ObjectId, ref: 'User', required: true },
+}, {
+  timestamps: { createdAt: true, updatedAt: false },
+});
+// compound index
+schema.index({ providerType: 1, accountId: 1 }, { unique: true });
+// apply plugins
+schema.plugin(mongoosePaginate);
+schema.plugin(uniqueValidator);
+
+/**
+ * limit items num for pagination
+ */
+const DEFAULT_LIMIT = 50;
+
+/**
+ * The Exception class thrown when User.username is duplicated when creating user
+ *
+ * @class DuplicatedUsernameException
+ */
+class DuplicatedUsernameException {
+
+  name: string;
+
+  message: string;
+
+  user: IUserHasId;
+
+  constructor(message, user) {
+    this.name = this.constructor.name;
+    this.message = message;
+    this.user = user;
+  }
+
+}
+
+/**
+ * find an account or register if not found
+ */
+schema.statics.findOrRegister = function(
+    isSameUsernameTreatedAsIdenticalUser: boolean,
+    isSameEmailTreatedAsIdenticalUser: boolean,
+    providerType: string,
+    accountId: string,
+    usernameToBeRegistered?: string,
+    nameToBeRegistered?: string,
+    mailToBeRegistered?: string,
+): Promise<IExternalAccountHasId> {
+  return this.findOne({ providerType, accountId })
+    .then((account) => {
+    // ExternalAccount is found
+      if (account != null) {
+        debug(`ExternalAccount '${accountId}' is found `, account);
+        return account;
+      }
+
+      if (usernameToBeRegistered == null) {
+        throw new NullUsernameToBeRegisteredError('username_should_not_be_null');
+      }
+
+      const User = mongoose.model('User');
+
+      let promise = User.findOne({ username: usernameToBeRegistered });
+      if (isSameUsernameTreatedAsIdenticalUser && isSameEmailTreatedAsIdenticalUser) {
+        promise = promise
+          .then((user) => {
+            if (user == null) { return User.findOne({ email: mailToBeRegistered }) }
+            return user;
+          });
+      }
+      else if (isSameEmailTreatedAsIdenticalUser) {
+        promise = User.findOne({ email: mailToBeRegistered });
+      }
+
+      return promise
+        .then((user) => {
+        // when the User that have the same `username` exists
+          if (user != null) {
+            throw new DuplicatedUsernameException(`User '${usernameToBeRegistered}' already exists`, user);
+          }
+          if (nameToBeRegistered == null) {
+          // eslint-disable-next-line no-param-reassign
+            nameToBeRegistered = '';
+          }
+
+          // create a new User with STATUS_ACTIVE
+          debug(`ExternalAccount '${accountId}' is not found, it is going to be registered.`);
+          return User.createUser(nameToBeRegistered, usernameToBeRegistered, mailToBeRegistered, undefined, undefined, User.STATUS_ACTIVE);
+        })
+        .then((newUser) => {
+          return this.associate(providerType, accountId, newUser);
+        });
+    });
+};
+
+/**
+ * Create ExternalAccount document and associate to existing User
+ */
+schema.statics.associate = function(providerType: string, accountId: string, user: IUserHasId) {
+  return this.create({ providerType, accountId, user: user._id });
+};
+
+/**
+ * find all entities with pagination
+ *
+ * @see https://github.com/edwardhotchkiss/mongoose-paginate
+ *
+ * @static
+ * @param {any} opts mongoose-paginate options object
+ * @returns {Promise<any>} mongoose-paginate result object
+ * @memberof ExternalAccount
+ */
+schema.statics.findAllWithPagination = function(opts) {
+  const query = {};
+  const options = Object.assign({ populate: 'user' }, opts);
+  if (options.sort == null) {
+    options.sort = { accountId: 1, createdAt: 1 };
+  }
+  if (options.limit == null) {
+    options.limit = DEFAULT_LIMIT;
+  }
+
+  return this.paginate(query, options);
+};
+
+export default getOrCreateModel<ExternalAccountDocument, ExternalAccountModel>('ExternalAccount', schema);

+ 12 - 6
apps/app/src/server/models/index.js

@@ -1,18 +1,24 @@
-import Page from '~/server/models/page';
+import Page from './page';
 
 
-module.exports = {
+export const modelsDependsOnCrowi = {
   Page,
   Page,
   PageTagRelation: require('./page-tag-relation'),
   PageTagRelation: require('./page-tag-relation'),
   User: require('./user'),
   User: require('./user'),
-  ExternalAccount: require('./external-account'),
-  UserGroupRelation: require('./user-group-relation'),
   Revision: require('./revision'),
   Revision: require('./revision'),
-  Tag: require('./tag'),
   Bookmark: require('./bookmark'),
   Bookmark: require('./bookmark'),
   Comment: require('./comment'),
   Comment: require('./comment'),
-  Attachment: require('./attachment'),
   GlobalNotificationSetting: require('./GlobalNotificationSetting'),
   GlobalNotificationSetting: require('./GlobalNotificationSetting'),
   GlobalNotificationMailSetting: require('./GlobalNotificationSetting/GlobalNotificationMailSetting'),
   GlobalNotificationMailSetting: require('./GlobalNotificationSetting/GlobalNotificationMailSetting'),
   GlobalNotificationSlackSetting: require('./GlobalNotificationSetting/GlobalNotificationSlackSetting'),
   GlobalNotificationSlackSetting: require('./GlobalNotificationSetting/GlobalNotificationSlackSetting'),
   SlackAppIntegration: require('./slack-app-integration'),
   SlackAppIntegration: require('./slack-app-integration'),
 };
 };
+
+// setup models that independent from crowi
+export * from './attachment';
+export * as Activity from './activity';
+export * as PageRedirect from './page-redirect';
+export * as ShareLink from './share-link';
+export * as Tag from './tag';
+export * as UserGroup from './user-group';
+
+export * from './serializers';

+ 4 - 4
apps/app/src/server/models/interfaces/page-operation.ts

@@ -1,4 +1,4 @@
-import { PageGrant } from '@growi/core';
+import { PageGrant, type IGrantedGroup } from '@growi/core';
 
 
 import { ObjectIdLike } from '../../interfaces/mongoose-utils';
 import { ObjectIdLike } from '../../interfaces/mongoose-utils';
 
 
@@ -9,7 +9,7 @@ export type IPageForResuming = {
   parent?: ObjectIdLike,
   parent?: ObjectIdLike,
   grant?: number,
   grant?: number,
   grantedUsers?: ObjectIdLike[],
   grantedUsers?: ObjectIdLike[],
-  grantedGroup?: ObjectIdLike,
+  grantedGroups: IGrantedGroup[],
   descendantCount: number,
   descendantCount: number,
   status?: number,
   status?: number,
   revision?: ObjectIdLike,
   revision?: ObjectIdLike,
@@ -23,14 +23,14 @@ export type IUserForResuming = {
 
 
 export type IOptionsForUpdate = {
 export type IOptionsForUpdate = {
   grant?: PageGrant,
   grant?: PageGrant,
-  grantUserGroupId?: ObjectIdLike,
+  grantUserGroupIds?: IGrantedGroup[],
   isSyncRevisionToHackmd?: boolean,
   isSyncRevisionToHackmd?: boolean,
   overwriteScopesOfDescendants?: boolean,
   overwriteScopesOfDescendants?: boolean,
 };
 };
 
 
 export type IOptionsForCreate = {
 export type IOptionsForCreate = {
   format?: string,
   format?: string,
-  grantUserGroupId?: ObjectIdLike,
+  grantUserGroupIds?: IGrantedGroup[],
   grant?: PageGrant,
   grant?: PageGrant,
   overwriteScopesOfDescendants?: boolean,
   overwriteScopesOfDescendants?: boolean,
   isSynchronously?: boolean,
   isSynchronously?: boolean,

+ 48 - 49
apps/app/src/server/models/obsolete-page.js

@@ -1,20 +1,24 @@
-import { PageGrant } from '@growi/core';
+import { PageGrant, GroupType } from '@growi/core';
 import { templateChecker, pagePathUtils, pathUtils } from '@growi/core/dist/utils';
 import { templateChecker, pagePathUtils, pathUtils } from '@growi/core/dist/utils';
 import escapeStringRegexp from 'escape-string-regexp';
 import escapeStringRegexp from 'escape-string-regexp';
 
 
+import ExternalUserGroup from '~/features/external-user-group/server/models/external-user-group';
+import ExternalUserGroupRelation from '~/features/external-user-group/server/models/external-user-group-relation';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
+import UserGroup from './user-group';
+import UserGroupRelation from './user-group-relation';
+
 
 
 // disable no-return-await for model functions
 // disable no-return-await for model functions
 /* eslint-disable no-return-await */
 /* eslint-disable no-return-await */
 
 
 /* eslint-disable no-use-before-define */
 /* eslint-disable no-use-before-define */
 
 
-const debug = require('debug')('growi:models:page');
-
 const nodePath = require('path');
 const nodePath = require('path');
 
 
 const differenceInYears = require('date-fns/differenceInYears');
 const differenceInYears = require('date-fns/differenceInYears');
+const debug = require('debug')('growi:models:page');
 const mongoose = require('mongoose');
 const mongoose = require('mongoose');
 const urljoin = require('url-join');
 const urljoin = require('url-join');
 
 
@@ -69,12 +73,12 @@ export const extractToAncestorsPaths = (pagePath) => {
 export const populateDataToShowRevision = (page, userPublicFields, shouldExcludeBody = false) => {
 export const populateDataToShowRevision = (page, userPublicFields, shouldExcludeBody = false) => {
   return page
   return page
     .populate([
     .populate([
-      { path: 'lastUpdateUser', model: 'User', select: userPublicFields },
-      { path: 'creator', model: 'User', select: userPublicFields },
-      { path: 'deleteUser', model: 'User', select: userPublicFields },
-      { path: 'grantedGroup', model: 'UserGroup' },
-      { path: 'revision', model: 'Revision', select: shouldExcludeBody ? '-body' : undefined, populate: {
-        path: 'author', model: 'User', select: userPublicFields,
+      { path: 'lastUpdateUser', select: userPublicFields },
+      { path: 'creator', select: userPublicFields },
+      { path: 'deleteUser', select: userPublicFields },
+      { path: 'grantedGroups.item' },
+      { path: 'revision', select: shouldExcludeBody ? '-body' : undefined, populate: {
+        path: 'author', select: userPublicFields,
       } },
       } },
     ]);
     ]);
 };
 };
@@ -249,10 +253,10 @@ export const getPageSchema = (crowi) => {
     return this.populate('revision');
     return this.populate('revision');
   };
   };
 
 
-  pageSchema.methods.applyScope = function(user, grant, grantUserGroupId) {
+  pageSchema.methods.applyScope = function(user, grant, grantUserGroupIds) {
     // Reset
     // Reset
     this.grantedUsers = [];
     this.grantedUsers = [];
-    this.grantedGroup = null;
+    this.grantedGroups = [];
 
 
     this.grant = grant || GRANT_PUBLIC;
     this.grant = grant || GRANT_PUBLIC;
 
 
@@ -261,7 +265,7 @@ export const getPageSchema = (crowi) => {
     }
     }
 
 
     if (grant === GRANT_USER_GROUP) {
     if (grant === GRANT_USER_GROUP) {
-      this.grantedGroup = grantUserGroupId;
+      this.grantedGroups = grantUserGroupIds;
     }
     }
   };
   };
 
 
@@ -319,12 +323,10 @@ export const getPageSchema = (crowi) => {
   pageSchema.statics.isAccessiblePageByViewer = async function(id, user) {
   pageSchema.statics.isAccessiblePageByViewer = async function(id, user) {
     const baseQuery = this.count({ _id: id });
     const baseQuery = this.count({ _id: id });
 
 
-    let userGroups = [];
-    if (user != null) {
-      validateCrowi();
-      const UserGroupRelation = crowi.model('UserGroupRelation');
-      userGroups = await UserGroupRelation.findAllUserGroupIdsRelatedToUser(user);
-    }
+    const userGroups = user != null ? [
+      ...(await UserGroupRelation.findAllUserGroupIdsRelatedToUser(user)),
+      ...(await ExternalUserGroupRelation.findAllUserGroupIdsRelatedToUser(user)),
+    ] : [];
 
 
     const queryBuilder = new this.PageQueryBuilder(baseQuery);
     const queryBuilder = new this.PageQueryBuilder(baseQuery);
     queryBuilder.addConditionToFilteringByViewer(user, userGroups, true);
     queryBuilder.addConditionToFilteringByViewer(user, userGroups, true);
@@ -341,12 +343,10 @@ export const getPageSchema = (crowi) => {
   pageSchema.statics.findByIdAndViewer = async function(id, user, userGroups, includeEmpty = false) {
   pageSchema.statics.findByIdAndViewer = async function(id, user, userGroups, includeEmpty = false) {
     const baseQuery = this.findOne({ _id: id });
     const baseQuery = this.findOne({ _id: id });
 
 
-    let relatedUserGroups = userGroups;
-    if (user != null && relatedUserGroups == null) {
-      validateCrowi();
-      const UserGroupRelation = crowi.model('UserGroupRelation');
-      relatedUserGroups = await UserGroupRelation.findAllUserGroupIdsRelatedToUser(user);
-    }
+    const relatedUserGroups = (user != null && userGroups == null) ? [
+      ...(await UserGroupRelation.findAllUserGroupIdsRelatedToUser(user)),
+      ...(await ExternalUserGroupRelation.findAllUserGroupIdsRelatedToUser(user)),
+    ] : userGroups;
 
 
     const queryBuilder = new this.PageQueryBuilder(baseQuery, includeEmpty);
     const queryBuilder = new this.PageQueryBuilder(baseQuery, includeEmpty);
     queryBuilder.addConditionToFilteringByViewer(user, relatedUserGroups, true);
     queryBuilder.addConditionToFilteringByViewer(user, relatedUserGroups, true);
@@ -384,12 +384,10 @@ export const getPageSchema = (crowi) => {
     // pick the longest one
     // pick the longest one
     const baseQuery = this.findOne({ path: { $in: ancestorsPaths } }).sort({ path: -1 });
     const baseQuery = this.findOne({ path: { $in: ancestorsPaths } }).sort({ path: -1 });
 
 
-    let relatedUserGroups = userGroups;
-    if (user != null && relatedUserGroups == null) {
-      validateCrowi();
-      const UserGroupRelation = crowi.model('UserGroupRelation');
-      relatedUserGroups = await UserGroupRelation.findAllUserGroupIdsRelatedToUser(user);
-    }
+    const relatedUserGroups = (user != null && userGroups == null) ? [
+      ...(await UserGroupRelation.findAllUserGroupIdsRelatedToUser(user)),
+      ...(await ExternalUserGroupRelation.findAllUserGroupIdsRelatedToUser(user)),
+    ] : userGroups;
 
 
     const queryBuilder = new this.PageQueryBuilder(baseQuery, includeEmpty);
     const queryBuilder = new this.PageQueryBuilder(baseQuery, includeEmpty);
     queryBuilder.addConditionToFilteringByViewer(user, relatedUserGroups);
     queryBuilder.addConditionToFilteringByViewer(user, relatedUserGroups);
@@ -513,11 +511,10 @@ export const getPageSchema = (crowi) => {
     const hidePagesRestrictedByGroup = crowi.configManager.getConfig('crowi', 'security:list-policy:hideRestrictedByGroup');
     const hidePagesRestrictedByGroup = crowi.configManager.getConfig('crowi', 'security:list-policy:hideRestrictedByGroup');
 
 
     // determine UserGroup condition
     // determine UserGroup condition
-    let userGroups = null;
-    if (user != null) {
-      const UserGroupRelation = crowi.model('UserGroupRelation');
-      userGroups = await UserGroupRelation.findAllUserGroupIdsRelatedToUser(user);
-    }
+    const userGroups = user != null ? [
+      ...(await UserGroupRelation.findAllUserGroupIdsRelatedToUser(user)),
+      ...(await ExternalUserGroupRelation.findAllUserGroupIdsRelatedToUser(user)),
+    ] : null;
 
 
     return builder.addConditionToFilteringByViewer(user, userGroups, showAnyoneKnowsLink, !hidePagesRestrictedByOwner, !hidePagesRestrictedByGroup);
     return builder.addConditionToFilteringByViewer(user, userGroups, showAnyoneKnowsLink, !hidePagesRestrictedByOwner, !hidePagesRestrictedByGroup);
   }
   }
@@ -531,14 +528,11 @@ export const getPageSchema = (crowi) => {
    * @param {boolean} showAnyoneKnowsLink
    * @param {boolean} showAnyoneKnowsLink
    */
    */
   async function addConditionToFilteringByViewerToEdit(builder, user) {
   async function addConditionToFilteringByViewerToEdit(builder, user) {
-    validateCrowi();
-
     // determine UserGroup condition
     // determine UserGroup condition
-    let userGroups = null;
-    if (user != null) {
-      const UserGroupRelation = crowi.model('UserGroupRelation');
-      userGroups = await UserGroupRelation.findAllUserGroupIdsRelatedToUser(user);
-    }
+    const userGroups = user != null ? [
+      ...(await UserGroupRelation.findAllUserGroupIdsRelatedToUser(user)),
+      ...(await ExternalUserGroupRelation.findAllUserGroupIdsRelatedToUser(user)),
+    ] : null;
 
 
     return builder.addConditionToFilteringByViewer(user, userGroups, false, false, false);
     return builder.addConditionToFilteringByViewer(user, userGroups, false, false, false);
   }
   }
@@ -663,7 +657,7 @@ export const getPageSchema = (crowi) => {
 
 
     await builder.query.updateMany({}, {
     await builder.query.updateMany({}, {
       grant,
       grant,
-      grantedGroup: grant === PageGrant.GRANT_USER_GROUP ? parentPage.grantedGroup : null,
+      grantedGroups: grant === PageGrant.GRANT_USER_GROUP ? parentPage.grantedGroups : null,
       grantedUsers: grant === PageGrant.GRANT_OWNER ? [user._id] : null,
       grantedUsers: grant === PageGrant.GRANT_OWNER ? [user._id] : null,
     });
     });
 
 
@@ -682,7 +676,7 @@ export const getPageSchema = (crowi) => {
         updateOne: {
         updateOne: {
           filter: { _id: page._id },
           filter: { _id: page._id },
           update: {
           update: {
-            grantedGroup: null,
+            grantedGroups: null,
             grant: this.GRANT_PUBLIC,
             grant: this.GRANT_PUBLIC,
           },
           },
         },
         },
@@ -691,14 +685,19 @@ export const getPageSchema = (crowi) => {
     await this.bulkWrite(operationsToPublicize);
     await this.bulkWrite(operationsToPublicize);
   };
   };
 
 
-  pageSchema.statics.transferPagesToGroup = async function(pages, transferToUserGroupId) {
-    const UserGroup = mongoose.model('UserGroup');
+  /**
+   * transfer pages grant to specified user group
+   * @param {Page[]} pages
+   * @param {IGrantedGroup} transferToUserGroup
+   */
+  pageSchema.statics.transferPagesToGroup = async function(pages, transferToUserGroup) {
+    const userGroupModel = transferToUserGroup.type === GroupType.userGroup ? UserGroup : ExternalUserGroup;
 
 
-    if ((await UserGroup.count({ _id: transferToUserGroupId })) === 0) {
-      throw Error('Cannot find the group to which private pages belong to. _id: ', transferToUserGroupId);
+    if ((await userGroupModel.count({ _id: transferToUserGroup.item })) === 0) {
+      throw Error('Cannot find the group to which private pages belong to. _id: ', transferToUserGroup.item);
     }
     }
 
 
-    await this.updateMany({ _id: { $in: pages.map(p => p._id) } }, { grantedGroup: transferToUserGroupId });
+    await this.updateMany({ _id: { $in: pages.map(p => p._id) } }, { grantedGroups: [transferToUserGroup] });
   };
   };
 
 
   /**
   /**

+ 23 - 2
apps/app/src/server/models/page-operation.ts

@@ -1,3 +1,4 @@
+import { GroupType } from '@growi/core';
 import { addSeconds } from 'date-fns';
 import { addSeconds } from 'date-fns';
 import mongoose, {
 import mongoose, {
   Schema, Model, Document, QueryOptions, FilterQuery,
   Schema, Model, Document, QueryOptions, FilterQuery,
@@ -58,7 +59,17 @@ const pageSchemaForResuming = new Schema<IPageForResuming>({
   status: { type: String },
   status: { type: String },
   grant: { type: Number },
   grant: { type: Number },
   grantedUsers: [{ type: ObjectId, ref: 'User' }],
   grantedUsers: [{ type: ObjectId, ref: 'User' }],
-  grantedGroup: { type: ObjectId, ref: 'UserGroup' },
+  grantedGroups: [{
+    type: {
+      type: String,
+      enum: Object.values(GroupType),
+      required: true,
+      default: 'UserGroup',
+    },
+    item: {
+      type: ObjectId, refPath: 'grantedGroups.type', required: true,
+    },
+  }],
   creator: { type: ObjectId, ref: 'User' },
   creator: { type: ObjectId, ref: 'User' },
   lastUpdateUser: { type: ObjectId, ref: 'User' },
   lastUpdateUser: { type: ObjectId, ref: 'User' },
 });
 });
@@ -72,7 +83,17 @@ const optionsSchemaForResuming = new Schema<IOptionsForResuming>({
   updateMetadata: { type: Boolean },
   updateMetadata: { type: Boolean },
   prevDescendantCount: { type: Number },
   prevDescendantCount: { type: Number },
   grant: { type: Number },
   grant: { type: Number },
-  grantUserGroupId: { type: ObjectId, ref: 'UserGroup' },
+  grantUserGroupIds: [{
+    type: {
+      type: String,
+      enum: Object.values(GroupType),
+      required: true,
+      default: 'UserGroup',
+    },
+    item: {
+      type: ObjectId, refPath: 'grantedGroups.type', required: true,
+    },
+  }],
   format: { type: String },
   format: { type: String },
   isSyncRevisionToHackmd: { type: Boolean },
   isSyncRevisionToHackmd: { type: Boolean },
   overwriteScopesOfDescendants: { type: Boolean },
   overwriteScopesOfDescendants: { type: Boolean },

+ 51 - 15
apps/app/src/server/models/page.ts

@@ -3,7 +3,11 @@
 import assert from 'assert';
 import assert from 'assert';
 import nodePath from 'path';
 import nodePath from 'path';
 
 
-import type { IPage, HasObjectId } from '@growi/core';
+import {
+  type IPage,
+  type IGrantedGroup,
+  GroupType, type HasObjectId,
+} from '@growi/core';
 import { isPopulated } from '@growi/core/dist/interfaces';
 import { isPopulated } from '@growi/core/dist/interfaces';
 import { isTopPage, hasSlash, collectAncestorPaths } from '@growi/core/dist/utils/page-path-utils';
 import { isTopPage, hasSlash, collectAncestorPaths } from '@growi/core/dist/utils/page-path-utils';
 import { addTrailingSlash, normalizePath } from '@growi/core/dist/utils/path-utils';
 import { addTrailingSlash, normalizePath } from '@growi/core/dist/utils/path-utils';
@@ -14,12 +18,17 @@ import mongoose, {
 import mongoosePaginate from 'mongoose-paginate-v2';
 import mongoosePaginate from 'mongoose-paginate-v2';
 import uniqueValidator from 'mongoose-unique-validator';
 import uniqueValidator from 'mongoose-unique-validator';
 
 
+import { ExternalUserGroupDocument } from '~/features/external-user-group/server/models/external-user-group';
+import ExternalUserGroupRelation from '~/features/external-user-group/server/models/external-user-group-relation';
+import { PopulatedGrantedGroup } from '~/interfaces/page-grant';
 import type { ObjectIdLike } from '~/server/interfaces/mongoose-utils';
 import type { ObjectIdLike } from '~/server/interfaces/mongoose-utils';
 
 
 import loggerFactory from '../../utils/logger';
 import loggerFactory from '../../utils/logger';
 import { getOrCreateModel } from '../util/mongoose-utils';
 import { getOrCreateModel } from '../util/mongoose-utils';
 
 
 import { getPageSchema, extractToAncestorsPaths, populateDataToShowRevision } from './obsolete-page';
 import { getPageSchema, extractToAncestorsPaths, populateDataToShowRevision } from './obsolete-page';
+import { UserGroupDocument } from './user-group';
+import UserGroupRelation from './user-group-relation';
 
 
 const logger = loggerFactory('growi:models:page');
 const logger = loggerFactory('growi:models:page');
 /*
 /*
@@ -57,6 +66,7 @@ export type CreateMethod = (path: string, body: string, user, options: PageCreat
 export interface PageModel extends Model<PageDocument> {
 export interface PageModel extends Model<PageDocument> {
   [x: string]: any; // for obsolete static methods
   [x: string]: any; // for obsolete static methods
   findByIdsAndViewer(pageIds: ObjectIdLike[], user, userGroups?, includeEmpty?: boolean, includeAnyoneWithTheLink?: boolean): Promise<PageDocument[]>
   findByIdsAndViewer(pageIds: ObjectIdLike[], user, userGroups?, includeEmpty?: boolean, includeAnyoneWithTheLink?: boolean): Promise<PageDocument[]>
+  findByPath(path: string, includeEmpty?: boolean): Promise<PageDocument | null>
   findByPathAndViewer(path: string | null, user, userGroups?, useFindOne?: true, includeEmpty?: boolean): Promise<PageDocument & HasObjectId | null>
   findByPathAndViewer(path: string | null, user, userGroups?, useFindOne?: true, includeEmpty?: boolean): Promise<PageDocument & HasObjectId | null>
   findByPathAndViewer(path: string | null, user, userGroups?, useFindOne?: false, includeEmpty?: boolean): Promise<(PageDocument & HasObjectId)[]>
   findByPathAndViewer(path: string | null, user, userGroups?, useFindOne?: false, includeEmpty?: boolean): Promise<(PageDocument & HasObjectId)[]>
   countByPathAndViewer(path: string | null, user, userGroups?, includeEmpty?:boolean): Promise<number>
   countByPathAndViewer(path: string | null, user, userGroups?, includeEmpty?:boolean): Promise<number>
@@ -65,6 +75,7 @@ export interface PageModel extends Model<PageDocument> {
   generateGrantCondition(
   generateGrantCondition(
     user, userGroups, includeAnyoneWithTheLink?: boolean, showPagesRestrictedByOwner?: boolean, showPagesRestrictedByGroup?: boolean,
     user, userGroups, includeAnyoneWithTheLink?: boolean, showPagesRestrictedByOwner?: boolean, showPagesRestrictedByGroup?: boolean,
   ): { $or: any[] }
   ): { $or: any[] }
+  removeLeafEmptyPagesRecursively(pageId: ObjectIdLike): Promise<void>
 
 
   PageQueryBuilder: typeof PageQueryBuilder
   PageQueryBuilder: typeof PageQueryBuilder
 
 
@@ -95,7 +106,28 @@ const schema = new Schema<PageDocument, PageModel>({
   status: { type: String, default: STATUS_PUBLISHED, index: true },
   status: { type: String, default: STATUS_PUBLISHED, index: true },
   grant: { type: Number, default: GRANT_PUBLIC, index: true },
   grant: { type: Number, default: GRANT_PUBLIC, index: true },
   grantedUsers: [{ type: ObjectId, ref: 'User' }],
   grantedUsers: [{ type: ObjectId, ref: 'User' }],
-  grantedGroup: { type: ObjectId, ref: 'UserGroup', index: true },
+  grantedGroups: {
+    type: [{
+      type: {
+        type: String,
+        enum: Object.values(GroupType),
+        required: true,
+        default: 'UserGroup',
+      },
+      item: {
+        type: ObjectId,
+        refPath: 'grantedGroups.type',
+        required: true,
+        index: true,
+      },
+    }],
+    validate: [function(arr) {
+      if (arr == null) return true;
+      const uniqueItemValues = new Set(arr.map(e => e.item));
+      return arr.length === uniqueItemValues.size;
+    }, 'grantedGroups contains non unique item'],
+    default: [],
+  },
   creator: { type: ObjectId, ref: 'User', index: true },
   creator: { type: ObjectId, ref: 'User', index: true },
   lastUpdateUser: { type: ObjectId, ref: 'User' },
   lastUpdateUser: { type: ObjectId, ref: 'User' },
   liker: [{ type: ObjectId, ref: 'User' }],
   liker: [{ type: ObjectId, ref: 'User' }],
@@ -304,11 +336,10 @@ export class PageQueryBuilder {
 
 
   async addConditionForParentNormalization(user): Promise<PageQueryBuilder> {
   async addConditionForParentNormalization(user): Promise<PageQueryBuilder> {
     // determine UserGroup condition
     // determine UserGroup condition
-    let userGroups;
-    if (user != null) {
-      const UserGroupRelation = mongoose.model('UserGroupRelation') as any; // TODO: Typescriptize model
-      userGroups = await UserGroupRelation.findAllUserGroupIdsRelatedToUser(user);
-    }
+    const userGroups = user != null ? [
+      ...(await UserGroupRelation.findAllUserGroupIdsRelatedToUser(user)),
+      ...(await ExternalUserGroupRelation.findAllUserGroupIdsRelatedToUser(user)),
+    ] : null;
 
 
     const grantConditions: any[] = [
     const grantConditions: any[] = [
       { grant: null },
       { grant: null },
@@ -323,7 +354,10 @@ export class PageQueryBuilder {
 
 
     if (userGroups != null && userGroups.length > 0) {
     if (userGroups != null && userGroups.length > 0) {
       grantConditions.push(
       grantConditions.push(
-        { grant: GRANT_USER_GROUP, grantedGroup: { $in: userGroups } },
+        {
+          grant: GRANT_USER_GROUP,
+          grantedGroups: { $elemMatch: { item: { $in: userGroups } } },
+        },
       );
       );
     }
     }
 
 
@@ -353,11 +387,10 @@ export class PageQueryBuilder {
 
 
   // add viewer condition to PageQueryBuilder instance
   // add viewer condition to PageQueryBuilder instance
   async addViewerCondition(user, userGroups = null, includeAnyoneWithTheLink = false): Promise<PageQueryBuilder> {
   async addViewerCondition(user, userGroups = null, includeAnyoneWithTheLink = false): Promise<PageQueryBuilder> {
-    let relatedUserGroups = userGroups;
-    if (user != null && relatedUserGroups == null) {
-      const UserGroupRelation: any = mongoose.model('UserGroupRelation');
-      relatedUserGroups = await UserGroupRelation.findAllUserGroupIdsRelatedToUser(user);
-    }
+    const relatedUserGroups = (user != null && userGroups == null) ? [
+      ...(await UserGroupRelation.findAllUserGroupIdsRelatedToUser(user)),
+      ...(await ExternalUserGroupRelation.findAllUserGroupIdsRelatedToUser(user)),
+    ] : userGroups;
 
 
     this.addConditionToFilteringByViewer(user, relatedUserGroups, includeAnyoneWithTheLink);
     this.addConditionToFilteringByViewer(user, relatedUserGroups, includeAnyoneWithTheLink);
     return this;
     return this;
@@ -935,7 +968,10 @@ export function generateGrantCondition(
   }
   }
   else if (userGroups != null && userGroups.length > 0) {
   else if (userGroups != null && userGroups.length > 0) {
     grantConditions.push(
     grantConditions.push(
-      { grant: GRANT_USER_GROUP, grantedGroup: { $in: userGroups } },
+      {
+        grant: GRANT_USER_GROUP,
+        grantedGroups: { $elemMatch: { item: { $in: userGroups } } },
+      },
     );
     );
   }
   }
 
 
@@ -1015,7 +1051,7 @@ schema.methods.calculateAndUpdateLatestRevisionBodyLength = async function(this:
 
 
 export type PageCreateOptions = {
 export type PageCreateOptions = {
   format?: string
   format?: string
-  grantUserGroupId?: ObjectIdLike
+  grantUserGroupIds?: IGrantedGroup[],
   grant?: number
   grant?: number
   overwriteScopesOfDescendants?: boolean
   overwriteScopesOfDescendants?: boolean
 }
 }

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