Преглед изворни кода

show progress bar for keycloak group sync

Futa Arai пре 2 година
родитељ
комит
f2ab365bc3

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

@@ -1,30 +1,84 @@
-import { FC, useCallback } from 'react';
+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 LabeledProgressBar from '~/components/Admin/Common/LabeledProgressBar';
+import { SocketEventName } from '~/interfaces/websocket';
+import { useAdminSocket } from '~/stores/socket-io';
+
+import { useSWRxExternalUserGroupList } from '../../stores/external-user-group';
 
 import { KeycloakGroupSyncSettingsForm } from './KeycloakGroupSyncSettingsForm';
 
 export const KeycloakGroupManagement: FC = () => {
   const { t } = useTranslation('admin');
+  const { data: socket } = useAdminSocket();
+  const { mutate: mutateExternalUserGroups } = useSWRxExternalUserGroupList();
+
+  const [syncStatus, setSyncStatus] = useState<'beforeSync' | 'syncExecuting' | 'syncFinished'>('beforeSync');
+  const [progress, setProgress] = useState({
+    total: 0,
+    current: 0,
+  });
+
+  useEffect(() => {
+    if (socket != null) {
+      socket.on(SocketEventName.GroupSyncProgress, (data) => {
+        setSyncStatus('syncExecuting');
+        setProgress({
+          total: data.totalCount,
+          current: data.count,
+        });
+      });
+
+      socket.on(SocketEventName.FinishGroupSync, () => {
+        setSyncStatus('syncFinished');
+      });
+    }
+  }, [socket]);
 
   const onSyncBtnClick = useCallback(async(e) => {
     e.preventDefault();
+    setProgress({ total: 0, current: 0 });
+    setSyncStatus('syncExecuting');
     try {
       await apiv3Put('/external-user-groups/keycloak/sync');
       toastSuccess(t('external_user_group.sync_succeeded'));
+      mutateExternalUserGroups();
     }
     catch (errs) {
       toastError(t(errs[0]?.code));
     }
-  }, [t]);
+  }, [t, mutateExternalUserGroups]);
+
+  const renderProgressBar = () => {
+    if (syncStatus === 'beforeSync') return null;
+
+    const header = syncStatus === 'syncExecuting' ? 'Processing..' : 'Completed';
+
+    return (
+      <LabeledProgressBar
+        header={header}
+        currentCount={progress.current}
+        totalCount={progress.total}
+      />
+    );
+  };
 
   return (
     <>
       <KeycloakGroupSyncSettingsForm />
       <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}>
         <div className="row">
           <div className="col-md-3"></div>

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

@@ -72,7 +72,7 @@ export const LdapGroupManagement: FC = () => {
       mutateExternalUserGroups();
     }
     catch (errs) {
-      toastError(t(errs[0]?.message));
+      toastError(t(errs[0]?.code));
     }
   }, [t, isUserBind, mutateExternalUserGroups]);
 

+ 19 - 8
apps/app/src/features/external-user-group/server/routes/apiv3/external-user-group.ts

@@ -17,7 +17,7 @@ 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 { keycloakUserGroupSyncService } from '../../service/keycloak-user-group-sync';
 import { ldapUserGroupSyncService } from '../../service/ldap-user-group-sync';
 
 const logger = loggerFactory('growi:routes:apiv3:external-user-group');
@@ -28,6 +28,10 @@ interface AuthorizedRequest extends Request {
   user?: any
 }
 
+const isExecutingSync = () => {
+  return ldapUserGroupSyncService?.isExecutingSync || keycloakUserGroupSyncService?.isExecutingSync || false;
+};
+
 module.exports = (crowi: Crowi): Router => {
   const loginRequiredStrictly = require('~/server/middlewares/login-required')(crowi);
   const adminRequired = require('~/server/middlewares/admin-required')(crowi);
@@ -307,20 +311,30 @@ module.exports = (crowi: Crowi): Router => {
     });
 
   router.put('/ldap/sync', loginRequiredStrictly, adminRequired, async(req: AuthorizedRequest, res: ApiV3Response) => {
-    if (ldapUserGroupSyncService?.isExecutingSync) return res.apiv3Err('external_user_group.sync_being_executed', 409);
+    if (isExecutingSync()) {
+      return res.apiv3Err(
+        new ErrorV3('There is an ongoing sync process', 'external_user_group.sync_being_executed'), 409,
+      );
+    }
 
     try {
       await ldapUserGroupSyncService?.syncExternalUserGroups({ userBindUsername: req.user.name, userBindPassword: req.body.password });
     }
     catch (err) {
       logger.error(err);
-      return res.apiv3Err(err.message, 500);
+      return res.apiv3Err(new ErrorV3('Sync failed', 'external_user_group.sync_failed'), 500);
     }
 
     return res.apiv3({}, 204);
   });
 
   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');
