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

Merge branch 'feat/ldap-group-sync' into feat/123280-130996-unuse-keycloak-admin-client-fork

Futa Arai 2 лет назад
Родитель
Сommit
926af3865d
30 измененных файлов с 625 добавлено и 461 удалено
  1. 1 1
      .github/release-drafter.yml
  2. 51 51
      .github/workflows/release.yml
  3. 19 19
      CHANGELOG.md
  4. 0 1
      apps/app/.env.development
  5. 1 3
      apps/app/package.json
  6. 6 8
      apps/app/public/static/locales/en_US/admin.json
  7. 6 7
      apps/app/public/static/locales/ja_JP/admin.json
  8. 6 7
      apps/app/public/static/locales/zh_CN/admin.json
  9. 2 2
      apps/app/src/components/Admin/Customize/CustomizePresentationSetting.tsx
  10. 3 2
      apps/app/src/components/Admin/ElasticsearchManagement/RebuildIndexControls.jsx
  11. 3 1
      apps/app/src/components/ReactMarkdownComponents/LightBox.tsx
  12. 6 21
      apps/app/src/features/external-user-group/client/components/ExternalUserGroup/KeycloakGroupManagement.tsx
  13. 30 38
      apps/app/src/features/external-user-group/client/components/ExternalUserGroup/LdapGroupManagement.tsx
  14. 127 0
      apps/app/src/features/external-user-group/client/components/ExternalUserGroup/SyncExecution.tsx
  15. 23 22
      apps/app/src/features/external-user-group/server/routes/apiv3/external-user-group.ts
  16. 112 25
      apps/app/src/features/external-user-group/server/service/external-user-group-sync.ts
  17. 25 6
      apps/app/src/features/external-user-group/server/service/keycloak-user-group-sync.ts
  18. 37 39
      apps/app/src/features/external-user-group/server/service/ldap-user-group-sync.ts
  19. 23 0
      apps/app/src/interfaces/websocket.ts
  20. 0 2
      apps/app/src/server/crowi/express-init.js
  21. 13 12
      apps/app/src/server/crowi/index.js
  22. 0 26
      apps/app/src/server/middlewares/promster.ts
  23. 17 2
      apps/app/src/server/routes/apiv3/page.js
  24. 0 12
      apps/app/src/server/service/config-loader.ts
  25. 25 17
      apps/app/src/server/service/ldap.ts
  26. 4 3
      apps/app/src/server/service/search-delegator/elasticsearch.ts
  27. 4 4
      apps/app/test/integration/service/external-user-group-sync.test.ts
  28. 11 12
      apps/app/test/integration/service/ldap-user-group-sync.test.ts
  29. 1 1
      packages/presentation/src/services/renderer/slides.ts
  30. 69 117
      yarn.lock

+ 1 - 1
.github/release-drafter.yml

@@ -7,7 +7,7 @@ filter-by-commitish: true
 categories:
   - title: 'BREAKING CHANGES'
     labels:
-      - 'type/reaking'
+      - 'type/breaking'
   - title: '💎 Features'
     labels:
       - 'type/feature'

+ 51 - 51
.github/workflows/release.yml

@@ -75,56 +75,6 @@ jobs:
         GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
 
 
-  create-pr-for-next-rc:
-    needs: create-github-release
-
-    runs-on: ubuntu-latest
-
-    steps:
-    - uses: actions/checkout@v3
-      with:
-        ref: v${{ needs.create-github-release.outputs.RELEASED_VERSION }}
-
-    - uses: actions/setup-node@v3
-      with:
-        node-version: '18'
-        cache: 'yarn'
-        cache-dependency-path: '**/yarn.lock'
-
-    - name: Install dependencies
-      run: |
-        yarn global add turbo
-        yarn --frozen-lockfile
-
-    - name: Bump versions for next RC
-      run: |
-        turbo run version --filter=@growi/app -- --prepatch
-        turbo run version --filter=@growi/slackbot-proxy -- --prepatch
-        yarn upgrade --scope=@growi
-
-    - name: Retrieve information from package.json
-      uses: myrotvorets/info-from-package-json-action@1.2.0
-      id: package-json
-
-    - name: Commit
-      uses: github-actions-x/commit@v2.9
-      with:
-        github-token: ${{ secrets.GITHUB_TOKEN }}
-        push-branch: support/prepare-v${{ steps.package-json.outputs.packageVersion }}
-        commit-message: 'Bump version'
-        name: GitHub Action
-
-    - name: Create PR
-      uses: repo-sync/pull-request@v2
-      with:
-        source_branch: support/prepare-v${{ steps.package-json.outputs.packageVersion }}
-        destination_branch: ${{ github.head_ref }}
-        pr_title: Prepare v${{ steps.package-json.outputs.packageVersion }}
-        pr_label: flag/exclude-from-changelog,type/prepare-next-version
-        pr_body: "[skip ci] An automated PR generated by create-pr-for-next-rc"
-        github_token: ${{ secrets.GITHUB_TOKEN }}
-
-
   determine-tags:
     needs: create-github-release
     runs-on: ubuntu-latest
@@ -203,7 +153,7 @@ jobs:
 
 
   post-publish:
-    needs: [create-github-release, publish-image, publish-image-ghcr]
+    needs: [publish-image, publish-image-ghcr]
     runs-on: ubuntu-latest
 
     steps:
@@ -230,3 +180,53 @@ jobs:
       run: |
         STATUS=`git status --porcelain`
         if [ -z "$STATUS" ]; then exit 0; else exit 1; fi
+
+
+
+  create-pr-for-next-rc:
+    needs: [publish-image, publish-image-ghcr]
+    runs-on: ubuntu-latest
+
+    steps:
+    - uses: actions/checkout@v3
+      with:
+        ref: v${{ needs.create-github-release.outputs.RELEASED_VERSION }}
+
+    - uses: actions/setup-node@v3
+      with:
+        node-version: '18'
+        cache: 'yarn'
+        cache-dependency-path: '**/yarn.lock'
+
+    - name: Install dependencies
+      run: |
+        yarn global add turbo
+        yarn --frozen-lockfile
+
+    - name: Bump versions for next RC
+      run: |
+        turbo run version --filter=@growi/app -- --prepatch
+        turbo run version --filter=@growi/slackbot-proxy -- --prepatch
+        yarn upgrade --scope=@growi
+
+    - name: Retrieve information from package.json
+      uses: myrotvorets/info-from-package-json-action@1.2.0
+      id: package-json
+
+    - name: Commit
+      uses: github-actions-x/commit@v2.9
+      with:
+        github-token: ${{ secrets.GITHUB_TOKEN }}
+        push-branch: support/prepare-v${{ steps.package-json.outputs.packageVersion }}
+        commit-message: 'Bump version'
+        name: GitHub Action
+
+    - name: Create PR
+      uses: repo-sync/pull-request@v2
+      with:
+        source_branch: support/prepare-v${{ steps.package-json.outputs.packageVersion }}
+        destination_branch: ${{ github.head_ref }}
+        pr_title: Prepare v${{ steps.package-json.outputs.packageVersion }}
+        pr_label: flag/exclude-from-changelog,type/prepare-next-version
+        pr_body: "[skip ci] An automated PR generated by create-pr-for-next-rc"
+        github_token: ${{ secrets.GITHUB_TOKEN }}

+ 19 - 19
CHANGELOG.md

@@ -4,25 +4,6 @@
 
 *Please do not manually update this file. We've automated the process.*
 
