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

Merge branch 'master' into dev/7.0.x

Shun Miyazawa 2 лет назад
Родитель
Сommit
6da42317e8
100 измененных файлов с 4778 добавлено и 542 удалено
  1. 8 1
      CHANGELOG.md
  2. 1 1
      apps/app/.env.test
  3. 4 2
      apps/app/_obsolete/src/components/PageEditorByHackmd.tsx
  4. 1 1
      apps/app/docker/README.md
  5. 13 3
      apps/app/package.json
  6. 59 1
      apps/app/public/static/locales/en_US/admin.json
  7. 60 1
      apps/app/public/static/locales/ja_JP/admin.json
  8. 60 1
      apps/app/public/static/locales/zh_CN/admin.json
  9. 1 1
      apps/app/resource/search/mappings-es7.json
  10. 1 1
      apps/app/resource/search/mappings-es8-for-ci.json
  11. 1 1
      apps/app/resource/search/mappings-es8.json
  12. 78 0
      apps/app/src/client/services/AdminAppContainer.js
  13. 9 2
      apps/app/src/client/services/side-effects/drawio-modal-launcher-for-view.ts
  14. 9 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 2
      apps/app/src/components/Admin/ElasticsearchManagement/RebuildIndexControls.jsx
  20. 2 2
      apps/app/src/components/Admin/Security/LdapSecuritySettingContents.jsx
  21. 1 1
      apps/app/src/components/Admin/Security/OidcSecuritySettingContents.jsx
  22. 23 11
      apps/app/src/components/Admin/UserGroup/UserGroupForm.tsx
  23. 10 1
      apps/app/src/components/Admin/UserGroup/UserGroupModal.tsx
  24. 6 0
      apps/app/src/components/Admin/UserGroup/UserGroupPage.tsx
  25. 42 24
      apps/app/src/components/Admin/UserGroup/UserGroupTable.tsx
  26. 63 38
      apps/app/src/components/Admin/UserGroupDetail/UserGroupDetailPage.tsx
  27. 35 36
      apps/app/src/components/Admin/UserGroupDetail/UserGroupUserTable.tsx
  28. 50 0
      apps/app/src/components/Admin/UserGroupDetail/use-user-group-resource.ts
  29. 4 4
      apps/app/src/components/Navbar/GrowiContextualSubNavigation.tsx
  30. 6 4
      apps/app/src/components/Navbar/PageEditorModeManager.tsx
  31. 7 6
      apps/app/src/components/Navbar/hooks.tsx
  32. 66 32
      apps/app/src/components/PageAlert/FixPageGrantAlert.tsx
  33. 10 1
      apps/app/src/components/PageAlert/PageGrantAlert.tsx
  34. 5 2
      apps/app/src/components/PageEditor/PageEditor.tsx
  35. 10 5
      apps/app/src/components/ReactMarkdownComponents/RichAttachment.tsx
  36. 3 4
      apps/app/src/components/SavePageControls.tsx
  37. 56 44
      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. 10 7
      apps/app/src/components/Sidebar/PageCreateButton/PageCreateButton.tsx
  41. 2 1
      apps/app/src/components/TreeItem/NewPageInput.tsx
  42. 181 0
      apps/app/src/features/external-user-group/client/components/ExternalUserGroup/ExternalUserGroupManagement.tsx
  43. 21 0
      apps/app/src/features/external-user-group/client/components/ExternalUserGroup/KeycloakGroupManagement.tsx
  44. 241 0
      apps/app/src/features/external-user-group/client/components/ExternalUserGroup/KeycloakGroupSyncSettingsForm.tsx
  45. 67 0
      apps/app/src/features/external-user-group/client/components/ExternalUserGroup/LdapGroupManagement.tsx
  46. 247 0
      apps/app/src/features/external-user-group/client/components/ExternalUserGroup/LdapGroupSyncSettingsForm.tsx
  47. 171 0
      apps/app/src/features/external-user-group/client/components/ExternalUserGroup/SyncExecution.tsx
  48. 106 0
      apps/app/src/features/external-user-group/client/stores/external-user-group.ts
  49. 62 0
      apps/app/src/features/external-user-group/interfaces/external-user-group.ts
  50. 126 0
      apps/app/src/features/external-user-group/server/models/external-user-group-relation.integ.ts
  51. 54 0
      apps/app/src/features/external-user-group/server/models/external-user-group-relation.ts
  52. 73 0
      apps/app/src/features/external-user-group/server/models/external-user-group.integ.ts
  53. 64 0
      apps/app/src/features/external-user-group/server/models/external-user-group.ts
  54. 55 0
      apps/app/src/features/external-user-group/server/routes/apiv3/external-user-group-relation.ts
  55. 393 0
      apps/app/src/features/external-user-group/server/routes/apiv3/external-user-group.ts
  56. 224 0
      apps/app/src/features/external-user-group/server/service/external-user-group-sync.ts
  57. 210 0
      apps/app/src/features/external-user-group/server/service/keycloak-user-group-sync.integ.ts
  58. 168 0
      apps/app/src/features/external-user-group/server/service/keycloak-user-group-sync.ts
  59. 156 0
      apps/app/src/features/external-user-group/server/service/ldap-user-group-sync.ts
  60. 1 0
      apps/app/src/features/questionnaire/interfaces/growi-info.ts
  61. 8 4
      apps/app/src/interfaces/crowi-request.ts
  62. 9 2
      apps/app/src/interfaces/page-grant.ts
  63. 4 3
      apps/app/src/interfaces/page-operation.ts
  64. 5 4
      apps/app/src/interfaces/page.ts
  65. 14 0
      apps/app/src/interfaces/res/admin/app-settings.ts
  66. 12 11
      apps/app/src/interfaces/user-group-response.ts
  67. 23 0
      apps/app/src/interfaces/websocket.ts
  68. 1 1
      apps/app/src/migrations/20200402160380-remove-deleteduser-from-relationgroup.js
  69. 2 3
      apps/app/src/migrations/20220613064207-add-attachment-type-to-existing-attachments.js
  70. 160 0
      apps/app/src/migrations/20230723061824-granted-group-to-array-of-objects.js
  71. 12 9
      apps/app/src/pages/[[...path]].page.tsx
  72. 1 1
      apps/app/src/pages/_document.page.tsx
  73. 1 1
      apps/app/src/pages/_private-legacy-pages.page.tsx
  74. 1 1
      apps/app/src/pages/_search.page.tsx
  75. 4 2
      apps/app/src/pages/admin/user-group-detail/[userGroupId].page.tsx
  76. 1 1
      apps/app/src/pages/invited.page.tsx
  77. 1 1
      apps/app/src/pages/maintenance.page.tsx
  78. 3 4
      apps/app/src/pages/me/[[...path]].page.tsx
  79. 13 7
      apps/app/src/pages/share/[[...path]].page.tsx
  80. 1 1
      apps/app/src/pages/tags.page.tsx
  81. 1 1
      apps/app/src/pages/trash.page.tsx
  82. 1 1
      apps/app/src/pages/utils/commons.ts
  83. 101 81
      apps/app/src/server/crowi/index.js
  84. 24 0
      apps/app/src/server/interfaces/attachment.ts
  85. 0 54
      apps/app/src/server/middlewares/certify-shared-file.js
  86. 145 0
      apps/app/src/server/middlewares/certify-shared-page-attachment/certify-shared-page-attachment.spec.ts
  87. 49 0
      apps/app/src/server/middlewares/certify-shared-page-attachment/certify-shared-page-attachment.ts
  88. 1 0
      apps/app/src/server/middlewares/certify-shared-page-attachment/index.ts
  89. 4 0
      apps/app/src/server/middlewares/certify-shared-page-attachment/interfaces.ts
  90. 28 0
      apps/app/src/server/middlewares/certify-shared-page-attachment/retrieve-valid-share-link.ts
  91. 25 0
      apps/app/src/server/middlewares/certify-shared-page-attachment/validate-attachment.ts
  92. 1 0
      apps/app/src/server/middlewares/certify-shared-page-attachment/validate-referer/index.ts
  93. 59 0
      apps/app/src/server/middlewares/certify-shared-page-attachment/validate-referer/retrieve-site-url.spec.ts
  94. 22 0
      apps/app/src/server/middlewares/certify-shared-page-attachment/validate-referer/retrieve-site-url.ts
  95. 131 0
      apps/app/src/server/middlewares/certify-shared-page-attachment/validate-referer/validate-referer.spec.ts
  96. 69 0
      apps/app/src/server/middlewares/certify-shared-page-attachment/validate-referer/validate-referer.ts
  97. 1 1
      apps/app/src/server/middlewares/certify-shared-page.js
  98. 0 103
      apps/app/src/server/models/attachment.js
  99. 115 0
      apps/app/src/server/models/attachment.ts
  100. 6 0
      apps/app/src/server/models/config.ts

+ 8 - 1
CHANGELOG.md

@@ -1,9 +1,16 @@
 # Changelog
 
-## [Unreleased](https://github.com/weseek/growi/compare/v6.2.2...HEAD)
+## [Unreleased](https://github.com/weseek/growi/compare/v6.2.3...HEAD)
 
 *Please do not manually update this file. We've automated the process.*
 
