Răsfoiți Sursa

Merge branch 'dev/7.0.x' into imprv/133905-replace-icons-material-symbols-outlined

Meiri Kikuta 2 ani în urmă
părinte
comite
a37a2688ed
100 a modificat fișierele cu 4155 adăugiri și 712 ștergeri
  1. 3 1
      .devcontainer/Dockerfile
  2. 2 1
      .devcontainer/devcontainer.json
  3. 2 36
      .devcontainer/docker-compose.yml
  4. 8 1
      CHANGELOG.md
  5. 1 1
      apps/app/.env.test
  6. 4 2
      apps/app/_obsolete/src/components/PageEditorByHackmd.tsx
  7. 0 5
      apps/app/_obsolete/src/styles/_override.scss
  8. 1 1
      apps/app/docker/README.md
  9. 13 3
      apps/app/package.json
  10. 59 1
      apps/app/public/static/locales/en_US/admin.json
  11. 1 1
      apps/app/public/static/locales/en_US/translation.json
  12. 60 1
      apps/app/public/static/locales/ja_JP/admin.json
  13. 1 1
      apps/app/public/static/locales/ja_JP/commons.json
  14. 1 1
      apps/app/public/static/locales/ja_JP/translation.json
  15. 60 1
      apps/app/public/static/locales/zh_CN/admin.json
  16. 1 1
      apps/app/public/static/locales/zh_CN/translation.json
  17. 1 1
      apps/app/resource/search/mappings-es7.json
  18. 1 1
      apps/app/resource/search/mappings-es8-for-ci.json
  19. 1 1
      apps/app/resource/search/mappings-es8.json
  20. 78 0
      apps/app/src/client/services/AdminAppContainer.js
  21. 11 6
      apps/app/src/client/services/side-effects/drawio-modal-launcher-for-view.ts
  22. 11 6
      apps/app/src/client/services/side-effects/handsontable-modal-launcher-for-view.ts
  23. 211 0
      apps/app/src/components/Admin/App/AzureSetting.tsx
  24. 75 2
      apps/app/src/components/Admin/App/FileUploadSetting.tsx
  25. 12 0
      apps/app/src/components/Admin/App/MaskedInput.module.scss
  26. 43 0
      apps/app/src/components/Admin/App/MaskedInput.tsx
  27. 3 2
      apps/app/src/components/Admin/ElasticsearchManagement/RebuildIndexControls.jsx
  28. 2 2
      apps/app/src/components/Admin/Security/LdapSecuritySettingContents.jsx
  29. 1 1
      apps/app/src/components/Admin/Security/OidcSecuritySettingContents.jsx
  30. 23 11
      apps/app/src/components/Admin/UserGroup/UserGroupForm.tsx
  31. 10 1
      apps/app/src/components/Admin/UserGroup/UserGroupModal.tsx
  32. 6 0
      apps/app/src/components/Admin/UserGroup/UserGroupPage.tsx
  33. 42 24
      apps/app/src/components/Admin/UserGroup/UserGroupTable.tsx
  34. 63 38
      apps/app/src/components/Admin/UserGroupDetail/UserGroupDetailPage.tsx
  35. 35 36
      apps/app/src/components/Admin/UserGroupDetail/UserGroupUserTable.tsx
  36. 50 0
      apps/app/src/components/Admin/UserGroupDetail/use-user-group-resource.ts
  37. 18 21
      apps/app/src/components/Comments.tsx
  38. 14 10
      apps/app/src/components/Common/PageViewLayout.module.scss
  39. 8 5
      apps/app/src/components/Common/PageViewLayout.tsx
  40. 0 76
      apps/app/src/components/CreateTemplateModal.jsx
  41. 91 0
      apps/app/src/components/CreateTemplateModal.tsx
  42. 11 32
      apps/app/src/components/InAppNotification/InAppNotificationElm.tsx
  43. 1 1
      apps/app/src/components/InAppNotification/InAppNotificationList.tsx
  44. 3 1
      apps/app/src/components/InAppNotification/PageNotification/ModelNotification.tsx
  45. 30 7
      apps/app/src/components/InAppNotification/PageNotification/PageModelNotification.tsx
  46. 11 8
      apps/app/src/components/InAppNotification/PageNotification/UserModelNotification.tsx
  47. 3 3
      apps/app/src/components/InAppNotification/PageNotification/useActionAndMsg.ts
  48. 2 4
      apps/app/src/components/InstallerForm.tsx
  49. 5 5
      apps/app/src/components/Navbar/GrowiContextualSubNavigation.tsx
  50. 2 2
      apps/app/src/components/Navbar/PageEditorModeManager.module.scss
  51. 7 5
      apps/app/src/components/Navbar/PageEditorModeManager.tsx
  52. 54 9
      apps/app/src/components/Navbar/hooks.tsx
  53. 18 10
      apps/app/src/components/Page/PageView.tsx
  54. 66 32
      apps/app/src/components/PageAlert/FixPageGrantAlert.tsx
  55. 10 1
      apps/app/src/components/PageAlert/PageGrantAlert.tsx
  56. 49 55
      apps/app/src/components/PageComment.tsx
  57. 5 5
      apps/app/src/components/PageComment/CommentEditor.tsx
  58. 4 4
      apps/app/src/components/PageCreateModal.jsx
  59. 0 106
      apps/app/src/components/PageEditor/Editor.module.scss
  60. 6 6
      apps/app/src/components/PageEditor/Editor.tsx
  61. 16 14
      apps/app/src/components/PageEditor/PageEditor.tsx
  62. 10 5
      apps/app/src/components/ReactMarkdownComponents/RichAttachment.tsx
  63. 3 4
      apps/app/src/components/SavePageControls.tsx
  64. 56 44
      apps/app/src/components/SavePageControls/GrantSelector/GrantSelector.tsx
  65. 1 0
      apps/app/src/components/SavePageControls/GrantSelector/index.ts
  66. 38 0
      apps/app/src/components/SavePageControls/GrantSelector/use-my-user-groups.ts
  67. 1 1
      apps/app/src/components/Sidebar/PageCreateButton/DropendMenu.tsx
  68. 11 14
      apps/app/src/components/Sidebar/PageCreateButton/PageCreateButton.tsx
  69. 1 1
      apps/app/src/components/Sidebar/Sidebar.tsx
  70. 2 1
      apps/app/src/components/TreeItem/NewPageInput.tsx
  71. 181 0
      apps/app/src/features/external-user-group/client/components/ExternalUserGroup/ExternalUserGroupManagement.tsx
  72. 21 0
      apps/app/src/features/external-user-group/client/components/ExternalUserGroup/KeycloakGroupManagement.tsx
  73. 241 0
      apps/app/src/features/external-user-group/client/components/ExternalUserGroup/KeycloakGroupSyncSettingsForm.tsx
  74. 67 0
      apps/app/src/features/external-user-group/client/components/ExternalUserGroup/LdapGroupManagement.tsx
  75. 247 0
      apps/app/src/features/external-user-group/client/components/ExternalUserGroup/LdapGroupSyncSettingsForm.tsx
  76. 171 0
      apps/app/src/features/external-user-group/client/components/ExternalUserGroup/SyncExecution.tsx
  77. 106 0
      apps/app/src/features/external-user-group/client/stores/external-user-group.ts
  78. 62 0
      apps/app/src/features/external-user-group/interfaces/external-user-group.ts
  79. 126 0
      apps/app/src/features/external-user-group/server/models/external-user-group-relation.integ.ts
  80. 54 0
      apps/app/src/features/external-user-group/server/models/external-user-group-relation.ts
  81. 73 0
      apps/app/src/features/external-user-group/server/models/external-user-group.integ.ts
  82. 64 0
      apps/app/src/features/external-user-group/server/models/external-user-group.ts
  83. 55 0
      apps/app/src/features/external-user-group/server/routes/apiv3/external-user-group-relation.ts
  84. 393 0
      apps/app/src/features/external-user-group/server/routes/apiv3/external-user-group.ts
  85. 224 0
      apps/app/src/features/external-user-group/server/service/external-user-group-sync.ts
  86. 210 0
      apps/app/src/features/external-user-group/server/service/keycloak-user-group-sync.integ.ts
  87. 168 0
      apps/app/src/features/external-user-group/server/service/keycloak-user-group-sync.ts
  88. 156 0
      apps/app/src/features/external-user-group/server/service/ldap-user-group-sync.ts
  89. 1 0
      apps/app/src/features/questionnaire/interfaces/growi-info.ts
  90. 8 4
      apps/app/src/interfaces/crowi-request.ts
  91. 2 2
      apps/app/src/interfaces/editor-settings.ts
  92. 2 2
      apps/app/src/interfaces/in-app-notification.ts
  93. 9 2
      apps/app/src/interfaces/page-grant.ts
  94. 4 4
      apps/app/src/interfaces/page-operation.ts
  95. 5 4
      apps/app/src/interfaces/page.ts
  96. 14 0
      apps/app/src/interfaces/res/admin/app-settings.ts
  97. 2 0
      apps/app/src/interfaces/template.ts
  98. 12 11
      apps/app/src/interfaces/user-group-response.ts
  99. 23 0
      apps/app/src/interfaces/websocket.ts
  100. 1 1
      apps/app/src/migrations/20200402160380-remove-deleteduser-from-relationgroup.js