@@ -350,14 +364,11 @@ module.exports = (crowi: Crowi): Router => {
     }
 
     try {
-      const keycloakUserGroupSyncService = new KeycloakUserGroupSyncService(authProviderType);
-      await keycloakUserGroupSyncService.syncExternalUserGroups();
+      await keycloakUserGroupSyncService?.syncExternalUserGroups(authProviderType);
     }
     catch (err) {
       logger.error(err);
-      return res.apiv3Err(
-        new ErrorV3('Sync failed', 'external_user_group.sync_failed'), 500,
-      );
+      return res.apiv3Err(new ErrorV3('Sync failed', 'external_user_group.sync_failed'), 500);
     }
 
     return res.apiv3({}, 204);

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

@@ -24,16 +24,15 @@ abstract class ExternalUserGroupSyncService<SyncParamsType = any> {
 
   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)
 
   socketIoService: any;
 
   isExecutingSync = false;
 
   // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
-  constructor(groupProviderType: ExternalGroupProviderType, authProviderType: string, socketIoService) {
+  constructor(groupProviderType: ExternalGroupProviderType, socketIoService) {
     this.groupProviderType = groupProviderType;
-    this.authProviderType = authProviderType;
     this.socketIoService = socketIoService;
   }
 
@@ -122,13 +121,16 @@ abstract class ExternalUserGroupSyncService<SyncParamsType = any> {
    * @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,
-        }, this.authProviderType);
+        }, authProviderType);
       }
       return ExternalAccount.findOne({ providerType: this.groupProviderType, accountId: userInfo.id });
     };

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

@@ -20,19 +20,24 @@ class KeycloakUserGroupSyncService extends ExternalUserGroupSyncService {
 
   groupDescriptionAttribute: string; // attribute to map to group description
 
-  constructor(authProviderType: string) {
+  constructor(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, socketIoService);
     this.kcAdminClient = new KeycloakAdminClient({ baseUrl: kcHost, realmName: kcGroupSyncClientRealm });
     this.realm = kcGroupRealm;
     this.groupDescriptionAttribute = kcGroupDescriptionAttribute;
   }
 
-  async generateExternalUserGroupTrees(): Promise<ExternalUserGroupTreeNode[]> {
+  override syncExternalUserGroups(authProviderType: 'oidc' | 'saml'): Promise<void> {
+    this.authProviderType = authProviderType;
+    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.
@@ -120,4 +125,9 @@ class KeycloakUserGroupSyncService extends ExternalUserGroupSyncService {
 
 }
 
-export default KeycloakUserGroupSyncService;
+// eslint-disable-next-line import/no-mutable-exports
+export let keycloakUserGroupSyncService: KeycloakUserGroupSyncService | undefined; // singleton instance
+// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
+export function instanciate(socketIoService): void {
+  keycloakUserGroupSyncService = new KeycloakUserGroupSyncService(socketIoService);
+}

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

@@ -28,12 +28,13 @@ class LdapUserGroupSyncService extends ExternalUserGroupSyncService<SyncParamsTy
 
   // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
   constructor(passportService, socketIoService) {
-    super(ExternalGroupProviderType.ldap, 'ldap', socketIoService);
+    super(ExternalGroupProviderType.ldap, socketIoService);
+    this.authProviderType = 'ldap';
     this.passportService = passportService;
     this.ldapService = new LdapService();
   }
 
-  async generateExternalUserGroupTrees(options?: SyncParamsType): Promise<ExternalUserGroupTreeNode[]> {
+  override async generateExternalUserGroupTrees(options?: SyncParamsType): 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');

+ 4 - 2
apps/app/src/server/crowi/index.js

@@ -10,6 +10,7 @@ import next from 'next';
 
 import pkg from '^/package.json';
 
+import { instanciate as instanciateKeycloakUserGroupSyncService } from '~/features/external-user-group/server/service/keycloak-user-group-sync';
 import { instanciate as instanciateLdapUserGroupSyncService } 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';
@@ -162,7 +163,7 @@ Crowi.prototype.init = async function() {
     this.setUpUserNotification(),
     // depends on passport service
     this.setupExternalAccountService(),
-    this.setupLdapUserGroupSyncService(),
+    this.setupExternalUserGroupSyncService(),
   ]);
 
   await this.autoInstall();
@@ -791,8 +792,9 @@ Crowi.prototype.setupExternalAccountService = function() {
 };
 
 // execute after setupPassport and socketIoService
-Crowi.prototype.setupLdapUserGroupSyncService = function() {
+Crowi.prototype.setupExternalUserGroupSyncService = function() {
   instanciateLdapUserGroupSyncService(this.passportService, this.socketIoService);
+  instanciateKeycloakUserGroupSyncService(this.socketIoService);
 };
 
 export default Crowi;