-## [v6.1.15](https://github.com/weseek/growi/compare/v6.1.14...v6.1.15) - 2023-09-11
-
-### 🚀 Improvement
-
-- imprv: Add CSP style-src for Safari and Content-Disposition of attachment (for v6.1.x) (#8057) @yuki-takei
-
-## [v6.1.14](https://github.com/weseek/growi/compare/v6.1.13...v6.1.14) - 2023-08-22
-
-### 🐛 Bug Fixes
-
-- fix: Add option to lightbox (6.1.x) (#8003) @yuki-takei
-
-## [v6.1.13](https://github.com/weseek/growi/compare/v6.1.12...v6.1.13) - 2023-08-17
-
-### 🐛 Bug Fixes
-
-- fix: Do not work img tag if use style property (#7988) @jam411
-- fix: "Searching..." label appearing unnecessarily (#7990) @yuki-takei
-
 ## [v6.2.0](https://github.com/weseek/growi/compare/v6.1.12...v6.2.0) - 2023-09-14
 
 ### 💎 Features
@@ -58,6 +39,25 @@
 - support: Improve build settings (#7919) @yuki-takei
 - support: Url to join to the slack team (#8073) @WNomunomu
 
+## [v6.1.15](https://github.com/weseek/growi/compare/v6.1.14...v6.1.15) - 2023-09-11
+
+### 🚀 Improvement
+
+- imprv: Add CSP style-src for Safari and Content-Disposition of attachment (for v6.1.x) (#8057) @yuki-takei
+
+## [v6.1.14](https://github.com/weseek/growi/compare/v6.1.13...v6.1.14) - 2023-08-22
+
+### 🐛 Bug Fixes
+
+- fix: Add option to lightbox (6.1.x) (#8003) @yuki-takei
+
+## [v6.1.13](https://github.com/weseek/growi/compare/v6.1.12...v6.1.13) - 2023-08-17
+
+### 🐛 Bug Fixes
+
+- fix: Do not work img tag if use style property (#7988) @jam411
+- fix: "Searching..." label appearing unnecessarily (#7990) @yuki-takei
+
 ## [v6.1.12](https://github.com/weseek/growi/compare/v6.1.11...v6.1.12) - 2023-08-14
 
 ### 🐛 Bug Fixes

+ 0 - 1
apps/app/.env.development

@@ -23,7 +23,6 @@ QUESTIONNAIRE_SERVER_ORIGIN="http://host.docker.internal:3003"
 # USER_UPPER_LIMIT=0
 # DEV_HTTPS=true
 # FORCE_WIKI_MODE=private
-# PROMSTER_ENABLED=true
 # SLACKBOT_WITHOUT_PROXY_SIGNING_SECRET=''
 # SLACKBOT_WITHOUT_PROXY_BOT_TOKEN=''
 # GROWI_CLOUD_URI='http://growi.cloud'

+ 1 - 3
apps/app/package.json

@@ -53,7 +53,6 @@
     "escape-string-regexp": "5.0.0 or above exports only ESM",
     "string-width": "5.0.0 or above exports only ESM.",
     "@keycloak/keycloak-admin-client": "19.0.0 or above exports only ESM.",
-    "prom-client": "!!DO NOT REMOVE!! A peer dependency of @promster.",
     "remark-wiki-link": "!!DO NOT REMOVE!! including 'mdast-util-wiki-link' and 'micromark-extension-wiki-link' required by pukiwiki-like-linker"
   },
   "dependencies": {
@@ -75,9 +74,9 @@
     "@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",
     "@promster/express": "^7.0.6",
     "@promster/server": "^7.0.8",
+    "@s3pweb/keycloak-admin-client-cjs": "^22.0.1",
     "@slack/web-api": "^6.2.4",
     "@slack/webhook": "^6.0.0",
     "@types/jest": "^29.5.2",
@@ -158,7 +157,6 @@
     "passport-ldapauth": "^3.0.1",
     "passport-local": "^1.0.0",
     "passport-saml": "^3.2.0",
-    "prom-client": "^14.1.1",
     "qs": "^6.11.1",
     "rate-limiter-flexible": "^2.3.7",
     "react": "^18.2.0",

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

@@ -488,8 +488,8 @@
       "enable_marp_desc": "Marp can be used in presentation preview. This option may make you vulnerable to XSS.",
       "marp_official_site": "The Marp Official Site",
       "marp_official_site_link": "https://marp.app",
-      "marp_in_growi" : "GROWI Docs - Create slide using Marp",
-      "marp_in_growi_link": "https://docs.growi.org/en/guide/features/marp.html"
+      "presentation_docs" : "GROWI Docs - Create slides for a presentation",
+      "presentation_docs_link": "https://docs.growi.org/en/guide/features/presentation.html"
     },
     "custom_title": "Custom title",
     "custom_title_detail": "You can customize <code>&lt;title&gt;</code> tag. Following placeholders will be automatically replaced:",
@@ -1064,8 +1064,9 @@
     "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_succeeded": "Sync succeeded",
-    "sync_failed": "Sync failed",
+    "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",
     "ldap": {
       "group_sync_settings": "LDAP Group Sync Settings",
       "group_search_base_DN": "Group Search Base DN",
@@ -1080,10 +1081,7 @@
       "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",
-      "circular_reference": "Sync failed because there is a possible circular reference in your LDAP group tree structure",
-      "group_search_failed": "LDAP group search failed. Please check your LDAP security settings and group sync settings.",
-      "user_search_failed": "LDAP user search failed. Please check your LDAP security settings and group sync settings."
+      "password_detail": "Login password is necessary because Bind type is set to User Bind"
     },
     "keycloak": {
       "group_sync_settings": "Keycloak Group Sync Settings",

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

@@ -497,8 +497,8 @@
       "enable_marp_desc": "プレゼンテーション表示に Marp を利用できるようになります。ただし、XSS に対して脆弱になる恐れがあります。",
       "marp_official_site": "参考:Marp 公式サイト",
       "marp_official_site_link": "https://marp.app",
-      "marp_in_growi" : "参考:GROWI Docs - Marp でスライドを作成する",
-      "marp_in_growi_link": "https://docs.growi.org/ja/guide/features/marp.html"
+      "presentation_docs" : "参考:GROWI Docs - プレゼンテーション機能を使う",
+      "presentation_docs_link": "https://docs.growi.org/ja/guide/features/presentation.html"
     },
     "custom_title": "カスタム Title",
     "custom_title_detail": "<code>&lt;title&gt;</code>タグのコンテンツをカスタマイズできます。以下のプレースホルダーは自動的に置換されます:",
@@ -1073,7 +1073,9 @@
     "update_sync_settings_failed": "同期設定の更新が失敗しました",
     "description_form_detail": "同期設定で「説明」の mapper が設定されている場合、編集内容は再同期によって上書きされることに注意してください",
     "only_description_edit_allowed": "外部グループは説明の編集のみが可能です",
-    "sync_succeeded": "同期に成功しました",
+    "sync_being_executed": "自身または他のユーザが実行した外部グループ同期が終了するまで次の実行ができません",
+    "sync_succeeded": "外部グループ同期に成功しました",
+    "sync_failed": "外部グループ同期に失敗しました",
     "ldap": {
       "group_sync_settings": "LDAP グループ同期設定",
       "group_search_base_DN": "グループ検索ベース DN",
@@ -1088,10 +1090,7 @@
       "name_mapper_detail": "グループの「名前」として読み込む属性",
       "updated_group_sync_settings": "LDAP グループ同期設定を更新しました",
       "password": "パスワード",
-      "password_detail": "認証設定がユーザ Bind のため、ログイン時のパスワードの入力が必要となります",
-      "circular_reference": "LDAP グループの木構造に循環参照が行われている可能性があるため、同期に失敗しました",
-      "group_search_failed": "LDAP グループ検索に失敗しました。LDAP セキュリティ設定、グループ同期設定が正しいことを確認してください。",
-      "user_search_failed": "LDAP ユーザ検索に失敗しました。LDAP セキュリティ設定、グループ同期設定が正しいことを確認してください。"
+      "password_detail": "認証設定がユーザ Bind のため、ログイン時のパスワードの入力が必要となります"
     },
     "keycloak": {
       "group_sync_settings": "Keycloak グループ同期設定",

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

@@ -496,8 +496,8 @@
       "enable_marp_desc": "Marp 可在演示视图中使用。该选项可能会使您受到 XSS 的攻击。",
       "marp_official_site": "参考资料:Marp 官方网站",
       "marp_official_site_link": "https://marp.app",
-      "marp_in_growi" : "参考资料:GROWI Docs - Create slide using Marp",
-      "marp_in_growi_link": "https://docs.growi.org/en/guide/features/marp.html"
+      "presentation_docs" : "参考资料:GROWI Docs - Create slides for a presentation",
+      "presentation_docs_link": "https://docs.growi.org/en/guide/features/presentation.html"
     },
     "custom_title": "自定义标题",
     "custom_title_detail": "您可以自定义<code>&lt;title&gt;</code>标记。<br><code>&123;&123;sitename&&125;&125;</code>将自动替换为应用程序名称,并且<code>&123;&123;page&&125;&125;</code>将替换为页面名称/路径。",
@@ -1072,7 +1072,9 @@
     "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_succeeded": "Sync succeeded",
+    "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",
     "ldap": {
       "group_sync_settings": "LDAP Group Sync Settings",
       "group_search_base_DN": "Group Search Base DN",
@@ -1087,10 +1089,7 @@
       "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",
-      "circular_reference": "Sync failed because there is a possible circular reference in your LDAP group tree structure",
-      "group_search_failed": "LDAP group search failed. Please check your LDAP security settings and group sync settings.",
-      "user_search_failed": "LDAP user search failed. Please check your LDAP security settings and group sync settings."
+      "password_detail": "Login password is necessary because Bind type is set to User Bind"
     },
     "keycloak": {
       "group_sync_settings": "Keycloak Group Sync Settings",

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

@@ -51,10 +51,10 @@ const CustomizePresentationSetting = (props: Props): JSX.Element => {
               </a>
               <br></br>
               <a
-                href={`${t('admin:customize_settings.presentation_options.marp_in_gorwi_link')}`}
+                href={`${t('admin:customize_settings.presentation_options.presentation_docs_link')}`}
                 target="_blank"
                 rel="noopener noreferrer"
-              >{`${t('admin:customize_settings.presenattion_options.marp_in_growi')}`}
+              >{`${t('admin:customize_settings.presentation_options.presentation_docs')}`}
               </a>
             </p>
           </CustomizePresentationOption>

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

+ 3 - 1
apps/app/src/components/ReactMarkdownComponents/LightBox.tsx

@@ -4,9 +4,11 @@ import FsLightbox from 'fslightbox-react';
 
 export const LightBox = (props) => {
   const [toggler, setToggler] = useState(false);
+  const { node, ...rest } = props;
+
   return (
     <>
-      <img {...props.node.properties} onClick={() => setToggler(!toggler)} />
+      <img {...rest} onClick={() => setToggler(!toggler)} />
       <FsLightbox
         toggler={toggler}
         sources={[props.src]}

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

@@ -1,36 +1,21 @@
 import { FC, useCallback } from 'react';
 
-import { useTranslation } from 'react-i18next';
-
 import { apiv3Put } from '~/client/util/apiv3-client';
-import { toastError, toastSuccess } from '~/client/util/toastr';
+import { ExternalGroupProviderType } from '~/features/external-user-group/interfaces/external-user-group';
 
 import { KeycloakGroupSyncSettingsForm } from './KeycloakGroupSyncSettingsForm';
+import { SyncExecution } from './SyncExecution';
 
 export const KeycloakGroupManagement: FC = () => {
-  const { t } = useTranslation('admin');
 
-  const onSyncBtnClick = useCallback(async(e) => {
-    e.preventDefault();
-    try {
-      await apiv3Put('/external-user-groups/keycloak/sync');
-      toastSuccess(t('external_user_group.sync_succeeded'));
-    }
-    catch (errs) {
-      toastError(t(errs[0]?.code));
-    }
-  }, [t]);
+  const requestSyncAPI = useCallback(async() => {
+    await apiv3Put('/external-user-groups/keycloak/sync');
+  }, []);
 
   return (
     <>
       <KeycloakGroupSyncSettingsForm />
-      <h3 className="border-bottom mb-3">{t('external_user_group.execute_sync')}</h3>
-      <form onSubmit={onSyncBtnClick}>
-        <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>
+      <SyncExecution provider={ExternalGroupProviderType.keycloak} requestSyncAPI={requestSyncAPI} />
     </>
   );
 };

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

@@ -5,9 +5,11 @@ import {
 import { useTranslation } from 'react-i18next';
 
 import { apiv3Get, apiv3Put } from '~/client/util/apiv3-client';
-import { toastError, toastSuccess } from '~/client/util/toastr';
+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);
@@ -27,49 +29,39 @@ export const LdapGroupManagement: FC = () => {
     getIsUserBind();
   }, []);
 
-  const onSyncBtnClick = useCallback(async(e) => {
-    e.preventDefault();
-    try {
-      if (isUserBind) {
-        const password = e.target.password.value;
-        await apiv3Put('/external-user-groups/ldap/sync', { password });
-      }
-      else {
-        await apiv3Put('/external-user-groups/ldap/sync');
-      }
-      toastSuccess(t('external_user_group.sync_succeeded'));
+  const requestSyncAPI = useCallback(async(e) => {
+    if (isUserBind) {
+      const password = e.target.password.value;
+      await apiv3Put('/external-user-groups/ldap/sync', { password });
     }
-    catch (errs) {
-      toastError(t(errs[0]?.message));
+    else {
+      await apiv3Put('/external-user-groups/ldap/sync');
     }
-  }, [t, isUserBind]);
+  }, [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 />
-      <h3 className="border-bottom mb-3">{t('external_user_group.execute_sync')}</h3>
-      <form onSubmit={onSyncBtnClick}>
-        {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>
-        )}
-        <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>
+      <SyncExecution provider={ExternalGroupProviderType.ldap} requestSyncAPI={requestSyncAPI} AdditionalForm={AdditionalForm} />
     </>
   );
 };

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

@@ -0,0 +1,127 @@
+import {
+  FC, useCallback, useEffect, useState,
+} from 'react';
+
+import { useTranslation } from 'react-i18next';
+
+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) => 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,
+  });
+
+  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]);
+
+  const onSyncBtnClick = useCallback(async(e) => {
+    e.preventDefault();
+    try {
+      await requestSyncAPI(e);
+      setProgress({ total: 0, current: 0 });
+      setSyncStatus(SyncStatus.syncExecuting);
+    }
+    catch (errs) {
+      toastError(t(errs[0]?.code));
+    }
+  }, [t, requestSyncAPI]);
+
+  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>
+    </>
+  );
+};

+ 23 - 22
apps/app/src/features/external-user-group/server/routes/apiv3/external-user-group.ts

@@ -17,9 +17,6 @@ import { configManager } from '~/server/service/config-manager';
 import UserGroupService from '~/server/service/user-group';
 import loggerFactory from '~/utils/logger';
 
-import KeycloakUserGroupSyncService from '../../service/keycloak-user-group-sync';
-import LdapUserGroupSyncService from '../../service/ldap-user-group-sync';
-
 const logger = loggerFactory('growi:routes:apiv3:external-user-group');
 
 const router = Router();
@@ -35,6 +32,10 @@ module.exports = (crowi: Crowi): Router => {
 
   const activityEvent = crowi.event('activity');
 
+  const isExecutingSync = () => {
+    return crowi.ldapUserGroupSyncService?.isExecutingSync || crowi.keycloakUserGroupSyncService?.isExecutingSync || false;
+  };
+
   const validators = {
     ldapSyncSettings: [
       body('ldapGroupSearchBase').optional({ nullable: true }).isString(),
@@ -307,19 +308,26 @@ module.exports = (crowi: Crowi): Router => {
     });
 
   router.put('/ldap/sync', loginRequiredStrictly, adminRequired, async(req: AuthorizedRequest, res: ApiV3Response) => {
-    try {
-      const ldapUserGroupSyncService = new LdapUserGroupSyncService(crowi.passportService, req.user.name, req.body.password);
-      await ldapUserGroupSyncService.syncExternalUserGroups();
-    }
-    catch (err) {
-      logger.error(err);
-      return res.apiv3Err(err.message, 500);
+    if (isExecutingSync()) {
+      return res.apiv3Err(
+        new ErrorV3('There is an ongoing sync process', 'external_user_group.sync_being_executed'), 409,
+      );
     }
 
-    return res.apiv3({}, 204);
+    // Do not await for sync to finish. Result (completed, failed) will be notified to the client by socket-io.
+    await crowi.ldapUserGroupSyncService?.init(req.user.name, req.body.password);
+    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');
@@ -348,18 +356,11 @@ module.exports = (crowi: Crowi): Router => {
       );
     }
 
-    try {
-      const keycloakUserGroupSyncService = new KeycloakUserGroupSyncService(authProviderType);
-      await keycloakUserGroupSyncService.syncExternalUserGroups();
-    }
-    catch (err) {
-      logger.error(err);
-      return res.apiv3Err(
-        new ErrorV3('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.keycloakUserGroupSyncService?.init(authProviderType);
+    crowi.keycloakUserGroupSyncService?.syncExternalUserGroups();
 
-    return res.apiv3({}, 204);
+    return res.apiv3({}, 202);
   });
 
   return router;

+ 112 - 25
apps/app/src/features/external-user-group/server/service/external-user-group-sync.ts

@@ -1,7 +1,12 @@
 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';
@@ -12,21 +17,69 @@ import {
 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;
 
-abstract class ExternalUserGroupSyncService {
+class ExternalUserGroupSyncS2sMessage extends S2sMessage {
+
+  isExecutingSync: boolean;
+
+}
+
+abstract class ExternalUserGroupSyncService implements S2sMessageHandlable {
 
   groupProviderType: ExternalGroupProviderType; // name of external service that contains user group info (e.g: ldap, keycloak)
 
-  authProviderType: string; // auth provider type (e.g: ldap, oidc)
+  authProviderType: string | null; // auth provider type (e.g: ldap, oidc). Has to be set before syncExternalUserGroups execution.
 
-  constructor(groupProviderType: ExternalGroupProviderType, authProviderType: string) {
+  socketIoService: any;
+
+  s2sMessagingService: S2sMessagingService | null;
+
+  isExecutingSync = false;
+
+  // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
+  constructor(groupProviderType: ExternalGroupProviderType, s2sMessagingService: S2sMessagingService | null, socketIoService) {
     this.groupProviderType = groupProviderType;
-    this.authProviderType = authProviderType;
+    this.s2sMessagingService = s2sMessagingService;
+    this.socketIoService = socketIoService;
+  }
+
+  /**
+   * @inheritdoc
+   */
+  shouldHandleS2sMessage(s2sMessage: ExternalUserGroupSyncS2sMessage): boolean {
+    return s2sMessage.eventName === 'switchExternalUserGroupExecSyncStatus';
+  }
+
+  /**
+   * @inheritdoc
+   */
+  async handleS2sMessage(s2sMessage: ExternalUserGroupSyncS2sMessage): Promise<void> {
+    logger.info(`Set isExecutingSync to ${s2sMessage.isExecutingSync} by pubsub notification`);
+    this.isExecutingSync = s2sMessage.isExecutingSync;
+  }
+
+  async switchIsExecutingSync(isExecutingSync: boolean): Promise<void> {
+    this.isExecutingSync = isExecutingSync;
+
+    if (this.s2sMessagingService != null) {
+      const s2sMessage = new ExternalUserGroupSyncS2sMessage('switchExternalUserGroupExecSyncStatus', {
+        isExecutingSync,
+      });
+
+      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
@@ -35,28 +88,49 @@ abstract class ExternalUserGroupSyncService {
    * 3. If preserveDeletedLDAPGroups is false、delete all ExternalUserGroups that were not found during tree search
   */
   async syncExternalUserGroups(): Promise<void> {
-    const trees = await this.generateExternalUserGroupTrees();
+    if (this.authProviderType == null) throw new Error('auth provider type is not set');
+    if (this.isExecutingSync) throw new Error('External user group sync is already being executed');
+    await this.switchIsExecutingSync(true);
 
+    const preserveDeletedLdapGroups: boolean = configManager?.getConfig('crowi', `external-user-group:${this.groupProviderType}:preserveDeletedGroups`);
     const existingExternalUserGroupIds: string[] = [];
 
-    const syncNode = async(node: ExternalUserGroupTreeNode, parentId?: string) => {
-      const externalUserGroup = await this.createUpdateExternalUserGroup(node, parentId);
-      existingExternalUserGroupIds.push(externalUserGroup._id);
-      // 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);
+    const socket = this.socketIoService?.getAdminSocket();
+
+    try {
+      const trees = await this.generateExternalUserGroupTrees();
+      const totalCount = trees.map(tree => this.getGroupCountOfTree(tree))
+        .reduce((sum, current) => sum + current);
+      let count = 0;
+
+      const syncNode = async(node: ExternalUserGroupTreeNode, parentId?: string) => {
+        const externalUserGroup = await this.createUpdateExternalUserGroup(node, parentId);
+        existingExternalUserGroupIds.push(externalUserGroup._id);
+        count++;
+        socket?.emit(SocketEventName.externalUserGroup[this.groupProviderType].GroupSyncProgress, { totalCount, 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);
+        }
+      };
+
+      await batchProcessPromiseAll(trees, TREES_BATCH_SIZE, async(tree) => {
+        return syncNode(tree);
+      });
+
+      if (!preserveDeletedLdapGroups) {
+        await ExternalUserGroup.deleteMany({ _id: { $nin: existingExternalUserGroupIds }, groupProviderType: this.groupProviderType });
+        await ExternalUserGroupRelation.removeAllInvalidRelations();
       }
-    };
-
-    await batchProcessPromiseAll(trees, TREES_BATCH_SIZE, (root) => {
-      return syncNode(root);
-    });
-
-    const preserveDeletedLdapGroups: boolean = configManager?.getConfig('crowi', `external-user-group:${this.groupProviderType}:preserveDeletedGroups`);
-    if (!preserveDeletedLdapGroups) {
-      await ExternalUserGroup.deleteMany({ _id: { $nin: existingExternalUserGroupIds }, groupProviderType: 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.switchIsExecutingSync(false);
     }
   }
 
@@ -68,7 +142,7 @@ abstract class ExternalUserGroupSyncService {
    * @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
   */
-  async createUpdateExternalUserGroup(node: ExternalUserGroupTreeNode, parentId?: string): Promise<IExternalUserGroupHasId> {
+  private async createUpdateExternalUserGroup(node: ExternalUserGroupTreeNode, parentId?: string): Promise<IExternalUserGroupHasId> {
     const externalUserGroup = await ExternalUserGroup.findAndUpdateOrCreateGroup(
       node.name, node.id, this.groupProviderType, node.description, parentId,
     );
@@ -97,14 +171,17 @@ abstract class ExternalUserGroupSyncService {
    * @param {ExternalUserInfo} externalUserInfo Search external app/server using this identifier
    * @returns {Promise<IUserHasId | null>} User when found or created, null when neither
    */
-  async getMemberUser(userInfo: ExternalUserInfo): Promise<IUserHasId | null> {
+  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,
-        }, this.authProviderType);
+        }, authProviderType);
       }
       return ExternalAccount.findOne({ providerType: this.groupProviderType, accountId: userInfo.id });
     };
@@ -117,6 +194,16 @@ abstract class ExternalUserGroupSyncService {
     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

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

@@ -3,18 +3,22 @@ import GroupRepresentation from '@keycloak/keycloak-admin-client/lib/defs/groupR
 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;
 
-class KeycloakUserGroupSyncService extends ExternalUserGroupSyncService {
+export class KeycloakUserGroupSyncService extends ExternalUserGroupSyncService {
 
   kcAdminClient: KeycloakAdminClient;
 
@@ -22,19 +26,36 @@ class KeycloakUserGroupSyncService extends ExternalUserGroupSyncService {
 
   groupDescriptionAttribute: string; // attribute to map to group description
 
-  constructor(authProviderType: string) {
+  isInitialized = false;
+
+  // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
+  constructor(s2sMessagingService: S2sMessagingService, 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, authProviderType);
+    super(ExternalGroupProviderType.keycloak, s2sMessagingService, socketIoService);
     this.kcAdminClient = new KeycloakAdminClient({ baseUrl: kcHost, realmName: kcGroupSyncClientRealm });
     this.realm = kcGroupRealm;
     this.groupDescriptionAttribute = kcGroupDescriptionAttribute;
   }
 
-  async generateExternalUserGroupTrees(): Promise<ExternalUserGroupTreeNode[]> {
+  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.
@@ -121,5 +142,3 @@ class KeycloakUserGroupSyncService extends ExternalUserGroupSyncService {
   }
 
 }
-
-export default KeycloakUserGroupSyncService;

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

@@ -1,6 +1,7 @@
 import { configManager } from '~/server/service/config-manager';
-import LdapService, { SearchResultEntry } from '~/server/service/ldap';
+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';
 
@@ -10,7 +11,7 @@ import {
 
 import ExternalUserGroupSyncService from './external-user-group-sync';
 
-const logger = loggerFactory('growi:service:ldap-user-sync-service');
+const logger = loggerFactory('growi:service:ldap-user-group-sync-service');
 
 // When d = max depth of group trees
 // Max space complexity of generateExternalUserGroupTrees will be:
@@ -18,53 +19,59 @@ const logger = loggerFactory('growi:service:ldap-user-sync-service');
 const TREES_BATCH_SIZE = 10;
 const USERS_BATCH_SIZE = 30;
 
-class LdapUserGroupSyncService extends ExternalUserGroupSyncService {
+export class LdapUserGroupSyncService extends ExternalUserGroupSyncService {
 
   passportService: PassportService;
 
-  ldapService: LdapService;
+  isInitialized = false;
 
   // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
-  constructor(passportService, userBindUsername?: string, userBindPassword?: string) {
-    super(ExternalGroupProviderType.ldap, 'ldap');
+  constructor(passportService: PassportService, s2sMessagingService: S2sMessagingService, socketIoService) {
+    super(ExternalGroupProviderType.ldap, s2sMessagingService, socketIoService);
+    this.authProviderType = 'ldap';
     this.passportService = passportService;
-    this.ldapService = new LdapService(userBindUsername, userBindPassword);
   }
 
-  async generateExternalUserGroupTrees(): Promise<ExternalUserGroupTreeNode[]> {
+  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 = this.ldapService.getGroupSearchBase();
+    const groupBase: string = ldapService.getGroupSearchBase();
 
-    let groupEntries: SearchResultEntry[];
-    try {
-      await this.ldapService.bind();
-      groupEntries = await this.ldapService.searchGroupDir();
-    }
-    catch (e) {
-      logger.error(e.message);
-      throw Error('external_user_group.ldap.group_search_failed');
-    }
+    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 this.ldapService.getArrayValFromSearchResultEntry(groupEntry, groupChildGroupAttribute).filter(attr => attr.includes(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 this.ldapService.getArrayValFromSearchResultEntry(groupEntry, groupMembershipAttribute).filter(attr => !attr.includes(groupBase));
+      return ldapService.getArrayValFromSearchResultEntry(groupEntry, groupMembershipAttribute).filter(attr => !attr.includes(groupBase));
     };
 
     const convert = async(entry: SearchResultEntry, converted: string[]): Promise<ExternalUserGroupTreeNode | null> => {
-      const name = this.ldapService.getStringValFromSearchResultEntry(entry, groupNameAttribute);
+      const name = ldapService.getStringValFromSearchResultEntry(entry, groupNameAttribute);
       if (name == null) return null;
 
       if (converted.includes(entry.objectName)) {
-        throw Error('external_user_group.ldap.circular_reference');
+        throw Error('Circular reference inside LDAP group tree');
       }
       converted.push(entry.objectName);
 
@@ -73,7 +80,7 @@ class LdapUserGroupSyncService extends ExternalUserGroupSyncService {
       const userInfos = (await batchProcessPromiseAll(userIds, USERS_BATCH_SIZE, (id) => {
         return this.getUserInfo(id);
       })).filter((info): info is NonNullable<ExternalUserInfo> => info != null);
-      const description = this.ldapService.getStringValFromSearchResultEntry(entry, groupDescriptionAttribute);
+      const description = ldapService.getStringValFromSearchResultEntry(entry, groupDescriptionAttribute);
       const childGroupDNs = getChildGroupDnsFromGroupEntry(entry);
 
       const childGroupNodesWithNull: (ExternalUserGroupTreeNode | null)[] = [];
@@ -118,29 +125,22 @@ class LdapUserGroupSyncService extends ExternalUserGroupSyncService {
     // get full user info from LDAP server using externalUserInfo (DN or UID)
     const getUserEntries = async() => {
       if (groupMembershipAttributeType === LdapGroupMembershipAttributeType.dn) {
-        return this.ldapService.search(undefined, userId, 'base');
+        return ldapService.search(undefined, userId, 'base');
       }
       if (groupMembershipAttributeType === LdapGroupMembershipAttributeType.uid) {
-        return this.ldapService.search(`(uid=${userId})`, undefined);
+        return ldapService.search(`(uid=${userId})`, undefined);
       }
     };
 
-    let userEntries: SearchResultEntry[] | undefined;
-    try {
-      userEntries = await getUserEntries();
-    }
-    catch (e) {
-      logger.error(e.message);
-      throw Error('external_user_group.ldap.user_search_failed');
-    }
+    const userEntries = await getUserEntries();
 
     if (userEntries != null && userEntries.length > 0) {
       const userEntry = userEntries[0];
-      const uid = this.ldapService.getStringValFromSearchResultEntry(userEntry, 'uid');
+      const uid = ldapService.getStringValFromSearchResultEntry(userEntry, 'uid');
       if (uid != null) {
-        const usernameToBeRegistered = attrMapUsername === 'uid' ? uid : this.ldapService.getStringValFromSearchResultEntry(userEntry, attrMapUsername);
-        const nameToBeRegistered = this.ldapService.getStringValFromSearchResultEntry(userEntry, attrMapName);
-        const mailToBeRegistered = this.ldapService.getStringValFromSearchResultEntry(userEntry, attrMapMail);
+        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,
@@ -154,5 +154,3 @@ class LdapUserGroupSyncService extends ExternalUserGroupSyncService {
   }
 
 }
-
-export default LdapUserGroupSyncService;

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

+ 0 - 2
apps/app/src/server/crowi/express-init.js

@@ -24,7 +24,6 @@ module.exports = function(crowi, app) {
   const flash = require('connect-flash');
   const mongoSanitize = require('express-mongo-sanitize');
 
-  const promster = require('../middlewares/promster')(crowi, app);
   const registerSafeRedirect = registerSafeRedirectFactory();
   const injectCurrentuserToLocalvars = require('../middlewares/inject-currentuser-to-localvars')();
   const autoReconnectToS2sMsgServer = require('../middlewares/auto-reconnect-to-s2s-msg-server')(crowi);
@@ -133,7 +132,6 @@ module.exports = function(crowi, app) {
   app.use(flash());
   app.use(mongoSanitize());
 
-  app.use(promster);
   app.use(registerSafeRedirect);
   app.use(injectCurrentuserToLocalvars);
   app.use(autoReconnectToS2sMsgServer);

+ 13 - 12
apps/app/src/server/crowi/index.js

@@ -10,6 +10,8 @@ import next from 'next';
 
 import pkg from '^/package.json';
 
+import { KeycloakUserGroupSyncService } from '~/features/external-user-group/server/service/keycloak-user-group-sync';
+import { LdapUserGroupSyncService } from '~/features/external-user-group/server/service/ldap-user-group-sync';
 import QuestionnaireService from '~/features/questionnaire/server/service/questionnaire';
 import QuestionnaireCronService from '~/features/questionnaire/server/service/questionnaire-cron';
 import CdnResourcesService from '~/services/cdn-resources-service';
@@ -153,13 +155,15 @@ Crowi.prototype.init = async function() {
     this.setupSyncPageStatusService(),
     this.setupQuestionnaireService(),
     this.setUpCustomize(), // depends on pluginService
-    this.setupExternalAccountService(),
   ]);
 
-  // globalNotification depends on slack and mailer
   await Promise.all([
+    // globalNotification depends on slack and mailer
     this.setUpGlobalNotification(),
     this.setUpUserNotification(),
+    // depends on passport service
+    this.setupExternalAccountService(),
+    this.setupExternalUserGroupSyncService(),
   ]);
 
   await this.autoInstall();
@@ -457,7 +461,7 @@ Crowi.prototype.start = async function() {
     this.crowiDev.init();
   }
 
-  const { express, configManager } = this;
+  const { express } = this;
 
   const app = (this.node_env === 'development') ? this.crowiDev.setupServer(express) : express;
 
@@ -475,15 +479,6 @@ Crowi.prototype.start = async function() {
       this.crowiDev.setupExpressAfterListening(express);
     }
   });
-  // listen for promster
-  if (configManager.getConfig('crowi', 'promster:isEnabled')) {
-    const { createServer } = require('@promster/server');
-    const promsterPort = configManager.getConfig('crowi', 'promster:port');
-
-    createServer({ port: promsterPort }).then(() => {
-      logger.info(`[${this.node_env}] Promster server is listening on port ${promsterPort}`);
-    });
-  }
 
   // setup Express Routes
   this.setupRoutesForPlugins();
@@ -787,4 +782,10 @@ Crowi.prototype.setupExternalAccountService = function() {
   instanciateExternalAccountService(this.passportService);
 };
 
+// execute after setupPassport, s2sMessagingService, socketIoService
+Crowi.prototype.setupExternalUserGroupSyncService = function() {
+  this.ldapUserGroupSyncService = new LdapUserGroupSyncService(this.passportService, this.s2sMessagingService, this.socketIoService);
+  this.keycloakUserGroupSyncService = new KeycloakUserGroupSyncService(this.s2sMessagingService, this.socketIoService);
+};
+
 export default Crowi;

+ 0 - 26
apps/app/src/server/middlewares/promster.ts

@@ -1,26 +0,0 @@
-import {
-  Request, Response, NextFunction,
-} from 'express';
-
-import loggerFactory from '~/utils/logger';
-
-const logger = loggerFactory('growi:middlewares:promster');
-
-type Middleware = (req: Request, res: Response, next: NextFunction) => void;
-
-// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
-function middleware(crowi: any, app): Middleware {
-  const { configManager } = crowi;
-
-  // when disabled
-  if (!configManager.getConfig('crowi', 'promster:isEnabled')) {
-    return (req, res, next) => next();
-  }
-
-  logger.info('Promster is enabled');
-
-  const { createMiddleware } = require('@promster/express');
-  return createMiddleware({ app });
-}
-
-module.exports = middleware;

+ 17 - 2
apps/app/src/server/routes/apiv3/page.js

@@ -1,8 +1,11 @@
+import path from 'path';
+
 import {
   AllSubscriptionStatusType, SubscriptionStatusType,
 } from '@growi/core';
 import { ErrorV3 } from '@growi/core/dist/models';
 import { convertToNewAffiliationPath } from '@growi/core/dist/utils/page-path-utils';
+import sanitize from 'sanitize-filename';
 
 import ExternalUserGroup from '~/features/external-user-group/server/models/external-user-group';
 import { SupportedAction, SupportedTargetModel } from '~/interfaces/activity';
@@ -14,6 +17,7 @@ import UserGroup from '~/server/models/user-group';
 import { divideByType } from '~/server/util/granted-group';
 import loggerFactory from '~/utils/logger';
 
+
 const logger = loggerFactory('growi:routes:apiv3:page'); // eslint-disable-line no-unused-vars
 
 const express = require('express');
@@ -598,6 +602,7 @@ module.exports = (crowi) => {
     const { pageId } = req.params;
     const { format, revisionId = null } = req.query;
     let revision;
+    let pagePath;
 
     try {
       const Page = crowi.model('Page');
@@ -616,6 +621,7 @@ module.exports = (crowi) => {
 
       const Revision = crowi.model('Revision');
       revision = await Revision.findById(revisionIdForFind);
+      pagePath = page.path;
 
       // Error if pageId and revison's pageIds do not match
       if (page._id.toString() !== revision.pageId.toString()) {
@@ -627,7 +633,16 @@ module.exports = (crowi) => {
       return res.apiv3Err(err, 500);
     }
 
-    const fileName = revision.id;
+    // replace forbidden characters to '_'
+    // refer to https://kb.acronis.com/node/56475?ckattempt=1
+    let fileName = sanitize(path.basename(pagePath), { replacement: '_' });
+
+
+    // replace root page name to '_top'
+    if (fileName === '') {
+      fileName = '_top';
+    }
+
     let stream;
 
     try {
@@ -639,7 +654,7 @@ module.exports = (crowi) => {
     }
 
     res.set({
-      'Content-Disposition': `attachment;filename*=UTF-8''${fileName}.${format}`,
+      'Content-Disposition': `attachment;filename*=UTF-8''${encodeURIComponent(fileName)}.${format}`,
     });
 
     const parameters = {

+ 0 - 12
apps/app/src/server/service/config-loader.ts

@@ -520,18 +520,6 @@ const ENV_VAR_NAME_TO_CONFIG_INFO = {
     type:    ValueType.BOOLEAN,
     default: false,
   },
-  PROMSTER_ENABLED: {
-    ns:      'crowi',
-    key:     'promster:isEnabled',
-    type:    ValueType.BOOLEAN,
-    default: false,
-  },
-  PROMSTER_PORT: {
-    ns:      'crowi',
-    key:     'promster:port',
-    type:    ValueType.NUMBER,
-    default: 7788,
-  },
   GROWI_CLOUD_URI: {
     ns:      'crowi',
     key:     'app:growiCloudUri',

+ 25 - 17
apps/app/src/server/service/ldap.ts

@@ -23,23 +23,21 @@ export interface SearchResultEntry {
 */
 class LdapService {
 
-  username?: string; // Necessary when bind type is user bind
-
-  password?: string; // Necessary when bind type is user bind
-
-  client: ldap.Client;
+  client: ldap.Client | null;
 
   searchBase: string;
 
-  constructor(username?: string, password?: string) {
+  /**
+   * Initialize LDAP client and bind.
+   * @param {string} userBindUsername Necessary when bind type is user bind
+   * @param {string} userBindPassword Necessary when bind type is user bind
+   */
+  initClient(userBindUsername?: string, userBindPassword?: string): void {
     const serverUrl = configManager?.getConfig('crowi', 'security:passport-ldap:serverUrl');
 
-    this.username = username;
-    this.password = password;
-
     // parse serverUrl
     // see: https://regex101.com/r/0tuYBB/1
-    const match = serverUrl.match(/(ldaps?:\/\/[^/]+)\/(.*)?/);
+    const match = serverUrl?.match(/(ldaps?:\/\/[^/]+)\/(.*)?/);
     if (match == null || match.length < 1) {
       const urlInvalidMessage = 'serverUrl is invalid';
       logger.error(urlInvalidMessage);
@@ -51,13 +49,19 @@ class LdapService {
     this.client = ldap.createClient({
       url,
     });
+    this.bind(userBindUsername, userBindPassword);
   }
 
   /**
    * Bind to LDAP server.
    * This method is declared independently, so multiple operations can be requested to the LDAP server with a single bind.
+   * @param {string} userBindUsername Necessary when bind type is user bind
+   * @param {string} userBindPassword Necessary when bind type is user bind
    */
-  bind(): Promise<void> {
+  bind(userBindUsername?: string, userBindPassword?: string): Promise<void> {
+    const client = this.client;
+    if (client == null) throw new Error('LDAP client is not initialized');
+
     const isLdapEnabled = configManager?.getConfig('crowi', 'security:passport-ldap:isEnabled');
     if (!isLdapEnabled) {
       const notEnabledMessage = 'LDAP is not enabled';
@@ -72,12 +76,12 @@ class LdapService {
 
     // user bind
     const fixedBindDN = (isUserBind)
-      ? bindDN.replace(/{{username}}/, this.username)
+      ? bindDN.replace(/{{username}}/, userBindUsername)
       : bindDN;
-    const fixedBindCredentials = (isUserBind) ? this.password : bindCredentials;
+    const fixedBindCredentials = (isUserBind) ? userBindPassword : bindCredentials;
 
     return new Promise<void>((resolve, reject) => {
-      this.client.bind(fixedBindDN, fixedBindCredentials, (err) => {
+      client.bind(fixedBindDN, fixedBindCredentials, (err) => {
         if (err != null) {
           reject(err);
         }
@@ -94,15 +98,18 @@ class LdapService {
    * @returns {SearchEntry[]} Search result. Default scope is set to 'sub'.
    */
   search(filter?: string, base?: string, scope: 'sub' | 'base' | 'one' = 'sub'): Promise<SearchResultEntry[]> {
+    const client = this.client;
+    if (client == null) throw new Error('LDAP client is not initialized');
+
     const searchResults: SearchResultEntry[] = [];
 
     return new Promise((resolve, reject) => {
       // reject on client connection error (occures when not binded or host is not found)
-      this.client.on('error', (err) => {
+      client.on('error', (err) => {
         reject(err);
       });
 
-      this.client.search(base || this.searchBase, {
+      client.search(base || this.searchBase, {
         scope, filter, paged: true, sizeLimit: 200,
       }, (err, res) => {
         if (err != null) {
@@ -162,4 +169,5 @@ class LdapService {
 
 }
 
-export default LdapService;
+// export the singleton instance
+export const ldapService = new LdapService();

+ 4 - 3
apps/app/src/server/service/search-delegator/elasticsearch.ts

@@ -9,6 +9,7 @@ import { SearchDelegatorName } from '~/interfaces/named-query';
 import {
   ISearchResult, ISearchResultData, SORT_AXIS, SORT_ORDER,
 } from '~/interfaces/search';
+import { SocketEventName } from '~/interfaces/websocket';
 import loggerFactory from '~/utils/logger';
 
 import {
@@ -301,7 +302,7 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
       logger.error('error.meta.body', error?.meta?.body);
 
       const socket = this.socketIoService.getAdminSocket();
-      socket.emit('rebuildingFailed', { error: error.message });
+      socket.emit(SocketEventName.RebuildingFailed, { error: error.message });
 
       throw error;
     }
@@ -582,7 +583,7 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
           logger.info(`Adding pages progressing: (count=${count}, errors=${bulkResponse.errors}, took=${bulkResponse.took}ms)`);
 
           if (shouldEmitProgress) {
-            socket?.emit('addPageProgress', { totalCount, count, skipped });
+            socket?.emit(SocketEventName.AddPageProgress, { totalCount, count, skipped });
           }
         }
         catch (err) {
@@ -606,7 +607,7 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
         logger.info(`Adding pages has completed: (totalCount=${totalCount}, skipped=${skipped})`);
 
         if (shouldEmitProgress) {
-          socket?.emit('finishAddPage', { totalCount, count, skipped });
+          socket?.emit(SocketEventName.FinishAddPage, { totalCount, count, skipped });
         }
         callback();
       },

+ 4 - 4
apps/app/test/integration/service/external-user-group-sync.test.ts

@@ -13,12 +13,12 @@ import { instanciate } from '../../../src/server/service/external-account';
 import PassportService from '../../../src/server/service/passport';
 import { getInstance } from '../setup-crowi';
 
-
 // dummy class to implement generateExternalUserGroupTrees which returns test data
 class TestExternalUserGroupSyncService extends ExternalUserGroupSyncService {
 
-  constructor() {
-    super(ExternalGroupProviderType.ldap, 'ldap');
+  constructor(s2sMessagingService, socketIoService) {
+    super('ldap', s2sMessagingService, socketIoService);
+    this.authProviderType = ExternalGroupProviderType.ldap;
   }
 
   async generateExternalUserGroupTrees(): Promise<ExternalUserGroupTreeNode[]> {
@@ -77,7 +77,7 @@ class TestExternalUserGroupSyncService extends ExternalUserGroupSyncService {
 
 }
 
-const testService = new TestExternalUserGroupSyncService();
+const testService = new TestExternalUserGroupSyncService(null, null);
 
 const checkGroup = (group: IExternalUserGroupHasId, expected: Omit<IExternalUserGroup, 'createdAt'>) => {
   const actual = {

+ 11 - 12
apps/app/test/integration/service/ldap-user-group-sync.test.ts

@@ -1,15 +1,14 @@
 import ldap, { Client } from 'ldapjs';
 
-import LdapUserGroupSyncService from '../../../src/features/external-user-group/server/service/ldap-user-group-sync';
+import { LdapUserGroupSyncService } from '../../../src/features/external-user-group/server/service/ldap-user-group-sync';
 import { configManager } from '../../../src/server/service/config-manager';
-import LdapService from '../../../src/server/service/ldap';
+import { ldapService } from '../../../src/server/service/ldap';
 import PassportService from '../../../src/server/service/passport';
 import { getInstance } from '../setup-crowi';
 
-
 describe('LdapUserGroupSyncService.generateExternalUserGroupTrees', () => {
   let crowi;
-  let ldapGroupSyncService: LdapUserGroupSyncService;
+  let ldapUserGroupSyncService: LdapUserGroupSyncService;
 
   const configParams = {
     'security:passport-ldap:attrMapName': 'name',
@@ -23,8 +22,8 @@ describe('LdapUserGroupSyncService.generateExternalUserGroupTrees', () => {
   };
 
   jest.mock('../../../src/server/service/ldap');
-  const mockBind = jest.spyOn(LdapService.prototype, 'bind');
-  const mockLdapSearch = jest.spyOn(LdapService.prototype, 'search');
+  const mockBind = jest.spyOn(ldapService, 'bind');
+  const mockLdapSearch = jest.spyOn(ldapService, 'search');
   const mockLdapCreateClient = jest.spyOn(ldap, 'createClient');
 
   beforeAll(async() => {
@@ -37,7 +36,7 @@ describe('LdapUserGroupSyncService.generateExternalUserGroupTrees', () => {
     mockLdapCreateClient.mockImplementation(() => { return {} as Client });
 
     const passportService = new PassportService(crowi);
-    ldapGroupSyncService = new LdapUserGroupSyncService(passportService);
+    ldapUserGroupSyncService = new LdapUserGroupSyncService(passportService, null, null);
   });
 
   describe('When there is no circular reference in group tree', () => {
@@ -150,12 +149,12 @@ describe('LdapUserGroupSyncService.generateExternalUserGroupTrees', () => {
         return Promise.reject(new Error('not found'));
       });
 
-      const rootNodes = await ldapGroupSyncService.generateExternalUserGroupTrees();
+      const rootNodes = await ldapUserGroupSyncService?.generateExternalUserGroupTrees();
 
-      expect(rootNodes.length).toBe(2);
+      expect(rootNodes?.length).toBe(2);
 
       // check grandParentGroup
-      const grandParentNode = rootNodes.find(node => node.id === 'cn=grandParentGroup,ou=groups,dc=example,dc=org');
+      const grandParentNode = rootNodes?.find(node => node.id === 'cn=grandParentGroup,ou=groups,dc=example,dc=org');
       const expectedChildNode = {
         id: 'cn=childGroup,ou=groups,dc=example,dc=org',
         userInfos: [{
@@ -195,7 +194,7 @@ describe('LdapUserGroupSyncService.generateExternalUserGroupTrees', () => {
       expect(grandParentNode).toStrictEqual(expectedGrandParentNode);
 
       // check rootGroup
-      const rootNode = rootNodes.find(node => node.id === 'cn=rootGroup,ou=groups,dc=example,dc=org');
+      const rootNode = rootNodes?.find(node => node.id === 'cn=rootGroup,ou=groups,dc=example,dc=org');
       const expectedRootNode = {
         id: 'cn=rootGroup,ou=groups,dc=example,dc=org',
         userInfos: [{
@@ -258,7 +257,7 @@ describe('LdapUserGroupSyncService.generateExternalUserGroupTrees', () => {
         return Promise.reject(new Error('not found'));
       });
 
-      await expect(ldapGroupSyncService.generateExternalUserGroupTrees()).rejects.toThrow('external_user_group.ldap.circular_reference');
+      await expect(ldapUserGroupSyncService?.generateExternalUserGroupTrees()).rejects.toThrow('Circular reference inside LDAP group tree');
     });
   });
 });

+ 1 - 1
packages/presentation/src/services/renderer/slides.ts

@@ -61,7 +61,7 @@ const rewriteNode = (tree: Node, node: Node, isEnabledMarp: boolean) => {
     tree.children = [newNode];
     data.hName = 'slide';
     data.hProperties = {
-      marp: marp ? '' : undefined,
+      marp: (marp && isEnabledMarp) ? '' : undefined,
       children: markdown,
     };
   }

+ 69 - 117
yarn.lock

@@ -2965,18 +2965,15 @@
   resolved "https://registry.yarnpkg.com/@juggle/resize-observer/-/resize-observer-3.3.1.tgz#b50a781709c81e10701004214340f25475a171a0"
   integrity sha512-zMM9Ds+SawiUkakS7y94Ymqx+S0ORzpG3frZirN3l+UlXUmSUR7hF4wxCVqW+ei94JzV5kt0uXBcoOEAuiydrw==
 
-"@keycloak/keycloak-admin-client@^18.0.0":
-  version "18.0.2"
-  resolved "https://registry.yarnpkg.com/@keycloak/keycloak-admin-client/-/keycloak-admin-client-18.0.2.tgz#e8329830ea2bc9fc7012e31b10c06a35ab58984c"
-  integrity sha512-UCa+5FTPBzbbfCpC27Sb40XbNm27m78z+yax9kiw9aFwk+itiGId09bMzECBRDrqwvVMxo1vzLERLjAty3rTRg==
-  dependencies:
-    axios "^0.26.1"
-    camelize-ts "^1.0.8"
-    keycloak-js "^17.0.1"
-    lodash "^4.17.21"
-    query-string "^7.0.1"
-    url-join "^4.0.0"
-    url-template "^2.0.8"
+"@keycloak/keycloak-admin-client@22.0.1":
+  version "22.0.1"
+  resolved "https://registry.yarnpkg.com/@keycloak/keycloak-admin-client/-/keycloak-admin-client-22.0.1.tgz#2cb574c90d20e69a5b98fccce376291857070da6"
+  integrity sha512-/eKzNzT2hW/tRQd8/33dX1dfRU4xBsd3/30bL2OFF5+J+1UUmRYM2klYcFhdIkFX3P9/ptqH+vHpqCusdMcSCw==
+  dependencies:
+    camelize-ts "^3.0.0"
+    lodash-es "^4.17.21"
+    url-join "^5.0.0"
+    url-template "^3.1.0"
 
 "@khanacademy/simple-markdown@^0.8.6":
   version "0.8.6"
@@ -3112,9 +3109,9 @@
     lodash ">=4.17.15"
 
 "@mapbox/node-pre-gyp@^1.0.10":
-  version "1.0.10"
-  resolved "https://registry.yarnpkg.com/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.10.tgz#8e6735ccebbb1581e5a7e652244cadc8a844d03c"
-  integrity sha512-4ySo4CjzStuprMwk35H5pPbkymjv1SF3jGLj6rAHp/xT/RF7TL7bd9CTm1xDY49K2qF7jmR/g7k+SkLETP6opA==
+  version "1.0.11"
+  resolved "https://registry.yarnpkg.com/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.11.tgz#417db42b7f5323d79e93b34a6d7a2a12c0df43fa"
+  integrity sha512-Yhlar6v9WQgUp/He7BdgzOz8lqMQ8sU+jkCq7Wx8Myc5YFJLbEe7lgui/V7G1qB1DJykHSGwreceSaD60Y0PUQ==
   dependencies:
     detect-libc "^2.0.0"
     https-proxy-agent "^5.0.0"
@@ -3502,6 +3499,13 @@
     colors "~1.2.1"
     string-argv "~0.3.1"
 
+"@s3pweb/keycloak-admin-client-cjs@^22.0.1":
+  version "22.0.1"
+  resolved "https://registry.yarnpkg.com/@s3pweb/keycloak-admin-client-cjs/-/keycloak-admin-client-cjs-22.0.1.tgz#65b2861a947a8fe9be34ff0b2932cb01652c05a8"
+  integrity sha512-F8zr13/rR3QcDzKEty541rXaubU6+Yn/5aMzmSy6in5TeUL3FLqF0QmuW3g1xrgABywcGopew2sEq0X3qJfRUw==
+  dependencies:
+    "@keycloak/keycloak-admin-client" "22.0.1"
+
 "@sematext/gc-stats@1.5.8":
   version "1.5.8"
   resolved "https://registry.yarnpkg.com/@sematext/gc-stats/-/gc-stats-1.5.8.tgz#73edb27bcbe0f3976041e2dc42cc86874128eeb9"
@@ -5146,13 +5150,6 @@ axios@^0.24.0:
   dependencies:
     follow-redirects "^1.14.4"
 
-axios@^0.26.1:
-  version "0.26.1"
-  resolved "https://registry.yarnpkg.com/axios/-/axios-0.26.1.tgz#1ede41c51fcf51bbbd6fd43669caaa4f0495aaa9"
-  integrity sha512-fPwcX4EvnSHuInCMItEhAGnaSEXRBjtzh9fOtsE6E1G6p7vl7edEeZe11QHf18+6+9gR5PbKV/sGKNaD8YaMeA==
-  dependencies:
-    follow-redirects "^1.14.8"
-
 axobject-query@^2.2.0:
   version "2.2.0"
   resolved "https://registry.yarnpkg.com/axobject-query/-/axobject-query-2.2.0.tgz#943d47e10c0b704aa42275e20edf3722648989be"
@@ -5267,7 +5264,7 @@ base64-arraybuffer@0.1.4:
   resolved "https://registry.yarnpkg.com/base64-arraybuffer/-/base64-arraybuffer-0.1.4.tgz#9818c79e059b1355f97e0428a017c838e90ba812"
   integrity sha1-mBjHngWbE1X5fgQooBfIOOkLqBI=
 
-base64-js@^1.0.2, base64-js@^1.3.0, base64-js@^1.3.1, base64-js@^1.5.1:
+base64-js@^1.0.2, base64-js@^1.3.0, base64-js@^1.3.1:
   version "1.5.1"
   resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a"
   integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==
@@ -5338,11 +5335,6 @@ binary@~0.3.0:
     buffers "~0.1.1"
     chainsaw "~0.1.0"
 
-bintrees@1.0.2:
-  version "1.0.2"
-  resolved "https://registry.yarnpkg.com/bintrees/-/bintrees-1.0.2.tgz#49f896d6e858a4a499df85c38fb399b9aff840f8"
-  integrity sha512-VOMgTMwjAaUG580SXn3LacVgjurrbMme7ZZNYGSSV7mmtY6QQRh0Eg3pwIcntQ77DErK1L0NxkbetjcoXzVwKw==
-
 bl@^4.0.3, bl@^4.1.0:
   version "4.1.0"
   resolved "https://registry.yarnpkg.com/bl/-/bl-4.1.0.tgz#451535264182bec2fbbc83a62ab98cf11d9f7b3a"
@@ -5694,10 +5686,10 @@ camelcase@^6.3.0:
   resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-6.3.0.tgz#5685b95eb209ac9c0c177467778c9c84df58ba9a"
   integrity sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==
 
-camelize-ts@^1.0.8:
-  version "1.0.9"
-  resolved "https://registry.yarnpkg.com/camelize-ts/-/camelize-ts-1.0.9.tgz#6ac46fbe660d18e093568ef0d56c836141b700f4"
-  integrity sha512-ePOW3V2qrQ0qtRlcTM6Qe3nXremdydIwsMKI1Vl2NBGM0tOo8n2xzJ7YOQpV1GIKHhs3p+F40ThI8/DoYWbYKQ==
+camelize-ts@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/camelize-ts/-/camelize-ts-3.0.0.tgz#b9a7b4ff802464dc3d6475637a64a9742ad3db09"
+  integrity sha512-cgRwKKavoDKLTjO4FQTs3dRBePZp/2Y9Xpud0FhuCOTE86M2cniKN4CCXgRnsyXNMmQMifVHcv6SPaMtTx6ofQ==
 
 can-use-dom@^0.1.0:
   version "0.1.0"
@@ -6279,7 +6271,7 @@ connect-redis@^4.0.4:
 console-control-strings@^1.0.0, console-control-strings@^1.1.0:
   version "1.1.0"
   resolved "https://registry.yarnpkg.com/console-control-strings/-/console-control-strings-1.1.0.tgz#3d7cf4464db6446ea644bf4b39507f9851008e8e"
-  integrity sha1-PXz0Rk22RG6mRL9LOVB/mFEAjo4=
+  integrity sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==
 
 consolidate@^0.16.0:
   version "0.16.0"
@@ -6969,11 +6961,6 @@ decode-tiff@^0.2.0:
   resolved "https://registry.yarnpkg.com/decode-tiff/-/decode-tiff-0.2.1.tgz#c18ca071b8decf5d49b0c732ead4f6bb061142cb"
   integrity sha512-v/7hQBv/DrOVQ+Eljg0BLMRbXZYuuw3YZ8duZuFxYpo6qUkdn7oFRkN95RZKbnh08EHNjrMXMbEUNhTLuhPvvA==
 
-decode-uri-component@^0.2.2:
-  version "0.2.2"
-  resolved "https://registry.yarnpkg.com/decode-uri-component/-/decode-uri-component-0.2.2.tgz#e69dbe25d37941171dd540e024c444cd5188e1e9"
-  integrity sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ==
-
 dedent@^0.7.0:
   version "0.7.0"
   resolved "https://registry.yarnpkg.com/dedent/-/dedent-0.7.0.tgz#2495ddbaf6eb874abb0e1be9df22d2e5a544326c"
@@ -7087,7 +7074,7 @@ delayed-stream@~1.0.0:
 delegates@^1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/delegates/-/delegates-1.0.0.tgz#84c6e159b81904fdca59a0ef44cd870d31250f9a"
-  integrity sha1-hMbhWbgZBP3KWaDvRM2HDTElD5o=
+  integrity sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==
 
 denque@^1.4.1:
   version "1.4.1"
@@ -7121,9 +7108,9 @@ detect-indent@^7.0.0:
   integrity sha512-/6kJlmVv6RDFPqaHC/ZDcU8bblYcoph2dUQ3kB47QqhkUEqXe3VZPELK9BaEMrC73qu+wn0AQ7iSteceN+yuMw==
 
 detect-libc@^2.0.0:
-  version "2.0.1"
-  resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-2.0.1.tgz#e1897aa88fa6ad197862937fbc0441ef352ee0cd"
-  integrity sha512-463v3ZeIrcWtdgIg6vI6XUncguvr2TnGl4SzDXinkt9mSLpBJKXT3mW6xT3VQdDN11+WVs29pgvivTc4Lp8v+w==
+  version "2.0.2"
+  resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-2.0.2.tgz#8ccf2ba9315350e1241b88d0ac3b0e1fbd99605d"
+  integrity sha512-UX6sGumvvqSaXgdKGUsgZWqcUyIXZ/vZTrlRT/iobiKhGL0zL4d3osHj3uqllWJK+i+sixDS/3COVEOFbupFyw==
 
 detect-newline@^3.0.0:
   version "3.1.0"
@@ -8284,11 +8271,6 @@ fill-range@^7.0.1:
   dependencies:
     to-regex-range "^5.0.1"
 
-filter-obj@^1.1.0:
-  version "1.1.0"
-  resolved "https://registry.yarnpkg.com/filter-obj/-/filter-obj-1.1.0.tgz#9b311112bc6c6127a16e016c6c5d7f19e0805c5b"
-  integrity sha512-8rXg1ZnX7xzy2NGDVkBVaAy+lSlPNwad13BtgSlLuxfIslyt5Vg64U7tFcCt4WS1R0hvtnQybT/IyCkGZ3DpXQ==
-
 finalhandler@~1.1.2:
   version "1.1.2"
   resolved "https://registry.yarnpkg.com/finalhandler/-/finalhandler-1.1.2.tgz#b7e7d000ffd11938d0fdb053506f6ebabe9f587d"
@@ -8399,11 +8381,6 @@ follow-redirects@^1.14.0, follow-redirects@^1.14.4:
   resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.14.7.tgz#2004c02eb9436eee9a21446a6477debf17e81685"
   integrity sha512-+hbxoLbFMbRKDwohX8GkTataGqO6Jb7jGwpAlwgy2bIz25XtRm7KEzJM76R1WiNT5SwZkX4Y75SwBolkpmE7iQ==
 
-follow-redirects@^1.14.8:
-  version "1.15.3"
-  resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.3.tgz#fe2f3ef2690afce7e82ed0b44db08165b207123a"
-  integrity sha512-1VzOtuEM8pC9SFU1E+8KfTjZyMztRsgEfwQl44z8A25uy13jSzTj6dyK2Df52iV0vgHCfBwLhDWevLn95w5v6Q==
-
 font-awesome@^4.7.0:
   version "4.7.0"
   resolved "https://registry.yarnpkg.com/font-awesome/-/font-awesome-4.7.0.tgz#8fa8cf0411a1a31afd07b06d2902bb9fc815a133"
@@ -8639,9 +8616,9 @@ get-caller-file@^2.0.5:
   integrity sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==
 
 get-func-name@^2.0.0:
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/get-func-name/-/get-func-name-2.0.0.tgz#ead774abee72e20409433a066366023dd6887a41"
-  integrity sha512-Hm0ixYtaSZ/V7C8FJrtZIuBBI+iSgL+1Aq82zSu8VQNB4S3Gk8e7Qs3VwBDJAhmRZcFqkl3tQu36g/Foh5I5ig==
+  version "2.0.2"
+  resolved "https://registry.yarnpkg.com/get-func-name/-/get-func-name-2.0.2.tgz#0d7cf20cd13fda808669ffa88f4ffc7a3943fc41"
+  integrity sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==
 
 get-intrinsic@^1.0.1, get-intrinsic@^1.0.2, get-intrinsic@^1.1.0, get-intrinsic@^1.1.1:
   version "1.1.2"
@@ -9062,7 +9039,7 @@ has-tostringtag@^1.0.0:
 has-unicode@^2.0.1:
   version "2.0.1"
   resolved "https://registry.yarnpkg.com/has-unicode/-/has-unicode-2.0.1.tgz#e0e6fe6a28cf51138855e086d1691e771de2a8b9"
-  integrity sha1-4Ob+aijPUROIVeCG0Wkedx3iqLk=
+  integrity sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==
 
 has@^1.0.3:
   version "1.0.3"
@@ -10502,11 +10479,6 @@ jquery@^3.7.0:
   resolved "https://registry.yarnpkg.com/jquery/-/jquery-3.7.0.tgz#fe2c01a05da500709006d8790fe21c8a39d75612"
   integrity sha512-umpJ0/k8X0MvD1ds0P9SfowREz2LenHsQaxSohMZ5OMNEU2r0tf8pdeEFTHMFxWVxKNyU9rTtK3CWzUCTKJUeQ==
 
-js-sha256@^0.9.0:
-  version "0.9.0"
-  resolved "https://registry.yarnpkg.com/js-sha256/-/js-sha256-0.9.0.tgz#0b89ac166583e91ef9123644bd3c5334ce9d0966"
-  integrity sha512-sga3MHh9sgQN2+pJ9VYZ+1LPwXOxuBJBA5nrR5/ofPfuiJBE2hnjsaN8se8JznOmGLN2p49Pe5U/ttafcs/apA==
-
 js-string-escape@^1.0.1:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/js-string-escape/-/js-string-escape-1.0.1.tgz#e2625badbc0d67c7533e9edc1068c587ae4137ef"
@@ -10731,14 +10703,6 @@ katex@^0.16.4:
   dependencies:
     commander "^8.0.0"
 
-keycloak-js@^17.0.1:
-  version "17.0.1"
-  resolved "https://registry.yarnpkg.com/keycloak-js/-/keycloak-js-17.0.1.tgz#403ea75b3e938ddc780f99ecbd73e1b6905f826f"
-  integrity sha512-mbLBSoogCBX5VYeKCdEz8BaRWVL9twzSqArRU3Mo3Z7vEO1mghGZJ5IzREfiMEi7kTUZtk5i9mu+Yc0koGkK6g==
-  dependencies:
-    base64-js "^1.5.1"
-    js-sha256 "^0.9.0"
-
 khroma@^2.0.0:
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/khroma/-/khroma-2.0.0.tgz#7577de98aed9f36c7a474c4d453d94c0d6c6588b"
@@ -12065,12 +12029,17 @@ minimist@^1.2.3, minimist@^1.2.8:
   integrity sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==
 
 minipass@^3.0.0:
-  version "3.1.3"
-  resolved "https://registry.yarnpkg.com/minipass/-/minipass-3.1.3.tgz#7d42ff1f39635482e15f9cdb53184deebd5815fd"
-  integrity sha512-Mgd2GdMVzY+x3IJ+oHnVM+KG3lA5c8tnabyJKmHSaG2kAGpudxuOf8ToDkhumF7UzME7DecbQE9uOZhNm7PuJg==
+  version "3.3.6"
+  resolved "https://registry.yarnpkg.com/minipass/-/minipass-3.3.6.tgz#7bba384db3a1520d18c9c0e5251c3444e95dd94a"
+  integrity sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==
   dependencies:
     yallist "^4.0.0"
 
+minipass@^5.0.0:
+  version "5.0.0"
+  resolved "https://registry.yarnpkg.com/minipass/-/minipass-5.0.0.tgz#3e9788ffb90b694a5d0ec94479a45b5d8738133d"
+  integrity sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==
+
 "minipass@^5.0.0 || ^6.0.2 || ^7.0.0":
   version "7.0.2"
   resolved "https://registry.yarnpkg.com/minipass/-/minipass-7.0.2.tgz#58a82b7d81c7010da5bd4b2c0c85ac4b4ec5131e"
@@ -12359,11 +12328,16 @@ named-placeholders@^1.1.2:
   dependencies:
     lru-cache "^4.1.3"
 
-nan@^2.14.0, nan@^2.17.0:
+nan@^2.14.0:
   version "2.17.0"
   resolved "https://registry.yarnpkg.com/nan/-/nan-2.17.0.tgz#c0150a2368a182f033e9aa5195ec76ea41a199cb"
   integrity sha512-2ZTgtl0nJsO0KQCjEpxcIr5D+Yv90plTitZt9JBfQvVJDS5seMl3FOvsh3+9CoYWXf/1l5OaZzzF6nDm4cagaQ==
 
+nan@^2.17.0:
+  version "2.18.0"
+  resolved "https://registry.yarnpkg.com/nan/-/nan-2.18.0.tgz#26a6faae7ffbeb293a39660e88a76b82e30b7554"
+  integrity sha512-W7tfG7vMOGtD30sHoZSSc/JVYiyDPEyQVso/Zz+/uQd0B0L46gtC+pHha5FFMRpil6fm/AoEcRWyOVi4+E/f8w==
+
 nanoid@^3.3.4:
   version "3.3.4"
   resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.4.tgz#730b67e3cd09e2deacf03c027c81c9d9dbc5e8ab"
@@ -12496,13 +12470,20 @@ node-fetch-h2@^2.3.0:
   dependencies:
     http2-client "^1.2.5"
 
-node-fetch@2.6.7, node-fetch@^2.3.0, node-fetch@^2.6.1, node-fetch@^2.6.7:
+node-fetch@2.6.7, node-fetch@^2.3.0, node-fetch@^2.6.1:
   version "2.6.7"
   resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.7.tgz#24de9fba827e3b4ae44dc8b20256a379160052ad"
   integrity sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ==
   dependencies:
     whatwg-url "^5.0.0"
 
+node-fetch@^2.6.7:
+  version "2.7.0"
+  resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.7.0.tgz#d0f0fa6e3e2dc1d27efcd8ad99d550bda94d187d"
+  integrity sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==
+  dependencies:
+    whatwg-url "^5.0.0"
+
 node-forge@^0.10.0:
   version "0.10.0"
   resolved "https://registry.yarnpkg.com/node-forge/-/node-forge-0.10.0.tgz#32dea2afb3e9926f02ee5ce8794902691a676bf3"
@@ -13551,13 +13532,6 @@ process-warning@^2.1.0:
   resolved "https://registry.yarnpkg.com/process-warning/-/process-warning-2.2.0.tgz#008ec76b579820a8e5c35d81960525ca64feb626"
   integrity sha512-/1WZ8+VQjR6avWOgHeEPd7SDQmFQ1B5mC1eRXsCm5TarlNmx/wCsa5GEaxGm05BORRtyG/Ex/3xq3TuRvq57qg==
 
-prom-client@^14.1.1:
-  version "14.1.1"
-  resolved "https://registry.yarnpkg.com/prom-client/-/prom-client-14.1.1.tgz#e9bebef0e2269bfde22a322f4ca803cb52b4a0c0"
-  integrity sha512-hFU32q7UZQ59bVJQGUtm3I2PrJ3gWvoCkilX9sF165ks1qflhugVCeK+S1JjJYHvyt3o5kj68+q3bchormjnzw==
-  dependencies:
-    tdigest "^0.1.1"
-
 prompts@^2.0.1:
   version "2.1.0"
   resolved "https://registry.yarnpkg.com/prompts/-/prompts-2.1.0.tgz#bf90bc71f6065d255ea2bdc0fe6520485c1b45db"
@@ -13672,16 +13646,6 @@ qs@~6.5.2:
   resolved "https://registry.yarnpkg.com/qs/-/qs-6.5.2.tgz#cb3ae806e8740444584ef154ce8ee98d403f3e36"
   integrity sha512-N5ZAX4/LxJmF+7wN74pUD6qAh9/wnvdQcjq9TZjevvXzSUo7bfmw91saqMjzGS2xq91/odN2dW/WOl7qQHNDGA==
 
-query-string@^7.0.1:
-  version "7.1.3"
-  resolved "https://registry.yarnpkg.com/query-string/-/query-string-7.1.3.tgz#a1cf90e994abb113a325804a972d98276fe02328"
-  integrity sha512-hh2WYhq4fi8+b+/2Kg9CEge4fDPvHS534aOOvOZeQ3+Vf2mCFsaFBYj0i+iXcAq6I9Vzp5fjMFBlONvayDC1qg==
-  dependencies:
-    decode-uri-component "^0.2.2"
-    filter-obj "^1.1.0"
-    split-on-first "^1.0.0"
-    strict-uri-encode "^2.0.0"
-
 querystring@0.2.0:
   version "0.2.0"
   resolved "https://registry.yarnpkg.com/querystring/-/querystring-0.2.0.tgz#b209849203bb25df820da756e747005878521620"
@@ -14905,7 +14869,7 @@ serve-static@1.14.1:
 set-blocking@^2.0.0:
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/set-blocking/-/set-blocking-2.0.0.tgz#045f9782d011ae9a6803ddd382b24392b3d890f7"
-  integrity sha1-BF+XgtARrppoA93TgrJDkrPYkPc=
+  integrity sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==
 
 setimmediate@~1.0.4:
   version "1.0.5"
@@ -15306,11 +15270,6 @@ speech-rule-engine@^4.0.6:
     wicked-good-xpath "1.3.0"
     xmldom-sre "0.1.31"
 
-split-on-first@^1.0.0:
-  version "1.1.0"
-  resolved "https://registry.yarnpkg.com/split-on-first/-/split-on-first-1.1.0.tgz#f610afeee3b12bce1d0c30425e76398b78249a5f"
-  integrity sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw==
-
 sprintf-js@~1.0.2:
   version "1.0.3"
   resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c"
@@ -15427,11 +15386,6 @@ streamsearch@^1.1.0:
   resolved "https://registry.yarnpkg.com/streamsearch/-/streamsearch-1.1.0.tgz#404dd1e2247ca94af554e841a8ef0eaa238da764"
   integrity sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==
 
-strict-uri-encode@^2.0.0:
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/strict-uri-encode/-/strict-uri-encode-2.0.0.tgz#b9c7330c7042862f6b142dc274bbcc5866ce3546"
-  integrity sha512-QwiXZgpRcKkhTj2Scnn++4PKtWsH0kpzZ62L2R6c/LUVYv7hVnZqcg2+sMuT6R7Jusu1vviK/MFsu6kNJfWlEQ==
-
 string-argv@~0.3.1:
   version "0.3.1"
   resolved "https://registry.yarnpkg.com/string-argv/-/string-argv-0.3.1.tgz#95e2fbec0427ae19184935f816d74aaa4c5c19da"
@@ -15882,24 +15836,17 @@ tar-stream@^2.1.4, tar-stream@^2.2.0:
     readable-stream "^3.1.1"
 
 tar@^6.1.11:
-  version "6.1.11"
-  resolved "https://registry.yarnpkg.com/tar/-/tar-6.1.11.tgz#6760a38f003afa1b2ffd0ffe9e9abbd0eab3d621"
-  integrity sha512-an/KZQzQUkZCkuoAA64hM92X0Urb6VpRhAFllDzz44U2mcD5scmT3zBc4VgVpkugF580+DQn8eAFSyoQt0tznA==
+  version "6.2.0"
+  resolved "https://registry.yarnpkg.com/tar/-/tar-6.2.0.tgz#b14ce49a79cb1cd23bc9b016302dea5474493f73"
+  integrity sha512-/Wo7DcT0u5HUV486xg675HtjNd3BXZ6xDbzsCUZPt5iw8bTQ63bP0Raut3mvro9u+CUyq7YQd8Cx55fsZXxqLQ==
   dependencies:
     chownr "^2.0.0"
     fs-minipass "^2.0.0"
-    minipass "^3.0.0"
+    minipass "^5.0.0"
     minizlib "^2.1.1"
     mkdirp "^1.0.3"
     yallist "^4.0.0"
 
-tdigest@^0.1.1:
-  version "0.1.2"
-  resolved "https://registry.yarnpkg.com/tdigest/-/tdigest-0.1.2.tgz#96c64bac4ff10746b910b0e23b515794e12faced"
-  integrity sha512-+G0LLgjjo9BZX2MfdvPfH+MKLCrxlXSYec5DaPYP1fe6Iyhf0/fSmJ0bFiZ1F8BT6cGXl2LpltQptzjXKWEkKA==
-  dependencies:
-    bintrees "1.0.2"
-
 teeny-request@^7.0.0:
   version "7.1.0"
   resolved "https://registry.yarnpkg.com/teeny-request/-/teeny-request-7.1.0.tgz#be7593e62d5f2d656646a0c35fc7c3f18f6300f9"
@@ -16645,10 +16592,15 @@ url-join@^4.0.0:
   version "4.0.0"
   resolved "https://registry.yarnpkg.com/url-join/-/url-join-4.0.0.tgz#4d3340e807d3773bda9991f8305acdcc2a665d2a"
 
-url-template@^2.0.8:
-  version "2.0.8"
-  resolved "https://registry.yarnpkg.com/url-template/-/url-template-2.0.8.tgz#fc565a3cccbff7730c775f5641f9555791439f21"
-  integrity sha512-XdVKMF4SJ0nP/O7XIPB0JwAEuT9lDIYnNsK8yGVe43y0AWoKeJNdv3ZNWh7ksJ6KqQFjOO6ox/VEitLnaVNufw==
+url-join@^5.0.0:
+  version "5.0.0"
+  resolved "https://registry.yarnpkg.com/url-join/-/url-join-5.0.0.tgz#c2f1e5cbd95fa91082a93b58a1f42fecb4bdbcf1"
+  integrity sha512-n2huDr9h9yzd6exQVnH/jU5mr+Pfx08LRXXZhkLLetAMESRj+anQsTAh940iMrIetKAmry9coFuZQ2jY8/p3WA==
+
+url-template@^3.1.0:
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/url-template/-/url-template-3.1.0.tgz#d9be13d342ad31fcedc3c0bd21405fd141d02ff1"
+  integrity sha512-vB/eHWttzhN+NZzk9FcQB2h1cSEgb7zDYyvyxPhw02LYw7YqIzO+w1AqkcKvZ51gPH8o4+nyiWve/xuQqMdJZw==
 
 url-value-parser@2.2.0:
   version "2.2.0"