+ 3 - 1
.devcontainer/Dockerfile

@@ -37,7 +37,9 @@ RUN sh -c 'echo "deb [arch=amd64] http://dl.google.com/linux/chrome/deb/ stable
 RUN wget -q -O - https://dl.google.com/linux/linux_signing_key.pub | sudo apt-key add -
 
 RUN apt-get update \
-    && apt-get -y install --no-install-recommends git-lfs \
+    && apt-get -y install --no-install-recommends \
+      git-lfs \
+      iputils-ping net-tools dnsutils \
 
     # Uncomment below lines to install Chromium
     # --- works only on AMD64 ---

+ 2 - 1
.devcontainer/devcontainer.json

@@ -19,6 +19,7 @@
     "eamodio.gitlens",
     "github.vscode-pull-request-github",
     "cschleiden.vscode-github-actions",
+    "mongodb.mongodb-vscode",
     "msjsdiag.debugger-for-chrome",
     "firefox-devtools.vscode-firefox-debug",
     "editorconfig.editorconfig",
@@ -34,7 +35,7 @@
   // "shutdownAction": "none",
 
   // Use 'postCreateCommand' to run commands after the container is created.
-  "postCreateCommand": "git-lfs pull & yarn install",
+  "postCreateCommand": "git-lfs pull & turbo run bootstrap",
 
   // Uncomment to connect as a non-root user. See https://aka.ms/vscode-remote/containers/non-root.
   "remoteUser": "node"

+ 2 - 36
.devcontainer/docker-compose.yml

@@ -31,17 +31,10 @@ services:
     image: mongo:6.0
     restart: unless-stopped
     ports:
-      - 27018:27017
+      - 27017
     volumes:
       - /data/db
 
-  # ogp:
-  #   image: ghcr.io/weseek/growi-unique-ogp:latest
-  #   ports:
-  #     - 8088:8088
-  #   restart: unless-stopped
-  #   tty: true
-
   # This container requires '../../growi-docker-compose' repository
   #   cloned from https://github.com/weseek/growi-docker-compose.git
   elasticsearch:
@@ -52,7 +45,7 @@ services:
         - version=8.7.0
     restart: unless-stopped
     ports:
-      - 9201:9200
+      - 9200
     environment:
       - bootstrap.memory_lock=true
       - "ES_JAVA_OPTS=-Xms256m -Xmx256m"
@@ -65,33 +58,6 @@ services:
       - /usr/share/elasticsearch/data
       - ../../growi-docker-compose/elasticsearch/v8/config/elasticsearch.yml:/usr/share/elasticsearch/config/elasticsearch.yml
 
-  #need to adjust kibana version based on elasticsearch version (use same version as elasticsearch version)
-  # kibana:
-  #   image: docker.elastic.co/kibana/kibana:8.7.0
-  #   restart: unless-stopped
-  #   environment:
-  #     ELASTICSEARCH_HOSTS: 'http://elasticsearch:9200'
-  #   ports:
-  #     - 5601:5601
-  #   depends_on:
-  #     - elasticsearch
-
-  # This container requires '../../growi-docker-compose' repository
-  #   cloned from https://github.com/weseek/growi-docker-compose.git
-  hackmd:
-    build:
-      context: ../../growi-docker-compose/hackmd
-    restart: unless-stopped
-    environment:
-      - GROWI_URI=http://localhost:3000
-      # define 'storage' option value
-      # see https://github.com/sequelize/cli/blob/7160d0/src/helpers/config-helper.js#L192
-      - CMD_DB_URL=sqlite://dummyhost/hackmd/sqlite/codimd.db
-      - CMD_CSP_ENABLE=false
-    ports:
-      - 3011:3000
-    volumes:
-      - /files/sqlite
 volumes:
   node_modules:
   node_modules_app:

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

+ 0 - 5
packages/core/scss/bootstrap/_override.scss → apps/app/_obsolete/src/styles/_override.scss

@@ -99,11 +99,6 @@
 //   }
 // }
 
-// Badges
-.badge {
-  @extend .rounded-pill;
-}
-
 // //Modals
 // .modal-open {
 //   width: 100%;

+ 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",

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

@@ -464,7 +464,7 @@
       "label": "Template for children",
       "desc": "Applies only to the same level pages which the template exists"
     },
-    "decendants": {
+    "descendants": {
       "label": "Template for descendants",
       "desc": "Applies to all decendant pages"
     }

+ 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}}を管理者から外しました",

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

@@ -79,7 +79,7 @@
     "template": {
       "desc": "テンプレートページの作成/編集",
       "children": "同一階層テンプレート",
-      "decendants": "下位層テンプレート"
+      "descendants": "下位層テンプレート"
     }
   },
 

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

@@ -497,7 +497,7 @@
       "label": "同一階層テンプレート",
       "desc": "テンプレートページが存在する階層にのみ適用されます"
     },
-    "decendants": {
+    "descendants": {
       "label": "下位層テンプレート",
       "desc": "テンプレートページが存在する下位層のすべてのページに適用されます"
     }

+ 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/public/static/locales/zh_CN/translation.json

@@ -451,7 +451,7 @@
 			"label": "子模板",
 			"desc": "仅应用于模板存在的同一级别页"
 		},