+## [v6.2.3](https://github.com/weseek/growi/compare/v6.2.2...v6.2.3) - 2023-11-13
+
+### 🚀 Improvement
+
+- imprv: Certify shared page attachment middleware (#8211) @yuki-takei
+- imprv: Printing styles 2 (#8203) @yuki-takei
+
 ## [v6.2.2](https://github.com/weseek/growi/compare/v6.2.1...v6.2.2) - 2023-10-30
 
 ### 🚀 Improvement

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

@@ -5,5 +5,5 @@
 ## > 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_MONGOMS_VERSION="6.0.6"
+VITE_MONGOMS_VERSION="6.0.9"
 # VITE_MONGOMS_DEBUG=1

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

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

+ 1 - 1
apps/app/docker/README.md

@@ -11,7 +11,7 @@ Supported tags and respective Dockerfile links
 ------------------------------------------------
 
 * [`7.0.0`, `7.0`, `7`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v7.0.0/apps/app/docker/Dockerfile)
-* [`6.2.2`, `6.2`, `6` (Dockerfile)](https://github.com/weseek/growi/blob/v6.2.2/apps/app/docker/Dockerfile)
+* [`6.2.3`, `6.2`, `6` (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)
 
 

+ 13 - 3
apps/app/package.json

@@ -40,6 +40,7 @@
     "reg:run": "reg-suit run",
     "vitest:run": "vitest run config 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": "",
     "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",
@@ -50,14 +51,18 @@
     "version": "yarn version --no-git-tag-version --preid=RC"
   },
   "// 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",
     "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"
   },
   "dependencies": {
     "@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",
     "@elastic/elasticsearch7": "npm:@elastic/elasticsearch@^7.17.0",
     "@elastic/elasticsearch8": "npm:@elastic/elasticsearch@^8.7.0",
@@ -72,9 +77,11 @@
     "@growi/remark-growi-directive": "link:../../packages/remark-growi-directive",
     "@growi/remark-lsx": "link:../../packages/remark-lsx",
     "@growi/slack": "link:../../packages/slack",
+    "@keycloak/keycloak-admin-client": "^18.0.0",
     "@slack/web-api": "^6.2.4",
     "@slack/webhook": "^6.0.0",
     "@types/jest": "^29.5.2",
+    "@types/ldapjs": "^2.2.5",
     "JSONStream": "^1.3.5",
     "archiver": "^5.3.0",
     "array.prototype.flatmap": "^1.2.2",
@@ -120,6 +127,7 @@
     "i18next-localstorage-backend": "^4.0.0",
     "is-absolute-url": "^4.0.1",
     "is-iso-date": "^0.0.1",
+    "ldapjs": "^3.0.2",
     "lucene-query-parser": "^1.2.0",
     "markdown-table": "^1.1.1",
     "md5": "^2.2.1",
@@ -220,6 +228,7 @@
     "@types/react-scroll": "^1.8.4",
     "@types/throttle-debounce": "^5.0.1",
     "@types/url-join": "^4.0.2",
+    "@vitest/coverage-v8": "^0.34.6",
     "autoprefixer": "^9.0.0",
     "babel-loader": "^8.2.5",
     "bootstrap": "^5.3.1",
@@ -238,7 +247,8 @@
     "jest-date-mock": "^1.0.8",
     "jest-localstorage-mock": "^2.4.14",
     "load-css-file": "^1.0.0",
-    "mongodb-memory-server": "^8.12.2",
+    "material-icons": "^1.11.3",
+    "mongodb-memory-server-core": "^9.1.1",
     "morgan": "^1.10.0",
     "null-loader": "^4.0.1",
     "plantuml-encoder": "^1.2.5",

+ 59 - 1
apps/app/public/static/locales/en_US/admin.json

@@ -9,6 +9,7 @@
   "specified_users": "Specified users",
   "only_me": "Only me",
   "only_inside_the_group": "Only inside the group",
+  "optional": "Optional",
   "security_settings": {
     "security_settings": "Security Settings",
     "scope_of_page_disclosure": "Scope of page disclosure",
@@ -94,7 +95,6 @@
     "enable_link_sharing": "Enable link sharing",
     "all_share_links": "All share links",
     "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_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",
@@ -381,6 +381,13 @@
     "aws_label": "AWS(S3)",
     "local_label": "Local",
     "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.",
     "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.",
@@ -1053,6 +1060,57 @@
     "error_generate_growi_archive": "Failed to generate GROWI archive file",
     "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": {
     "grant_user_admin": "Succeeded to grant {{username}} admin",
     "revoke_user_admin": "Succeeded to revoke {{username}} admin",

+ 60 - 1
apps/app/public/static/locales/ja_JP/admin.json

@@ -10,6 +10,7 @@
   "Created": "作成日",
   "Edit": "編集",
   "Description": "説明",
+  "Execute": "実行",
   "last_login": "最終ログイン",
   "wiki_management_homepage": "Wiki管理トップ",
   "public": "公開",
@@ -17,6 +18,7 @@
   "specified_users": "特定ユーザーのみ",
   "only_me": "自分のみ",
   "only_inside_the_group": "特定グループのみ",
+  "optional": "オプション",
   "security_settings": {
     "security_settings": "セキュリティ設定",
     "scope_of_page_disclosure": "ページの公開範囲",
@@ -102,7 +104,6 @@
     "enable_link_sharing": "リンクのシェアを許可",
     "all_share_links": "全てのシェアリンク",
     "configuration": "設定",
-    "optional": "オプション",
     "Treat username matching as identical": "新規ログイン時、<code>username</code> が一致したローカルアカウントが存在した場合は自動的に紐付ける",
     "Treat username matching as identical_warn": "警告: <code>username</code> の一致を以て同一ユーザーであるとみなすので、セキュリティに注意してください",
     "Treat email matching as identical": "新規ログイン時、<code>email</code> が一致したローカルアカウントが存在した場合は自動的に紐付ける",
@@ -388,6 +389,13 @@
     "aws_label": "AWS(S3)",
     "local_label": "Local",
     "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> により固定されています。",
     "file_upload": "ファイルをアップロードするための設定を行います。ファイルアップロードの設定を完了させると、ファイルアップロード機能、プロフィール写真機能などが有効になります。",
     "test_connection": "接続テスト",
@@ -1062,6 +1070,57 @@
     "error_generate_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": {
     "grant_user_admin": "{{username}}を管理者に設定しました",
     "revoke_user_admin": "{{username}}を管理者から外しました",

+ 60 - 1
apps/app/public/static/locales/zh_CN/admin.json

@@ -10,6 +10,7 @@
   "Page": "页面",
   "Edit": "编辑",
   "Description": "描述",
+  "Execute": "执行",
   "last_login": "上次登录",
   "wiki_management_homepage": "Wiki管理首页",
   "public": "公共",
@@ -17,6 +18,7 @@
   "specified_users": "仅指定用户",
   "only_me": "只有我",
   "only_inside_the_group": "仅组内",
+  "optional": "可选的",
   "security_settings": {
     "security_settings": "安全设置",
     "scope_of_page_disclosure": "页面公开范围",
@@ -102,7 +104,6 @@
     "enable_link_sharing": "启用链接共享",
     "all_share_links": "所有共享链接",
 		"configuration": " 配置",
-		"optional": "可选的",
 		"Treat username matching as identical": "Automatically bind external accounts newly logged in to local accounts when <code>username</code> match",
 		"Treat username matching as identical_warn": "WARNING: Be aware of security because the system treats the same user as a match of <code>username</code>.",
 		"Treat email matching as identical": "Automatically bind external accounts newly logged in to local accounts when <code>email</code> match",
@@ -388,6 +389,13 @@
     "aws_label": "AWS(S3)",
     "local_label": "Local",
     "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>.",
     "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": "测试邮件服务器连接",
@@ -1061,6 +1069,57 @@
     "error_generate_growi_archive": "Failed to generate GROWI archive file",
     "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": {
     "grant_user_admin": "Succeeded to grant {{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": {
         "type": "keyword"
       },
-      "granted_group": {
+      "granted_groups": {
         "type": "keyword"
       },
       "created_at": {

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

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

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

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

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

@@ -60,6 +60,19 @@ export default class AdminAppContainer extends Container {
       s3SecretAccessKey: '',
       s3ReferenceFileWithRelayMode: false,
 
+      azureReferenceFileWithRelayMode: false,
+      azureUseOnlyEnvVars: false,
+      azureTenantId: '',
+      azureClientId: '',
+      azureClientSecret: '',
+      azureStorageAccountName: '',
+      azureStorageContainerName: '',
+      envAzureTenantId: '',
+      envAzureClientId: '',
+      envAzureClientSecret: '',
+      envAzureStorageAccountName: '',
+      envAzureStorageContainerName: '',
+
       isEnabledPlugins: true,
 
       isMaintenanceMode: false,
@@ -120,6 +133,20 @@ export default class AdminAppContainer extends Container {
       envGcsApiKeyJsonPath: appSettingsParams.envGcsApiKeyJsonPath,
       envGcsBucket: appSettingsParams.envGcsBucket,
       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,
       isMaintenanceMode: appSettingsParams.isMaintenanceMode,
     });
@@ -316,6 +343,48 @@ export default class AdminAppContainer extends Container {
     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
    * @memberOf AdminAppContainer
@@ -430,6 +499,15 @@ export default class AdminAppContainer extends Container {
       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 { responseParams } = response.data;
     return this.setState(responseParams);

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

@@ -43,12 +43,19 @@ export const useDrawioModalLauncherForView = (opts?: {
     const currentMarkdown = currentPage.revision.body;
     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 = {
       isSlackEnabled: false,
       slackChannels: '',
       grant: currentPage.grant,
-      grantUserGroupId: currentPage.grantedGroup?._id,
-      grantUserGroupName: currentPage.grantedGroup?.name,
+      grantUserGroupIds,
+      // pageTags: tagsInfo.tags,
     };
 
     try {

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

@@ -42,12 +42,19 @@ export const useHandsontableModalLauncherForView = (opts?: {
     const currentMarkdown = currentPage.revision.body;
     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 = {
       isSlackEnabled: false,
       slackChannels: '',
       grant: currentPage.grant,
-      grantUserGroupId: currentPage.grantedGroup?._id,
-      grantUserGroupName: currentPage.grantedGroup?.name,
+      grantUserGroupIds,
+      // pageTags: tagsInfo.tags,
     };
 
     try {

+ 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 type { AwsSettingMoleculeProps } from './AwsSetting';
+import { AzureSettingMolecule } from './AzureSetting';
+import type { AzureSettingMoleculeProps } from './AzureSetting';
 import { GcsSettingMolecule } 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 = {
   fileUploadType: string
   isFixedFileUploadByEnvVar: boolean
   envFileUploadType?: string
   onChangeFileUploadType: (e: ChangeEvent, type: string) => void
-} & AwsSettingMoleculeProps & GcsSettingMoleculeProps;
+} & AwsSettingMoleculeProps & GcsSettingMoleculeProps & AzureSettingMoleculeProps;
 
 export const FileUploadSettingMolecule = React.memo((props: FileUploadSettingMoleculeProps): JSX.Element => {
   const { t } = useTranslation(['admin', 'commons']);
@@ -102,6 +105,28 @@ export const FileUploadSettingMolecule = React.memo((props: FileUploadSettingMol
           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,
     gcsApiKeyJsonPath, envGcsApiKeyJsonPath, gcsBucket,
     envGcsBucket, gcsUploadNamespace, envGcsUploadNamespace,
+    azureReferenceFileWithRelayMode, azureUseOnlyEnvVars,
+    azureTenantId, azureClientId, azureClientSecret,
+    azureStorageAccountName, azureStorageContainerName,
+    envAzureTenantId, envAzureClientId, envAzureClientSecret,
+    envAzureStorageAccountName, envAzureStorageContainerName,
   } = adminAppContainer.state;
 
   const submitHandler = useCallback(async() => {
@@ -182,6 +212,31 @@ const FileUploadSetting = (props: FileUploadSettingProps): JSX.Element => {
     adminAppContainer.changeGcsUploadNamespace(val);
   }, [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 (
     <>
       <FileUploadSettingMolecule
@@ -213,6 +268,24 @@ const FileUploadSetting = (props: FileUploadSettingProps): JSX.Element => {
         onChangeGcsApiKeyJsonPath={onChangeGcsApiKeyJsonPathHandler}
         onChangeGcsBucket={onChangeGcsBucketHandler}
         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} />
     </>

+ 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 - 2
apps/app/src/components/Admin/ElasticsearchManagement/RebuildIndexControls.jsx

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

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

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

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

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

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

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

@@ -14,6 +14,7 @@ type Props = {
   onClickSubmit?: (userGroupData: Partial<IUserGroupHasId>) => Promise<IUserGroupHasId | void>
   isShow?: boolean
   onHide?: () => Promise<void> | void
+  isExternalGroup?: boolean
 };
 
 export const UserGroupModal: FC<Props> = (props: Props) => {
@@ -21,7 +22,7 @@ export const UserGroupModal: FC<Props> = (props: Props) => {
   const { t } = useTranslation('admin');
 
   const {
-    userGroup, buttonLabel, onClickSubmit, isShow, onHide,
+    userGroup, buttonLabel, onClickSubmit, isShow, onHide, isExternalGroup = false,
   } = props;
 
   /*
@@ -86,6 +87,7 @@ export const UserGroupModal: FC<Props> = (props: Props) => {
               value={currentName}
               onChange={onChangeNameHandler}
               required
+              disabled={isExternalGroup}
             />
           </div>
 
@@ -94,6 +96,13 @@ export const UserGroupModal: FC<Props> = (props: Props) => {
               {t('Description')}
             </label>
             <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>
 
           {/* 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 { toastSuccess, toastError } from '~/client/util/toastr';
+import { ExternalGroupManagement } from '~/features/external-user-group/client/components/ExternalUserGroup/ExternalUserGroupManagement';
 import { useIsAclEnabled } from '~/stores/context';
 import { useSWRxUserGroupList, useSWRxChildUserGroupList, useSWRxUserGroupRelationList } from '~/stores/user-group';
 
+
 const UserGroupDeleteModal = dynamic(() => import('./UserGroupDeleteModal').then(mod => mod.UserGroupDeleteModal), { ssr: false });
 const UserGroupModal = dynamic(() => import('./UserGroupModal').then(mod => mod.UserGroupModal), { ssr: false });
 const UserGroupTable = dynamic(() => import('./UserGroupTable').then(mod => mod.UserGroupTable), { ssr: false });
@@ -146,6 +148,7 @@ export const UserGroupPage: FC = () => {
 
   return (
     <div data-testid="admin-user-groups">
+      <h2 className="border-bottom">{t('admin:user_group_management.user_group_management')}</h2>
       {
         isAclEnabled ? (
           <div className="mb-3">
@@ -190,6 +193,9 @@ export const UserGroupPage: FC = () => {
         isShow={isDeleteModalShown}
         onHide={hideDeleteModal}
       />
+      <div className="mt-5">
+        <ExternalGroupManagement />
+      </div>
     </div>
   );
 };

+ 42 - 24
apps/app/src/components/Admin/UserGroup/UserGroupTable.tsx

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

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

@@ -13,22 +13,25 @@ import {
   apiv3Get, apiv3Put, apiv3Delete, apiv3Post,
 } from '~/client/util/apiv3-client';
 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 Xss from '~/services/xss';
 import { useIsAclEnabled } from '~/stores/context';
 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 {
+  useAncestorUserGroups,
+  useChildUserGroupList, useUserGroup, useUserGroupRelationList, useUserGroupRelations,
+} from './use-user-group-resource';
+
 import styles from './UserGroupDetailPage.module.scss';
 
 const logger = loggerFactory('growi:services:AdminCustomizeContainer');
 
 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 });
 
@@ -42,15 +45,16 @@ const UpdateParentConfirmModal = dynamic(() => import('./UpdateParentConfirmModa
 
 type Props = {
   userGroupId: string,
+  isExternalGroup: boolean,
 }
 
 const UserGroupDetailPage = (props: Props): JSX.Element => {
   const { t } = useTranslation('admin');
   const router = useRouter();
   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 [isAlsoMailSearched, setAlsoMailSearched] = useState<boolean>(false);
@@ -76,26 +80,36 @@ const UserGroupDetailPage = (props: Props): JSX.Element => {
    */
   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 grandChildUserGroups = childUserGroupsList != null ? childUserGroupsList.grandChildUserGroups : [];
   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 { 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 { 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
    */
@@ -113,19 +127,26 @@ const UserGroupDetailPage = (props: Props): JSX.Element => {
 
   const updateUserGroup = useCallback(async(userGroup: IUserGroupHasId, update: Partial<IUserGroupHasId>, forceUpdateParents: boolean) => {
     const parentId = typeof update.parent === 'string' ? update.parent : update.parent?._id;
-    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
     mutateChildUserGroups();
     mutateAncestorUserGroups();
     mutateSelectableChildUserGroups();
     mutateSelectableParentUserGroups();
-  }, [mutateAncestorUserGroups, mutateChildUserGroups, mutateSelectableChildUserGroups, mutateSelectableParentUserGroups]);
+  }, [mutateAncestorUserGroups, mutateChildUserGroups, mutateSelectableChildUserGroups, mutateSelectableParentUserGroups, isExternalGroup]);
 
   const onSubmitUpdateGroup = useCallback(
     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) => {
     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' }));
 
-      // mutate
-      mutateChildUserGroups();
-
       hideUpdateModal();
     }
     catch (err) {
       toastError(err);
     }
-  }, [t, mutateChildUserGroups, hideUpdateModal]);
+  }, [t, updateChild, hideUpdateModal]);
 
   const onClickAddExistingUserGroupButtonHandler = useCallback(async(selectedChild: IUserGroupHasId): Promise<void> => {
     // show confirm modal before submiting
@@ -283,8 +297,9 @@ const UserGroupDetailPage = (props: Props): JSX.Element => {
   }, [setSelectedUserGroup, setDeleteModalShown]);
 
   const deleteChildUserGroupById = useCallback(async(deleteGroupId: string, actionName: string, transferToUserGroupId: string) => {
+    const url = isExternalGroup ? `/external-user-groups/${deleteGroupId}` : `/user-groups/${deleteGroupId}`;
     try {
-      const res = await apiv3Delete(`/user-groups/${deleteGroupId}`, {
+      const res = await apiv3Delete(url, {
         actionName,
         transferToUserGroupId,
       });
@@ -300,7 +315,7 @@ const UserGroupDetailPage = (props: Props): JSX.Element => {
     catch (err) {
       toastError(new Error('Unable to delete the groups'));
     }
-  }, [mutateChildUserGroups, setSelectedUserGroup, setDeleteModalShown]);
+  }, [mutateChildUserGroups, setSelectedUserGroup, setDeleteModalShown, isExternalGroup]);
 
   const removeChildUserGroup = useCallback(async(userGroupData: IUserGroupHasId) => {
     try {
@@ -348,7 +363,11 @@ const UserGroupDetailPage = (props: Props): JSX.Element => {
                 { ancestorUserGroup._id === currentUserGroupId ? (
                   <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}
                   </Link>
                 ) }
@@ -366,6 +385,7 @@ const UserGroupDetailPage = (props: Props): JSX.Element => {
           selectableParentUserGroups={selectableParentUserGroups}
           submitButtonLabel={t('Update')}
           onSubmit={onClickSubmitForm}
+          isExternalGroup={isExternalGroup}
         />
       </div>
       <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}
         onClickPlusBtn={() => setIsUserGroupUserModalShown(true)}
         onClickRemoveUserBtn={removeUserByUsername}
+        isExternalGroup={isExternalGroup}
       />
       <UserGroupUserModal
         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>
-      <UserGroupDropdown
-        selectableUserGroups={selectableChildUserGroups}
-        onClickAddExistingUserGroupButton={onClickAddExistingUserGroupButtonHandler}
-        onClickCreateUserGroupButton={showCreateModal}
-      />
+      {!isExternalGroup && (
+        <UserGroupDropdown
+          selectableUserGroups={selectableChildUserGroups}
+          onClickAddExistingUserGroupButton={onClickAddExistingUserGroupButtonHandler}
+          onClickCreateUserGroupButton={showCreateModal}
+        />
+      )}
 
       <UserGroupModal
         userGroup={selectedUserGroup}
@@ -401,6 +424,7 @@ const UserGroupDetailPage = (props: Props): JSX.Element => {
         onClickSubmit={updateChildUserGroup}
         isShow={isUpdateModalShown}
         onHide={hideUpdateModal}
+        isExternalGroup={isExternalGroup}
       />
 
       <UserGroupModal
@@ -420,6 +444,7 @@ const UserGroupDetailPage = (props: Props): JSX.Element => {
         onRemove={removeChildUserGroup}
         onDelete={showDeleteModal}
         userGroupRelations={childUserGroupRelations}
+        isExternalGroup={isExternalGroup}
       />
 
       <UserGroupDeleteModal

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

@@ -10,15 +10,12 @@ type Props = {
   userGroupRelations: IUserGroupRelationHasIdPopulatedUser[] | undefined,
   onClickRemoveUserBtn: (username: string) => Promise<void>,
   onClickPlusBtn: () => void,
+  isExternalGroup?: boolean
 }
 
 export const UserGroupUserTable = (props: Props): JSX.Element => {
   const { t } = useTranslation('admin');
 
-  const {
-    userGroupRelations, onClickRemoveUserBtn, onClickPlusBtn,
-  } = props;
-
   return (
     <table className="table table-bordered table-user-list">
       <thead>
@@ -30,11 +27,11 @@ export const UserGroupUserTable = (props: Props): JSX.Element => {
           <th>{t('Name')}</th>
           <th style={{ width: '100px' }}>{t('Created')}</th>
           <th style={{ width: '160px' }}>{t('last_login')}</th>
-          <th style={{ width: '70px' }}></th>
+          {!props.isExternalGroup && <th style={{ width: '70px' }}></th>}
         </tr>
       </thead>
       <tbody>
-        {userGroupRelations != null && userGroupRelations.map((relation) => {
+        {props.userGroupRelations != null && props.userGroupRelations.map((relation) => {
           const { relatedUser } = relation;
 
           return (
@@ -48,47 +45,49 @@ export const UserGroupUserTable = (props: Props): JSX.Element => {
               <td>{relatedUser.name}</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>
-                <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-bs-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
-                      className="dropdown-item"
                       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>
+                    <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>
-              </td>
+                </td>
+              )}
             </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>
     </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;
+};

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

@@ -208,8 +208,8 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
   const { mutate: mutatePageInfo } = useSWRxPageInfo(pageId);
 
   const path = currentPage?.path ?? currentPathname;
-  const grant = currentPage?.grant ?? grantData?.grant;
-  const grantUserGroupId = currentPage?.grantedGroup?._id ?? grantData?.grantedGroup?.id;
+  // const grant = currentPage?.grant ?? grantData?.grant;
+  // const grantUserGroupId = currentPage?.grantedGroup?._id ?? grantData?.grantedGroup?.id;
 
   const [isPageTemplateModalShown, setIsPageTempleteModalShown] = useState(false);
 
@@ -325,8 +325,8 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
             editorMode={editorMode}
             isBtnDisabled={!!isGuestUser || !!isReadOnlyUser}
             path={path}
-            grant={grant}
-            grantUserGroupId={grantUserGroupId}
+            // grant={grant}
+            // grantUserGroupId={grantUserGroupId}
           />
         )}
       </div>

+ 6 - 4
apps/app/src/components/Navbar/PageEditorModeManager.tsx

@@ -1,5 +1,6 @@
 import React, { type ReactNode, useCallback, useState } from 'react';
 
+import type { IGrantedGroup } from '@growi/core';
 import { useTranslation } from 'next-i18next';
 
 import { EditorMode, useIsDeviceLargerThanMd } from '~/stores/ui';
@@ -46,7 +47,8 @@ type Props = {
   isBtnDisabled: boolean,
   path?: string,
   grant?: number,
-  grantUserGroupId?: string
+  // grantUserGroupId?: string
+  grantUserGroupIds?: IGrantedGroup[]
 }
 
 export const PageEditorModeManager = (props: Props): JSX.Element => {
@@ -54,8 +56,8 @@ export const PageEditorModeManager = (props: Props): JSX.Element => {
     editorMode = EditorMode.View,
     isBtnDisabled,
     path,
-    grant,
-    grantUserGroupId,
+    // grant,
+    // grantUserGroupId,
   } = props;
 
   const { t } = useTranslation();
@@ -63,7 +65,7 @@ export const PageEditorModeManager = (props: Props): JSX.Element => {
 
   const { data: isDeviceLargerThanMd } = useIsDeviceLargerThanMd();
 
-  const onPageEditorModeButtonClicked = useOnPageEditorModeButtonClicked(setIsCreating, path, grant, grantUserGroupId);
+  const onPageEditorModeButtonClicked = useOnPageEditorModeButtonClicked(setIsCreating, path);
   const _isBtnDisabled = isCreating || isBtnDisabled;
 
   const pageEditorModeButtonClickedHandler = useCallback((viewType: EditorMode) => {

+ 7 - 6
apps/app/src/components/Navbar/hooks.tsx

@@ -14,8 +14,8 @@ const logger = loggerFactory('growi:Navbar:GrowiContextualSubNavigation');
 export const useOnPageEditorModeButtonClicked = (
     setIsCreating:React.Dispatch<React.SetStateAction<boolean>>,
     path?: string,
-    grant?: number,
-    grantUserGroupId?: string,
+    // grant?: number,
+    // grantUserGroupId?: string,
 ): (editorMode: EditorMode) => Promise<void> => {
   const router = useRouter();
   const { t } = useTranslation('commons');
@@ -23,7 +23,7 @@ export const useOnPageEditorModeButtonClicked = (
   const { mutate: mutateEditorMode } = useEditorMode();
 
   return useCallback(async(editorMode: EditorMode) => {
-    if (isNotFound == null || path == null || grant == null) {
+    if (isNotFound == null || path == null) {
       return;
     }
 
@@ -34,8 +34,9 @@ export const useOnPageEditorModeButtonClicked = (
         const params = {
           isSlackEnabled: false,
           slackChannels: '',
-          grant,
-          grantUserGroupId,
+          grant: 4,
+          // grant,
+          // grantUserGroupId,
         };
 
         const response = await createPage(path, '', params);
@@ -53,5 +54,5 @@ export const useOnPageEditorModeButtonClicked = (
     }
 
     mutateEditorMode(editorMode);
-  }, [grant, grantUserGroupId, isNotFound, mutateEditorMode, path, router, setIsCreating, t]);
+  }, [isNotFound, mutateEditorMode, path, router, setIsCreating, t]);
 };

+ 66 - 32
apps/app/src/components/PageAlert/FixPageGrantAlert.tsx

@@ -1,6 +1,6 @@
 import React, { useEffect, useState, useCallback } from 'react';
 
-import { PageGrant } from '@growi/core';
+import { PageGrant, GroupType } from '@growi/core';
 import { useTranslation } from 'react-i18next';
 import {
   Modal, ModalHeader, ModalBody, ModalFooter,
@@ -9,7 +9,7 @@ import {
 import { apiv3Put } from '~/client/util/apiv3-client';
 import { toastError, toastSuccess } from '~/client/util/toastr';
 import { IPageGrantData } from '~/interfaces/page';
-import { IRecordApplicableGrant, IResIsGrantNormalizedGrantData } from '~/interfaces/page-grant';
+import { ApplicableGroup, IRecordApplicableGrant, IResIsGrantNormalizedGrantData } from '~/interfaces/page-grant';
 import { useCurrentUser } from '~/stores/context';
 import { useSWRxApplicableGrant, useSWRxIsGrantNormalized, useSWRxCurrentPage } from '~/stores/page';
 
@@ -29,7 +29,9 @@ const FixPageGrantModal = (props: ModalProps): JSX.Element => {
   } = props;
 
   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 [selectedGroups, setSelectedGroups] = useState<ApplicableGroup[]>([]);
 
   // Alert message state
   const [shouldShowModalAlert, setShowModalAlert] = useState<boolean>(false);
@@ -40,14 +42,23 @@ const FixPageGrantModal = (props: ModalProps): JSX.Element => {
   useEffect(() => {
     if (isOpen) {
       setSelectedGrant(PageGrant.GRANT_RESTRICTED);
-      setSelectedGroup(undefined);
+      setSelectedGroups([]);
       setShowModalAlert(false);
     }
   }, [isOpen]);
 
+  const groupListItemClickHandler = (group: ApplicableGroup) => {
+    if (selectedGroups.find(g => g.item._id === group.item._id) != null) {
+      setSelectedGroups(selectedGroups.filter(g => g.item._id !== group.item._id));
+    }
+    else {
+      setSelectedGroups([...selectedGroups, group]);
+    }
+  };
+
   const submit = async() => {
     // Validate input values
-    if (selectedGrant === PageGrant.GRANT_USER_GROUP && selectedGroup == null) {
+    if (selectedGrant === PageGrant.GRANT_USER_GROUP && selectedGroups.length === 0) {
       setShowModalAlert(true);
       return;
     }
@@ -57,7 +68,9 @@ const FixPageGrantModal = (props: ModalProps): JSX.Element => {
     try {
       await apiv3Put(`/page/${pageId}/grant`, {
         grant: selectedGrant,
-        grantedGroup: selectedGroup?._id,
+        grantedGroups: selectedGroups.length !== 0 ? selectedGroups.map((g) => {
+          return { item: g.item._id, type: g.type };
+        }) : null,
       });
 
       toastSuccess(t('Successfully updated'));
@@ -86,10 +99,10 @@ const FixPageGrantModal = (props: ModalProps): JSX.Element => {
     }
 
     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.radio_btn.grant_group')}: (${grantData.grantedGroup.name})`;
+      return `${t('fix_page_grant.modal.radio_btn.grant_group')} (${grantData.grantedGroups.map(g => g.name).join(', ')})`;
     }
 
     throw Error('cannot get grant label'); // this error can't be throwed
@@ -177,32 +190,18 @@ const FixPageGrantModal = (props: ModalProps): JSX.Element => {
                 <div className="dropdown ms-2">
                   <button
                     type="button"
-                    className="btn btn-secondary dropdown-toggle text-end w-100 border-0 shadow-none"
-                    data-bs-toggle="dropdown"
+                    className="btn btn-secondary dropdown-toggle text-right w-100 border-0 shadow-none"
                     disabled={selectedGrant !== PageGrant.GRANT_USER_GROUP} // disable when its radio input is not selected
+                    onClick={() => setIsGroupSelectModalShown(true)}
                   >
                     <span className="float-start ms-2">
                       {
-                        selectedGroup == null
+                        selectedGroups.length === 0
                           ? t('fix_page_grant.modal.select_group_default_text')
-                          : selectedGroup.name
+                          : selectedGroups.map(g => g.item.name).join(', ')
                       }
                     </span>
                   </button>
-                  <div className="dropdown-menu">
-                    {
-                      applicableGroups != null && applicableGroups.map(g => (
-                        <button
-                          key={g._id}
-                          className="dropdown-item"
-                          type="button"
-                          onClick={() => setSelectedGroup(g)}
-                        >
-                          {g.name}
-                        </button>
-                      ))
-                    }
-                  </div>
                 </div>
               </div>
               {
@@ -225,12 +224,47 @@ const FixPageGrantModal = (props: ModalProps): JSX.Element => {
   };
 
   return (
-    <Modal size="lg" isOpen={isOpen} toggle={close}>
-      <ModalHeader tag="h4" toggle={close} className="bg-primary text-light">
-        { t('fix_page_grant.modal.title') }
-      </ModalHeader>
-      {renderModalBodyAndFooter()}
-    </Modal>
+    <>
+      <Modal size="lg" isOpen={isOpen} toggle={close}>
+        <ModalHeader tag="h4" toggle={close} className="bg-primary text-light">
+          { t('fix_page_grant.modal.title') }
+        </ModalHeader>
+        {renderModalBodyAndFooter()}
+      </Modal>
+      {applicableGroups != null && (
+        <Modal
+          isOpen={isGroupSelectModalShown}
+          toggle={() => setIsGroupSelectModalShown(false)}
+        >
+          <ModalHeader tag="h4" toggle={() => setIsGroupSelectModalShown(false)} className="bg-purple text-light">
+            {t('user_group.select_group')}
+          </ModalHeader>
+          <ModalBody>
+            <>
+              { applicableGroups.map((group) => {
+                const groupIsGranted = selectedGroups?.find(g => g.item._id === group.item._id) != null;
+                const activeClass = groupIsGranted ? 'active' : '';
+
+                return (
+                  <button
+                    className={`btn btn-outline-primary w-100 d-flex justify-content-start mb-3 align-items-center p-3 ${activeClass}`}
+                    type="button"
+                    key={group.item._id}
+                    onClick={() => groupListItemClickHandler(group)}
+                  >
+                    <span className="align-middle"><input type="checkbox" checked={groupIsGranted} /></span>
+                    <h5 className="d-inline-block ml-3">{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> */}
+                  </button>
+                );
+              }) }
+              <button type="button" className="btn btn-primary mt-2 float-right" onClick={() => setIsGroupSelectModalShown(false)}>{t('Done')}</button>
+            </>
+          </ModalBody>
+        </Modal>
+      )}
+    </>
   );
 };
 

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

@@ -1,5 +1,6 @@
 import React from 'react';
 
+import { isPopulated } from '@growi/core';
 import { useTranslation } from 'react-i18next';
 
 import { useSWRxCurrentPage } from '~/stores/page';
@@ -13,6 +14,10 @@ export const PageGrantAlert = (): JSX.Element => {
     return <></>;
   }
 
+  const populatedGrantedGroups = () => {
+    return pageData.grantedGroups.filter(group => isPopulated(group.item));
+  };
+
   const renderAlertContent = () => {
     const getGrantLabel = () => {
       if (pageData.grant === 2) {
@@ -32,7 +37,11 @@ export const PageGrantAlert = (): JSX.Element => {
       if (pageData.grant === 5) {
         return (
           <>
-            <i className="icon-fw icon-organization"></i><strong>{pageData.grantedGroup.name}</strong>
+            <i className="icon-fw icon-organization"></i>
+            <strong>{
+              populatedGrantedGroups().map(g => g.item.name).join(', ')
+            }
+            </strong>
           </>
         );
       }

+ 5 - 2
apps/app/src/components/PageEditor/PageEditor.tsx

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

+ 10 - 5
apps/app/src/components/ReactMarkdownComponents/RichAttachment.tsx

@@ -9,11 +9,14 @@ import { useDeleteAttachmentModal } from '~/stores/modal';
 
 import styles from './RichAttachment.module.scss';
 
-export const RichAttachment: React.FC<{
+type RichAttachmentProps = {
   attachmentId: string,
   url: string,
-  attachmentName: string
-}> = React.memo(({ attachmentId, url, attachmentName }) => {
+  attachmentName: string,
+}
+
+export const RichAttachment = React.memo((props: RichAttachmentProps) => {
+  const { attachmentId, attachmentName } = props;
   const { t } = useTranslation();
   const { data: attachment, remove } = useSWRxAttachment(attachmentId);
   const { open: openDeleteAttachmentModal } = useDeleteAttachmentModal();
@@ -58,13 +61,15 @@ export const RichAttachment: React.FC<{
           </div>
           <div className="ps-0">
             <div className="d-inline-block">
-              <a target="_blank" rel="noopener noreferrer" href={filePathProxied}>
+              {/* Since we need to include the "referer" to view the attachment on the shared page */}
+              {/* eslint-disable-next-line react/jsx-no-target-blank */}
+              <a target="_blank" rel="noopener" href={filePathProxied}>
                 {attachmentName || originalName}
               </a>
               <a className="ms-2 attachment-download" href={downloadPathProxied}>
                 <i className="icon-cloud-download" />
               </a>
-              <a className="ms-2 text-danger attachment-delete" onClick={onClickTrashButtonHandler}>
+              <a className="ml-2 text-danger attachment-delete d-share-link-none" type="button" onClick={onClickTrashButtonHandler}>
                 <i className="icon-trash" />
               </a>
             </div>

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

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

+ 56 - 44
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 { isPopulated } from '@growi/core';
-import type { IUserGroupHasId } from '@growi/core';
+import { isPopulated, GroupType, type IGrantedGroup } from '@growi/core';
 import { useTranslation } from 'next-i18next';
 import {
   UncontrolledDropdown,
@@ -12,8 +11,8 @@ import {
 
 import type { IPageGrantData } from '~/interfaces/page';
 import { useCurrentUser } from '~/stores/context';
-import { useSWRxMyUserGroupRelations } from '~/stores/user-group';
 
+import { useMyUserGroups } from './use-my-user-groups';
 
 const AVAILABLE_GRANTS = [
   {
@@ -35,8 +34,11 @@ const AVAILABLE_GRANTS = [
 type Props = {
   disabled?: boolean,
   grant: number,
-  grantGroupId?: string,
-  grantGroupName?: string,
+  grantedGroups?: {
+    id: string,
+    name: string,
+    type: GroupType,
+  }[]
 
   onUpdateGrant?: (grantData: IPageGrantData) => void,
 }
@@ -44,15 +46,14 @@ type Props = {
 /**
  * Page grant select component
  */
-const GrantSelector = (props: Props): JSX.Element => {
+export const GrantSelector = (props: Props): JSX.Element => {
   const { t } = useTranslation();
 
   const {
     disabled,
-    grantGroupName,
+    grantedGroups,
     onUpdateGrant,
     grant: currentGrant,
-    grantGroupId,
   } = props;
 
 
@@ -61,12 +62,12 @@ const GrantSelector = (props: Props): JSX.Element => {
   const { data: currentUser } = useCurrentUser();
 
   const shouldFetch = isSelectGroupModalShown;
-  const { data: myUserGroupRelations, mutate: mutateMyUserGroupRelations } = useSWRxMyUserGroupRelations(shouldFetch);
+  const { data: myUserGroups, update: updateMyUserGroups } = useMyUserGroups(shouldFetch);
 
   const showSelectGroupModal = useCallback(() => {
-    mutateMyUserGroupRelations();
+    updateMyUserGroups();
     setIsSelectGroupModalShown(true);
-  }, [mutateMyUserGroupRelations]);
+  }, [updateMyUserGroups]);
 
   /**
    * change event handler for grant selector
@@ -79,18 +80,23 @@ const GrantSelector = (props: Props): JSX.Element => {
     }
 
     if (onUpdateGrant != null) {
-      onUpdateGrant({ grant, grantedGroup: undefined });
+      onUpdateGrant({ grant, grantedGroups: undefined });
     }
   }, [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)) {
+      let grantedGroupsCopy = grantedGroups != null ? [...grantedGroups] : [];
+      const grantGroupInfo = { id: grantGroup.item._id, name: grantGroup.item.name, type: grantGroup.type };
+      if (grantedGroupsCopy.find(group => group.id === grantGroupInfo.id) == null) {
+        grantedGroupsCopy.push(grantGroupInfo);
+      }
+      else {
+        grantedGroupsCopy = grantedGroupsCopy.filter(group => group.id !== grantGroupInfo.id);
+      }
+      onUpdateGrant({ grant: 5, grantedGroups: grantedGroupsCopy });
     }
-
-    // hide modal
-    setIsSelectGroupModalShown(false);
-  }, [onUpdateGrant]);
+  }, [onUpdateGrant, grantedGroups]);
 
   /**
    * Render grant selector DOM.
@@ -101,7 +107,7 @@ const GrantSelector = (props: Props): JSX.Element => {
     let dropdownToggleLabelElm;
 
     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.label;
 
@@ -122,11 +128,19 @@ const GrantSelector = (props: Props): JSX.Element => {
     });
 
     // add specified group option
-    if (grantGroupId != null) {
+    if (grantedGroups != null && grantedGroups.length > 0) {
       const labelElm = (
         <span>
           <i className="icon icon-fw icon-organization"></i>
-          <span className="label">{grantGroupName}</span>
+          <span className="label">
+            {grantedGroups.length > 1
+              ? (
+                <span>
+                  {`${grantedGroups[0].name}... `}
+                  <span className="badge badge-purple">+{grantedGroups.length - 1}</span>
+                </span>
+              ) : grantedGroups[0].name}
+          </span>
         </span>
       );
 
@@ -148,7 +162,7 @@ const GrantSelector = (props: Props): JSX.Element => {
         </UncontrolledDropdown>
       </div>
     );
-  }, [changeGrantHandler, currentGrant, disabled, grantGroupId, grantGroupName, t]);
+  }, [changeGrantHandler, currentGrant, disabled, grantedGroups, t]);
 
   /**
    * Render select grantgroup modal.
@@ -159,7 +173,7 @@ const GrantSelector = (props: Props): JSX.Element => {
     }
 
     // show spinner
-    if (myUserGroupRelations == null) {
+    if (myUserGroups == null) {
       return (
         <div className="my-3 text-center">
           <i className="fa fa-lg fa-spinner fa-pulse mx-auto text-muted"></i>
@@ -167,16 +181,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 (
         <div>
           <h4>{t('user_group.belonging_to_no_group')}</h4>
@@ -188,19 +193,30 @@ const GrantSelector = (props: Props): JSX.Element => {
     }
 
     return (
-      <div className="list-group">
-        { userRelatedGroups.map((group) => {
+      <>
+        { myUserGroups.map((group) => {
+          const groupIsGranted = grantedGroups?.find(g => g.id === group.item._id) != null;
+          const activeClass = groupIsGranted ? 'active' : '';
+
           return (
-            <button key={group._id} type="button" className="list-group-item list-group-item-action" onClick={() => groupListItemClickHandler(group)}>
-              <h5>{group.name}</h5>
+            <button
+              className={`btn btn-outline-primary w-100 d-flex justify-content-start mb-3 align-items-center p-3 ${activeClass}`}
+              type="button"
+              key={group.item._id}
+              onClick={() => groupListItemClickHandler(group)}
+            >
+              <span className="align-middle"><input type="checkbox" checked={groupIsGranted} /></span>
+              <h5 className="d-inline-block ml-3">{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> */}
             </button>
           );
         }) }
-      </div>
+        <button type="button" className="btn btn-primary mt-2 float-right" onClick={() => setIsSelectGroupModalShown(false)}>{t('Done')}</button>
+      </>
     );
 
-  }, [currentUser?.admin, groupListItemClickHandler, myUserGroupRelations, shouldFetch, t]);
+  }, [currentUser?.admin, groupListItemClickHandler, myUserGroups, shouldFetch, t, grantedGroups]);
 
   return (
     <>
@@ -209,7 +225,6 @@ const GrantSelector = (props: Props): JSX.Element => {
       {/* render modal */}
       { !disabled && currentUser != null && (
         <Modal
-          className="select-grant-group"
           isOpen={isSelectGroupModalShown}
           toggle={() => setIsSelectGroupModalShown(false)}
         >
@@ -223,7 +238,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 };
+};

+ 10 - 7
apps/app/src/components/Sidebar/PageCreateButton/PageCreateButton.tsx

@@ -49,8 +49,9 @@ export const PageCreateButton = React.memo((): JSX.Element => {
       const params = {
         isSlackEnabled: false,
         slackChannels: '',
-        grant: currentPage?.grant || 1,
-        grantUserGroupId: currentPage?.grantedGroup?._id,
+        grant: 4,
+        // grant: currentPage?.grant || 1,
+        // grantUserGroupId: currentPage?.grantedGroup?._id,
         shouldGeneratePath: true,
       };
 
@@ -80,7 +81,7 @@ export const PageCreateButton = React.memo((): JSX.Element => {
       const params = {
         isSlackEnabled: false,
         slackChannels: '',
-        grant: 1,
+        grant: 4,
       };
 
       const res = await exist(JSON.stringify([todaysPath]));
@@ -112,8 +113,9 @@ export const PageCreateButton = React.memo((): JSX.Element => {
       const params = {
         isSlackEnabled: false,
         slackChannels: '',
-        grant: currentPage?.grant || 1,
-        grantUserGroupId: currentPage?.grantedGroup?._id,
+        grant: 4,
+        // grant: currentPage?.grant || 1,
+        // grantUserGroupId: currentPage?.grantedGroup?._id,
       };
 
       const res = await exist(JSON.stringify([path]));
@@ -145,8 +147,9 @@ export const PageCreateButton = React.memo((): JSX.Element => {
       const params = {
         isSlackEnabled: false,
         slackChannels: '',
-        grant: currentPage?.grant || 1,
-        grantUserGroupId: currentPage?.grantedGroup?._id,
+        grant: 4,
+        // grant: currentPage?.grant || 1,
+        // grantUserGroupId: currentPage?.grantedGroup?._id,
       };
 
       const res = await exist(JSON.stringify([path]));

+ 2 - 1
apps/app/src/components/TreeItem/NewPageInput.tsx

@@ -54,7 +54,8 @@ export const NewPageInput: FC<NewPageInputProps> = (props) => {
         path: newPagePath,
         body: undefined,
         grant: page.grant,
-        grantUserGroupId: page.grantedGroup,
+        // grantUserGroupId: page.grantedGroup,
+        grantUserGroupIds: page.grantedGroups,
       });
 
       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>
+    </>
+  );
+};

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

@@ -0,0 +1,171 @@
+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
+        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;
+  }
+
+}

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

@@ -13,6 +13,7 @@ export const GrowiAttachmentType = {
   aws: 'aws',
   gcs: 'gcs',
   gcp: 'gcp',
+  azure: 'azure',
   gridfs: 'gridfs',
   mongo: 'mongo',
   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
   crowi: any,
@@ -14,3 +16,5 @@ export interface CrowiRequest<U extends IUser = IUserHasId> extends Request {
   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';
 
+
+type UserGroupType = typeof GroupType.userGroup;
+type ExternalUserGroupType = typeof GroupType.externalUserGroup;
+export type ApplicableGroup = {type: UserGroupType, item: UserGroupDocument } | {type: ExternalUserGroupType, item: ExternalUserGroupDocument }
 export type IDataApplicableGroup = {
-  applicableGroups?: {_id: string, name: string}[] // TODO: Typescriptize model
+  applicableGroups?: ApplicableGroup[]
 }
 
 export type IDataApplicableGrant = null | IDataApplicableGroup;

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

@@ -1,3 +1,5 @@
+import type { IGrantedGroup } from '@growi/core';
+
 export const PageActionType = {
   Create: 'Create',
   Update: 'Update',
@@ -31,7 +33,6 @@ export type OptionsToSave = {
   isSlackEnabled: boolean;
   slackChannels: string;
   grant: number;
-  grantUserGroupId?: string | null;
-  grantUserGroupName?: string | null;
-  shouldGeneratePath?: boolean | null;
+  // grantUserGroupIds?: IGrantedGroup[];
+  // 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';
 
@@ -10,10 +10,11 @@ export type IPageForItem = Partial<IPageHasId & {isTarget?: boolean, processData
 
 export type IPageGrantData = {
   grant: number,
-  grantedGroup?: {
+  grantedGroups?: {
     id: string,
-    name: string
-  }
+    name: string,
+    type: GroupType,
+  }[]
 }
 
 export type IDeleteSinglePageApiv1Result = {

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

@@ -39,6 +39,20 @@ export type IResAppSettings = {
   envGcsBucket: 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,
 
   isQuestionnaireEnabled: boolean,

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

@@ -1,29 +1,30 @@
 import type {
   HasObjectId, Ref,
   IPageHasId,
-  IUser, IUserGroup, IUserGroupHasId, IUserGroupRelationHasId,
+  IUserGroup, IUserGroupHasId, IUserGroupRelationHasId, IUserHasId,
 } from '@growi/core';
 
+
 export type UserGroupResult = {
   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,
 } & 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 = {
   // Update descendantCount
   UpdateDescCount: 'UpdateDescCount',
@@ -17,6 +37,9 @@ export const SocketEventName = {
   FinishAddPage: 'finishAddPage',
   RebuildingFailed: 'rebuildingFailed',
 
+  // External user group sync
+  externalUserGroup: generateGroupSyncEvents(),
+
   // Page Operation
   PageCreated: 'page:create',
   PageUpdated: 'page:update',

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

@@ -1,5 +1,6 @@
 import mongoose from 'mongoose';
 
+import UserGroupRelation from '~/server/models/user-group-relation';
 import { getModelSafely, getMongoUri, mongoOptions } from '~/server/util/mongoose-utils';
 import loggerFactory from '~/utils/logger';
 
@@ -11,7 +12,6 @@ module.exports = {
     mongoose.connect(getMongoUri(), mongoOptions);
 
     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 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 { 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';
 
 const logger = loggerFactory('growi:migrate:add-attachment-type-to-existing-attachments');
@@ -11,7 +11,6 @@ module.exports = {
   async up(db) {
     logger.info('Apply migration');
     mongoose.connect(getMongoUri(), mongoOptions);
-    const Attachment = getModelSafely('Attachment') || attachmentModel();
 
     // Add attachmentType for wiki page
     // 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');
+  },
+};

+ 12 - 9
apps/app/src/pages/[[...path]].page.tsx

@@ -400,17 +400,20 @@ class MultiplePagesHitsError extends ExtensibleCustomError {
 
 // apply parent page grant fot creating page
 async function applyGrantToPage(props: Props, ancestor: any) {
-  await ancestor.populate('grantedGroup');
+  await ancestor.populate('grantedGroups.item');
   const grant = {
     grant: ancestor.grant,
   };
-  const grantedGroup = ancestor.grantedGroup ? {
-    grantedGroup: {
-      id: ancestor.grantedGroup.id,
-      name: ancestor.grantedGroup.name,
-    },
+  const grantedGroups = ancestor.grantedGroups ? {
+    grantedGroups: ancestor.grantedGroups.map((group) => {
+      return {
+        id: group.item._id,
+        name: group.item.name,
+        type: group.type,
+      };
+    }),
   } : {};
-  props.grantData = Object.assign(grant, grantedGroup);
+  props.grantData = Object.assign(grant, grantedGroups);
 }
 
 async function injectPageData(context: GetServerSidePropsContext, props: Props): Promise<void> {
@@ -473,7 +476,7 @@ async function injectPageData(context: GetServerSidePropsContext, props: Props):
       props.templateBodyData = templateData.templateBody as string;
     }
 
-    // apply pagrent page grant
+    // apply parent page grant
     const ancestor = await Page.findAncestorByPathAndViewer(currentPathname, user);
     if (ancestor != null) {
       await applyGrantToPage(props, ancestor);
@@ -603,7 +606,7 @@ async function injectNextI18NextConfigurations(context: GetServerSidePropsContex
 }
 
 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 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> {
     const initialProps: DocumentInitialProps = await Document.getInitialProps(ctx);
-    const { crowi } = ctx.req as CrowiRequest<any>;
+    const { crowi } = ctx.req as CrowiRequest;
     const { customizeService } = crowi;
 
     const { themeHref } = customizeService;

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

@@ -123,7 +123,7 @@ async function injectNextI18NextConfigurations(context: GetServerSidePropsContex
 }
 
 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 result = await getServerSideCommonProps(context);

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

@@ -149,7 +149,7 @@ async function injectNextI18NextConfigurations(context: GetServerSidePropsContex
 }
 
 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 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);
   useCurrentUser(props.currentUser ?? null);
   const router = useRouter();
-  const { userGroupId } = router.query;
+  const { userGroupId, isExternalGroup } = router.query;
 
   const title = t('user_group_management.user_group_management');
   const customTitle = generateCustomTitle(props, title);
 
   const currentUserGroupId = Array.isArray(userGroupId) ? userGroupId[0] : userGroupId;
 
+  const isExternalGroupBool = isExternalGroup === 'true';
+
   useIsAclEnabled(props.isAclEnabled);
 
   if (props.isAccessDeniedForNonAdminUser) {
@@ -46,7 +48,7 @@ const AdminUserGroupDetailPage: NextPage<Props> = (props: Props) => {
       </Head>
       {
         currentUserGroupId != null && router.isReady
-      && <UserGroupDetailPage userGroupId={currentUserGroupId} />
+      && <UserGroupDetailPage userGroupId={currentUserGroupId} isExternalGroup={isExternalGroupBool} />
       }
     </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) => {
-  const req = context.req as CrowiRequest<IUserHasId & any>;
+  const req = context.req as CrowiRequest;
   const { user } = req;
   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) => {
-  const req = context.req as CrowiRequest<IUserHasId & any>;
+  const req = context.req as CrowiRequest;
 
   const result = await getServerSideCommonProps(context);
 

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

@@ -1,7 +1,6 @@
 import React, { type ReactNode, useMemo } from 'react';
 
-import type { IUserHasId } from '@growi/core';
-import type {
+import {
   GetServerSideProps, GetServerSidePropsContext,
 } from 'next';
 import { useTranslation } from 'next-i18next';
@@ -207,7 +206,7 @@ async function injectNextI18NextConfigurations(context: GetServerSidePropsContex
 }
 
 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 result = await getServerSideCommonProps(context);
@@ -222,7 +221,7 @@ export const getServerSideProps: GetServerSideProps = async(context: GetServerSi
 
   if (user != null) {
     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();
   }
 

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

@@ -1,6 +1,6 @@
 import React, { useEffect } from 'react';
 
-import type { IUserHasId, IPagePopulatedToShowRevision } from '@growi/core';
+import { type IUserHasId, type IPagePopulatedToShowRevision, getIdForRef } from '@growi/core';
 import type {
   GetServerSideProps, GetServerSidePropsContext,
 } from 'next';
@@ -18,6 +18,7 @@ import type { CrowiRequest } from '~/interfaces/crowi-request';
 import type { RendererConfig } from '~/interfaces/services/renderer';
 import type { IShareLinkHasId } from '~/interfaces/share-link';
 import type { PageDocument } from '~/server/models/page';
+import ShareLink from '~/server/models/share-link';
 import {
   useCurrentUser, useRendererConfig, useIsSearchPage, useCurrentPathname,
   useShareLinkId, useIsSearchServiceConfigured, useIsSearchServiceReachable, useIsSearchScopeChildrenAsDefault, useIsContainerFluid, useIsEnabledMarp,
@@ -221,7 +222,7 @@ async function addActivity(context: GetServerSidePropsContext, action: Supported
 }
 
 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 result = await getServerSideCommonProps(context);
 
@@ -231,18 +232,23 @@ export const getServerSideProps: GetServerSideProps = async(context: GetServerSi
   const props: Props = result.props as Props;
 
   try {
-    const ShareLinkModel = crowi.model('ShareLink');
-    const shareLink = await ShareLinkModel.findOne({ _id: params.linkId }).populate('relatedPage');
+    const shareLink = await ShareLink.findOne({ _id: params.linkId }).populate('relatedPage');
     if (shareLink == null) {
       props.isNotFound = true;
     }
     else {
       props.isNotFound = false;
-      const ssrMaxRevisionBodyLength = crowi.configManager.getConfig('crowi', 'app:ssrMaxRevisionBodyLength');
-      props.skipSSR = await skipSSR(shareLink.relatedPage, ssrMaxRevisionBodyLength);
-      props.shareLinkRelatedPage = await shareLink.relatedPage.populateDataToShowRevision(props.skipSSR); // shouldExcludeBody = skipSSR
       props.isExpired = shareLink.isExpired();
       props.shareLink = shareLink.toObject();
+
+      // retrieve Page
+      const Page = crowi.model('Page');
+      const relatedPage = await Page.findOne({ _id: getIdForRef(shareLink.relatedPage) });
+      // determine whether skip SSR
+      const ssrMaxRevisionBodyLength = crowi.configManager.getConfig('crowi', 'app:ssrMaxRevisionBodyLength');
+      props.skipSSR = await skipSSR(relatedPage, ssrMaxRevisionBodyLength);
+      // populate
+      props.shareLinkRelatedPage = await relatedPage.populateDataToShowRevision(props.skipSSR); // shouldExcludeBody = skipSSR
     }
   }
   catch (err) {

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

@@ -151,7 +151,7 @@ async function injectNextI18NextConfigurations(context: GetServerSidePropsContex
 }
 
 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 result = await getServerSideCommonProps(context);
 

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

@@ -131,7 +131,7 @@ async function injectNextI18NextConfigurations(context: GetServerSidePropsContex
 }
 
 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 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) => {
   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 {
     appService, configManager, customizeService, attachmentService,

+ 101 - 81
apps/app/src/server/crowi/index.js

@@ -10,6 +10,8 @@ import next from 'next';
 
 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 QuestionnaireCronService from '~/features/questionnaire/server/service/questionnaire-cron';
 import CdnResourcesService from '~/services/cdn-resources-service';
@@ -18,91 +20,101 @@ import loggerFactory from '~/utils/logger';
 import { projectRoot } from '~/utils/project-dir-utils';
 
 import UserEvent from '../events/user';
-import Activity from '../models/activity';
-import PageRedirect from '../models/page-redirect';
-import Tag from '../models/tag';
-import UserGroup from '../models/user-group';
+import { modelsDependsOnCrowi } from '../models';
 import { aclService as aclServiceSingletonInstance } from '../service/acl';
 import AppService from '../service/app';
 import AttachmentService from '../service/attachment';
 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 { InstallerService } from '../service/installer';
 import PageService from '../service/page';
 import PageGrantService from '../service/page-grant';
 import PageOperationService from '../service/page-operation';
+import PassportService from '../service/passport';
 import SearchService from '../service/search';
 import { SlackIntegrationService } from '../service/slack-integration';
+import UserGroupService from '../service/user-group';
 import { UserNotificationService } from '../service/user-notification';
 import { getMongoUri, mongoOptions } from '../util/mongoose-utils';
 
 const logger = loggerFactory('growi:crowi');
 const httpErrorHandler = require('../middlewares/http-error-handler');
-const models = require('../models');
 
 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),
-    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),
+      tag: new (require('../events/tag'))(this),
+      admin: new (require('../events/admin'))(this),
+    };
+  }
+
 }
 
 Crowi.prototype.init = async function() {
@@ -150,10 +162,13 @@ Crowi.prototype.init = async function() {
     this.setUpCustomize(), // depends on pluginService
   ]);
 
-  // globalNotification depends on slack and mailer
   await Promise.all([
+    // globalNotification depends on slack and mailer
     this.setUpGlobalNotification(),
     this.setUpUserNotification(),
+    // depends on passport service
+    this.setupExternalAccountService(),
+    this.setupExternalUserGroupSyncService(),
   ]);
 
   await this.autoInstall();
@@ -292,19 +307,15 @@ Crowi.prototype.setupSocketIoService = 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;
+    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));
   });
 
 };
@@ -357,7 +368,6 @@ Crowi.prototype.setupPassport = async function() {
   logger.debug('Passport is enabled');
 
   // initialize service
-  const PassportService = require('../service/passport');
   if (this.passportService == null) {
     this.passportService = new PassportService(this);
   }
@@ -630,7 +640,7 @@ Crowi.prototype.setUpApp = async function() {
  */
 Crowi.prototype.setUpFileUpload = async function(isForceUpdate = false) {
   if (this.fileUploadService == null || isForceUpdate) {
-    this.fileUploadService = require('../service/file-uploader')(this);
+    this.fileUploadService = getUploader(this);
   }
 };
 
@@ -666,7 +676,6 @@ Crowi.prototype.setUpRestQiitaAPI = async function() {
 };
 
 Crowi.prototype.setupUserGroupService = async function() {
-  const UserGroupService = require('../service/user-group');
   if (this.userGroupService == null) {
     this.userGroupService = new UserGroupService(this);
     return this.userGroupService.init();
@@ -769,4 +778,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;

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

@@ -5,3 +5,27 @@ export const AttachmentType = {
 } as const;
 
 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];

+ 0 - 54
apps/app/src/server/middlewares/certify-shared-file.js

@@ -1,54 +0,0 @@
-import loggerFactory from '~/utils/logger';
-
-const url = require('url');
-
-const logger = loggerFactory('growi:middleware:certify-shared-fire');
-
-module.exports = (crowi) => {
-
-  return async(req, res, next) => {
-    const { referer } = req.headers;
-
-    // Attachments cannot be viewed by clients who do not send referer.
-    // https://github.com/weseek/growi/issues/2819
-    if (referer == null) {
-      return next();
-    }
-
-    const { path } = url.parse(referer);
-
-    if (!path.startsWith('/share/')) {
-      return next();
-    }
-
-    const fileId = req.params.id || null;
-
-    const Attachment = crowi.model('Attachment');
-    const ShareLink = crowi.model('ShareLink');
-
-    const attachment = await Attachment.findOne({ _id: fileId });
-
-    if (attachment == null) {
-      return next();
-    }
-
-    const shareLinks = await ShareLink.find({ relatedPage: attachment.page });
-
-    // If sharelinks don't exist, skip it
-    if (shareLinks.length === 0) {
-      return next();
-    }
-
-    // Is there a valid share link
-    shareLinks.map((sharelink) => {
-      if (!sharelink.isExpired()) {
-        logger.debug('Confirmed target file belong to a share page');
-        req.isSharedPage = true;
-      }
-      return;
-    });
-
-    next();
-  };
-
-};

+ 145 - 0
apps/app/src/server/middlewares/certify-shared-page-attachment/certify-shared-page-attachment.spec.ts

@@ -0,0 +1,145 @@
+import type { Response } from 'express';
+import { mock } from 'vitest-mock-extended';
+
+import { ShareLinkDocument } from '~/server/models/share-link';
+
+import { certifySharedPageAttachmentMiddleware, type RequestToAllowShareLink } from './certify-shared-page-attachment';
+import { ValidReferer } from './interfaces';
+
+const mocks = vi.hoisted(() => {
+  return {
+    validateRefererMock: vi.fn(),
+    retrieveValidShareLinkByRefererMock: vi.fn(),
+    validateAttachmentMock: vi.fn(),
+  };
+});
+
+vi.mock('./validate-referer', () => ({ validateReferer: mocks.validateRefererMock }));
+vi.mock('./retrieve-valid-share-link', () => ({ retrieveValidShareLinkByReferer: mocks.retrieveValidShareLinkByRefererMock }));
+vi.mock('./validate-attachment', () => ({ validateAttachment: mocks.validateAttachmentMock }));
+
+
+describe('certifySharedPageAttachmentMiddleware', () => {
+
+  const res = mock<Response>();
+  const next = vi.fn();
+
+  describe('should called next() without req.isSharedPage set', () => {
+
+    it('when the fileId param is null', async() => {
+      // setup
+      const req = mock<RequestToAllowShareLink>();
+      req.params = {}; // id: undefined
+      req.headers = {};
+
+      // when
+      await certifySharedPageAttachmentMiddleware(req, res, next);
+
+      // then
+      expect(mocks.validateRefererMock).not.toHaveBeenCalled();
+      expect(req.isSharedPage === true).toBeFalsy();
+      expect(next).toHaveBeenCalledOnce();
+    });
+
+    it('when validateReferer returns null', async() => {
+      // setup
+      const req = mock<RequestToAllowShareLink>();
+      req.params = { id: 'file id string' };
+      req.headers = { referer: 'referer string' };
+
+      // when
+      await certifySharedPageAttachmentMiddleware(req, res, next);
+
+      // then
+      expect(mocks.validateRefererMock).toHaveBeenCalledOnce();
+      expect(mocks.validateRefererMock).toHaveBeenCalledWith('referer string');
+      expect(req.isSharedPage === true).toBeFalsy();
+      expect(next).toHaveBeenCalledOnce();
+    });
+
+    it('when retrieveValidShareLinkByReferer returns null', async() => {
+      // setup
+      const req = mock<RequestToAllowShareLink>();
+      req.params = { id: 'file id string' };
+      req.headers = { referer: 'referer string' };
+
+      const validReferer: ValidReferer = {
+        referer: 'referer string',
+        shareLinkId: 'ffffffffffffffffffffffff',
+      };
+      mocks.validateRefererMock.mockImplementation(() => validReferer);
+
+      mocks.retrieveValidShareLinkByRefererMock.mockResolvedValue(null);
+
+      // when
+      await certifySharedPageAttachmentMiddleware(req, res, next);
+
+      // then
+      expect(mocks.validateRefererMock).toHaveBeenCalledOnce();
+      expect(mocks.validateRefererMock).toHaveBeenCalledWith('referer string');
+      expect(mocks.retrieveValidShareLinkByRefererMock).toHaveBeenCalledOnce();
+      expect(mocks.retrieveValidShareLinkByRefererMock).toHaveBeenCalledWith(validReferer);
+      expect(req.isSharedPage === true).toBeFalsy();
+      expect(next).toHaveBeenCalledOnce();
+    });
+
+    it('when validateAttachment returns false', async() => {
+      // setup
+      const req = mock<RequestToAllowShareLink>();
+      req.params = { id: 'file id string' };
+      req.headers = { referer: 'referer string' };
+
+      const validReferer = vi.fn();
+      mocks.validateRefererMock.mockImplementation(() => validReferer);
+
+      const shareLinkMock = mock<ShareLinkDocument>();
+      mocks.retrieveValidShareLinkByRefererMock.mockResolvedValue(shareLinkMock);
+
+      mocks.validateAttachmentMock.mockResolvedValue(false);
+
+      // when
+      await certifySharedPageAttachmentMiddleware(req, res, next);
+
+      // then
+      expect(mocks.validateRefererMock).toHaveBeenCalledOnce();
+      expect(mocks.validateRefererMock).toHaveBeenCalledWith('referer string');
+      expect(mocks.retrieveValidShareLinkByRefererMock).toHaveBeenCalledOnce();
+      expect(mocks.retrieveValidShareLinkByRefererMock).toHaveBeenCalledWith(validReferer);
+      expect(mocks.validateAttachmentMock).toHaveBeenCalledOnce();
+      expect(mocks.validateAttachmentMock).toHaveBeenCalledWith('file id string', shareLinkMock);
+      expect(req.isSharedPage === true).toBeFalsy();
+      expect(next).toHaveBeenCalledOnce();
+    });
+
+  });
+
+  it('should set req.isSharedPage true', async() => {
+    // setup
+    const req = mock<RequestToAllowShareLink>();
+    req.params = { id: 'file id string' };
+    req.headers = { referer: 'referer string' };
+
+    const validReferer = vi.fn();
+    mocks.validateRefererMock.mockImplementation(() => validReferer);
+
+    const shareLinkMock = mock<ShareLinkDocument>();
+    mocks.retrieveValidShareLinkByRefererMock.mockResolvedValue(shareLinkMock);
+
+    mocks.validateAttachmentMock.mockResolvedValue(true);
+
+    // when
+    await certifySharedPageAttachmentMiddleware(req, res, next);
+
+    // then
+    expect(mocks.validateRefererMock).toHaveBeenCalledOnce();
+    expect(mocks.validateRefererMock).toHaveBeenCalledWith('referer string');
+    expect(mocks.retrieveValidShareLinkByRefererMock).toHaveBeenCalledOnce();
+    expect(mocks.retrieveValidShareLinkByRefererMock).toHaveBeenCalledWith(validReferer);
+    expect(mocks.validateAttachmentMock).toHaveBeenCalledOnce();
+    expect(mocks.validateAttachmentMock).toHaveBeenCalledWith('file id string', shareLinkMock);
+
+    expect(req.isSharedPage === true).toBeTruthy();
+
+    expect(next).toHaveBeenCalledOnce();
+  });
+});

+ 49 - 0
apps/app/src/server/middlewares/certify-shared-page-attachment/certify-shared-page-attachment.ts

@@ -0,0 +1,49 @@
+import type { NextFunction, Request, Response } from 'express';
+
+import loggerFactory from '~/utils/logger';
+
+import { retrieveValidShareLinkByReferer } from './retrieve-valid-share-link';
+import { validateAttachment } from './validate-attachment';
+import { validateReferer } from './validate-referer';
+
+
+const logger = loggerFactory('growi:middleware:certify-shared-page-attachment');
+
+
+export interface RequestToAllowShareLink extends Request {
+  isSharedPage?: boolean,
+}
+
+export const certifySharedPageAttachmentMiddleware = async(req: RequestToAllowShareLink, res: Response, next: NextFunction): Promise<void> => {
+
+  const fileId: string | undefined = req.params.id;
+  const { referer } = req.headers;
+
+  if (fileId == null) {
+    logger.error('The param fileId is required. Please confirm to usage of this middleware.');
+    return next();
+  }
+
+  const validReferer = validateReferer(referer);
+  if (!validReferer) {
+    logger.info('invalid referer.');
+    return next();
+  }
+
+  logger.info('referer is valid.');
+
+  const shareLink = await retrieveValidShareLinkByReferer(validReferer);
+  if (shareLink == null) {
+    logger.info(`No valid ShareLink document found by the referer (${validReferer.referer}})`);
+    return next();
+  }
+
+  if (!(await validateAttachment(fileId, shareLink))) {
+    logger.info(`No valid ShareLink document found by the fileId (${fileId}) and referer (${validReferer.referer}})`);
+    return next();
+  }
+
+  req.isSharedPage = true;
+  next();
+
+};

+ 1 - 0
apps/app/src/server/middlewares/certify-shared-page-attachment/index.ts

@@ -0,0 +1 @@
+export * from './certify-shared-page-attachment';

+ 4 - 0
apps/app/src/server/middlewares/certify-shared-page-attachment/interfaces.ts

@@ -0,0 +1,4 @@
+export type ValidReferer = {
+  referer: string,
+  shareLinkId: string,
+};

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

@@ -0,0 +1,28 @@
+import type { ShareLinkDocument, ShareLinkModel } from '~/server/models/share-link';
+import { getModelSafely } from '~/server/util/mongoose-utils';
+import loggerFactory from '~/utils/logger';
+
+import type { ValidReferer } from './interfaces';
+
+
+const logger = loggerFactory('growi:middleware:certify-shared-page-attachment:retrieve-valid-share-link');
+
+
+export const retrieveValidShareLinkByReferer = async(referer: ValidReferer): Promise<ShareLinkDocument | null> => {
+  const ShareLink = getModelSafely<ShareLinkDocument, ShareLinkModel>('ShareLink');
+  if (ShareLink == null) {
+    logger.warn('Could not get ShareLink model. next() will be called without processing anything.');
+    return null;
+  }
+
+  const shareLinkId = referer;
+  const shareLink = await ShareLink.findOne({
+    id: shareLinkId,
+  });
+  if (shareLink == null || shareLink.isExpired()) {
+    logger.info(`ShareLink ('${shareLinkId}') is not found or has already expired.`);
+    return null;
+  }
+
+  return shareLink;
+};

+ 25 - 0
apps/app/src/server/middlewares/certify-shared-page-attachment/validate-attachment.ts

@@ -0,0 +1,25 @@
+import { getIdForRef, type IAttachment } from '@growi/core';
+
+import { ShareLinkDocument } from '~/server/models/share-link';
+import { getModelSafely } from '~/server/util/mongoose-utils';
+import loggerFactory from '~/utils/logger';
+
+
+const logger = loggerFactory('growi:middleware:certify-shared-page-attachment:validate-attachment');
+
+
+export const validateAttachment = async(fileId: string, shareLink: ShareLinkDocument): Promise<boolean> => {
+  const Attachment = getModelSafely<IAttachment>('Attachment');
+  if (Attachment == null) {
+    logger.warn('Could not get Attachment model. next() will be called without processing anything.');
+    return false;
+  }
+
+  const relatedPageId = getIdForRef(shareLink.relatedPage);
+  const result = await Attachment.exists({
+    _id: fileId,
+    page: relatedPageId,
+  });
+
+  return result != null;
+};

+ 1 - 0
apps/app/src/server/middlewares/certify-shared-page-attachment/validate-referer/index.ts

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

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

@@ -0,0 +1,59 @@
+import { retrieveSiteUrl } from './retrieve-site-url';
+
+const mocks = vi.hoisted(() => {
+  return {
+    configManagerMock: {
+      getConfig: vi.fn(),
+    },
+  };
+});
+
+vi.mock('~/server/service/config-manager', () => {
+  return { configManager: mocks.configManagerMock };
+});
+
+
+describe('retrieveSiteUrl', () => {
+
+  describe('returns null', () => {
+
+    it('when the siteUrl is not set', () => {
+      // setup
+      mocks.configManagerMock.getConfig.mockImplementation(() => {
+        return null;
+      });
+
+      // when
+      const result = retrieveSiteUrl();
+
+      // then
+      expect(result).toBeNull();
+    });
+
+    it('when the siteUrl is invalid', () => {
+      // setup
+      mocks.configManagerMock.getConfig.mockImplementation(() => {
+        return 'invalid siteUrl string';
+      });
+
+      // when
+      const result = retrieveSiteUrl();
+
+      // then
+      expect(result).toBeNull();
+    });
+  });
+
+  it('returns a URL instance', () => {
+    // setup
+    const siteUrl = 'https://example.com';
+    mocks.configManagerMock.getConfig.mockImplementation(() => siteUrl);
+
+    // when
+    const result = retrieveSiteUrl();
+
+    // then
+    expect(result).toEqual(new URL(siteUrl));
+  });
+
+});

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

@@ -0,0 +1,22 @@
+import { configManager } from '~/server/service/config-manager';
+import loggerFactory from '~/utils/logger';
+
+
+const logger = loggerFactory('growi:middlewares:certify-shared-file:validate-referer:retrieve-site-url');
+
+
+export const retrieveSiteUrl = (): URL | null => {
+  const siteUrlString = configManager.getConfig('crowi', 'app:siteUrl');
+  if (siteUrlString == null) {
+    logger.warn("Verification referer does not work because 'Site URL' is NOT set. All of attachments in share link page is invisible.");
+    return null;
+  }
+
+  try {
+    return new URL(siteUrlString);
+  }
+  catch (err) {
+    logger.error(`Parsing 'app:siteUrl' ('${siteUrlString}') has failed.`);
+    return null;
+  }
+};

+ 131 - 0
apps/app/src/server/middlewares/certify-shared-page-attachment/validate-referer/validate-referer.spec.ts

@@ -0,0 +1,131 @@
+import { objectIdUtils } from '@growi/core/dist/utils';
+
+import { validateReferer } from './validate-referer';
+
+const mocks = vi.hoisted(() => {
+  return {
+    retrieveSiteUrlMock: vi.fn(),
+  };
+});
+
+vi.mock('./retrieve-site-url', () => ({ retrieveSiteUrl: mocks.retrieveSiteUrlMock }));
+
+
+describe('validateReferer', () => {
+
+  const isValidObjectIdSpy = vi.spyOn(objectIdUtils, 'isValidObjectId');
+
+  beforeEach(() => {
+    isValidObjectIdSpy.mockClear();
+  });
+
+  describe('refurns false', () => {
+
+    it('when the referer argument is undefined', () => {
+      // setup
+
+      // when
+      const result = validateReferer(undefined);
+
+      // then
+      expect(result).toBeFalsy();
+      expect(mocks.retrieveSiteUrlMock).not.toHaveBeenCalled();
+      expect(isValidObjectIdSpy).not.toHaveBeenCalled();
+    });
+
+    it('when the referer is invalid', () => {
+      // when
+      const result = validateReferer('invalid URL');
+
+      // then
+      expect(result).toBeFalsy();
+      expect(mocks.retrieveSiteUrlMock).not.toHaveBeenCalledOnce();
+      expect(isValidObjectIdSpy).not.toHaveBeenCalled();
+    });
+
+    it('when the siteUrl returns null', () => {
+      // setup
+      mocks.retrieveSiteUrlMock.mockImplementation(() => {
+        return null;
+      });
+
+      // when
+      const refererString = 'https://example.org/share/xxxxx';
+      const result = validateReferer(refererString);
+
+      // then
+      expect(result).toBeFalsy();
+      expect(mocks.retrieveSiteUrlMock).toHaveBeenCalledOnce();
+      expect(isValidObjectIdSpy).not.toHaveBeenCalled();
+    });
+
+    it('when the hostname of the referer does not match with siteUrl', () => {
+      // setup
+      mocks.retrieveSiteUrlMock.mockImplementation(() => {
+        return new URL('https://example.com');
+      });
+
+      // when
+      const refererString = 'https://example.org/share/xxxxx';
+      const result = validateReferer(refererString);
+
+      // then
+      expect(result).toBeFalsy();
+      expect(mocks.retrieveSiteUrlMock).toHaveBeenCalledOnce();
+      expect(isValidObjectIdSpy).not.toHaveBeenCalled();
+    });
+
+    it('when the port of the referer does not match with siteUrl', () => {
+      // setup
+      mocks.retrieveSiteUrlMock.mockImplementation(() => {
+        return new URL('https://example.com');
+      });
+
+      // when
+      const refererString = 'https://example.com:8080/share/xxxxx';
+      const result = validateReferer(refererString);
+
+      // then
+      expect(result).toBeFalsy();
+      expect(mocks.retrieveSiteUrlMock).toHaveBeenCalledOnce();
+      expect(isValidObjectIdSpy).not.toHaveBeenCalled();
+    });
+
+    it('when the shareLinkId is invalid', () => {
+      // setup
+      mocks.retrieveSiteUrlMock.mockImplementation(() => {
+        return new URL('https://example.com');
+      });
+
+      // when
+      const refererString = 'https://example.com/share/FFFFFFFFFFFFFFFFFFFFFFFF';
+      const result = validateReferer(refererString);
+
+      // then
+      expect(result).toBeFalsy();
+      expect(mocks.retrieveSiteUrlMock).toHaveBeenCalledOnce();
+      expect(isValidObjectIdSpy).toHaveBeenCalledOnce();
+    });
+
+  });
+
+  it('returns ValidReferer instance', () => {
+    // setup
+    mocks.retrieveSiteUrlMock.mockImplementation(() => {
+      return new URL('https://example.com');
+    });
+
+
+    // when
+    const shareLinkId = '65436ba09ae6983bd608b89c';
+    const refererString = `https://example.com/share/${shareLinkId}`;
+    const result = validateReferer(refererString);
+
+    // then
+    expect(result).toStrictEqual({
+      referer: refererString,
+      shareLinkId,
+    });
+  });
+
+});

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

@@ -0,0 +1,69 @@
+import { objectIdUtils } from '@growi/core/dist/utils';
+
+import loggerFactory from '~/utils/logger';
+
+import { ValidReferer } from '../interfaces';
+
+import { retrieveSiteUrl } from './retrieve-site-url';
+
+
+const logger = loggerFactory('growi:middlewares:certify-shared-file:validate-referer');
+
+
+export const validateReferer = (referer: string | undefined): ValidReferer | false => {
+  // not null
+  if (referer == null) {
+    logger.info('The referer string is undefined');
+    return false;
+  }
+
+  let refererUrl: URL;
+  try {
+    refererUrl = new URL(referer);
+  }
+  catch (err) {
+    logger.info(`Parsing referer ('${referer}') has failed`);
+    return false;
+  }
+
+  // siteUrl
+  const siteUrl = retrieveSiteUrl();
+  if (siteUrl == null) {
+    logger.info('The siteUrl is null.');
+    return false;
+  }
+
+  // validate hostname and port
+  if (refererUrl.hostname !== siteUrl.hostname || refererUrl.port !== siteUrl.port) {
+    logger.warn('The hostname or port mismatched.', {
+      refererUrl: {
+        hostname: refererUrl.hostname,
+        port: refererUrl.port,
+      },
+      siteUrl: {
+        hostname: siteUrl.hostname,
+        port: siteUrl.port,
+      },
+    });
+    return false;
+  }
+
+  // validate pathname
+  // https://regex101.com/r/M5Bp6E/1
+  const match = refererUrl.pathname.match(/^\/share\/(?<shareLinkId>[a-f0-9]{24})$/i);
+  if (match == null || match.groups?.shareLinkId == null) {
+    logger.warn(`The pathname ('${refererUrl.pathname}') is invalid.`, match);
+    return false;
+  }
+
+  // validate shareLinkId is an correct ObjectId
+  if (!objectIdUtils.isValidObjectId(match.groups.shareLinkId)) {
+    logger.warn(`The shareLinkId ('${match.groups.shareLinkId}') is invalid as an ObjectId.`);
+    return false;
+  }
+
+  return {
+    referer,
+    shareLinkId: match.groups.shareLinkId,
+  };
+};

+ 1 - 1
apps/app/src/server/middlewares/certify-shared-page.js

@@ -1,3 +1,4 @@
+import ShareLink from '~/server/models/share-link';
 import loggerFactory from '~/utils/logger';
 
 const logger = loggerFactory('growi:middleware:certify-shared-page');
@@ -11,7 +12,6 @@ module.exports = (crowi) => {
       return next();
     }
 
-    const ShareLink = crowi.model('ShareLink');
     const sharelink = await ShareLink.findOne({ _id: shareLinkId, relatedPage: pageId });
 
     // check sharelink enabled

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

+ 6 - 0
apps/app/src/server/models/config.ts

@@ -142,6 +142,12 @@ export const defaultCrowiConfigs: { [key: string]: any } = {
   'importer:esa:access_token': undefined,
   'importer:qiita:team_name': 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 */
 };
 

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