-		"decendants": {
+		"descendants": {
 			"label": "子代模板",
 			"desc": "适用于所有分散页"
 		}

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

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

@@ -30,27 +30,32 @@ export const useDrawioModalLauncherForView = (opts?: {
   const { data: shareLinkId } = useShareLinkId();
 
   const { data: currentPage } = useSWRxCurrentPage();
-  const { data: tagsInfo } = useSWRxTagsInfo(currentPage?._id);
 
   const { open: openDrawioModal } = useDrawioModal();
 
   const saveOrUpdate = useSaveOrUpdate();
 
   const saveByDrawioModal = useCallback(async(drawioMxFile: string, bol: number, eol: number) => {
-    if (currentPage == null || tagsInfo == null || shareLinkId != null) {
+    if (currentPage == null || shareLinkId != null) {
       return;
     }
 
     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,
-      pageTags: tagsInfo.tags,
+      // grantUserGroupIds,
+      // pageTags: tagsInfo.tags,
     };
 
     try {
@@ -67,7 +72,7 @@ export const useDrawioModalLauncherForView = (opts?: {
       logger.error('failed to save', error);
       opts?.onSaveError?.(error);
     }
-  }, [currentPage, opts, saveOrUpdate, shareLinkId, tagsInfo]);
+  }, [currentPage, opts, saveOrUpdate, shareLinkId]);
 
 
   // set handler to open DrawioModal

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

@@ -29,27 +29,32 @@ export const useHandsontableModalLauncherForView = (opts?: {
   const { data: shareLinkId } = useShareLinkId();
 
   const { data: currentPage } = useSWRxCurrentPage();
-  const { data: tagsInfo } = useSWRxTagsInfo(currentPage?._id);
 
   const { open: openHandsontableModal } = useHandsontableModal();
 
   const saveOrUpdate = useSaveOrUpdate();
 
   const saveByHandsontableModal = useCallback(async(table: MarkdownTable, bol: number, eol: number) => {
-    if (currentPage == null || tagsInfo == null || shareLinkId != null) {
+    if (currentPage == null || shareLinkId != null) {
       return;
     }
 
     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,
-      pageTags: tagsInfo.tags,
+      // grantUserGroupIds,
+      // pageTags: tagsInfo.tags,
     };
 
     try {
@@ -66,7 +71,7 @@ export const useHandsontableModalLauncherForView = (opts?: {
       logger.error('failed to save', error);
       opts?.onSaveError?.(error);
     }
-  }, [currentPage, opts, saveOrUpdate, shareLinkId, tagsInfo]);
+  }, [currentPage, opts, saveOrUpdate, shareLinkId]);
 
 
   // set handler to open HandsonTableModal

+ 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}>
                             <span className="material-symbols-outlined text-danger">delete_forever</span> {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;
+};

+ 18 - 21
apps/app/src/components/Comments.tsx

@@ -55,7 +55,7 @@ export const Comments = (props: CommentsProps): JSX.Element => {
     // see: https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver/observe
     // > You can call observe() multiple times on the same MutationObserver
     // > to watch for changes to different parts of the DOM tree and/or different types of changes.
-  }, [onLoaded]);
+  }, [onLoadedDebounced]);
 
   const isTopPagePath = isTopPage(pagePath);
 
@@ -69,29 +69,26 @@ export const Comments = (props: CommentsProps): JSX.Element => {
   };
 
   return (
-    <div className="page-comments-row mt-5 py-4 d-edit-none d-print-none">
-      <div className="container-lg">
-        <div id="page-comments-list" className="page-comments-list" ref={pageCommentParentRef}>
-          <PageComment
+    <div className="page-comments-row mt-5 py-4 border-top border-3 d-edit-none d-print-none">
+      <div id="page-comments-list" className="page-comments-list" ref={pageCommentParentRef}>
+        <PageComment
+          pageId={pageId}
+          pagePath={pagePath}
+          revision={revision}
+          currentUser={currentUser}
+          isReadOnly={false}
+        />
+      </div>
+      {!isDeleted && (
+        <div id="page-comment-write">
+          <CommentEditor
             pageId={pageId}
-            pagePath={pagePath}
-            revision={revision}
-            currentUser={currentUser}
-            isReadOnly={false}
-            titleAlign="left"
+            isForNewComment
+            onCommentButtonClicked={onCommentButtonClickHandler}
+            revisionId={revision._id}
           />
         </div>
-        {!isDeleted && (
-          <div id="page-comment-write">
-            <CommentEditor
-              pageId={pageId}
-              isForNewComment
-              onCommentButtonClicked={onCommentButtonClickHandler}
-              revisionId={revision._id}
-            />
-          </div>
-        )}
-      </div>
+      )}
     </div>
   );
 

+ 14 - 10
apps/app/src/components/Common/PageViewLayout.module.scss

@@ -3,9 +3,22 @@
 @use '~/styles/variables' as var;
 
 
+$subnavigation-height: 50px;
+$page-view-layout-margin-top: 32px;
+
+.page-view-layout :global {
+  $page-content-footer-min-heigh: 130px;
+  min-height: calc(100vh - #{$subnavigation-height + $page-view-layout-margin-top + $page-content-footer-min-heigh});
+}
+
+// md/lg layout padding
 .page-view-layout :global {
-  min-height: calc(100vh - 48px - 250px); // 100vh - subnavigation height - page-comments-row minimum height
+  @include bs.media-breakpoint-between(md, xl) {
+    padding-left: var.$grw-sidebar-nav-width;
+  }
+}
 
+.page-view-layout :global {
   .grw-side-contents-container {
     margin-bottom: 1rem;
 
@@ -17,20 +30,11 @@
   }
 }
 
-// md/lg layout padding
-.page-view-layout :global {
-  @include bs.media-breakpoint-between(md, xl) {
-    padding-left: var.$grw-sidebar-nav-width;
-  }
-}
-
 // sticky side contents
 .page-view-layout :global {
   .grw-side-contents-sticky-container {
     position: sticky;
 
-    $subnavigation-height: 50px;
-    $page-view-layout-margin-top: 32px;
     $page-path-nav-height: 99px;
     top: calc($subnavigation-height + $page-view-layout-margin-top + $page-path-nav-height + 4px);
   }

+ 8 - 5
apps/app/src/components/Common/PageViewLayout.tsx

@@ -2,6 +2,9 @@ import type { ReactNode } from 'react';
 
 import styles from './PageViewLayout.module.scss';
 
+const pageViewLayoutClass = styles['page-view-layout'] ?? '';
+const footerLayoutClass = styles['footer-layout'] ?? '';
+
 type Props = {
   children?: ReactNode,
   headerContents?: ReactNode,
@@ -16,13 +19,13 @@ export const PageViewLayout = (props: Props): JSX.Element => {
 
   return (
     <>
-      <div id="main" className={`main ${styles['page-view-layout']}`}>
-        <div id="content-main" className="content-main container-lg grw-container-convertible">
+      <div id="main" className={`main ${pageViewLayoutClass} flex-expand-vert`}>
+        <div id="content-main" className="content-main container-lg grw-container-convertible flex-expand-vert">
           { headerContents != null && headerContents }
           { sideContents != null
             ? (
-              <div className="d-flex gap-3">
-                <div className="flex-grow-1 flex-basis-0 mw-0">
+              <div className="flex-expand-horiz gap-3">
+                <div className="flex-expand-vert flex-basis-0 mw-0">
                   {children}
                 </div>
                 <div className="grw-side-contents-container col-lg-3  d-edit-none d-print-none" data-vrt-blackout-side-contents>
@@ -40,7 +43,7 @@ export const PageViewLayout = (props: Props): JSX.Element => {
       </div>
 
       { footerContents != null && (
-        <footer className="footer d-edit-none">
+        <footer className={`footer d-edit-none ${footerLayoutClass}`}>
           {footerContents}
         </footer>
       ) }

+ 0 - 76
apps/app/src/components/CreateTemplateModal.jsx

@@ -1,76 +0,0 @@
-import React from 'react';
-
-import { pathUtils } from '@growi/core/dist/utils';
-import { useTranslation } from 'next-i18next';
-import PropTypes from 'prop-types';
-import { Modal, ModalHeader, ModalBody } from 'reactstrap';
-import urljoin from 'url-join';
-
-const CreateTemplateModal = (props) => {
-  const { t } = useTranslation();
-  const { path } = props;
-
-  const parentPath = pathUtils.addTrailingSlash(path);
-
-  function generateUrl(label) {
-    return encodeURI(urljoin(parentPath, label, '#edit'));
-  }
-
-  /**
-   * @param {string} target Which hierarchy to create [children, decendants]
-   */
-  function renderTemplateCard(target, label) {
-    return (
-      <div className="card card-select-template">
-        <div className="card-header">{ t(`template.${target}.label`) }</div>
-        <div className="card-body">
-          <p className="text-center"><code>{label}</code></p>
-          <p className="form-text text-muted text-center"><small>{t(`template.${target}.desc`) }</small></p>
-        </div>
-        <div className="card-footer text-center">
-          <a
-            data-testid={`template-button-${target}`}
-            href={generateUrl(label)}
-            className="btn btn-sm btn-primary"
-            id={`template-button-${target}`}
-          >
-            { t('Edit') }
-          </a>
-        </div>
-      </div>
-    );
-  }
-
-  return (
-    <Modal isOpen={props.isOpen} toggle={props.onClose} data-testid="page-template-modal">
-      <ModalHeader tag="h4" toggle={props.onClose} className="bg-primary text-light">
-        {t('template.modal_label.Create/Edit Template Page')}
-      </ModalHeader>
-      <ModalBody>
-        <div>
-          <label className="form-label mb-4">
-            <code>{parentPath}</code><br />
-            { t('template.modal_label.Create template under') }
-          </label>
-          <div className="row row-cols-2">
-            <div className="col">
-              {renderTemplateCard('children', '_template')}
-            </div>
-            <div className="col">
-              {renderTemplateCard('decendants', '__template')}
-            </div>
-          </div>
-        </div>
-      </ModalBody>
-    </Modal>
-
-  );
-};
-
-CreateTemplateModal.propTypes = {
-  path: PropTypes.string.isRequired,
-  isOpen: PropTypes.bool.isRequired,
-  onClose: PropTypes.func.isRequired,
-};
-
-export default CreateTemplateModal;

+ 91 - 0
apps/app/src/components/CreateTemplateModal.tsx

@@ -0,0 +1,91 @@
+import React from 'react';
+
+import { pathUtils } from '@growi/core/dist/utils';
+import { useTranslation } from 'next-i18next';
+import { Modal, ModalHeader, ModalBody } from 'reactstrap';
+
+import { TargetType, LabelType } from '~/interfaces/template';
+
+import { useOnTemplateButtonClicked } from './Navbar/hooks';
+
+type TemplateCardProps = {
+  target: TargetType;
+  label: LabelType;
+  isPageCreating: boolean;
+  onClickHandler: () => void;
+};
+
+const TemplateCard: React.FC<TemplateCardProps> = ({
+  target, label, isPageCreating, onClickHandler,
+}) => {
+  const { t } = useTranslation();
+
+  return (
+    <div className="card card-select-template">
+      <div className="card-header">{t(`template.${target}.label`)}</div>
+      <div className="card-body">
+        <p className="text-center"><code>{label}</code></p>
+        <p className="form-text text-muted text-center"><small>{t(`template.${target}.desc`)}</small></p>
+      </div>
+      <div className="card-footer text-center">
+        <button
+          disabled={isPageCreating}
+          data-testid={`template-button-${target}`}
+          className="btn btn-sm btn-primary"
+          id={`template-button-${target}`}
+          onClick={onClickHandler}
+          type="button"
+        >
+          {t('Edit')}
+        </button>
+      </div>
+    </div>
+  );
+};
+
+type CreateTemplateModalProps = {
+  path: string;
+  isOpen: boolean;
+  onClose: () => void;
+};
+
+export const CreateTemplateModal: React.FC<CreateTemplateModalProps> = ({
+  path, isOpen, onClose,
+}) => {
+  const { t } = useTranslation();
+
+  const { onClickHandler: onClickTemplateButton, isPageCreating } = useOnTemplateButtonClicked(path);
+
+  const parentPath = pathUtils.addTrailingSlash(path);
+
+  const renderTemplateCard = (target: TargetType, label: LabelType) => (
+    <div className="col">
+      <TemplateCard
+        target={target}
+        label={label}
+        isPageCreating={isPageCreating}
+        onClickHandler={() => onClickTemplateButton(label)}
+      />
+    </div>
+  );
+
+  return (
+    <Modal isOpen={isOpen} toggle={onClose} data-testid="page-template-modal">
+      <ModalHeader tag="h4" toggle={onClose} className="bg-primary text-light">
+        {t('template.modal_label.Create/Edit Template Page')}
+      </ModalHeader>
+      <ModalBody>
+        <div>
+          <label className="form-label mb-4">
+            <code>{parentPath}</code><br />
+            {t('template.modal_label.Create template under')}
+          </label>
+          <div className="row row-cols-2">
+            {renderTemplateCard('children', '_template')}
+            {renderTemplateCard('descendants', '__template')}
+          </div>
+        </div>
+      </ModalBody>
+    </Modal>
+  );
+};

+ 11 - 32
apps/app/src/components/InAppNotification/InAppNotificationElm.tsx

@@ -2,7 +2,7 @@ import React, {
   FC, useRef,
 } from 'react';
 
-import type { HasObjectId } from '@growi/core';
+import type { IUser, IPage, HasObjectId } from '@growi/core';
 import { UserPicture } from '@growi/ui/dist/components';
 import { DropdownItem } from 'reactstrap';
 
@@ -40,31 +40,6 @@ const InAppNotificationElm: FC<Props> = (props: Props) => {
     }
   };
 
-  const getActionUsers = () => {
-    if (notification.targetModel === SupportedTargetModel.MODEL_USER) {
-      return notification.target.username;
-    }
-
-    const latestActionUsers = notification.actionUsers.slice(0, 3);
-    const latestUsers = latestActionUsers.map((user) => {
-      return `@${user.name}`;
-    });
-
-    let actionedUsers = '';
-    const latestUsersCount = latestUsers.length;
-    if (latestUsersCount === 1) {
-      actionedUsers = latestUsers[0];
-    }
-    else if (notification.actionUsers.length >= 4) {
-      actionedUsers = `${latestUsers.slice(0, 2).join(', ')} and ${notification.actionUsers.length - 2} others`;
-    }
-    else {
-      actionedUsers = latestUsers.join(', ');
-    }
-
-    return actionedUsers;
-  };
-
   const renderActionUserPictures = (): JSX.Element => {
     const actionUsers = notification.actionUsers;
 
@@ -84,10 +59,16 @@ const InAppNotificationElm: FC<Props> = (props: Props) => {
     );
   };
 
-  const actionUsers = getActionUsers();
-
   const isDropdownItem = props.type === 'dropdown-item';
 
+  const isPageNotification = (notification: IInAppNotification): notification is IInAppNotification<IPage> => {
+    return notification.targetModel === SupportedTargetModel.MODEL_PAGE;
+  };
+
+  const isUserNotification = (notification: IInAppNotification): notification is IInAppNotification<IUser> => {
+    return notification.targetModel === SupportedTargetModel.MODEL_USER;
+  };
+
   // determine tag
   const TagElem = isDropdownItem
     ? DropdownItem
@@ -105,18 +86,16 @@ const InAppNotificationElm: FC<Props> = (props: Props) => {
         >
         </span>
         {renderActionUserPictures()}
-        {notification.targetModel === SupportedTargetModel.MODEL_PAGE && (
+        {isPageNotification(notification) && (
           <PageModelNotification
             ref={notificationRef}
             notification={notification}
-            actionUsers={actionUsers}
           />
         )}
-        {notification.targetModel === SupportedTargetModel.MODEL_USER && (
+        {isUserNotification(notification) && (
           <UserModelNotification
             ref={notificationRef}
             notification={notification}
-            actionUsers={actionUsers}
           />
         )}
       </div>

+ 1 - 1
apps/app/src/components/InAppNotification/InAppNotificationList.tsx

@@ -26,7 +26,7 @@ const InAppNotificationList: FC<Props> = (props: Props) => {
     );
   }
 
-  const notifications = inAppNotificationData.docs.filter((notification) => { return notification.parsedSnapshot != null });
+  const notifications = inAppNotificationData.docs;
 
   return (
     <>

+ 3 - 1
apps/app/src/components/InAppNotification/PageNotification/ModelNotification.tsx

@@ -31,7 +31,9 @@ export const ModelNotification: FC<Props> = (props) => {
   return (
     <div className="p-2 overflow-hidden">
       <div className="text-truncate">
-        <b>{actionUsers}</b> {actionMsg} <PagePathLabel path={notification.parsedSnapshot?.path ?? ''} />
+        <b>{actionUsers}</b>
+        {actionMsg}
+        <PagePathLabel path={notification.parsedSnapshot?.path ?? ''} />
       </div>
       <i className={`${actionIcon} me-2`} />
       <FormattedDistanceDate

+ 30 - 7
apps/app/src/components/InAppNotification/PageNotification/PageModelNotification.tsx

@@ -1,32 +1,53 @@
 import React, {
-  forwardRef, ForwardRefRenderFunction,
+  forwardRef, ForwardRefRenderFunction, useCallback,
 } from 'react';
 
-import type { HasObjectId } from '@growi/core';
+import type { IPage, HasObjectId } from '@growi/core';
 import { useRouter } from 'next/router';
 
 import type { IInAppNotificationOpenable } from '~/client/interfaces/in-app-notification-openable';
 import type { IInAppNotification } from '~/interfaces/in-app-notification';
+import * as pageSerializers from '~/models/serializers/in-app-notification-snapshot/page';
 
 import { ModelNotification } from './ModelNotification';
 import { useActionMsgAndIconForPageModelNotification } from './useActionAndMsg';
 
 
 interface Props {
-  notification: IInAppNotification & HasObjectId
-  actionUsers: string
+  notification: IInAppNotification<IPage> & HasObjectId
 }
 
 const PageModelNotification: ForwardRefRenderFunction<IInAppNotificationOpenable, Props> = (props: Props, ref) => {
 
-  const {
-    notification, actionUsers,
-  } = props;
+  const { notification } = props;
 
   const { actionMsg, actionIcon } = useActionMsgAndIconForPageModelNotification(notification);
 
   const router = useRouter();
 
+  const getActionUsers = useCallback(() => {
+    const latestActionUsers = notification.actionUsers.slice(0, 3);
+    const latestUsers = latestActionUsers.map((user) => {
+      return `@${user.name}`;
+    });
+
+    let actionedUsers = '';
+    const latestUsersCount = latestUsers.length;
+    if (latestUsersCount === 1) {
+      actionedUsers = latestUsers[0];
+    }
+    else if (notification.actionUsers.length >= 4) {
+      actionedUsers = `${latestUsers.slice(0, 2).join(', ')} and ${notification.actionUsers.length - 2} others`;
+    }
+    else {
+      actionedUsers = latestUsers.join(', ');
+    }
+
+    return actionedUsers;
+  }, [notification.actionUsers]);
+
+  const actionUsers = getActionUsers();
+
   // publish open()
   const publishOpen = () => {
     if (notification.target != null) {
@@ -38,6 +59,8 @@ const PageModelNotification: ForwardRefRenderFunction<IInAppNotificationOpenable
     }
   };
 
+  notification.parsedSnapshot = pageSerializers.parseSnapshot(notification.snapshot);
+
   return (
     <ModelNotification
       notification={notification}

+ 11 - 8
apps/app/src/components/InAppNotification/PageNotification/UserModelNotification.tsx

@@ -2,7 +2,7 @@ import React, {
   forwardRef, ForwardRefRenderFunction,
 } from 'react';
 
-import type { HasObjectId } from '@growi/core';
+import type { IUser, HasObjectId } from '@growi/core';
 import { useRouter } from 'next/router';
 
 import type { IInAppNotificationOpenable } from '~/client/interfaces/in-app-notification-openable';
@@ -11,22 +11,25 @@ import type { IInAppNotification } from '~/interfaces/in-app-notification';
 import { ModelNotification } from './ModelNotification';
 import { useActionMsgAndIconForUserModelNotification } from './useActionAndMsg';
 
+interface Props {
+  notification: IInAppNotification<IUser> & HasObjectId
+}
 
-const UserModelNotification: ForwardRefRenderFunction<IInAppNotificationOpenable, {
-  notification: IInAppNotification & HasObjectId
-  actionUsers: string
-}> = ({
-  notification, actionUsers,
-}, ref) => {
-  const router = useRouter();
+const UserModelNotification: ForwardRefRenderFunction<IInAppNotificationOpenable, Props> = (props: Props, ref) => {
+
+  const { notification } = props;
 
   const { actionMsg, actionIcon } = useActionMsgAndIconForUserModelNotification(notification);
 
+  const router = useRouter();
+
   // publish open()
   const publishOpen = () => {
     router.push('/admin/users');
   };
 
+  const actionUsers = notification.target.username;
+
   return (
     <ModelNotification
       notification={notification}

+ 3 - 3
apps/app/src/components/InAppNotification/PageNotification/useActionAndMsg.ts

@@ -1,4 +1,4 @@
-import type { HasObjectId } from '@growi/core';
+import type { IUser, IPage, HasObjectId } from '@growi/core';
 
 import { SupportedAction } from '~/interfaces/activity';
 import type { IInAppNotification } from '~/interfaces/in-app-notification';
@@ -8,7 +8,7 @@ export type ActionMsgAndIconType = {
   actionIcon: string
 }
 
-export const useActionMsgAndIconForPageModelNotification = (notification: IInAppNotification & HasObjectId): ActionMsgAndIconType => {
+export const useActionMsgAndIconForPageModelNotification = (notification: IInAppNotification<IPage> & HasObjectId): ActionMsgAndIconType => {
   const actionType: string = notification.action;
   let actionMsg: string;
   let actionIcon: string;
@@ -77,7 +77,7 @@ export const useActionMsgAndIconForPageModelNotification = (notification: IInApp
   };
 };
 
-export const useActionMsgAndIconForUserModelNotification = (notification: IInAppNotification & HasObjectId): ActionMsgAndIconType => {
+export const useActionMsgAndIconForUserModelNotification = (notification: IInAppNotification<IUser> & HasObjectId): ActionMsgAndIconType => {
   const actionType: string = notification.action;
   let actionMsg: string;
   let actionIcon: string;

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

@@ -99,10 +99,8 @@ const InstallerForm = memo((): JSX.Element => {
       <div className="row">
         <form role="form" id="register-form" className="col-md-12" onSubmit={submitHandler}>
           <div className="dropdown mb-3">
-            <div className="input-group">
-              <div className=" dropdown-with-icon">
-                <span className="material-symbols-outlined">bubble_chart</span>
-              </div>
+            <div className="input-group dropdown-with-icon">
+              <span className="input-group-text"></span><span className="material-symbols-outlined">bubble_chart</span>
               <button
                 type="button"
                 className="btn btn-secondary dropdown-toggle form-control text-end rounded-end"

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

@@ -31,7 +31,7 @@ import {
   useSelectedGrant,
 } from '~/stores/ui';
 
-import CreateTemplateModal from '../CreateTemplateModal';
+import { CreateTemplateModal } from '../CreateTemplateModal';
 import AttachmentIcon from '../Icons/AttachmentIcon';
 import HistoryIcon from '../Icons/HistoryIcon';
 import PresentationIcon from '../Icons/PresentationIcon';
@@ -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>

+ 2 - 2
apps/app/src/components/Navbar/PageEditorModeManager.module.scss

@@ -31,7 +31,7 @@
 // == Colors
 @include bs.color-mode(light) {
   .grw-page-editor-mode-manager :global {
-    .btn-outline-primary {
+    .btn {
       $color: var(--grw-page-editor-mode-manager-btn-color, var(--grw-primary-700));
       $bg: var(--grw-page-editor-mode-manager-btn-bg, var(--grw-primary-100));
       $bg-rgb: var(--grw-page-editor-mode-manager-btn-bg-rgb, var(--grw-primary-100-rgb));
@@ -51,7 +51,7 @@
 }
 @include bs.color-mode(dark) {
   .grw-page-editor-mode-manager :global {
-    .btn-outline-primary {
+    .btn {
       $color: var(--grw-page-editor-mode-manager-btn-color, var(--grw-primary-300));
       $bg: var(--grw-page-editor-mode-manager-btn-bg, var(--grw-primary-800));
       $bg-rgb: var(--grw-page-editor-mode-manager-btn-bg-rgb, var(--grw-primary-800-rgb));

+ 7 - 5
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';
@@ -21,7 +22,7 @@ const PageEditorModeButton = React.memo((props: PageEditorModeButtonProps) => {
     currentEditorMode, isBtnDisabled, editorMode, children, onClick,
   } = props;
 
-  const classNames = ['btn btn-outline-primary py-1 px-2 d-flex align-items-center justify-content-center'];
+  const classNames = ['btn py-1 px-2 d-flex align-items-center justify-content-center'];
   if (currentEditorMode === editorMode) {
     classNames.push('active');
   }
@@ -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) => {

+ 54 - 9
apps/app/src/components/Navbar/hooks.tsx

@@ -1,10 +1,11 @@
-import { useCallback } from 'react';
+import { useCallback, useState } from 'react';
 
 import { useTranslation } from 'next-i18next';
 import { useRouter } from 'next/router';
 
-import { createPage } from '~/client/services/page-operation';
+import { createPage, exist } from '~/client/services/page-operation';
 import { toastError } from '~/client/util/toastr';
+import { LabelType } from '~/interfaces/template';
 import { useIsNotFound } from '~/stores/page';
 import { EditorMode, useEditorMode } from '~/stores/ui';
 import loggerFactory from '~/utils/logger';
@@ -14,8 +15,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 +24,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,9 +35,9 @@ export const useOnPageEditorModeButtonClicked = (
         const params = {
           isSlackEnabled: false,
           slackChannels: '',
-          grant,
-          pageTags: [],
-          grantUserGroupId,
+          grant: 4,
+          // grant,
+          // grantUserGroupId,
         };
 
         const response = await createPage(path, '', params);
@@ -54,5 +55,49 @@ export const useOnPageEditorModeButtonClicked = (
     }
 
     mutateEditorMode(editorMode);
-  }, [grant, grantUserGroupId, isNotFound, mutateEditorMode, path, router, setIsCreating, t]);
+  }, [isNotFound, mutateEditorMode, path, router, setIsCreating, t]);
+};
+
+export const useOnTemplateButtonClicked = (
+    currentPagePath: string,
+): {
+  onClickHandler: (label: LabelType) => Promise<void>,
+  isPageCreating: boolean
+} => {
+  const router = useRouter();
+  const [isPageCreating, setIsPageCreating] = useState(false);
+
+  const onClickHandler = useCallback(async(label: LabelType) => {
+    try {
+      setIsPageCreating(true);
+
+      const path = currentPagePath == null || currentPagePath === '/'
+        ? `/${label}`
+        : `${currentPagePath}/${label}`;
+
+      const params = {
+        isSlackEnabled: false,
+        slackChannels: '',
+        grant: 4,
+        // grant: currentPage?.grant || 1,
+        // grantUserGroupId: currentPage?.grantedGroup?._id,
+      };
+
+      const res = await exist(JSON.stringify([path]));
+      if (!res.pages[path]) {
+        await createPage(path, '', params);
+      }
+
+      router.push(`${path}#edit`);
+    }
+    catch (err) {
+      logger.warn(err);
+      toastError(err);
+    }
+    finally {
+      setIsPageCreating(false);
+    }
+  }, [currentPagePath, router]);
+
+  return { onClickHandler, isPageCreating };
 };

+ 18 - 10
apps/app/src/components/Page/PageView.tsx

@@ -112,14 +112,6 @@ export const PageView = (props: Props): JSX.Element => {
   const footerContents = !isIdenticalPathPage && !isNotFound
     ? (
       <>
-        <div id="comments-container" ref={commentsContainerRef}>
-          <Comments
-            pageId={page._id}
-            pagePath={pagePath}
-            revision={page.revision}
-            onLoaded={() => setCommentsLoaded(true)}
-          />
-        </div>
         {(isUsersHomepagePath && page.creator != null) && (
           <UsersHomepageFooter creatorId={page.creator._id} />
         )}
@@ -139,11 +131,27 @@ export const PageView = (props: Props): JSX.Element => {
     return (
       <>
         <PageContentsUtilities />
-        <RevisionRenderer rendererOptions={rendererOptions} markdown={markdown} />
+
+        <div className="flex-expand-vert justify-content-between">
+          <RevisionRenderer rendererOptions={rendererOptions} markdown={markdown} />
+
+          { !isIdenticalPathPage && !isNotFound && (
+            <div id="comments-container" ref={commentsContainerRef}>
+              <Comments
+                pageId={page._id}
+                pagePath={pagePath}
+                revision={page.revision}
+                onLoaded={() => setCommentsLoaded(true)}
+              />
+            </div>
+          ) }
+        </div>
       </>
     );
   };
 
+  const mobileClass = isMobile ? styles['page-mobile'] : '';
+
   return (
     <PageViewLayout
       headerContents={headerContents}
@@ -156,7 +164,7 @@ export const PageView = (props: Props): JSX.Element => {
       {specialContents == null && (
         <>
           {(isUsersHomepagePath && page?.creator != null) && <UserInfo author={page.creator} />}
-          <div className={`mb-5 ${isMobile ? `page-mobile ${styles['page-mobile']}` : ''}`}>
+          <div className={`flex-expand-vert ${mobileClass}`}>
             <Contents />
           </div>
         </>

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

+ 49 - 55
apps/app/src/components/PageComment.tsx

@@ -31,14 +31,13 @@ export type PageCommentProps = {
   revision: string | IRevisionHasId,
   currentUser: any,
   isReadOnly: boolean,
-  titleAlign?: 'center' | 'left' | 'right',
 }
 
 export const PageComment: FC<PageCommentProps> = memo((props: PageCommentProps): JSX.Element => {
 
   const {
     rendererOptions: rendererOptionsByProps,
-    pageId, pagePath, revision, currentUser, isReadOnly, titleAlign,
+    pageId, pagePath, revision, currentUser, isReadOnly,
   } = props;
 
   const { data: comments, mutate } = useSWRxPageComment(pageId);
@@ -112,9 +111,6 @@ export const PageComment: FC<PageCommentProps> = memo((props: PageCommentProps):
     return <></>;
   }
 
-  let commentTitleClasses = 'border-bottom py-3 mb-3';
-  commentTitleClasses = titleAlign != null ? `${commentTitleClasses} text-${titleAlign}` : `${commentTitleClasses} text-center`;
-
   const rendererOptions = rendererOptionsByProps ?? rendererOptionsForCurrentPage;
 
   if (commentsFromOldest == null || commentsExceptReply == null || rendererOptions == null) {
@@ -156,58 +152,56 @@ export const PageComment: FC<PageCommentProps> = memo((props: PageCommentProps):
 
   return (
     <div className={`${styles['page-comment-styles']} page-comments-row comment-list`}>
-      <div className="container-lg">
-        <div className="page-comments">
-          <h2 className={commentTitleClasses}><span className="material-symbols-outlined">bubble_chart</span>Comments</h2>
-          <div className="page-comments-list" id="page-comments-list">
-            {commentsExceptReply.map((comment) => {
-
-              const defaultCommentThreadClasses = 'page-comment-thread pb-5';
-              const hasReply: boolean = Object.keys(allReplies).includes(comment._id);
-
-              let commentThreadClasses = '';
-              commentThreadClasses = hasReply ? `${defaultCommentThreadClasses} page-comment-thread-no-replies` : defaultCommentThreadClasses;
-
-              return (
-                <div key={comment._id} className={commentThreadClasses}>
-                  {commentElement(comment)}
-                  {hasReply && replyCommentsElement(allReplies[comment._id])}
-                  {(!isReadOnly && !showEditorIds.has(comment._id)) && (
-                    <div className="d-flex flex-row-reverse">
-                      <NotAvailableForGuest>
-                        <NotAvailableForReadOnlyUser>
-                          <Button
-                            data-testid="comment-reply-button"
-                            outline
-                            color="secondary"
-                            size="sm"
-                            className="btn-comment-reply"
-                            onClick={() => onReplyButtonClickHandler(comment._id)}
-                          >
-                            <span className="material-symbols-outlined">replay</span> Reply
-                          </Button>
-                        </NotAvailableForReadOnlyUser>
-                      </NotAvailableForGuest>
-                    </div>
-                  )}
-                  {(!isReadOnly && showEditorIds.has(comment._id)) && (
-                    <CommentEditor
-                      pageId={pageId}
-                      replyTo={comment._id}
-                      onCancelButtonClicked={() => {
-                        removeShowEditorId(comment._id);
-                      }}
-                      onCommentButtonClicked={() => onCommentButtonClickHandler(comment._id)}
-                      revisionId={revisionId}
-                    />
-                  )}
-                </div>
-              );
-
-            })}
-          </div>
+      <div className="page-comments">
+        <div className="page-comments-list" id="page-comments-list">
+          {commentsExceptReply.map((comment) => {
+
+            const defaultCommentThreadClasses = 'page-comment-thread pb-5';
+            const hasReply: boolean = Object.keys(allReplies).includes(comment._id);
+
+            let commentThreadClasses = '';
+            commentThreadClasses = hasReply ? `${defaultCommentThreadClasses} page-comment-thread-no-replies` : defaultCommentThreadClasses;
+
+            return (
+              <div key={comment._id} className={commentThreadClasses}>
+                {commentElement(comment)}
+                {hasReply && replyCommentsElement(allReplies[comment._id])}
+                {(!isReadOnly && !showEditorIds.has(comment._id)) && (
+                  <div className="d-flex flex-row-reverse">
+                    <NotAvailableForGuest>
+                      <NotAvailableForReadOnlyUser>
+                        <Button
+                          data-testid="comment-reply-button"
+                          outline
+                          color="secondary"
+                          size="sm"
+                          className="btn-comment-reply"
+                          onClick={() => onReplyButtonClickHandler(comment._id)}
+                        >
+                          <span className="material-symbols-outlined">replay</span> Reply
+                        </Button>
+                      </NotAvailableForReadOnlyUser>
+                    </NotAvailableForGuest>
+                  </div>
+                )}
+                {(!isReadOnly && showEditorIds.has(comment._id)) && (
+                  <CommentEditor
+                    pageId={pageId}
+                    replyTo={comment._id}
+                    onCancelButtonClicked={() => {
+                      removeShowEditorId(comment._id);
+                    }}
+                    onCommentButtonClicked={() => onCommentButtonClickHandler(comment._id)}
+                    revisionId={revisionId}
+                  />
+                )}
+              </div>
+            );
+
+          })}
         </div>
       </div>
+
       {!isReadOnly && (
         <DeleteCommentModal
           isShown={isDeleteConfirmModalShown}

+ 5 - 5
apps/app/src/components/PageComment/CommentEditor.tsx

@@ -16,7 +16,7 @@ import { IEditorMethods } from '~/interfaces/editor-methods';
 import { useSWRxPageComment, useSWRxEditingCommentsNum } from '~/stores/comment';
 import {
   useCurrentUser, useIsSlackConfigured,
-  useIsUploadableFile, useIsUploadableImage,
+  useIsUploadAllFileAllowed, useIsUploadEnabled,
 } from '~/stores/context';
 import { useSWRxSlackChannels, useIsSlackEnabled, useIsEnabledUnsavedWarning } from '~/stores/editor';
 import { useCurrentPagePath } from '~/stores/page';
@@ -71,8 +71,8 @@ export const CommentEditor = (props: CommentEditorProps): JSX.Element => {
   const { data: isSlackEnabled, mutate: mutateIsSlackEnabled } = useIsSlackEnabled();
   const { data: slackChannelsData } = useSWRxSlackChannels(currentPagePath);
   const { data: isSlackConfigured } = useIsSlackConfigured();
-  const { data: isUploadableFile } = useIsUploadableFile();
-  const { data: isUploadableImage } = useIsUploadableImage();
+  const { data: isUploadAllFileAllowed } = useIsUploadAllFileAllowed();
+  const { data: isUploadEnabled } = useIsUploadEnabled();
   const { mutate: mutateIsEnabledUnsavedWarning } = useIsEnabledUnsavedWarning();
   const {
     increment: incrementEditingCommentsNum,
@@ -303,7 +303,7 @@ export const CommentEditor = (props: CommentEditorProps): JSX.Element => {
       </Button>
     );
 
-    const isUploadable = isUploadableImage || isUploadableFile;
+    const isUploadable = isUploadEnabled || isUploadAllFileAllowed;
 
     return (
       <>
@@ -315,7 +315,7 @@ export const CommentEditor = (props: CommentEditorProps): JSX.Element => {
                 ref={editorRef}
                 value={commentBody ?? ''} // DO NOT use state
                 isUploadable={isUploadable}
-                isUploadableFile={isUploadableFile}
+                isUploadAllFileAllowed={isUploadAllFileAllowed}
                 onChange={onChangeHandler}
                 onUpload={uploadHandler}
                 onCtrlEnter={ctrlEnterHandler}

+ 4 - 4
apps/app/src/components/PageCreateModal.jsx

@@ -281,16 +281,16 @@ const PageCreateModal = () => {
               <DropdownToggle id="template-type" caret>
                 {template == null && t('template.option_label.select')}
                 {template === 'children' && t('template.children.label')}
-                {template === 'decendants' && t('template.decendants.label')}
+                {template === 'descendants' && t('template.descendants.label')}
               </DropdownToggle>
               <DropdownMenu>
                 <DropdownItem onClick={() => onChangeTemplateHandler('children')}>
                   {t('template.children.label')} (_template)<br className="d-block d-md-none" />
                   <small className="text-muted text-wrap">- {t('template.children.desc')}</small>
                 </DropdownItem>
-                <DropdownItem onClick={() => onChangeTemplateHandler('decendants')}>
-                  {t('template.decendants.label')} (__template) <br className="d-block d-md-none" />
-                  <small className="text-muted">- {t('template.decendants.desc')}</small>
+                <DropdownItem onClick={() => onChangeTemplateHandler('descendants')}>
+                  {t('template.descendants.label')} (__template) <br className="d-block d-md-none" />
+                  <small className="text-muted">- {t('template.descendants.desc')}</small>
                 </DropdownItem>
               </DropdownMenu>
             </UncontrolledButtonDropdown>

+ 0 - 106
apps/app/src/components/PageEditor/Editor.module.scss

@@ -4,112 +4,6 @@
 
 
 .editor-container :global {
-  // overlay in .editor-container
-  .overlay {
-    position: absolute;
-    top: 0;
-    right: 0;
-    bottom: 0;
-    left: 0;
-    z-index: 7; // forward than .CodeMirror-vscrollbar
-    display: flex;
-    align-items: center;
-    justify-content: center;
-  }
-
-  // loading keymap
-  @include ms.overlay-processing-style(overlay-loading-keymap, 2.5em, 0.3em);
-
-  // for Dropzone
-  .dropzone {
-    position: relative; // against .overlay position: absolute
-
-    @include ms.overlay-processing-style(overlay-dropzone-active, 2.5em, 0.5em);
-
-    // unuploadable or rejected
-    &.dropzone-unuploadable,
-    &.dropzone-rejected {
-      .overlay.overlay-dropzone-active {
-        background: rgba(200, 200, 200, 0.8);
-
-        .overlay-content {
-          color: bs.$gray-300;
-        }
-      }
-    }
-
-    // uploading
-    &.dropzone-uploading {
-      @include ms.overlay-processing-style(overlay-dropzone-active, 2.5em, 0.5em);
-    }
-
-    // unuploadable
-    &.dropzone-unuploadable {
-      .overlay.overlay-dropzone-active {
-        .overlay-content {
-          // insert content
-          @include ms.insertSimpleLineIcons('\e617'); // icon-exclamation
-
-          &:after {
-            content: 'File uploading is disabled';
-          }
-        }
-      }
-    }
-
-    // uploadable
-    &.dropzone-uploadable {
-      // accepted
-      &.dropzone-accepted:not(.dropzone-rejected) {
-        .overlay.overlay-dropzone-active {
-          border: 4px dashed bs.$gray-300;
-
-          .overlay-content {
-            // insert content
-            @include ms.insertSimpleLineIcons('\e084'); // icon-cloud-upload
-
-            &:after {
-              content: 'Drop here to upload';
-            }
-
-            // style
-            color: bs.$secondary;
-            background: rgba(200, 200, 200, 0.8);
-          }
-        }
-      }
-
-      // file type mismatch
-      &.dropzone-rejected:not(.dropzone-uploadablefile) {
-        .overlay.overlay-dropzone-active {
-          .overlay-content {
-            // insert content
-            @include ms.insertSimpleLineIcons('\e032'); // icon-picture
-
-            &:after {
-              content: 'Only an image file is allowed';
-            }
-          }
-        }
-      }
-
-      // multiple files
-      &.dropzone-accepted.dropzone-rejected {
-        .overlay.overlay-dropzone-active {
-          .overlay-content {
-            // insert content
-            @include ms.insertSimpleLineIcons('\e617'); // icon-exclamation
-
-            &:after {
-              content: 'Only 1 file is allowed';
-            }
-          }
-        }
-      }
-    }
-
-    /* end of.dropzone */
-  }
 
   .btn.btn-open-dropzone {
     z-index: 2;

+ 6 - 6
apps/app/src/components/PageEditor/Editor.tsx

@@ -32,7 +32,7 @@ export type EditorPropsType = {
   isGfmMode?: boolean,
   noCdn?: boolean,
   isUploadable?: boolean,
-  isUploadableFile?: boolean,
+  isUploadAllFileAllowed?: boolean,
   onChange?: (newValue: string, isClean?: boolean) => void,
   onUpload?: (file) => void,
   editorSettings?: IEditorSettings,
@@ -54,7 +54,7 @@ type DropzoneRef = {
 
 const Editor: ForwardRefRenderFunction<IEditorMethods, EditorPropsType> = (props, ref): JSX.Element => {
   const {
-    onUpload, isUploadable, isUploadableFile, indentSize, isGfmMode = true,
+    onUpload, isUploadable, isUploadAllFileAllowed, indentSize, isGfmMode = true,
   } = props;
 
   const [dropzoneActive, setDropzoneActive] = useState(false);
@@ -121,7 +121,7 @@ const Editor: ForwardRefRenderFunction<IEditorMethods, EditorPropsType> = (props
   const getAcceptableType = useCallback(() => {
     let accept = 'null'; // reject all
     if (isUploadable) {
-      if (!isUploadableFile) {
+      if (!isUploadAllFileAllowed) {
         accept = 'image/*'; // image only
       }
       else {
@@ -130,7 +130,7 @@ const Editor: ForwardRefRenderFunction<IEditorMethods, EditorPropsType> = (props
     }
 
     return accept;
-  }, [isUploadable, isUploadableFile]);
+  }, [isUploadable, isUploadAllFileAllowed]);
 
   const pasteFilesHandler = useCallback((event) => {
     const items = event.clipboardData.items || event.clipboardData.files || [];
@@ -191,7 +191,7 @@ const Editor: ForwardRefRenderFunction<IEditorMethods, EditorPropsType> = (props
     else {
       className += ' dropzone-uploadable';
 
-      if (isUploadableFile) {
+      if (isUploadAllFileAllowed) {
         className += ' dropzone-uploadablefile';
       }
     }
@@ -210,7 +210,7 @@ const Editor: ForwardRefRenderFunction<IEditorMethods, EditorPropsType> = (props
     }
 
     return className;
-  }, [isUploadable, isUploading, isUploadableFile]);
+  }, [isUploadable, isUploading, isUploadAllFileAllowed]);
 
   const renderDropzoneOverlay = useCallback(() => {
     return (

+ 16 - 14
apps/app/src/components/PageEditor/PageEditor.tsx

@@ -24,7 +24,7 @@ import { SocketEventName } from '~/interfaces/websocket';
 import {
   useDefaultIndentSize,
   useCurrentPathname, useIsEnabledAttachTitleHeader,
-  useIsEditable, useIsUploadableFile, useIsUploadableImage, useIsIndentSizeForced,
+  useIsEditable, useIsUploadAllFileAllowed, useIsUploadEnabled, useIsIndentSizeForced,
 } from '~/stores/context';
 import {
   useCurrentIndentSize, useIsSlackEnabled, usePageTagsForEditors,
@@ -98,7 +98,7 @@ export const PageEditor = React.memo((props: Props): JSX.Element => {
   const { data: currentPage } = useSWRxCurrentPage();
   const { trigger: mutateCurrentPage } = useSWRMUTxCurrentPage();
   const { data: grantData } = useSelectedGrant();
-  const { data: pageTags, sync: syncTagsInfoForEditor } = usePageTagsForEditors(pageId);
+  const { sync: syncTagsInfoForEditor } = usePageTagsForEditors(pageId);
   const { mutate: mutateTagsInfo } = useSWRxTagsInfo(pageId);
   const { data: editingMarkdown, mutate: mutateEditingMarkdown } = useEditingMarkdown();
   const { data: isEnabledAttachTitleHeader } = useIsEnabledAttachTitleHeader();
@@ -110,8 +110,8 @@ export const PageEditor = React.memo((props: Props): JSX.Element => {
   const { data: isIndentSizeForced } = useIsIndentSizeForced();
   const { data: currentIndentSize, mutate: mutateCurrentIndentSize } = useCurrentIndentSize();
   const { data: defaultIndentSize } = useDefaultIndentSize();
-  const { data: isUploadableFile } = useIsUploadableFile();
-  const { data: isUploadableImage } = useIsUploadableImage();
+  const { data: isUploadAllFileAllowed } = useIsUploadAllFileAllowed();
+  const { data: isUploadEnabled } = useIsUploadEnabled();
   const { data: conflictDiffModalStatus, close: closeConflictDiffModal } = useConflictDiffModal();
   const { mutate: mutateIsLatestRevision } = useIsLatestRevision();
   const { mutate: mutateRemotePageId } = useRemoteRevisionId();
@@ -214,16 +214,18 @@ 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,
-      pageTags: pageTags ?? [],
-      grantUserGroupId: grantData.grantedGroup?.id,
-      grantUserGroupName: grantData.grantedGroup?.name,
+      // pageTags: pageTags ?? [],
+      grantUserGroupIds: grantedGroups,
     };
     return optionsToSave;
-  }, [grantData, isSlackEnabled, pageTags]);
+  }, [grantData, isSlackEnabled]);
 
 
   const save = useCallback(async(opts?: {slackChannels: string, overwriteScopesOfDescendants?: boolean}): Promise<IPageHasId | null> => {
@@ -357,14 +359,14 @@ export const PageEditor = React.memo((props: Props): JSX.Element => {
   }, [codeMirrorEditor, currentPagePath, pageId]);
 
   const acceptedFileType = useMemo(() => {
-    if (!isUploadableFile) {
+    if (!isUploadEnabled) {
       return AcceptedUploadFileType.NONE;
     }
-    if (isUploadableImage) {
-      return AcceptedUploadFileType.IMAGE;
+    if (isUploadAllFileAllowed) {
+      return AcceptedUploadFileType.ALL;
     }
-    return AcceptedUploadFileType.ALL;
-  }, [isUploadableFile, isUploadableImage]);
+    return AcceptedUploadFileType.IMAGE;
+  }, [isUploadAllFileAllowed, isUploadEnabled]);
 
   const scrollPreviewByEditorLine = useCallback((line: number) => {
     if (previewRef.current == null) {
@@ -573,7 +575,7 @@ export const PageEditor = React.memo((props: Props): JSX.Element => {
             ref={editorRef}
             value={initialValue}
             isUploadable={isUploadable}
-            isUploadableFile={isUploadableFile}
+            isUploadAllFileAllowed={isUploadAllFileAllowed}
             indentSize={currentIndentSize}
             onScroll={editorScrolledHandler}
             onScrollCursorIntoView={editorScrollCursorIntoViewHandler}

+ 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}>
                 <span className="material-symbols-outlined">delete</span>
               </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 };
+};

+ 1 - 1
apps/app/src/components/Sidebar/PageCreateButton/DropendMenu.tsx

@@ -60,7 +60,7 @@ export const DropendMenu = React.memo((props: DropendMenuProps): JSX.Element =>
           onClick={onClickTemplateForDescendantsButtonHandler}
           type="button"
         >
-          {t('create_page_dropdown.template.decendants')}
+          {t('create_page_dropdown.template.descendants')}
         </button>
       </li>
     </ul>

+ 11 - 14
apps/app/src/components/Sidebar/PageCreateButton/PageCreateButton.tsx

@@ -10,8 +10,8 @@ import { useCurrentUser } from '~/stores/context';
 import { useSWRxCurrentPage } from '~/stores/page';
 import loggerFactory from '~/utils/logger';
 
-import { DropendMenu } from './DropendMenu';
 import { CreateButton } from './CreateButton';
+import { DropendMenu } from './DropendMenu';
 import { DropendToggle } from './DropendToggle';
 
 const logger = loggerFactory('growi:cli:PageCreateButton');
@@ -49,9 +49,9 @@ export const PageCreateButton = React.memo((): JSX.Element => {
       const params = {
         isSlackEnabled: false,
         slackChannels: '',
-        grant: currentPage?.grant || 1,
-        pageTags: [],
-        grantUserGroupId: currentPage?.grantedGroup?._id,
+        grant: 4,
+        // grant: currentPage?.grant || 1,
+        // grantUserGroupId: currentPage?.grantedGroup?._id,
         shouldGeneratePath: true,
       };
 
@@ -81,8 +81,7 @@ export const PageCreateButton = React.memo((): JSX.Element => {
       const params = {
         isSlackEnabled: false,
         slackChannels: '',
-        grant: 1,
-        pageTags: [],
+        grant: 4,
       };
 
       const res = await exist(JSON.stringify([todaysPath]));
@@ -114,9 +113,9 @@ export const PageCreateButton = React.memo((): JSX.Element => {
       const params = {
         isSlackEnabled: false,
         slackChannels: '',
-        grant: currentPage?.grant || 1,
-        pageTags: [],
-        grantUserGroupId: currentPage?.grantedGroup?._id,
+        grant: 4,
+        // grant: currentPage?.grant || 1,
+        // grantUserGroupId: currentPage?.grantedGroup?._id,
       };
 
       const res = await exist(JSON.stringify([path]));
@@ -148,9 +147,9 @@ export const PageCreateButton = React.memo((): JSX.Element => {
       const params = {
         isSlackEnabled: false,
         slackChannels: '',
-        grant: currentPage?.grant || 1,
-        pageTags: [],
-        grantUserGroupId: currentPage?.grantedGroup?._id,
+        grant: 4,
+        // grant: currentPage?.grant || 1,
+        // grantUserGroupId: currentPage?.grantedGroup?._id,
       };
 
       const res = await exist(JSON.stringify([path]));
@@ -169,8 +168,6 @@ export const PageCreateButton = React.memo((): JSX.Element => {
     }
   }, [currentPage, isLoading, router]);
 
-  // TODO: update button design
-  // https://redmine.weseek.co.jp/issues/132683
   return (
     <div
       className="d-flex flex-row"

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

@@ -201,7 +201,7 @@ export const Sidebar = (): JSX.Element => {
         </DrawerToggler>
       ) }
       { sidebarMode != null && !isDockMode() && <AppTitleOnSubnavigation /> }
-      <DrawableContainer className={`${grwSidebarClass} ${modeClass} border-end vh-100`} data-testid="grw-sidebar">
+      <DrawableContainer className={`${grwSidebarClass} ${modeClass} border-end flex-expand-vh-100`} data-testid="grw-sidebar">
         <ResizableContainer>
           { sidebarMode != null && !isCollapsedMode() && <AppTitleOnSidebarHead /> }
           <SidebarHead />

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

+ 2 - 2
apps/app/src/interfaces/editor-settings.ts

@@ -18,7 +18,7 @@ export interface IEditorSettings {
 
 export type EditorConfig = {
   upload: {
-    isUploadableFile: boolean,
-    isUploadableImage: boolean,
+    isUploadAllFileAllowed: boolean,
+    isUploadEnabled: boolean,
   }
 }

+ 2 - 2
apps/app/src/interfaces/in-app-notification.ts

@@ -10,10 +10,10 @@ export enum InAppNotificationStatuses {
 
 // TODO: do not use any type
 // https://redmine.weseek.co.jp/issues/120632
-export interface IInAppNotification {
+export interface IInAppNotification<T = unknown> {
   user: IUser
   targetModel: SupportedTargetModelType
-  target: any
+  target: T
   action: SupportedActionType
   status: InAppNotificationStatuses
   actionUsers: IUser[]

+ 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 - 4
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,8 +33,6 @@ export type OptionsToSave = {
   isSlackEnabled: boolean;
   slackChannels: string;
   grant: number;
-  pageTags: string[] | null;
-  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,

+ 2 - 0
apps/app/src/interfaces/template.ts

@@ -0,0 +1,2 @@
+export type TargetType = 'children' | 'descendants';
+export type LabelType = '_template' | '__template';

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

Unele fișiere nu au fost afișate deoarece prea multe fișiere au fost modificate în acest diff