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

Merge pull request #10636 from growilabs/support/156162-176217-app-some-client-components-biome-6

support: Configure biome for some client components in app 6
Yuki Takei 3 месяцев назад
Родитель
Сommit
0859cb9d70
70 измененных файлов с 4144 добавлено и 2391 удалено
  1. 5 0
      apps/app/.eslintrc.js
  2. 110 75
      apps/app/src/client/components/Admin/App/AppSetting.jsx
  3. 54 32
      apps/app/src/client/components/Admin/App/AppSettingsPageContents.tsx
  4. 50 19
      apps/app/src/client/components/Admin/App/AwsSetting.tsx
  5. 111 30
      apps/app/src/client/components/Admin/App/AzureSetting.tsx
  6. 22 25
      apps/app/src/client/components/Admin/App/ConfirmModal.tsx
  7. 60 49
      apps/app/src/client/components/Admin/App/FileUploadSetting.tsx
  8. 36 30
      apps/app/src/client/components/Admin/App/FileUploadSetting.types.ts
  9. 80 24
      apps/app/src/client/components/Admin/App/GcsSetting.tsx
  10. 76 50
      apps/app/src/client/components/Admin/App/MailSetting.tsx
  11. 53 20
      apps/app/src/client/components/Admin/App/MaintenanceMode.tsx
  12. 26 21
      apps/app/src/client/components/Admin/App/MaskedInput.tsx
  13. 64 34
      apps/app/src/client/components/Admin/App/PageBulkExportSettings.tsx
  14. 16 10
      apps/app/src/client/components/Admin/App/SesSetting.tsx
  15. 61 34
      apps/app/src/client/components/Admin/App/SiteUrlSetting.tsx
  16. 26 11
      apps/app/src/client/components/Admin/App/SmtpSetting.tsx
  17. 43 31
      apps/app/src/client/components/Admin/App/V5PageMigration.tsx
  18. 42 18
      apps/app/src/client/components/Admin/App/useFileUploadSettings.spec.ts
  19. 54 28
      apps/app/src/client/components/Admin/App/useFileUploadSettings.ts
  20. 39 25
      apps/app/src/client/components/Admin/SlackIntegration/BotTypeCard.tsx
  21. 43 29
      apps/app/src/client/components/Admin/SlackIntegration/Bridge.tsx
  22. 12 10
      apps/app/src/client/components/Admin/SlackIntegration/ConfirmBotChangeModal.jsx
  23. 26 14
      apps/app/src/client/components/Admin/SlackIntegration/CustomBotWithProxyConnectionStatus.tsx
  24. 99 47
      apps/app/src/client/components/Admin/SlackIntegration/CustomBotWithProxySettings.jsx
  25. 23 12
      apps/app/src/client/components/Admin/SlackIntegration/CustomBotWithoutProxyConnectionStatus.tsx
  26. 45 24
      apps/app/src/client/components/Admin/SlackIntegration/CustomBotWithoutProxySecretTokenSection.jsx
  27. 18 10
      apps/app/src/client/components/Admin/SlackIntegration/CustomBotWithoutProxySettings.jsx
  28. 157 49
      apps/app/src/client/components/Admin/SlackIntegration/CustomBotWithoutProxySettingsAccordion.jsx
  29. 81 79
      apps/app/src/client/components/Admin/SlackIntegration/DeleteSlackBotSettingsModal.tsx
  30. 149 103
      apps/app/src/client/components/Admin/SlackIntegration/ManageCommandsProcess.jsx
  31. 79 39
      apps/app/src/client/components/Admin/SlackIntegration/ManageCommandsProcessWithoutProxy.jsx
  32. 12 4
      apps/app/src/client/components/Admin/SlackIntegration/MessageBasedOnConnection.jsx
  33. 70 46
      apps/app/src/client/components/Admin/SlackIntegration/OfficialBotSettings.jsx
  34. 11 10
      apps/app/src/client/components/Admin/SlackIntegration/SlackAppIntegrationControl.tsx
  35. 49 37
      apps/app/src/client/components/Admin/SlackIntegration/SlackIntegration.tsx
  36. 282 107
      apps/app/src/client/components/Admin/SlackIntegration/WithProxyAccordions.jsx
  37. 5 1
      apps/app/src/client/components/Admin/SlackIntegration/slack-integration-util.ts
  38. 108 59
      apps/app/src/client/components/Admin/UserGroup/UserGroupDeleteModal.tsx
  39. 42 33
      apps/app/src/client/components/Admin/UserGroup/UserGroupDropdown.tsx
  40. 83 60
      apps/app/src/client/components/Admin/UserGroup/UserGroupForm.tsx
  41. 45 39
      apps/app/src/client/components/Admin/UserGroup/UserGroupModal.tsx
  42. 165 109
      apps/app/src/client/components/Admin/UserGroup/UserGroupPage.tsx
  43. 143 84
      apps/app/src/client/components/Admin/UserGroup/UserGroupTable.tsx
  44. 8 6
      apps/app/src/client/components/Admin/UserGroupDetail/CheckBoxForSerchUserOption.jsx
  45. 4 6
      apps/app/src/client/components/Admin/UserGroupDetail/RadioButtonForSerchUserOption.jsx
  46. 84 65
      apps/app/src/client/components/Admin/UserGroupDetail/UpdateParentConfirmModal.tsx
  47. 443 243
      apps/app/src/client/components/Admin/UserGroupDetail/UserGroupDetailPage.tsx
  48. 27 26
      apps/app/src/client/components/Admin/UserGroupDetail/UserGroupPageList.tsx
  49. 31 21
      apps/app/src/client/components/Admin/UserGroupDetail/UserGroupUserFormByInput.tsx
  50. 20 19
      apps/app/src/client/components/Admin/UserGroupDetail/UserGroupUserModal.tsx
  51. 67 44
      apps/app/src/client/components/Admin/UserGroupDetail/UserGroupUserTable.tsx
  52. 45 14
      apps/app/src/client/components/Admin/UserGroupDetail/use-user-group-resource.ts
  53. 98 58
      apps/app/src/client/components/Admin/Users/ExternalAccountTable.tsx
  54. 17 14
      apps/app/src/client/components/Admin/Users/GrantAdminButton.tsx
  55. 16 11
      apps/app/src/client/components/Admin/Users/GrantReadOnlyButton.tsx
  56. 9 6
      apps/app/src/client/components/Admin/Users/InviteUserControl.jsx
  57. 93 39
      apps/app/src/client/components/Admin/Users/PasswordResetModal.jsx
  58. 28 18
      apps/app/src/client/components/Admin/Users/RevokeAdminButton.tsx
  59. 29 24
      apps/app/src/client/components/Admin/Users/RevokeAdminMenuItem.tsx
  60. 18 13
      apps/app/src/client/components/Admin/Users/RevokeReadOnlyMenuItem.tsx
  61. 29 16
      apps/app/src/client/components/Admin/Users/SendInvitationEmailButton.jsx
  62. 16 13
      apps/app/src/client/components/Admin/Users/SortIcons.tsx
  63. 18 10
      apps/app/src/client/components/Admin/Users/StatusActivateButton.jsx
  64. 27 20
      apps/app/src/client/components/Admin/Users/StatusSuspendMenuItem.tsx
  65. 88 44
      apps/app/src/client/components/Admin/Users/UserInviteModal.jsx
  66. 62 30
      apps/app/src/client/components/Admin/Users/UserMenu.tsx
  67. 17 9
      apps/app/src/client/components/Admin/Users/UserRemoveButton.jsx
  68. 13 8
      apps/app/src/client/components/Admin/Users/UserStatisticsTable.tsx
  69. 48 42
      apps/app/src/client/components/Admin/Users/UserTable.tsx
  70. 14 1
      biome.json

+ 5 - 0
apps/app/.eslintrc.js

@@ -55,6 +55,11 @@ module.exports = {
     'src/client/components/*.jsx',
     'src/client/components/*.jsx',
     'src/client/components/*.ts',
     'src/client/components/*.ts',
     'src/client/components/*.js',
     'src/client/components/*.js',
+    'src/client/components/Admin/App/**',
+    'src/client/components/Admin/SlackIntegration/**',
+    'src/client/components/Admin/Users/**',
+    'src/client/components/Admin/UserGroup/**',
+    'src/client/components/Admin/UserGroupDetail/**',
     'src/client/components/Me/**',
     'src/client/components/Me/**',
     'src/client/components/Bookmarks/**',
     'src/client/components/Bookmarks/**',
     'src/client/components/InAppNotification/**',
     'src/client/components/InAppNotification/**',

+ 110 - 75
apps/app/src/client/components/Admin/App/AppSetting.jsx

@@ -1,31 +1,24 @@
 import React, { useCallback, useEffect } from 'react';
 import React, { useCallback, useEffect } from 'react';
-
-import { useTranslation, i18n } from 'next-i18next';
+import { i18n, useTranslation } from 'next-i18next';
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
 import { useForm } from 'react-hook-form';
 import { useForm } from 'react-hook-form';
 
 
 import { i18n as i18nConfig } from '^/config/next-i18next.config';
 import { i18n as i18nConfig } from '^/config/next-i18next.config';
 
 
 import AdminAppContainer from '~/client/services/AdminAppContainer';
 import AdminAppContainer from '~/client/services/AdminAppContainer';
-import { toastSuccess, toastError } from '~/client/util/toastr';
+import { toastError, toastSuccess } from '~/client/util/toastr';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
-
 import { withUnstatedContainers } from '../../UnstatedUtils';
 import { withUnstatedContainers } from '../../UnstatedUtils';
 import AdminUpdateButtonRow from '../Common/AdminUpdateButtonRow';
 import AdminUpdateButtonRow from '../Common/AdminUpdateButtonRow';
 
 
 const logger = loggerFactory('growi:appSettings');
 const logger = loggerFactory('growi:appSettings');
 
 
-
 const AppSetting = (props) => {
 const AppSetting = (props) => {
   const { adminAppContainer } = props;
   const { adminAppContainer } = props;
   const { t } = useTranslation(['admin', 'commons']);
   const { t } = useTranslation(['admin', 'commons']);
 
 
-  const {
-    register,
-    handleSubmit,
-    reset,
-  } = useForm();
+  const { register, handleSubmit, reset } = useForm();
 
 
   // Reset form when adminAppContainer state changes (e.g., after reload)
   // Reset form when adminAppContainer state changes (e.g., after reload)
   useEffect(() => {
   useEffect(() => {
@@ -34,8 +27,11 @@ const AppSetting = (props) => {
       confidential: adminAppContainer.state.confidential || '',
       confidential: adminAppContainer.state.confidential || '',
       globalLang: adminAppContainer.state.globalLang || 'en-US',
       globalLang: adminAppContainer.state.globalLang || 'en-US',
       // Convert boolean to string for radio button value
       // Convert boolean to string for radio button value
-      isEmailPublishedForNewUser: String(adminAppContainer.state.isEmailPublishedForNewUser ?? true),
-      isReadOnlyForNewUser: adminAppContainer.state.isReadOnlyForNewUser ?? false,
+      isEmailPublishedForNewUser: String(
+        adminAppContainer.state.isEmailPublishedForNewUser ?? true,
+      ),
+      isReadOnlyForNewUser:
+        adminAppContainer.state.isReadOnlyForNewUser ?? false,
     });
     });
   }, [
   }, [
     adminAppContainer.state.title,
     adminAppContainer.state.title,
@@ -46,47 +42,67 @@ const AppSetting = (props) => {
     reset,
     reset,
   ]);
   ]);
 
 
-  const onSubmit = useCallback(async(data) => {
-    try {
-      // Await all setState completions before API call
-      await Promise.all([
-        adminAppContainer.changeTitle(data.title),
-        adminAppContainer.changeConfidential(data.confidential),
-        adminAppContainer.changeGlobalLang(data.globalLang),
-      ]);
-      // Convert string 'true'/'false' to boolean
-      const isEmailPublished = data.isEmailPublishedForNewUser === 'true' || data.isEmailPublishedForNewUser === true;
-      await adminAppContainer.changeIsEmailPublishedForNewUserShow(isEmailPublished);
-      await adminAppContainer.changeIsReadOnlyForNewUserShow(data.isReadOnlyForNewUser);
-
-      await adminAppContainer.updateAppSettingHandler();
-      toastSuccess(t('commons:toaster.update_successed', { target: t('commons:headers.app_settings') }));
-    }
-    catch (err) {
-      toastError(err);
-      logger.error(err);
-    }
-  }, [adminAppContainer, t]);
-
+  const onSubmit = useCallback(
+    async (data) => {
+      try {
+        // Await all setState completions before API call
+        await Promise.all([
+          adminAppContainer.changeTitle(data.title),
+          adminAppContainer.changeConfidential(data.confidential),
+          adminAppContainer.changeGlobalLang(data.globalLang),
+        ]);
+        // Convert string 'true'/'false' to boolean
+        const isEmailPublished =
+          data.isEmailPublishedForNewUser === 'true' ||
+          data.isEmailPublishedForNewUser === true;
+        await adminAppContainer.changeIsEmailPublishedForNewUserShow(
+          isEmailPublished,
+        );
+        await adminAppContainer.changeIsReadOnlyForNewUserShow(
+          data.isReadOnlyForNewUser,
+        );
+
+        await adminAppContainer.updateAppSettingHandler();
+        toastSuccess(
+          t('commons:toaster.update_successed', {
+            target: t('commons:headers.app_settings'),
+          }),
+        );
+      } catch (err) {
+        toastError(err);
+        logger.error(err);
+      }
+    },
+    [adminAppContainer, t],
+  );
 
 
   return (
   return (
     <form onSubmit={handleSubmit(onSubmit)}>
     <form onSubmit={handleSubmit(onSubmit)}>
       <div className="row">
       <div className="row">
-        <label className="text-start text-md-end col-md-3 col-form-label">{t('admin:app_setting.site_name')}</label>
+        <label
+          className="text-start text-md-end col-md-3 col-form-label"
+          htmlFor="admin-app-setting-site-name"
+        >
+          {t('admin:app_setting.site_name')}
+        </label>
         <div className="col-md-6">
         <div className="col-md-6">
           <input
           <input
             className="form-control"
             className="form-control"
             type="text"
             type="text"
             placeholder="GROWI"
             placeholder="GROWI"
+            id="admin-app-setting-site-name"
             {...register('title')}
             {...register('title')}
           />
           />
-          <p className="form-text text-muted">{t('admin:app_setting.sitename_change')}</p>
+          <p className="form-text text-muted">
+            {t('admin:app_setting.sitename_change')}
+          </p>
         </div>
         </div>
       </div>
       </div>
 
 
       <div className="row mb-5">
       <div className="row mb-5">
         <label
         <label
           className="text-start text-md-end col-md-3 col-form-label"
           className="text-start text-md-end col-md-3 col-form-label"
+          htmlFor="admin-app-setting-confidential-name"
         >
         >
           {t('admin:app_setting.confidential_name')}
           {t('admin:app_setting.confidential_name')}
         </label>
         </label>
@@ -95,49 +111,52 @@ const AppSetting = (props) => {
             className="form-control"
             className="form-control"
             type="text"
             type="text"
             placeholder={t('admin:app_setting.confidential_example')}
             placeholder={t('admin:app_setting.confidential_example')}
+            id="admin-app-setting-confidential-name"
             {...register('confidential')}
             {...register('confidential')}
           />
           />
-          <p className="form-text text-muted">{t('admin:app_setting.header_content')}</p>
+          <p className="form-text text-muted">
+            {t('admin:app_setting.header_content')}
+          </p>
         </div>
         </div>
       </div>
       </div>
 
 
       <div className="row mb-5">
       <div className="row mb-5">
-        <label
-          className="text-start text-md-end col-md-3 col-form-label"
-        >
+        <span className="text-start text-md-end col-md-3 col-form-label">
           {t('admin:app_setting.default_language')}
           {t('admin:app_setting.default_language')}
-        </label>
+        </span>
         <div className="col-md-6 py-2">
         <div className="col-md-6 py-2">
-          {
-            i18nConfig.locales.map((locale) => {
-              if (i18n == null) { return }
-              const fixedT = i18n.getFixedT(locale, 'admin');
-
-              return (
-                <div key={locale} className="form-check form-check-inline">
-                  <input
-                    type="radio"
-                    id={`radioLang${locale}`}
-                    className="form-check-input"
-                    value={locale}
-                    {...register('globalLang')}
-                  />
-                  <label className="form-label form-check-label" htmlFor={`radioLang${locale}`}>{fixedT('meta.display_name')}</label>
-                </div>
-              );
-            })
-          }
+          {i18nConfig.locales.map((locale) => {
+            if (i18n == null) {
+              return null;
+            }
+            const fixedT = i18n.getFixedT(locale, 'admin');
+
+            return (
+              <div key={locale} className="form-check form-check-inline">
+                <input
+                  type="radio"
+                  id={`radioLang${locale}`}
+                  className="form-check-input"
+                  value={locale}
+                  {...register('globalLang')}
+                />
+                <label
+                  className="form-label form-check-label"
+                  htmlFor={`radioLang${locale}`}
+                >
+                  {fixedT('meta.display_name')}
+                </label>
+              </div>
+            );
+          })}
         </div>
         </div>
       </div>
       </div>
 
 
       <div className="row mb-5">
       <div className="row mb-5">
-        <label
-          className="text-start text-md-end col-md-3 col-form-label"
-        >
+        <span className="text-start text-md-end col-md-3 col-form-label">
           {t('admin:app_setting.default_mail_visibility')}
           {t('admin:app_setting.default_mail_visibility')}
-        </label>
+        </span>
         <div className="col-md-6 py-2">
         <div className="col-md-6 py-2">
-
           <div className="form-check form-check-inline">
           <div className="form-check form-check-inline">
             <input
             <input
               type="radio"
               type="radio"
@@ -146,7 +165,12 @@ const AppSetting = (props) => {
               value="true"
               value="true"
               {...register('isEmailPublishedForNewUser')}
               {...register('isEmailPublishedForNewUser')}
             />
             />
-            <label className="form-label form-check-label" htmlFor="radio-email-show">{t('commons:Show')}</label>
+            <label
+              className="form-label form-check-label"
+              htmlFor="radio-email-show"
+            >
+              {t('commons:Show')}
+            </label>
           </div>
           </div>
 
 
           <div className="form-check form-check-inline">
           <div className="form-check form-check-inline">
@@ -157,20 +181,24 @@ const AppSetting = (props) => {
               value="false"
               value="false"
               {...register('isEmailPublishedForNewUser')}
               {...register('isEmailPublishedForNewUser')}
             />
             />
-            <label className="form-label form-check-label" htmlFor="radio-email-hide">{t('commons:Hide')}</label>
+            <label
+              className="form-label form-check-label"
+              htmlFor="radio-email-hide"
+            >
+              {t('commons:Hide')}
+            </label>
           </div>
           </div>
-
         </div>
         </div>
       </div>
       </div>
 
 
       <div className="row mb-5">
       <div className="row mb-5">
         <label
         <label
           className="text-start text-md-end col-md-3 col-form-label"
           className="text-start text-md-end col-md-3 col-form-label"
+          htmlFor="checkbox-read-only-for-new-user"
         >
         >
           {t('admin:app_setting.default_read_only_for_new_user')}
           {t('admin:app_setting.default_read_only_for_new_user')}
         </label>
         </label>
         <div className="col-md-6 py-2">
         <div className="col-md-6 py-2">
-
           <div className="form-check form-check-inline">
           <div className="form-check form-check-inline">
             <input
             <input
               type="checkbox"
               type="checkbox"
@@ -178,26 +206,33 @@ const AppSetting = (props) => {
               className="form-check-input"
               className="form-check-input"
               {...register('isReadOnlyForNewUser')}
               {...register('isReadOnlyForNewUser')}
             />
             />
-            <label className="form-label form-check-label" htmlFor="checkbox-read-only-for-new-user">{t('admin:app_setting.set_read_only_for_new_user')}</label>
+            <label
+              className="form-label form-check-label"
+              htmlFor="checkbox-read-only-for-new-user"
+            >
+              {t('admin:app_setting.set_read_only_for_new_user')}
+            </label>
           </div>
           </div>
         </div>
         </div>
       </div>
       </div>
 
 
-      <AdminUpdateButtonRow type="submit" disabled={adminAppContainer.state.retrieveError != null} />
+      <AdminUpdateButtonRow
+        type="submit"
+        disabled={adminAppContainer.state.retrieveError != null}
+      />
     </form>
     </form>
   );
   );
-
 };
 };
 
 
-
 /**
 /**
  * Wrapper component for using unstated
  * Wrapper component for using unstated
  */
  */
-const AppSettingWrapper = withUnstatedContainers(AppSetting, [AdminAppContainer]);
+const AppSettingWrapper = withUnstatedContainers(AppSetting, [
+  AdminAppContainer,
+]);
 
 
 AppSetting.propTypes = {
 AppSetting.propTypes = {
   adminAppContainer: PropTypes.instanceOf(AdminAppContainer).isRequired,
   adminAppContainer: PropTypes.instanceOf(AdminAppContainer).isRequired,
 };
 };
 
 
-
 export default AppSettingWrapper;
 export default AppSettingWrapper;

+ 54 - 32
apps/app/src/client/components/Admin/App/AppSettingsPageContents.tsx

@@ -1,5 +1,4 @@
 import React, { useEffect } from 'react';
 import React, { useEffect } from 'react';
-
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 
 
 import AdminAppContainer from '~/client/services/AdminAppContainer';
 import AdminAppContainer from '~/client/services/AdminAppContainer';
@@ -9,7 +8,6 @@ import { toArrayIfNot } from '~/utils/array-utils';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
 import { withUnstatedContainers } from '../../UnstatedUtils';
-
 import AppSetting from './AppSetting';
 import AppSetting from './AppSetting';
 import FileUploadSetting from './FileUploadSetting';
 import FileUploadSetting from './FileUploadSetting';
 import MailSetting from './MailSetting';
 import MailSetting from './MailSetting';
@@ -18,12 +16,11 @@ import PageBulkExportSettings from './PageBulkExportSettings';
 import SiteUrlSetting from './SiteUrlSetting';
 import SiteUrlSetting from './SiteUrlSetting';
 import V5PageMigration from './V5PageMigration';
 import V5PageMigration from './V5PageMigration';
 
 
-
 const logger = loggerFactory('growi:appSettings');
 const logger = loggerFactory('growi:appSettings');
 
 
 type Props = {
 type Props = {
-  adminAppContainer: AdminAppContainer,
-}
+  adminAppContainer: AdminAppContainer;
+};
 
 
 const AppSettingsPageContents = (props: Props) => {
 const AppSettingsPageContents = (props: Props) => {
   const { t } = useTranslation('admin');
   const { t } = useTranslation('admin');
@@ -34,14 +31,13 @@ const AppSettingsPageContents = (props: Props) => {
   const { isV5Compatible } = adminAppContainer.state;
   const { isV5Compatible } = adminAppContainer.state;
 
 
   useEffect(() => {
   useEffect(() => {
-    const fetchAppSettingsData = async() => {
+    const fetchAppSettingsData = async () => {
       await adminAppContainer.retrieveAppSettingsData();
       await adminAppContainer.retrieveAppSettingsData();
     };
     };
 
 
     try {
     try {
       fetchAppSettingsData();
       fetchAppSettingsData();
-    }
-    catch (err) {
+    } catch (err) {
       const errs = toArrayIfNot(err);
       const errs = toArrayIfNot(err);
       toastError(errs);
       toastError(errs);
       logger.error(errs);
       logger.error(errs);
@@ -57,67 +53,90 @@ const AppSettingsPageContents = (props: Props) => {
             <h3 className="alert-heading">
             <h3 className="alert-heading">
               {t('admin:maintenance_mode.maintenance_mode')}
               {t('admin:maintenance_mode.maintenance_mode')}
             </h3>
             </h3>
-            <p>
-              {t('admin:maintenance_mode.description')}
-            </p>
+            <p>{t('admin:maintenance_mode.description')}</p>
             <hr />
             <hr />
-            <a className="btn-link" href="#maintenance-mode" rel="noopener noreferrer">
-              <span className="material-symbols-outlined ms-1" aria-hidden="true">expand_more</span>
-              <strong>{t('admin:maintenance_mode.end_maintenance_mode')}</strong>
+            <a
+              className="btn-link"
+              href="#maintenance-mode"
+              rel="noopener noreferrer"
+            >
+              <span
+                className="material-symbols-outlined ms-1"
+                aria-hidden="true"
+              >
+                expand_more
+              </span>
+              <strong>
+                {t('admin:maintenance_mode.end_maintenance_mode')}
+              </strong>
             </a>
             </a>
           </div>
           </div>
         )
         )
       }
       }
-      {
-        !isV5Compatible
-          && (
-            <div className="row">
-              <div className="col-lg-12">
-                <h2 className="admin-setting-header" data-testid="v5-page-migration">{t('V5 Page Migration')}</h2>
-                <V5PageMigration />
-              </div>
-            </div>
-          )
-      }
+      {!isV5Compatible && (
+        <div className="row">
+          <div className="col-lg-12">
+            <h2
+              className="admin-setting-header"
+              data-testid="v5-page-migration"
+            >
+              {t('V5 Page Migration')}
+            </h2>
+            <V5PageMigration />
+          </div>
+        </div>
+      )}
 
 
       <div className="row">
       <div className="row">
         <div className="col-lg-12">
         <div className="col-lg-12">
-          <h2 className="admin-setting-header">{t('headers.app_settings', { ns: 'commons' })}</h2>
+          <h2 className="admin-setting-header">
+            {t('headers.app_settings', { ns: 'commons' })}
+          </h2>
           <AppSetting />
           <AppSetting />
         </div>
         </div>
       </div>
       </div>
 
 
       <div className="row mt-5">
       <div className="row mt-5">
         <div className="col-lg-12">
         <div className="col-lg-12">
-          <h2 className="admin-setting-header">{t('app_setting.site_url.title')}</h2>
+          <h2 className="admin-setting-header">
+            {t('app_setting.site_url.title')}
+          </h2>
           <SiteUrlSetting />
           <SiteUrlSetting />
         </div>
         </div>
       </div>
       </div>
 
 
       <div className="row mt-5">
       <div className="row mt-5">
         <div className="col-lg-12">
         <div className="col-lg-12">
-          <h2 className="admin-setting-header" id="mail-settings">{t('app_setting.mail_settings')}</h2>
+          <h2 className="admin-setting-header" id="mail-settings">
+            {t('app_setting.mail_settings')}
+          </h2>
           <MailSetting />
           <MailSetting />
         </div>
         </div>
       </div>
       </div>
 
 
       <div className="row mt-5">
       <div className="row mt-5">
         <div className="col-lg-12">
         <div className="col-lg-12">
-          <h2 className="admin-setting-header">{t('admin:app_setting.file_upload_settings')}</h2>
+          <h2 className="admin-setting-header">
+            {t('admin:app_setting.file_upload_settings')}
+          </h2>
           <FileUploadSetting />
           <FileUploadSetting />
         </div>
         </div>
       </div>
       </div>
 
 
       <div className="row mt-5">
       <div className="row mt-5">
         <div className="col-lg-12">
         <div className="col-lg-12">
-          <h2 className="admin-setting-header">{t('admin:app_setting.page_bulk_export_settings')}</h2>
+          <h2 className="admin-setting-header">
+            {t('admin:app_setting.page_bulk_export_settings')}
+          </h2>
           <PageBulkExportSettings />
           <PageBulkExportSettings />
         </div>
         </div>
       </div>
       </div>
 
 
       <div className="row">
       <div className="row">
         <div className="col-lg-12">
         <div className="col-lg-12">
-          <h2 className="admin-setting-header" id="maintenance-mode">{t('admin:maintenance_mode.maintenance_mode')}</h2>
+          <h2 className="admin-setting-header" id="maintenance-mode">
+            {t('admin:maintenance_mode.maintenance_mode')}
+          </h2>
           <MaintenanceMode />
           <MaintenanceMode />
         </div>
         </div>
       </div>
       </div>
@@ -128,6 +147,9 @@ const AppSettingsPageContents = (props: Props) => {
 /**
 /**
  * Wrapper component for using unstated
  * Wrapper component for using unstated
  */
  */
-const AppSettingsPageContentsWrapper = withUnstatedContainers(AppSettingsPageContents, [AdminAppContainer]);
+const AppSettingsPageContentsWrapper = withUnstatedContainers(
+  AppSettingsPageContents,
+  [AdminAppContainer],
+);
 
 
 export default AppSettingsPageContentsWrapper;
 export default AppSettingsPageContentsWrapper;

+ 50 - 19
apps/app/src/client/components/Admin/App/AwsSetting.tsx

@@ -1,25 +1,26 @@
 import type { JSX } from 'react';
 import type { JSX } from 'react';
-
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 import type { UseFormRegister } from 'react-hook-form';
 import type { UseFormRegister } from 'react-hook-form';
 
 
 import type { FileUploadFormValues } from './FileUploadSetting.types';
 import type { FileUploadFormValues } from './FileUploadSetting.types';
 
 
 export type AwsSettingMoleculeProps = {
 export type AwsSettingMoleculeProps = {
-  register: UseFormRegister<FileUploadFormValues>
-  s3ReferenceFileWithRelayMode: boolean
-  onChangeS3ReferenceFileWithRelayMode: (val: boolean) => void
+  register: UseFormRegister<FileUploadFormValues>;
+  s3ReferenceFileWithRelayMode: boolean;
+  onChangeS3ReferenceFileWithRelayMode: (val: boolean) => void;
 };
 };
 
 
-export const AwsSettingMolecule = (props: AwsSettingMoleculeProps): JSX.Element => {
+export const AwsSettingMolecule = (
+  props: AwsSettingMoleculeProps,
+): JSX.Element => {
   const { t } = useTranslation();
   const { t } = useTranslation();
 
 
   return (
   return (
     <>
     <>
       <div className="row my-3">
       <div className="row my-3">
-        <label className="text-start text-md-end col-md-3 col-form-label">
+        <span className="text-start text-md-end col-md-3 col-form-label">
           {t('admin:app_setting.file_delivery_method')}
           {t('admin:app_setting.file_delivery_method')}
-        </label>
+        </span>
 
 
         <div className="col-md-6">
         <div className="col-md-6">
           <div className="dropdown">
           <div className="dropdown">
@@ -31,21 +32,27 @@ export const AwsSettingMolecule = (props: AwsSettingMoleculeProps): JSX.Element
               aria-haspopup="true"
               aria-haspopup="true"
               aria-expanded="true"
               aria-expanded="true"
             >
             >
-              {props.s3ReferenceFileWithRelayMode && t('admin:app_setting.file_delivery_method_relay')}
-              {!props.s3ReferenceFileWithRelayMode && t('admin:app_setting.file_delivery_method_redirect')}
+              {props.s3ReferenceFileWithRelayMode &&
+                t('admin:app_setting.file_delivery_method_relay')}
+              {!props.s3ReferenceFileWithRelayMode &&
+                t('admin:app_setting.file_delivery_method_redirect')}
             </button>
             </button>
-            <div className="dropdown-menu" aria-labelledby="ddS3ReferenceFileWithRelayMode">
+            <div className="dropdown-menu">
               <button
               <button
                 className="dropdown-item"
                 className="dropdown-item"
                 type="button"
                 type="button"
-                onClick={() => { props.onChangeS3ReferenceFileWithRelayMode(true) }}
+                onClick={() => {
+                  props.onChangeS3ReferenceFileWithRelayMode(true);
+                }}
               >
               >
                 {t('admin:app_setting.file_delivery_method_relay')}
                 {t('admin:app_setting.file_delivery_method_relay')}
               </button>
               </button>
               <button
               <button
                 className="dropdown-item"
                 className="dropdown-item"
                 type="button"
                 type="button"
-                onClick={() => { props.onChangeS3ReferenceFileWithRelayMode(false) }}
+                onClick={() => {
+                  props.onChangeS3ReferenceFileWithRelayMode(false);
+                }}
               >
               >
                 {t('admin:app_setting.file_delivery_method_redirect')}
                 {t('admin:app_setting.file_delivery_method_redirect')}
               </button>
               </button>
@@ -61,20 +68,27 @@ export const AwsSettingMolecule = (props: AwsSettingMoleculeProps): JSX.Element
       </div>
       </div>
 
 
       <div className="row">
       <div className="row">
-        <label className="text-start text-md-end col-md-3 col-form-label">
+        <label
+          className="text-start text-md-end col-md-3 col-form-label"
+          htmlFor="admin-aws-setting-region"
+        >
           {t('admin:app_setting.region')}
           {t('admin:app_setting.region')}
         </label>
         </label>
         <div className="col-md-6">
         <div className="col-md-6">
           <input
           <input
             className="form-control"
             className="form-control"
             placeholder={`${t('eg')} ap-northeast-1`}
             placeholder={`${t('eg')} ap-northeast-1`}
+            id="admin-aws-setting-region"
             {...props.register('s3Region')}
             {...props.register('s3Region')}
           />
           />
         </div>
         </div>
       </div>
       </div>
 
 
       <div className="row">
       <div className="row">
-        <label className="text-start text-md-end col-md-3 col-form-label">
+        <label
+          className="text-start text-md-end col-md-3 col-form-label"
+          htmlFor="admin-aws-setting-custom-endpoint"
+        >
           {t('admin:app_setting.custom_endpoint')}
           {t('admin:app_setting.custom_endpoint')}
         </label>
         </label>
         <div className="col-md-6">
         <div className="col-md-6">
@@ -82,14 +96,20 @@ export const AwsSettingMolecule = (props: AwsSettingMoleculeProps): JSX.Element
             className="form-control"
             className="form-control"
             type="text"
             type="text"
             placeholder={`${t('eg')} http://localhost:9000`}
             placeholder={`${t('eg')} http://localhost:9000`}
+            id="admin-aws-setting-custom-endpoint"
             {...props.register('s3CustomEndpoint')}
             {...props.register('s3CustomEndpoint')}
           />
           />
-          <p className="form-text text-muted">{t('admin:app_setting.custom_endpoint_change')}</p>
+          <p className="form-text text-muted">
+            {t('admin:app_setting.custom_endpoint_change')}
+          </p>
         </div>
         </div>
       </div>
       </div>
 
 
       <div className="row">
       <div className="row">
-        <label className="text-start text-md-end col-md-3 col-form-label">
+        <label
+          className="text-start text-md-end col-md-3 col-form-label"
+          htmlFor="admin-aws-setting-bucket-name"
+        >
           {t('admin:app_setting.bucket_name')}
           {t('admin:app_setting.bucket_name')}
         </label>
         </label>
         <div className="col-md-6">
         <div className="col-md-6">
@@ -97,35 +117,46 @@ export const AwsSettingMolecule = (props: AwsSettingMoleculeProps): JSX.Element
             className="form-control"
             className="form-control"
             type="text"
             type="text"
             placeholder={`${t('eg')} crowi`}
             placeholder={`${t('eg')} crowi`}
+            id="admin-aws-setting-bucket-name"
             {...props.register('s3Bucket')}
             {...props.register('s3Bucket')}
           />
           />
         </div>
         </div>
       </div>
       </div>
 
 
       <div className="row">
       <div className="row">
-        <label className="text-start text-md-end col-md-3 col-form-label">
+        <label
+          className="text-start text-md-end col-md-3 col-form-label"
+          htmlFor="admin-aws-setting-access-key-id"
+        >
           Access key ID
           Access key ID
         </label>
         </label>
         <div className="col-md-6">
         <div className="col-md-6">
           <input
           <input
             className="form-control"
             className="form-control"
             type="text"
             type="text"
+            id="admin-aws-setting-access-key-id"
             {...props.register('s3AccessKeyId')}
             {...props.register('s3AccessKeyId')}
           />
           />
         </div>
         </div>
       </div>
       </div>
 
 
       <div className="row">
       <div className="row">
-        <label className="text-start text-md-end col-md-3 col-form-label">
+        <label
+          className="text-start text-md-end col-md-3 col-form-label"
+          htmlFor="admin-aws-setting-secret-access-key"
+        >
           Secret access key
           Secret access key
         </label>
         </label>
         <div className="col-md-6">
         <div className="col-md-6">
           <input
           <input
             className="form-control"
             className="form-control"
             type="text"
             type="text"
+            id="admin-aws-setting-secret-access-key"
             {...props.register('s3SecretAccessKey')}
             {...props.register('s3SecretAccessKey')}
           />
           />
-          <p className="form-text text-muted">{t('admin:app_setting.s3_secret_access_key_input_description')}</p>
+          <p className="form-text text-muted">
+            {t('admin:app_setting.s3_secret_access_key_input_description')}
+          </p>
         </div>
         </div>
       </div>
       </div>
     </>
     </>

+ 111 - 30
apps/app/src/client/components/Admin/App/AzureSetting.tsx

@@ -1,5 +1,4 @@
 import type { JSX } from 'react';
 import type { JSX } from 'react';
-
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 import type { UseFormRegister } from 'react-hook-form';
 import type { UseFormRegister } from 'react-hook-form';
 
 
@@ -7,18 +6,20 @@ import type { FileUploadFormValues } from './FileUploadSetting.types';
 import MaskedInput from './MaskedInput';
 import MaskedInput from './MaskedInput';
 
 
 export type AzureSettingMoleculeProps = {
 export type AzureSettingMoleculeProps = {
-  register: UseFormRegister<FileUploadFormValues>
-  azureReferenceFileWithRelayMode: boolean
-  azureUseOnlyEnvVars: boolean
-  envAzureTenantId?: string
-  envAzureClientId?: string
-  envAzureClientSecret?: string
-  envAzureStorageAccountName?: string
-  envAzureStorageContainerName?: string
-  onChangeAzureReferenceFileWithRelayMode: (val: boolean) => void
+  register: UseFormRegister<FileUploadFormValues>;
+  azureReferenceFileWithRelayMode: boolean;
+  azureUseOnlyEnvVars: boolean;
+  envAzureTenantId?: string;
+  envAzureClientId?: string;
+  envAzureClientSecret?: string;
+  envAzureStorageAccountName?: string;
+  envAzureStorageContainerName?: string;
+  onChangeAzureReferenceFileWithRelayMode: (val: boolean) => void;
 };
 };
 
 
-export const AzureSettingMolecule = (props: AzureSettingMoleculeProps): JSX.Element => {
+export const AzureSettingMolecule = (
+  props: AzureSettingMoleculeProps,
+): JSX.Element => {
   const { t } = useTranslation();
   const { t } = useTranslation();
 
 
   const {
   const {
@@ -34,9 +35,9 @@ export const AzureSettingMolecule = (props: AzureSettingMoleculeProps): JSX.Elem
   return (
   return (
     <>
     <>
       <div className="row form-group my-3">
       <div className="row form-group my-3">
-        <label className="text-left text-md-right col-md-3 col-form-label">
+        <span className="text-left text-md-right col-md-3 col-form-label">
           {t('admin:app_setting.file_delivery_method')}
           {t('admin:app_setting.file_delivery_method')}
-        </label>
+        </span>
 
 
         <div className="col-md-6">
         <div className="col-md-6">
           <div className="dropdown">
           <div className="dropdown">
@@ -48,21 +49,27 @@ export const AzureSettingMolecule = (props: AzureSettingMoleculeProps): JSX.Elem
               aria-haspopup="true"
               aria-haspopup="true"
               aria-expanded="true"
               aria-expanded="true"
             >
             >
-              {azureReferenceFileWithRelayMode && t('admin:app_setting.file_delivery_method_relay')}
-              {!azureReferenceFileWithRelayMode && t('admin:app_setting.file_delivery_method_redirect')}
+              {azureReferenceFileWithRelayMode &&
+                t('admin:app_setting.file_delivery_method_relay')}
+              {!azureReferenceFileWithRelayMode &&
+                t('admin:app_setting.file_delivery_method_redirect')}
             </button>
             </button>
-            <div className="dropdown-menu" aria-labelledby="ddAzureReferenceFileWithRelayMode">
+            <div className="dropdown-menu">
               <button
               <button
                 className="dropdown-item"
                 className="dropdown-item"
                 type="button"
                 type="button"
-                onClick={() => { props.onChangeAzureReferenceFileWithRelayMode(true) }}
+                onClick={() => {
+                  props.onChangeAzureReferenceFileWithRelayMode(true);
+                }}
               >
               >
                 {t('admin:app_setting.file_delivery_method_relay')}
                 {t('admin:app_setting.file_delivery_method_relay')}
               </button>
               </button>
               <button
               <button
                 className="dropdown-item"
                 className="dropdown-item"
                 type="button"
                 type="button"
-                onClick={() => { props.onChangeAzureReferenceFileWithRelayMode(false) }}
+                onClick={() => {
+                  props.onChangeAzureReferenceFileWithRelayMode(false);
+                }}
               >
               >
                 {t('admin:app_setting.file_delivery_method_redirect')}
                 {t('admin:app_setting.file_delivery_method_redirect')}
               </button>
               </button>
@@ -81,10 +88,17 @@ export const AzureSettingMolecule = (props: AzureSettingMoleculeProps): JSX.Elem
         <p
         <p
           className="alert alert-info"
           className="alert alert-info"
           // eslint-disable-next-line react/no-danger
           // eslint-disable-next-line react/no-danger
-          dangerouslySetInnerHTML={{ __html: t('admin:app_setting.azure_note_for_the_only_env_option', { env: 'AZURE_USES_ONLY_ENV_VARS_FOR_SOME_OPTIONS' }) }}
+          // biome-ignore lint/security/noDangerouslySetInnerHtml: includes <br> and <code> from i18n strings
+          dangerouslySetInnerHTML={{
+            __html: t('admin:app_setting.azure_note_for_the_only_env_option', {
+              env: 'AZURE_USES_ONLY_ENV_VARS_FOR_SOME_OPTIONS',
+            }),
+          }}
         />
         />
       )}
       )}
-      <table className={`table settings-table ${azureUseOnlyEnvVars && 'use-only-env-vars'}`}>
+      <table
+        className={`table settings-table ${azureUseOnlyEnvVars && 'use-only-env-vars'}`}
+      >
         <colgroup>
         <colgroup>
           <col className="item-name" />
           <col className="item-name" />
           <col className="from-db" />
           <col className="from-db" />
@@ -108,10 +122,23 @@ export const AzureSettingMolecule = (props: AzureSettingMoleculeProps): JSX.Elem
               />
               />
             </td>
             </td>
             <td>
             <td>
-              <MaskedInput name="envAzureTenantId" value={envAzureTenantId || ''} readOnly tabIndex={-1} />
+              <MaskedInput
+                name="envAzureTenantId"
+                value={envAzureTenantId || ''}
+                readOnly
+                tabIndex={-1}
+              />
               <p className="form-text text-muted">
               <p className="form-text text-muted">
                 {/* eslint-disable-next-line react/no-danger */}
                 {/* eslint-disable-next-line react/no-danger */}
-                <small dangerouslySetInnerHTML={{ __html: t('admin:app_setting.use_env_var_if_empty', { variable: 'AZURE_TENANT_ID' }) }} />
+                <small
+                  // eslint-disable-next-line react/no-danger
+                  // biome-ignore lint/security/noDangerouslySetInnerHtml: includes markup from i18n strings
+                  dangerouslySetInnerHTML={{
+                    __html: t('admin:app_setting.use_env_var_if_empty', {
+                      variable: 'AZURE_TENANT_ID',
+                    }),
+                  }}
+                />
               </p>
               </p>
             </td>
             </td>
           </tr>
           </tr>
@@ -125,10 +152,23 @@ export const AzureSettingMolecule = (props: AzureSettingMoleculeProps): JSX.Elem
               />
               />
             </td>
             </td>
             <td>
             <td>
-              <MaskedInput name="envAzureClientId" value={envAzureClientId || ''} readOnly tabIndex={-1} />
+              <MaskedInput
+                name="envAzureClientId"
+                value={envAzureClientId || ''}
+                readOnly
+                tabIndex={-1}
+              />
               <p className="form-text text-muted">
               <p className="form-text text-muted">
                 {/* eslint-disable-next-line react/no-danger */}
                 {/* eslint-disable-next-line react/no-danger */}
-                <small dangerouslySetInnerHTML={{ __html: t('admin:app_setting.use_env_var_if_empty', { variable: 'AZURE_CLIENT_ID' }) }} />
+                <small
+                  // eslint-disable-next-line react/no-danger
+                  // biome-ignore lint/security/noDangerouslySetInnerHtml: includes markup from i18n strings
+                  dangerouslySetInnerHTML={{
+                    __html: t('admin:app_setting.use_env_var_if_empty', {
+                      variable: 'AZURE_CLIENT_ID',
+                    }),
+                  }}
+                />
               </p>
               </p>
             </td>
             </td>
           </tr>
           </tr>
@@ -142,10 +182,23 @@ export const AzureSettingMolecule = (props: AzureSettingMoleculeProps): JSX.Elem
               />
               />
             </td>
             </td>
             <td>
             <td>
-              <MaskedInput name="envAzureClientSecret" value={envAzureClientSecret || ''} readOnly tabIndex={-1} />
+              <MaskedInput
+                name="envAzureClientSecret"
+                value={envAzureClientSecret || ''}
+                readOnly
+                tabIndex={-1}
+              />
               <p className="form-text text-muted">
               <p className="form-text text-muted">
                 {/* eslint-disable-next-line react/no-danger */}
                 {/* eslint-disable-next-line react/no-danger */}
-                <small dangerouslySetInnerHTML={{ __html: t('admin:app_setting.use_env_var_if_empty', { variable: 'AZURE_CLIENT_SECRET' }) }} />
+                <small
+                  // eslint-disable-next-line react/no-danger
+                  // biome-ignore lint/security/noDangerouslySetInnerHtml: includes markup from i18n strings
+                  dangerouslySetInnerHTML={{
+                    __html: t('admin:app_setting.use_env_var_if_empty', {
+                      variable: 'AZURE_CLIENT_SECRET',
+                    }),
+                  }}
+                />
               </p>
               </p>
             </td>
             </td>
           </tr>
           </tr>
@@ -160,10 +213,24 @@ export const AzureSettingMolecule = (props: AzureSettingMoleculeProps): JSX.Elem
               />
               />
             </td>
             </td>
             <td>
             <td>
-              <input className="form-control" type="text" value={envAzureStorageAccountName || ''} readOnly tabIndex={-1} />
+              <input
+                className="form-control"
+                type="text"
+                value={envAzureStorageAccountName || ''}
+                readOnly
+                tabIndex={-1}
+              />
               <p className="form-text text-muted">
               <p className="form-text text-muted">
                 {/* eslint-disable-next-line react/no-danger */}
                 {/* eslint-disable-next-line react/no-danger */}
-                <small dangerouslySetInnerHTML={{ __html: t('admin:app_setting.use_env_var_if_empty', { variable: 'AZURE_STORAGE_ACCOUNT_NAME' }) }} />
+                <small
+                  // eslint-disable-next-line react/no-danger
+                  // biome-ignore lint/security/noDangerouslySetInnerHtml: includes markup from i18n strings
+                  dangerouslySetInnerHTML={{
+                    __html: t('admin:app_setting.use_env_var_if_empty', {
+                      variable: 'AZURE_STORAGE_ACCOUNT_NAME',
+                    }),
+                  }}
+                />
               </p>
               </p>
             </td>
             </td>
           </tr>
           </tr>
@@ -178,10 +245,24 @@ export const AzureSettingMolecule = (props: AzureSettingMoleculeProps): JSX.Elem
               />
               />
             </td>
             </td>
             <td>
             <td>
-              <input className="form-control" type="text" value={envAzureStorageContainerName || ''} readOnly tabIndex={-1} />
+              <input
+                className="form-control"
+                type="text"
+                value={envAzureStorageContainerName || ''}
+                readOnly
+                tabIndex={-1}
+              />
               <p className="form-text text-muted">
               <p className="form-text text-muted">
                 {/* eslint-disable-next-line react/no-danger */}
                 {/* eslint-disable-next-line react/no-danger */}
-                <small dangerouslySetInnerHTML={{ __html: t('admin:app_setting.use_env_var_if_empty', { variable: 'AZURE_STORAGE_CONTAINER_NAME' }) }} />
+                <small
+                  // eslint-disable-next-line react/no-danger
+                  // biome-ignore lint/security/noDangerouslySetInnerHtml: includes markup from i18n strings
+                  dangerouslySetInnerHTML={{
+                    __html: t('admin:app_setting.use_env_var_if_empty', {
+                      variable: 'AZURE_STORAGE_CONTAINER_NAME',
+                    }),
+                  }}
+                />
               </p>
               </p>
             </td>
             </td>
           </tr>
           </tr>

+ 22 - 25
apps/app/src/client/components/Admin/App/ConfirmModal.tsx

@@ -1,21 +1,20 @@
 import type { FC } from 'react';
 import type { FC } from 'react';
 import React from 'react';
 import React from 'react';
-
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
-import {
-  Modal, ModalHeader, ModalBody, ModalFooter,
-} from 'reactstrap';
+import { Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
 
 
 type ConfirmModalProps = {
 type ConfirmModalProps = {
-  isModalOpen: boolean
-  warningMessage: string
-  supplymentaryMessage: string | null
-  confirmButtonTitle: string
-  onConfirm?: () => Promise<void>
-  onCancel?: () => void
+  isModalOpen: boolean;
+  warningMessage: string;
+  supplymentaryMessage: string | null;
+  confirmButtonTitle: string;
+  onConfirm?: () => Promise<void>;
+  onCancel?: () => void;
 };
 };
 
 
-export const ConfirmModal: FC<ConfirmModalProps> = (props: ConfirmModalProps) => {
+export const ConfirmModal: FC<ConfirmModalProps> = (
+  props: ConfirmModalProps,
+) => {
   const { t } = useTranslation();
   const { t } = useTranslation();
 
 
   const onCancel = () => {
   const onCancel = () => {
@@ -38,20 +37,18 @@ export const ConfirmModal: FC<ConfirmModalProps> = (props: ConfirmModalProps) =>
       </ModalHeader>
       </ModalHeader>
       <ModalBody>
       <ModalBody>
         {props.warningMessage}
         {props.warningMessage}
-        {
-          props.supplymentaryMessage != null && (
-            <>
-              <br />
-              <br />
-              <span className="text-warning">
-                <>
-                  <span className="material-symbols-outlined">error</span>
-                  {props.supplymentaryMessage}
-                </>
-              </span>
-            </>
-          )
-        }
+        {props.supplymentaryMessage != null && (
+          <>
+            <br />
+            <br />
+            <span className="text-warning">
+              <>
+                <span className="material-symbols-outlined">error</span>
+                {props.supplymentaryMessage}
+              </>
+            </span>
+          </>
+        )}
       </ModalBody>
       </ModalBody>
       <ModalFooter>
       <ModalFooter>
         <button
         <button

+ 60 - 49
apps/app/src/client/components/Admin/App/FileUploadSetting.tsx

@@ -1,14 +1,12 @@
 import type { JSX } from 'react';
 import type { JSX } from 'react';
 import { useCallback } from 'react';
 import { useCallback } from 'react';
-
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
-import { useForm, useController } from 'react-hook-form';
+import { useController, useForm } from 'react-hook-form';
 
 
-import { toastSuccess, toastError } from '~/client/util/toastr';
+import { toastError, toastSuccess } from '~/client/util/toastr';
 import { FileUploadType } from '~/interfaces/file-uploader';
 import { FileUploadType } from '~/interfaces/file-uploader';
 
 
 import AdminUpdateButtonRow from '../Common/AdminUpdateButtonRow';
 import AdminUpdateButtonRow from '../Common/AdminUpdateButtonRow';
-
 import { AwsSettingMolecule } from './AwsSetting';
 import { AwsSettingMolecule } from './AwsSetting';
 import { AzureSettingMolecule } from './AzureSetting';
 import { AzureSettingMolecule } from './AzureSetting';
 import type { FileUploadFormValues } from './FileUploadSetting.types';
 import type { FileUploadFormValues } from './FileUploadSetting.types';
@@ -17,33 +15,33 @@ import { useFileUploadSettings } from './useFileUploadSettings';
 
 
 const FileUploadSetting = (): JSX.Element => {
 const FileUploadSetting = (): JSX.Element => {
   const { t } = useTranslation(['admin', 'commons']);
   const { t } = useTranslation(['admin', 'commons']);
-  const {
-    data, isLoading, error, updateSettings,
-  } = useFileUploadSettings();
-
-  const {
-    register, handleSubmit, control, watch, formState,
-  } = useForm<FileUploadFormValues>({
-    values: data ? {
-      fileUploadType: data.fileUploadType,
-      s3Region: data.s3Region,
-      s3CustomEndpoint: data.s3CustomEndpoint,
-      s3Bucket: data.s3Bucket,
-      s3AccessKeyId: data.s3AccessKeyId,
-      s3SecretAccessKey: data.s3SecretAccessKey,
-      s3ReferenceFileWithRelayMode: data.s3ReferenceFileWithRelayMode,
-      gcsApiKeyJsonPath: data.gcsApiKeyJsonPath,
-      gcsBucket: data.gcsBucket,
-      gcsUploadNamespace: data.gcsUploadNamespace,
-      gcsReferenceFileWithRelayMode: data.gcsReferenceFileWithRelayMode,
-      azureTenantId: data.azureTenantId,
-      azureClientId: data.azureClientId,
-      azureClientSecret: data.azureClientSecret,
-      azureStorageAccountName: data.azureStorageAccountName,
-      azureStorageContainerName: data.azureStorageContainerName,
-      azureReferenceFileWithRelayMode: data.azureReferenceFileWithRelayMode,
-    } : undefined,
-  });
+  const { data, isLoading, error, updateSettings } = useFileUploadSettings();
+
+  const { register, handleSubmit, control, watch, formState } =
+    useForm<FileUploadFormValues>({
+      values: data
+        ? {
+            fileUploadType: data.fileUploadType,
+            s3Region: data.s3Region,
+            s3CustomEndpoint: data.s3CustomEndpoint,
+            s3Bucket: data.s3Bucket,
+            s3AccessKeyId: data.s3AccessKeyId,
+            s3SecretAccessKey: data.s3SecretAccessKey,
+            s3ReferenceFileWithRelayMode: data.s3ReferenceFileWithRelayMode,
+            gcsApiKeyJsonPath: data.gcsApiKeyJsonPath,
+            gcsBucket: data.gcsBucket,
+            gcsUploadNamespace: data.gcsUploadNamespace,
+            gcsReferenceFileWithRelayMode: data.gcsReferenceFileWithRelayMode,
+            azureTenantId: data.azureTenantId,
+            azureClientId: data.azureClientId,
+            azureClientSecret: data.azureClientSecret,
+            azureStorageAccountName: data.azureStorageAccountName,
+            azureStorageContainerName: data.azureStorageContainerName,
+            azureReferenceFileWithRelayMode:
+              data.azureReferenceFileWithRelayMode,
+          }
+        : undefined,
+    });
 
 
   // Use controller for fileUploadType radio buttons
   // Use controller for fileUploadType radio buttons
   const { field: fileUploadTypeField } = useController({
   const { field: fileUploadTypeField } = useController({
@@ -69,15 +67,22 @@ const FileUploadSetting = (): JSX.Element => {
 
 
   const fileUploadType = watch('fileUploadType');
   const fileUploadType = watch('fileUploadType');
 
 
-  const onSubmit = useCallback(async(formData: FileUploadFormValues) => {
-    try {
-      await updateSettings(formData, formState.dirtyFields);
-      toastSuccess(t('toaster.update_successed', { target: t('admin:app_setting.file_upload_settings'), ns: 'commons' }));
-    }
-    catch (err) {
-      toastError(err);
-    }
-  }, [updateSettings, formState.dirtyFields, t]);
+  const onSubmit = useCallback(
+    async (formData: FileUploadFormValues) => {
+      try {
+        await updateSettings(formData, formState.dirtyFields);
+        toastSuccess(
+          t('toaster.update_successed', {
+            target: t('admin:app_setting.file_upload_settings'),
+            ns: 'commons',
+          }),
+        );
+      } catch (err) {
+        toastError(err);
+      }
+    },
+    [updateSettings, formState.dirtyFields, t],
+  );
 
 
   if (isLoading) {
   if (isLoading) {
     return <div>Loading...</div>;
     return <div>Loading...</div>;
@@ -98,9 +103,9 @@ const FileUploadSetting = (): JSX.Element => {
       </p>
       </p>
 
 
       <div className="row mb-3">
       <div className="row mb-3">
-        <label className="text-start text-md-end col-md-3 col-form-label">
+        <span className="text-start text-md-end col-md-3 col-form-label">
           {t('admin:app_setting.file_upload_method')}
           {t('admin:app_setting.file_upload_method')}
-        </label>
+        </span>
 
 
         <div className="col-md-6 py-2">
         <div className="col-md-6 py-2">
           {Object.values(FileUploadType).map((type) => {
           {Object.values(FileUploadType).map((type) => {
@@ -115,7 +120,10 @@ const FileUploadSetting = (): JSX.Element => {
                   disabled={data.isFixedFileUploadByEnvVar}
                   disabled={data.isFixedFileUploadByEnvVar}
                   onChange={() => fileUploadTypeField.onChange(type)}
                   onChange={() => fileUploadTypeField.onChange(type)}
                 />
                 />
-                <label className="form-label form-check-label" htmlFor={`file-upload-type-radio-${type}`}>
+                <label
+                  className="form-label form-check-label"
+                  htmlFor={`file-upload-type-radio-${type}`}
+                >
                   {t(`admin:app_setting.${type}_label`)}
                   {t(`admin:app_setting.${type}_label`)}
                 </label>
                 </label>
               </div>
               </div>
@@ -128,12 +136,15 @@ const FileUploadSetting = (): JSX.Element => {
             <b>FIXED</b>
             <b>FIXED</b>
             <br />
             <br />
             {/* eslint-disable-next-line react/no-danger */}
             {/* eslint-disable-next-line react/no-danger */}
-            <b dangerouslySetInnerHTML={{
-              __html: t('admin:app_setting.fixed_by_env_var', {
-                envKey: 'FILE_UPLOAD',
-                envVar: data.envFileUploadType,
-              }),
-            }}
+            <b
+              // eslint-disable-next-line react/no-danger
+              // biome-ignore lint/security/noDangerouslySetInnerHtml: includes markup from i18n strings
+              dangerouslySetInnerHTML={{
+                __html: t('admin:app_setting.fixed_by_env_var', {
+                  envKey: 'FILE_UPLOAD',
+                  envVar: data.envFileUploadType,
+                }),
+              }}
             />
             />
           </p>
           </p>
         )}
         )}

+ 36 - 30
apps/app/src/client/components/Admin/App/FileUploadSetting.types.ts

@@ -1,41 +1,47 @@
-export type FileUploadType = 'aws' | 'gcs' | 'azure' | 'local' | 'mongodb' | 'none';
+export type FileUploadType =
+  | 'aws'
+  | 'gcs'
+  | 'azure'
+  | 'local'
+  | 'mongodb'
+  | 'none';
 
 
 export type FileUploadFormValues = {
 export type FileUploadFormValues = {
-  fileUploadType: FileUploadType
+  fileUploadType: FileUploadType;
   // AWS S3
   // AWS S3
-  s3Region: string
-  s3CustomEndpoint: string
-  s3Bucket: string
-  s3AccessKeyId: string
-  s3SecretAccessKey: string
-  s3ReferenceFileWithRelayMode: boolean
+  s3Region: string;
+  s3CustomEndpoint: string;
+  s3Bucket: string;
+  s3AccessKeyId: string;
+  s3SecretAccessKey: string;
+  s3ReferenceFileWithRelayMode: boolean;
   // GCS
   // GCS
-  gcsApiKeyJsonPath: string
-  gcsBucket: string
-  gcsUploadNamespace: string
-  gcsReferenceFileWithRelayMode: boolean
+  gcsApiKeyJsonPath: string;
+  gcsBucket: string;
+  gcsUploadNamespace: string;
+  gcsReferenceFileWithRelayMode: boolean;
   // Azure
   // Azure
-  azureTenantId: string
-  azureClientId: string
-  azureClientSecret: string
-  azureStorageAccountName: string
-  azureStorageContainerName: string
-  azureReferenceFileWithRelayMode: boolean
+  azureTenantId: string;
+  azureClientId: string;
+  azureClientSecret: string;
+  azureStorageAccountName: string;
+  azureStorageContainerName: string;
+  azureReferenceFileWithRelayMode: boolean;
 };
 };
 
 
 export type FileUploadSettingsData = FileUploadFormValues & {
 export type FileUploadSettingsData = FileUploadFormValues & {
-  isFixedFileUploadByEnvVar: boolean
-  envFileUploadType?: string
+  isFixedFileUploadByEnvVar: boolean;
+  envFileUploadType?: string;
   // GCS env vars
   // GCS env vars
-  gcsUseOnlyEnvVars: boolean
-  envGcsApiKeyJsonPath?: string
-  envGcsBucket?: string
-  envGcsUploadNamespace?: string
+  gcsUseOnlyEnvVars: boolean;
+  envGcsApiKeyJsonPath?: string;
+  envGcsBucket?: string;
+  envGcsUploadNamespace?: string;
   // Azure env vars
   // Azure env vars
-  azureUseOnlyEnvVars: boolean
-  envAzureTenantId?: string
-  envAzureClientId?: string
-  envAzureClientSecret?: string
-  envAzureStorageAccountName?: string
-  envAzureStorageContainerName?: string
+  azureUseOnlyEnvVars: boolean;
+  envAzureTenantId?: string;
+  envAzureClientId?: string;
+  envAzureClientSecret?: string;
+  envAzureStorageAccountName?: string;
+  envAzureStorageContainerName?: string;
 };
 };

+ 80 - 24
apps/app/src/client/components/Admin/App/GcsSetting.tsx

@@ -1,21 +1,22 @@
 import type { JSX } from 'react';
 import type { JSX } from 'react';
-
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 import type { UseFormRegister } from 'react-hook-form';
 import type { UseFormRegister } from 'react-hook-form';
 
 
 import type { FileUploadFormValues } from './FileUploadSetting.types';
 import type { FileUploadFormValues } from './FileUploadSetting.types';
 
 
 export type GcsSettingMoleculeProps = {
 export type GcsSettingMoleculeProps = {
-  register: UseFormRegister<FileUploadFormValues>
-  gcsReferenceFileWithRelayMode: boolean
-  gcsUseOnlyEnvVars: boolean
-  envGcsApiKeyJsonPath?: string
-  envGcsBucket?: string
-  envGcsUploadNamespace?: string
-  onChangeGcsReferenceFileWithRelayMode: (val: boolean) => void
+  register: UseFormRegister<FileUploadFormValues>;
+  gcsReferenceFileWithRelayMode: boolean;
+  gcsUseOnlyEnvVars: boolean;
+  envGcsApiKeyJsonPath?: string;
+  envGcsBucket?: string;
+  envGcsUploadNamespace?: string;
+  onChangeGcsReferenceFileWithRelayMode: (val: boolean) => void;
 };
 };
 
 
-export const GcsSettingMolecule = (props: GcsSettingMoleculeProps): JSX.Element => {
+export const GcsSettingMolecule = (
+  props: GcsSettingMoleculeProps,
+): JSX.Element => {
   const { t } = useTranslation();
   const { t } = useTranslation();
 
 
   const {
   const {
@@ -29,9 +30,9 @@ export const GcsSettingMolecule = (props: GcsSettingMoleculeProps): JSX.Element
   return (
   return (
     <>
     <>
       <div className="row my-3">
       <div className="row my-3">
-        <label className="text-start text-md-end col-md-3 col-form-label">
+        <span className="text-start text-md-end col-md-3 col-form-label">
           {t('admin:app_setting.file_delivery_method')}
           {t('admin:app_setting.file_delivery_method')}
-        </label>
+        </span>
 
 
         <div className="col-md-6">
         <div className="col-md-6">
           <div className="dropdown">
           <div className="dropdown">
@@ -43,21 +44,27 @@ export const GcsSettingMolecule = (props: GcsSettingMoleculeProps): JSX.Element
               aria-haspopup="true"
               aria-haspopup="true"
               aria-expanded="true"
               aria-expanded="true"
             >
             >
-              {gcsReferenceFileWithRelayMode && t('admin:app_setting.file_delivery_method_relay')}
-              {!gcsReferenceFileWithRelayMode && t('admin:app_setting.file_delivery_method_redirect')}
+              {gcsReferenceFileWithRelayMode &&
+                t('admin:app_setting.file_delivery_method_relay')}
+              {!gcsReferenceFileWithRelayMode &&
+                t('admin:app_setting.file_delivery_method_redirect')}
             </button>
             </button>
-            <div className="dropdown-menu" aria-labelledby="ddGcsReferenceFileWithRelayMode">
+            <div className="dropdown-menu">
               <button
               <button
                 className="dropdown-item"
                 className="dropdown-item"
                 type="button"
                 type="button"
-                onClick={() => { props.onChangeGcsReferenceFileWithRelayMode(true) }}
+                onClick={() => {
+                  props.onChangeGcsReferenceFileWithRelayMode(true);
+                }}
               >
               >
                 {t('admin:app_setting.file_delivery_method_relay')}
                 {t('admin:app_setting.file_delivery_method_relay')}
               </button>
               </button>
               <button
               <button
                 className="dropdown-item"
                 className="dropdown-item"
                 type="button"
                 type="button"
-                onClick={() => { props.onChangeGcsReferenceFileWithRelayMode(false) }}
+                onClick={() => {
+                  props.onChangeGcsReferenceFileWithRelayMode(false);
+                }}
               >
               >
                 {t('admin:app_setting.file_delivery_method_redirect')}
                 {t('admin:app_setting.file_delivery_method_redirect')}
               </button>
               </button>
@@ -76,10 +83,17 @@ export const GcsSettingMolecule = (props: GcsSettingMoleculeProps): JSX.Element
         <p
         <p
           className="alert alert-info"
           className="alert alert-info"
           // eslint-disable-next-line react/no-danger
           // eslint-disable-next-line react/no-danger
-          dangerouslySetInnerHTML={{ __html: t('admin:app_setting.note_for_the_only_env_option', { env: 'GCS_USES_ONLY_ENV_VARS_FOR_SOME_OPTIONS' }) }}
+          // biome-ignore lint/security/noDangerouslySetInnerHtml: includes markup from i18n strings
+          dangerouslySetInnerHTML={{
+            __html: t('admin:app_setting.note_for_the_only_env_option', {
+              env: 'GCS_USES_ONLY_ENV_VARS_FOR_SOME_OPTIONS',
+            }),
+          }}
         />
         />
       )}
       )}
-      <table className={`table settings-table ${gcsUseOnlyEnvVars && 'use-only-env-vars'}`}>
+      <table
+        className={`table settings-table ${gcsUseOnlyEnvVars && 'use-only-env-vars'}`}
+      >
         <colgroup>
         <colgroup>
           <col className="item-name" />
           <col className="item-name" />
           <col className="from-db" />
           <col className="from-db" />
@@ -104,10 +118,24 @@ export const GcsSettingMolecule = (props: GcsSettingMoleculeProps): JSX.Element
               />
               />
             </td>
             </td>
             <td>
             <td>
-              <input className="form-control" type="text" value={envGcsApiKeyJsonPath || ''} readOnly tabIndex={-1} />
+              <input
+                className="form-control"
+                type="text"
+                value={envGcsApiKeyJsonPath || ''}
+                readOnly
+                tabIndex={-1}
+              />
               <p className="form-text text-muted">
               <p className="form-text text-muted">
                 {/* eslint-disable-next-line react/no-danger */}
                 {/* eslint-disable-next-line react/no-danger */}
-                <small dangerouslySetInnerHTML={{ __html: t('admin:app_setting.use_env_var_if_empty', { variable: 'GCS_API_KEY_JSON_PATH' }) }} />
+                <small
+                  // eslint-disable-next-line react/no-danger
+                  // biome-ignore lint/security/noDangerouslySetInnerHtml: includes markup from i18n strings
+                  dangerouslySetInnerHTML={{
+                    __html: t('admin:app_setting.use_env_var_if_empty', {
+                      variable: 'GCS_API_KEY_JSON_PATH',
+                    }),
+                  }}
+                />
               </p>
               </p>
             </td>
             </td>
           </tr>
           </tr>
@@ -122,10 +150,24 @@ export const GcsSettingMolecule = (props: GcsSettingMoleculeProps): JSX.Element
               />
               />
             </td>
             </td>
             <td>
             <td>
-              <input className="form-control" type="text" value={envGcsBucket || ''} readOnly tabIndex={-1} />
+              <input
+                className="form-control"
+                type="text"
+                value={envGcsBucket || ''}
+                readOnly
+                tabIndex={-1}
+              />
               <p className="form-text text-muted">
               <p className="form-text text-muted">
                 {/* eslint-disable-next-line react/no-danger */}
                 {/* eslint-disable-next-line react/no-danger */}
-                <small dangerouslySetInnerHTML={{ __html: t('admin:app_setting.use_env_var_if_empty', { variable: 'GCS_BUCKET' }) }} />
+                <small
+                  // eslint-disable-next-line react/no-danger
+                  // biome-ignore lint/security/noDangerouslySetInnerHtml: includes markup from i18n strings
+                  dangerouslySetInnerHTML={{
+                    __html: t('admin:app_setting.use_env_var_if_empty', {
+                      variable: 'GCS_BUCKET',
+                    }),
+                  }}
+                />
               </p>
               </p>
             </td>
             </td>
           </tr>
           </tr>
@@ -140,10 +182,24 @@ export const GcsSettingMolecule = (props: GcsSettingMoleculeProps): JSX.Element
               />
               />
             </td>
             </td>
             <td>
             <td>
-              <input className="form-control" type="text" value={envGcsUploadNamespace || ''} readOnly tabIndex={-1} />
+              <input
+                className="form-control"
+                type="text"
+                value={envGcsUploadNamespace || ''}
+                readOnly
+                tabIndex={-1}
+              />
               <p className="form-text text-muted">
               <p className="form-text text-muted">
                 {/* eslint-disable-next-line react/no-danger */}
                 {/* eslint-disable-next-line react/no-danger */}
-                <small dangerouslySetInnerHTML={{ __html: t('admin:app_setting.use_env_var_if_empty', { variable: 'GCS_UPLOAD_NAMESPACE' }) }} />
+                <small
+                  // eslint-disable-next-line react/no-danger
+                  // biome-ignore lint/security/noDangerouslySetInnerHtml: includes markup from i18n strings
+                  dangerouslySetInnerHTML={{
+                    __html: t('admin:app_setting.use_env_var_if_empty', {
+                      variable: 'GCS_UPLOAD_NAMESPACE',
+                    }),
+                  }}
+                />
               </p>
               </p>
             </td>
             </td>
           </tr>
           </tr>

+ 76 - 50
apps/app/src/client/components/Admin/App/MailSetting.tsx

@@ -1,21 +1,17 @@
 import React, { useCallback, useEffect } from 'react';
 import React, { useCallback, useEffect } from 'react';
-
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 import { useForm } from 'react-hook-form';
 import { useForm } from 'react-hook-form';
 
 
 import AdminAppContainer from '~/client/services/AdminAppContainer';
 import AdminAppContainer from '~/client/services/AdminAppContainer';
-import { toastSuccess, toastError } from '~/client/util/toastr';
+import { toastError, toastSuccess } from '~/client/util/toastr';
 
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
 import { withUnstatedContainers } from '../../UnstatedUtils';
-
 import { SesSetting } from './SesSetting';
 import { SesSetting } from './SesSetting';
 import { SmtpSetting } from './SmtpSetting';
 import { SmtpSetting } from './SmtpSetting';
 
 
-
 type Props = {
 type Props = {
-  adminAppContainer: AdminAppContainer,
-}
-
+  adminAppContainer: AdminAppContainer;
+};
 
 
 const MailSetting = (props: Props) => {
 const MailSetting = (props: Props) => {
   const { t } = useTranslation(['admin', 'commons']);
   const { t } = useTranslation(['admin', 'commons']);
@@ -23,15 +19,13 @@ const MailSetting = (props: Props) => {
 
 
   const transmissionMethods = ['smtp', 'ses'];
   const transmissionMethods = ['smtp', 'ses'];
 
 
-  const {
-    register,
-    handleSubmit,
-    reset,
-    watch,
-  } = useForm();
+  const { register, handleSubmit, reset, watch } = useForm();
 
 
   // Watch the transmission method to dynamically switch between SMTP and SES settings
   // Watch the transmission method to dynamically switch between SMTP and SES settings
-  const currentTransmissionMethod = watch('transmissionMethod', adminAppContainer.state.transmissionMethod || 'smtp');
+  const currentTransmissionMethod = watch(
+    'transmissionMethod',
+    adminAppContainer.state.transmissionMethod || 'smtp',
+  );
 
 
   // Reset form when adminAppContainer state changes
   // Reset form when adminAppContainer state changes
   useEffect(() => {
   useEffect(() => {
@@ -57,61 +51,75 @@ const MailSetting = (props: Props) => {
     reset,
     reset,
   ]);
   ]);
 
 
-  const onSubmit = useCallback(async(data) => {
-    try {
-      // Await all setState completions before API call
-      await Promise.all([
-        adminAppContainer.changeFromAddress(data.fromAddress),
-        adminAppContainer.changeTransmissionMethod(data.transmissionMethod),
-        adminAppContainer.changeSmtpHost(data.smtpHost),
-        adminAppContainer.changeSmtpPort(data.smtpPort),
-        adminAppContainer.changeSmtpUser(data.smtpUser),
-        adminAppContainer.changeSmtpPassword(data.smtpPassword),
-        adminAppContainer.changeSesAccessKeyId(data.sesAccessKeyId),
-        adminAppContainer.changeSesSecretAccessKey(data.sesSecretAccessKey),
-      ]);
-
-      await adminAppContainer.updateMailSettingHandler();
-      toastSuccess(t('toaster.update_successed', { target: t('admin:app_setting.mail_settings'), ns: 'commons' }));
-    }
-    catch (err) {
-      toastError(err);
-    }
-  }, [adminAppContainer, t]);
+  const onSubmit = useCallback(
+    async (data) => {
+      try {
+        // Await all setState completions before API call
+        await Promise.all([
+          adminAppContainer.changeFromAddress(data.fromAddress),
+          adminAppContainer.changeTransmissionMethod(data.transmissionMethod),
+          adminAppContainer.changeSmtpHost(data.smtpHost),
+          adminAppContainer.changeSmtpPort(data.smtpPort),
+          adminAppContainer.changeSmtpUser(data.smtpUser),
+          adminAppContainer.changeSmtpPassword(data.smtpPassword),
+          adminAppContainer.changeSesAccessKeyId(data.sesAccessKeyId),
+          adminAppContainer.changeSesSecretAccessKey(data.sesSecretAccessKey),
+        ]);
+
+        await adminAppContainer.updateMailSettingHandler();
+        toastSuccess(
+          t('toaster.update_successed', {
+            target: t('admin:app_setting.mail_settings'),
+            ns: 'commons',
+          }),
+        );
+      } catch (err) {
+        toastError(err);
+      }
+    },
+    [adminAppContainer, t],
+  );
 
 
   async function sendTestEmailHandler() {
   async function sendTestEmailHandler() {
     const { adminAppContainer } = props;
     const { adminAppContainer } = props;
     try {
     try {
       await adminAppContainer.sendTestEmail();
       await adminAppContainer.sendTestEmail();
       toastSuccess(t('admin:app_setting.success_to_send_test_email'));
       toastSuccess(t('admin:app_setting.success_to_send_test_email'));
-    }
-    catch (err) {
+    } catch (err) {
       toastError(err);
       toastError(err);
     }
     }
   }
   }
 
 
-
   return (
   return (
     <form onSubmit={handleSubmit(onSubmit)}>
     <form onSubmit={handleSubmit(onSubmit)}>
       {!adminAppContainer.state.isMailerSetup && (
       {!adminAppContainer.state.isMailerSetup && (
-        <div className="alert alert-danger"><span className="material-symbols-outlined">error</span> {t('admin:app_setting.mailer_is_not_set_up')}</div>
+        <div className="alert alert-danger">
+          <span className="material-symbols-outlined">error</span>{' '}
+          {t('admin:app_setting.mailer_is_not_set_up')}
+        </div>
       )}
       )}
       <div className="row mb-4">
       <div className="row mb-4">
-        <label className="col-md-3 col-form-label text-end">{t('admin:app_setting.from_e-mail_address')}</label>
+        <label
+          className="col-md-3 col-form-label text-end"
+          htmlFor="admin-mail-setting-from-address"
+        >
+          {t('admin:app_setting.from_e-mail_address')}
+        </label>
         <div className="col-md-6">
         <div className="col-md-6">
           <input
           <input
             className="form-control"
             className="form-control"
             type="text"
             type="text"
             placeholder={`${t('eg')} mail@growi.org`}
             placeholder={`${t('eg')} mail@growi.org`}
+            id="admin-mail-setting-from-address"
             {...register('fromAddress')}
             {...register('fromAddress')}
           />
           />
         </div>
         </div>
       </div>
       </div>
 
 
       <div className="row mb-2">
       <div className="row mb-2">
-        <label className="form-label text-start text-md-end col-md-3 col-form-label">
+        <span className="form-label text-start text-md-end col-md-3 col-form-label">
           {t('admin:app_setting.transmission_method')}
           {t('admin:app_setting.transmission_method')}
-        </label>
+        </span>
         <div className="col-md-6 py-2">
         <div className="col-md-6 py-2">
           {transmissionMethods.map((method) => {
           {transmissionMethods.map((method) => {
             return (
             return (
@@ -123,24 +131,41 @@ const MailSetting = (props: Props) => {
                   value={method}
                   value={method}
                   {...register('transmissionMethod')}
                   {...register('transmissionMethod')}
                 />
                 />
-                <label className="form-label form-check-label" htmlFor={`transmission-method-radio-${method}`}>{t(`admin:app_setting.${method}_label`)}</label>
+                <label
+                  className="form-label form-check-label"
+                  htmlFor={`transmission-method-radio-${method}`}
+                >
+                  {t(`admin:app_setting.${method}_label`)}
+                </label>
               </div>
               </div>
             );
             );
           })}
           })}
         </div>
         </div>
       </div>
       </div>
 
 
-      {currentTransmissionMethod === 'smtp' && <SmtpSetting register={register} />}
-      {currentTransmissionMethod === 'ses' && <SesSetting register={register} />}
+      {currentTransmissionMethod === 'smtp' && (
+        <SmtpSetting register={register} />
+      )}
+      {currentTransmissionMethod === 'ses' && (
+        <SesSetting register={register} />
+      )}
 
 
       <div className="row my-3">
       <div className="row my-3">
         <div className="col-md-3"></div>
         <div className="col-md-3"></div>
         <div className="col-md-9">
         <div className="col-md-9">
-          <button type="submit" className="btn btn-primary" disabled={adminAppContainer.state.retrieveError != null}>
-            { t('Update') }
+          <button
+            type="submit"
+            className="btn btn-primary"
+            disabled={adminAppContainer.state.retrieveError != null}
+          >
+            {t('Update')}
           </button>
           </button>
           {adminAppContainer.state.transmissionMethod === 'smtp' && (
           {adminAppContainer.state.transmissionMethod === 'smtp' && (
-            <button type="button" className="btn btn-secondary ms-4" onClick={sendTestEmailHandler}>
+            <button
+              type="button"
+              className="btn btn-secondary ms-4"
+              onClick={sendTestEmailHandler}
+            >
               {t('admin:app_setting.send_test_email')}
               {t('admin:app_setting.send_test_email')}
             </button>
             </button>
           )}
           )}
@@ -148,12 +173,13 @@ const MailSetting = (props: Props) => {
       </div>
       </div>
     </form>
     </form>
   );
   );
-
 };
 };
 
 
 /**
 /**
  * Wrapper component for using unstated
  * Wrapper component for using unstated
  */
  */
-const MailSettingWrapper = withUnstatedContainers(MailSetting, [AdminAppContainer]);
+const MailSettingWrapper = withUnstatedContainers(MailSetting, [
+  AdminAppContainer,
+]);
 
 
 export default MailSettingWrapper;
 export default MailSettingWrapper;

+ 53 - 20
apps/app/src/client/components/Admin/App/MaintenanceMode.tsx

@@ -1,54 +1,81 @@
 import type { FC } from 'react';
 import type { FC } from 'react';
-import React, { useState, useCallback } from 'react';
-
+import React, { useCallback, useState } from 'react';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 
 
 import { useMaintenanceModeActions } from '~/client/services/maintenance-mode';
 import { useMaintenanceModeActions } from '~/client/services/maintenance-mode';
-import { toastSuccess, toastError } from '~/client/util/toastr';
+import { toastError, toastSuccess } from '~/client/util/toastr';
 import { useIsMaintenanceMode } from '~/states/global';
 import { useIsMaintenanceMode } from '~/states/global';
 
 
 import { ConfirmModal } from './ConfirmModal';
 import { ConfirmModal } from './ConfirmModal';
 
 
-
 export const MaintenanceMode: FC = () => {
 export const MaintenanceMode: FC = () => {
   const { t } = useTranslation();
   const { t } = useTranslation();
 
 
   const isMaintenanceMode = useIsMaintenanceMode();
   const isMaintenanceMode = useIsMaintenanceMode();
-  const { start: startMaintenanceMode, end: endMaintenanceMode } = useMaintenanceModeActions();
+  const { start: startMaintenanceMode, end: endMaintenanceMode } =
+    useMaintenanceModeActions();
 
 
   const [isModalOpen, setModalOpen] = useState<boolean>(false);
   const [isModalOpen, setModalOpen] = useState<boolean>(false);
 
 
-  const openModal = useCallback(() => { setModalOpen(true) }, []);
+  const openModal = useCallback(() => {
+    setModalOpen(true);
+  }, []);
 
 
-  const closeModal = useCallback(() => { setModalOpen(false) }, []);
+  const closeModal = useCallback(() => {
+    setModalOpen(false);
+  }, []);
 
 
-  const onConfirmHandler = useCallback(async() => {
+  const onConfirmHandler = useCallback(async () => {
     closeModal();
     closeModal();
 
 
     try {
     try {
       if (isMaintenanceMode) {
       if (isMaintenanceMode) {
         endMaintenanceMode();
         endMaintenanceMode();
-      }
-      else {
+      } else {
         startMaintenanceMode();
         startMaintenanceMode();
       }
       }
-    }
-    catch (err) {
-      toastError(isMaintenanceMode ? t('admin:maintenance_mode.failed_to_end_maintenance_mode') : t('admin:maintenance_mode.failed_to_start_maintenance_mode'));
+    } catch (err) {
+      toastError(
+        isMaintenanceMode
+          ? t('admin:maintenance_mode.failed_to_end_maintenance_mode')
+          : t('admin:maintenance_mode.failed_to_start_maintenance_mode'),
+      );
     }
     }
 
 
     // eslint-disable-next-line max-len
     // eslint-disable-next-line max-len
-    toastSuccess(isMaintenanceMode ? t('admin:maintenance_mode.successfully_ended_maintenance_mode') : t('admin:maintenance_mode.successfully_started_maintenance_mode'));
-  }, [isMaintenanceMode, closeModal, startMaintenanceMode, endMaintenanceMode, t]);
+    toastSuccess(
+      isMaintenanceMode
+        ? t('admin:maintenance_mode.successfully_ended_maintenance_mode')
+        : t('admin:maintenance_mode.successfully_started_maintenance_mode'),
+    );
+  }, [
+    isMaintenanceMode,
+    closeModal,
+    startMaintenanceMode,
+    endMaintenanceMode,
+    t,
+  ]);
 
 
   return (
   return (
     <div className="mb-5">
     <div className="mb-5">
       <ConfirmModal
       <ConfirmModal
         isModalOpen={isModalOpen}
         isModalOpen={isModalOpen}
-        warningMessage={isMaintenanceMode ? t('admin:maintenance_mode.warning_message_to_end') : t('admin:maintenance_mode.warning_message_to_start')}
+        warningMessage={
+          isMaintenanceMode
+            ? t('admin:maintenance_mode.warning_message_to_end')
+            : t('admin:maintenance_mode.warning_message_to_start')
+        }
         // eslint-disable-next-line max-len
         // eslint-disable-next-line max-len
-        supplymentaryMessage={isMaintenanceMode ? null : t('admin:maintenance_mode.supplymentary_message_to_start')}
-        confirmButtonTitle={isMaintenanceMode ? t('admin:maintenance_mode.end_maintenance_mode') : t('admin:maintenance_mode.start_maintenance_mode')}
+        supplymentaryMessage={
+          isMaintenanceMode
+            ? null
+            : t('admin:maintenance_mode.supplymentary_message_to_start')
+        }
+        confirmButtonTitle={
+          isMaintenanceMode
+            ? t('admin:maintenance_mode.end_maintenance_mode')
+            : t('admin:maintenance_mode.start_maintenance_mode')
+        }
         onConfirm={onConfirmHandler}
         onConfirm={onConfirmHandler}
         onCancel={() => closeModal()}
         onCancel={() => closeModal()}
       />
       />
@@ -60,8 +87,14 @@ export const MaintenanceMode: FC = () => {
         </span>
         </span>
       </p>
       </p>
       <div className="mx-auto my-3">
       <div className="mx-auto my-3">
-        <button type="button" className="btn btn-success" onClick={() => openModal()}>
-          {isMaintenanceMode ? t('admin:maintenance_mode.end_maintenance_mode') : t('admin:maintenance_mode.start_maintenance_mode')}
+        <button
+          type="button"
+          className="btn btn-success"
+          onClick={() => openModal()}
+        >
+          {isMaintenanceMode
+            ? t('admin:maintenance_mode.end_maintenance_mode')
+            : t('admin:maintenance_mode.start_maintenance_mode')}
         </button>
         </button>
       </div>
       </div>
     </div>
     </div>

+ 26 - 21
apps/app/src/client/components/Admin/App/MaskedInput.tsx

@@ -1,19 +1,18 @@
 import type { ChangeEvent } from 'react';
 import type { ChangeEvent } from 'react';
-import { useState, type JSX } from 'react';
-
+import { type JSX, useState } from 'react';
 import type { UseFormRegister } from 'react-hook-form';
 import type { UseFormRegister } from 'react-hook-form';
 
 
 import styles from './MaskedInput.module.scss';
 import styles from './MaskedInput.module.scss';
 
 
 type Props = {
 type Props = {
-  name?: string
-  readOnly: boolean
-  value?: string
-  onChange?: (e: ChangeEvent<HTMLInputElement>) => void
-  tabIndex?: number | undefined
+  name?: string;
+  readOnly: boolean;
+  value?: string;
+  onChange?: (e: ChangeEvent<HTMLInputElement>) => void;
+  tabIndex?: number | undefined;
   // eslint-disable-next-line @typescript-eslint/no-explicit-any
   // eslint-disable-next-line @typescript-eslint/no-explicit-any
-  register?: UseFormRegister<any>
-  fieldName?: string
+  register?: UseFormRegister<any>;
+  fieldName?: string;
 };
 };
 
 
 export default function MaskedInput(props: Props): JSX.Element {
 export default function MaskedInput(props: Props): JSX.Element {
@@ -22,18 +21,18 @@ export default function MaskedInput(props: Props): JSX.Element {
     setPasswordShown(!passwordShown);
     setPasswordShown(!passwordShown);
   };
   };
 
 
-  const {
-    name, readOnly, value, onChange, tabIndex, register, fieldName,
-  } = props;
+  const { name, readOnly, value, onChange, tabIndex, register, fieldName } =
+    props;
 
 
   // Use register if provided, otherwise use value/onChange
   // Use register if provided, otherwise use value/onChange
-  const inputProps = register && fieldName
-    ? register(fieldName)
-    : {
-      name,
-      value,
-      onChange,
-    };
+  const inputProps =
+    register && fieldName
+      ? register(fieldName)
+      : {
+          name,
+          value,
+          onChange,
+        };
 
 
   return (
   return (
     <div className={styles.MaskedInput}>
     <div className={styles.MaskedInput}>
@@ -44,13 +43,19 @@ export default function MaskedInput(props: Props): JSX.Element {
         tabIndex={tabIndex}
         tabIndex={tabIndex}
         {...inputProps}
         {...inputProps}
       />
       />
-      <span onClick={togglePassword} className={styles.PasswordReveal}>
+      <button
+        type="button"
+        onClick={togglePassword}
+        className={`${styles.PasswordReveal} border-0 bg-transparent p-0`}
+        aria-pressed={passwordShown}
+        aria-label="Toggle password visibility"
+      >
         {passwordShown ? (
         {passwordShown ? (
           <span className="material-symbols-outlined">visibility</span>
           <span className="material-symbols-outlined">visibility</span>
         ) : (
         ) : (
           <span className="material-symbols-outlined">visibility_off</span>
           <span className="material-symbols-outlined">visibility_off</span>
         )}
         )}
-      </span>
+      </button>
     </div>
     </div>
   );
   );
 }
 }

+ 64 - 34
apps/app/src/client/components/Admin/App/PageBulkExportSettings.tsx

@@ -1,12 +1,9 @@
-import {
-  useState, useCallback, useEffect, type JSX,
-} from 'react';
-
+import { type JSX, useCallback, useEffect, useState } from 'react';
 import { LoadingSpinner } from '@growi/ui/dist/components';
 import { LoadingSpinner } from '@growi/ui/dist/components';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 
 
 import { apiv3Put } from '~/client/util/apiv3-client';
 import { apiv3Put } from '~/client/util/apiv3-client';
-import { toastSuccess, toastError } from '~/client/util/toastr';
+import { toastError, toastSuccess } from '~/client/util/toastr';
 import { useSWRxAppSettings } from '~/stores/admin/app-settings';
 import { useSWRxAppSettings } from '~/stores/admin/app-settings';
 
 
 import AdminUpdateButtonRow from '../Common/AdminUpdateButtonRow';
 import AdminUpdateButtonRow from '../Common/AdminUpdateButtonRow';
@@ -16,36 +13,53 @@ const PageBulkExportSettings = (): JSX.Element => {
 
 
   const { data, error, mutate } = useSWRxAppSettings();
   const { data, error, mutate } = useSWRxAppSettings();
 
 
-  const [isBulkExportPagesEnabled, setIsBulkExportPagesEnabled] = useState(data?.isBulkExportPagesEnabled);
-  const [bulkExportDownloadExpirationSeconds, setBulkExportDownloadExpirationSeconds] = useState(data?.bulkExportDownloadExpirationSeconds);
-
-  const changeBulkExportDownloadExpirationSeconds = (bulkExportDownloadExpirationDays: number) => {
-    const bulkExportDownloadExpirationSeconds = bulkExportDownloadExpirationDays * 24 * 60 * 60;
+  const [isBulkExportPagesEnabled, setIsBulkExportPagesEnabled] = useState(
+    data?.isBulkExportPagesEnabled,
+  );
+  const [
+    bulkExportDownloadExpirationSeconds,
+    setBulkExportDownloadExpirationSeconds,
+  ] = useState(data?.bulkExportDownloadExpirationSeconds);
+
+  const changeBulkExportDownloadExpirationSeconds = (
+    bulkExportDownloadExpirationDays: number,
+  ) => {
+    const bulkExportDownloadExpirationSeconds =
+      bulkExportDownloadExpirationDays * 24 * 60 * 60;
     setBulkExportDownloadExpirationSeconds(bulkExportDownloadExpirationSeconds);
     setBulkExportDownloadExpirationSeconds(bulkExportDownloadExpirationSeconds);
   };
   };
 
 
-  const onSubmitHandler = useCallback(async() => {
+  const onSubmitHandler = useCallback(async () => {
     try {
     try {
       await apiv3Put('/app-settings/page-bulk-export-settings', {
       await apiv3Put('/app-settings/page-bulk-export-settings', {
         isBulkExportPagesEnabled,
         isBulkExportPagesEnabled,
         bulkExportDownloadExpirationSeconds,
         bulkExportDownloadExpirationSeconds,
       });
       });
-      toastSuccess(t('commons:toaster.update_successed', { target: t('app_setting.page_bulk_export_settings') }));
-    }
-    catch (err) {
+      toastSuccess(
+        t('commons:toaster.update_successed', {
+          target: t('app_setting.page_bulk_export_settings'),
+        }),
+      );
+    } catch (err) {
       toastError(err);
       toastError(err);
     }
     }
     mutate();
     mutate();
-  }, [isBulkExportPagesEnabled, bulkExportDownloadExpirationSeconds, mutate, t]);
+  }, [
+    isBulkExportPagesEnabled,
+    bulkExportDownloadExpirationSeconds,
+    mutate,
+    t,
+  ]);
 
 
   useEffect(() => {
   useEffect(() => {
     if (data?.useOnlyEnvVarForFileUploadType) {
     if (data?.useOnlyEnvVarForFileUploadType) {
       setIsBulkExportPagesEnabled(data?.envIsBulkExportPagesEnabled);
       setIsBulkExportPagesEnabled(data?.envIsBulkExportPagesEnabled);
-    }
-    else {
+    } else {
       setIsBulkExportPagesEnabled(data?.isBulkExportPagesEnabled);
       setIsBulkExportPagesEnabled(data?.isBulkExportPagesEnabled);
     }
     }
-    setBulkExportDownloadExpirationSeconds(data?.bulkExportDownloadExpirationSeconds);
+    setBulkExportDownloadExpirationSeconds(
+      data?.bulkExportDownloadExpirationSeconds,
+    );
   }, [data]);
   }, [data]);
 
 
   const isLoading = data === undefined && error === undefined;
   const isLoading = data === undefined && error === undefined;
@@ -68,10 +82,7 @@ const PageBulkExportSettings = (): JSX.Element => {
           </p>
           </p>
 
 
           <div className="my-4 row">
           <div className="my-4 row">
-            <label
-              className="text-start text-md-end col-md-3 col-form-label"
-            >
-            </label>
+            <div className="text-start text-md-end col-md-3 col-form-label"></div>
 
 
             <div className="col-md-6">
             <div className="col-md-6">
               <div className="form-check form-switch form-check-info">
               <div className="form-check form-switch form-check-info">
@@ -81,21 +92,29 @@ const PageBulkExportSettings = (): JSX.Element => {
                   id="cbIsPageBulkExportEnabled"
                   id="cbIsPageBulkExportEnabled"
                   checked={isBulkExportPagesEnabled}
                   checked={isBulkExportPagesEnabled}
                   disabled={data?.useOnlyEnvVarsForIsBulkExportPagesEnabled}
                   disabled={data?.useOnlyEnvVarsForIsBulkExportPagesEnabled}
-                  onChange={e => setIsBulkExportPagesEnabled(e.target.checked)}
+                  onChange={(e) =>
+                    setIsBulkExportPagesEnabled(e.target.checked)
+                  }
                 />
                 />
-                <label className="form-label form-check-label" htmlFor="cbIsPageBulkExportEnabled">
+                <label
+                  className="form-label form-check-label"
+                  htmlFor="cbIsPageBulkExportEnabled"
+                >
                   {t('app_setting.enable_page_bulk_export')}
                   {t('app_setting.enable_page_bulk_export')}
                 </label>
                 </label>
               </div>
               </div>
               {data?.useOnlyEnvVarsForIsBulkExportPagesEnabled && (
               {data?.useOnlyEnvVarsForIsBulkExportPagesEnabled && (
                 <p className="form-text text-muted">
                 <p className="form-text text-muted">
                   {/* eslint-disable-next-line react/no-danger */}
                   {/* eslint-disable-next-line react/no-danger */}
-                  <b dangerouslySetInnerHTML={{
-                    __html: t('admin:app_setting.fixed_by_env_var', {
-                      envKey: 'BULK_EXPORT_PAGES_ENABLED',
-                      envVar: isBulkExportPagesEnabled,
-                    }),
-                  }}
+                  <b
+                    // eslint-disable-next-line react/no-danger
+                    // biome-ignore lint/security/noDangerouslySetInnerHtml: includes markup from i18n strings
+                    dangerouslySetInnerHTML={{
+                      __html: t('admin:app_setting.fixed_by_env_var', {
+                        envKey: 'BULK_EXPORT_PAGES_ENABLED',
+                        envVar: isBulkExportPagesEnabled,
+                      }),
+                    }}
                   />
                   />
                 </p>
                 </p>
               )}
               )}
@@ -106,6 +125,7 @@ const PageBulkExportSettings = (): JSX.Element => {
             <div className="row">
             <div className="row">
               <label
               <label
                 className="text-start text-md-end col-md-3 col-form-label"
                 className="text-start text-md-end col-md-3 col-form-label"
+                htmlFor="admin-page-bulk-export-expiration"
               >
               >
                 {t('app_setting.page_bulk_export_storage_period')}
                 {t('app_setting.page_bulk_export_storage_period')}
               </label>
               </label>
@@ -113,11 +133,21 @@ const PageBulkExportSettings = (): JSX.Element => {
               <div className="col-md-2">
               <div className="col-md-2">
                 <select
                 <select
                   className="form-select"
                   className="form-select"
-                  value={(bulkExportDownloadExpirationSeconds ?? 0) / (24 * 60 * 60)}
-                  onChange={(e) => { changeBulkExportDownloadExpirationSeconds(Number(e.target.value)) }}
+                  id="admin-page-bulk-export-expiration"
+                  value={
+                    (bulkExportDownloadExpirationSeconds ?? 0) / (24 * 60 * 60)
+                  }
+                  onChange={(e) => {
+                    changeBulkExportDownloadExpirationSeconds(
+                      Number(e.target.value),
+                    );
+                  }}
                 >
                 >
-                  {Array.from({ length: 7 }, (_, i) => i + 1).map(number => (
-                    <option key={`be-download-expiration-option-${number}`} value={number}>
+                  {Array.from({ length: 7 }, (_, i) => i + 1).map((number) => (
+                    <option
+                      key={`be-download-expiration-option-${number}`}
+                      value={number}
+                    >
                       {number} {t('admin:days')}
                       {number} {t('admin:days')}
                     </option>
                     </option>
                   ))}
                   ))}

+ 16 - 10
apps/app/src/client/components/Admin/App/SesSetting.tsx

@@ -1,6 +1,4 @@
-
 import React from 'react';
 import React from 'react';
-
 import type { UseFormRegister } from 'react-hook-form';
 import type { UseFormRegister } from 'react-hook-form';
 
 
 import AdminAppContainer from '~/client/services/AdminAppContainer';
 import AdminAppContainer from '~/client/services/AdminAppContainer';
@@ -8,10 +6,10 @@ import AdminAppContainer from '~/client/services/AdminAppContainer';
 import { withUnstatedContainers } from '../../UnstatedUtils';
 import { withUnstatedContainers } from '../../UnstatedUtils';
 
 
 type Props = {
 type Props = {
-  adminAppContainer?: AdminAppContainer,
+  adminAppContainer?: AdminAppContainer;
   // eslint-disable-next-line @typescript-eslint/no-explicit-any
   // eslint-disable-next-line @typescript-eslint/no-explicit-any
-  register: UseFormRegister<any>,
-}
+  register: UseFormRegister<any>;
+};
 
 
 const SesSetting = (props: Props): JSX.Element => {
 const SesSetting = (props: Props): JSX.Element => {
   const { register } = props;
   const { register } = props;
@@ -19,34 +17,40 @@ const SesSetting = (props: Props): JSX.Element => {
   return (
   return (
     <React.Fragment>
     <React.Fragment>
       <div id="mail-ses" className="tab-pane active">
       <div id="mail-ses" className="tab-pane active">
-
         <div className="row">
         <div className="row">
-          <label className="text-start text-md-end col-md-3 col-form-label">
+          <label
+            className="text-start text-md-end col-md-3 col-form-label"
+            htmlFor="admin-ses-access-key-id"
+          >
             Access key ID
             Access key ID
           </label>
           </label>
           <div className="col-md-6">
           <div className="col-md-6">
             <input
             <input
               className="form-control"
               className="form-control"
               type="text"
               type="text"
+              id="admin-ses-access-key-id"
               {...register('sesAccessKeyId')}
               {...register('sesAccessKeyId')}
             />
             />
           </div>
           </div>
         </div>
         </div>
 
 
         <div className="row">
         <div className="row">
-          <label className="text-start text-md-end col-md-3 col-form-label">
+          <label
+            className="text-start text-md-end col-md-3 col-form-label"
+            htmlFor="admin-ses-secret-access-key"
+          >
             Secret access key
             Secret access key
           </label>
           </label>
           <div className="col-md-6">
           <div className="col-md-6">
             <input
             <input
               className="form-control"
               className="form-control"
               type="text"
               type="text"
+              id="admin-ses-secret-access-key"
               {...register('sesSecretAccessKey')}
               {...register('sesSecretAccessKey')}
             />
             />
           </div>
           </div>
         </div>
         </div>
       </div>
       </div>
-
     </React.Fragment>
     </React.Fragment>
   );
   );
 };
 };
@@ -56,6 +60,8 @@ export { SesSetting };
 /**
 /**
  * Wrapper component for using unstated
  * Wrapper component for using unstated
  */
  */
-const SesSettingWrapper = withUnstatedContainers(SesSetting, [AdminAppContainer]);
+const SesSettingWrapper = withUnstatedContainers(SesSetting, [
+  AdminAppContainer,
+]);
 
 
 export default SesSettingWrapper;
 export default SesSettingWrapper;

+ 61 - 34
apps/app/src/client/components/Admin/App/SiteUrlSetting.tsx

@@ -1,10 +1,9 @@
 import React, { useCallback, useEffect } from 'react';
 import React, { useCallback, useEffect } from 'react';
-
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 import { useForm } from 'react-hook-form';
 import { useForm } from 'react-hook-form';
 
 
 import AdminAppContainer from '~/client/services/AdminAppContainer';
 import AdminAppContainer from '~/client/services/AdminAppContainer';
-import { toastSuccess, toastError } from '~/client/util/toastr';
+import { toastError, toastSuccess } from '~/client/util/toastr';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
 import { withUnstatedContainers } from '../../UnstatedUtils';
@@ -12,21 +11,16 @@ import AdminUpdateButtonRow from '../Common/AdminUpdateButtonRow';
 
 
 const logger = loggerFactory('growi:appSettings');
 const logger = loggerFactory('growi:appSettings');
 
 
-
 type Props = {
 type Props = {
-  adminAppContainer: AdminAppContainer,
-}
+  adminAppContainer: AdminAppContainer;
+};
 
 
 const SiteUrlSetting = (props: Props) => {
 const SiteUrlSetting = (props: Props) => {
   const { t } = useTranslation('admin', { keyPrefix: 'app_setting' });
   const { t } = useTranslation('admin', { keyPrefix: 'app_setting' });
   const { t: tCommon } = useTranslation('commons');
   const { t: tCommon } = useTranslation('commons');
   const { adminAppContainer } = props;
   const { adminAppContainer } = props;
 
 
-  const {
-    register,
-    handleSubmit,
-    reset,
-  } = useForm();
+  const { register, handleSubmit, reset } = useForm();
 
 
   // Reset form when adminAppContainer state changes
   // Reset form when adminAppContainer state changes
   useEffect(() => {
   useEffect(() => {
@@ -35,36 +29,47 @@ const SiteUrlSetting = (props: Props) => {
     });
     });
   }, [adminAppContainer.state.siteUrl, reset]);
   }, [adminAppContainer.state.siteUrl, reset]);
 
 
-  const onSubmit = useCallback(async(data) => {
-    try {
-      // Await setState completion before API call
-      await adminAppContainer.changeSiteUrl(data.siteUrl);
-      await adminAppContainer.updateSiteUrlSettingHandler();
-      toastSuccess(tCommon('toaster.update_successed', { target: t('site_url.title') }));
-    }
-    catch (err) {
-      toastError(err);
-      logger.error(err);
-    }
-  }, [adminAppContainer, t, tCommon]);
+  const onSubmit = useCallback(
+    async (data) => {
+      try {
+        // Await setState completion before API call
+        await adminAppContainer.changeSiteUrl(data.siteUrl);
+        await adminAppContainer.updateSiteUrlSettingHandler();
+        toastSuccess(
+          tCommon('toaster.update_successed', { target: t('site_url.title') }),
+        );
+      } catch (err) {
+        toastError(err);
+        logger.error(err);
+      }
+    },
+    [adminAppContainer, t, tCommon],
+  );
 
 
   return (
   return (
     <form onSubmit={handleSubmit(onSubmit)}>
     <form onSubmit={handleSubmit(onSubmit)}>
       <p className="card custom-card bg-body-tertiary">{t('site_url.desc')}</p>
       <p className="card custom-card bg-body-tertiary">{t('site_url.desc')}</p>
-      {!adminAppContainer.state.isSetSiteUrl
-          && (<p className="alert alert-danger"><span className="material-symbols-outlined">error</span> {t('site_url.warn')}</p>)}
-
-      { adminAppContainer.state.siteUrlUseOnlyEnvVars && (
+      {!adminAppContainer.state.isSetSiteUrl && (
+        <p className="alert alert-danger">
+          <span className="material-symbols-outlined">error</span>{' '}
+          {t('site_url.warn')}
+        </p>
+      )}
+
+      {adminAppContainer.state.siteUrlUseOnlyEnvVars && (
         <div className="row">
         <div className="row">
           <p
           <p
             className="alert alert-info"
             className="alert alert-info"
             // eslint-disable-next-line react/no-danger
             // eslint-disable-next-line react/no-danger
+            // biome-ignore lint/security/noDangerouslySetInnerHtml: includes markup from i18n strings
             dangerouslySetInnerHTML={{
             dangerouslySetInnerHTML={{
-              __html: t('site_url.note_for_the_only_env_option', { env: 'APP_SITE_URL_USES_ONLY_ENV_VARS' }),
+              __html: t('site_url.note_for_the_only_env_option', {
+                env: 'APP_SITE_URL_USES_ONLY_ENV_VARS',
+              }),
             }}
             }}
           />
           />
         </div>
         </div>
-      ) }
+      )}
 
 
       <div className="row">
       <div className="row">
         <table className="table settings-table">
         <table className="table settings-table">
@@ -84,20 +89,37 @@ const SiteUrlSetting = (props: Props) => {
                 <input
                 <input
                   className="form-control"
                   className="form-control"
                   type="text"
                   type="text"
-                  readOnly={adminAppContainer.state.siteUrlUseOnlyEnvVars ?? true}
+                  readOnly={
+                    adminAppContainer.state.siteUrlUseOnlyEnvVars ?? true
+                  }
                   placeholder="e.g. https://my.growi.org"
                   placeholder="e.g. https://my.growi.org"
                   {...register('siteUrl')}
                   {...register('siteUrl')}
                 />
                 />
                 <p className="form-text text-muted">
                 <p className="form-text text-muted">
                   {/* eslint-disable-next-line react/no-danger */}
                   {/* eslint-disable-next-line react/no-danger */}
-                  <span dangerouslySetInnerHTML={{ __html: t('site_url.help') }} />
+                  <span
+                    // biome-ignore lint/security/noDangerouslySetInnerHtml: includes markup from i18n strings
+                    dangerouslySetInnerHTML={{ __html: t('site_url.help') }}
+                  />
                 </p>
                 </p>
               </td>
               </td>
               <td>
               <td>
-                <input className="form-control" type="text" value={adminAppContainer.state.envSiteUrl || ''} readOnly />
+                <input
+                  className="form-control"
+                  type="text"
+                  value={adminAppContainer.state.envSiteUrl || ''}
+                  readOnly
+                />
                 <p className="form-text text-muted">
                 <p className="form-text text-muted">
                   {/* eslint-disable-next-line react/no-danger */}
                   {/* eslint-disable-next-line react/no-danger */}
-                  <span dangerouslySetInnerHTML={{ __html: t('use_env_var_if_empty', { variable: 'APP_SITE_URL' }) }} />
+                  <span
+                    // biome-ignore lint/security/noDangerouslySetInnerHtml: includes markup from i18n strings
+                    dangerouslySetInnerHTML={{
+                      __html: t('use_env_var_if_empty', {
+                        variable: 'APP_SITE_URL',
+                      }),
+                    }}
+                  />
                 </p>
                 </p>
               </td>
               </td>
             </tr>
             </tr>
@@ -105,7 +127,10 @@ const SiteUrlSetting = (props: Props) => {
         </table>
         </table>
       </div>
       </div>
 
 
-      <AdminUpdateButtonRow type="submit" disabled={adminAppContainer.state.retrieveError != null} />
+      <AdminUpdateButtonRow
+        type="submit"
+        disabled={adminAppContainer.state.retrieveError != null}
+      />
     </form>
     </form>
   );
   );
 };
 };
@@ -113,6 +138,8 @@ const SiteUrlSetting = (props: Props) => {
 /**
 /**
  * Wrapper component for using unstated
  * Wrapper component for using unstated
  */
  */
-const SiteUrlSettingWrapper = withUnstatedContainers(SiteUrlSetting, [AdminAppContainer]);
+const SiteUrlSettingWrapper = withUnstatedContainers(SiteUrlSetting, [
+  AdminAppContainer,
+]);
 
 
 export default SiteUrlSettingWrapper;
 export default SiteUrlSettingWrapper;

+ 26 - 11
apps/app/src/client/components/Admin/App/SmtpSetting.tsx

@@ -1,6 +1,4 @@
-
 import React from 'react';
 import React from 'react';
-
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 import type { UseFormRegister } from 'react-hook-form';
 import type { UseFormRegister } from 'react-hook-form';
 
 
@@ -8,12 +6,11 @@ import AdminAppContainer from '~/client/services/AdminAppContainer';
 
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
 import { withUnstatedContainers } from '../../UnstatedUtils';
 
 
-
 type Props = {
 type Props = {
-  adminAppContainer?: AdminAppContainer,
+  adminAppContainer?: AdminAppContainer;
   // eslint-disable-next-line @typescript-eslint/no-explicit-any
   // eslint-disable-next-line @typescript-eslint/no-explicit-any
-  register: UseFormRegister<any>,
-}
+  register: UseFormRegister<any>;
+};
 
 
 const SmtpSetting = (props: Props): JSX.Element => {
 const SmtpSetting = (props: Props): JSX.Element => {
   const { t } = useTranslation();
   const { t } = useTranslation();
@@ -23,51 +20,67 @@ const SmtpSetting = (props: Props): JSX.Element => {
     <React.Fragment>
     <React.Fragment>
       <div id="mail-smtp" className="tab-pane active">
       <div id="mail-smtp" className="tab-pane active">
         <div className="row">
         <div className="row">
-          <label className="text-start text-md-end col-md-3 col-form-label">
+          <label
+            className="text-start text-md-end col-md-3 col-form-label"
+            htmlFor="admin-smtp-host"
+          >
             {t('admin:app_setting.host')}
             {t('admin:app_setting.host')}
           </label>
           </label>
           <div className="col-md-6">
           <div className="col-md-6">
             <input
             <input
               className="form-control"
               className="form-control"
               type="text"
               type="text"
+              id="admin-smtp-host"
               {...register('smtpHost')}
               {...register('smtpHost')}
             />
             />
           </div>
           </div>
         </div>
         </div>
 
 
         <div className="row">
         <div className="row">
-          <label className="text-start text-md-end col-md-3 col-form-label">
+          <label
+            className="text-start text-md-end col-md-3 col-form-label"
+            htmlFor="admin-smtp-port"
+          >
             {t('admin:app_setting.port')}
             {t('admin:app_setting.port')}
           </label>
           </label>
           <div className="col-md-6">
           <div className="col-md-6">
             <input
             <input
               className="form-control"
               className="form-control"
+              id="admin-smtp-port"
               {...register('smtpPort')}
               {...register('smtpPort')}
             />
             />
           </div>
           </div>
         </div>
         </div>
 
 
         <div className="row">
         <div className="row">
-          <label className="text-start text-md-end col-md-3 col-form-label">
+          <label
+            className="text-start text-md-end col-md-3 col-form-label"
+            htmlFor="admin-smtp-user"
+          >
             {t('admin:app_setting.user')}
             {t('admin:app_setting.user')}
           </label>
           </label>
           <div className="col-md-6">
           <div className="col-md-6">
             <input
             <input
               className="form-control"
               className="form-control"
               type="text"
               type="text"
+              id="admin-smtp-user"
               {...register('smtpUser')}
               {...register('smtpUser')}
             />
             />
           </div>
           </div>
         </div>
         </div>
 
 
         <div className="row">
         <div className="row">
-          <label className="text-start text-md-end col-md-3 col-form-label">
+          <label
+            className="text-start text-md-end col-md-3 col-form-label"
+            htmlFor="admin-smtp-password"
+          >
             {t('Password')}
             {t('Password')}
           </label>
           </label>
           <div className="col-md-6">
           <div className="col-md-6">
             <input
             <input
               className="form-control"
               className="form-control"
               type="password"
               type="password"
+              id="admin-smtp-password"
               {...register('smtpPassword')}
               {...register('smtpPassword')}
             />
             />
           </div>
           </div>
@@ -82,5 +95,7 @@ export { SmtpSetting };
 /**
 /**
  * Wrapper component for using unstated
  * Wrapper component for using unstated
  */
  */
-const SmtpSettingWrapper = withUnstatedContainers(SmtpSetting, [AdminAppContainer]);
+const SmtpSettingWrapper = withUnstatedContainers(SmtpSetting, [
+  AdminAppContainer,
+]);
 export default SmtpSettingWrapper;
 export default SmtpSettingWrapper;

+ 43 - 31
apps/app/src/client/components/Admin/App/V5PageMigration.tsx

@@ -1,33 +1,36 @@
 import type { FC } from 'react';
 import type { FC } from 'react';
 import React, { useCallback, useEffect, useState } from 'react';
 import React, { useCallback, useEffect, useState } from 'react';
-
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 
 
 import { toastError, toastSuccess } from '~/client/util/toastr';
 import { toastError, toastSuccess } from '~/client/util/toastr';
 import { useAdminSocket } from '~/features/admin/states/socket-io';
 import { useAdminSocket } from '~/features/admin/states/socket-io';
 import type {
 import type {
-  PMStartedData, PMMigratingData, PMErrorCountData, PMEndedData,
-} from '~/interfaces/websocket';
-import {
-  SocketEventName,
+  PMEndedData,
+  PMErrorCountData,
+  PMMigratingData,
+  PMStartedData,
 } from '~/interfaces/websocket';
 } from '~/interfaces/websocket';
+import { SocketEventName } from '~/interfaces/websocket';
 
 
 import AdminAppContainer from '../../../services/AdminAppContainer';
 import AdminAppContainer from '../../../services/AdminAppContainer';
 import { withUnstatedContainers } from '../../UnstatedUtils';
 import { withUnstatedContainers } from '../../UnstatedUtils';
 import LabeledProgressBar from '../Common/LabeledProgressBar';
 import LabeledProgressBar from '../Common/LabeledProgressBar';
-
 import { ConfirmModal } from './ConfirmModal';
 import { ConfirmModal } from './ConfirmModal';
 
 
-
 type Props = {
 type Props = {
-  adminAppContainer: typeof AdminAppContainer & { v5PageMigrationHandler: () => Promise<{ isV5Compatible: boolean }> },
-}
+  adminAppContainer: typeof AdminAppContainer & {
+    v5PageMigrationHandler: () => Promise<{ isV5Compatible: boolean }>;
+  };
+};
 
 
 const V5PageMigration: FC<Props> = (props: Props) => {
 const V5PageMigration: FC<Props> = (props: Props) => {
   // Modal
   // Modal
-  const [isV5PageMigrationModalShown, setIsV5PageMigrationModalShown] = useState(false);
+  const [isV5PageMigrationModalShown, setIsV5PageMigrationModalShown] =
+    useState(false);
   // Progress bar
   // Progress bar
-  const [isInProgress, setProgressing] = useState<boolean | undefined>(undefined); // use false as ended
+  const [isInProgress, setProgressing] = useState<boolean | undefined>(
+    undefined,
+  ); // use false as ended
   const [total, setTotal] = useState<number>(0);
   const [total, setTotal] = useState<number>(0);
   const [skip, setSkip] = useState<number>(0);
   const [skip, setSkip] = useState<number>(0);
   const [current, setCurrent] = useState<number>(0);
   const [current, setCurrent] = useState<number>(0);
@@ -41,17 +44,24 @@ const V5PageMigration: FC<Props> = (props: Props) => {
   /*
   /*
    * Local components
    * Local components
    */
    */
-  const renderResultMessage = useCallback((isSucceeded: boolean) => {
-    return (
-      <>
-        {
-          isSucceeded
-            ? <p className="text-success p-1">{t('admin:v5_page_migration.migration_succeeded')}</p>
-            : <p className="text-danger p-1">{t('admin:v5_page_migration.migration_failed')}</p>
-        }
-      </>
-    );
-  }, [t]);
+  const renderResultMessage = useCallback(
+    (isSucceeded: boolean) => {
+      return (
+        <>
+          {isSucceeded ? (
+            <p className="text-success p-1">
+              {t('admin:v5_page_migration.migration_succeeded')}
+            </p>
+          ) : (
+            <p className="text-danger p-1">
+              {t('admin:v5_page_migration.migration_failed')}
+            </p>
+          )}
+        </>
+      );
+    },
+    [t],
+  );
 
 
   const renderProgressBar = () => {
   const renderProgressBar = () => {
     if (isInProgress == null) {
     if (isInProgress == null) {
@@ -60,9 +70,7 @@ const V5PageMigration: FC<Props> = (props: Props) => {
 
 
     return (
     return (
       <>
       <>
-        {
-          isSucceeded != null && renderResultMessage(isSucceeded)
-        }
+        {isSucceeded != null && renderResultMessage(isSucceeded)}
         <LabeledProgressBar
         <LabeledProgressBar
           header={t('admin:v5_page_migration.header_upgrading_progress')}
           header={t('admin:v5_page_migration.header_upgrading_progress')}
           currentCount={current}
           currentCount={current}
@@ -76,17 +84,16 @@ const V5PageMigration: FC<Props> = (props: Props) => {
   /*
   /*
    * Functions
    * Functions
    */
    */
-  const onConfirm = async() => {
+  const onConfirm = async () => {
     setIsV5PageMigrationModalShown(false);
     setIsV5PageMigrationModalShown(false);
     try {
     try {
-      const { isV5Compatible } = await adminAppContainer.v5PageMigrationHandler();
+      const { isV5Compatible } =
+        await adminAppContainer.v5PageMigrationHandler();
       if (isV5Compatible) {
       if (isV5Compatible) {
-
         return toastSuccess(t('admin:v5_page_migration.already_upgraded'));
         return toastSuccess(t('admin:v5_page_migration.already_upgraded'));
       }
       }
       toastSuccess(t('admin:v5_page_migration.successfully_started'));
       toastSuccess(t('admin:v5_page_migration.successfully_started'));
-    }
-    catch (err) {
+    } catch (err) {
       toastError(err);
       toastError(err);
     }
     }
   };
   };
@@ -146,7 +153,12 @@ const V5PageMigration: FC<Props> = (props: Props) => {
       {renderProgressBar()}
       {renderProgressBar()}
       <div className="row my-3">
       <div className="row my-3">
         <div className="mx-auto">
         <div className="mx-auto">
-          <button type="button" className="btn btn-warning" onClick={() => setIsV5PageMigrationModalShown(true)} disabled={isInProgress != null}>
+          <button
+            type="button"
+            className="btn btn-warning"
+            onClick={() => setIsV5PageMigrationModalShown(true)}
+            disabled={isInProgress != null}
+          >
             {t('admin:v5_page_migration.upgrade_to_v5')}
             {t('admin:v5_page_migration.upgrade_to_v5')}
           </button>
           </button>
         </div>
         </div>

+ 42 - 18
apps/app/src/client/components/Admin/App/useFileUploadSettings.spec.ts

@@ -1,17 +1,23 @@
-import { describe, it, expect } from 'vitest';
+import { describe, expect, it } from 'vitest';
 
 
-import type { FileUploadFormValues, FileUploadSettingsData } from './FileUploadSetting.types';
+import type {
+  FileUploadFormValues,
+  FileUploadSettingsData,
+} from './FileUploadSetting.types';
 
 
 /**
 /**
  * Helper function to build settings data (mimics useFileUploadSettings fetchData logic)
  * Helper function to build settings data (mimics useFileUploadSettings fetchData logic)
  */
  */
-function buildSettingsData(appSettingsParams: Record<string, any>): FileUploadSettingsData {
+function buildSettingsData(
+  appSettingsParams: Record<string, any>,
+): FileUploadSettingsData {
   return {
   return {
     // File upload type
     // File upload type
     fileUploadType: appSettingsParams.useOnlyEnvVarForFileUploadType
     fileUploadType: appSettingsParams.useOnlyEnvVarForFileUploadType
       ? appSettingsParams.envFileUploadType
       ? appSettingsParams.envFileUploadType
       : appSettingsParams.fileUploadType,
       : appSettingsParams.fileUploadType,
-    isFixedFileUploadByEnvVar: appSettingsParams.useOnlyEnvVarForFileUploadType || false,
+    isFixedFileUploadByEnvVar:
+      appSettingsParams.useOnlyEnvVarForFileUploadType || false,
     envFileUploadType: appSettingsParams.envFileUploadType,
     envFileUploadType: appSettingsParams.envFileUploadType,
 
 
     // AWS S3
     // AWS S3
@@ -20,13 +26,15 @@ function buildSettingsData(appSettingsParams: Record<string, any>): FileUploadSe
     s3Bucket: appSettingsParams.s3Bucket || '',
     s3Bucket: appSettingsParams.s3Bucket || '',
     s3AccessKeyId: appSettingsParams.s3AccessKeyId || '',
     s3AccessKeyId: appSettingsParams.s3AccessKeyId || '',
     s3SecretAccessKey: appSettingsParams.s3SecretAccessKey || '',
     s3SecretAccessKey: appSettingsParams.s3SecretAccessKey || '',
-    s3ReferenceFileWithRelayMode: appSettingsParams.s3ReferenceFileWithRelayMode || false,
+    s3ReferenceFileWithRelayMode:
+      appSettingsParams.s3ReferenceFileWithRelayMode || false,
 
 
     // GCS
     // GCS
     gcsApiKeyJsonPath: appSettingsParams.gcsApiKeyJsonPath || '',
     gcsApiKeyJsonPath: appSettingsParams.gcsApiKeyJsonPath || '',
     gcsBucket: appSettingsParams.gcsBucket || '',
     gcsBucket: appSettingsParams.gcsBucket || '',
     gcsUploadNamespace: appSettingsParams.gcsUploadNamespace || '',
     gcsUploadNamespace: appSettingsParams.gcsUploadNamespace || '',
-    gcsReferenceFileWithRelayMode: appSettingsParams.gcsReferenceFileWithRelayMode || false,
+    gcsReferenceFileWithRelayMode:
+      appSettingsParams.gcsReferenceFileWithRelayMode || false,
     gcsUseOnlyEnvVars: appSettingsParams.gcsUseOnlyEnvVars || false,
     gcsUseOnlyEnvVars: appSettingsParams.gcsUseOnlyEnvVars || false,
     envGcsApiKeyJsonPath: appSettingsParams.envGcsApiKeyJsonPath,
     envGcsApiKeyJsonPath: appSettingsParams.envGcsApiKeyJsonPath,
     envGcsBucket: appSettingsParams.envGcsBucket,
     envGcsBucket: appSettingsParams.envGcsBucket,
@@ -37,14 +45,17 @@ function buildSettingsData(appSettingsParams: Record<string, any>): FileUploadSe
     azureClientId: appSettingsParams.azureClientId || '',
     azureClientId: appSettingsParams.azureClientId || '',
     azureClientSecret: appSettingsParams.azureClientSecret || '',
     azureClientSecret: appSettingsParams.azureClientSecret || '',
     azureStorageAccountName: appSettingsParams.azureStorageAccountName || '',
     azureStorageAccountName: appSettingsParams.azureStorageAccountName || '',
-    azureStorageContainerName: appSettingsParams.azureStorageContainerName || '',
-    azureReferenceFileWithRelayMode: appSettingsParams.azureReferenceFileWithRelayMode || false,
+    azureStorageContainerName:
+      appSettingsParams.azureStorageContainerName || '',
+    azureReferenceFileWithRelayMode:
+      appSettingsParams.azureReferenceFileWithRelayMode || false,
     azureUseOnlyEnvVars: appSettingsParams.azureUseOnlyEnvVars || false,
     azureUseOnlyEnvVars: appSettingsParams.azureUseOnlyEnvVars || false,
     envAzureTenantId: appSettingsParams.envAzureTenantId,
     envAzureTenantId: appSettingsParams.envAzureTenantId,
     envAzureClientId: appSettingsParams.envAzureClientId,
     envAzureClientId: appSettingsParams.envAzureClientId,
     envAzureClientSecret: appSettingsParams.envAzureClientSecret,
     envAzureClientSecret: appSettingsParams.envAzureClientSecret,
     envAzureStorageAccountName: appSettingsParams.envAzureStorageAccountName,
     envAzureStorageAccountName: appSettingsParams.envAzureStorageAccountName,
-    envAzureStorageContainerName: appSettingsParams.envAzureStorageContainerName,
+    envAzureStorageContainerName:
+      appSettingsParams.envAzureStorageContainerName,
   };
   };
 }
 }
 
 
@@ -52,8 +63,8 @@ function buildSettingsData(appSettingsParams: Record<string, any>): FileUploadSe
  * Helper function to build request params (mimics useFileUploadSettings updateSettings logic)
  * Helper function to build request params (mimics useFileUploadSettings updateSettings logic)
  */
  */
 function buildRequestParams(
 function buildRequestParams(
-    formData: FileUploadFormValues,
-    dirtyFields: Partial<Record<keyof FileUploadFormValues, boolean>>,
+  formData: FileUploadFormValues,
+  dirtyFields: Partial<Record<keyof FileUploadFormValues, boolean>>,
 ): Record<string, any> {
 ): Record<string, any> {
   const { fileUploadType } = formData;
   const { fileUploadType } = formData;
 
 
@@ -70,14 +81,16 @@ function buildRequestParams(
     if (dirtyFields.s3SecretAccessKey) {
     if (dirtyFields.s3SecretAccessKey) {
       requestParams.s3SecretAccessKey = formData.s3SecretAccessKey;
       requestParams.s3SecretAccessKey = formData.s3SecretAccessKey;
     }
     }
-    requestParams.s3ReferenceFileWithRelayMode = formData.s3ReferenceFileWithRelayMode;
+    requestParams.s3ReferenceFileWithRelayMode =
+      formData.s3ReferenceFileWithRelayMode;
   }
   }
 
 
   if (fileUploadType === 'gcs') {
   if (fileUploadType === 'gcs') {
     requestParams.gcsApiKeyJsonPath = formData.gcsApiKeyJsonPath;
     requestParams.gcsApiKeyJsonPath = formData.gcsApiKeyJsonPath;
     requestParams.gcsBucket = formData.gcsBucket;
     requestParams.gcsBucket = formData.gcsBucket;
     requestParams.gcsUploadNamespace = formData.gcsUploadNamespace;
     requestParams.gcsUploadNamespace = formData.gcsUploadNamespace;
-    requestParams.gcsReferenceFileWithRelayMode = formData.gcsReferenceFileWithRelayMode;
+    requestParams.gcsReferenceFileWithRelayMode =
+      formData.gcsReferenceFileWithRelayMode;
   }
   }
 
 
   if (fileUploadType === 'azure') {
   if (fileUploadType === 'azure') {
@@ -92,8 +105,10 @@ function buildRequestParams(
       requestParams.azureClientSecret = formData.azureClientSecret;
       requestParams.azureClientSecret = formData.azureClientSecret;
     }
     }
     requestParams.azureStorageAccountName = formData.azureStorageAccountName;
     requestParams.azureStorageAccountName = formData.azureStorageAccountName;
-    requestParams.azureStorageContainerName = formData.azureStorageContainerName;
-    requestParams.azureReferenceFileWithRelayMode = formData.azureReferenceFileWithRelayMode;
+    requestParams.azureStorageContainerName =
+      formData.azureStorageContainerName;
+    requestParams.azureReferenceFileWithRelayMode =
+      formData.azureReferenceFileWithRelayMode;
   }
   }
 
 
   return requestParams;
   return requestParams;
@@ -309,8 +324,14 @@ describe('useFileUploadSettings - secret field dirty tracking', () => {
     expect(requestParams).not.toHaveProperty('azureTenantId');
     expect(requestParams).not.toHaveProperty('azureTenantId');
     expect(requestParams).not.toHaveProperty('azureClientId');
     expect(requestParams).not.toHaveProperty('azureClientId');
     expect(requestParams).not.toHaveProperty('azureClientSecret');
     expect(requestParams).not.toHaveProperty('azureClientSecret');
-    expect(requestParams).toHaveProperty('azureStorageAccountName', 'new-account');
-    expect(requestParams).toHaveProperty('azureStorageContainerName', 'new-container');
+    expect(requestParams).toHaveProperty(
+      'azureStorageAccountName',
+      'new-account',
+    );
+    expect(requestParams).toHaveProperty(
+      'azureStorageContainerName',
+      'new-container',
+    );
   });
   });
 
 
   it('should include Azure secret fields in request when they are dirty', () => {
   it('should include Azure secret fields in request when they are dirty', () => {
@@ -390,6 +411,9 @@ describe('useFileUploadSettings - secret field dirty tracking', () => {
 
 
     expect(requestParams).toHaveProperty('azureTenantId', 'new-tenant-id');
     expect(requestParams).toHaveProperty('azureTenantId', 'new-tenant-id');
     expect(requestParams).not.toHaveProperty('azureClientId');
     expect(requestParams).not.toHaveProperty('azureClientId');
-    expect(requestParams).toHaveProperty('azureClientSecret', 'new-client-secret');
+    expect(requestParams).toHaveProperty(
+      'azureClientSecret',
+      'new-client-secret',
+    );
   });
   });
 });
 });

+ 54 - 28
apps/app/src/client/components/Admin/App/useFileUploadSettings.ts

@@ -1,16 +1,21 @@
-import { useState, useEffect } from 'react';
-
+import { useEffect, useState } from 'react';
 import type { FieldNamesMarkedBoolean } from 'react-hook-form';
 import type { FieldNamesMarkedBoolean } from 'react-hook-form';
 
 
 import { apiv3Get, apiv3Put } from '~/client/util/apiv3-client';
 import { apiv3Get, apiv3Put } from '~/client/util/apiv3-client';
 
 
-import type { FileUploadSettingsData, FileUploadFormValues } from './FileUploadSetting.types';
+import type {
+  FileUploadFormValues,
+  FileUploadSettingsData,
+} from './FileUploadSetting.types';
 
 
 type UseFileUploadSettingsReturn = {
 type UseFileUploadSettingsReturn = {
-  data: FileUploadSettingsData | null
-  isLoading: boolean
-  error: Error | null
-  updateSettings: (formData: FileUploadFormValues, dirtyFields: FieldNamesMarkedBoolean<FileUploadFormValues>) => Promise<void>
+  data: FileUploadSettingsData | null;
+  isLoading: boolean;
+  error: Error | null;
+  updateSettings: (
+    formData: FileUploadFormValues,
+    dirtyFields: FieldNamesMarkedBoolean<FileUploadFormValues>,
+  ) => Promise<void>;
 };
 };
 
 
 export function useFileUploadSettings(): UseFileUploadSettingsReturn {
 export function useFileUploadSettings(): UseFileUploadSettingsReturn {
@@ -19,7 +24,7 @@ export function useFileUploadSettings(): UseFileUploadSettingsReturn {
   const [error, setError] = useState<Error | null>(null);
   const [error, setError] = useState<Error | null>(null);
 
 
   useEffect(() => {
   useEffect(() => {
-    const fetchData = async() => {
+    const fetchData = async () => {
       try {
       try {
         setIsLoading(true);
         setIsLoading(true);
         const response = await apiv3Get('/app-settings/');
         const response = await apiv3Get('/app-settings/');
@@ -30,7 +35,8 @@ export function useFileUploadSettings(): UseFileUploadSettingsReturn {
           fileUploadType: appSettingsParams.useOnlyEnvVarForFileUploadType
           fileUploadType: appSettingsParams.useOnlyEnvVarForFileUploadType
             ? appSettingsParams.envFileUploadType
             ? appSettingsParams.envFileUploadType
             : appSettingsParams.fileUploadType,
             : appSettingsParams.fileUploadType,
-          isFixedFileUploadByEnvVar: appSettingsParams.useOnlyEnvVarForFileUploadType || false,
+          isFixedFileUploadByEnvVar:
+            appSettingsParams.useOnlyEnvVarForFileUploadType || false,
           envFileUploadType: appSettingsParams.envFileUploadType,
           envFileUploadType: appSettingsParams.envFileUploadType,
 
 
           // AWS S3
           // AWS S3
@@ -39,13 +45,15 @@ export function useFileUploadSettings(): UseFileUploadSettingsReturn {
           s3Bucket: appSettingsParams.s3Bucket || '',
           s3Bucket: appSettingsParams.s3Bucket || '',
           s3AccessKeyId: appSettingsParams.s3AccessKeyId || '',
           s3AccessKeyId: appSettingsParams.s3AccessKeyId || '',
           s3SecretAccessKey: appSettingsParams.s3SecretAccessKey || '',
           s3SecretAccessKey: appSettingsParams.s3SecretAccessKey || '',
-          s3ReferenceFileWithRelayMode: appSettingsParams.s3ReferenceFileWithRelayMode || false,
+          s3ReferenceFileWithRelayMode:
+            appSettingsParams.s3ReferenceFileWithRelayMode || false,
 
 
           // GCS
           // GCS
           gcsApiKeyJsonPath: appSettingsParams.gcsApiKeyJsonPath || '',
           gcsApiKeyJsonPath: appSettingsParams.gcsApiKeyJsonPath || '',
           gcsBucket: appSettingsParams.gcsBucket || '',
           gcsBucket: appSettingsParams.gcsBucket || '',
           gcsUploadNamespace: appSettingsParams.gcsUploadNamespace || '',
           gcsUploadNamespace: appSettingsParams.gcsUploadNamespace || '',
-          gcsReferenceFileWithRelayMode: appSettingsParams.gcsReferenceFileWithRelayMode || false,
+          gcsReferenceFileWithRelayMode:
+            appSettingsParams.gcsReferenceFileWithRelayMode || false,
           gcsUseOnlyEnvVars: appSettingsParams.gcsUseOnlyEnvVars || false,
           gcsUseOnlyEnvVars: appSettingsParams.gcsUseOnlyEnvVars || false,
           envGcsApiKeyJsonPath: appSettingsParams.envGcsApiKeyJsonPath,
           envGcsApiKeyJsonPath: appSettingsParams.envGcsApiKeyJsonPath,
           envGcsBucket: appSettingsParams.envGcsBucket,
           envGcsBucket: appSettingsParams.envGcsBucket,
@@ -55,24 +63,29 @@ export function useFileUploadSettings(): UseFileUploadSettingsReturn {
           azureTenantId: appSettingsParams.azureTenantId || '',
           azureTenantId: appSettingsParams.azureTenantId || '',
           azureClientId: appSettingsParams.azureClientId || '',
           azureClientId: appSettingsParams.azureClientId || '',
           azureClientSecret: appSettingsParams.azureClientSecret || '',
           azureClientSecret: appSettingsParams.azureClientSecret || '',
-          azureStorageAccountName: appSettingsParams.azureStorageAccountName || '',
-          azureStorageContainerName: appSettingsParams.azureStorageContainerName || '',
-          azureReferenceFileWithRelayMode: appSettingsParams.azureReferenceFileWithRelayMode || false,
+          azureStorageAccountName:
+            appSettingsParams.azureStorageAccountName || '',
+          azureStorageContainerName:
+            appSettingsParams.azureStorageContainerName || '',
+          azureReferenceFileWithRelayMode:
+            appSettingsParams.azureReferenceFileWithRelayMode || false,
           azureUseOnlyEnvVars: appSettingsParams.azureUseOnlyEnvVars || false,
           azureUseOnlyEnvVars: appSettingsParams.azureUseOnlyEnvVars || false,
           envAzureTenantId: appSettingsParams.envAzureTenantId,
           envAzureTenantId: appSettingsParams.envAzureTenantId,
           envAzureClientId: appSettingsParams.envAzureClientId,
           envAzureClientId: appSettingsParams.envAzureClientId,
           envAzureClientSecret: appSettingsParams.envAzureClientSecret,
           envAzureClientSecret: appSettingsParams.envAzureClientSecret,
-          envAzureStorageAccountName: appSettingsParams.envAzureStorageAccountName,
-          envAzureStorageContainerName: appSettingsParams.envAzureStorageContainerName,
+          envAzureStorageAccountName:
+            appSettingsParams.envAzureStorageAccountName,
+          envAzureStorageContainerName:
+            appSettingsParams.envAzureStorageContainerName,
         };
         };
 
 
         setData(settingsData);
         setData(settingsData);
         setError(null);
         setError(null);
-      }
-      catch (err) {
-        setError(err instanceof Error ? err : new Error('Failed to fetch settings'));
-      }
-      finally {
+      } catch (err) {
+        setError(
+          err instanceof Error ? err : new Error('Failed to fetch settings'),
+        );
+      } finally {
         setIsLoading(false);
         setIsLoading(false);
       }
       }
     };
     };
@@ -80,7 +93,10 @@ export function useFileUploadSettings(): UseFileUploadSettingsReturn {
     fetchData();
     fetchData();
   }, []);
   }, []);
 
 
-  const updateSettings = async(formData: FileUploadFormValues, dirtyFields: FieldNamesMarkedBoolean<FileUploadFormValues>): Promise<void> => {
+  const updateSettings = async (
+    formData: FileUploadFormValues,
+    dirtyFields: FieldNamesMarkedBoolean<FileUploadFormValues>,
+  ): Promise<void> => {
     const { fileUploadType } = formData;
     const { fileUploadType } = formData;
 
 
     const requestParams: Record<string, any> = {
     const requestParams: Record<string, any> = {
@@ -97,14 +113,16 @@ export function useFileUploadSettings(): UseFileUploadSettingsReturn {
       if (dirtyFields.s3SecretAccessKey) {
       if (dirtyFields.s3SecretAccessKey) {
         requestParams.s3SecretAccessKey = formData.s3SecretAccessKey;
         requestParams.s3SecretAccessKey = formData.s3SecretAccessKey;
       }
       }
-      requestParams.s3ReferenceFileWithRelayMode = formData.s3ReferenceFileWithRelayMode;
+      requestParams.s3ReferenceFileWithRelayMode =
+        formData.s3ReferenceFileWithRelayMode;
     }
     }
 
 
     if (fileUploadType === 'gcs') {
     if (fileUploadType === 'gcs') {
       requestParams.gcsApiKeyJsonPath = formData.gcsApiKeyJsonPath;
       requestParams.gcsApiKeyJsonPath = formData.gcsApiKeyJsonPath;
       requestParams.gcsBucket = formData.gcsBucket;
       requestParams.gcsBucket = formData.gcsBucket;
       requestParams.gcsUploadNamespace = formData.gcsUploadNamespace;
       requestParams.gcsUploadNamespace = formData.gcsUploadNamespace;
-      requestParams.gcsReferenceFileWithRelayMode = formData.gcsReferenceFileWithRelayMode;
+      requestParams.gcsReferenceFileWithRelayMode =
+        formData.gcsReferenceFileWithRelayMode;
     }
     }
 
 
     if (fileUploadType === 'azure') {
     if (fileUploadType === 'azure') {
@@ -119,11 +137,16 @@ export function useFileUploadSettings(): UseFileUploadSettingsReturn {
         requestParams.azureClientSecret = formData.azureClientSecret;
         requestParams.azureClientSecret = formData.azureClientSecret;
       }
       }
       requestParams.azureStorageAccountName = formData.azureStorageAccountName;
       requestParams.azureStorageAccountName = formData.azureStorageAccountName;
-      requestParams.azureStorageContainerName = formData.azureStorageContainerName;
-      requestParams.azureReferenceFileWithRelayMode = formData.azureReferenceFileWithRelayMode;
+      requestParams.azureStorageContainerName =
+        formData.azureStorageContainerName;
+      requestParams.azureReferenceFileWithRelayMode =
+        formData.azureReferenceFileWithRelayMode;
     }
     }
 
 
-    const response = await apiv3Put('/app-settings/file-upload-setting', requestParams);
+    const response = await apiv3Put(
+      '/app-settings/file-upload-setting',
+      requestParams,
+    );
     const { responseParams } = response.data;
     const { responseParams } = response.data;
 
 
     // Update local state with response
     // Update local state with response
@@ -136,6 +159,9 @@ export function useFileUploadSettings(): UseFileUploadSettingsReturn {
   };
   };
 
 
   return {
   return {
-    data, isLoading, error, updateSettings,
+    data,
+    isLoading,
+    error,
+    updateSettings,
   };
   };
 }
 }

+ 39 - 25
apps/app/src/client/components/Admin/SlackIntegration/BotTypeCard.tsx

@@ -1,8 +1,7 @@
 import React, { type JSX } from 'react';
 import React, { type JSX } from 'react';
-
+import Image from 'next/image';
 import { SlackbotType } from '@growi/slack';
 import { SlackbotType } from '@growi/slack';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
-import Image from 'next/image';
 
 
 const botDetails = {
 const botDetails = {
   officialBot: {
   officialBot: {
@@ -31,9 +30,9 @@ const botDetails = {
 };
 };
 
 
 type BotTypeCardProps = {
 type BotTypeCardProps = {
-  isActive: boolean,
-  botType: string,
-  onBotTypeSelectHandler: (botType: SlackbotType) => void,
+  isActive: boolean;
+  botType: string;
+  onBotTypeSelectHandler: (botType: SlackbotType) => void;
 };
 };
 
 
 export const BotTypeCard = (props: BotTypeCardProps): JSX.Element => {
 export const BotTypeCard = (props: BotTypeCardProps): JSX.Element => {
@@ -44,34 +43,41 @@ export const BotTypeCard = (props: BotTypeCardProps): JSX.Element => {
   const isBotTypeOfficial = botType === SlackbotType.OFFICIAL;
   const isBotTypeOfficial = botType === SlackbotType.OFFICIAL;
 
 
   return (
   return (
-    <div
-      className={`card admin-bot-card rounded border-radius-sm shadow ${isActive ? 'border-primary' : ''}`}
+    <button
+      type="button"
+      className={`card admin-bot-card rounded border-radius-sm shadow ${isActive ? 'border-primary' : ''} border-0 bg-transparent p-0`}
       onClick={() => onBotTypeSelectHandler(botDetails[botType].botType)}
       onClick={() => onBotTypeSelectHandler(botDetails[botType].botType)}
-      role="button"
-      key={botType}
+      aria-pressed={isActive}
     >
     >
       <div>
       <div>
-        <h3 className={`card-header mb-0 py-3
+        <h3
+          className={`card-header mb-0 py-3
               ${isBotTypeOfficial ? 'd-flex align-items-center justify-content-center' : 'text-center'}
               ${isBotTypeOfficial ? 'd-flex align-items-center justify-content-center' : 'text-center'}
               ${isActive ? 'bg-primary grw-botcard-title-active' : ''}`}
               ${isActive ? 'bg-primary grw-botcard-title-active' : ''}`}
         >
         >
           <span className="me-2">
           <span className="me-2">
-            {t(`admin:slack_integration.selecting_bot_types.${botDetails[botType].botTypeCategory}`)}
+            {t(
+              `admin:slack_integration.selecting_bot_types.${botDetails[botType].botTypeCategory}`,
+            )}
           </span>
           </span>
 
 
           {/*  A recommended badge is shown on official bot card, supplementary names are shown on Custom bot cards   */}
           {/*  A recommended badge is shown on official bot card, supplementary names are shown on Custom bot cards   */}
-          { isBotTypeOfficial
-            ? (
-              <span className="badge bg-info me-2">
-                {t('admin:slack_integration.selecting_bot_types.recommended')}
-              </span>
-            ) : (
-              <span className="supplementary-bot-name me-2">
-                {t(`admin:slack_integration.selecting_bot_types.${botDetails[botType].supplementaryBotName}`)}
-              </span>
-            )}
+          {isBotTypeOfficial ? (
+            <span className="badge bg-info me-2">
+              {t('admin:slack_integration.selecting_bot_types.recommended')}
+            </span>
+          ) : (
+            <span className="supplementary-bot-name me-2">
+              {t(
+                `admin:slack_integration.selecting_bot_types.${botDetails[botType].supplementaryBotName}`,
+              )}
+            </span>
+          )}
 
 
-          <i className={isActive ? 'grw-botcard-title-active' : ''} aria-hidden="true"></i>
+          <i
+            className={isActive ? 'grw-botcard-title-active' : ''}
+            aria-hidden="true"
+          ></i>
         </h3>
         </h3>
       </div>
       </div>
       <div className="card-body p-4">
       <div className="card-body p-4">
@@ -85,7 +91,11 @@ export const BotTypeCard = (props: BotTypeCardProps): JSX.Element => {
               height={60}
               height={60}
             />
             />
             <div className="d-flex justify-content-between mb-3 align-items-center">
             <div className="d-flex justify-content-between mb-3 align-items-center">
-              <span>{t('admin:slack_integration.selecting_bot_types.multiple_workspaces_integration')}</span>
+              <span>
+                {t(
+                  'admin:slack_integration.selecting_bot_types.multiple_workspaces_integration',
+                )}
+              </span>
               <Image
               <Image
                 className="bot-type-disc"
                 className="bot-type-disc"
                 src={`/images/slack-integration/${botDetails[botType].multiWSIntegration}.png`}
                 src={`/images/slack-integration/${botDetails[botType].multiWSIntegration}.png`}
@@ -95,7 +105,11 @@ export const BotTypeCard = (props: BotTypeCardProps): JSX.Element => {
               />
               />
             </div>
             </div>
             <div className="d-flex justify-content-between align-items-center">
             <div className="d-flex justify-content-between align-items-center">
-              <span>{t('admin:slack_integration.selecting_bot_types.security_control')}</span>
+              <span>
+                {t(
+                  'admin:slack_integration.selecting_bot_types.security_control',
+                )}
+              </span>
               <Image
               <Image
                 className="bot-type-disc"
                 className="bot-type-disc"
                 src={`/images/slack-integration/${botDetails[botType].securityControl}.png`}
                 src={`/images/slack-integration/${botDetails[botType].securityControl}.png`}
@@ -107,7 +121,7 @@ export const BotTypeCard = (props: BotTypeCardProps): JSX.Element => {
           </div>
           </div>
         </div>
         </div>
       </div>
       </div>
-    </div>
+    </button>
   );
   );
 };
 };
 
 

+ 43 - 29
apps/app/src/client/components/Admin/SlackIntegration/Bridge.tsx

@@ -1,50 +1,59 @@
-
 import type { JSX } from 'react';
 import type { JSX } from 'react';
-
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 import { UncontrolledTooltip } from 'reactstrap';
 import { UncontrolledTooltip } from 'reactstrap';
 
 
-
 const ProxyCircle = () => (
 const ProxyCircle = () => (
   <div className="grw-bridge-proxy-circle position-relative">
   <div className="grw-bridge-proxy-circle position-relative">
     <div className="circle position-absolute m-auto z-1 bg-primary border-light rounded-circle">
     <div className="circle position-absolute m-auto z-1 bg-primary border-light rounded-circle">
-      <p className="circle-inner position-absolute text-light fw-bold d-none d-lg-inline">Proxy Server</p>
-      <p className="circle-inner position-absolute grw-proxy-server-name d-inline d-lg-none">Proxy Server</p>
+      <p className="circle-inner position-absolute text-light fw-bold d-none d-lg-inline">
+        Proxy Server
+      </p>
+      <p className="circle-inner position-absolute grw-proxy-server-name d-inline d-lg-none">
+        Proxy Server
+      </p>
     </div>
     </div>
   </div>
   </div>
 );
 );
 
 
 type BridgeCoreProps = {
 type BridgeCoreProps = {
-  description: string,
-  iconClass: string,
-  iconName: string,
-  hrClass: string,
-  withProxy?: boolean,
-}
+  description: string;
+  iconClass: string;
+  iconName: string;
+  hrClass: string;
+  withProxy?: boolean;
+};
 const BridgeCore = (props: BridgeCoreProps): JSX.Element => {
 const BridgeCore = (props: BridgeCoreProps): JSX.Element => {
-  const {
-    description, iconClass, iconName, hrClass, withProxy,
-  } = props;
+  const { description, iconClass, iconName, hrClass, withProxy } = props;
 
 
   return (
   return (
     <>
     <>
-      <div id="grw-bridge-container" className={`grw-bridge-container ${withProxy ? 'with-proxy' : ''}`}>
+      <div
+        id="grw-bridge-container"
+        className={`grw-bridge-container ${withProxy ? 'with-proxy' : ''}`}
+      >
         <p className={`${withProxy ? 'mt-0' : 'mt-2'}`}>
         <p className={`${withProxy ? 'mt-0' : 'mt-2'}`}>
           <span className={iconClass}>{iconName}</span>
           <span className={iconClass}>{iconName}</span>
           <small
           <small
             className="ms-2 d-none d-lg-inline"
             className="ms-2 d-none d-lg-inline"
             // eslint-disable-next-line react/no-danger
             // eslint-disable-next-line react/no-danger
+            // biome-ignore lint/security/noDangerouslySetInnerHtml: includes markup from i18n strings
             dangerouslySetInnerHTML={{ __html: description }}
             dangerouslySetInnerHTML={{ __html: description }}
           />
           />
         </p>
         </p>
         <div className="hr-container">
         <div className="hr-container">
-          { withProxy && <ProxyCircle /> }
+          {withProxy && <ProxyCircle />}
           <hr className={`align-self-center ${hrClass}`} />
           <hr className={`align-self-center ${hrClass}`} />
         </div>
         </div>
       </div>
       </div>
-      <UncontrolledTooltip placement="top" fade={false} target="grw-bridge-container" className="d-block d-lg-none">
+      <UncontrolledTooltip
+        placement="top"
+        fade={false}
+        target="grw-bridge-container"
+        className="d-block d-lg-none"
+      >
         <small
         <small
           // eslint-disable-next-line react/no-danger
           // eslint-disable-next-line react/no-danger
+          // biome-ignore lint/security/noDangerouslySetInnerHtml: includes markup from i18n strings
           dangerouslySetInnerHTML={{ __html: description }}
           dangerouslySetInnerHTML={{ __html: description }}
         />
         />
       </UncontrolledTooltip>
       </UncontrolledTooltip>
@@ -52,38 +61,43 @@ const BridgeCore = (props: BridgeCoreProps): JSX.Element => {
   );
   );
 };
 };
 
 
-
 type BridgeProps = {
 type BridgeProps = {
-  errorCount: number,
-  totalCount: number,
-  withProxy?: boolean,
-}
+  errorCount: number;
+  totalCount: number;
+  withProxy?: boolean;
+};
 export const Bridge = (props: BridgeProps): JSX.Element => {
 export const Bridge = (props: BridgeProps): JSX.Element => {
   const { t } = useTranslation();
   const { t } = useTranslation();
   const { errorCount, totalCount, withProxy } = props;
   const { errorCount, totalCount, withProxy } = props;
 
 
-  let description;
-  let iconClass;
-  let iconName;
-  let hrClass;
+  let description = '';
+  let iconClass = '';
+  let iconName = '';
+  let hrClass = '';
 
 
   // empty or all failed
   // empty or all failed
   if (totalCount === 0 || errorCount === totalCount) {
   if (totalCount === 0 || errorCount === totalCount) {
-    description = t('admin:slack_integration.integration_sentence.integration_is_not_complete');
+    description = t(
+      'admin:slack_integration.integration_sentence.integration_is_not_complete',
+    );
     iconClass = 'material-symbols-outlined text-danger';
     iconClass = 'material-symbols-outlined text-danger';
     iconName = 'info';
     iconName = 'info';
     hrClass = 'border-danger admin-border-failed';
     hrClass = 'border-danger admin-border-failed';
   }
   }
   // all green
   // all green
   else if (errorCount === 0) {
   else if (errorCount === 0) {
-    description = t('admin:slack_integration.integration_sentence.integration_successful');
+    description = t(
+      'admin:slack_integration.integration_sentence.integration_successful',
+    );
     iconClass = 'material-symbols-outlined text-success';
     iconClass = 'material-symbols-outlined text-success';
     iconName = 'check';
     iconName = 'check';
     hrClass = 'border-success admin-border-success';
     hrClass = 'border-success admin-border-success';
   }
   }
   // some of them failed
   // some of them failed
   else {
   else {
-    description = t('admin:slack_integration.integration_sentence.integration_some_ws_is_not_complete');
+    description = t(
+      'admin:slack_integration.integration_sentence.integration_some_ws_is_not_complete',
+    );
     iconClass = 'material-symbols-outlined text-warning';
     iconClass = 'material-symbols-outlined text-warning';
     iconName = 'check';
     iconName = 'check';
     hrClass = 'border-warning admin-border-failed';
     hrClass = 'border-warning admin-border-failed';

+ 12 - 10
apps/app/src/client/components/Admin/SlackIntegration/ConfirmBotChangeModal.jsx

@@ -1,10 +1,7 @@
 import React from 'react';
 import React from 'react';
-
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
-import {
-  Modal, ModalHeader, ModalBody, ModalFooter,
-} from 'reactstrap';
+import { Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
 
 
 const ConfirmBotChangeModal = (props) => {
 const ConfirmBotChangeModal = (props) => {
   const { t } = useTranslation('admin');
   const { t } = useTranslation('admin');
@@ -23,10 +20,7 @@ const ConfirmBotChangeModal = (props) => {
 
 
   return (
   return (
     <Modal isOpen={props.isOpen} centered>
     <Modal isOpen={props.isOpen} centered>
-      <ModalHeader
-        toggle={handleCancelButton}
-        className="text-danger"
-      >
+      <ModalHeader toggle={handleCancelButton} className="text-danger">
         {t('slack_integration.modal.warning')}
         {t('slack_integration.modal.warning')}
       </ModalHeader>
       </ModalHeader>
       <ModalBody>
       <ModalBody>
@@ -38,10 +32,18 @@ const ConfirmBotChangeModal = (props) => {
         </div>
         </div>
       </ModalBody>
       </ModalBody>
       <ModalFooter>
       <ModalFooter>
-        <button type="button" className="btn btn-secondary" onClick={handleCancelButton}>
+        <button
+          type="button"
+          className="btn btn-secondary"
+          onClick={handleCancelButton}
+        >
           {t('slack_integration.modal.cancel')}
           {t('slack_integration.modal.cancel')}
         </button>
         </button>
-        <button type="button" className="btn btn-danger" onClick={handleChangeButton}>
+        <button
+          type="button"
+          className="btn btn-danger"
+          onClick={handleChangeButton}
+        >
           {t('slack_integration.modal.change')}
           {t('slack_integration.modal.change')}
         </button>
         </button>
       </ModalFooter>
       </ModalFooter>

+ 26 - 14
apps/app/src/client/components/Admin/SlackIntegration/CustomBotWithProxyConnectionStatus.tsx

@@ -1,38 +1,49 @@
 import React, { type JSX } from 'react';
 import React, { type JSX } from 'react';
-
-import type { ConnectionStatus } from '@growi/slack';
 import Image from 'next/image';
 import Image from 'next/image';
+import type { ConnectionStatus } from '@growi/slack';
 
 
 import { Bridge } from './Bridge';
 import { Bridge } from './Bridge';
 
 
-
 type CustomBotWithProxyConnectionStatusProps = {
 type CustomBotWithProxyConnectionStatusProps = {
-  siteName: string,
-  connectionStatuses: any,
-}
+  siteName: string;
+  connectionStatuses: any;
+};
 
 
-export const CustomBotWithProxyConnectionStatus = (props: CustomBotWithProxyConnectionStatusProps): JSX.Element => {
+export const CustomBotWithProxyConnectionStatus = (
+  props: CustomBotWithProxyConnectionStatusProps,
+): JSX.Element => {
   const { siteName, connectionStatuses } = props;
   const { siteName, connectionStatuses } = props;
 
 
-  const connectionStatusValues: ConnectionStatus[] = Object.values(connectionStatuses);
+  const connectionStatusValues: ConnectionStatus[] =
+    Object.values(connectionStatuses);
 
 
   const totalCount = connectionStatusValues.length;
   const totalCount = connectionStatusValues.length;
-  const errorCount = connectionStatusValues.filter(connectionStatus => connectionStatus.error != null).length;
+  const errorCount = connectionStatusValues.filter(
+    (connectionStatus) => connectionStatus.error != null,
+  ).length;
 
 
   return (
   return (
     <div className="row justify-content-center my-5 bot-integration">
     <div className="row justify-content-center my-5 bot-integration">
-
       <div className="card rounded shadow col-4 border-0 admin-bot-card">
       <div className="card rounded shadow col-4 border-0 admin-bot-card">
         <h5 className="card-title fw-bold mt-3 text-center">Slack</h5>
         <h5 className="card-title fw-bold mt-3 text-center">Slack</h5>
         <div className="card-body px-5">
         <div className="card-body px-5">
           {connectionStatusValues.map((connectionStatus, i) => {
           {connectionStatusValues.map((connectionStatus, i) => {
-            const workspaceName = connectionStatus.workspaceName || `Settings #${i}`;
+            const workspaceName =
+              connectionStatus.workspaceName || `Settings #${i}`;
 
 
             return (
             return (
-              <div key={workspaceName} className="card slack-work-space-name-card">
+              <div
+                key={workspaceName}
+                className="card slack-work-space-name-card"
+              >
                 <div className="m-2 text-center">
                 <div className="m-2 text-center">
                   <h5 className="fw-bold">{workspaceName}</h5>
                   <h5 className="fw-bold">{workspaceName}</h5>
-                  <Image width={20} height={20} src="/images/slack-integration/growi-bot-kun-icon.png" alt="" />
+                  <Image
+                    width={20}
+                    height={20}
+                    src="/images/slack-integration/growi-bot-kun-icon.png"
+                    alt=""
+                  />
                 </div>
                 </div>
               </div>
               </div>
             );
             );
@@ -56,4 +67,5 @@ export const CustomBotWithProxyConnectionStatus = (props: CustomBotWithProxyConn
   );
   );
 };
 };
 
 
-CustomBotWithProxyConnectionStatus.displayName = 'CustomBotWithProxyConnectionStatus';
+CustomBotWithProxyConnectionStatus.displayName =
+  'CustomBotWithProxyConnectionStatus';

+ 99 - 47
apps/app/src/client/components/Admin/SlackIntegration/CustomBotWithProxySettings.jsx

@@ -1,26 +1,30 @@
-import React, { useState, useEffect, useCallback } from 'react';
-
+import React, { useCallback, useEffect, useState } from 'react';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
 
 
 import { apiv3Delete, apiv3Put } from '~/client/util/apiv3-client';
 import { apiv3Delete, apiv3Put } from '~/client/util/apiv3-client';
-import { toastSuccess, toastError } from '~/client/util/toastr';
+import { toastError, toastSuccess } from '~/client/util/toastr';
 import { useAppTitle } from '~/states/global';
 import { useAppTitle } from '~/states/global';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
-
 import { CustomBotWithProxyConnectionStatus } from './CustomBotWithProxyConnectionStatus';
 import { CustomBotWithProxyConnectionStatus } from './CustomBotWithProxyConnectionStatus';
 import { DeleteSlackBotSettingsModal } from './DeleteSlackBotSettingsModal';
 import { DeleteSlackBotSettingsModal } from './DeleteSlackBotSettingsModal';
 import { SlackAppIntegrationControl } from './SlackAppIntegrationControl';
 import { SlackAppIntegrationControl } from './SlackAppIntegrationControl';
 import WithProxyAccordions from './WithProxyAccordions';
 import WithProxyAccordions from './WithProxyAccordions';
 
 
-const logger = loggerFactory('growi:cli:SlackIntegration:CustomBotWithProxySettings');
+const logger = loggerFactory(
+  'growi:cli:SlackIntegration:CustomBotWithProxySettings',
+);
 
 
 const CustomBotWithProxySettings = (props) => {
 const CustomBotWithProxySettings = (props) => {
   const {
   const {
-    slackAppIntegrations, proxyServerUri,
-    onClickAddSlackWorkspaceBtn, onPrimaryUpdated,
-    connectionStatuses, onUpdateTokens, onSubmitForm,
+    slackAppIntegrations,
+    proxyServerUri,
+    onClickAddSlackWorkspaceBtn,
+    onPrimaryUpdated,
+    connectionStatuses,
+    onUpdateTokens,
+    onSubmitForm,
   } = props;
   } = props;
   const [newProxyServerUri, setNewProxyServerUri] = useState();
   const [newProxyServerUri, setNewProxyServerUri] = useState();
   const [integrationIdToDelete, setIntegrationIdToDelete] = useState(null);
   const [integrationIdToDelete, setIntegrationIdToDelete] = useState(null);
@@ -33,53 +37,63 @@ const CustomBotWithProxySettings = (props) => {
     setNewProxyServerUri(proxyServerUri);
     setNewProxyServerUri(proxyServerUri);
   }, [proxyServerUri]);
   }, [proxyServerUri]);
 
 
-  const addSlackAppIntegrationHandler = async() => {
+  const addSlackAppIntegrationHandler = async () => {
     if (onClickAddSlackWorkspaceBtn != null) {
     if (onClickAddSlackWorkspaceBtn != null) {
       onClickAddSlackWorkspaceBtn();
       onClickAddSlackWorkspaceBtn();
     }
     }
   };
   };
 
 
-  const isPrimaryChangedHandler = useCallback(async(slackIntegrationToChange, newValue) => {
-    // do nothing when turning off
-    if (!newValue) {
-      return;
-    }
+  const isPrimaryChangedHandler = useCallback(
+    async (slackIntegrationToChange, newValue) => {
+      // do nothing when turning off
+      if (!newValue) {
+        return;
+      }
 
 
-    try {
-      await apiv3Put(`/slack-integration-settings/slack-app-integrations/${slackIntegrationToChange._id}/make-primary`);
-      if (onPrimaryUpdated != null) {
-        onPrimaryUpdated();
+      try {
+        await apiv3Put(
+          `/slack-integration-settings/slack-app-integrations/${slackIntegrationToChange._id}/make-primary`,
+        );
+        if (onPrimaryUpdated != null) {
+          onPrimaryUpdated();
+        }
+        toastSuccess(
+          t('toaster.update_successed', { target: 'Primary', ns: 'commons' }),
+        );
+      } catch (err) {
+        toastError(err);
+        logger.error('Failed to change isPrimary', err);
       }
       }
-      toastSuccess(t('toaster.update_successed', { target: 'Primary', ns: 'commons' }));
-    }
-    catch (err) {
-      toastError(err);
-      logger.error('Failed to change isPrimary', err);
-    }
-  }, [t, onPrimaryUpdated]);
+    },
+    [t, onPrimaryUpdated],
+  );
 
 
-  const deleteSlackAppIntegrationHandler = async() => {
+  const deleteSlackAppIntegrationHandler = async () => {
     try {
     try {
-      await apiv3Delete(`/slack-integration-settings/slack-app-integrations/${integrationIdToDelete}`);
+      await apiv3Delete(
+        `/slack-integration-settings/slack-app-integrations/${integrationIdToDelete}`,
+      );
       if (props.onDeleteSlackAppIntegration != null) {
       if (props.onDeleteSlackAppIntegration != null) {
         props.onDeleteSlackAppIntegration();
         props.onDeleteSlackAppIntegration();
       }
       }
-      toastSuccess(t('admin:slack_integration.toastr.delete_slack_integration_procedure'));
-    }
-    catch (err) {
+      toastSuccess(
+        t('admin:slack_integration.toastr.delete_slack_integration_procedure'),
+      );
+    } catch (err) {
       toastError(err);
       toastError(err);
       logger.error('Failed to delete', err);
       logger.error('Failed to delete', err);
     }
     }
   };
   };
 
 
-  const updateProxyUri = async() => {
+  const updateProxyUri = async () => {
     try {
     try {
       await apiv3Put('/slack-integration-settings/proxy-uri', {
       await apiv3Put('/slack-integration-settings/proxy-uri', {
         proxyUri: newProxyServerUri,
         proxyUri: newProxyServerUri,
       });
       });
-      toastSuccess(t('toaster.update_successed', { target: 'Proxy URL', ns: 'commons' }));
-    }
-    catch (err) {
+      toastSuccess(
+        t('toaster.update_successed', { target: 'Proxy URL', ns: 'commons' }),
+      );
+    } catch (err) {
       toastError(err);
       toastError(err);
       logger.error('Failed to update', err);
       logger.error('Failed to update', err);
     }
     }
@@ -91,9 +105,16 @@ const CustomBotWithProxySettings = (props) => {
 
 
   return (
   return (
     <>
     <>
-      <h2 className="admin-setting-header mb-2">{t('admin:slack_integration.custom_bot_with_proxy_integration')}
-        <a href={t('admin:slack_integration.docs_url.custom_bot_with_proxy')} target="_blank" rel="noopener noreferrer">
-          <span className="growi-custom-icons btn-link ms-2">external_link</span>
+      <h2 className="admin-setting-header mb-2">
+        {t('admin:slack_integration.custom_bot_with_proxy_integration')}
+        <a
+          href={t('admin:slack_integration.docs_url.custom_bot_with_proxy')}
+          target="_blank"
+          rel="noopener noreferrer"
+        >
+          <span className="growi-custom-icons btn-link ms-2">
+            external_link
+          </span>
         </a>
         </a>
       </h2>
       </h2>
 
 
@@ -105,42 +126,67 @@ const CustomBotWithProxySettings = (props) => {
           />
           />
 
 
           <div className="row my-4">
           <div className="row my-4">
-            <label className="text-start text-md-end col-md-3 col-form-label mt-3">Proxy URL</label>
+            <label
+              className="text-start text-md-end col-md-3 col-form-label mt-3"
+              htmlFor="admin-slack-proxy-url"
+            >
+              Proxy URL
+            </label>
             <div className="col-md-6 mt-3">
             <div className="col-md-6 mt-3">
               <input
               <input
                 className="form-control"
                 className="form-control"
                 type="text"
                 type="text"
                 name="settingForm[proxyUrl]"
                 name="settingForm[proxyUrl]"
+                id="admin-slack-proxy-url"
                 value={newProxyServerUri}
                 value={newProxyServerUri}
-                onChange={(e) => { setNewProxyServerUri(e.target.value) }}
+                onChange={(e) => {
+                  setNewProxyServerUri(e.target.value);
+                }}
               />
               />
             </div>
             </div>
             <div className="col-md-2 mt-3 text-center text-md-start">
             <div className="col-md-2 mt-3 text-center text-md-start">
-              <button type="button" className="btn btn-primary" onClick={updateProxyUri}>{ t('Update') }</button>
+              <button
+                type="button"
+                className="btn btn-primary"
+                onClick={updateProxyUri}
+              >
+                {t('Update')}
+              </button>
             </div>
             </div>
           </div>
           </div>
 
 
-          <h2 className="admin-setting-header">{t('admin:slack_integration.integration_procedure')}</h2>
+          <h2 className="admin-setting-header">
+            {t('admin:slack_integration.integration_procedure')}
+          </h2>
         </>
         </>
       )}
       )}
 
 
       <div className="mx-3">
       <div className="mx-3">
         {slackAppIntegrations.map((slackAppIntegration, i) => {
         {slackAppIntegrations.map((slackAppIntegration, i) => {
           const {
           const {
-            tokenGtoP, tokenPtoG, _id, permissionsForBroadcastUseCommands, permissionsForSingleUseCommands, permissionsForSlackEventActions,
+            tokenGtoP,
+            tokenPtoG,
+            _id,
+            permissionsForBroadcastUseCommands,
+            permissionsForSingleUseCommands,
+            permissionsForSlackEventActions,
           } = slackAppIntegration;
           } = slackAppIntegration;
           const workspaceName = connectionStatuses[_id]?.workspaceName;
           const workspaceName = connectionStatuses[_id]?.workspaceName;
           return (
           return (
             <React.Fragment key={slackAppIntegration._id}>
             <React.Fragment key={slackAppIntegration._id}>
               <div className="my-3 d-flex align-items-center justify-content-between">
               <div className="my-3 d-flex align-items-center justify-content-between">
                 <h2 id={_id || `settings-accordions-${i}`}>
                 <h2 id={_id || `settings-accordions-${i}`}>
-                  {(workspaceName != null) ? `${workspaceName} Work Space` : `Settings #${i}`}
+                  {workspaceName != null
+                    ? `${workspaceName} Work Space`
+                    : `Settings #${i}`}
                 </h2>
                 </h2>
                 <SlackAppIntegrationControl
                 <SlackAppIntegrationControl
                   slackAppIntegration={slackAppIntegration}
                   slackAppIntegration={slackAppIntegration}
                   onIsPrimaryChanged={isPrimaryChangedHandler}
                   onIsPrimaryChanged={isPrimaryChangedHandler}
                   // set state to open DeleteSlackBotSettingsModal
                   // set state to open DeleteSlackBotSettingsModal
-                  onDeleteButtonClicked={saiToDelete => setIntegrationIdToDelete(saiToDelete._id)}
+                  onDeleteButtonClicked={(saiToDelete) =>
+                    setIntegrationIdToDelete(saiToDelete._id)
+                  }
                 />
                 />
               </div>
               </div>
               <WithProxyAccordions
               <WithProxyAccordions
@@ -148,9 +194,15 @@ const CustomBotWithProxySettings = (props) => {
                 slackAppIntegrationId={slackAppIntegration._id}
                 slackAppIntegrationId={slackAppIntegration._id}
                 tokenGtoP={tokenGtoP}
                 tokenGtoP={tokenGtoP}
                 tokenPtoG={tokenPtoG}
                 tokenPtoG={tokenPtoG}
-                permissionsForBroadcastUseCommands={permissionsForBroadcastUseCommands}
-                permissionsForSingleUseCommands={permissionsForSingleUseCommands}
-                permissionsForSlackEventActions={permissionsForSlackEventActions}
+                permissionsForBroadcastUseCommands={
+                  permissionsForBroadcastUseCommands
+                }
+                permissionsForSingleUseCommands={
+                  permissionsForSingleUseCommands
+                }
+                permissionsForSlackEventActions={
+                  permissionsForSlackEventActions
+                }
                 onUpdateTokens={onUpdateTokens}
                 onUpdateTokens={onUpdateTokens}
                 onSubmitForm={onSubmitForm}
                 onSubmitForm={onSubmitForm}
               />
               />

+ 23 - 12
apps/app/src/client/components/Admin/SlackIntegration/CustomBotWithoutProxyConnectionStatus.tsx

@@ -1,22 +1,26 @@
 import React, { type JSX } from 'react';
 import React, { type JSX } from 'react';
-
-import type { ConnectionStatus } from '@growi/slack';
 import Image from 'next/image';
 import Image from 'next/image';
+import type { ConnectionStatus } from '@growi/slack';
 
 
 import { Bridge } from './Bridge';
 import { Bridge } from './Bridge';
 
 
 type CustomBotWithoutProxyConnectionStatusProps = {
 type CustomBotWithoutProxyConnectionStatusProps = {
-  siteName: string,
-  connectionStatuses: any,
-}
+  siteName: string;
+  connectionStatuses: any;
+};
 
 
-export const CustomBotWithoutProxyConnectionStatus = (props: CustomBotWithoutProxyConnectionStatusProps): JSX.Element => {
+export const CustomBotWithoutProxyConnectionStatus = (
+  props: CustomBotWithoutProxyConnectionStatusProps,
+): JSX.Element => {
   const { siteName, connectionStatuses } = props;
   const { siteName, connectionStatuses } = props;
 
 
-  const connectionStatusValues: ConnectionStatus[] = Object.values(connectionStatuses);
+  const connectionStatusValues: ConnectionStatus[] =
+    Object.values(connectionStatuses);
 
 
   const totalCount = connectionStatusValues.length;
   const totalCount = connectionStatusValues.length;
-  const errorCount = connectionStatusValues.filter(connectionStatus => connectionStatus.error != null).length;
+  const errorCount = connectionStatusValues.filter(
+    (connectionStatus) => connectionStatus.error != null,
+  ).length;
   const workspaceName = connectionStatusValues[0]?.workspaceName;
   const workspaceName = connectionStatusValues[0]?.workspaceName;
 
 
   return (
   return (
@@ -30,10 +34,17 @@ export const CustomBotWithoutProxyConnectionStatus = (props: CustomBotWithoutPro
                 <h5 className="fw-bold">
                 <h5 className="fw-bold">
                   {workspaceName != null ? workspaceName : 'Settings #1'}
                   {workspaceName != null ? workspaceName : 'Settings #1'}
                 </h5>
                 </h5>
-                <Image width={20} height={20} src="/images/slack-integration/growi-bot-kun-icon.png" alt="" />
+                <Image
+                  width={20}
+                  height={20}
+                  src="/images/slack-integration/growi-bot-kun-icon.png"
+                  alt=""
+                />
               </div>
               </div>
             </div>
             </div>
-          ) : ''}
+          ) : (
+            ''
+          )}
         </div>
         </div>
       </div>
       </div>
 
 
@@ -49,9 +60,9 @@ export const CustomBotWithoutProxyConnectionStatus = (props: CustomBotWithoutPro
           </div>
           </div>
         </div>
         </div>
       </div>
       </div>
-
     </div>
     </div>
   );
   );
 };
 };
 
 
-CustomBotWithoutProxyConnectionStatus.displayName = 'CustomBotWithoutProxyConnectionStatus';
+CustomBotWithoutProxyConnectionStatus.displayName =
+  'CustomBotWithoutProxyConnectionStatus';

+ 45 - 24
apps/app/src/client/components/Admin/SlackIntegration/CustomBotWithoutProxySecretTokenSection.jsx

@@ -1,21 +1,25 @@
-import React, { useState, useEffect } from 'react';
-
+import React, { useEffect, useState } from 'react';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
 
 
 import { apiv3Put } from '~/client/util/apiv3-client';
 import { apiv3Put } from '~/client/util/apiv3-client';
-import { toastSuccess, toastError } from '~/client/util/toastr';
+import { toastError, toastSuccess } from '~/client/util/toastr';
 
 
 import AdminUpdateButtonRow from '../Common/AdminUpdateButtonRow';
 import AdminUpdateButtonRow from '../Common/AdminUpdateButtonRow';
 
 
-
 const CustomBotWithoutProxySecretTokenSection = (props) => {
 const CustomBotWithoutProxySecretTokenSection = (props) => {
   const {
   const {
-    slackSigningSecret, slackBotToken, slackSigningSecretEnv, slackBotTokenEnv, onUpdatedSecretToken,
+    slackSigningSecret,
+    slackBotToken,
+    slackSigningSecretEnv,
+    slackBotTokenEnv,
+    onUpdatedSecretToken,
   } = props;
   } = props;
   const { t } = useTranslation();
   const { t } = useTranslation();
 
 
-  const [inputSigningSecret, setInputSigningSecret] = useState(slackSigningSecret || '');
+  const [inputSigningSecret, setInputSigningSecret] = useState(
+    slackSigningSecret || '',
+  );
   const [inputBotToken, setInputBotToken] = useState(slackBotToken || '');
   const [inputBotToken, setInputBotToken] = useState(slackBotToken || '');
 
 
   // update states when props are updated
   // update states when props are updated
@@ -26,37 +30,44 @@ const CustomBotWithoutProxySecretTokenSection = (props) => {
     setInputBotToken(slackBotToken || '');
     setInputBotToken(slackBotToken || '');
   }, [slackBotToken]);
   }, [slackBotToken]);
 
 
-  const updatedSecretToken = async() => {
+  const updatedSecretToken = async () => {
     try {
     try {
-      await apiv3Put('/slack-integration-settings/without-proxy/update-settings', {
-        slackSigningSecret: inputSigningSecret,
-        slackBotToken: inputBotToken,
-      });
+      await apiv3Put(
+        '/slack-integration-settings/without-proxy/update-settings',
+        {
+          slackSigningSecret: inputSigningSecret,
+          slackBotToken: inputBotToken,
+        },
+      );
 
 
       if (onUpdatedSecretToken != null) {
       if (onUpdatedSecretToken != null) {
         onUpdatedSecretToken(inputSigningSecret, inputBotToken);
         onUpdatedSecretToken(inputSigningSecret, inputBotToken);
       }
       }
 
 
-      toastSuccess(t('toaster.update_successed', { target: t('admin:slack_integration.custom_bot_without_proxy_settings'), ns: 'commons' }));
-    }
-    catch (err) {
+      toastSuccess(
+        t('toaster.update_successed', {
+          target: t(
+            'admin:slack_integration.custom_bot_without_proxy_settings',
+          ),
+          ns: 'commons',
+        }),
+      );
+    } catch (err) {
       toastError(err);
       toastError(err);
     }
     }
   };
   };
 
 
   return (
   return (
     <div className="w-75 mx-auto">
     <div className="w-75 mx-auto">
-
       <h3>Signing Secret</h3>
       <h3>Signing Secret</h3>
       <div className="row">
       <div className="row">
-
         <div className="col-sm">
         <div className="col-sm">
           <p>Database</p>
           <p>Database</p>
           <input
           <input
             className="form-control"
             className="form-control"
             type="text"
             type="text"
             value={inputSigningSecret}
             value={inputSigningSecret}
-            onChange={e => setInputSigningSecret(e.target.value)}
+            onChange={(e) => setInputSigningSecret(e.target.value)}
           />
           />
         </div>
         </div>
 
 
@@ -70,22 +81,27 @@ const CustomBotWithoutProxySecretTokenSection = (props) => {
           />
           />
           <p className="form-text text-muted">
           <p className="form-text text-muted">
             {/* eslint-disable-next-line max-len, react/no-danger */}
             {/* eslint-disable-next-line max-len, react/no-danger */}
-            <small dangerouslySetInnerHTML={{ __html: t('admin:slack_integration.use_env_var_if_empty', { variable: 'SLACKBOT_WITHOUT_PROXY_SIGNING_SECRET' }) }} />
+            <small
+              // biome-ignore lint/security/noDangerouslySetInnerHtml: includes markup from i18n strings
+              dangerouslySetInnerHTML={{
+                __html: t('admin:slack_integration.use_env_var_if_empty', {
+                  variable: 'SLACKBOT_WITHOUT_PROXY_SIGNING_SECRET',
+                }),
+              }}
+            />
           </p>
           </p>
         </div>
         </div>
-
       </div>
       </div>
 
 
       <h3>Bot User OAuth Token</h3>
       <h3>Bot User OAuth Token</h3>
       <div className="row">
       <div className="row">
-
         <div className="col-sm">
         <div className="col-sm">
           <p>Database</p>
           <p>Database</p>
           <input
           <input
             className="form-control"
             className="form-control"
             type="text"
             type="text"
             value={inputBotToken}
             value={inputBotToken}
-            onChange={e => setInputBotToken(e.target.value)}
+            onChange={(e) => setInputBotToken(e.target.value)}
           />
           />
         </div>
         </div>
 
 
@@ -99,14 +115,19 @@ const CustomBotWithoutProxySecretTokenSection = (props) => {
           />
           />
           <p className="form-text text-muted">
           <p className="form-text text-muted">
             {/* eslint-disable-next-line react/no-danger */}
             {/* eslint-disable-next-line react/no-danger */}
-            <small dangerouslySetInnerHTML={{ __html: t('admin:slack_integration.use_env_var_if_empty', { variable: 'SLACKBOT_WITHOUT_PROXY_BOT_TOKEN' }) }} />
+            <small
+              // biome-ignore lint/security/noDangerouslySetInnerHtml: includes markup from i18n strings
+              dangerouslySetInnerHTML={{
+                __html: t('admin:slack_integration.use_env_var_if_empty', {
+                  variable: 'SLACKBOT_WITHOUT_PROXY_BOT_TOKEN',
+                }),
+              }}
+            />
           </p>
           </p>
         </div>
         </div>
-
       </div>
       </div>
 
 
       <AdminUpdateButtonRow onClick={updatedSecretToken} disabled={false} />
       <AdminUpdateButtonRow onClick={updatedSecretToken} disabled={false} />
-
     </div>
     </div>
   );
   );
 };
 };

+ 18 - 10
apps/app/src/client/components/Admin/SlackIntegration/CustomBotWithoutProxySettings.jsx

@@ -1,12 +1,13 @@
-import React, { useState, useEffect } from 'react';
-
+import React, { useEffect, useState } from 'react';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
 
 
 import { useAppTitle } from '~/states/global';
 import { useAppTitle } from '~/states/global';
 
 
 import { CustomBotWithoutProxyConnectionStatus } from './CustomBotWithoutProxyConnectionStatus';
 import { CustomBotWithoutProxyConnectionStatus } from './CustomBotWithoutProxyConnectionStatus';
-import CustomBotWithoutProxySettingsAccordion, { botInstallationStep } from './CustomBotWithoutProxySettingsAccordion';
+import CustomBotWithoutProxySettingsAccordion, {
+  botInstallationStep,
+} from './CustomBotWithoutProxySettingsAccordion';
 
 
 const CustomBotWithoutProxySettings = (props) => {
 const CustomBotWithoutProxySettings = (props) => {
   const { connectionStatuses } = props;
   const { connectionStatuses } = props;
@@ -22,9 +23,16 @@ const CustomBotWithoutProxySettings = (props) => {
 
 
   return (
   return (
     <>
     <>
-      <h2 className="admin-setting-header">{t('admin:slack_integration.custom_bot_without_proxy_integration')}
-        <a href={t('admin:slack_integration.docs_url.custom_bot_without_proxy')} target="_blank" rel="noopener noreferrer">
-          <span className="growi-custom-icons btn-link ms-2">external_link</span>
+      <h2 className="admin-setting-header">
+        {t('admin:slack_integration.custom_bot_without_proxy_integration')}
+        <a
+          href={t('admin:slack_integration.docs_url.custom_bot_without_proxy')}
+          target="_blank"
+          rel="noopener noreferrer"
+        >
+          <span className="growi-custom-icons btn-link ms-2">
+            external_link
+          </span>
         </a>
         </a>
       </h2>
       </h2>
 
 
@@ -33,12 +41,14 @@ const CustomBotWithoutProxySettings = (props) => {
         connectionStatuses={connectionStatuses}
         connectionStatuses={connectionStatuses}
       />
       />
 
 
-      <h2 className="admin-setting-header">{t('admin:slack_integration.integration_procedure')}</h2>
+      <h2 className="admin-setting-header">
+        {t('admin:slack_integration.integration_procedure')}
+      </h2>
 
 
       <div className="px-3">
       <div className="px-3">
         <div className="my-3 d-flex align-items-center justify-content-between">
         <div className="my-3 d-flex align-items-center justify-content-between">
           <h2 id={props.slackBotToken || 'settings-accordions'}>
           <h2 id={props.slackBotToken || 'settings-accordions'}>
-            {(workspaceName != null) ? `${workspaceName} Work Space` : 'Settings'}
+            {workspaceName != null ? `${workspaceName} Work Space` : 'Settings'}
           </h2>
           </h2>
         </div>
         </div>
         <CustomBotWithoutProxySettingsAccordion
         <CustomBotWithoutProxySettingsAccordion
@@ -57,9 +67,7 @@ const CustomBotWithoutProxySettings = (props) => {
   );
   );
 };
 };
 
 
-
 CustomBotWithoutProxySettings.propTypes = {
 CustomBotWithoutProxySettings.propTypes = {
-
   slackSigningSecret: PropTypes.string,
   slackSigningSecret: PropTypes.string,
   slackSigningSecretEnv: PropTypes.string,
   slackSigningSecretEnv: PropTypes.string,
   slackBotToken: PropTypes.string,
   slackBotToken: PropTypes.string,

+ 157 - 49
apps/app/src/client/components/Admin/SlackIntegration/CustomBotWithoutProxySettingsAccordion.jsx

@@ -1,18 +1,15 @@
 import React, { useState } from 'react';
 import React, { useState } from 'react';
-
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
 
 
 import { apiv3Post } from '~/client/util/apiv3-client';
 import { apiv3Post } from '~/client/util/apiv3-client';
 
 
 import Accordion from '../Common/Accordion';
 import Accordion from '../Common/Accordion';
-
 import CustomBotWithoutProxySecretTokenSection from './CustomBotWithoutProxySecretTokenSection';
 import CustomBotWithoutProxySecretTokenSection from './CustomBotWithoutProxySecretTokenSection';
 import ManageCommandsProcessWithoutProxy from './ManageCommandsProcessWithoutProxy';
 import ManageCommandsProcessWithoutProxy from './ManageCommandsProcessWithoutProxy';
 import MessageBasedOnConnection from './MessageBasedOnConnection';
 import MessageBasedOnConnection from './MessageBasedOnConnection';
 import { addLogs } from './slack-integration-util';
 import { addLogs } from './slack-integration-util';
 
 
-
 export const botInstallationStep = {
 export const botInstallationStep = {
   CREATE_BOT: 'create-bot',
   CREATE_BOT: 'create-bot',
   INSTALL_BOT: 'install-bot',
   INSTALL_BOT: 'install-bot',
@@ -20,32 +17,41 @@ export const botInstallationStep = {
   CONNECTION_TEST: 'connection-test',
   CONNECTION_TEST: 'connection-test',
 };
 };
 
 
-
 const CustomBotWithoutProxySettingsAccordion = (props) => {
 const CustomBotWithoutProxySettingsAccordion = (props) => {
   const {
   const {
-    activeStep, onTestConnectionInvoked,
-    slackSigningSecret, slackBotToken, slackSigningSecretEnv, slackBotTokenEnv, commandPermission, eventActionsPermission,
+    activeStep,
+    onTestConnectionInvoked,
+    slackSigningSecret,
+    slackBotToken,
+    slackSigningSecretEnv,
+    slackBotTokenEnv,
+    commandPermission,
+    eventActionsPermission,
   } = props;
   } = props;
   const successMessage = 'Successfully sent to Slack workspace.';
   const successMessage = 'Successfully sent to Slack workspace.';
 
 
   const { t } = useTranslation();
   const { t } = useTranslation();
   // eslint-disable-next-line no-unused-vars
   // eslint-disable-next-line no-unused-vars
-  const [defaultOpenAccordionKeys, setDefaultOpenAccordionKeys] = useState(new Set([activeStep]));
-  const [isLatestConnectionSuccess, setIsLatestConnectionSuccess] = useState(false);
+  const [defaultOpenAccordionKeys, setDefaultOpenAccordionKeys] = useState(
+    new Set([activeStep]),
+  );
+  const [isLatestConnectionSuccess, setIsLatestConnectionSuccess] =
+    useState(false);
   const [testChannel, setTestChannel] = useState('');
   const [testChannel, setTestChannel] = useState('');
   const [logsValue, setLogsValue] = useState('');
   const [logsValue, setLogsValue] = useState('');
 
 
-  const testConnection = async() => {
+  const testConnection = async () => {
     try {
     try {
-      await apiv3Post('/slack-integration-settings/without-proxy/test', { channel: testChannel });
+      await apiv3Post('/slack-integration-settings/without-proxy/test', {
+        channel: testChannel,
+      });
       setIsLatestConnectionSuccess(true);
       setIsLatestConnectionSuccess(true);
       if (onTestConnectionInvoked != null) {
       if (onTestConnectionInvoked != null) {
         onTestConnectionInvoked();
         onTestConnectionInvoked();
         const newLogs = addLogs(logsValue, successMessage, null);
         const newLogs = addLogs(logsValue, successMessage, null);
         setLogsValue(newLogs);
         setLogsValue(newLogs);
       }
       }
-    }
-    catch (err) {
+    } catch (err) {
       setIsLatestConnectionSuccess(false);
       setIsLatestConnectionSuccess(false);
       const newLogs = addLogs(logsValue, err[0].message, err[0].code);
       const newLogs = addLogs(logsValue, err[0].message, err[0].code);
       setLogsValue(newLogs);
       setLogsValue(newLogs);
@@ -61,27 +67,41 @@ const CustomBotWithoutProxySettingsAccordion = (props) => {
     testConnection();
     testConnection();
   };
   };
 
 
-
-  const slackSigningSecretCombined = slackSigningSecret || slackSigningSecretEnv;
+  const slackSigningSecretCombined =
+    slackSigningSecret || slackSigningSecretEnv;
   const slackBotTokenCombined = slackBotToken || slackBotTokenEnv;
   const slackBotTokenCombined = slackBotToken || slackBotTokenEnv;
-  const isEnterdSecretAndToken = (
-    (slackSigningSecretCombined != null && slackSigningSecretCombined.length > 0)
-    && (slackBotTokenCombined != null && slackBotTokenCombined.length > 0)
-  );
+  const isEnterdSecretAndToken =
+    slackSigningSecretCombined != null &&
+    slackSigningSecretCombined.length > 0 &&
+    slackBotTokenCombined != null &&
+    slackBotTokenCombined.length > 0;
 
 
   return (
   return (
     <div className="accordion">
     <div className="accordion">
       <Accordion
       <Accordion
-        defaultIsActive={defaultOpenAccordionKeys.has(botInstallationStep.CREATE_BOT)}
-        title={<><span className="me-3">1</span>{t('admin:slack_integration.accordion.create_bot')}</>}
+        defaultIsActive={defaultOpenAccordionKeys.has(
+          botInstallationStep.CREATE_BOT,
+        )}
+        title={
+          <>
+            <span className="me-3">1</span>
+            {t('admin:slack_integration.accordion.create_bot')}
+          </>
+        }
       >
       >
         <div className="my-5 d-flex flex-column align-items-center">
         <div className="my-5 d-flex flex-column align-items-center">
-          <button type="button" className="btn btn-primary text-nowrap" onClick={() => window.open('https://api.slack.com/apps', '_blank')}>
+          <button
+            type="button"
+            className="btn btn-primary text-nowrap"
+            onClick={() => window.open('https://api.slack.com/apps', '_blank')}
+          >
             {t('admin:slack_integration.accordion.create_bot')}
             {t('admin:slack_integration.accordion.create_bot')}
             <span className="growi-custom-icons ms-2">external_link</span>
             <span className="growi-custom-icons ms-2">external_link</span>
           </button>
           </button>
           <a
           <a
-            href={t('admin:slack_integration.docs_url.custom_bot_without_proxy_setting')}
+            href={t(
+              'admin:slack_integration.docs_url.custom_bot_without_proxy_setting',
+            )}
             target="_blank"
             target="_blank"
             rel="noopener noreferrer"
             rel="noopener noreferrer"
           >
           >
@@ -95,27 +115,80 @@ const CustomBotWithoutProxySettingsAccordion = (props) => {
         </div>
         </div>
       </Accordion>
       </Accordion>
       <Accordion
       <Accordion
-        defaultIsActive={defaultOpenAccordionKeys.has(botInstallationStep.INSTALL_BOT)}
-        title={<><span className="me-3">2</span>{t('admin:slack_integration.accordion.install_bot_to_slack')}</>}
+        defaultIsActive={defaultOpenAccordionKeys.has(
+          botInstallationStep.INSTALL_BOT,
+        )}
+        title={
+          <>
+            <span className="me-3">2</span>
+            {t('admin:slack_integration.accordion.install_bot_to_slack')}
+          </>
+        }
       >
       >
         <div className="container w-75 py-5">
         <div className="container w-75 py-5">
-          <p>1. {t('admin:slack_integration.accordion.select_install_your_app')}</p>
-          <img src="/images/slack-integration/slack-bot-install-your-app-introduction.png" className="border border-light img-fluid mb-5" />
-          <p>2. {t('admin:slack_integration.accordion.select_install_to_workspace')}</p>
-          <img src="/images/slack-integration/slack-bot-install-to-workspace.png" className="border border-light img-fluid mb-5" />
+          <p>
+            1. {t('admin:slack_integration.accordion.select_install_your_app')}
+          </p>
+          <img
+            src="/images/slack-integration/slack-bot-install-your-app-introduction.png"
+            className="border border-light img-fluid mb-5"
+            alt=""
+          />
+          <p>
+            2.{' '}
+            {t('admin:slack_integration.accordion.select_install_to_workspace')}
+          </p>
+          <img
+            src="/images/slack-integration/slack-bot-install-to-workspace.png"
+            className="border border-light img-fluid mb-5"
+            alt=""
+          />
           <p>3. {t('admin:slack_integration.accordion.click_allow')}</p>
           <p>3. {t('admin:slack_integration.accordion.click_allow')}</p>
-          <img src="/images/slack-integration/slack-bot-install-your-app-transition-destination.png" className="border border-light img-fluid mb-5" />
-          <p>4. {t('admin:slack_integration.accordion.install_complete_if_checked')}</p>
-          <img src="/images/slack-integration/slack-bot-install-your-app-complete.png" className="border border-light img-fluid mb-5" />
-          <p>5. {t('admin:slack_integration.accordion.invite_bot_to_channel')}</p>
-          <img src="/images/slack-integration/slack-bot-install-to-workspace-joined-bot.png" className="border border-light img-fluid mb-1" />
-          <img src="/images/slack-integration/slack-bot-install-your-app-introduction-to-channel.png" className="border border-light img-fluid" />
+          <img
+            src="/images/slack-integration/slack-bot-install-your-app-transition-destination.png"
+            className="border border-light img-fluid mb-5"
+            alt=""
+          />
+          <p>
+            4.{' '}
+            {t('admin:slack_integration.accordion.install_complete_if_checked')}
+          </p>
+          <img
+            src="/images/slack-integration/slack-bot-install-your-app-complete.png"
+            className="border border-light img-fluid mb-5"
+            alt=""
+          />
+          <p>
+            5. {t('admin:slack_integration.accordion.invite_bot_to_channel')}
+          </p>
+          <img
+            src="/images/slack-integration/slack-bot-install-to-workspace-joined-bot.png"
+            className="border border-light img-fluid mb-1"
+            alt=""
+          />
+          <img
+            src="/images/slack-integration/slack-bot-install-your-app-introduction-to-channel.png"
+            className="border border-light img-fluid"
+            alt=""
+          />
         </div>
         </div>
       </Accordion>
       </Accordion>
       <Accordion
       <Accordion
-        defaultIsActive={defaultOpenAccordionKeys.has(botInstallationStep.REGISTER_SLACK_CONFIGURATION)}
+        defaultIsActive={defaultOpenAccordionKeys.has(
+          botInstallationStep.REGISTER_SLACK_CONFIGURATION,
+        )}
         // eslint-disable-next-line max-len
         // eslint-disable-next-line max-len
-        title={<><span className="me-3">3</span>{t('admin:slack_integration.accordion.register_secret_and_token')}{isEnterdSecretAndToken && <span className="material-symbols-outlined ms-3 text-success">check</span>}</>}
+        title={
+          <>
+            <span className="me-3">3</span>
+            {t('admin:slack_integration.accordion.register_secret_and_token')}
+            {isEnterdSecretAndToken && (
+              <span className="material-symbols-outlined ms-3 text-success">
+                check
+              </span>
+            )}
+          </>
+        }
       >
       >
         <CustomBotWithoutProxySecretTokenSection
         <CustomBotWithoutProxySecretTokenSection
           onUpdatedSecretToken={props.onUpdatedSecretToken}
           onUpdatedSecretToken={props.onUpdatedSecretToken}
@@ -126,9 +199,16 @@ const CustomBotWithoutProxySettingsAccordion = (props) => {
         />
         />
       </Accordion>
       </Accordion>
       <Accordion
       <Accordion
-        defaultIsActive={defaultOpenAccordionKeys.has(botInstallationStep.CONNECTION_TEST)}
+        defaultIsActive={defaultOpenAccordionKeys.has(
+          botInstallationStep.CONNECTION_TEST,
+        )}
         // eslint-disable-next-line max-len
         // eslint-disable-next-line max-len
-        title={<><span className="me-3">4</span>{t('admin:slack_integration.accordion.manage_permission')}</>}
+        title={
+          <>
+            <span className="me-3">4</span>
+            {t('admin:slack_integration.accordion.manage_permission')}
+          </>
+        }
       >
       >
         <ManageCommandsProcessWithoutProxy
         <ManageCommandsProcessWithoutProxy
           commandPermission={commandPermission}
           commandPermission={commandPermission}
@@ -136,43 +216,72 @@ const CustomBotWithoutProxySettingsAccordion = (props) => {
         />
         />
       </Accordion>
       </Accordion>
       <Accordion
       <Accordion
-        defaultIsActive={defaultOpenAccordionKeys.has(botInstallationStep.CONNECTION_TEST)}
+        defaultIsActive={defaultOpenAccordionKeys.has(
+          botInstallationStep.CONNECTION_TEST,
+        )}
         // eslint-disable-next-line max-len
         // eslint-disable-next-line max-len
-        title={<><span className="me-3">5</span>{t('admin:slack_integration.accordion.test_connection')}{isLatestConnectionSuccess && <span className="material-symbols-outlined ms-3 text-success">check</span>}</>}
+        title={
+          <>
+            <span className="me-3">5</span>
+            {t('admin:slack_integration.accordion.test_connection')}
+            {isLatestConnectionSuccess && (
+              <span className="material-symbols-outlined ms-3 text-success">
+                check
+              </span>
+            )}
+          </>
+        }
       >
       >
-        <p className="text-center m-4">{t('admin:slack_integration.accordion.test_connection_by_pressing_button')}</p>
+        <p className="text-center m-4">
+          {t(
+            'admin:slack_integration.accordion.test_connection_by_pressing_button',
+          )}
+        </p>
         <p className="text-center text-warning">
         <p className="text-center text-warning">
-          <span className="material-symbols-outlined">info</span>{t('admin:slack_integration.accordion.test_connection_only_public_channel')}
+          <span className="material-symbols-outlined">info</span>
+          {t(
+            'admin:slack_integration.accordion.test_connection_only_public_channel',
+          )}
         </p>
         </p>
         <div className="d-flex justify-content-center">
         <div className="d-flex justify-content-center">
-          <form className="align-items-center" onSubmit={e => submitForm(e)}>
+          <form className="align-items-center" onSubmit={(e) => submitForm(e)}>
             <div className="input-group col-8">
             <div className="input-group col-8">
               <div>
               <div>
-                <span className="input-group-text" id="slack-channel-addon"><span className="material-symbols-outlined">tag</span></span>
+                <span className="input-group-text" id="slack-channel-addon">
+                  <span className="material-symbols-outlined">tag</span>
+                </span>
               </div>
               </div>
               <input
               <input
                 className="form-control"
                 className="form-control"
                 type="text"
                 type="text"
                 value={testChannel}
                 value={testChannel}
                 placeholder="Slack Channel"
                 placeholder="Slack Channel"
-                onChange={e => inputTestChannelHandler(e.target.value)}
+                onChange={(e) => inputTestChannelHandler(e.target.value)}
               />
               />
             </div>
             </div>
             <button
             <button
               type="submit"
               type="submit"
               className="btn btn-info mx-3 fw-bold"
               className="btn btn-info mx-3 fw-bold"
               disabled={testChannel.trim().length === 0}
               disabled={testChannel.trim().length === 0}
-            >Test
+            >
+              Test
             </button>
             </button>
           </form>
           </form>
         </div>
         </div>
 
 
-        <MessageBasedOnConnection isLatestConnectionSuccess={isLatestConnectionSuccess} logsValue={logsValue} />
+        <MessageBasedOnConnection
+          isLatestConnectionSuccess={isLatestConnectionSuccess}
+          logsValue={logsValue}
+        />
 
 
         <form>
         <form>
           <div className="row my-3 justify-content-center">
           <div className="row my-3 justify-content-center">
             <div className="slack-connection-log col-md-4">
             <div className="slack-connection-log col-md-4">
-              <label className="form-label mb-1"><p className="border-info slack-connection-log-title ps-2 m-0">Logs</p></label>
+              <div className="form-label mb-1">
+                <p className="border-info slack-connection-log-title ps-2 m-0">
+                  Logs
+                </p>
+              </div>
               <textarea
               <textarea
                 className="form-control card border-info slack-connection-log-body rounded-3"
                 className="form-control card border-info slack-connection-log-body rounded-3"
                 rows="5"
                 rows="5"
@@ -187,7 +296,6 @@ const CustomBotWithoutProxySettingsAccordion = (props) => {
   );
   );
 };
 };
 
 
-
 CustomBotWithoutProxySettingsAccordion.propTypes = {
 CustomBotWithoutProxySettingsAccordion.propTypes = {
   activeStep: PropTypes.oneOf(Object.values(botInstallationStep)).isRequired,
   activeStep: PropTypes.oneOf(Object.values(botInstallationStep)).isRequired,
 
 

+ 81 - 79
apps/app/src/client/components/Admin/SlackIntegration/DeleteSlackBotSettingsModal.tsx

@@ -1,103 +1,105 @@
 import React, { useCallback, useMemo } from 'react';
 import React, { useCallback, useMemo } from 'react';
-
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
-import {
-  Button, Modal, ModalHeader, ModalBody, ModalFooter,
-} from 'reactstrap';
+import { Button, Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
 
 
 type DeleteSlackBotSettingsModalProps = {
 type DeleteSlackBotSettingsModalProps = {
-  isResetAll: boolean,
-  isOpen: boolean,
-  onClose?: () => void,
-  onClickDeleteButton?: () => void,
-}
-
-export const DeleteSlackBotSettingsModal = React.memo((props: DeleteSlackBotSettingsModalProps) => {
+  isResetAll: boolean;
+  isOpen: boolean;
+  onClose?: () => void;
+  onClickDeleteButton?: () => void;
+};
 
 
-  const { t } = useTranslation();
+export const DeleteSlackBotSettingsModal = React.memo(
+  (props: DeleteSlackBotSettingsModalProps) => {
+    const { t } = useTranslation();
 
 
-  const {
-    isResetAll, isOpen, onClose, onClickDeleteButton,
-  } = props;
+    const { isResetAll, isOpen, onClose, onClickDeleteButton } = props;
 
 
-  const deleteSlackCredentialsHandler = useCallback(() => {
-    onClickDeleteButton?.();
-    onClose?.();
-  }, [onClickDeleteButton, onClose]);
+    const deleteSlackCredentialsHandler = useCallback(() => {
+      onClickDeleteButton?.();
+      onClose?.();
+    }, [onClickDeleteButton, onClose]);
 
 
-  const closeButtonHandler = useCallback(() => {
-    onClose?.();
-  }, [onClose]);
+    const closeButtonHandler = useCallback(() => {
+      onClose?.();
+    }, [onClose]);
 
 
-  // Memoize conditional content
-  const headerContent = useMemo(() => {
-    if (isResetAll) {
+    // Memoize conditional content
+    const headerContent = useMemo(() => {
+      if (isResetAll) {
+        return (
+          <>
+            <span className="material-symbols-outlined">delete_forever</span>
+            {t('admin:slack_integration.reset_all_settings')}
+          </>
+        );
+      }
       return (
       return (
         <>
         <>
-          <span className="material-symbols-outlined">delete_forever</span>
-          {t('admin:slack_integration.reset_all_settings')}
+          <span className="material-symbols-outlined">delete</span>
+          {t('admin:slack_integration.delete_slackbot_settings')}
         </>
         </>
       );
       );
-    }
-    return (
-      <>
-        <span className="material-symbols-outlined">delete</span>
-        {t('admin:slack_integration.delete_slackbot_settings')}
-      </>
-    );
-  }, [isResetAll, t]);
+    }, [isResetAll, t]);
 
 
-  const bodyContent = useMemo(() => {
-    const htmlContent = isResetAll
-      ? t('admin:slack_integration.all_settings_of_the_bot_will_be_reset')
-      : t('admin:slack_integration.slackbot_settings_notice');
-    return (
-      <span
-        // eslint-disable-next-line react/no-danger
-        dangerouslySetInnerHTML={{ __html: htmlContent }}
-      />
-    );
-  }, [isResetAll, t]);
+    const bodyContent = useMemo(() => {
+      const htmlContent = isResetAll
+        ? t('admin:slack_integration.all_settings_of_the_bot_will_be_reset')
+        : t('admin:slack_integration.slackbot_settings_notice');
+      return (
+        <span
+          // eslint-disable-next-line react/no-danger
+          // biome-ignore lint/security/noDangerouslySetInnerHtml: includes markup from i18n strings
+          dangerouslySetInnerHTML={{ __html: htmlContent }}
+        />
+      );
+    }, [isResetAll, t]);
 
 
-  const deleteButtonContent = useMemo(() => {
-    if (isResetAll) {
+    const deleteButtonContent = useMemo(() => {
+      if (isResetAll) {
+        return (
+          <>
+            <span className="material-symbols-outlined">delete_forever</span>
+            {t('admin:slack_integration.reset')}
+          </>
+        );
+      }
       return (
       return (
         <>
         <>
-          <span className="material-symbols-outlined">delete_forever</span>
-          {t('admin:slack_integration.reset')}
+          <span className="material-symbols-outlined">delete</span>
+          {t('admin:slack_integration.delete')}
         </>
         </>
       );
       );
+    }, [isResetAll, t]);
+
+    // Early return optimization
+    if (!isOpen) {
+      return <></>;
     }
     }
+
     return (
     return (
-      <>
-        <span className="material-symbols-outlined">delete</span>
-        {t('admin:slack_integration.delete')}
-      </>
+      <Modal
+        isOpen={isOpen}
+        toggle={closeButtonHandler}
+        className="page-comment-delete-modal"
+      >
+        <ModalHeader
+          tag="h4"
+          toggle={closeButtonHandler}
+          className="text-danger"
+        >
+          <span>{headerContent}</span>
+        </ModalHeader>
+        <ModalBody>{bodyContent}</ModalBody>
+        <ModalFooter>
+          <Button onClick={closeButtonHandler}>{t('Cancel')}</Button>
+          <Button color="danger" onClick={deleteSlackCredentialsHandler}>
+            {deleteButtonContent}
+          </Button>
+        </ModalFooter>
+      </Modal>
     );
     );
-  }, [isResetAll, t]);
-
-  // Early return optimization
-  if (!isOpen) {
-    return <></>;
-  }
-
-  return (
-    <Modal isOpen={isOpen} toggle={closeButtonHandler} className="page-comment-delete-modal">
-      <ModalHeader tag="h4" toggle={closeButtonHandler} className="text-danger">
-        <span>{headerContent}</span>
-      </ModalHeader>
-      <ModalBody>
-        {bodyContent}
-      </ModalBody>
-      <ModalFooter>
-        <Button onClick={closeButtonHandler}>{t('Cancel')}</Button>
-        <Button color="danger" onClick={deleteSlackCredentialsHandler}>
-          {deleteButtonContent}
-        </Button>
-      </ModalFooter>
-    </Modal>
-  );
-
-});
+  },
+);
 
 
 DeleteSlackBotSettingsModal.displayName = 'DeleteSlackBotSettingsModal';
 DeleteSlackBotSettingsModal.displayName = 'DeleteSlackBotSettingsModal';

+ 149 - 103
apps/app/src/client/components/Admin/SlackIntegration/ManageCommandsProcess.jsx

@@ -1,6 +1,9 @@
 import React, { useCallback, useState } from 'react';
 import React, { useCallback, useState } from 'react';
-
-import { defaultSupportedCommandsNameForBroadcastUse, defaultSupportedCommandsNameForSingleUse, defaultSupportedSlackEventActions } from '@growi/slack';
+import {
+  defaultSupportedCommandsNameForBroadcastUse,
+  defaultSupportedCommandsNameForSingleUse,
+  defaultSupportedSlackEventActions,
+} from '@growi/slack';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
 
 
@@ -8,7 +11,6 @@ import { apiv3Put } from '~/client/util/apiv3-client';
 import { toastError, toastSuccess } from '~/client/util/toastr';
 import { toastError, toastSuccess } from '~/client/util/toastr';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
-
 const logger = loggerFactory('growi:SlackIntegration:ManageCommandsProcess');
 const logger = loggerFactory('growi:SlackIntegration:ManageCommandsProcess');
 
 
 const PermissionTypes = {
 const PermissionTypes = {
@@ -26,13 +28,14 @@ const EventTypes = {
   LINK_SHARING: 'linkSharing',
   LINK_SHARING: 'linkSharing',
 };
 };
 
 
-
 // A utility function that returns the new state but identical to the previous state
 // A utility function that returns the new state but identical to the previous state
 const getUpdatedChannelsList = (prevState, commandName, value) => {
 const getUpdatedChannelsList = (prevState, commandName, value) => {
   // string to array
   // string to array
   const allowedChannelsArray = value.split(',');
   const allowedChannelsArray = value.split(',');
   // trim whitespace from all elements
   // trim whitespace from all elements
-  const trimedAllowedChannelsArray = allowedChannelsArray.map(channelName => channelName.trim());
+  const trimedAllowedChannelsArray = allowedChannelsArray.map((channelName) =>
+    channelName.trim(),
+  );
 
 
   prevState[commandName] = trimedAllowedChannelsArray;
   prevState[commandName] = trimedAllowedChannelsArray;
   return prevState;
   return prevState;
@@ -71,15 +74,23 @@ const getPermissionTypeFromValue = (value) => {
 };
 };
 
 
 const PermissionSettingForEachPermissionTypeComponent = ({
 const PermissionSettingForEachPermissionTypeComponent = ({
-  keyName, onUpdatePermissions, onUpdateChannels, singleCommandDescription, allowedChannelsDescription, currentPermissionType, permissionSettings,
+  keyName,
+  onUpdatePermissions,
+  onUpdateChannels,
+  singleCommandDescription,
+  allowedChannelsDescription,
+  currentPermissionType,
+  permissionSettings,
 }) => {
 }) => {
   const { t } = useTranslation();
   const { t } = useTranslation();
-  const hiddenClass = currentPermissionType === PermissionTypes.ALLOW_SPECIFIED ? '' : 'd-none';
+  const hiddenClass =
+    currentPermissionType === PermissionTypes.ALLOW_SPECIFIED ? '' : 'd-none';
 
 
   const permission = permissionSettings[keyName];
   const permission = permissionSettings[keyName];
   if (permission === undefined) logger.error('Must be implemented');
   if (permission === undefined) logger.error('Must be implemented');
-  const textareaDefaultValue = Array.isArray(permission) ? permission.join(',') : '';
-
+  const textareaDefaultValue = Array.isArray(permission)
+    ? permission.join(',')
+    : '';
 
 
   return (
   return (
     <div className="my-1 mb-2">
     <div className="my-1 mb-2">
@@ -88,7 +99,7 @@ const PermissionSettingForEachPermissionTypeComponent = ({
           <strong className="text-capitalize">{keyName}</strong>
           <strong className="text-capitalize">{keyName}</strong>
           {singleCommandDescription && (
           {singleCommandDescription && (
             <small className="form-text text-muted small">
             <small className="form-text text-muted small">
-              { singleCommandDescription }
+              {singleCommandDescription}
             </small>
             </small>
           )}
           )}
         </p>
         </p>
@@ -102,12 +113,12 @@ const PermissionSettingForEachPermissionTypeComponent = ({
             aria-expanded="true"
             aria-expanded="true"
           >
           >
             <span className="float-start">
             <span className="float-start">
-              {currentPermissionType === PermissionTypes.ALLOW_ALL
-              && t('admin:slack_integration.accordion.allow_all')}
-              {currentPermissionType === PermissionTypes.DENY_ALL
-              && t('admin:slack_integration.accordion.deny_all')}
-              {currentPermissionType === PermissionTypes.ALLOW_SPECIFIED
-              && t('admin:slack_integration.accordion.allow_specified')}
+              {currentPermissionType === PermissionTypes.ALLOW_ALL &&
+                t('admin:slack_integration.accordion.allow_all')}
+              {currentPermissionType === PermissionTypes.DENY_ALL &&
+                t('admin:slack_integration.accordion.deny_all')}
+              {currentPermissionType === PermissionTypes.ALLOW_SPECIFIED &&
+                t('admin:slack_integration.accordion.allow_specified')}
             </span>
             </span>
           </button>
           </button>
           <div className="dropdown-menu">
           <div className="dropdown-menu">
@@ -170,17 +181,80 @@ PermissionSettingForEachPermissionTypeComponent.propTypes = {
   permissionSettings: PropTypes.object,
   permissionSettings: PropTypes.object,
 };
 };
 
 
+const PermissionSettingsForEachCategoryComponent = ({
+  currentPermissionTypes,
+  usageType,
+  menuItem,
+  permissionSettings,
+}) => {
+  const {
+    title,
+    description,
+    defaultCommandsName,
+    singleCommandDescription,
+    updatePermissionsHandler,
+    updateChannelsHandler,
+    allowedChannelsDescription,
+  } = menuItem;
+
+  return (
+    <>
+      {(title || description) && (
+        <div className="row">
+          <div className="col-md-7 offset-md-2">
+            {title && <p className="fw-bold mb-1">{title}</p>}
+            {description && <p className="text-muted">{description}</p>}
+          </div>
+        </div>
+      )}
+
+      <div className="form-check">
+        <div className="row mb-5 d-block">
+          {defaultCommandsName.map((keyName) => (
+            <PermissionSettingForEachPermissionTypeComponent
+              key={`${keyName}-component`}
+              keyName={keyName}
+              usageType={usageType}
+              permissionSettings={permissionSettings}
+              currentPermissionType={currentPermissionTypes[keyName]}
+              singleCommandDescription={singleCommandDescription}
+              onUpdatePermissions={updatePermissionsHandler}
+              onUpdateChannels={updateChannelsHandler}
+              allowedChannelsDescription={allowedChannelsDescription}
+            />
+          ))}
+        </div>
+      </div>
+    </>
+  );
+};
+
+PermissionSettingsForEachCategoryComponent.propTypes = {
+  currentPermissionTypes: PropTypes.object,
+  usageType: PropTypes.string,
+  menuItem: PropTypes.object,
+  permissionSettings: PropTypes.object,
+};
 
 
 // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
 // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
 const ManageCommandsProcess = ({
 const ManageCommandsProcess = ({
-  slackAppIntegrationId, permissionsForBroadcastUseCommands, permissionsForSingleUseCommands, permissionsForSlackEventActions,
+  slackAppIntegrationId,
+  permissionsForBroadcastUseCommands,
+  permissionsForSingleUseCommands,
+  permissionsForSlackEventActions,
 }) => {
 }) => {
   const { t } = useTranslation();
   const { t } = useTranslation();
 
 
-  const [permissionsForBroadcastUseCommandsState, setPermissionsForBroadcastUseCommandsState] = useState({
+  const [
+    permissionsForBroadcastUseCommandsState,
+    setPermissionsForBroadcastUseCommandsState,
+  ] = useState({
     search: permissionsForBroadcastUseCommands.search,
     search: permissionsForBroadcastUseCommands.search,
   });
   });
-  const [permissionsForSingleUseCommandsState, setPermissionsForSingleUseCommandsState] = useState({
+  const [
+    permissionsForSingleUseCommandsState,
+    setPermissionsForSingleUseCommandsState,
+  ] = useState({
     note: permissionsForSingleUseCommands.note,
     note: permissionsForSingleUseCommands.note,
     keep: permissionsForSingleUseCommands.keep,
     keep: permissionsForSingleUseCommands.keep,
   });
   });
@@ -204,11 +278,12 @@ const ManageCommandsProcess = ({
     return initialState;
     return initialState;
   });
   });
 
 
-
   const handleUpdateSingleUsePermissions = useCallback((e) => {
   const handleUpdateSingleUsePermissions = useCallback((e) => {
     const { target } = e;
     const { target } = e;
     const { name: commandName, value } = target;
     const { name: commandName, value } = target;
-    setPermissionsForSingleUseCommandsState(prev => getUpdatedPermissionSettings(prev, commandName, value));
+    setPermissionsForSingleUseCommandsState((prev) =>
+      getUpdatedPermissionSettings(prev, commandName, value),
+    );
     setCurrentPermissionTypes((prevState) => {
     setCurrentPermissionTypes((prevState) => {
       const newState = { ...prevState };
       const newState = { ...prevState };
       newState[commandName] = value;
       newState[commandName] = value;
@@ -219,7 +294,9 @@ const ManageCommandsProcess = ({
   const handleUpdateBroadcastUsePermissions = useCallback((e) => {
   const handleUpdateBroadcastUsePermissions = useCallback((e) => {
     const { target } = e;
     const { target } = e;
     const { name: commandName, value } = target;
     const { name: commandName, value } = target;
-    setPermissionsForBroadcastUseCommandsState(prev => getUpdatedPermissionSettings(prev, commandName, value));
+    setPermissionsForBroadcastUseCommandsState((prev) =>
+      getUpdatedPermissionSettings(prev, commandName, value),
+    );
     setCurrentPermissionTypes((prevState) => {
     setCurrentPermissionTypes((prevState) => {
       const newState = { ...prevState };
       const newState = { ...prevState };
       newState[commandName] = value;
       newState[commandName] = value;
@@ -230,7 +307,9 @@ const ManageCommandsProcess = ({
   const handleUpdateEventsPermissions = useCallback((e) => {
   const handleUpdateEventsPermissions = useCallback((e) => {
     const { target } = e;
     const { target } = e;
     const { name: commandName, value } = target;
     const { name: commandName, value } = target;
-    setPermissionsForEventsState(prev => getUpdatedPermissionSettings(prev, commandName, value));
+    setPermissionsForEventsState((prev) =>
+      getUpdatedPermissionSettings(prev, commandName, value),
+    );
     setCurrentPermissionTypes((prevState) => {
     setCurrentPermissionTypes((prevState) => {
       const newState = { ...prevState };
       const newState = { ...prevState };
       newState[commandName] = value;
       newState[commandName] = value;
@@ -241,107 +320,66 @@ const ManageCommandsProcess = ({
   const handleUpdateSingleUseChannels = useCallback((e) => {
   const handleUpdateSingleUseChannels = useCallback((e) => {
     const { target } = e;
     const { target } = e;
     const { name: commandName, value } = target;
     const { name: commandName, value } = target;
-    setPermissionsForSingleUseCommandsState(prev => getUpdatedChannelsList(prev, commandName, value));
+    setPermissionsForSingleUseCommandsState((prev) =>
+      getUpdatedChannelsList(prev, commandName, value),
+    );
   }, []);
   }, []);
 
 
   const handleUpdateBroadcastUseChannels = useCallback((e) => {
   const handleUpdateBroadcastUseChannels = useCallback((e) => {
     const { target } = e;
     const { target } = e;
     const { name: commandName, value } = target;
     const { name: commandName, value } = target;
-    setPermissionsForBroadcastUseCommandsState(prev => getUpdatedChannelsList(prev, commandName, value));
+    setPermissionsForBroadcastUseCommandsState((prev) =>
+      getUpdatedChannelsList(prev, commandName, value),
+    );
   }, []);
   }, []);
 
 
   const handleUpdateEventsChannels = useCallback((e) => {
   const handleUpdateEventsChannels = useCallback((e) => {
     const { target } = e;
     const { target } = e;
     const { name: commandName, value } = target;
     const { name: commandName, value } = target;
-    setPermissionsForEventsState(prev => getUpdatedChannelsList(prev, commandName, value));
+    setPermissionsForEventsState((prev) =>
+      getUpdatedChannelsList(prev, commandName, value),
+    );
   }, []);
   }, []);
 
 
-
-  const updateSettingsHandler = async(e) => {
+  const updateSettingsHandler = async (e) => {
     try {
     try {
       // TODO: add new attribute 78975
       // TODO: add new attribute 78975
-      await apiv3Put(`/slack-integration-settings/slack-app-integrations/${slackAppIntegrationId}/permissions`, {
-        permissionsForBroadcastUseCommands: permissionsForBroadcastUseCommandsState,
-        permissionsForSingleUseCommands: permissionsForSingleUseCommandsState,
-        permissionsForSlackEventActions: permissionsForEventsState,
-      });
-      toastSuccess(t('toaster.update_successed', { target: 'Token', ns: 'commons' }));
-    }
-    catch (err) {
+      await apiv3Put(
+        `/slack-integration-settings/slack-app-integrations/${slackAppIntegrationId}/permissions`,
+        {
+          permissionsForBroadcastUseCommands:
+            permissionsForBroadcastUseCommandsState,
+          permissionsForSingleUseCommands: permissionsForSingleUseCommandsState,
+          permissionsForSlackEventActions: permissionsForEventsState,
+        },
+      );
+      toastSuccess(
+        t('toaster.update_successed', { target: 'Token', ns: 'commons' }),
+      );
+    } catch (err) {
       toastError(err);
       toastError(err);
       logger.error(err);
       logger.error(err);
     }
     }
   };
   };
 
 
-  const PermissionSettingsForEachCategoryComponent = ({
-    currentPermissionTypes,
-    usageType,
-    menuItem,
-  }) => {
-    const permissionMap = {
-      broadcastUse: permissionsForBroadcastUseCommandsState,
-      singleUse: permissionsForSingleUseCommandsState,
-      linkSharing: permissionsForEventsState,
-    };
-
-    const {
-      title,
-      description,
-      defaultCommandsName,
-      singleCommandDescription,
-      updatePermissionsHandler,
-      updateChannelsHandler,
-      allowedChannelsDescription,
-    } = menuItem;
-
-    return (
-      <>
-        {(title || description) && (
-          <div className="row">
-            <div className="col-md-7 offset-md-2">
-              { title && <p className="fw-bold mb-1">{title}</p> }
-              { description && <p className="text-muted">{description}</p> }
-            </div>
-          </div>
-        )}
-
-        <div className="form-check">
-          <div className="row mb-5 d-block">
-            {defaultCommandsName.map(keyName => (
-              <PermissionSettingForEachPermissionTypeComponent
-                key={`${keyName}-component`}
-                keyName={keyName}
-                usageType={usageType}
-                permissionSettings={permissionMap[usageType]}
-                currentPermissionType={currentPermissionTypes[keyName]}
-                singleCommandDescription={singleCommandDescription}
-                onUpdatePermissions={updatePermissionsHandler}
-                onUpdateChannels={updateChannelsHandler}
-                allowedChannelsDescription={allowedChannelsDescription}
-              />
-            ))}
-          </div>
-        </div>
-      </>
-    );
-  };
-
-
-  PermissionSettingsForEachCategoryComponent.propTypes = {
-    currentPermissionTypes: PropTypes.object,
-    usageType: PropTypes.string,
-    menuItem: PropTypes.object,
+  const permissionMap = {
+    broadcastUse: permissionsForBroadcastUseCommandsState,
+    singleUse: permissionsForSingleUseCommandsState,
+    linkSharing: permissionsForEventsState,
   };
   };
 
 
   // Using i18n in allowedChannelsDescription will cause interpolation error
   // Using i18n in allowedChannelsDescription will cause interpolation error
   const menuMap = {
   const menuMap = {
     broadcastUse: {
     broadcastUse: {
       title: 'Multiple GROWI',
       title: 'Multiple GROWI',
-      description: t('admin:slack_integration.accordion.multiple_growi_command'),
+      description: t(
+        'admin:slack_integration.accordion.multiple_growi_command',
+      ),
       defaultCommandsName: defaultSupportedCommandsNameForBroadcastUse,
       defaultCommandsName: defaultSupportedCommandsNameForBroadcastUse,
       updatePermissionsHandler: handleUpdateBroadcastUsePermissions,
       updatePermissionsHandler: handleUpdateBroadcastUsePermissions,
       updateChannelsHandler: handleUpdateBroadcastUseChannels,
       updateChannelsHandler: handleUpdateBroadcastUseChannels,
-      allowedChannelsDescription: 'admin:slack_integration.accordion.allowed_channels_description',
+      allowedChannelsDescription:
+        'admin:slack_integration.accordion.allowed_channels_description',
     },
     },
     singleUse: {
     singleUse: {
       title: 'Single GROWI',
       title: 'Single GROWI',
@@ -349,28 +387,35 @@ const ManageCommandsProcess = ({
       defaultCommandsName: defaultSupportedCommandsNameForSingleUse,
       defaultCommandsName: defaultSupportedCommandsNameForSingleUse,
       updatePermissionsHandler: handleUpdateSingleUsePermissions,
       updatePermissionsHandler: handleUpdateSingleUsePermissions,
       updateChannelsHandler: handleUpdateSingleUseChannels,
       updateChannelsHandler: handleUpdateSingleUseChannels,
-      allowedChannelsDescription: 'admin:slack_integration.accordion.allowed_channels_description',
+      allowedChannelsDescription:
+        'admin:slack_integration.accordion.allowed_channels_description',
     },
     },
     linkSharing: {
     linkSharing: {
       defaultCommandsName: defaultSupportedSlackEventActions,
       defaultCommandsName: defaultSupportedSlackEventActions,
       updatePermissionsHandler: handleUpdateEventsPermissions,
       updatePermissionsHandler: handleUpdateEventsPermissions,
       updateChannelsHandler: handleUpdateEventsChannels,
       updateChannelsHandler: handleUpdateEventsChannels,
-      singleCommandDescription: t('admin:slack_integration.accordion.unfurl_description'),
-      allowedChannelsDescription: 'admin:slack_integration.accordion.unfurl_allowed_channels_description',
+      singleCommandDescription: t(
+        'admin:slack_integration.accordion.unfurl_description',
+      ),
+      allowedChannelsDescription:
+        'admin:slack_integration.accordion.unfurl_allowed_channels_description',
     },
     },
   };
   };
 
 
   return (
   return (
     <div className="py-4 px-5">
     <div className="py-4 px-5">
-      <p className="mb-4 fw-bold">{t('admin:slack_integration.accordion.growi_commands')}</p>
+      <p className="mb-4 fw-bold">
+        {t('admin:slack_integration.accordion.growi_commands')}
+      </p>
       <div className="row d-flex flex-column align-items-center">
       <div className="row d-flex flex-column align-items-center">
         <div className="col-8">
         <div className="col-8">
-          {Object.values(CommandUsageTypes).map(commandUsageType => (
+          {Object.values(CommandUsageTypes).map((commandUsageType) => (
             <PermissionSettingsForEachCategoryComponent
             <PermissionSettingsForEachCategoryComponent
               key={commandUsageType}
               key={commandUsageType}
               currentPermissionTypes={currentPermissionTypes}
               currentPermissionTypes={currentPermissionTypes}
               usageType={commandUsageType}
               usageType={commandUsageType}
               menuItem={menuMap[commandUsageType]}
               menuItem={menuMap[commandUsageType]}
+              permissionSettings={permissionMap[commandUsageType]}
             />
             />
           ))}
           ))}
         </div>
         </div>
@@ -379,12 +424,13 @@ const ManageCommandsProcess = ({
       <p className="mb-4 fw-bold">Events</p>
       <p className="mb-4 fw-bold">Events</p>
       <div className="row d-flex flex-column align-items-center">
       <div className="row d-flex flex-column align-items-center">
         <div className="col-8">
         <div className="col-8">
-          {Object.values(EventTypes).map(EventType => (
+          {Object.values(EventTypes).map((EventType) => (
             <PermissionSettingsForEachCategoryComponent
             <PermissionSettingsForEachCategoryComponent
               key={EventType}
               key={EventType}
               currentPermissionTypes={currentPermissionTypes}
               currentPermissionTypes={currentPermissionTypes}
               usageType={EventType}
               usageType={EventType}
               menuItem={menuMap[EventType]}
               menuItem={menuMap[EventType]}
+              permissionSettings={permissionMap[EventType]}
             />
             />
           ))}
           ))}
         </div>
         </div>
@@ -396,7 +442,7 @@ const ManageCommandsProcess = ({
           className="btn btn-primary mx-auto"
           className="btn btn-primary mx-auto"
           onClick={updateSettingsHandler}
           onClick={updateSettingsHandler}
         >
         >
-          { t('Update') }
+          {t('Update')}
         </button>
         </button>
       </div>
       </div>
     </div>
     </div>

+ 79 - 39
apps/app/src/client/components/Admin/SlackIntegration/ManageCommandsProcessWithoutProxy.jsx

@@ -1,6 +1,9 @@
 import React, { useCallback, useEffect, useState } from 'react';
 import React, { useCallback, useEffect, useState } from 'react';
-
-import { defaultSupportedCommandsNameForBroadcastUse, defaultSupportedCommandsNameForSingleUse, defaultSupportedSlackEventActions } from '@growi/slack';
+import {
+  defaultSupportedCommandsNameForBroadcastUse,
+  defaultSupportedCommandsNameForSingleUse,
+  defaultSupportedSlackEventActions,
+} from '@growi/slack';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
 
 
@@ -8,7 +11,6 @@ import { apiv3Put } from '~/client/util/apiv3-client';
 import { toastError, toastSuccess } from '~/client/util/toastr';
 import { toastError, toastSuccess } from '~/client/util/toastr';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
-
 const logger = loggerFactory('growi:SlackIntegration:ManageCommandsProcess');
 const logger = loggerFactory('growi:SlackIntegration:ManageCommandsProcess');
 
 
 const PermissionTypes = {
 const PermissionTypes = {
@@ -17,22 +19,30 @@ const PermissionTypes = {
   ALLOW_SPECIFIED: 'allowSpecified',
   ALLOW_SPECIFIED: 'allowSpecified',
 };
 };
 
 
-const defaultCommandsName = [...defaultSupportedCommandsNameForBroadcastUse, ...defaultSupportedCommandsNameForSingleUse];
-
+const defaultCommandsName = [
+  ...defaultSupportedCommandsNameForBroadcastUse,
+  ...defaultSupportedCommandsNameForSingleUse,
+];
 
 
 // A utility function that returns the new state but identical to the previous state
 // A utility function that returns the new state but identical to the previous state
 const getUpdatedChannelsList = (commandPermissionObj, commandName, value) => {
 const getUpdatedChannelsList = (commandPermissionObj, commandName, value) => {
   // string to array
   // string to array
   const allowedChannelsArray = value.split(',');
   const allowedChannelsArray = value.split(',');
   // trim whitespace from all elements
   // trim whitespace from all elements
-  const trimedAllowedChannelsArray = allowedChannelsArray.map(channelName => channelName.trim());
+  const trimedAllowedChannelsArray = allowedChannelsArray.map((channelName) =>
+    channelName.trim(),
+  );
 
 
   commandPermissionObj[commandName] = trimedAllowedChannelsArray;
   commandPermissionObj[commandName] = trimedAllowedChannelsArray;
   return commandPermissionObj;
   return commandPermissionObj;
 };
 };
 
 
 // A utility function that returns the new state
 // A utility function that returns the new state
-const getUpdatedPermissionSettings = (commandPermissionObj, commandName, value) => {
+const getUpdatedPermissionSettings = (
+  commandPermissionObj,
+  commandName,
+  value,
+) => {
   const editedCommandPermissionObj = { ...commandPermissionObj };
   const editedCommandPermissionObj = { ...commandPermissionObj };
   switch (value) {
   switch (value) {
     case PermissionTypes.ALLOW_ALL:
     case PermissionTypes.ALLOW_ALL:
@@ -51,9 +61,11 @@ const getUpdatedPermissionSettings = (commandPermissionObj, commandName, value)
   return editedCommandPermissionObj;
   return editedCommandPermissionObj;
 };
 };
 
 
-
 const SinglePermissionSettingComponent = ({
 const SinglePermissionSettingComponent = ({
-  commandName, editingCommandPermission, onPermissionTypeClicked, onPermissionListChanged,
+  commandName,
+  editingCommandPermission,
+  onPermissionTypeClicked,
+  onPermissionListChanged,
 }) => {
 }) => {
   const { t } = useTranslation();
   const { t } = useTranslation();
 
 
@@ -77,13 +89,16 @@ const SinglePermissionSettingComponent = ({
 
 
   const permission = editingCommandPermission[commandName];
   const permission = editingCommandPermission[commandName];
   const hiddenClass = Array.isArray(permission) ? '' : 'd-none';
   const hiddenClass = Array.isArray(permission) ? '' : 'd-none';
-  const textareaDefaultValue = Array.isArray(permission) ? permission.join(',') : '';
-
+  const textareaDefaultValue = Array.isArray(permission)
+    ? permission.join(',')
+    : '';
 
 
   return (
   return (
     <div className="my-1 mb-2">
     <div className="my-1 mb-2">
       <div className="row align-items-center mb-3">
       <div className="row align-items-center mb-3">
-        <p className="col my-auto text-capitalize align-middle">{commandName}</p>
+        <p className="col my-auto text-capitalize align-middle">
+          {commandName}
+        </p>
         <div className="col dropdown">
         <div className="col dropdown">
           <button
           <button
             className="btn btn-outline-secondary dropdown-toggle text-end col-12 col-md-auto"
             className="btn btn-outline-secondary dropdown-toggle text-end col-12 col-md-auto"
@@ -94,9 +109,12 @@ const SinglePermissionSettingComponent = ({
             aria-expanded="true"
             aria-expanded="true"
           >
           >
             <span className="float-start">
             <span className="float-start">
-              {permission === true && t('admin:slack_integration.accordion.allow_all')}
-              {permission === false && t('admin:slack_integration.accordion.deny_all')}
-              {Array.isArray(permission) && t('admin:slack_integration.accordion.allow_specified')}
+              {permission === true &&
+                t('admin:slack_integration.accordion.allow_all')}
+              {permission === false &&
+                t('admin:slack_integration.accordion.deny_all')}
+              {Array.isArray(permission) &&
+                t('admin:slack_integration.accordion.allow_specified')}
             </span>
             </span>
           </button>
           </button>
           <div className="dropdown-menu">
           <div className="dropdown-menu">
@@ -105,7 +123,7 @@ const SinglePermissionSettingComponent = ({
               type="button"
               type="button"
               name={commandName}
               name={commandName}
               value={PermissionTypes.ALLOW_ALL}
               value={PermissionTypes.ALLOW_ALL}
-              onClick={e => permissionTypeClickHandler(e)}
+              onClick={(e) => permissionTypeClickHandler(e)}
             >
             >
               {t('admin:slack_integration.accordion.allow_all_long')}
               {t('admin:slack_integration.accordion.allow_all_long')}
             </button>
             </button>
@@ -114,7 +132,7 @@ const SinglePermissionSettingComponent = ({
               type="button"
               type="button"
               name={commandName}
               name={commandName}
               value={PermissionTypes.DENY_ALL}
               value={PermissionTypes.DENY_ALL}
-              onClick={e => permissionTypeClickHandler(e)}
+              onClick={(e) => permissionTypeClickHandler(e)}
             >
             >
               {t('admin:slack_integration.accordion.deny_all_long')}
               {t('admin:slack_integration.accordion.deny_all_long')}
             </button>
             </button>
@@ -123,7 +141,7 @@ const SinglePermissionSettingComponent = ({
               type="button"
               type="button"
               name={commandName}
               name={commandName}
               value={PermissionTypes.ALLOW_SPECIFIED}
               value={PermissionTypes.ALLOW_SPECIFIED}
-              onClick={e => permissionTypeClickHandler(e)}
+              onClick={(e) => permissionTypeClickHandler(e)}
             >
             >
               {t('admin:slack_integration.accordion.allow_specified_long')}
               {t('admin:slack_integration.accordion.allow_specified_long')}
             </button>
             </button>
@@ -136,10 +154,12 @@ const SinglePermissionSettingComponent = ({
           type="textarea"
           type="textarea"
           name={commandName}
           name={commandName}
           value={textareaDefaultValue}
           value={textareaDefaultValue}
-          onChange={e => onPermissionListChangeHandler(e)}
+          onChange={(e) => onPermissionListChangeHandler(e)}
         />
         />
         <p className="form-text text-muted small">
         <p className="form-text text-muted small">
-          {t('admin:slack_integration.accordion.allowed_channels_description', { commandName })}
+          {t('admin:slack_integration.accordion.allowed_channels_description', {
+            commandName,
+          })}
           <br />
           <br />
         </p>
         </p>
       </div>
       </div>
@@ -154,12 +174,15 @@ SinglePermissionSettingComponent.propTypes = {
   onPermissionListChanged: PropTypes.func,
   onPermissionListChanged: PropTypes.func,
 };
 };
 
 
-
 // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
 // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
-const ManageCommandsProcessWithoutProxy = ({ commandPermission, eventActionsPermission }) => {
+const ManageCommandsProcessWithoutProxy = ({
+  commandPermission,
+  eventActionsPermission,
+}) => {
   const { t } = useTranslation();
   const { t } = useTranslation();
   const [editingCommandPermission, setEditingCommandPermission] = useState({});
   const [editingCommandPermission, setEditingCommandPermission] = useState({});
-  const [editingEventActionsPermission, setEditingEventActionsPermission] = useState({});
+  const [editingEventActionsPermission, setEditingEventActionsPermission] =
+    useState({});
 
 
   useEffect(() => {
   useEffect(() => {
     if (commandPermission == null) {
     if (commandPermission == null) {
@@ -180,36 +203,51 @@ const ManageCommandsProcessWithoutProxy = ({ commandPermission, eventActionsPerm
   const updatePermissionsCommandsState = useCallback((e) => {
   const updatePermissionsCommandsState = useCallback((e) => {
     const { target } = e;
     const { target } = e;
     const { name: commandName, value } = target;
     const { name: commandName, value } = target;
-    setEditingCommandPermission(commandPermissionObj => getUpdatedPermissionSettings(commandPermissionObj, commandName, value));
+    setEditingCommandPermission((commandPermissionObj) =>
+      getUpdatedPermissionSettings(commandPermissionObj, commandName, value),
+    );
   }, []);
   }, []);
 
 
   const updatePermissionsEventsState = useCallback((e) => {
   const updatePermissionsEventsState = useCallback((e) => {
     const { target } = e;
     const { target } = e;
     const { name: actionName, value } = target;
     const { name: actionName, value } = target;
-    setEditingEventActionsPermission(eventActionPermissionObj => getUpdatedPermissionSettings(eventActionPermissionObj, actionName, value));
+    setEditingEventActionsPermission((eventActionPermissionObj) =>
+      getUpdatedPermissionSettings(eventActionPermissionObj, actionName, value),
+    );
   }, []);
   }, []);
 
 
   const updateCommandsChannelsListState = useCallback((e) => {
   const updateCommandsChannelsListState = useCallback((e) => {
     const { target } = e;
     const { target } = e;
     const { name: commandName, value } = target;
     const { name: commandName, value } = target;
-    setEditingCommandPermission(commandPermissionObj => ({ ...getUpdatedChannelsList(commandPermissionObj, commandName, value) }));
+    setEditingCommandPermission((commandPermissionObj) => ({
+      ...getUpdatedChannelsList(commandPermissionObj, commandName, value),
+    }));
   }, []);
   }, []);
 
 
   const updateEventsChannelsListState = useCallback((e) => {
   const updateEventsChannelsListState = useCallback((e) => {
     const { target } = e;
     const { target } = e;
     const { name: actionName, value } = target;
     const { name: actionName, value } = target;
-    setEditingEventActionsPermission(eventActionPermissionObj => ({ ...getUpdatedChannelsList(eventActionPermissionObj, actionName, value) }));
+    setEditingEventActionsPermission((eventActionPermissionObj) => ({
+      ...getUpdatedChannelsList(eventActionPermissionObj, actionName, value),
+    }));
   }, []);
   }, []);
 
 
-  const updateCommandsHandler = async(e) => {
+  const updateCommandsHandler = async (e) => {
     try {
     try {
-      await apiv3Put('/slack-integration-settings/without-proxy/update-permissions', {
-        commandPermission: editingCommandPermission,
-        eventActionsPermission: editingEventActionsPermission,
-      });
-      toastSuccess(t('toaster.update_successed', { target: 'the permission for commands', ns: 'commons' }));
-    }
-    catch (err) {
+      await apiv3Put(
+        '/slack-integration-settings/without-proxy/update-permissions',
+        {
+          commandPermission: editingCommandPermission,
+          eventActionsPermission: editingEventActionsPermission,
+        },
+      );
+      toastSuccess(
+        t('toaster.update_successed', {
+          target: 'the permission for commands',
+          ns: 'commons',
+        }),
+      );
+    } catch (err) {
       toastError(err);
       toastError(err);
       logger.error(err);
       logger.error(err);
     }
     }
@@ -217,12 +255,14 @@ const ManageCommandsProcessWithoutProxy = ({ commandPermission, eventActionsPerm
 
 
   return (
   return (
     <div className="py-4 px-5">
     <div className="py-4 px-5">
-      <p className="mb-4 fw-bold">{t('admin:slack_integration.accordion.growi_commands')}</p>
+      <p className="mb-4 fw-bold">
+        {t('admin:slack_integration.accordion.growi_commands')}
+      </p>
       <div className="row d-flex flex-column align-items-center">
       <div className="row d-flex flex-column align-items-center">
         <div className="col-8">
         <div className="col-8">
           <div className="form-check">
           <div className="form-check">
             <div className="row mb-5 d-block">
             <div className="row mb-5 d-block">
-              { defaultCommandsName.map((commandName) => {
+              {defaultCommandsName.map((commandName) => {
                 // eslint-disable-next-line max-len
                 // eslint-disable-next-line max-len
                 return (
                 return (
                   <SinglePermissionSettingComponent
                   <SinglePermissionSettingComponent
@@ -243,7 +283,7 @@ const ManageCommandsProcessWithoutProxy = ({ commandPermission, eventActionsPerm
         <div className="col-8">
         <div className="col-8">
           <div className="form-check">
           <div className="form-check">
             <div className="row mb-5 d-block">
             <div className="row mb-5 d-block">
-              { defaultSupportedSlackEventActions.map(actionName => (
+              {defaultSupportedSlackEventActions.map((actionName) => (
                 <SinglePermissionSettingComponent
                 <SinglePermissionSettingComponent
                   key={`${actionName}-component`}
                   key={`${actionName}-component`}
                   commandName={actionName}
                   commandName={actionName}
@@ -262,7 +302,7 @@ const ManageCommandsProcessWithoutProxy = ({ commandPermission, eventActionsPerm
           className="btn btn-primary mx-auto"
           className="btn btn-primary mx-auto"
           onClick={updateCommandsHandler}
           onClick={updateCommandsHandler}
         >
         >
-          { t('Update') }
+          {t('Update')}
         </button>
         </button>
       </div>
       </div>
     </div>
     </div>

+ 12 - 4
apps/app/src/client/components/Admin/SlackIntegration/MessageBasedOnConnection.jsx

@@ -1,22 +1,30 @@
 import React from 'react';
 import React from 'react';
-
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
 
 
-
 const MessageBasedOnConnection = (props) => {
 const MessageBasedOnConnection = (props) => {
   const { isLatestConnectionSuccess, logsValue } = props;
   const { isLatestConnectionSuccess, logsValue } = props;
   const { t } = useTranslation();
   const { t } = useTranslation();
 
 
   if (isLatestConnectionSuccess) {
   if (isLatestConnectionSuccess) {
-    return <p className="text-info text-center my-4">{t('admin:slack_integration.accordion.send_message_to_slack_work_space')}</p>;
+    return (
+      <p className="text-info text-center my-4">
+        {t(
+          'admin:slack_integration.accordion.send_message_to_slack_work_space',
+        )}
+      </p>
+    );
   }
   }
 
 
   if (logsValue === '') {
   if (logsValue === '') {
     return <p></p>;
     return <p></p>;
   }
   }
 
 
-  return <p className="text-danger text-center my-4">{t('admin:slack_integration.accordion.error_check_logs_below')}</p>;
+  return (
+    <p className="text-danger text-center my-4">
+      {t('admin:slack_integration.accordion.error_check_logs_below')}
+    </p>
+  );
 };
 };
 
 
 MessageBasedOnConnection.propTypes = {
 MessageBasedOnConnection.propTypes = {

+ 70 - 46
apps/app/src/client/components/Admin/SlackIntegration/OfficialBotSettings.jsx

@@ -1,16 +1,13 @@
-import React, { useState, useEffect, useCallback } from 'react';
-
+import React, { useCallback, useEffect, useState } from 'react';
 import { SlackbotType } from '@growi/slack';
 import { SlackbotType } from '@growi/slack';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
 
 
-
 import { apiv3Delete, apiv3Put } from '~/client/util/apiv3-client';
 import { apiv3Delete, apiv3Put } from '~/client/util/apiv3-client';
-import { toastSuccess, toastError } from '~/client/util/toastr';
+import { toastError, toastSuccess } from '~/client/util/toastr';
 import { useAppTitle } from '~/states/global';
 import { useAppTitle } from '~/states/global';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
-
 import { CustomBotWithProxyConnectionStatus } from './CustomBotWithProxyConnectionStatus';
 import { CustomBotWithProxyConnectionStatus } from './CustomBotWithProxyConnectionStatus';
 import { DeleteSlackBotSettingsModal } from './DeleteSlackBotSettingsModal';
 import { DeleteSlackBotSettingsModal } from './DeleteSlackBotSettingsModal';
 import { SlackAppIntegrationControl } from './SlackAppIntegrationControl';
 import { SlackAppIntegrationControl } from './SlackAppIntegrationControl';
@@ -21,66 +18,79 @@ const logger = loggerFactory('growi:cli:SlackIntegration:OfficialBotSettings');
 const OfficialBotSettings = (props) => {
 const OfficialBotSettings = (props) => {
   const {
   const {
     slackAppIntegrations,
     slackAppIntegrations,
-    onClickAddSlackWorkspaceBtn, onPrimaryUpdated,
-    connectionStatuses, onUpdateTokens, onSubmitForm,
+    onClickAddSlackWorkspaceBtn,
+    onPrimaryUpdated,
+    connectionStatuses,
+    onUpdateTokens,
+    onSubmitForm,
   } = props;
   } = props;
   const [siteName, setSiteName] = useState('');
   const [siteName, setSiteName] = useState('');
   const [integrationIdToDelete, setIntegrationIdToDelete] = useState(null);
   const [integrationIdToDelete, setIntegrationIdToDelete] = useState(null);
   const { t } = useTranslation();
   const { t } = useTranslation();
   const appTitle = useAppTitle();
   const appTitle = useAppTitle();
 
 
-  const addSlackAppIntegrationHandler = async() => {
+  const addSlackAppIntegrationHandler = async () => {
     if (onClickAddSlackWorkspaceBtn != null) {
     if (onClickAddSlackWorkspaceBtn != null) {
       onClickAddSlackWorkspaceBtn();
       onClickAddSlackWorkspaceBtn();
     }
     }
   };
   };
 
 
-  const isPrimaryChangedHandler = useCallback(async(slackIntegrationToChange, newValue) => {
-    // do nothing when turning off
-    if (!newValue) {
-      return;
-    }
+  const isPrimaryChangedHandler = useCallback(
+    async (slackIntegrationToChange, newValue) => {
+      // do nothing when turning off
+      if (!newValue) {
+        return;
+      }
 
 
-    try {
-      await apiv3Put(`/slack-integration-settings/slack-app-integrations/${slackIntegrationToChange._id}/make-primary`);
-      if (onPrimaryUpdated != null) {
-        onPrimaryUpdated();
+      try {
+        await apiv3Put(
+          `/slack-integration-settings/slack-app-integrations/${slackIntegrationToChange._id}/make-primary`,
+        );
+        if (onPrimaryUpdated != null) {
+          onPrimaryUpdated();
+        }
+        toastSuccess(
+          t('toaster.update_successed', { target: 'Primary', ns: 'commons' }),
+        );
+      } catch (err) {
+        toastError(err);
+        logger.error('Failed to change isPrimary', err);
       }
       }
-      toastSuccess(t('toaster.update_successed', { target: 'Primary', ns: 'commons' }));
-    }
-    catch (err) {
-      toastError(err);
-      logger.error('Failed to change isPrimary', err);
-    }
-  }, [t, onPrimaryUpdated]);
+    },
+    [t, onPrimaryUpdated],
+  );
 
 
-  const deleteSlackAppIntegrationHandler = async() => {
-    await apiv3Delete(`/slack-integration-settings/slack-app-integrations/${integrationIdToDelete}`);
+  const deleteSlackAppIntegrationHandler = async () => {
+    await apiv3Delete(
+      `/slack-integration-settings/slack-app-integrations/${integrationIdToDelete}`,
+    );
     try {
     try {
       if (props.onDeleteSlackAppIntegration != null) {
       if (props.onDeleteSlackAppIntegration != null) {
         props.onDeleteSlackAppIntegration();
         props.onDeleteSlackAppIntegration();
       }
       }
-      toastSuccess(t('admin:slack_integration.toastr.delete_slack_integration_procedure'));
-    }
-    catch (err) {
+      toastSuccess(
+        t('admin:slack_integration.toastr.delete_slack_integration_procedure'),
+      );
+    } catch (err) {
       toastError('Failed to delete');
       toastError('Failed to delete');
       logger.error('Failed to delete', err);
       logger.error('Failed to delete', err);
     }
     }
   };
   };
 
 
-
   useEffect(() => {
   useEffect(() => {
     setSiteName(appTitle);
     setSiteName(appTitle);
   }, [appTitle]);
   }, [appTitle]);
 
 
   return (
   return (
     <>
     <>
-      <h2 className="admin-setting-header">{t('admin:slack_integration.official_bot_integration')}
-        <a href={t('admin:slack_integration.docs_url.official_bot')} target="_blank" rel="noopener noreferrer">
-          <span
-            className="growi-custom-icons btn-link ms-2"
-            onClick={() => window.open(`${t('admin:slack_integration.docs_url.official_bot')}`, '_blank')}
-          >
+      <h2 className="admin-setting-header">
+        {t('admin:slack_integration.official_bot_integration')}
+        <a
+          href={t('admin:slack_integration.docs_url.official_bot')}
+          target="_blank"
+          rel="noopener noreferrer"
+        >
+          <span className="growi-custom-icons btn-link ms-2">
             external_link
             external_link
           </span>
           </span>
         </a>
         </a>
@@ -93,27 +103,38 @@ const OfficialBotSettings = (props) => {
             connectionStatuses={connectionStatuses}
             connectionStatuses={connectionStatuses}
           />
           />
 
 
-          <h2 className="admin-setting-header">{t('admin:slack_integration.integration_procedure')}</h2>
+          <h2 className="admin-setting-header">
+            {t('admin:slack_integration.integration_procedure')}
+          </h2>
         </>
         </>
       )}
       )}
 
 
       <div className="mx-3">
       <div className="mx-3">
         {slackAppIntegrations.map((slackAppIntegration, i) => {
         {slackAppIntegrations.map((slackAppIntegration, i) => {
           const {
           const {
-            tokenGtoP, tokenPtoG, _id, permissionsForBroadcastUseCommands, permissionsForSingleUseCommands, permissionsForSlackEventActions,
+            tokenGtoP,
+            tokenPtoG,
+            _id,
+            permissionsForBroadcastUseCommands,
+            permissionsForSingleUseCommands,
+            permissionsForSlackEventActions,
           } = slackAppIntegration;
           } = slackAppIntegration;
           const workspaceName = connectionStatuses[_id]?.workspaceName;
           const workspaceName = connectionStatuses[_id]?.workspaceName;
           return (
           return (
             <React.Fragment key={slackAppIntegration._id}>
             <React.Fragment key={slackAppIntegration._id}>
               <div className="my-3 d-flex align-items-center justify-content-between">
               <div className="my-3 d-flex align-items-center justify-content-between">
                 <h2 id={_id || `settings-accordions-${i}`}>
                 <h2 id={_id || `settings-accordions-${i}`}>
-                  {(workspaceName != null) ? `${workspaceName} Work Space` : `Settings #${i}`}
+                  {workspaceName != null
+                    ? `${workspaceName} Work Space`
+                    : `Settings #${i}`}
                 </h2>
                 </h2>
                 <SlackAppIntegrationControl
                 <SlackAppIntegrationControl
                   slackAppIntegration={slackAppIntegration}
                   slackAppIntegration={slackAppIntegration}
                   onIsPrimaryChanged={isPrimaryChangedHandler}
                   onIsPrimaryChanged={isPrimaryChangedHandler}
                   // set state to open DeleteSlackBotSettingsModal
                   // set state to open DeleteSlackBotSettingsModal
-                  onDeleteButtonClicked={saiToDelete => setIntegrationIdToDelete(saiToDelete._id)}
+                  onDeleteButtonClicked={(saiToDelete) =>
+                    setIntegrationIdToDelete(saiToDelete._id)
+                  }
                 />
                 />
               </div>
               </div>
               <WithProxyAccordions
               <WithProxyAccordions
@@ -121,9 +142,15 @@ const OfficialBotSettings = (props) => {
                 slackAppIntegrationId={slackAppIntegration._id}
                 slackAppIntegrationId={slackAppIntegration._id}
                 tokenGtoP={tokenGtoP}
                 tokenGtoP={tokenGtoP}
                 tokenPtoG={tokenPtoG}
                 tokenPtoG={tokenPtoG}
-                permissionsForBroadcastUseCommands={permissionsForBroadcastUseCommands}
-                permissionsForSingleUseCommands={permissionsForSingleUseCommands}
-                permissionsForSlackEventActions={permissionsForSlackEventActions}
+                permissionsForBroadcastUseCommands={
+                  permissionsForBroadcastUseCommands
+                }
+                permissionsForSingleUseCommands={
+                  permissionsForSingleUseCommands
+                }
+                permissionsForSlackEventActions={
+                  permissionsForSlackEventActions
+                }
                 onUpdateTokens={onUpdateTokens}
                 onUpdateTokens={onUpdateTokens}
                 onSubmitForm={onSubmitForm}
                 onSubmitForm={onSubmitForm}
               />
               />
@@ -147,17 +174,14 @@ const OfficialBotSettings = (props) => {
         onClickDeleteButton={deleteSlackAppIntegrationHandler}
         onClickDeleteButton={deleteSlackAppIntegrationHandler}
       />
       />
     </>
     </>
-
   );
   );
 };
 };
 
 
-
 OfficialBotSettings.defaultProps = {
 OfficialBotSettings.defaultProps = {
   slackAppIntegrations: [],
   slackAppIntegrations: [],
 };
 };
 
 
 OfficialBotSettings.propTypes = {
 OfficialBotSettings.propTypes = {
-
   slackAppIntegrations: PropTypes.array,
   slackAppIntegrations: PropTypes.array,
   onClickAddSlackWorkspaceBtn: PropTypes.func,
   onClickAddSlackWorkspaceBtn: PropTypes.func,
   onPrimaryUpdated: PropTypes.func,
   onPrimaryUpdated: PropTypes.func,

+ 11 - 10
apps/app/src/client/components/Admin/SlackIntegration/SlackAppIntegrationControl.tsx

@@ -1,22 +1,23 @@
-
 import type { JSX } from 'react';
 import type { JSX } from 'react';
-
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 
 
-
 type Props = {
 type Props = {
   slackAppIntegration: {
   slackAppIntegration: {
-    _id: string,
-    isPrimary?: boolean,
-  },
-  onIsPrimaryChanged?: (slackAppIntegration: unknown, newValue: boolean) => void,
-  onDeleteButtonClicked?: (slackAppIntegration: unknown) => void,
-}
+    _id: string;
+    isPrimary?: boolean;
+  };
+  onIsPrimaryChanged?: (
+    slackAppIntegration: unknown,
+    newValue: boolean,
+  ) => void;
+  onDeleteButtonClicked?: (slackAppIntegration: unknown) => void;
+};
 
 
 export const SlackAppIntegrationControl = (props: Props): JSX.Element => {
 export const SlackAppIntegrationControl = (props: Props): JSX.Element => {
   const { t } = useTranslation();
   const { t } = useTranslation();
 
 
-  const { slackAppIntegration, onIsPrimaryChanged, onDeleteButtonClicked } = props;
+  const { slackAppIntegration, onIsPrimaryChanged, onDeleteButtonClicked } =
+    props;
   const inputId = `cb-primary-${slackAppIntegration._id}`;
   const inputId = `cb-primary-${slackAppIntegration._id}`;
   const isPrimary = slackAppIntegration.isPrimary === true;
   const isPrimary = slackAppIntegration.isPrimary === true;
 
 

+ 49 - 37
apps/app/src/client/components/Admin/SlackIntegration/SlackIntegration.tsx

@@ -1,39 +1,41 @@
-import React, {
-  useState, useEffect, useCallback, type JSX,
-} from 'react';
-
+import React, { type JSX, useCallback, useEffect, useState } from 'react';
 import { SlackbotType } from '@growi/slack';
 import { SlackbotType } from '@growi/slack';
 import { LoadingSpinner } from '@growi/ui/dist/components';
 import { LoadingSpinner } from '@growi/ui/dist/components';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 
 
-
 import {
 import {
-  apiv3Delete, apiv3Get, apiv3Post, apiv3Put,
+  apiv3Delete,
+  apiv3Get,
+  apiv3Post,
+  apiv3Put,
 } from '~/client/util/apiv3-client';
 } from '~/client/util/apiv3-client';
-import { toastSuccess, toastError } from '~/client/util/toastr';
+import { toastError, toastSuccess } from '~/client/util/toastr';
 
 
 import { BotTypeCard } from './BotTypeCard';
 import { BotTypeCard } from './BotTypeCard';
 import ConfirmBotChangeModal from './ConfirmBotChangeModal';
 import ConfirmBotChangeModal from './ConfirmBotChangeModal';
-import CustomBotWithProxySettings from './CustomBotWithProxySettings';
 import CustomBotWithoutProxySettings from './CustomBotWithoutProxySettings';
 import CustomBotWithoutProxySettings from './CustomBotWithoutProxySettings';
+import CustomBotWithProxySettings from './CustomBotWithProxySettings';
 import { DeleteSlackBotSettingsModal } from './DeleteSlackBotSettingsModal';
 import { DeleteSlackBotSettingsModal } from './DeleteSlackBotSettingsModal';
 import OfficialBotSettings from './OfficialBotSettings';
 import OfficialBotSettings from './OfficialBotSettings';
 
 
-
 const botTypes = Object.values(SlackbotType);
 const botTypes = Object.values(SlackbotType);
 
 
 export const SlackIntegration = (): JSX.Element => {
 export const SlackIntegration = (): JSX.Element => {
-
   const { t } = useTranslation();
   const { t } = useTranslation();
-  const [currentBotType, setCurrentBotType] = useState<SlackbotType | undefined>();
-  const [selectedBotType, setSelectedBotType] = useState<SlackbotType | undefined>();
+  const [currentBotType, setCurrentBotType] = useState<
+    SlackbotType | undefined
+  >();
+  const [selectedBotType, setSelectedBotType] = useState<
+    SlackbotType | undefined
+  >();
   const [slackSigningSecret, setSlackSigningSecret] = useState(null);
   const [slackSigningSecret, setSlackSigningSecret] = useState(null);
   const [slackBotToken, setSlackBotToken] = useState(null);
   const [slackBotToken, setSlackBotToken] = useState(null);
   const [slackSigningSecretEnv, setSlackSigningSecretEnv] = useState('');
   const [slackSigningSecretEnv, setSlackSigningSecretEnv] = useState('');
   const [slackBotTokenEnv, setSlackBotTokenEnv] = useState('');
   const [slackBotTokenEnv, setSlackBotTokenEnv] = useState('');
   const [commandPermission, setCommandPermission] = useState(null);
   const [commandPermission, setCommandPermission] = useState(null);
   const [eventActionsPermission, setEventActionsPermission] = useState(null);
   const [eventActionsPermission, setEventActionsPermission] = useState(null);
-  const [isDeleteConfirmModalShown, setIsDeleteConfirmModalShown] = useState(false);
+  const [isDeleteConfirmModalShown, setIsDeleteConfirmModalShown] =
+    useState(false);
   const [slackAppIntegrations, setSlackAppIntegrations] = useState();
   const [slackAppIntegrations, setSlackAppIntegrations] = useState();
   const [proxyServerUri, setProxyServerUri] = useState();
   const [proxyServerUri, setProxyServerUri] = useState();
   const [connectionStatuses, setConnectionStatuses] = useState({});
   const [connectionStatuses, setConnectionStatuses] = useState({});
@@ -41,8 +43,7 @@ export const SlackIntegration = (): JSX.Element => {
   const [errorCode, setErrorCode] = useState(null);
   const [errorCode, setErrorCode] = useState(null);
   const [isLoading, setIsLoading] = useState(true);
   const [isLoading, setIsLoading] = useState(true);
 
 
-
-  const fetchSlackIntegrationData = useCallback(async() => {
+  const fetchSlackIntegrationData = useCallback(async () => {
     try {
     try {
       const { data } = await apiv3Get('/slack-integration-settings');
       const { data } = await apiv3Get('/slack-integration-settings');
       const {
       const {
@@ -68,33 +69,33 @@ export const SlackIntegration = (): JSX.Element => {
       setProxyServerUri(proxyServerUri);
       setProxyServerUri(proxyServerUri);
       setCommandPermission(commandPermission);
       setCommandPermission(commandPermission);
       setEventActionsPermission(eventActionsPermission);
       setEventActionsPermission(eventActionsPermission);
-    }
-    catch (err) {
+    } catch (err) {
       toastError(err);
       toastError(err);
-    }
-    finally {
+    } finally {
       setIsLoading(false);
       setIsLoading(false);
     }
     }
   }, []);
   }, []);
 
 
-  const resetAllSettings = async() => {
+  const resetAllSettings = async () => {
     try {
     try {
       await apiv3Delete('/slack-integration-settings/bot-type');
       await apiv3Delete('/slack-integration-settings/bot-type');
       fetchSlackIntegrationData();
       fetchSlackIntegrationData();
       toastSuccess(t('admin:slack_integration.bot_all_reset_successful'));
       toastSuccess(t('admin:slack_integration.bot_all_reset_successful'));
-    }
-    catch (error) {
+    } catch (error) {
       toastError(error);
       toastError(error);
     }
     }
   };
   };
 
 
-  const createSlackIntegrationData = async() => {
+  const createSlackIntegrationData = async () => {
     try {
     try {
       await apiv3Post('/slack-integration-settings/slack-app-integrations');
       await apiv3Post('/slack-integration-settings/slack-app-integrations');
       fetchSlackIntegrationData();
       fetchSlackIntegrationData();
-      toastSuccess(t('admin:slack_integration.adding_slack_ws_integration_settings_successful'));
-    }
-    catch (error) {
+      toastSuccess(
+        t(
+          'admin:slack_integration.adding_slack_ws_integration_settings_successful',
+        ),
+      );
+    } catch (error) {
       toastError(error);
       toastError(error);
     }
     }
   };
   };
@@ -108,20 +109,19 @@ export const SlackIntegration = (): JSX.Element => {
     fetchSlackIntegrationData();
     fetchSlackIntegrationData();
   }, [fetchSlackIntegrationData]);
   }, [fetchSlackIntegrationData]);
 
 
-  const changeCurrentBotSettings = async(botType?: SlackbotType) => {
+  const changeCurrentBotSettings = async (botType?: SlackbotType) => {
     try {
     try {
       await apiv3Put('/slack-integration-settings/bot-type', {
       await apiv3Put('/slack-integration-settings/bot-type', {
         currentBotType: botType,
         currentBotType: botType,
       });
       });
       setSelectedBotType(undefined);
       setSelectedBotType(undefined);
       fetchSlackIntegrationData();
       fetchSlackIntegrationData();
-    }
-    catch (err) {
+    } catch (err) {
       toastError(err);
       toastError(err);
     }
     }
   };
   };
 
 
-  const botTypeSelectHandler = async(botType: SlackbotType) => {
+  const botTypeSelectHandler = async (botType: SlackbotType) => {
     if (botType === currentBotType) {
     if (botType === currentBotType) {
       return;
       return;
     }
     }
@@ -131,7 +131,7 @@ export const SlackIntegration = (): JSX.Element => {
     setSelectedBotType(botType);
     setSelectedBotType(botType);
   };
   };
 
 
-  const changeCurrentBotSettingsHandler = async() => {
+  const changeCurrentBotSettingsHandler = async () => {
     changeCurrentBotSettings(selectedBotType);
     changeCurrentBotSettings(selectedBotType);
     toastSuccess(t('admin:slack_integration.bot_reset_successful'));
     toastSuccess(t('admin:slack_integration.bot_reset_successful'));
   };
   };
@@ -213,23 +213,35 @@ export const SlackIntegration = (): JSX.Element => {
       <div className="selecting-bot-type mb-5">
       <div className="selecting-bot-type mb-5">
         <h2 className="admin-setting-header mb-4">
         <h2 className="admin-setting-header mb-4">
           {t('admin:slack_integration.selecting_bot_types.slack_bot')}
           {t('admin:slack_integration.selecting_bot_types.slack_bot')}
-          <a className="ms-2 btn-link small" href={t('admin:slack_integration.docs_url.slack_integration')} target="_blank" rel="noopener noreferrer">
-            <span className="material-symbols-outlined ms-1" aria-hidden="true">help</span>
+          <a
+            className="ms-2 btn-link small"
+            href={t('admin:slack_integration.docs_url.slack_integration')}
+            target="_blank"
+            rel="noopener noreferrer"
+          >
+            <span className="visually-hidden">
+              {t('admin:slack_integration.selecting_bot_types.slack_bot')}
+            </span>
+            <span className="material-symbols-outlined ms-1" aria-hidden="true">
+              help
+            </span>
           </a>
           </a>
         </h2>
         </h2>
 
 
-        { errorCode && (
+        {errorCode && (
           <div className="alert alert-warning">
           <div className="alert alert-warning">
-            <strong>ERROR: </strong>{errorMsg} ({errorCode})
+            <strong>ERROR: </strong>
+            {errorMsg} ({errorCode})
           </div>
           </div>
-        ) }
+        )}
 
 
         <div className="d-flex justify-content-end">
         <div className="d-flex justify-content-end">
           <button
           <button
             className="btn btn-outline-danger"
             className="btn btn-outline-danger"
             type="button"
             type="button"
             onClick={() => setIsDeleteConfirmModalShown(true)}
             onClick={() => setIsDeleteConfirmModalShown(true)}
-          >{t('admin:slack_integration.reset_all_settings')}
+          >
+            {t('admin:slack_integration.reset_all_settings')}
           </button>
           </button>
         </div>
         </div>
 
 

+ 282 - 107
apps/app/src/client/components/Admin/SlackIntegration/WithProxyAccordions.jsx

@@ -1,34 +1,40 @@
 /* eslint-disable react/prop-types */
 /* eslint-disable react/prop-types */
 import React, { useState } from 'react';
 import React, { useState } from 'react';
-
 import { SlackbotType } from '@growi/slack';
 import { SlackbotType } from '@growi/slack';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
 
 
-import { apiv3Put, apiv3Post } from '~/client/util/apiv3-client';
-import { toastSuccess, toastError } from '~/client/util/toastr';
+import { apiv3Post, apiv3Put } from '~/client/util/apiv3-client';
+import { toastError, toastSuccess } from '~/client/util/toastr';
 import { useSiteUrlWithEmptyValueWarn } from '~/states/global';
 import { useSiteUrlWithEmptyValueWarn } from '~/states/global';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
 import CustomCopyToClipBoard from '../../Common/CustomCopyToClipBoard';
 import CustomCopyToClipBoard from '../../Common/CustomCopyToClipBoard';
 import Accordion from '../Common/Accordion';
 import Accordion from '../Common/Accordion';
-
 import ManageCommandsProcess from './ManageCommandsProcess';
 import ManageCommandsProcess from './ManageCommandsProcess';
 import MessageBasedOnConnection from './MessageBasedOnConnection';
 import MessageBasedOnConnection from './MessageBasedOnConnection';
 import { addLogs } from './slack-integration-util';
 import { addLogs } from './slack-integration-util';
 
 
-const logger = loggerFactory('growi:SlackIntegration:WithProxyAccordionsWrapper');
+const logger = loggerFactory(
+  'growi:SlackIntegration:WithProxyAccordionsWrapper',
+);
 
 
 const BotCreateProcess = () => {
 const BotCreateProcess = () => {
   const { t } = useTranslation();
   const { t } = useTranslation();
   return (
   return (
     <div className="my-5 d-flex flex-column align-items-center">
     <div className="my-5 d-flex flex-column align-items-center">
-      <button type="button" className="btn btn-primary text-nowrap" onClick={() => window.open('https://api.slack.com/apps', '_blank')}>
+      <button
+        type="button"
+        className="btn btn-primary text-nowrap"
+        onClick={() => window.open('https://api.slack.com/apps', '_blank')}
+      >
         {t('admin:slack_integration.accordion.create_bot')}
         {t('admin:slack_integration.accordion.create_bot')}
         <span className="growi-custom-icons ms-2">external_link</span>
         <span className="growi-custom-icons ms-2">external_link</span>
       </button>
       </button>
       <a
       <a
-        href={t('admin:slack_integration.docs_url.custom_bot_with_proxy_setting')}
+        href={t(
+          'admin:slack_integration.docs_url.custom_bot_with_proxy_setting',
+        )}
         target="_blank"
         target="_blank"
         rel="noopener noreferrer"
         rel="noopener noreferrer"
       >
       >
@@ -47,7 +53,13 @@ const BotInstallProcessForOfficialBot = () => {
   const { t } = useTranslation();
   const { t } = useTranslation();
   return (
   return (
     <div className="my-5 d-flex flex-column align-items-center">
     <div className="my-5 d-flex flex-column align-items-center">
-      <button type="button" className="btn btn-primary text-nowrap" onClick={() => window.open('https://slackbot-proxy.growi.org/', '_blank')}>
+      <button
+        type="button"
+        className="btn btn-primary text-nowrap"
+        onClick={() =>
+          window.open('https://slackbot-proxy.growi.org/', '_blank')
+        }
+      >
         {t('admin:slack_integration.accordion.install_now')}
         {t('admin:slack_integration.accordion.install_now')}
         <span className="growi-custom-icons ms-2">external_link</span>
         <span className="growi-custom-icons ms-2">external_link</span>
       </button>
       </button>
@@ -71,18 +83,50 @@ const BotInstallProcessForCustomBotWithProxy = () => {
   const { t } = useTranslation();
   const { t } = useTranslation();
   return (
   return (
     <div className="container w-75 py-5">
     <div className="container w-75 py-5">
-      <p>1. {t('admin:slack_integration.accordion.go-to-manage-distribution')}</p>
-      <p>2. {t('admin:slack_integration.accordion.activate-public-distribution')}</p>
-      <img src="/images/slack-integration/activate-public-dist.png" className="border border-light img-fluid mb-5" />
-      <p>3. {t('admin:slack_integration.accordion.click-add-to-slack-button')}</p>
-      <img src="/images/slack-integration/click-add-to-slack.png" className="border border-light img-fluid mb-5" />
+      <p>
+        1. {t('admin:slack_integration.accordion.go-to-manage-distribution')}
+      </p>
+      <p>
+        2. {t('admin:slack_integration.accordion.activate-public-distribution')}
+      </p>
+      <img
+        src="/images/slack-integration/activate-public-dist.png"
+        className="border border-light img-fluid mb-5"
+        alt=""
+      />
+      <p>
+        3. {t('admin:slack_integration.accordion.click-add-to-slack-button')}
+      </p>
+      <img
+        src="/images/slack-integration/click-add-to-slack.png"
+        className="border border-light img-fluid mb-5"
+        alt=""
+      />
       <p>4. {t('admin:slack_integration.accordion.click_allow')}</p>
       <p>4. {t('admin:slack_integration.accordion.click_allow')}</p>
-      <img src="/images/slack-integration/slack-bot-install-your-app-transition-destination.png" className="border border-light img-fluid mb-5" />
-      <p>5. {t('admin:slack_integration.accordion.install_complete_if_checked')}</p>
-      <img src="/images/slack-integration/basicinfo-all-checked.png" className="border border-light img-fluid mb-5" />
+      <img
+        src="/images/slack-integration/slack-bot-install-your-app-transition-destination.png"
+        className="border border-light img-fluid mb-5"
+        alt=""
+      />
+      <p>
+        5. {t('admin:slack_integration.accordion.install_complete_if_checked')}
+      </p>
+      <img
+        src="/images/slack-integration/basicinfo-all-checked.png"
+        className="border border-light img-fluid mb-5"
+        alt=""
+      />
       <p>6. {t('admin:slack_integration.accordion.invite_bot_to_channel')}</p>
       <p>6. {t('admin:slack_integration.accordion.invite_bot_to_channel')}</p>
-      <img src="/images/slack-integration/slack-bot-install-to-workspace-joined-bot.png" className="border border-light img-fluid mb-1" />
-      <img src="/images/slack-integration/slack-bot-install-your-app-introduction-to-channel.png" className="border border-light img-fluid" />
+      <img
+        src="/images/slack-integration/slack-bot-install-to-workspace-joined-bot.png"
+        className="border border-light img-fluid mb-1"
+        alt=""
+      />
+      <img
+        src="/images/slack-integration/slack-bot-install-your-app-introduction-to-channel.png"
+        className="border border-light img-fluid"
+        alt=""
+      />
     </div>
     </div>
   );
   );
 };
 };
@@ -95,21 +139,39 @@ const RegisteringProxyUrlProcess = () => {
         <li>
         <li>
           <p
           <p
             // eslint-disable-next-line react/no-danger
             // eslint-disable-next-line react/no-danger
-            dangerouslySetInnerHTML={{ __html: t('admin:slack_integration.accordion.copy_proxy_url') }}
+            // biome-ignore lint/security/noDangerouslySetInnerHtml: includes markup from i18n strings
+            dangerouslySetInnerHTML={{
+              __html: t('admin:slack_integration.accordion.copy_proxy_url'),
+            }}
           />
           />
           <p>
           <p>
-            <img className="border border-light img-fluid" src="/images/slack-integration/growi-register-sentence.png" />
+            <img
+              className="border border-light img-fluid"
+              src="/images/slack-integration/growi-register-sentence.png"
+              alt=""
+            />
           </p>
           </p>
         </li>
         </li>
         <li>
         <li>
           <p
           <p
             // eslint-disable-next-line react/no-danger
             // eslint-disable-next-line react/no-danger
-            dangerouslySetInnerHTML={{ __html: t('admin:slack_integration.accordion.enter_proxy_url_and_update') }}
+            // biome-ignore lint/security/noDangerouslySetInnerHtml: includes markup from i18n strings
+            dangerouslySetInnerHTML={{
+              __html: t(
+                'admin:slack_integration.accordion.enter_proxy_url_and_update',
+              ),
+            }}
           />
           />
           <p>
           <p>
-            <img className="border border-light img-fluid" src="/images/slack-integration/growi-set-proxy-url.png" />
+            <img
+              className="border border-light img-fluid"
+              src="/images/slack-integration/growi-set-proxy-url.png"
+              alt=""
+            />
+          </p>
+          <p className="text-danger">
+            {t('admin:slack_integration.accordion.dont_need_update')}
           </p>
           </p>
-          <p className="text-danger">{t('admin:slack_integration.accordion.dont_need_update')}</p>
         </li>
         </li>
       </ol>
       </ol>
     </div>
     </div>
@@ -120,15 +182,18 @@ const GeneratingTokensAndRegisteringProxyServiceProcess = (props) => {
   const { t } = useTranslation();
   const { t } = useTranslation();
   const { slackAppIntegrationId } = props;
   const { slackAppIntegrationId } = props;
 
 
-  const regenerateTokensHandler = async() => {
+  const regenerateTokensHandler = async () => {
     try {
     try {
-      await apiv3Put(`/slack-integration-settings/slack-app-integrations/${slackAppIntegrationId}/regenerate-tokens`);
+      await apiv3Put(
+        `/slack-integration-settings/slack-app-integrations/${slackAppIntegrationId}/regenerate-tokens`,
+      );
       if (props.onUpdateTokens != null) {
       if (props.onUpdateTokens != null) {
         props.onUpdateTokens();
         props.onUpdateTokens();
       }
       }
-      toastSuccess(t('toaster.update_successed', { target: 'Token', ns: 'commons' }));
-    }
-    catch (err) {
+      toastSuccess(
+        t('toaster.update_successed', { target: 'Token', ns: 'commons' }),
+      );
+    } catch (err) {
       toastError(err);
       toastError(err);
       logger.error(err);
       logger.error(err);
     }
     }
@@ -136,22 +201,52 @@ const GeneratingTokensAndRegisteringProxyServiceProcess = (props) => {
 
 
   return (
   return (
     <div className="py-4 px-5">
     <div className="py-4 px-5">
-      <p className="fw-bold">1. {t('admin:slack_integration.accordion.generate_access_token')}</p>
+      <p className="fw-bold">
+        1. {t('admin:slack_integration.accordion.generate_access_token')}
+      </p>
       <div className="row">
       <div className="row">
-        <label className="text-start text-md-end col-md-3 col-form-label">Access Token Proxy to GROWI</label>
+        <label
+          className="text-start text-md-end col-md-3 col-form-label"
+          htmlFor="admin-slack-access-token-ptog"
+        >
+          Access Token Proxy to GROWI
+        </label>
         <div className="col-md-6">
         <div className="col-md-6">
           <div className=" mx-1">
           <div className=" mx-1">
-            <input className="form-control" type="text" value={props.tokenPtoG || ''} readOnly />
-            <CustomCopyToClipBoard textToBeCopied={props.tokenPtoG || ''} message="admin:slack_integration.copied_to_clipboard"></CustomCopyToClipBoard>
+            <input
+              className="form-control"
+              type="text"
+              value={props.tokenPtoG || ''}
+              id="admin-slack-access-token-ptog"
+              readOnly
+            />
+            <CustomCopyToClipBoard
+              textToBeCopied={props.tokenPtoG || ''}
+              message="admin:slack_integration.copied_to_clipboard"
+            ></CustomCopyToClipBoard>
           </div>
           </div>
         </div>
         </div>
       </div>
       </div>
       <div className="row">
       <div className="row">
-        <label className="text-start text-md-end col-md-3 col-form-label">Access Token GROWI to Proxy</label>
+        <label
+          className="text-start text-md-end col-md-3 col-form-label"
+          htmlFor="admin-slack-access-token-gtop"
+        >
+          Access Token GROWI to Proxy
+        </label>
         <div className="col-md-6">
         <div className="col-md-6">
           <div className=" mx-1">
           <div className=" mx-1">
-            <input className="form-control" type="text" value={props.tokenGtoP || ''} readOnly />
-            <CustomCopyToClipBoard textToBeCopied={props.tokenGtoP || ''} message="admin:slack_integration.copied_to_clipboard"></CustomCopyToClipBoard>
+            <input
+              className="form-control"
+              type="text"
+              value={props.tokenGtoP || ''}
+              id="admin-slack-access-token-gtop"
+              readOnly
+            />
+            <CustomCopyToClipBoard
+              textToBeCopied={props.tokenGtoP || ''}
+              message="admin:slack_integration.copied_to_clipboard"
+            ></CustomCopyToClipBoard>
           </div>
           </div>
         </div>
         </div>
       </div>
       </div>
@@ -162,17 +257,27 @@ const GeneratingTokensAndRegisteringProxyServiceProcess = (props) => {
           className="btn btn-primary mx-auto"
           className="btn btn-primary mx-auto"
           onClick={regenerateTokensHandler}
           onClick={regenerateTokensHandler}
         >
         >
-          { t('admin:slack_integration.access_token_settings.regenerate') }
+          {t('admin:slack_integration.access_token_settings.regenerate')}
         </button>
         </button>
       </div>
       </div>
-      <p className="fw-bold mt-5">2. {t('admin:slack_integration.accordion.register_for_growi_official_bot_proxy_service')}</p>
+      <p className="fw-bold mt-5">
+        2.{' '}
+        {t(
+          'admin:slack_integration.accordion.register_for_growi_official_bot_proxy_service',
+        )}
+      </p>
       <div className="d-flex flex-column align-items-center">
       <div className="d-flex flex-column align-items-center">
         <ol className="p-0">
         <ol className="p-0">
           <li>
           <li>
             <p
             <p
               className="ms-2"
               className="ms-2"
               // eslint-disable-next-line react/no-danger
               // eslint-disable-next-line react/no-danger
-              dangerouslySetInnerHTML={{ __html: t('admin:slack_integration.accordion.enter_growi_register_on_slack') }}
+              // biome-ignore lint/security/noDangerouslySetInnerHtml: includes markup from i18n strings
+              dangerouslySetInnerHTML={{
+                __html: t(
+                  'admin:slack_integration.accordion.enter_growi_register_on_slack',
+                ),
+              }}
             />
             />
           </li>
           </li>
           <li>
           <li>
@@ -181,52 +286,75 @@ const GeneratingTokensAndRegisteringProxyServiceProcess = (props) => {
               // TODO: Add dynamic link
               // TODO: Add dynamic link
               // TODO: Add logo
               // TODO: Add logo
               // eslint-disable-next-line react/no-danger
               // eslint-disable-next-line react/no-danger
-              dangerouslySetInnerHTML={{ __html: t('admin:slack_integration.accordion.paste_growi_url') }}
+              // biome-ignore lint/security/noDangerouslySetInnerHtml: includes markup from i18n strings
+              dangerouslySetInnerHTML={{
+                __html: t('admin:slack_integration.accordion.paste_growi_url'),
+              }}
             />
             />
             <div className="input-group align-items-center ps-2 mb-3">
             <div className="input-group align-items-center ps-2 mb-3">
               <div className="w-75">
               <div className="w-75">
-                <input className="form-control" type="text" value={props.growiUrl} readOnly />
-                <CustomCopyToClipBoard textToBeCopied={props.growiUrl} message="admin:slack_integration.copied_to_clipboard"></CustomCopyToClipBoard>
+                <input
+                  className="form-control"
+                  type="text"
+                  value={props.growiUrl}
+                  readOnly
+                />
+                <CustomCopyToClipBoard
+                  textToBeCopied={props.growiUrl}
+                  message="admin:slack_integration.copied_to_clipboard"
+                ></CustomCopyToClipBoard>
               </div>
               </div>
             </div>
             </div>
-
           </li>
           </li>
           <li>
           <li>
             <p
             <p
               className="ms-2"
               className="ms-2"
               // eslint-disable-next-line react/no-danger
               // eslint-disable-next-line react/no-danger
-              dangerouslySetInnerHTML={{ __html: t('admin:slack_integration.accordion.enter_access_token_for_growi_and_proxy') }}
+              // biome-ignore lint/security/noDangerouslySetInnerHtml: includes markup from i18n strings
+              dangerouslySetInnerHTML={{
+                __html: t(
+                  'admin:slack_integration.accordion.enter_access_token_for_growi_and_proxy',
+                ),
+              }}
             />
             />
           </li>
           </li>
         </ol>
         </ol>
-        <img className="mb-3 border border-light img-fluid" width={500} src="/images/slack-integration/growi-register-modal.png" />
+        <img
+          className="mb-3 border border-light img-fluid"
+          width={500}
+          src="/images/slack-integration/growi-register-modal.png"
+          alt=""
+        />
       </div>
       </div>
     </div>
     </div>
-
   );
   );
 };
 };
 
 
 const TestProcess = ({
 const TestProcess = ({
-  slackAppIntegrationId, onSubmitForm, onSubmitFormFailed, isLatestConnectionSuccess,
+  slackAppIntegrationId,
+  onSubmitForm,
+  onSubmitFormFailed,
+  isLatestConnectionSuccess,
 }) => {
 }) => {
-
   const { t } = useTranslation();
   const { t } = useTranslation();
   const [testChannel, setTestChannel] = useState('');
   const [testChannel, setTestChannel] = useState('');
   const [logsValue, setLogsValue] = useState('');
   const [logsValue, setLogsValue] = useState('');
   const successMessage = 'Successfully sent to Slack workspace.';
   const successMessage = 'Successfully sent to Slack workspace.';
 
 
-  const submitForm = async(e) => {
+  const submitForm = async (e) => {
     e.preventDefault();
     e.preventDefault();
     try {
     try {
-      await apiv3Post(`/slack-integration-settings/slack-app-integrations/${slackAppIntegrationId}/relation-test`, { channel: testChannel });
+      await apiv3Post(
+        `/slack-integration-settings/slack-app-integrations/${slackAppIntegrationId}/relation-test`,
+        { channel: testChannel },
+      );
       const newLogs = addLogs(logsValue, successMessage, null);
       const newLogs = addLogs(logsValue, successMessage, null);
       setLogsValue(newLogs);
       setLogsValue(newLogs);
 
 
       if (onSubmitForm != null) {
       if (onSubmitForm != null) {
         onSubmitForm();
         onSubmitForm();
       }
       }
-    }
-    catch (error) {
+    } catch (error) {
       const newLogs = addLogs(logsValue, error[0].message, error[0].code);
       const newLogs = addLogs(logsValue, error[0].message, error[0].code);
       setLogsValue(newLogs);
       setLogsValue(newLogs);
       logger.error(error);
       logger.error(error);
@@ -238,22 +366,34 @@ const TestProcess = ({
 
 
   return (
   return (
     <>
     <>
-      <p className="text-center m-4">{t('admin:slack_integration.accordion.test_connection_by_pressing_button')}</p>
+      <p className="text-center m-4">
+        {t(
+          'admin:slack_integration.accordion.test_connection_by_pressing_button',
+        )}
+      </p>
       <p className="text-center text-warning">
       <p className="text-center text-warning">
-        <span className="material-symbols-outlined me-1">info</span>{t('admin:slack_integration.accordion.test_connection_only_public_channel')}
+        <span className="material-symbols-outlined me-1">info</span>
+        {t(
+          'admin:slack_integration.accordion.test_connection_only_public_channel',
+        )}
       </p>
       </p>
       <div className="d-flex justify-content-center">
       <div className="d-flex justify-content-center">
-        <form className="justify-content-center" onSubmit={e => submitForm(e)}>
+        <form
+          className="justify-content-center"
+          onSubmit={(e) => submitForm(e)}
+        >
           <div className="input-group col-8">
           <div className="input-group col-8">
             <div>
             <div>
-              <span className="input-group-text" id="slack-channel-addon"><span className="material-symbols-outlined">tag</span></span>
+              <span className="input-group-text" id="slack-channel-addon">
+                <span className="material-symbols-outlined">tag</span>
+              </span>
             </div>
             </div>
             <input
             <input
               className="form-control"
               className="form-control"
               type="text"
               type="text"
               value={testChannel}
               value={testChannel}
               placeholder="Slack Channel"
               placeholder="Slack Channel"
-              onChange={e => setTestChannel(e.target.value)}
+              onChange={(e) => setTestChannel(e.target.value)}
             />
             />
           </div>
           </div>
           <button
           <button
@@ -265,11 +405,18 @@ const TestProcess = ({
           </button>
           </button>
         </form>
         </form>
       </div>
       </div>
-      <MessageBasedOnConnection isLatestConnectionSuccess={isLatestConnectionSuccess} logsValue={logsValue} />
+      <MessageBasedOnConnection
+        isLatestConnectionSuccess={isLatestConnectionSuccess}
+        logsValue={logsValue}
+      />
       <form>
       <form>
         <div className="row my-3 justify-content-center">
         <div className="row my-3 justify-content-center">
           <div className="slack-connection-log col-md-4">
           <div className="slack-connection-log col-md-4">
-            <label className="form-label mb-1"><p className="border-info slack-connection-log-title ps-2 m-0">Logs</p></label>
+            <div className="form-label mb-1">
+              <p className="border-info slack-connection-log-title ps-2 m-0">
+                Logs
+              </p>
+            </div>
             <textarea
             <textarea
               className="form-control card border-info slack-connection-log-body rounded-3"
               className="form-control card border-info slack-connection-log-body rounded-3"
               rows="5"
               rows="5"
@@ -283,11 +430,11 @@ const TestProcess = ({
   );
   );
 };
 };
 
 
-
 const WithProxyAccordions = (props) => {
 const WithProxyAccordions = (props) => {
   const { t } = useTranslation();
   const { t } = useTranslation();
   const siteUrl = useSiteUrlWithEmptyValueWarn();
   const siteUrl = useSiteUrlWithEmptyValueWarn();
-  const [isLatestConnectionSuccess, setIsLatestConnectionSuccess] = useState(false);
+  const [isLatestConnectionSuccess, setIsLatestConnectionSuccess] =
+    useState(false);
 
 
   const submitForm = () => {
   const submitForm = () => {
     setIsLatestConnectionSuccess(true);
     setIsLatestConnectionSuccess(true);
@@ -299,7 +446,6 @@ const WithProxyAccordions = (props) => {
     setIsLatestConnectionSuccess(false);
     setIsLatestConnectionSuccess(false);
   };
   };
 
 
-
   const officialBotIntegrationProcedure = {
   const officialBotIntegrationProcedure = {
     1: {
     1: {
       title: 'install_bot_to_slack',
       title: 'install_bot_to_slack',
@@ -307,31 +453,43 @@ const WithProxyAccordions = (props) => {
     },
     },
     2: {
     2: {
       title: 'register_for_growi_official_bot_proxy_service',
       title: 'register_for_growi_official_bot_proxy_service',
-      content: <GeneratingTokensAndRegisteringProxyServiceProcess
-        growiUrl={siteUrl}
-        slackAppIntegrationId={props.slackAppIntegrationId}
-        tokenPtoG={props.tokenPtoG}
-        tokenGtoP={props.tokenGtoP}
-        onUpdateTokens={props.onUpdateTokens}
-      />,
+      content: (
+        <GeneratingTokensAndRegisteringProxyServiceProcess
+          growiUrl={siteUrl}
+          slackAppIntegrationId={props.slackAppIntegrationId}
+          tokenPtoG={props.tokenPtoG}
+          tokenGtoP={props.tokenGtoP}
+          onUpdateTokens={props.onUpdateTokens}
+        />
+      ),
     },
     },
     3: {
     3: {
       title: 'manage_permission',
       title: 'manage_permission',
-      content: <ManageCommandsProcess
-        slackAppIntegrationId={props.slackAppIntegrationId}
-        permissionsForBroadcastUseCommands={props.permissionsForBroadcastUseCommands}
-        permissionsForSingleUseCommands={props.permissionsForSingleUseCommands}
-        permissionsForSlackEventActions={props.permissionsForSlackEventActions}
-      />,
+      content: (
+        <ManageCommandsProcess
+          slackAppIntegrationId={props.slackAppIntegrationId}
+          permissionsForBroadcastUseCommands={
+            props.permissionsForBroadcastUseCommands
+          }
+          permissionsForSingleUseCommands={
+            props.permissionsForSingleUseCommands
+          }
+          permissionsForSlackEventActions={
+            props.permissionsForSlackEventActions
+          }
+        />
+      ),
     },
     },
     4: {
     4: {
       title: 'test_connection',
       title: 'test_connection',
-      content: <TestProcess
-        slackAppIntegrationId={props.slackAppIntegrationId}
-        onSubmitForm={submitForm}
-        onSubmitFormFailed={submitFormFailed}
-        isLatestConnectionSuccess={isLatestConnectionSuccess}
-      />,
+      content: (
+        <TestProcess
+          slackAppIntegrationId={props.slackAppIntegrationId}
+          onSubmitForm={submitForm}
+          onSubmitFormFailed={submitFormFailed}
+          isLatestConnectionSuccess={isLatestConnectionSuccess}
+        />
+      ),
     },
     },
   };
   };
 
 
@@ -346,13 +504,15 @@ const WithProxyAccordions = (props) => {
     },
     },
     3: {
     3: {
       title: 'register_for_growi_custom_bot_proxy',
       title: 'register_for_growi_custom_bot_proxy',
-      content: <GeneratingTokensAndRegisteringProxyServiceProcess
-        growiUrl={siteUrl}
-        slackAppIntegrationId={props.slackAppIntegrationId}
-        tokenPtoG={props.tokenPtoG}
-        tokenGtoP={props.tokenGtoP}
-        onUpdateTokens={props.onUpdateTokens}
-      />,
+      content: (
+        <GeneratingTokensAndRegisteringProxyServiceProcess
+          growiUrl={siteUrl}
+          slackAppIntegrationId={props.slackAppIntegrationId}
+          tokenPtoG={props.tokenPtoG}
+          tokenGtoP={props.tokenGtoP}
+          onUpdateTokens={props.onUpdateTokens}
+        />
+      ),
     },
     },
     4: {
     4: {
       title: 'set_proxy_url_on_growi',
       title: 'set_proxy_url_on_growi',
@@ -360,40 +520,56 @@ const WithProxyAccordions = (props) => {
     },
     },
     5: {
     5: {
       title: 'manage_permission',
       title: 'manage_permission',
-      content: <ManageCommandsProcess
-        slackAppIntegrationId={props.slackAppIntegrationId}
-        permissionsForBroadcastUseCommands={props.permissionsForBroadcastUseCommands}
-        permissionsForSingleUseCommands={props.permissionsForSingleUseCommands}
-        permissionsForSlackEventActions={props.permissionsForSlackEventActions}
-      />,
+      content: (
+        <ManageCommandsProcess
+          slackAppIntegrationId={props.slackAppIntegrationId}
+          permissionsForBroadcastUseCommands={
+            props.permissionsForBroadcastUseCommands
+          }
+          permissionsForSingleUseCommands={
+            props.permissionsForSingleUseCommands
+          }
+          permissionsForSlackEventActions={
+            props.permissionsForSlackEventActions
+          }
+        />
+      ),
     },
     },
     6: {
     6: {
       title: 'test_connection',
       title: 'test_connection',
-      content: <TestProcess
-        slackAppIntegrationId={props.slackAppIntegrationId}
-        onSubmitForm={submitForm}
-        onSubmitFormFailed={submitFormFailed}
-        isLatestConnectionSuccess={isLatestConnectionSuccess}
-      />,
+      content: (
+        <TestProcess
+          slackAppIntegrationId={props.slackAppIntegrationId}
+          onSubmitForm={submitForm}
+          onSubmitFormFailed={submitFormFailed}
+          isLatestConnectionSuccess={isLatestConnectionSuccess}
+        />
+      ),
     },
     },
   };
   };
 
 
-  const integrationProcedureMapping = props.botType === SlackbotType.OFFICIAL ? officialBotIntegrationProcedure : CustomBotIntegrationProcedure;
+  const integrationProcedureMapping =
+    props.botType === SlackbotType.OFFICIAL
+      ? officialBotIntegrationProcedure
+      : CustomBotIntegrationProcedure;
 
 
   return (
   return (
-    <div
-      className="accordion"
-    >
+    <div className="accordion">
       {Object.entries(integrationProcedureMapping).map(([key, value]) => {
       {Object.entries(integrationProcedureMapping).map(([key, value]) => {
         return (
         return (
           <Accordion
           <Accordion
-            title={(
+            title={
               <>
               <>
                 <span className="me-3">{key}</span>
                 <span className="me-3">{key}</span>
                 {t(`admin:slack_integration.accordion.${value.title}`)}
                 {t(`admin:slack_integration.accordion.${value.title}`)}
-                {value.title === 'test_connection' && isLatestConnectionSuccess && <span className="material-symbols-outlined ms-3 text-success">check</span>}
+                {value.title === 'test_connection' &&
+                  isLatestConnectionSuccess && (
+                    <span className="material-symbols-outlined ms-3 text-success">
+                      check
+                    </span>
+                  )}
               </>
               </>
-            )}
+            }
             key={key}
             key={key}
           >
           >
             {value.content}
             {value.content}
@@ -404,7 +580,6 @@ const WithProxyAccordions = (props) => {
   );
   );
 };
 };
 
 
-
 /**
 /**
  * Wrapper component for using unstated
  * Wrapper component for using unstated
  */
  */

+ 5 - 1
apps/app/src/client/components/Admin/SlackIntegration/slack-integration-util.ts

@@ -1,4 +1,8 @@
-export const addLogs = (log: string, newLogMessage:string, newLogCode?: string): string => {
+export const addLogs = (
+  log: string,
+  newLogMessage: string,
+  newLogCode?: string,
+): string => {
   const newLog = `${new Date()} - ${newLogCode ? `${newLogCode}, ` : ''}${newLogMessage}\n\n`;
   const newLog = `${new Date()} - ${newLogCode ? `${newLogCode}, ` : ''}${newLogMessage}\n\n`;
   return `${newLog}${log ?? ''}`;
   return `${newLog}${log ?? ''}`;
 };
 };

+ 108 - 59
apps/app/src/client/components/Admin/UserGroup/UserGroupDeleteModal.tsx

@@ -1,17 +1,16 @@
 import type { FC } from 'react';
 import type { FC } from 'react';
-import React, { useCallback, useState, useMemo } from 'react';
-
+import React, { useCallback, useMemo, useState } from 'react';
 import {
 import {
-  getIdStringForRef, isPopulated, type IGrantedGroup, type IUserGroupHasId,
+  getIdStringForRef,
+  type IGrantedGroup,
+  type IUserGroupHasId,
+  isPopulated,
 } from '@growi/core';
 } from '@growi/core';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
-import {
-  Modal, ModalHeader, ModalBody, ModalFooter,
-} from 'reactstrap';
+import { Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
 
 
 import { PageActionOnGroupDelete } from '~/interfaces/user-group';
 import { PageActionOnGroupDelete } from '~/interfaces/user-group';
 
 
-
 /**
 /**
  * Delete User Group Select component
  * Delete User Group Select component
  *
  *
@@ -20,26 +19,27 @@ import { PageActionOnGroupDelete } from '~/interfaces/user-group';
  * @extends {React.Component}
  * @extends {React.Component}
  */
  */
 type Props = {
 type Props = {
-  userGroups: IGrantedGroup[],
-  deleteUserGroup?: IUserGroupHasId,
-  onDelete?: (deleteGroupId: string, actionName: PageActionOnGroupDelete, transferToUserGroup: IGrantedGroup | null) => Promise<void> | void,
-  isShow: boolean,
-  onHide?: () => Promise<void> | void,
+  userGroups: IGrantedGroup[];
+  deleteUserGroup?: IUserGroupHasId;
+  onDelete?: (
+    deleteGroupId: string,
+    actionName: PageActionOnGroupDelete,
+    transferToUserGroup: IGrantedGroup | null,
+  ) => Promise<void> | void;
+  isShow: boolean;
+  onHide?: () => Promise<void> | void;
 };
 };
 
 
 type AvailableOption = {
 type AvailableOption = {
-  id: number,
-  actionForPages: PageActionOnGroupDelete,
-  label: string,
+  id: number;
+  actionForPages: PageActionOnGroupDelete;
+  label: string;
 };
 };
 
 
 export const UserGroupDeleteModal: FC<Props> = (props: Props) => {
 export const UserGroupDeleteModal: FC<Props> = (props: Props) => {
-
   const { t } = useTranslation();
   const { t } = useTranslation();
 
 
-  const {
-    onHide, onDelete, userGroups, deleteUserGroup,
-  } = props;
+  const { onHide, onDelete, userGroups, deleteUserGroup } = props;
 
 
   const availableOptions = useMemo<AvailableOption[]>(() => {
   const availableOptions = useMemo<AvailableOption[]>(() => {
     return [
     return [
@@ -64,8 +64,11 @@ export const UserGroupDeleteModal: FC<Props> = (props: Props) => {
   /*
   /*
    * State
    * State
    */
    */
-  const [actionName, setActionName] = useState<PageActionOnGroupDelete | null>(null);
-  const [transferToUserGroup, setTransferToUserGroup] = useState<IGrantedGroup | null>(null);
+  const [actionName, setActionName] = useState<PageActionOnGroupDelete | null>(
+    null,
+  );
+  const [transferToUserGroup, setTransferToUserGroup] =
+    useState<IGrantedGroup | null>(null);
 
 
   /*
   /*
    * Function
    * Function
@@ -87,31 +90,40 @@ export const UserGroupDeleteModal: FC<Props> = (props: Props) => {
   const handleActionChange = useCallback((e) => {
   const handleActionChange = useCallback((e) => {
     const actionName = e.target.value;
     const actionName = e.target.value;
     setActionName(actionName);
     setActionName(actionName);
-  }, [setActionName]);
+  }, []);
 
 
-  const handleGroupChange = useCallback((e) => {
-    const transferToUserGroupId: string = e.target.value;
-    const selectedGroup = userGroups.find(group => getIdStringForRef(group.item) === transferToUserGroupId) ?? null;
-    setTransferToUserGroup(selectedGroup);
-  }, [userGroups]);
+  const handleGroupChange = useCallback(
+    (e) => {
+      const transferToUserGroupId: string = e.target.value;
+      const selectedGroup =
+        userGroups.find(
+          (group) => getIdStringForRef(group.item) === transferToUserGroupId,
+        ) ?? null;
+      setTransferToUserGroup(selectedGroup);
+    },
+    [userGroups],
+  );
 
 
-  const handleSubmit = useCallback((e) => {
-    if (onDelete == null || deleteUserGroup == null || actionName == null) {
-      return;
-    }
+  const handleSubmit = useCallback(
+    (e) => {
+      if (onDelete == null || deleteUserGroup == null || actionName == null) {
+        return;
+      }
 
 
-    e.preventDefault();
+      e.preventDefault();
 
 
-    onDelete(
-      deleteUserGroup._id,
-      actionName,
-      transferToUserGroup,
-    );
-  }, [onDelete, deleteUserGroup, actionName, transferToUserGroup]);
+      onDelete(deleteUserGroup._id, actionName, transferToUserGroup);
+    },
+    [onDelete, deleteUserGroup, actionName, transferToUserGroup],
+  );
 
 
   const renderPageActionSelector = useCallback(() => {
   const renderPageActionSelector = useCallback(() => {
     const options = availableOptions.map((opt) => {
     const options = availableOptions.map((opt) => {
-      return <option key={opt.id} value={opt.actionForPages}>{opt.label}</option>;
+      return (
+        <option key={opt.id} value={opt.actionForPages}>
+          {opt.label}
+        </option>
+      );
     });
     });
 
 
     // TODO: Use GROWI original dropdown.
     // TODO: Use GROWI original dropdown.
@@ -123,7 +135,9 @@ export const UserGroupDeleteModal: FC<Props> = (props: Props) => {
         value={actionName ?? ''}
         value={actionName ?? ''}
         onChange={handleActionChange}
         onChange={handleActionChange}
       >
       >
-        <option value="" disabled>{t('admin:user_group_management.delete_modal.dropdown_desc')}</option>
+        <option value="" disabled>
+          {t('admin:user_group_management.delete_modal.dropdown_desc')}
+        </option>
         {options}
         {options}
       </select>
       </select>
     );
     );
@@ -138,36 +152,56 @@ export const UserGroupDeleteModal: FC<Props> = (props: Props) => {
       return getIdStringForRef(group.item) !== deleteUserGroup._id;
       return getIdStringForRef(group.item) !== deleteUserGroup._id;
     });
     });
 
 
-    const options = groups.map((group) => {
-      const groupId = getIdStringForRef(group.item);
-      const groupName = isPopulated(group.item) ? group.item.name : null;
-      return { id: groupId, name: groupName };
-    }).filter(obj => obj.name != null)
-      .map(obj => <option key={obj.id} value={obj.id}>{obj.name}</option>);
+    const options = groups
+      .map((group) => {
+        const groupId = getIdStringForRef(group.item);
+        const groupName = isPopulated(group.item) ? group.item.name : null;
+        return { id: groupId, name: groupName };
+      })
+      .filter((obj) => obj.name != null)
+      .map((obj) => (
+        <option key={obj.id} value={obj.id}>
+          {obj.name}
+        </option>
+      ));
 
 
-    const defaultOptionText = groups.length === 0 ? t('admin:user_group_management.delete_modal.no_groups')
-      : t('admin:user_group_management.delete_modal.select_group');
+    const defaultOptionText =
+      groups.length === 0
+        ? t('admin:user_group_management.delete_modal.no_groups')
+        : t('admin:user_group_management.delete_modal.select_group');
 
 
     return (
     return (
       <select
       <select
         name="transferToUserGroup"
         name="transferToUserGroup"
         className={`form-control ${actionName === PageActionOnGroupDelete.transfer ? '' : 'd-none'}`}
         className={`form-control ${actionName === PageActionOnGroupDelete.transfer ? '' : 'd-none'}`}
-        value={transferToUserGroup != null ? getIdStringForRef(transferToUserGroup.item) : ''}
+        value={
+          transferToUserGroup != null
+            ? getIdStringForRef(transferToUserGroup.item)
+            : ''
+        }
         onChange={handleGroupChange}
         onChange={handleGroupChange}
       >
       >
-        <option value="" disabled>{defaultOptionText}</option>
+        <option value="" disabled>
+          {defaultOptionText}
+        </option>
         {options}
         {options}
       </select>
       </select>
     );
     );
-  }, [deleteUserGroup, userGroups, t, actionName, transferToUserGroup, handleGroupChange]);
+  }, [
+    deleteUserGroup,
+    userGroups,
+    t,
+    actionName,
+    transferToUserGroup,
+    handleGroupChange,
+  ]);
 
 
   const validateForm = useCallback(() => {
   const validateForm = useCallback(() => {
     let isValid = true;
     let isValid = true;
 
 
     if (actionName === null) {
     if (actionName === null) {
       isValid = false;
       isValid = false;
-    }
-    else if (actionName === PageActionOnGroupDelete.transfer) {
+    } else if (actionName === PageActionOnGroupDelete.transfer) {
       isValid = transferToUserGroup != null;
       isValid = transferToUserGroup != null;
     }
     }
 
 
@@ -177,29 +211,44 @@ export const UserGroupDeleteModal: FC<Props> = (props: Props) => {
   return (
   return (
     <Modal className="modal-md" isOpen={props.isShow} toggle={toggleHandler}>
     <Modal className="modal-md" isOpen={props.isShow} toggle={toggleHandler}>
       <ModalHeader tag="h4" toggle={toggleHandler}>
       <ModalHeader tag="h4" toggle={toggleHandler}>
-        <span className="material-symbols-outlined">delete_forever</span> {t('admin:user_group_management.delete_modal.header')}
+        <span className="material-symbols-outlined">delete_forever</span>{' '}
+        {t('admin:user_group_management.delete_modal.header')}
       </ModalHeader>
       </ModalHeader>
       <ModalBody>
       <ModalBody>
         <div>
         <div>
-          <span className="fw-bold">{t('admin:user_group_management.group_name')}</span> : &quot;{props?.deleteUserGroup?.name || ''}&quot;
+          <span className="fw-bold">
+            {t('admin:user_group_management.group_name')}
+          </span>{' '}
+          : &quot;{props?.deleteUserGroup?.name || ''}&quot;
         </div>
         </div>
         <div className="text-danger mt-3">
         <div className="text-danger mt-3">
           {t('admin:user_group_management.delete_modal.desc')}
           {t('admin:user_group_management.delete_modal.desc')}
         </div>
         </div>
       </ModalBody>
       </ModalBody>
       <ModalFooter>
       <ModalFooter>
-        <form className="d-flex justify-content-between w-100" onSubmit={handleSubmit}>
+        <form
+          className="d-flex justify-content-between w-100"
+          onSubmit={handleSubmit}
+        >
           <div className="d-flex mb-0 me-3">
           <div className="d-flex mb-0 me-3">
             {renderPageActionSelector()}
             {renderPageActionSelector()}
             {renderGroupSelector()}
             {renderGroupSelector()}
           </div>
           </div>
-          <button type="submit" value="" className="btn btn-sm btn-danger text-nowrap" disabled={!validateForm()}>
-            <span className="material-symbols-outlined">delete_forever</span> {t('Delete')}
+          <button
+            type="submit"
+            value=""
+            className="btn btn-sm btn-danger text-nowrap"
+            disabled={!validateForm()}
+          >
+            <span className="material-symbols-outlined">delete_forever</span>{' '}
+            {t('Delete')}
           </button>
           </button>
         </form>
         </form>
         {actionName === PageActionOnGroupDelete.publicize && (
         {actionName === PageActionOnGroupDelete.publicize && (
           <div className="form-text text-muted">
           <div className="form-text text-muted">
-            <small>{t('admin:user_group_management.delete_modal.option_explanation')}</small>
+            <small>
+              {t('admin:user_group_management.delete_modal.option_explanation')}
+            </small>
           </div>
           </div>
         )}
         )}
       </ModalFooter>
       </ModalFooter>

+ 42 - 33
apps/app/src/client/components/Admin/UserGroup/UserGroupDropdown.tsx

@@ -1,25 +1,31 @@
 import type { FC } from 'react';
 import type { FC } from 'react';
 import React, { useCallback } from 'react';
 import React, { useCallback } from 'react';
-
 import type { IUserGroupHasId } from '@growi/core';
 import type { IUserGroupHasId } from '@growi/core';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 
 
 type Props = {
 type Props = {
-  selectableUserGroups?: IUserGroupHasId[]
-  onClickAddExistingUserGroupButton?(userGroup: IUserGroupHasId | null): void
-  onClickCreateUserGroupButton?(): void
+  selectableUserGroups?: IUserGroupHasId[];
+  onClickAddExistingUserGroupButton?(userGroup: IUserGroupHasId | null): void;
+  onClickCreateUserGroupButton?(): void;
 };
 };
 
 
 export const UserGroupDropdown: FC<Props> = (props: Props) => {
 export const UserGroupDropdown: FC<Props> = (props: Props) => {
   const { t } = useTranslation();
   const { t } = useTranslation();
 
 
-  const { selectableUserGroups, onClickAddExistingUserGroupButton, onClickCreateUserGroupButton } = props;
+  const {
+    selectableUserGroups,
+    onClickAddExistingUserGroupButton,
+    onClickCreateUserGroupButton,
+  } = props;
 
 
-  const onClickAddExistingUserGroupButtonHandler = useCallback((userGroup: IUserGroupHasId) => {
-    if (onClickAddExistingUserGroupButton != null) {
-      onClickAddExistingUserGroupButton(userGroup);
-    }
-  }, [onClickAddExistingUserGroupButton]);
+  const onClickAddExistingUserGroupButtonHandler = useCallback(
+    (userGroup: IUserGroupHasId) => {
+      if (onClickAddExistingUserGroupButton != null) {
+        onClickAddExistingUserGroupButton(userGroup);
+      }
+    },
+    [onClickAddExistingUserGroupButton],
+  );
 
 
   const onClickCreateUserGroupButtonHandler = useCallback(() => {
   const onClickCreateUserGroupButtonHandler = useCallback(() => {
     if (onClickCreateUserGroupButton != null) {
     if (onClickCreateUserGroupButton != null) {
@@ -30,37 +36,40 @@ export const UserGroupDropdown: FC<Props> = (props: Props) => {
   return (
   return (
     <>
     <>
       <div className="dropdown">
       <div className="dropdown">
-        <button className="btn btn-secondary dropdown-toggle" type="button" id="dropdownMenuButton" data-bs-toggle="dropdown">
+        <button
+          className="btn btn-secondary dropdown-toggle"
+          type="button"
+          id="dropdownMenuButton"
+          data-bs-toggle="dropdown"
+        >
           {t('admin:user_group_management.add_child_group')}
           {t('admin:user_group_management.add_child_group')}
         </button>
         </button>
 
 
-        <div className="dropdown-menu" aria-labelledby="dropdownMenuButton">
-
-          {
-            (selectableUserGroups != null && selectableUserGroups.length > 0) && (
-              <>
-                {
-                  selectableUserGroups.map(userGroup => (
-                    <button
-                      key={userGroup._id}
-                      type="button"
-                      className="dropdown-item"
-                      onClick={() => onClickAddExistingUserGroupButtonHandler(userGroup)}
-                    >
-                      {userGroup.name}
-                    </button>
-                  ))
-                }
-                <div className="dropdown-divider"></div>
-              </>
-            )
-          }
+        <div className="dropdown-menu">
+          {selectableUserGroups != null && selectableUserGroups.length > 0 && (
+            <>
+              {selectableUserGroups.map((userGroup) => (
+                <button
+                  key={userGroup._id}
+                  type="button"
+                  className="dropdown-item"
+                  onClick={() =>
+                    onClickAddExistingUserGroupButtonHandler(userGroup)
+                  }
+                >
+                  {userGroup.name}
+                </button>
+              ))}
+              <div className="dropdown-divider"></div>
+            </>
+          )}
 
 
           <button
           <button
             className="dropdown-item"
             className="dropdown-item"
             type="button"
             type="button"
             onClick={() => onClickCreateUserGroupButtonHandler()}
             onClick={() => onClickCreateUserGroupButtonHandler()}
-          >{t('admin:user_group_management.create_group')}
+          >
+            {t('admin:user_group_management.create_group')}
           </button>
           </button>
         </div>
         </div>
       </div>
       </div>

+ 83 - 60
apps/app/src/client/components/Admin/UserGroup/UserGroupForm.tsx

@@ -1,32 +1,42 @@
 import type { FC } from 'react';
 import type { FC } from 'react';
 import React, { useCallback, useEffect, useState } from 'react';
 import React, { useCallback, useEffect, useState } from 'react';
-
 import type { IUserGroupHasId } from '@growi/core';
 import type { IUserGroupHasId } from '@growi/core';
 import { format as dateFnsFormat } from 'date-fns/format';
 import { format as dateFnsFormat } from 'date-fns/format';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 
 
 type Props = {
 type Props = {
-  userGroup: IUserGroupHasId,
-  parentUserGroup?: IUserGroupHasId,
-  selectableParentUserGroups?: IUserGroupHasId[],
+  userGroup: IUserGroupHasId;
+  parentUserGroup?: IUserGroupHasId;
+  selectableParentUserGroups?: IUserGroupHasId[];
   submitButtonLabel: string;
   submitButtonLabel: string;
-  onSubmit: (targetGroup: IUserGroupHasId, userGroupData: Partial<IUserGroupHasId>) => Promise<void>
-  isExternalGroup?: boolean
+  onSubmit: (
+    targetGroup: IUserGroupHasId,
+    userGroupData: Partial<IUserGroupHasId>,
+  ) => Promise<void>;
+  isExternalGroup?: boolean;
 };
 };
 
 
 export const UserGroupForm: FC<Props> = (props: Props) => {
 export const UserGroupForm: FC<Props> = (props: Props) => {
-
   const { t } = useTranslation('admin');
   const { t } = useTranslation('admin');
 
 
   const {
   const {
-    userGroup, parentUserGroup, selectableParentUserGroups, submitButtonLabel, onSubmit, isExternalGroup = false,
+    userGroup,
+    parentUserGroup,
+    selectableParentUserGroups,
+    submitButtonLabel,
+    onSubmit,
+    isExternalGroup = false,
   } = props;
   } = props;
   /*
   /*
    * State
    * State
    */
    */
   const [currentName, setName] = useState<string>(userGroup.name);
   const [currentName, setName] = useState<string>(userGroup.name);
-  const [currentDescription, setDescription] = useState<string>(userGroup.description);
-  const [selectedParent, setSelectedParent] = useState<IUserGroupHasId | undefined>();
+  const [currentDescription, setDescription] = useState<string>(
+    userGroup.description,
+  );
+  const [selectedParent, setSelectedParent] = useState<
+    IUserGroupHasId | undefined
+  >();
   /*
   /*
    * Function
    * Function
    */
    */
@@ -38,48 +48,57 @@ export const UserGroupForm: FC<Props> = (props: Props) => {
     setDescription(e.target.value);
     setDescription(e.target.value);
   }, []);
   }, []);
 
 
-  const onChangeParentButtonHandler = useCallback((userGroup: IUserGroupHasId) => {
-    if (userGroup._id !== selectedParent?._id) {
-      setSelectedParent(userGroup);
-    }
-  }, [selectedParent, setSelectedParent]);
+  const onChangeParentButtonHandler = useCallback(
+    (userGroup: IUserGroupHasId) => {
+      if (userGroup._id !== selectedParent?._id) {
+        setSelectedParent(userGroup);
+      }
+    },
+    [selectedParent],
+  );
 
 
   useEffect(() => {
   useEffect(() => {
     setSelectedParent(parentUserGroup);
     setSelectedParent(parentUserGroup);
   }, [parentUserGroup]);
   }, [parentUserGroup]);
 
 
-  const isSelectableParentUserGroups = selectableParentUserGroups != null && selectableParentUserGroups.length > 0;
+  const isSelectableParentUserGroups =
+    selectableParentUserGroups != null && selectableParentUserGroups.length > 0;
 
 
   const isChildUserGroup = parentUserGroup !== undefined;
   const isChildUserGroup = parentUserGroup !== undefined;
-  const messageAtReleaseParentGroup = isChildUserGroup ? t('user_group_management.release_parent_group') : t('user_group_management.select_parent_group');
+  const messageAtReleaseParentGroup = isChildUserGroup
+    ? t('user_group_management.release_parent_group')
+    : t('user_group_management.select_parent_group');
 
 
   return (
   return (
-    <form onSubmit={(e) => {
-      e.preventDefault();
-      onSubmit(props.userGroup, {
-        name: currentName,
-        description: currentDescription,
-        parent: selectedParent,
-      });
-    }}
+    <form
+      onSubmit={(e) => {
+        e.preventDefault();
+        onSubmit(props.userGroup, {
+          name: currentName,
+          description: currentDescription,
+          parent: selectedParent,
+        });
+      }}
     >
     >
-
       <fieldset>
       <fieldset>
-        <h2 className="admin-setting-header">{t('user_group_management.basic_info')}</h2>
-        {isExternalGroup
-        && (
+        <h2 className="admin-setting-header">
+          {t('user_group_management.basic_info')}
+        </h2>
+        {isExternalGroup && (
           <div className="mb-3">
           <div className="mb-3">
-            <small className="text-muted">{t('external_user_group.only_description_edit_allowed')}</small>
+            <small className="text-muted">
+              {t('external_user_group.only_description_edit_allowed')}
+            </small>
+          </div>
+        )}
+        {userGroup?.createdAt != null && (
+          <div className="row mb-3">
+            <p className="col-md-2 col-form-label">{t('Created')}</p>
+            <p className="col-md-6 my-auto">
+              {dateFnsFormat(userGroup.createdAt, 'yyyy-MM-dd')}
+            </p>
           </div>
           </div>
         )}
         )}
-        {
-          userGroup?.createdAt != null && (
-            <div className="row mb-3">
-              <p className="col-md-2 col-form-label">{t('Created')}</p>
-              <p className="col-md-6 my-auto">{dateFnsFormat(userGroup.createdAt, 'yyyy-MM-dd')}</p>
-            </div>
-          )
-        }
 
 
         <div className="row mb-3">
         <div className="row mb-3">
           <label htmlFor="name" className="col-md-2 col-form-label">
           <label htmlFor="name" className="col-md-2 col-form-label">
@@ -104,7 +123,12 @@ export const UserGroupForm: FC<Props> = (props: Props) => {
             {t('Description')}
             {t('Description')}
           </label>
           </label>
           <div className="col-md-6">
           <div className="col-md-6">
-            <textarea className="form-control" name="description" value={currentDescription} onChange={onChangeDescriptionHandler} />
+            <textarea
+              className="form-control"
+              name="description"
+              value={currentDescription}
+              onChange={onChangeDescriptionHandler}
+            />
           </div>
           </div>
         </div>
         </div>
 
 
@@ -122,33 +146,32 @@ export const UserGroupForm: FC<Props> = (props: Props) => {
             >
             >
               {selectedParent?.name ?? messageAtReleaseParentGroup}
               {selectedParent?.name ?? messageAtReleaseParentGroup}
             </button>
             </button>
-            <div className="dropdown-menu" aria-labelledby="dropdownMenuButton">
-              {
-                isSelectableParentUserGroups && (
-                  <>
-                    {
-                      selectableParentUserGroups.map(userGroup => (
-                        <button
-                          key={userGroup._id}
-                          type="button"
-                          className={`dropdown-item ${selectedParent?._id === userGroup._id ? 'active' : ''}`}
-                          onClick={() => onChangeParentButtonHandler(userGroup)}
-                        >
-                          {userGroup.name}
-                        </button>
-                      ))
-                    }
-                  </>
-                )
-              }
+            <div className="dropdown-menu">
+              {isSelectableParentUserGroups && (
+                <>
+                  {selectableParentUserGroups.map((userGroup) => (
+                    <button
+                      key={userGroup._id}
+                      type="button"
+                      className={`dropdown-item ${selectedParent?._id === userGroup._id ? 'active' : ''}`}
+                      onClick={() => onChangeParentButtonHandler(userGroup)}
+                    >
+                      {userGroup.name}
+                    </button>
+                  ))}
+                </>
+              )}
 
 
               <div className="dropdown-divider" />
               <div className="dropdown-divider" />
 
 
               <button
               <button
                 className="dropdown-item"
                 className="dropdown-item"
                 type="button"
                 type="button"
-                onClick={() => { setSelectedParent(undefined) }}
-              >{t('user_group_management.release_parent_group')}
+                onClick={() => {
+                  setSelectedParent(undefined);
+                }}
+              >
+                {t('user_group_management.release_parent_group')}
               </button>
               </button>
             </div>
             </div>
           </div>
           </div>

+ 45 - 39
apps/app/src/client/components/Admin/UserGroup/UserGroupModal.tsx

@@ -1,29 +1,29 @@
 import type { FC } from 'react';
 import type { FC } from 'react';
-import React, {
-  useState, useEffect, useCallback, useMemo,
-} from 'react';
-
-import type { Ref, IUserGroup, IUserGroupHasId } from '@growi/core';
+import React, { useCallback, useEffect, useMemo, useState } from 'react';
+import type { IUserGroup, IUserGroupHasId, Ref } from '@growi/core';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
-import {
-  Modal, ModalHeader, ModalBody, ModalFooter,
-} from 'reactstrap';
+import { Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
 
 
 type Props = {
 type Props = {
-  userGroup?: IUserGroupHasId,
-  buttonLabel?: string,
-  onClickSubmit?: (userGroupData: Partial<IUserGroupHasId>) => Promise<IUserGroupHasId | void>
-  isShow?: boolean
-  onHide?: () => Promise<void> | void
-  isExternalGroup?: boolean
+  userGroup?: IUserGroupHasId;
+  buttonLabel?: string;
+  onClickSubmit?: (
+    userGroupData: Partial<IUserGroupHasId>,
+  ) => Promise<IUserGroupHasId | void>;
+  isShow?: boolean;
+  onHide?: () => Promise<void> | void;
+  isExternalGroup?: boolean;
 };
 };
 
 
 const UserGroupModalSubstance: FC<Props> = (props: Props) => {
 const UserGroupModalSubstance: FC<Props> = (props: Props) => {
-
   const { t } = useTranslation('admin');
   const { t } = useTranslation('admin');
 
 
   const {
   const {
-    userGroup, buttonLabel, onClickSubmit, onHide, isExternalGroup = false,
+    userGroup,
+    buttonLabel,
+    onClickSubmit,
+    onHide,
+    isExternalGroup = false,
   } = props;
   } = props;
 
 
   /*
   /*
@@ -45,22 +45,28 @@ const UserGroupModalSubstance: FC<Props> = (props: Props) => {
   }, []);
   }, []);
 
 
   // Memoized user group data for submission
   // Memoized user group data for submission
-  const userGroupData = useMemo(() => ({
-    _id: userGroup?._id,
-    name: currentName,
-    description: currentDescription,
-    parent: currentParent,
-  }), [userGroup?._id, currentName, currentDescription, currentParent]);
-
-  const onSubmitHandler = useCallback(async(e) => {
-    e.preventDefault(); // no reload
-
-    if (onClickSubmit == null) {
-      return;
-    }
+  const userGroupData = useMemo(
+    () => ({
+      _id: userGroup?._id,
+      name: currentName,
+      description: currentDescription,
+      parent: currentParent,
+    }),
+    [userGroup?._id, currentName, currentDescription, currentParent],
+  );
+
+  const onSubmitHandler = useCallback(
+    async (e) => {
+      e.preventDefault(); // no reload
 
 
-    await onClickSubmit(userGroupData);
-  }, [onClickSubmit, userGroupData]);
+      if (onClickSubmit == null) {
+        return;
+      }
+
+      await onClickSubmit(userGroupData);
+    },
+    [onClickSubmit, userGroupData],
+  );
 
 
   // componentDidMount
   // componentDidMount
   useEffect(() => {
   useEffect(() => {
@@ -98,12 +104,15 @@ const UserGroupModalSubstance: FC<Props> = (props: Props) => {
           <label htmlFor="description" className="form-label">
           <label htmlFor="description" className="form-label">
             {t('Description')}
             {t('Description')}
           </label>
           </label>
-          <textarea className="form-control" name="description" value={currentDescription} onChange={onChangeDescriptionHandler} />
+          <textarea
+            className="form-control"
+            name="description"
+            value={currentDescription}
+            onChange={onChangeDescriptionHandler}
+          />
           {isExternalGroup && (
           {isExternalGroup && (
             <p className="form-text text-muted">
             <p className="form-text text-muted">
-              <small>
-                {t('external_user_group.description_form_detail')}
-              </small>
+              <small>{t('external_user_group.description_form_detail')}</small>
             </p>
             </p>
           )}
           )}
         </div>
         </div>
@@ -111,7 +120,6 @@ const UserGroupModalSubstance: FC<Props> = (props: Props) => {
         {/* TODO 90732: Add a drop-down to show selectable parents */}
         {/* TODO 90732: Add a drop-down to show selectable parents */}
 
 
         {/* TODO 85462: Add a note that "if you change the parent, the offspring will also be moved together */}
         {/* TODO 85462: Add a note that "if you change the parent, the offspring will also be moved together */}
-
       </ModalBody>
       </ModalBody>
 
 
       <ModalFooter>
       <ModalFooter>
@@ -130,9 +138,7 @@ export const UserGroupModal: FC<Props> = (props: Props) => {
 
 
   return (
   return (
     <Modal className="modal-md" isOpen={isShow} toggle={onHide}>
     <Modal className="modal-md" isOpen={isShow} toggle={onHide}>
-      {isShow && (
-        <UserGroupModalSubstance {...props} />
-      )}
+      {isShow && <UserGroupModalSubstance {...props} />}
     </Modal>
     </Modal>
   );
   );
 };
 };

+ 165 - 109
apps/app/src/client/components/Admin/UserGroup/UserGroupPage.tsx

@@ -1,25 +1,41 @@
 import type { FC } from 'react';
 import type { FC } from 'react';
-import React, { useState, useCallback } from 'react';
-
+import React, { useCallback, useState } from 'react';
+import dynamic from 'next/dynamic';
 import {
 import {
-  GroupType, getIdForRef, type IGrantedGroup, type IUserGroup, type IUserGroupHasId,
+  GroupType,
+  getIdForRef,
+  type IGrantedGroup,
+  type IUserGroup,
+  type IUserGroupHasId,
 } from '@growi/core';
 } from '@growi/core';
 import { useAtomValue } from 'jotai';
 import { useAtomValue } from 'jotai';
-import dynamic from 'next/dynamic';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 
 
 import { apiv3Delete, apiv3Post, apiv3Put } from '~/client/util/apiv3-client';
 import { apiv3Delete, apiv3Post, apiv3Put } from '~/client/util/apiv3-client';
-import { toastSuccess, toastError } from '~/client/util/toastr';
+import { toastError, toastSuccess } from '~/client/util/toastr';
 import { ExternalGroupManagement } from '~/features/external-user-group/client/components/ExternalUserGroup/ExternalUserGroupManagement';
 import { ExternalGroupManagement } from '~/features/external-user-group/client/components/ExternalUserGroup/ExternalUserGroupManagement';
 import { useSWRxExternalUserGroupList } from '~/features/external-user-group/client/stores/external-user-group';
 import { useSWRxExternalUserGroupList } from '~/features/external-user-group/client/stores/external-user-group';
 import type { PageActionOnGroupDelete } from '~/interfaces/user-group';
 import type { PageActionOnGroupDelete } from '~/interfaces/user-group';
 import { isAclEnabledAtom } from '~/states/server-configurations';
 import { isAclEnabledAtom } from '~/states/server-configurations';
-import { useSWRxUserGroupList, useSWRxChildUserGroupList, useSWRxUserGroupRelationList } from '~/stores/user-group';
-
-
-const UserGroupDeleteModal = dynamic(() => import('./UserGroupDeleteModal').then(mod => mod.UserGroupDeleteModal), { ssr: false });
-const UserGroupModal = dynamic(() => import('./UserGroupModal').then(mod => mod.UserGroupModal), { ssr: false });
-const UserGroupTable = dynamic(() => import('./UserGroupTable').then(mod => mod.UserGroupTable), { ssr: false });
+import {
+  useSWRxChildUserGroupList,
+  useSWRxUserGroupList,
+  useSWRxUserGroupRelationList,
+} from '~/stores/user-group';
+
+const UserGroupDeleteModal = dynamic(
+  () =>
+    import('./UserGroupDeleteModal').then((mod) => mod.UserGroupDeleteModal),
+  { ssr: false },
+);
+const UserGroupModal = dynamic(
+  () => import('./UserGroupModal').then((mod) => mod.UserGroupModal),
+  { ssr: false },
+);
+const UserGroupTable = dynamic(
+  () => import('./UserGroupTable').then((mod) => mod.UserGroupTable),
+  { ssr: false },
+);
 
 
 export const UserGroupPage: FC = () => {
 export const UserGroupPage: FC = () => {
   const { t } = useTranslation();
   const { t } = useTranslation();
@@ -29,27 +45,36 @@ export const UserGroupPage: FC = () => {
   /*
   /*
    * Fetch
    * Fetch
    */
    */
-  const { data: userGroupList, mutate: mutateUserGroups } = useSWRxUserGroupList();
+  const { data: userGroupList, mutate: mutateUserGroups } =
+    useSWRxUserGroupList();
   const { data: externalUserGroupList } = useSWRxExternalUserGroupList();
   const { data: externalUserGroupList } = useSWRxExternalUserGroupList();
   const userGroups = userGroupList != null ? userGroupList : [];
   const userGroups = userGroupList != null ? userGroupList : [];
   const userGroupsForDeleteModal: IGrantedGroup[] = userGroups.map((group) => {
   const userGroupsForDeleteModal: IGrantedGroup[] = userGroups.map((group) => {
     return { item: group, type: GroupType.userGroup };
     return { item: group, type: GroupType.userGroup };
   });
   });
-  const externalUserGroupsForDeleteModal: IGrantedGroup[] = externalUserGroupList != null ? externalUserGroupList.map((group) => {
-    return { item: group, type: GroupType.externalUserGroup };
-  }) : [];
-  const userGroupIds = userGroups.map(group => group._id);
-
-  const { data: userGroupRelationList } = useSWRxUserGroupRelationList(userGroupIds);
-  const userGroupRelations = userGroupRelationList != null ? userGroupRelationList : [];
+  const externalUserGroupsForDeleteModal: IGrantedGroup[] =
+    externalUserGroupList != null
+      ? externalUserGroupList.map((group) => {
+          return { item: group, type: GroupType.externalUserGroup };
+        })
+      : [];
+  const userGroupIds = userGroups.map((group) => group._id);
+
+  const { data: userGroupRelationList } =
+    useSWRxUserGroupRelationList(userGroupIds);
+  const userGroupRelations =
+    userGroupRelationList != null ? userGroupRelationList : [];
 
 
   const { data: childUserGroupsList } = useSWRxChildUserGroupList(userGroupIds);
   const { data: childUserGroupsList } = useSWRxChildUserGroupList(userGroupIds);
-  const childUserGroups = childUserGroupsList != null ? childUserGroupsList.childUserGroups : [];
+  const childUserGroups =
+    childUserGroupsList != null ? childUserGroupsList.childUserGroups : [];
 
 
   /*
   /*
    * State
    * State
    */
    */
-  const [selectedUserGroup, setSelectedUserGroup] = useState<IUserGroupHasId | undefined>(undefined); // not null but undefined (to use defaultProps in UserGroupDeleteModal)
+  const [selectedUserGroup, setSelectedUserGroup] = useState<
+    IUserGroupHasId | undefined
+  >(undefined); // not null but undefined (to use defaultProps in UserGroupDeleteModal)
   const [isCreateModalShown, setCreateModalShown] = useState<boolean>(false);
   const [isCreateModalShown, setCreateModalShown] = useState<boolean>(false);
   const [isUpdateModalShown, setUpdateModalShown] = useState<boolean>(false);
   const [isUpdateModalShown, setUpdateModalShown] = useState<boolean>(false);
   const [isDeleteModalShown, setDeleteModalShown] = useState<boolean>(false);
   const [isDeleteModalShown, setDeleteModalShown] = useState<boolean>(false);
@@ -59,123 +84,152 @@ export const UserGroupPage: FC = () => {
    */
    */
   const showCreateModal = useCallback(() => {
   const showCreateModal = useCallback(() => {
     setCreateModalShown(true);
     setCreateModalShown(true);
-  }, [setCreateModalShown]);
+  }, []);
 
 
   const hideCreateModal = useCallback(() => {
   const hideCreateModal = useCallback(() => {
     setCreateModalShown(false);
     setCreateModalShown(false);
-  }, [setCreateModalShown]);
+  }, []);
 
 
   const showUpdateModal = useCallback((group: IUserGroupHasId) => {
   const showUpdateModal = useCallback((group: IUserGroupHasId) => {
     setUpdateModalShown(true);
     setUpdateModalShown(true);
     setSelectedUserGroup(group);
     setSelectedUserGroup(group);
-  }, [setUpdateModalShown]);
+  }, []);
 
 
   const hideUpdateModal = useCallback(() => {
   const hideUpdateModal = useCallback(() => {
     setUpdateModalShown(false);
     setUpdateModalShown(false);
     setSelectedUserGroup(undefined);
     setSelectedUserGroup(undefined);
-  }, [setUpdateModalShown]);
+  }, []);
 
 
-  const syncUserGroupAndRelations = useCallback(async() => {
+  const syncUserGroupAndRelations = useCallback(async () => {
     try {
     try {
       await mutateUserGroups();
       await mutateUserGroups();
-    }
-    catch (err) {
+    } catch (err) {
       toastError(err);
       toastError(err);
     }
     }
   }, [mutateUserGroups]);
   }, [mutateUserGroups]);
 
 
-  const showDeleteModal = useCallback(async(group: IUserGroupHasId) => {
-    try {
-      await syncUserGroupAndRelations();
+  const showDeleteModal = useCallback(
+    async (group: IUserGroupHasId) => {
+      try {
+        await syncUserGroupAndRelations();
 
 
-      setSelectedUserGroup(group);
-      setDeleteModalShown(true);
-    }
-    catch (err) {
-      toastError(err);
-    }
-  }, [syncUserGroupAndRelations]);
+        setSelectedUserGroup(group);
+        setDeleteModalShown(true);
+      } catch (err) {
+        toastError(err);
+      }
+    },
+    [syncUserGroupAndRelations],
+  );
 
 
   const hideDeleteModal = useCallback(() => {
   const hideDeleteModal = useCallback(() => {
     setSelectedUserGroup(undefined);
     setSelectedUserGroup(undefined);
     setDeleteModalShown(false);
     setDeleteModalShown(false);
   }, []);
   }, []);
 
 
-  const createUserGroup = useCallback(async(userGroupData: IUserGroup) => {
-    try {
-      await apiv3Post('/user-groups', {
-        name: userGroupData.name,
-        description: userGroupData.description,
-      });
-
-      toastSuccess(t('toaster.update_successed', { target: t('UserGroup'), ns: 'commons' }));
-
-      // mutate
-      await mutateUserGroups();
-
-      hideCreateModal();
-    }
-    catch (err) {
-      toastError(err);
-    }
-  }, [t, mutateUserGroups, hideCreateModal]);
-
-  const updateUserGroup = useCallback(async(userGroupData: IUserGroupHasId) => {
-    try {
-      await apiv3Put(`/user-groups/${userGroupData._id}`, {
-        name: userGroupData.name,
-        description: userGroupData.description,
-      });
-
-      toastSuccess(t('toaster.update_successed', { target: t('UserGroup'), ns: 'commons' }));
-
-      // mutate
-      await mutateUserGroups();
-
-      hideUpdateModal();
-    }
-    catch (err) {
-      toastError(err);
-    }
-  }, [t, mutateUserGroups, hideUpdateModal]);
-
-  const deleteUserGroupById = useCallback(async(deleteGroupId: string, actionName: PageActionOnGroupDelete, transferToUserGroup: IGrantedGroup | null) => {
-    const transferToUserGroupId = transferToUserGroup != null ? getIdForRef(transferToUserGroup.item) : null;
-    const transferToUserGroupType = transferToUserGroup != null ? transferToUserGroup.type : null;
-    try {
-      await apiv3Delete(`/user-groups/${deleteGroupId}`, {
-        actionName,
-        transferToUserGroupId,
-        transferToUserGroupType,
-      });
-
-      // sync
-      await mutateUserGroups();
+  const createUserGroup = useCallback(
+    async (userGroupData: IUserGroup) => {
+      try {
+        await apiv3Post('/user-groups', {
+          name: userGroupData.name,
+          description: userGroupData.description,
+        });
+
+        toastSuccess(
+          t('toaster.update_successed', {
+            target: t('UserGroup'),
+            ns: 'commons',
+          }),
+        );
+
+        // mutate
+        await mutateUserGroups();
+
+        hideCreateModal();
+      } catch (err) {
+        toastError(err);
+      }
+    },
+    [t, mutateUserGroups, hideCreateModal],
+  );
 
 
-      setSelectedUserGroup(undefined);
-      setDeleteModalShown(false);
+  const updateUserGroup = useCallback(
+    async (userGroupData: IUserGroupHasId) => {
+      try {
+        await apiv3Put(`/user-groups/${userGroupData._id}`, {
+          name: userGroupData.name,
+          description: userGroupData.description,
+        });
+
+        toastSuccess(
+          t('toaster.update_successed', {
+            target: t('UserGroup'),
+            ns: 'commons',
+          }),
+        );
+
+        // mutate
+        await mutateUserGroups();
+
+        hideUpdateModal();
+      } catch (err) {
+        toastError(err);
+      }
+    },
+    [t, mutateUserGroups, hideUpdateModal],
+  );
 
 
-      toastSuccess(`Deleted ${selectedUserGroup?.name} group.`);
-    }
-    catch (err) {
-      toastError(new Error('Unable to delete the groups'));
-    }
-  }, [mutateUserGroups, selectedUserGroup]);
+  const deleteUserGroupById = useCallback(
+    async (
+      deleteGroupId: string,
+      actionName: PageActionOnGroupDelete,
+      transferToUserGroup: IGrantedGroup | null,
+    ) => {
+      const transferToUserGroupId =
+        transferToUserGroup != null
+          ? getIdForRef(transferToUserGroup.item)
+          : null;
+      const transferToUserGroupType =
+        transferToUserGroup != null ? transferToUserGroup.type : null;
+      try {
+        await apiv3Delete(`/user-groups/${deleteGroupId}`, {
+          actionName,
+          transferToUserGroupId,
+          transferToUserGroupType,
+        });
+
+        // sync
+        await mutateUserGroups();
+
+        setSelectedUserGroup(undefined);
+        setDeleteModalShown(false);
+
+        toastSuccess(`Deleted ${selectedUserGroup?.name} group.`);
+      } catch (err) {
+        toastError(new Error('Unable to delete the groups'));
+      }
+    },
+    [mutateUserGroups, selectedUserGroup],
+  );
 
 
   return (
   return (
     <div data-testid="admin-user-groups">
     <div data-testid="admin-user-groups">
-      <h2 className="border-bottom">{t('admin:user_group_management.user_group_management')}</h2>
-      {
-        isAclEnabled ? (
-          <div className="mb-3">
-            <button type="button" className="btn btn-outline-secondary" onClick={showCreateModal}>
-              {t('admin:user_group_management.create_group')}
-            </button>
-          </div>
-        ) : (
-          t('admin:user_group_management.deny_create_group')
-        )
-      }
+      <h2 className="border-bottom">
+        {t('admin:user_group_management.user_group_management')}
+      </h2>
+      {isAclEnabled ? (
+        <div className="mb-3">
+          <button
+            type="button"
+            className="btn btn-outline-secondary"
+            onClick={showCreateModal}
+          >
+            {t('admin:user_group_management.create_group')}
+          </button>
+        </div>
+      ) : (
+        t('admin:user_group_management.deny_create_group')
+      )}
 
 
       <UserGroupModal
       <UserGroupModal
         buttonLabel={t('Create')}
         buttonLabel={t('Create')}
@@ -203,7 +257,9 @@ export const UserGroupPage: FC = () => {
       />
       />
 
 
       <UserGroupDeleteModal
       <UserGroupDeleteModal
-        userGroups={userGroupsForDeleteModal.concat(externalUserGroupsForDeleteModal)}
+        userGroups={userGroupsForDeleteModal.concat(
+          externalUserGroupsForDeleteModal,
+        )}
         deleteUserGroup={selectedUserGroup}
         deleteUserGroup={selectedUserGroup}
         onDelete={deleteUserGroupById}
         onDelete={deleteUserGroupById}
         isShow={isDeleteModalShown}
         isShow={isDeleteModalShown}

+ 143 - 84
apps/app/src/client/components/Admin/UserGroup/UserGroupTable.tsx

@@ -1,35 +1,39 @@
+import type React from 'react';
 import type { FC, JSX } from 'react';
 import type { FC, JSX } from 'react';
-import React, { useState, useEffect } from 'react';
-
-import type { IUserGroupHasId, IUserGroupRelation, IUserHasId } from '@growi/core';
+import { useEffect, useState } from 'react';
+import Link from 'next/link';
+import type {
+  IUserGroupHasId,
+  IUserGroupRelation,
+  IUserHasId,
+} from '@growi/core';
 import { format as dateFnsFormat } from 'date-fns/format';
 import { format as dateFnsFormat } from 'date-fns/format';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
-import Link from 'next/link';
 
 
 import type { IExternalUserGroupHasId } from '~/features/external-user-group/interfaces/external-user-group';
 import type { IExternalUserGroupHasId } from '~/features/external-user-group/interfaces/external-user-group';
 
 
-
 import styles from './UserGroupTable.module.scss';
 import styles from './UserGroupTable.module.scss';
 
 
 const userGroupEditLinkStyle = styles['user-group-edit-link'] ?? '';
 const userGroupEditLinkStyle = styles['user-group-edit-link'] ?? '';
 
 
-
 type Props = {
 type Props = {
-  headerLabel?: string,
-  userGroups: IUserGroupHasId[],
-  userGroupRelations: IUserGroupRelation[],
-  childUserGroups: IUserGroupHasId[],
-  isAclEnabled: boolean,
-  onEdit?: (userGroup: IUserGroupHasId) => void | Promise<void>,
-  onRemove?: (userGroup: IUserGroupHasId) => void | Promise<void>,
-  onDelete?: (userGroup: IUserGroupHasId) => void | Promise<void>,
-  isExternalGroup?: boolean
+  headerLabel?: string;
+  userGroups: IUserGroupHasId[];
+  userGroupRelations: IUserGroupRelation[];
+  childUserGroups: IUserGroupHasId[];
+  isAclEnabled: boolean;
+  onEdit?: (userGroup: IUserGroupHasId) => void | Promise<void>;
+  onRemove?: (userGroup: IUserGroupHasId) => void | Promise<void>;
+  onDelete?: (userGroup: IUserGroupHasId) => void | Promise<void>;
+  isExternalGroup?: boolean;
 };
 };
 
 
 /*
 /*
  * Utility
  * Utility
  */
  */
-const generateGroupIdToUsersMap = (userGroupRelations: IUserGroupRelation[]): Record<string, Partial<IUserHasId>[]> => {
+const generateGroupIdToUsersMap = (
+  userGroupRelations: IUserGroupRelation[],
+): Record<string, Partial<IUserHasId>[]> => {
   const userGroupMap = {};
   const userGroupMap = {};
   userGroupRelations.forEach((relation) => {
   userGroupRelations.forEach((relation) => {
     const group = relation.relatedGroup as string; // must be an id of related group
     const group = relation.relatedGroup as string; // must be an id of related group
@@ -44,7 +48,9 @@ const generateGroupIdToUsersMap = (userGroupRelations: IUserGroupRelation[]): Re
   return userGroupMap;
   return userGroupMap;
 };
 };
 
 
-const generateGroupIdToChildGroupsMap = (childUserGroups: IUserGroupHasId[]): Record<string, IUserGroupHasId[]> => {
+const generateGroupIdToChildGroupsMap = (
+  childUserGroups: IUserGroupHasId[],
+): Record<string, IUserGroupHasId[]> => {
   const map = {};
   const map = {};
   childUserGroups.forEach((group) => {
   childUserGroups.forEach((group) => {
     const parentId = group.parent as string; // must be an id
     const parentId = group.parent as string; // must be an id
@@ -60,9 +66,9 @@ const generateGroupIdToChildGroupsMap = (childUserGroups: IUserGroupHasId[]): Re
 };
 };
 
 
 type UserGroupEditLinkProps = {
 type UserGroupEditLinkProps = {
-  group:IUserGroupHasId,
-  isExternalGroup:boolean,
-}
+  group: IUserGroupHasId;
+  isExternalGroup: boolean;
+};
 
 
 const UserGroupEditLink = (props: UserGroupEditLinkProps): JSX.Element => {
 const UserGroupEditLink = (props: UserGroupEditLinkProps): JSX.Element => {
   return (
   return (
@@ -72,7 +78,9 @@ const UserGroupEditLink = (props: UserGroupEditLinkProps): JSX.Element => {
     >
     >
       <span className="material-symbols-outlined pe-2 pt-2">group</span>
       <span className="material-symbols-outlined pe-2 pt-2">group</span>
       <span>{props.group.name}</span>
       <span>{props.group.name}</span>
-      <span className="grw-edit-icon material-symbols-outlined px-2 py-0">edit</span>
+      <span className="grw-edit-icon material-symbols-outlined px-2 py-0">
+        edit
+      </span>
     </Link>
     </Link>
   );
   );
 };
 };
@@ -93,20 +101,26 @@ export const UserGroupTable: FC<Props> = ({
   /*
   /*
    * State
    * State
    */
    */
-  const [groupIdToUsersMap, setGroupIdToUsersMap] = useState(generateGroupIdToUsersMap(userGroupRelations));
-  const [groupIdToChildGroupsMap, setGroupIdToChildGroupsMap] = useState(generateGroupIdToChildGroupsMap(childUserGroups));
+  const [groupIdToUsersMap, setGroupIdToUsersMap] = useState(
+    generateGroupIdToUsersMap(userGroupRelations),
+  );
+  const [groupIdToChildGroupsMap, setGroupIdToChildGroupsMap] = useState(
+    generateGroupIdToChildGroupsMap(childUserGroups),
+  );
 
 
   /*
   /*
    * Function
    * Function
    */
    */
-  const findUserGroup = (e: React.ChangeEvent<HTMLInputElement>): IUserGroupHasId | undefined => {
+  const findUserGroup = (
+    e: React.ChangeEvent<HTMLInputElement>,
+  ): IUserGroupHasId | undefined => {
     const groupId = e.target.getAttribute('data-user-group-id');
     const groupId = e.target.getAttribute('data-user-group-id');
     return userGroups.find((group) => {
     return userGroups.find((group) => {
       return group._id === groupId;
       return group._id === groupId;
     });
     });
   };
   };
 
 
-  const onClickEdit = async(e) => {
+  const onClickEdit = async (e) => {
     if (onEdit == null) {
     if (onEdit == null) {
       return;
       return;
     }
     }
@@ -119,7 +133,7 @@ export const UserGroupTable: FC<Props> = ({
     onEdit(userGroup);
     onEdit(userGroup);
   };
   };
 
 
-  const onClickRemove = async(e) => {
+  const onClickRemove = async (e) => {
     if (onRemove == null) {
     if (onRemove == null) {
       return;
       return;
     }
     }
@@ -132,13 +146,13 @@ export const UserGroupTable: FC<Props> = ({
     try {
     try {
       await onRemove(userGroup);
       await onRemove(userGroup);
       userGroup.parent = null;
       userGroup.parent = null;
-    }
-    catch {
+    } catch {
       //
       //
     }
     }
   };
   };
 
 
-  const onClickDelete = (e) => { // no preventDefault
+  const onClickDelete = (e) => {
+    // no preventDefault
     if (onDelete == null) {
     if (onDelete == null) {
       return;
       return;
     }
     }
@@ -156,7 +170,9 @@ export const UserGroupTable: FC<Props> = ({
    */
    */
   useEffect(() => {
   useEffect(() => {
     setGroupIdToUsersMap(generateGroupIdToUsersMap(userGroupRelations));
     setGroupIdToUsersMap(generateGroupIdToUsersMap(userGroupRelations));
-    setGroupIdToChildGroupsMap(generateGroupIdToChildGroupsMap(childUserGroups));
+    setGroupIdToChildGroupsMap(
+      generateGroupIdToChildGroupsMap(childUserGroups),
+    );
   }, [userGroupRelations, childUserGroups]);
   }, [userGroupRelations, childUserGroups]);
 
 
   return (
   return (
@@ -181,77 +197,120 @@ export const UserGroupTable: FC<Props> = ({
 
 
             return (
             return (
               <tr key={group._id}>
               <tr key={group._id}>
-                {isExternalGroup && <td>{(group as IExternalUserGroupHasId).provider}</td>}
-                {isAclEnabled
-                  ? (
-                    <td>
-                      <UserGroupEditLink group={group} isExternalGroup={isExternalGroup} />
-                    </td>
-                  )
-                  : (
-                    <td>{group.name}</td>
-                  )
-                }
+                {isExternalGroup && (
+                  <td>{(group as IExternalUserGroupHasId).provider}</td>
+                )}
+                {isAclEnabled ? (
+                  <td>
+                    <UserGroupEditLink
+                      group={group}
+                      isExternalGroup={isExternalGroup}
+                    />
+                  </td>
+                ) : (
+                  <td>{group.name}</td>
+                )}
                 <td>{group.description}</td>
                 <td>{group.description}</td>
                 <td>
                 <td>
                   <ul className="list-inline">
                   <ul className="list-inline">
-                    {users != null && users.map((user) => {
-                      return <li key={user._id} className="list-inline-item badge text-bg-warning">{user.username}</li>;
-                    })}
+                    {users != null &&
+                      users.map((user) => {
+                        return (
+                          <li
+                            key={user._id}
+                            className="list-inline-item badge text-bg-warning"
+                          >
+                            {user.username}
+                          </li>
+                        );
+                      })}
                   </ul>
                   </ul>
                 </td>
                 </td>
                 <td>
                 <td>
                   <ul className="list-inline">
                   <ul className="list-inline">
-                    {groupIdToChildGroupsMap[group._id] != null && groupIdToChildGroupsMap[group._id].map((group) => {
-                      return (
-                        <li key={group._id} className="list-inline-item badge text-bg-success">
-                          {isAclEnabled
-                            ? (
-                              <Link href={`/admin/user-group-detail/${group._id}?isExternalGroup=${isExternalGroup}`}>{group.name}</Link>
-                            )
-                            : (
+                    {groupIdToChildGroupsMap[group._id] != null &&
+                      groupIdToChildGroupsMap[group._id].map((group) => {
+                        return (
+                          <li
+                            key={group._id}
+                            className="list-inline-item badge text-bg-success"
+                          >
+                            {isAclEnabled ? (
+                              <Link
+                                href={`/admin/user-group-detail/${group._id}?isExternalGroup=${isExternalGroup}`}
+                              >
+                                {group.name}
+                              </Link>
+                            ) : (
                               <p>{group.name}</p>
                               <p>{group.name}</p>
-                            )
-                          }
-                        </li>
-                      );
-                    })}
+                            )}
+                          </li>
+                        );
+                      })}
                   </ul>
                   </ul>
                 </td>
                 </td>
                 <td>{dateFnsFormat(group.createdAt, 'yyyy-MM-dd')}</td>
                 <td>{dateFnsFormat(group.createdAt, 'yyyy-MM-dd')}</td>
-                {isAclEnabled
-                  ? (
-                    <td>
-                      <div className="btn-group admin-group-menu">
+                {isAclEnabled ? (
+                  <td>
+                    <div className="btn-group admin-group-menu">
+                      <button
+                        type="button"
+                        id={`admin-group-menu-button-${group._id}`}
+                        className="btn btn-outline-secondary btn-sm dropdown-toggle"
+                        data-bs-toggle="dropdown"
+                      >
+                        <span className="material-symbols-outlined fs-5">
+                          settings
+                        </span>
+                      </button>
+                      <div
+                        className="dropdown-menu"
+                        role="menu"
+                        aria-labelledby={`admin-group-menu-button-${group._id}`}
+                      >
                         <button
                         <button
+                          className="dropdown-item"
                           type="button"
                           type="button"
-                          id={`admin-group-menu-button-${group._id}`}
-                          className="btn btn-outline-secondary btn-sm dropdown-toggle"
-                          data-bs-toggle="dropdown"
+                          onClick={onClickEdit}
+                          data-user-group-id={group._id}
                         >
                         >
-                          <span className="material-symbols-outlined fs-5">settings</span>
+                          <span className="material-symbols-outlined me-1">
+                            edit_square
+                          </span>{' '}
+                          {t('Edit')}
                         </button>
                         </button>
-                        <div className="dropdown-menu" role="menu" aria-labelledby={`admin-group-menu-button-${group._id}`}>
-                          <button className="dropdown-item" type="button" role="button" onClick={onClickEdit} data-user-group-id={group._id}>
-                            <span className="material-symbols-outlined me-1">edit_square</span> {t('Edit')}
-                          </button>
-                          {onRemove != null
-                          && (
-                            <button className="dropdown-item" type="button" role="button" onClick={onClickRemove} data-user-group-id={group._id}>
-                              <span className="material-symbols-outlined me-1">group_remove</span> {t('admin:user_group_management.remove_child_group')}
-                            </button>
-                          )}
-                          <button className="dropdown-item" type="button" role="button" onClick={onClickDelete} data-user-group-id={group._id}>
-                            <span className="material-symbols-outlined text-danger">delete_forever</span> {t('Delete')}
+                        {onRemove != null && (
+                          <button
+                            className="dropdown-item"
+                            type="button"
+                            onClick={onClickRemove}
+                            data-user-group-id={group._id}
+                          >
+                            <span className="material-symbols-outlined me-1">
+                              group_remove
+                            </span>{' '}
+                            {t(
+                              'admin:user_group_management.remove_child_group',
+                            )}
                           </button>
                           </button>
-                        </div>
+                        )}
+                        <button
+                          className="dropdown-item"
+                          type="button"
+                          onClick={onClickDelete}
+                          data-user-group-id={group._id}
+                        >
+                          <span className="material-symbols-outlined text-danger">
+                            delete_forever
+                          </span>{' '}
+                          {t('Delete')}
+                        </button>
                       </div>
                       </div>
-                    </td>
-                  )
-                  : (
-                    <td></td>
-                  )
-                }
+                    </div>
+                  </td>
+                ) : (
+                  <td></td>
+                )}
               </tr>
               </tr>
             );
             );
           })}
           })}

+ 8 - 6
apps/app/src/client/components/Admin/UserGroupDetail/CheckBoxForSerchUserOption.jsx

@@ -1,15 +1,15 @@
-
 import React from 'react';
 import React from 'react';
-
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
 
 
 class CheckBoxForSerchUserOption extends React.Component {
 class CheckBoxForSerchUserOption extends React.Component {
-
   render() {
   render() {
     const { t, option } = this.props;
     const { t, option } = this.props;
     return (
     return (
-      <div className="form-check form-check-info" key={`isAlso${option}Searched`}>
+      <div
+        className="form-check form-check-info"
+        key={`isAlso${option}Searched`}
+      >
         <input
         <input
           type="checkbox"
           type="checkbox"
           id={`isAlso${option}Searched`}
           id={`isAlso${option}Searched`}
@@ -17,13 +17,15 @@ class CheckBoxForSerchUserOption extends React.Component {
           checked={this.props.checked}
           checked={this.props.checked}
           onChange={this.props.onChange}
           onChange={this.props.onChange}
         />
         />
-        <label className="form-label text-capitalize form-check-label ms-3" htmlFor={`isAlso${option}Searched`}>
+        <label
+          className="form-label text-capitalize form-check-label ms-3"
+          htmlFor={`isAlso${option}Searched`}
+        >
           {t('admin:user_group_management.add_modal.enable_option', { option })}
           {t('admin:user_group_management.add_modal.enable_option', { option })}
         </label>
         </label>
       </div>
       </div>
     );
     );
   }
   }
-
 }
 }
 
 
 CheckBoxForSerchUserOption.propTypes = {
 CheckBoxForSerchUserOption.propTypes = {

+ 4 - 6
apps/app/src/client/components/Admin/UserGroupDetail/RadioButtonForSerchUserOption.jsx

@@ -1,11 +1,8 @@
-
 import React from 'react';
 import React from 'react';
-
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
 
 
 const RadioButtonForSerchUserOption = (props) => {
 const RadioButtonForSerchUserOption = (props) => {
-
   const { searchType } = props;
   const { searchType } = props;
   const { t } = useTranslation();
   const { t } = useTranslation();
   return (
   return (
@@ -17,16 +14,17 @@ const RadioButtonForSerchUserOption = (props) => {
         checked={props.checked}
         checked={props.checked}
         onChange={props.onChange}
         onChange={props.onChange}
       />
       />
-      <label className="form-label text-capitalize form-check-label ms-3" htmlFor={`${searchType}Match`}>
+      <label
+        className="form-label text-capitalize form-check-label ms-3"
+        htmlFor={`${searchType}Match`}
+      >
         {t(`admin:user_group_management.add_modal.${searchType}_match`)}
         {t(`admin:user_group_management.add_modal.${searchType}_match`)}
       </label>
       </label>
     </div>
     </div>
   );
   );
 };
 };
 
 
-
 RadioButtonForSerchUserOption.propTypes = {
 RadioButtonForSerchUserOption.propTypes = {
-
   searchType: PropTypes.string.isRequired,
   searchType: PropTypes.string.isRequired,
   checked: PropTypes.bool.isRequired,
   checked: PropTypes.bool.isRequired,
   onChange: PropTypes.func.isRequired,
   onChange: PropTypes.func.isRequired,

+ 84 - 65
apps/app/src/client/components/Admin/UserGroupDetail/UpdateParentConfirmModal.tsx

@@ -1,13 +1,12 @@
 import type { FC } from 'react';
 import type { FC } from 'react';
 import React, { useState } from 'react';
 import React, { useState } from 'react';
-
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
-import {
-  Modal, ModalHeader, ModalBody, ModalFooter,
-} from 'reactstrap';
-
-import { useUpdateUserGroupConfirmModalStatus, useUpdateUserGroupConfirmModalActions } from '~/states/ui/modal/update-user-group-confirm';
+import { Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
 
 
+import {
+  useUpdateUserGroupConfirmModalActions,
+  useUpdateUserGroupConfirmModalStatus,
+} from '~/states/ui/modal/update-user-group-confirm';
 
 
 export const UpdateParentConfirmModal: FC = () => {
 export const UpdateParentConfirmModal: FC = () => {
   const { t } = useTranslation();
   const { t } = useTranslation();
@@ -22,72 +21,92 @@ export const UpdateParentConfirmModal: FC = () => {
     return <></>;
     return <></>;
   }
   }
 
 
-  const {
-    isOpened, targetGroup, updateData, onConfirm,
-  } = modalStatus;
+  const { isOpened, targetGroup, updateData, onConfirm } = modalStatus;
 
 
   return (
   return (
     <Modal className="modal-md" isOpen={isOpened} toggle={closeModal}>
     <Modal className="modal-md" isOpen={isOpened} toggle={closeModal}>
       <ModalHeader tag="h4" toggle={closeModal} className="text-warning">
       <ModalHeader tag="h4" toggle={closeModal} className="text-warning">
-        <span className="material-symbols-outlined">warning</span> {t('admin:user_group_management.update_parent_confirm_modal.header')}
+        <span className="material-symbols-outlined">warning</span>{' '}
+        {t('admin:user_group_management.update_parent_confirm_modal.header')}
       </ModalHeader>
       </ModalHeader>
-      {
-        targetGroup != null && updateData != null ? (
-          <>
-            <ModalBody>
-              <div className="mb-2">
-                <span className="fw-bold">{t('admin:user_group_management.group_name')}</span> : &quot;{targetGroup.name}&quot;
-                <hr />
-                {t('admin:user_group_management.update_parent_confirm_modal.caution_change_parent', { groupName: targetGroup.name })}
-              </div>
-              <div className="text-danger mb-3">
-                <span className="material-symbols-outlined">error</span>
-                {t('admin:user_group_management.update_parent_confirm_modal.danger_message')}
-              </div>
+      {targetGroup != null && updateData != null ? (
+        <>
+          <ModalBody>
+            <div className="mb-2">
+              <span className="fw-bold">
+                {t('admin:user_group_management.group_name')}
+              </span>{' '}
+              : &quot;{targetGroup.name}&quot;
+              <hr />
+              {t(
+                'admin:user_group_management.update_parent_confirm_modal.caution_change_parent',
+                { groupName: targetGroup.name },
+              )}
+            </div>
+            <div className="text-danger mb-3">
+              <span className="material-symbols-outlined">error</span>
+              {t(
+                'admin:user_group_management.update_parent_confirm_modal.danger_message',
+              )}
+            </div>
 
 
-              <div className="form-check form-check-succsess ps-5">
-                <input
-                  className="form-check-input"
-                  name="forceUpdateParents"
-                  id="forceUpdateParents"
-                  type="checkbox"
-                  checked={isForceUpdate}
-                  onChange={() => setForceUpdate(!isForceUpdate)}
-                />
-                <label className="form-label form-check-label" htmlFor="forceUpdateParents">
-                  {t('admin:user_group_management.update_parent_confirm_modal.force_update_parents_label')}
-                  <p className="form-text text-muted mt-0">{t('admin:user_group_management.update_parent_confirm_modal.force_update_parents_description')}</p>
-                </label>
-              </div>
-            </ModalBody>
-            <ModalFooter>
-              <button
-                type="button"
-                className="btn btn-warning"
-                onClick={() => {
-                  onConfirm?.(targetGroup, updateData, isForceUpdate);
-                  closeModal();
-                }}
+            <div className="form-check form-check-succsess ps-5">
+              <input
+                className="form-check-input"
+                name="forceUpdateParents"
+                id="forceUpdateParents"
+                type="checkbox"
+                checked={isForceUpdate}
+                onChange={() => setForceUpdate(!isForceUpdate)}
+              />
+              <label
+                className="form-label form-check-label"
+                htmlFor="forceUpdateParents"
               >
               >
-                {t('Confirm')}
-              </button>
-            </ModalFooter>
-          </>
-        ) : (
-          <>
-            <ModalBody>
-              <div>
-                <span className="text-error">Something went wrong. Please try again.</span>
-              </div>
-            </ModalBody>
-            <ModalFooter>
-              <button type="button" onClick={() => closeModal()} className="btn btn-sm btn-secondary">
-                {t('Cancel')}
-              </button>
-            </ModalFooter>
-          </>
-        )
-      }
+                {t(
+                  'admin:user_group_management.update_parent_confirm_modal.force_update_parents_label',
+                )}
+                <p className="form-text text-muted mt-0">
+                  {t(
+                    'admin:user_group_management.update_parent_confirm_modal.force_update_parents_description',
+                  )}
+                </p>
+              </label>
+            </div>
+          </ModalBody>
+          <ModalFooter>
+            <button
+              type="button"
+              className="btn btn-warning"
+              onClick={() => {
+                onConfirm?.(targetGroup, updateData, isForceUpdate);
+                closeModal();
+              }}
+            >
+              {t('Confirm')}
+            </button>
+          </ModalFooter>
+        </>
+      ) : (
+        <>
+          <ModalBody>
+            <div>
+              <span className="text-error">
+                Something went wrong. Please try again.
+              </span>
+            </div>
+          </ModalBody>
+          <ModalFooter>
+            <button
+              type="button"
+              onClick={() => closeModal()}
+              className="btn btn-sm btn-secondary"
+            >
+              {t('Cancel')}
+            </button>
+          </ModalFooter>
+        </>
+      )}
     </Modal>
     </Modal>
   );
   );
 };
 };

+ 443 - 243
apps/app/src/client/components/Admin/UserGroupDetail/UserGroupDetailPage.tsx

@@ -1,349 +1,540 @@
-import React, {
-  useState, useCallback, useEffect, type JSX,
-} from 'react';
-
+import React, { type JSX, useCallback, useEffect, useState } from 'react';
+import dynamic from 'next/dynamic';
+import Link from 'next/link';
+import { useRouter } from 'next/router';
 import {
 import {
-  GroupType, getIdStringForRef, type IGrantedGroup, type IUserGroup, type IUserGroupHasId,
+  GroupType,
+  getIdStringForRef,
+  type IGrantedGroup,
+  type IUserGroup,
+  type IUserGroupHasId,
 } from '@growi/core';
 } from '@growi/core';
 import { objectIdUtils } from '@growi/core/dist/utils';
 import { objectIdUtils } from '@growi/core/dist/utils';
 import { useAtomValue } from 'jotai';
 import { useAtomValue } from 'jotai';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
-import dynamic from 'next/dynamic';
-import Link from 'next/link';
-import { useRouter } from 'next/router';
 
 
 import {
 import {
-  apiv3Get, apiv3Put, apiv3Delete, apiv3Post,
+  apiv3Delete,
+  apiv3Get,
+  apiv3Post,
+  apiv3Put,
 } from '~/client/util/apiv3-client';
 } from '~/client/util/apiv3-client';
-import { toastSuccess, toastError } from '~/client/util/toastr';
+import { toastError, toastSuccess } from '~/client/util/toastr';
 import type { IExternalUserGroupHasId } from '~/features/external-user-group/interfaces/external-user-group';
 import type { IExternalUserGroupHasId } from '~/features/external-user-group/interfaces/external-user-group';
-import type { PageActionOnGroupDelete, SearchType } from '~/interfaces/user-group';
+import type {
+  PageActionOnGroupDelete,
+  SearchType,
+} from '~/interfaces/user-group';
 import { SearchTypes } from '~/interfaces/user-group';
 import { SearchTypes } from '~/interfaces/user-group';
 import { isAclEnabledAtom } from '~/states/server-configurations';
 import { isAclEnabledAtom } from '~/states/server-configurations';
 import { useUpdateUserGroupConfirmModalActions } from '~/states/ui/modal/update-user-group-confirm';
 import { useUpdateUserGroupConfirmModalActions } from '~/states/ui/modal/update-user-group-confirm';
-import { useSWRxUserGroupPages, useSWRxSelectableParentUserGroups, useSWRxSelectableChildUserGroups } from '~/stores/user-group';
+import {
+  useSWRxSelectableChildUserGroups,
+  useSWRxSelectableParentUserGroups,
+  useSWRxUserGroupPages,
+} from '~/stores/user-group';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
 import {
 import {
   useAncestorUserGroups,
   useAncestorUserGroups,
-  useChildUserGroupList, useUserGroup, useUserGroupRelationList, useUserGroupRelations,
+  useChildUserGroupList,
+  useUserGroup,
+  useUserGroupRelationList,
+  useUserGroupRelations,
 } from './use-user-group-resource';
 } from './use-user-group-resource';
 
 
 import styles from './UserGroupDetailPage.module.scss';
 import styles from './UserGroupDetailPage.module.scss';
 
 
 const logger = loggerFactory('growi:services:AdminCustomizeContainer');
 const logger = loggerFactory('growi:services:AdminCustomizeContainer');
 
 
-const UserGroupPageList = dynamic(() => import('./UserGroupPageList'), { ssr: false });
-const UserGroupUserTable = dynamic(() => import('./UserGroupUserTable').then(mod => mod.UserGroupUserTable), { ssr: false });
-
-const UserGroupUserModal = dynamic(() => import('./UserGroupUserModal'), { ssr: false });
-
-const UserGroupDeleteModal = dynamic(() => import('../UserGroup/UserGroupDeleteModal').then(mod => mod.UserGroupDeleteModal), { ssr: false });
-const UserGroupDropdown = dynamic(() => import('../UserGroup/UserGroupDropdown').then(mod => mod.UserGroupDropdown), { ssr: false });
-const UserGroupForm = dynamic(() => import('../UserGroup/UserGroupForm').then(mod => mod.UserGroupForm), { ssr: false });
-const UserGroupModal = dynamic(() => import('../UserGroup/UserGroupModal').then(mod => mod.UserGroupModal), { ssr: false });
-const UserGroupTable = dynamic(() => import('../UserGroup/UserGroupTable').then(mod => mod.UserGroupTable), { ssr: false });
-const UpdateParentConfirmModal = dynamic(() => import('./UpdateParentConfirmModal').then(mod => mod.UpdateParentConfirmModal), { ssr: false });
-
+const UserGroupPageList = dynamic(() => import('./UserGroupPageList'), {
+  ssr: false,
+});
+const UserGroupUserTable = dynamic(
+  () => import('./UserGroupUserTable').then((mod) => mod.UserGroupUserTable),
+  { ssr: false },
+);
+
+const UserGroupUserModal = dynamic(() => import('./UserGroupUserModal'), {
+  ssr: false,
+});
+
+const UserGroupDeleteModal = dynamic(
+  () =>
+    import('../UserGroup/UserGroupDeleteModal').then(
+      (mod) => mod.UserGroupDeleteModal,
+    ),
+  { ssr: false },
+);
+const UserGroupDropdown = dynamic(
+  () =>
+    import('../UserGroup/UserGroupDropdown').then(
+      (mod) => mod.UserGroupDropdown,
+    ),
+  { ssr: false },
+);
+const UserGroupForm = dynamic(
+  () => import('../UserGroup/UserGroupForm').then((mod) => mod.UserGroupForm),
+  { ssr: false },
+);
+const UserGroupModal = dynamic(
+  () => import('../UserGroup/UserGroupModal').then((mod) => mod.UserGroupModal),
+  { ssr: false },
+);
+const UserGroupTable = dynamic(
+  () => import('../UserGroup/UserGroupTable').then((mod) => mod.UserGroupTable),
+  { ssr: false },
+);
+const UpdateParentConfirmModal = dynamic(
+  () =>
+    import('./UpdateParentConfirmModal').then(
+      (mod) => mod.UpdateParentConfirmModal,
+    ),
+  { ssr: false },
+);
 
 
 type Props = {
 type Props = {
-  userGroupId: string,
-  isExternalGroup: boolean,
-}
+  userGroupId: string;
+  isExternalGroup: boolean;
+};
 
 
 const UserGroupDetailPage = (props: Props): JSX.Element => {
 const UserGroupDetailPage = (props: Props): JSX.Element => {
   const { t } = useTranslation('admin');
   const { t } = useTranslation('admin');
   const router = useRouter();
   const router = useRouter();
   const { userGroupId: currentUserGroupId, isExternalGroup } = props;
   const { userGroupId: currentUserGroupId, isExternalGroup } = props;
 
 
-  const { data: currentUserGroup } = useUserGroup(currentUserGroupId, isExternalGroup);
+  const { data: currentUserGroup } = useUserGroup(
+    currentUserGroupId,
+    isExternalGroup,
+  );
 
 
   const [searchType, setSearchType] = useState<SearchType>(SearchTypes.PARTIAL);
   const [searchType, setSearchType] = useState<SearchType>(SearchTypes.PARTIAL);
   const [isAlsoMailSearched, setAlsoMailSearched] = useState<boolean>(false);
   const [isAlsoMailSearched, setAlsoMailSearched] = useState<boolean>(false);
   const [isAlsoNameSearched, setAlsoNameSearched] = useState<boolean>(false);
   const [isAlsoNameSearched, setAlsoNameSearched] = useState<boolean>(false);
-  const [selectedUserGroup, setSelectedUserGroup] = useState<IUserGroupHasId | undefined>(undefined); // not null but undefined (to use defaultProps in UserGroupDeleteModal)
+  const [selectedUserGroup, setSelectedUserGroup] = useState<
+    IUserGroupHasId | undefined
+  >(undefined); // not null but undefined (to use defaultProps in UserGroupDeleteModal)
   const [isCreateModalShown, setCreateModalShown] = useState<boolean>(false);
   const [isCreateModalShown, setCreateModalShown] = useState<boolean>(false);
   const [isUpdateModalShown, setUpdateModalShown] = useState<boolean>(false);
   const [isUpdateModalShown, setUpdateModalShown] = useState<boolean>(false);
   const [isDeleteModalShown, setDeleteModalShown] = useState<boolean>(false);
   const [isDeleteModalShown, setDeleteModalShown] = useState<boolean>(false);
-  const [isUserGroupUserModalShown, setIsUserGroupUserModalShown] = useState<boolean>(false);
+  const [isUserGroupUserModalShown, setIsUserGroupUserModalShown] =
+    useState<boolean>(false);
 
 
   const isLoading = currentUserGroup === undefined;
   const isLoading = currentUserGroup === undefined;
   const notExistsUerGroup = !isLoading && currentUserGroup == null;
   const notExistsUerGroup = !isLoading && currentUserGroup == null;
 
 
   useEffect(() => {
   useEffect(() => {
-    if (!objectIdUtils.isValidObjectId(currentUserGroupId) || notExistsUerGroup) {
+    if (
+      !objectIdUtils.isValidObjectId(currentUserGroupId) ||
+      notExistsUerGroup
+    ) {
       router.push('/admin/user-groups');
       router.push('/admin/user-groups');
     }
     }
-  }, [currentUserGroup, currentUserGroupId, notExistsUerGroup, router]);
-
+  }, [currentUserGroupId, notExistsUerGroup, router]);
 
 
   /*
   /*
    * Fetch
    * Fetch
    */
    */
-  const { data: userGroupPages } = useSWRxUserGroupPages(currentUserGroupId, 10, 0);
-
-  const { data: userGroupRelations, mutate: mutateUserGroupRelations } = useUserGroupRelations(currentUserGroupId, isExternalGroup);
-
-  const { data: childUserGroupsList, mutate: mutateChildUserGroups, updateChild } = useChildUserGroupList(currentUserGroupId, isExternalGroup);
-  const childUserGroups = childUserGroupsList != null ? childUserGroupsList.childUserGroups : [];
-  const childUserGroupsForDeleteModal: IGrantedGroup[] = childUserGroups.map((group) => {
-    const groupType = isExternalGroup ? GroupType.externalUserGroup : GroupType.userGroup;
-    return { item: group, type: groupType };
-  });
-  const grandChildUserGroups = childUserGroupsList != null ? childUserGroupsList.grandChildUserGroups : [];
-  const childUserGroupIds = childUserGroups.map(group => group._id);
-
-  const { data: userGroupRelationList, mutate: mutateUserGroupRelationList } = useUserGroupRelationList(childUserGroupIds, isExternalGroup);
-  const childUserGroupRelations = userGroupRelationList != null ? userGroupRelationList : [];
+  const { data: userGroupPages } = useSWRxUserGroupPages(
+    currentUserGroupId,
+    10,
+    0,
+  );
 
 
-  const { data: selectableParentUserGroups, mutate: mutateSelectableParentUserGroups } = useSWRxSelectableParentUserGroups(
+  const { data: userGroupRelations, mutate: mutateUserGroupRelations } =
+    useUserGroupRelations(currentUserGroupId, isExternalGroup);
+
+  const {
+    data: childUserGroupsList,
+    mutate: mutateChildUserGroups,
+    updateChild,
+  } = useChildUserGroupList(currentUserGroupId, isExternalGroup);
+  const childUserGroups =
+    childUserGroupsList != null ? childUserGroupsList.childUserGroups : [];
+  const childUserGroupsForDeleteModal: IGrantedGroup[] = childUserGroups.map(
+    (group) => {
+      const groupType = isExternalGroup
+        ? GroupType.externalUserGroup
+        : GroupType.userGroup;
+      return { item: group, type: groupType };
+    },
+  );
+  const grandChildUserGroups =
+    childUserGroupsList != null ? childUserGroupsList.grandChildUserGroups : [];
+  const childUserGroupIds = childUserGroups.map((group) => group._id);
+
+  const { data: userGroupRelationList, mutate: mutateUserGroupRelationList } =
+    useUserGroupRelationList(childUserGroupIds, isExternalGroup);
+  const childUserGroupRelations =
+    userGroupRelationList != null ? userGroupRelationList : [];
+
+  const {
+    data: selectableParentUserGroups,
+    mutate: mutateSelectableParentUserGroups,
+  } = useSWRxSelectableParentUserGroups(
     isExternalGroup ? null : currentUserGroupId,
     isExternalGroup ? null : currentUserGroupId,
   );
   );
-  const { data: selectableChildUserGroups, mutate: mutateSelectableChildUserGroups } = useSWRxSelectableChildUserGroups(
+  const {
+    data: selectableChildUserGroups,
+    mutate: mutateSelectableChildUserGroups,
+  } = useSWRxSelectableChildUserGroups(
     isExternalGroup ? null : currentUserGroupId,
     isExternalGroup ? null : currentUserGroupId,
   );
   );
 
 
-  const { data: ancestorUserGroups, mutate: mutateAncestorUserGroups } = useAncestorUserGroups(currentUserGroupId, isExternalGroup);
+  const { data: ancestorUserGroups, mutate: mutateAncestorUserGroups } =
+    useAncestorUserGroups(currentUserGroupId, isExternalGroup);
 
 
   const isAclEnabled = useAtomValue(isAclEnabledAtom);
   const isAclEnabled = useAtomValue(isAclEnabledAtom);
 
 
-  const { open: openUpdateParentConfirmModal } = useUpdateUserGroupConfirmModalActions();
+  const { open: openUpdateParentConfirmModal } =
+    useUpdateUserGroupConfirmModalActions();
 
 
   const parentUserGroup = (() => {
   const parentUserGroup = (() => {
     if (isExternalGroup) {
     if (isExternalGroup) {
       return ancestorUserGroups != null && ancestorUserGroups.length > 1
       return ancestorUserGroups != null && ancestorUserGroups.length > 1
-        ? ancestorUserGroups[ancestorUserGroups.length - 2] : undefined;
+        ? ancestorUserGroups[ancestorUserGroups.length - 2]
+        : undefined;
     }
     }
-    return selectableParentUserGroups?.find(selectableParentUserGroup => selectableParentUserGroup._id === currentUserGroup?.parent);
+    return selectableParentUserGroups?.find(
+      (selectableParentUserGroup) =>
+        selectableParentUserGroup._id === currentUserGroup?.parent,
+    );
   })();
   })();
   /*
   /*
    * Function
    * Function
    */
    */
   const toggleIsAlsoMailSearched = useCallback(() => {
   const toggleIsAlsoMailSearched = useCallback(() => {
-    setAlsoMailSearched(prev => !prev);
+    setAlsoMailSearched((prev) => !prev);
   }, []);
   }, []);
 
 
   const toggleAlsoNameSearched = useCallback(() => {
   const toggleAlsoNameSearched = useCallback(() => {
-    setAlsoNameSearched(prev => !prev);
+    setAlsoNameSearched((prev) => !prev);
   }, []);
   }, []);
 
 
   const switchSearchType = useCallback((searchType: SearchType) => {
   const switchSearchType = useCallback((searchType: SearchType) => {
     setSearchType(searchType);
     setSearchType(searchType);
   }, []);
   }, []);
 
 
-  const updateUserGroup = useCallback(async(userGroup: IUserGroupHasId, update: IUserGroupHasId, forceUpdateParents: boolean) => {
-    if (isExternalGroup) {
-      await apiv3Put<{ userGroup: IExternalUserGroupHasId }>(`/external-user-groups/${userGroup._id}`, {
-        description: update.description,
-      });
-    }
-    else {
-      await apiv3Put<{ userGroup: IUserGroupHasId }>(`/user-groups/${userGroup._id}`, {
-        name: update.name,
-        description: update.description,
-        parentId: update.parent != null ? getIdStringForRef(update.parent) : null,
-        forceUpdateParents,
-      });
-    }
+  const updateUserGroup = useCallback(
+    async (
+      userGroup: IUserGroupHasId,
+      update: IUserGroupHasId,
+      forceUpdateParents: boolean,
+    ) => {
+      if (isExternalGroup) {
+        await apiv3Put<{ userGroup: IExternalUserGroupHasId }>(
+          `/external-user-groups/${userGroup._id}`,
+          {
+            description: update.description,
+          },
+        );
+      } else {
+        await apiv3Put<{ userGroup: IUserGroupHasId }>(
+          `/user-groups/${userGroup._id}`,
+          {
+            name: update.name,
+            description: update.description,
+            parentId:
+              update.parent != null ? getIdStringForRef(update.parent) : null,
+            forceUpdateParents,
+          },
+        );
+      }
 
 
-    // mutate
-    mutateChildUserGroups();
-    mutateAncestorUserGroups();
-    mutateSelectableChildUserGroups();
-    mutateSelectableParentUserGroups();
-  }, [mutateAncestorUserGroups, mutateChildUserGroups, mutateSelectableChildUserGroups, mutateSelectableParentUserGroups, isExternalGroup]);
+      // mutate
+      mutateChildUserGroups();
+      mutateAncestorUserGroups();
+      mutateSelectableChildUserGroups();
+      mutateSelectableParentUserGroups();
+    },
+    [
+      mutateAncestorUserGroups,
+      mutateChildUserGroups,
+      mutateSelectableChildUserGroups,
+      mutateSelectableParentUserGroups,
+      isExternalGroup,
+    ],
+  );
 
 
   const onSubmitUpdateGroup = useCallback(
   const onSubmitUpdateGroup = useCallback(
-    async(targetGroup: IUserGroupHasId, userGroupData: IUserGroupHasId, forceUpdateParents: boolean): Promise<void> => {
+    async (
+      targetGroup: IUserGroupHasId,
+      userGroupData: IUserGroupHasId,
+      forceUpdateParents: boolean,
+    ): Promise<void> => {
       try {
       try {
         await updateUserGroup(targetGroup, userGroupData, forceUpdateParents);
         await updateUserGroup(targetGroup, userGroupData, forceUpdateParents);
-        toastSuccess(t('toaster.update_successed', { target: t('UserGroup'), ns: 'commons' }));
-      }
-      catch {
-        toastError(t('toaster.update_failed', { target: t('UserGroup'), ns: 'commons' }));
+        toastSuccess(
+          t('toaster.update_successed', {
+            target: t('UserGroup'),
+            ns: 'commons',
+          }),
+        );
+      } catch {
+        toastError(
+          t('toaster.update_failed', { target: t('UserGroup'), ns: 'commons' }),
+        );
       }
       }
     },
     },
     [t, updateUserGroup],
     [t, updateUserGroup],
   );
   );
 
 
-  const onClickSubmitForm = useCallback(async(targetGroup: IUserGroupHasId, userGroupData: IUserGroupHasId) => {
-    if (typeof userGroupData.parent === 'string') {
-      toastError(t('Something went wrong. Please try again.'));
-      logger.error('Something went wrong.');
-      return;
-    }
-
-    const prevParentId = typeof targetGroup.parent === 'string' ? targetGroup.parent : (targetGroup.parent?._id || null);
-    const newParentId = typeof userGroupData.parent?._id === 'string' ? userGroupData.parent?._id : null;
+  const onClickSubmitForm = useCallback(
+    async (targetGroup: IUserGroupHasId, userGroupData: IUserGroupHasId) => {
+      if (typeof userGroupData.parent === 'string') {
+        toastError(t('Something went wrong. Please try again.'));
+        logger.error('Something went wrong.');
+        return;
+      }
 
 
-    const shouldShowConfirmModal = prevParentId !== newParentId;
+      const prevParentId =
+        typeof targetGroup.parent === 'string'
+          ? targetGroup.parent
+          : targetGroup.parent?._id || null;
+      const newParentId =
+        typeof userGroupData.parent?._id === 'string'
+          ? userGroupData.parent?._id
+          : null;
+
+      const shouldShowConfirmModal = prevParentId !== newParentId;
+
+      if (shouldShowConfirmModal) {
+        // show confirm modal before submiting
+        await openUpdateParentConfirmModal(
+          targetGroup,
+          userGroupData,
+          onSubmitUpdateGroup,
+        );
+      } else {
+        // directly submit
+        await onSubmitUpdateGroup(targetGroup, userGroupData, false);
+      }
+    },
+    [t, openUpdateParentConfirmModal, onSubmitUpdateGroup],
+  );
 
 
-    if (shouldShowConfirmModal) { // show confirm modal before submiting
-      await openUpdateParentConfirmModal(
-        targetGroup,
-        userGroupData,
-        onSubmitUpdateGroup,
+  const fetchApplicableUsers = useCallback(
+    async (searchWord: string) => {
+      const res = await apiv3Get(
+        `/user-groups/${currentUserGroupId}/unrelated-users`,
+        {
+          searchWord,
+          searchType,
+          isAlsoMailSearched,
+          isAlsoNameSearched,
+        },
       );
       );
-    }
-    else { // directly submit
-      await onSubmitUpdateGroup(targetGroup, userGroupData, false);
-    }
-  }, [t, openUpdateParentConfirmModal, onSubmitUpdateGroup]);
-
-  const fetchApplicableUsers = useCallback(async(searchWord: string) => {
-    const res = await apiv3Get(`/user-groups/${currentUserGroupId}/unrelated-users`, {
-      searchWord,
-      searchType,
-      isAlsoMailSearched,
-      isAlsoNameSearched,
-    });
-
-    const { users } = res.data;
-
-    return users;
-  }, [currentUserGroupId, searchType, isAlsoMailSearched, isAlsoNameSearched]);
-
-  const addUserByUsername = useCallback(async(username: string) => {
-    try {
-      await apiv3Post(`/user-groups/${currentUserGroupId}/users/${username}`);
-      setIsUserGroupUserModalShown(false);
-      mutateUserGroupRelations();
-      mutateUserGroupRelationList();
-    }
-    catch (err) {
-      toastError(new Error(`Unable to add "${username}" from "${currentUserGroup?.name}"`));
-    }
-  }, [currentUserGroup?.name, currentUserGroupId, mutateUserGroupRelationList, mutateUserGroupRelations]);
+
+      const { users } = res.data;
+
+      return users;
+    },
+    [currentUserGroupId, searchType, isAlsoMailSearched, isAlsoNameSearched],
+  );
+
+  const addUserByUsername = useCallback(
+    async (username: string) => {
+      try {
+        await apiv3Post(`/user-groups/${currentUserGroupId}/users/${username}`);
+        setIsUserGroupUserModalShown(false);
+        mutateUserGroupRelations();
+        mutateUserGroupRelationList();
+      } catch (err) {
+        toastError(
+          new Error(
+            `Unable to add "${username}" from "${currentUserGroup?.name}"`,
+          ),
+        );
+      }
+    },
+    [
+      currentUserGroupId,
+      mutateUserGroupRelationList,
+      mutateUserGroupRelations,
+      currentUserGroup?.name,
+    ],
+  );
 
 
   // Fix: invalid csrf token => https://redmine.weseek.co.jp/issues/102704
   // Fix: invalid csrf token => https://redmine.weseek.co.jp/issues/102704
-  const removeUserByUsername = useCallback(async(username: string) => {
-    try {
-      await apiv3Delete(`/user-groups/${currentUserGroupId}/users/${username}`);
-      toastSuccess(`Removed "${username}" from "${currentUserGroup?.name}"`);
-      mutateUserGroupRelationList();
-    }
-    catch (err) {
-      toastError(new Error(`Unable to remove "${username}" from "${currentUserGroup?.name}"`));
-    }
-  }, [currentUserGroup?.name, currentUserGroupId, mutateUserGroupRelationList]);
+  const removeUserByUsername = useCallback(
+    async (username: string) => {
+      try {
+        await apiv3Delete(
+          `/user-groups/${currentUserGroupId}/users/${username}`,
+        );
+        toastSuccess(`Removed "${username}" from "${currentUserGroup?.name}"`);
+        mutateUserGroupRelationList();
+      } catch (err) {
+        toastError(
+          new Error(
+            `Unable to remove "${username}" from "${currentUserGroup?.name}"`,
+          ),
+        );
+      }
+    },
+    [currentUserGroupId, mutateUserGroupRelationList, currentUserGroup?.name],
+  );
 
 
   const showUpdateModal = useCallback((group: IUserGroupHasId) => {
   const showUpdateModal = useCallback((group: IUserGroupHasId) => {
     setUpdateModalShown(true);
     setUpdateModalShown(true);
     setSelectedUserGroup(group);
     setSelectedUserGroup(group);
-  }, [setUpdateModalShown]);
+  }, []);
 
 
   const hideUpdateModal = useCallback(() => {
   const hideUpdateModal = useCallback(() => {
     setUpdateModalShown(false);
     setUpdateModalShown(false);
     setSelectedUserGroup(undefined);
     setSelectedUserGroup(undefined);
-  }, [setUpdateModalShown]);
-
-  const updateChildUserGroup = useCallback(async(userGroupData: IUserGroupHasId) => {
-    try {
-      updateChild(userGroupData);
+  }, []);
 
 
-      toastSuccess(t('toaster.update_successed', { target: t('UserGroup'), ns: 'commons' }));
+  const updateChildUserGroup = useCallback(
+    async (userGroupData: IUserGroupHasId) => {
+      try {
+        updateChild(userGroupData);
+
+        toastSuccess(
+          t('toaster.update_successed', {
+            target: t('UserGroup'),
+            ns: 'commons',
+          }),
+        );
+
+        hideUpdateModal();
+      } catch (err) {
+        toastError(err);
+      }
+    },
+    [t, updateChild, hideUpdateModal],
+  );
 
 
-      hideUpdateModal();
-    }
-    catch (err) {
-      toastError(err);
-    }
-  }, [t, updateChild, hideUpdateModal]);
-
-  const onClickAddExistingUserGroupButtonHandler = useCallback(async(selectedChild: IUserGroupHasId): Promise<void> => {
-    // show confirm modal before submiting
-    await openUpdateParentConfirmModal(
-      selectedChild,
-      {
-        parent: currentUserGroupId,
-      },
-      onSubmitUpdateGroup,
-    );
-  }, [openUpdateParentConfirmModal, currentUserGroupId, onSubmitUpdateGroup]);
+  const onClickAddExistingUserGroupButtonHandler = useCallback(
+    async (selectedChild: IUserGroupHasId): Promise<void> => {
+      // show confirm modal before submiting
+      await openUpdateParentConfirmModal(
+        selectedChild,
+        {
+          parent: currentUserGroupId,
+        },
+        onSubmitUpdateGroup,
+      );
+    },
+    [openUpdateParentConfirmModal, currentUserGroupId, onSubmitUpdateGroup],
+  );
 
 
   const showCreateModal = useCallback(() => {
   const showCreateModal = useCallback(() => {
     setCreateModalShown(true);
     setCreateModalShown(true);
-  }, [setCreateModalShown]);
+  }, []);
 
 
   const hideCreateModal = useCallback(() => {
   const hideCreateModal = useCallback(() => {
     setCreateModalShown(false);
     setCreateModalShown(false);
-  }, [setCreateModalShown]);
-
-  const createChildUserGroup = useCallback(async(userGroupData: IUserGroup) => {
-    try {
-      await apiv3Post('/user-groups', {
-        name: userGroupData.name,
-        description: userGroupData.description,
-        parentId: currentUserGroupId,
-      });
-
-      toastSuccess(t('toaster.update_successed', { target: t('UserGroup'), ns: 'commons' }));
-
-      // mutate
-      mutateChildUserGroups();
-      mutateSelectableChildUserGroups();
-      mutateSelectableParentUserGroups();
+  }, []);
 
 
-      hideCreateModal();
-    }
-    catch (err) {
-      toastError(err);
-    }
-  }, [currentUserGroupId, t, mutateChildUserGroups, mutateSelectableChildUserGroups, mutateSelectableParentUserGroups, hideCreateModal]);
+  const createChildUserGroup = useCallback(
+    async (userGroupData: IUserGroup) => {
+      try {
+        await apiv3Post('/user-groups', {
+          name: userGroupData.name,
+          description: userGroupData.description,
+          parentId: currentUserGroupId,
+        });
+
+        toastSuccess(
+          t('toaster.update_successed', {
+            target: t('UserGroup'),
+            ns: 'commons',
+          }),
+        );
+
+        // mutate
+        mutateChildUserGroups();
+        mutateSelectableChildUserGroups();
+        mutateSelectableParentUserGroups();
+
+        hideCreateModal();
+      } catch (err) {
+        toastError(err);
+      }
+    },
+    [
+      currentUserGroupId,
+      t,
+      mutateChildUserGroups,
+      mutateSelectableChildUserGroups,
+      mutateSelectableParentUserGroups,
+      hideCreateModal,
+    ],
+  );
 
 
-  const showDeleteModal = useCallback(async(group: IUserGroupHasId) => {
+  const showDeleteModal = useCallback(async (group: IUserGroupHasId) => {
     setSelectedUserGroup(group);
     setSelectedUserGroup(group);
     setDeleteModalShown(true);
     setDeleteModalShown(true);
-  }, [setSelectedUserGroup, setDeleteModalShown]);
+  }, []);
 
 
   const hideDeleteModal = useCallback(() => {
   const hideDeleteModal = useCallback(() => {
     setSelectedUserGroup(undefined);
     setSelectedUserGroup(undefined);
     setDeleteModalShown(false);
     setDeleteModalShown(false);
-  }, [setSelectedUserGroup, setDeleteModalShown]);
-
-  const deleteChildUserGroupById = useCallback(async(deleteGroupId: string, actionName: PageActionOnGroupDelete, transferToUserGroup: IGrantedGroup | null) => {
-    const url = isExternalGroup ? `/external-user-groups/${deleteGroupId}` : `/user-groups/${deleteGroupId}`;
-    const transferToUserGroupId = transferToUserGroup != null ? getIdStringForRef(transferToUserGroup.item) : null;
-    const transferToUserGroupType = transferToUserGroup != null ? transferToUserGroup.type : null;
-    try {
-      const res = await apiv3Delete(url, {
-        actionName,
-        transferToUserGroupId,
-        transferToUserGroupType,
-      });
-
-      // sync
-      await mutateChildUserGroups();
-
-      setSelectedUserGroup(undefined);
-      setDeleteModalShown(false);
-
-      toastSuccess(`Deleted ${res.data.userGroups.length} groups.`);
-    }
-    catch (err) {
-      toastError(new Error('Unable to delete the groups'));
-    }
-  }, [mutateChildUserGroups, setSelectedUserGroup, setDeleteModalShown, isExternalGroup]);
+  }, []);
 
 
-  const removeChildUserGroup = useCallback(async(userGroupData: IUserGroupHasId) => {
-    try {
-      await apiv3Put(`/user-groups/${userGroupData._id}`, {
-        name: userGroupData.name,
-        description: userGroupData.description,
-        parentId: null,
-      });
+  const deleteChildUserGroupById = useCallback(
+    async (
+      deleteGroupId: string,
+      actionName: PageActionOnGroupDelete,
+      transferToUserGroup: IGrantedGroup | null,
+    ) => {
+      const url = isExternalGroup
+        ? `/external-user-groups/${deleteGroupId}`
+        : `/user-groups/${deleteGroupId}`;
+      const transferToUserGroupId =
+        transferToUserGroup != null
+          ? getIdStringForRef(transferToUserGroup.item)
+          : null;
+      const transferToUserGroupType =
+        transferToUserGroup != null ? transferToUserGroup.type : null;
+      try {
+        const res = await apiv3Delete(url, {
+          actionName,
+          transferToUserGroupId,
+          transferToUserGroupType,
+        });
 
 
-      toastSuccess(t('toaster.update_successed', { target: t('UserGroup'), ns: 'commons' }));
+        // sync
+        await mutateChildUserGroups();
 
 
-      // mutate
-      mutateChildUserGroups();
-      mutateSelectableChildUserGroups();
-    }
-    catch (err) {
-      toastError(err);
-      throw err;
-    }
-  }, [t, mutateChildUserGroups, mutateSelectableChildUserGroups]);
+        setSelectedUserGroup(undefined);
+        setDeleteModalShown(false);
+
+        toastSuccess(`Deleted ${res.data.userGroups.length} groups.`);
+      } catch (err) {
+        toastError(new Error('Unable to delete the groups'));
+      }
+    },
+    [mutateChildUserGroups, isExternalGroup],
+  );
+
+  const removeChildUserGroup = useCallback(
+    async (userGroupData: IUserGroupHasId) => {
+      try {
+        await apiv3Put(`/user-groups/${userGroupData._id}`, {
+          name: userGroupData.name,
+          description: userGroupData.description,
+          parentId: null,
+        });
+
+        toastSuccess(
+          t('toaster.update_successed', {
+            target: t('UserGroup'),
+            ns: 'commons',
+          }),
+        );
+
+        // mutate
+        mutateChildUserGroups();
+        mutateSelectableChildUserGroups();
+      } catch (err) {
+        toastError(err);
+        throw err;
+      }
+    },
+    [t, mutateChildUserGroups, mutateSelectableChildUserGroups],
+  );
 
 
   /*
   /*
    * Dependencies
    * Dependencies
@@ -361,27 +552,27 @@ const UserGroupDetailPage = (props: Props): JSX.Element => {
               {t('user_group_management.group_list')}
               {t('user_group_management.group_list')}
             </Link>
             </Link>
           </li>
           </li>
-          {
-            ancestorUserGroups != null && ancestorUserGroups.length > 0 && (ancestorUserGroups.map((ancestorUserGroup: IUserGroupHasId) => (
+          {ancestorUserGroups != null &&
+            ancestorUserGroups.length > 0 &&
+            ancestorUserGroups.map((ancestorUserGroup: IUserGroupHasId) => (
               <li
               <li
                 key={ancestorUserGroup._id}
                 key={ancestorUserGroup._id}
                 className={`breadcrumb-item ${ancestorUserGroup._id === currentUserGroupId ? 'active' : ''}`}
                 className={`breadcrumb-item ${ancestorUserGroup._id === currentUserGroupId ? 'active' : ''}`}
               >
               >
-                { ancestorUserGroup._id === currentUserGroupId ? (
+                {ancestorUserGroup._id === currentUserGroupId ? (
                   <span>{ancestorUserGroup.name}</span>
                   <span>{ancestorUserGroup.name}</span>
                 ) : (
                 ) : (
-                  <Link href={{
-                    pathname: `/admin/user-group-detail/${ancestorUserGroup._id}`,
-                    query: { isExternalGroup: 'true' },
-                  }}
+                  <Link
+                    href={{
+                      pathname: `/admin/user-group-detail/${ancestorUserGroup._id}`,
+                      query: { isExternalGroup: 'true' },
+                    }}
                   >
                   >
                     {ancestorUserGroup.name}
                     {ancestorUserGroup.name}
                   </Link>
                   </Link>
-                ) }
+                )}
               </li>
               </li>
-            ))
-            )
-          }
+            ))}
         </ol>
         </ol>
       </nav>
       </nav>
 
 
@@ -395,7 +586,9 @@ const UserGroupDetailPage = (props: Props): JSX.Element => {
           isExternalGroup={isExternalGroup}
           isExternalGroup={isExternalGroup}
         />
         />
       </div>
       </div>
-      <h2 className="admin-setting-header mt-4">{t('user_group_management.user_list')}</h2>
+      <h2 className="admin-setting-header mt-4">
+        {t('user_group_management.user_list')}
+      </h2>
       <UserGroupUserTable
       <UserGroupUserTable
         userGroupRelations={userGroupRelations}
         userGroupRelations={userGroupRelations}
         onClickPlusBtn={() => setIsUserGroupUserModalShown(true)}
         onClickPlusBtn={() => setIsUserGroupUserModalShown(true)}
@@ -416,11 +609,15 @@ const UserGroupDetailPage = (props: Props): JSX.Element => {
         onToggleIsAlsoNameSearched={toggleAlsoNameSearched}
         onToggleIsAlsoNameSearched={toggleAlsoNameSearched}
       />
       />
 
 
-      <h2 className="admin-setting-header mt-4">{t('user_group_management.child_group_list')}</h2>
+      <h2 className="admin-setting-header mt-4">
+        {t('user_group_management.child_group_list')}
+      </h2>
       {!isExternalGroup && (
       {!isExternalGroup && (
         <UserGroupDropdown
         <UserGroupDropdown
           selectableUserGroups={selectableChildUserGroups}
           selectableUserGroups={selectableChildUserGroups}
-          onClickAddExistingUserGroupButton={onClickAddExistingUserGroupButtonHandler}
+          onClickAddExistingUserGroupButton={
+            onClickAddExistingUserGroupButtonHandler
+          }
           onClickCreateUserGroupButton={showCreateModal}
           onClickCreateUserGroupButton={showCreateModal}
         />
         />
       )}
       )}
@@ -464,7 +661,10 @@ const UserGroupDetailPage = (props: Props): JSX.Element => {
 
 
       <h2 className="admin-setting-header mt-4">{t('Page')}</h2>
       <h2 className="admin-setting-header mt-4">{t('Page')}</h2>
       <div className={`page-list ${styles['page-list']}`}>
       <div className={`page-list ${styles['page-list']}`}>
-        <UserGroupPageList userGroupId={currentUserGroupId} relatedPages={userGroupPages} />
+        <UserGroupPageList
+          userGroupId={currentUserGroupId}
+          relatedPages={userGroupPages}
+        />
       </div>
       </div>
     </div>
     </div>
   );
   );

+ 27 - 26
apps/app/src/client/components/Admin/UserGroupDetail/UserGroupPageList.tsx

@@ -1,7 +1,4 @@
-import React, {
-  useEffect, useState, useCallback, type JSX,
-} from 'react';
-
+import React, { type JSX, useCallback, useEffect, useState } from 'react';
 import type { IPageHasId } from '@growi/core';
 import type { IPageHasId } from '@growi/core';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 
 
@@ -14,9 +11,9 @@ import PaginationWrapper from '../../PaginationWrapper';
 const pagingLimit = 10;
 const pagingLimit = 10;
 
 
 type Props = {
 type Props = {
-  userGroupId: string,
-  relatedPages?: IPageHasId[],
-}
+  userGroupId: string;
+  relatedPages?: IPageHasId[];
+};
 
 
 const UserGroupPageList = (props: Props): JSX.Element => {
 const UserGroupPageList = (props: Props): JSX.Element => {
   const { t } = useTranslation('admin');
   const { t } = useTranslation('admin');
@@ -26,24 +23,26 @@ const UserGroupPageList = (props: Props): JSX.Element => {
   const [activePage, setActivePage] = useState(1);
   const [activePage, setActivePage] = useState(1);
   const [total, setTotal] = useState(0);
   const [total, setTotal] = useState(0);
 
 
-  const handlePageChange = useCallback(async(pageNum) => {
-    const offset = (pageNum - 1) * pagingLimit;
+  const handlePageChange = useCallback(
+    async (pageNum) => {
+      const offset = (pageNum - 1) * pagingLimit;
 
 
-    try {
-      const res = await apiv3Get(`/user-groups/${userGroupId}/pages`, {
-        limit: pagingLimit,
-        offset,
-      });
-      const { total, pages } = res.data;
+      try {
+        const res = await apiv3Get(`/user-groups/${userGroupId}/pages`, {
+          limit: pagingLimit,
+          offset,
+        });
+        const { total, pages } = res.data;
 
 
-      setTotal(total);
-      setActivePage(pageNum);
-      setCurrentPages(pages);
-    }
-    catch (err) {
-      toastError(err);
-    }
-  }, [userGroupId]);
+        setTotal(total);
+        setActivePage(pageNum);
+        setCurrentPages(pages);
+      } catch (err) {
+        toastError(err);
+      }
+    },
+    [userGroupId],
+  );
 
 
   useEffect(() => {
   useEffect(() => {
     handlePageChange(activePage);
     handlePageChange(activePage);
@@ -52,13 +51,15 @@ const UserGroupPageList = (props: Props): JSX.Element => {
   return (
   return (
     <>
     <>
       <ul className="page-list-ul page-list-ul-flat mt-3 mb-3">
       <ul className="page-list-ul page-list-ul-flat mt-3 mb-3">
-        { currentPages.map(page => (
+        {currentPages.map((page) => (
           <li key={page._id} className="mt-2">
           <li key={page._id} className="mt-2">
             <PageListItemS page={page} />
             <PageListItemS page={page} />
           </li>
           </li>
-        )) }
+        ))}
       </ul>
       </ul>
-      {relatedPages != null && relatedPages.length === 0 ? <p>{t('user_group_management.no_pages')}</p> : (
+      {relatedPages != null && relatedPages.length === 0 ? (
+        <p>{t('user_group_management.no_pages')}</p>
+      ) : (
         <PaginationWrapper
         <PaginationWrapper
           activePage={activePage}
           activePage={activePage}
           changePage={handlePageChange}
           changePage={handlePageChange}

+ 31 - 21
apps/app/src/client/components/Admin/UserGroupDetail/UserGroupUserFormByInput.tsx

@@ -1,26 +1,30 @@
 import type { FC, KeyboardEvent } from 'react';
 import type { FC, KeyboardEvent } from 'react';
 import React, { useState } from 'react';
 import React, { useState } from 'react';
-
 import type { IUserGroupHasId, IUserHasId } from '@growi/core';
 import type { IUserGroupHasId, IUserHasId } from '@growi/core';
 import { UserPicture } from '@growi/ui/dist/components';
 import { UserPicture } from '@growi/ui/dist/components';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 import { AsyncTypeahead } from 'react-bootstrap-typeahead';
 import { AsyncTypeahead } from 'react-bootstrap-typeahead';
 
 
-import { toastSuccess, toastError } from '~/client/util/toastr';
+import { toastError, toastSuccess } from '~/client/util/toastr';
 import type { SearchType } from '~/interfaces/user-group';
 import type { SearchType } from '~/interfaces/user-group';
 
 
 type Props = {
 type Props = {
-  userGroup: IUserGroupHasId,
-  onClickAddUserBtn: (username: string) => Promise<void>,
-  onSearchApplicableUsers: (searchWord: string) => Promise<IUserHasId[]>,
-  isAlsoNameSearched: boolean,
-  isAlsoMailSearched: boolean,
-  searchType: SearchType,
-}
+  userGroup: IUserGroupHasId;
+  onClickAddUserBtn: (username: string) => Promise<void>;
+  onSearchApplicableUsers: (searchWord: string) => Promise<IUserHasId[]>;
+  isAlsoNameSearched: boolean;
+  isAlsoMailSearched: boolean;
+  searchType: SearchType;
+};
 
 
 export const UserGroupUserFormByInput: FC<Props> = (props) => {
 export const UserGroupUserFormByInput: FC<Props> = (props) => {
   const {
   const {
-    userGroup, onClickAddUserBtn, onSearchApplicableUsers, isAlsoNameSearched, isAlsoMailSearched, searchType,
+    userGroup,
+    onClickAddUserBtn,
+    onSearchApplicableUsers,
+    isAlsoNameSearched,
+    isAlsoMailSearched,
+    searchType,
   } = props;
   } = props;
 
 
   const { t } = useTranslation();
   const { t } = useTranslation();
@@ -29,27 +33,29 @@ export const UserGroupUserFormByInput: FC<Props> = (props) => {
   const [isLoading, setIsLoading] = useState(false);
   const [isLoading, setIsLoading] = useState(false);
   const [isSearchError, setIsSearchError] = useState(false);
   const [isSearchError, setIsSearchError] = useState(false);
 
 
-  const addUserBySubmit = async() => {
-    if (inputUser.length === 0) { return }
+  const addUserBySubmit = async () => {
+    if (inputUser.length === 0) {
+      return;
+    }
     const userName = inputUser[0].username;
     const userName = inputUser[0].username;
 
 
     try {
     try {
       await onClickAddUserBtn(userName);
       await onClickAddUserBtn(userName);
       toastSuccess(`Added "${userName}" to "${userGroup.name}"`);
       toastSuccess(`Added "${userName}" to "${userGroup.name}"`);
       setInputUser([]);
       setInputUser([]);
-    }
-    catch (err) {
-      toastError(new Error(`Unable to add "${userName}" to "${userGroup.name}"`));
+    } catch (err) {
+      toastError(
+        new Error(`Unable to add "${userName}" to "${userGroup.name}"`),
+      );
     }
     }
   };
   };
 
 
-  const searchApplicableUsers = async(keyword: string) => {
+  const searchApplicableUsers = async (keyword: string) => {
     try {
     try {
       const users = await onSearchApplicableUsers(keyword);
       const users = await onSearchApplicableUsers(keyword);
       setApplicableUsers(users);
       setApplicableUsers(users);
       setIsLoading(false);
       setIsLoading(false);
-    }
-    catch (err) {
+    } catch (err) {
       setIsSearchError(true);
       setIsSearchError(true);
       toastError(err);
       toastError(err);
     }
     }
@@ -59,7 +65,7 @@ export const UserGroupUserFormByInput: FC<Props> = (props) => {
     setInputUser(inputUser);
     setInputUser(inputUser);
   };
   };
 
 
-  const handleSearch = async(keyword: string) => {
+  const handleSearch = async (keyword: string) => {
     setIsLoading(true);
     setIsLoading(true);
     await searchApplicableUsers(keyword);
     await searchApplicableUsers(keyword);
   };
   };
@@ -91,13 +97,17 @@ export const UserGroupUserFormByInput: FC<Props> = (props) => {
           id="name-typeahead-asynctypeahead"
           id="name-typeahead-asynctypeahead"
           inputProps={{ autoComplete: 'off' }}
           inputProps={{ autoComplete: 'off' }}
           isLoading={isLoading}
           isLoading={isLoading}
-          labelKey={(user: IUserHasId) => `${user.username} ${user.name} ${user.email}`}
+          labelKey={(user: IUserHasId) =>
+            `${user.username} ${user.name} ${user.email}`
+          }
           options={applicableUsers} // Search result
           options={applicableUsers} // Search result
           onSearch={handleSearch}
           onSearch={handleSearch}
           onChange={handleChange}
           onChange={handleChange}
           onKeyDown={onKeyDown}
           onKeyDown={onKeyDown}
           minLength={1}
           minLength={1}
-          searchText={isLoading ? 'Searching...' : (isSearchError && 'Error on searching.')}
+          searchText={
+            isLoading ? 'Searching...' : isSearchError && 'Error on searching.'
+          }
           renderMenuItemChildren={renderMenuItemChildren}
           renderMenuItemChildren={renderMenuItemChildren}
           align="left"
           align="left"
           clearButton
           clearButton

+ 20 - 19
apps/app/src/client/components/Admin/UserGroupDetail/UserGroupUserModal.tsx

@@ -1,10 +1,7 @@
 import React, { type JSX } from 'react';
 import React, { type JSX } from 'react';
-
 import type { IUserGroupHasId, IUserHasId } from '@growi/core';
 import type { IUserGroupHasId, IUserHasId } from '@growi/core';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
-import {
-  Modal, ModalHeader, ModalBody,
-} from 'reactstrap';
+import { Modal, ModalBody, ModalHeader } from 'reactstrap';
 
 
 import type { SearchType } from '~/interfaces/user-group';
 import type { SearchType } from '~/interfaces/user-group';
 import { SearchTypes } from '~/interfaces/user-group';
 import { SearchTypes } from '~/interfaces/user-group';
@@ -14,18 +11,18 @@ import RadioButtonForSerchUserOption from './RadioButtonForSerchUserOption';
 import { UserGroupUserFormByInput } from './UserGroupUserFormByInput';
 import { UserGroupUserFormByInput } from './UserGroupUserFormByInput';
 
 
 type Props = {
 type Props = {
-  isOpen: boolean,
-  userGroup: IUserGroupHasId,
-  searchType: SearchType,
-  isAlsoMailSearched: boolean,
-  isAlsoNameSearched: boolean,
-  onClickAddUserBtn: (username: string) => Promise<void>,
-  onSearchApplicableUsers: (searchWord: string) => Promise<IUserHasId[]>,
-  onSwitchSearchType: (searchType: SearchType) => void
-  onClose: () => void,
-  onToggleIsAlsoMailSearched: () => void,
-  onToggleIsAlsoNameSearched: () => void,
-}
+  isOpen: boolean;
+  userGroup: IUserGroupHasId;
+  searchType: SearchType;
+  isAlsoMailSearched: boolean;
+  isAlsoNameSearched: boolean;
+  onClickAddUserBtn: (username: string) => Promise<void>;
+  onSearchApplicableUsers: (searchWord: string) => Promise<IUserHasId[]>;
+  onSwitchSearchType: (searchType: SearchType) => void;
+  onClose: () => void;
+  onToggleIsAlsoMailSearched: () => void;
+  onToggleIsAlsoNameSearched: () => void;
+};
 
 
 const UserGroupUserModal = (props: Props): JSX.Element => {
 const UserGroupUserModal = (props: Props): JSX.Element => {
   const { t } = useTranslation();
   const { t } = useTranslation();
@@ -46,10 +43,12 @@ const UserGroupUserModal = (props: Props): JSX.Element => {
   return (
   return (
     <Modal isOpen={isOpen} toggle={onClose}>
     <Modal isOpen={isOpen} toggle={onClose}>
       <ModalHeader tag="h4" toggle={onClose} className="text-info">
       <ModalHeader tag="h4" toggle={onClose} className="text-info">
-        {t('admin:user_group_management.add_modal.add_user') }
+        {t('admin:user_group_management.add_modal.add_user')}
       </ModalHeader>
       </ModalHeader>
       <ModalBody>
       <ModalBody>
-        <p className="card custom-card">{t('admin:user_group_management.add_modal.description')}</p>
+        <p className="card custom-card">
+          {t('admin:user_group_management.add_modal.description')}
+        </p>
         <div className="p-3">
         <div className="p-3">
           <UserGroupUserFormByInput
           <UserGroupUserFormByInput
             userGroup={userGroup}
             userGroup={userGroup}
@@ -60,7 +59,9 @@ const UserGroupUserModal = (props: Props): JSX.Element => {
             searchType={searchType}
             searchType={searchType}
           />
           />
         </div>
         </div>
-        <h2 className="border-bottom">{t('admin:user_group_management.add_modal.search_option')}</h2>
+        <h2 className="border-bottom">
+          {t('admin:user_group_management.add_modal.search_option')}
+        </h2>
         <div className="row mt-4">
         <div className="row mt-4">
           <div className="col-6">
           <div className="col-6">
             <div className="mb-5">
             <div className="mb-5">

+ 67 - 44
apps/app/src/client/components/Admin/UserGroupDetail/UserGroupUserTable.tsx

@@ -1,5 +1,4 @@
 import React, { type JSX } from 'react';
 import React, { type JSX } from 'react';
-
 import { UserPicture } from '@growi/ui/dist/components';
 import { UserPicture } from '@growi/ui/dist/components';
 import { format as dateFnsFormat } from 'date-fns/format';
 import { format as dateFnsFormat } from 'date-fns/format';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
@@ -7,11 +6,11 @@ import { useTranslation } from 'next-i18next';
 import type { IUserGroupRelationHasIdPopulatedUser } from '~/interfaces/user-group-response';
 import type { IUserGroupRelationHasIdPopulatedUser } from '~/interfaces/user-group-response';
 
 
 type Props = {
 type Props = {
-  userGroupRelations: IUserGroupRelationHasIdPopulatedUser[] | undefined,
-  onClickRemoveUserBtn: (username: string) => Promise<void>,
-  onClickPlusBtn: () => void,
-  isExternalGroup?: boolean
-}
+  userGroupRelations: IUserGroupRelationHasIdPopulatedUser[] | undefined;
+  onClickRemoveUserBtn: (username: string) => Promise<void>;
+  onClickPlusBtn: () => void;
+  isExternalGroup?: boolean;
+};
 
 
 export const UserGroupUserTable = (props: Props): JSX.Element => {
 export const UserGroupUserTable = (props: Props): JSX.Element => {
   const { t } = useTranslation('admin');
   const { t } = useTranslation('admin');
@@ -21,9 +20,7 @@ export const UserGroupUserTable = (props: Props): JSX.Element => {
       <thead>
       <thead>
         <tr>
         <tr>
           <th style={{ width: '100px' }}>#</th>
           <th style={{ width: '100px' }}>#</th>
-          <th>
-            {t('username')}
-          </th>
+          <th>{t('username')}</th>
           <th>{t('Name')}</th>
           <th>{t('Name')}</th>
           <th style={{ width: '100px' }}>{t('Created')}</th>
           <th style={{ width: '100px' }}>{t('Created')}</th>
           <th style={{ width: '160px' }}>{t('last_login')}</th>
           <th style={{ width: '160px' }}>{t('last_login')}</th>
@@ -31,52 +28,79 @@ export const UserGroupUserTable = (props: Props): JSX.Element => {
         </tr>
         </tr>
       </thead>
       </thead>
       <tbody>
       <tbody>
-        {props.userGroupRelations != null && props.userGroupRelations.map((relation) => {
-          const { relatedUser } = relation;
+        {props.userGroupRelations != null &&
+          props.userGroupRelations.map((relation) => {
+            const { relatedUser } = relation;
 
 
-          return (
-            <tr key={relation._id}>
-              <td>
-                <UserPicture user={relatedUser} />
-              </td>
-              <td>
-                <strong>{relatedUser.username}</strong>
-              </td>
-              <td>{relatedUser.name}</td>
-              <td>{relatedUser.createdAt ? dateFnsFormat(relatedUser.createdAt, 'yyyy-MM-dd') : ''}</td>
-              <td>{relatedUser.lastLoginAt ? dateFnsFormat(relatedUser.lastLoginAt, 'yyyy-MM-dd HH:mm:ss') : ''}</td>
-              {!props.isExternalGroup && (
+            return (
+              <tr key={relation._id}>
+                <td>
+                  <UserPicture user={relatedUser} />
+                </td>
+                <td>
+                  <strong>{relatedUser.username}</strong>
+                </td>
+                <td>{relatedUser.name}</td>
                 <td>
                 <td>
-                  <div className="btn-group admin-user-menu">
-                    <button
-                      type="button"
-                      id={`admin-group-menu-button-${relatedUser._id}`}
-                      className="btn btn-outline-secondary btn-sm dropdown-toggle"
-                      data-bs-toggle="dropdown"
-                    >
-                      <span className="material-symbols-outlined fs-5">settings</span>
-                    </button>
-                    <div className="dropdown-menu" role="menu" aria-labelledby={`admin-group-menu-button-${relatedUser._id}`}>
+                  {relatedUser.createdAt
+                    ? dateFnsFormat(relatedUser.createdAt, 'yyyy-MM-dd')
+                    : ''}
+                </td>
+                <td>
+                  {relatedUser.lastLoginAt
+                    ? dateFnsFormat(
+                        relatedUser.lastLoginAt,
+                        'yyyy-MM-dd HH:mm:ss',
+                      )
+                    : ''}
+                </td>
+                {!props.isExternalGroup && (
+                  <td>
+                    <div className="btn-group admin-user-menu">
                       <button
                       <button
-                        className="dropdown-item"
                         type="button"
                         type="button"
-                        onClick={() => props.onClickRemoveUserBtn(relatedUser.username)}
+                        id={`admin-group-menu-button-${relatedUser._id}`}
+                        className="btn btn-outline-secondary btn-sm dropdown-toggle"
+                        data-bs-toggle="dropdown"
                       >
                       >
-                        <span className="material-symbols-outlined me-1">person_remove</span>{t('admin:user_group_management.remove_from_group')}
+                        <span className="material-symbols-outlined fs-5">
+                          settings
+                        </span>
                       </button>
                       </button>
+                      <div
+                        className="dropdown-menu"
+                        role="menu"
+                        aria-labelledby={`admin-group-menu-button-${relatedUser._id}`}
+                      >
+                        <button
+                          className="dropdown-item"
+                          type="button"
+                          onClick={() =>
+                            props.onClickRemoveUserBtn(relatedUser.username)
+                          }
+                        >
+                          <span className="material-symbols-outlined me-1">
+                            person_remove
+                          </span>
+                          {t('admin:user_group_management.remove_from_group')}
+                        </button>
+                      </div>
                     </div>
                     </div>
-                  </div>
-                </td>
-              )}
-            </tr>
-          );
-        })}
+                  </td>
+                )}
+              </tr>
+            );
+          })}
 
 
         {!props.isExternalGroup && (
         {!props.isExternalGroup && (
           <tr>
           <tr>
             <td></td>
             <td></td>
             <td className="text-center">
             <td className="text-center">
-              <button className="btn btn-outline-secondary" type="button" onClick={props.onClickPlusBtn}>
+              <button
+                className="btn btn-outline-secondary"
+                type="button"
+                onClick={props.onClickPlusBtn}
+              >
                 <span className="material-symbols-outlined">add</span>
                 <span className="material-symbols-outlined">add</span>
               </button>
               </button>
             </td>
             </td>
@@ -86,7 +110,6 @@ export const UserGroupUserTable = (props: Props): JSX.Element => {
             <td></td>
             <td></td>
           </tr>
           </tr>
         )}
         )}
-
       </tbody>
       </tbody>
     </table>
     </table>
   );
   );

+ 45 - 14
apps/app/src/client/components/Admin/UserGroupDetail/use-user-group-resource.ts

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

+ 98 - 58
apps/app/src/client/components/Admin/Users/ExternalAccountTable.tsx

@@ -1,11 +1,10 @@
-import React, { useCallback, type JSX } from 'react';
-
+import React, { type JSX, useCallback } from 'react';
 import type { IAdminExternalAccount } from '@growi/core';
 import type { IAdminExternalAccount } from '@growi/core';
 import { format as dateFnsFormat } from 'date-fns/format';
 import { format as dateFnsFormat } from 'date-fns/format';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 
 
 import AdminExternalAccountsContainer from '~/client/services/AdminExternalAccountsContainer';
 import AdminExternalAccountsContainer from '~/client/services/AdminExternalAccountsContainer';
-import { toastSuccess, toastError } from '~/client/util/toastr';
+import { toastError, toastSuccess } from '~/client/util/toastr';
 import type { IExternalAuthProviderType } from '~/interfaces/external-auth-provider';
 import type { IExternalAuthProviderType } from '~/interfaces/external-auth-provider';
 
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
 import { withUnstatedContainers } from '../../UnstatedUtils';
@@ -13,28 +12,36 @@ import { withUnstatedContainers } from '../../UnstatedUtils';
 import styles from './ExternalAccountTable.module.scss';
 import styles from './ExternalAccountTable.module.scss';
 
 
 type ExternalAccountTableProps = {
 type ExternalAccountTableProps = {
-  adminExternalAccountsContainer: AdminExternalAccountsContainer,
-}
-
-const ExternalAccountTable = (props: ExternalAccountTableProps): JSX.Element => {
+  adminExternalAccountsContainer: AdminExternalAccountsContainer;
+};
 
 
+const ExternalAccountTable = (
+  props: ExternalAccountTableProps,
+): JSX.Element => {
   const { t } = useTranslation('admin');
   const { t } = useTranslation('admin');
 
 
   const { adminExternalAccountsContainer } = props;
   const { adminExternalAccountsContainer } = props;
 
 
-  const removeExtenalAccount = useCallback(async(externalAccountId) => {
-    try {
-      const accountId = await adminExternalAccountsContainer.removeExternalAccountById(externalAccountId);
-      toastSuccess(t('toaster.remove_external_user_success', { accountId }));
-    }
-    catch (err) {
-      toastError(err);
-    }
-  }, [adminExternalAccountsContainer, t]);
+  const removeExtenalAccount = useCallback(
+    async (externalAccountId) => {
+      try {
+        const accountId =
+          await adminExternalAccountsContainer.removeExternalAccountById(
+            externalAccountId,
+          );
+        toastSuccess(t('toaster.remove_external_user_success', { accountId }));
+      } catch (err) {
+        toastError(err);
+      }
+    },
+    [adminExternalAccountsContainer, t],
+  );
 
 
   return (
   return (
     <div className="table-responsive text-nowrap">
     <div className="table-responsive text-nowrap">
-      <table className={`${styles['ea-table']} table table-bordered table-user-list`}>
+      <table
+        className={`${styles['ea-table']} table table-bordered table-user-list`}
+      >
         <thead>
         <thead>
           <tr>
           <tr>
             <th style={{ width: '100px' }}>
             <th style={{ width: '100px' }}>
@@ -49,75 +56,108 @@ const ExternalAccountTable = (props: ExternalAccountTableProps): JSX.Element =>
             </th>
             </th>
             <th style={{ width: '200px' }}>
             <th style={{ width: '200px' }}>
               <div className="d-flex align-items-center">
               <div className="d-flex align-items-center">
-                {t('user_management.related_username')}<code className="ms-2">username</code>
+                {t('user_management.related_username')}
+                <code className="ms-2">username</code>
               </div>
               </div>
             </th>
             </th>
             <th style={{ width: '100px' }}>
             <th style={{ width: '100px' }}>
               <div className="d-flex align-items-center">
               <div className="d-flex align-items-center">
                 {t('user_management.password_setting')}
                 {t('user_management.password_setting')}
-                <span
-                  role="button"
-                  className="text-muted mx-2"
+                <button
+                  type="button"
+                  className="text-muted mx-2 btn btn-link p-0"
                   data-bs-toggle="popper"
                   data-bs-toggle="popper"
                   data-placement="top"
                   data-placement="top"
                   data-trigger="hover"
                   data-trigger="hover"
                   data-html="true"
                   data-html="true"
                   title={t('user_management.password_setting_help')}
                   title={t('user_management.password_setting_help')}
+                  aria-label={t('user_management.password_setting_help')}
                 >
                 >
-                  <small><span className="material-symbols-outlined" aria-hidden="true">help</span></small>
-                </span>
+                  <small>
+                    <span
+                      className="material-symbols-outlined"
+                      aria-hidden="true"
+                    >
+                      help
+                    </span>
+                  </small>
+                </button>
               </div>
               </div>
             </th>
             </th>
             <th style={{ width: '100px' }}>
             <th style={{ width: '100px' }}>
-              <div className="d-flex align-items-center">
-                {t('Created')}
-              </div>
+              <div className="d-flex align-items-center">{t('Created')}</div>
             </th>
             </th>
             <th style={{ width: '70px' }}></th>
             <th style={{ width: '70px' }}></th>
           </tr>
           </tr>
         </thead>
         </thead>
         <tbody>
         <tbody>
-          { adminExternalAccountsContainer.state.externalAccounts.map((ea: IAdminExternalAccount<IExternalAuthProviderType>) => {
-            return (
-              <tr key={ea._id}>
-                <td><span>{ea.providerType}</span></td>
-                <td><strong>{ea.accountId}</strong></td>
-                <td><strong>{ea.user.username}</strong></td>
-                <td>
-                  {ea.user.password
-                    ? (<span className="badge bg-info">{t('user_management.set')}</span>)
-                    : (<span className="badge bg-warning text-dark">{t('user_management.unset')}</span>)
-                  }
-                </td>
-                <td>{dateFnsFormat(ea.createdAt, 'yyyy-MM-dd')}</td>
-                <td>
-                  <div className="btn-group admin-user-menu">
-                    <button type="button" className="btn btn-outline-secondary btn-sm dropdown-toggle" data-bs-toggle="dropdown">
-                      <span className="material-symbols-outlined">settings</span> <span className="caret"></span>
-                    </button>
-                    <ul className="dropdown-menu" role="menu">
-                      <li className="dropdown-header">{t('user_management.user_table.edit_menu')}</li>
+          {adminExternalAccountsContainer.state.externalAccounts.map(
+            (ea: IAdminExternalAccount<IExternalAuthProviderType>) => {
+              return (
+                <tr key={ea._id}>
+                  <td>
+                    <span>{ea.providerType}</span>
+                  </td>
+                  <td>
+                    <strong>{ea.accountId}</strong>
+                  </td>
+                  <td>
+                    <strong>{ea.user.username}</strong>
+                  </td>
+                  <td>
+                    {ea.user.password ? (
+                      <span className="badge bg-info">
+                        {t('user_management.set')}
+                      </span>
+                    ) : (
+                      <span className="badge bg-warning text-dark">
+                        {t('user_management.unset')}
+                      </span>
+                    )}
+                  </td>
+                  <td>{dateFnsFormat(ea.createdAt, 'yyyy-MM-dd')}</td>
+                  <td>
+                    <div className="btn-group admin-user-menu">
                       <button
                       <button
-                        className="dropdown-item"
                         type="button"
                         type="button"
-                        role="button"
-                        onClick={() => removeExtenalAccount(ea._id)}
+                        className="btn btn-outline-secondary btn-sm dropdown-toggle"
+                        data-bs-toggle="dropdown"
                       >
                       >
-                        <span className="material-symbols-outlined text-danger">delete_forever</span> {t('Delete')}
+                        <span className="material-symbols-outlined">
+                          settings
+                        </span>{' '}
+                        <span className="caret"></span>
                       </button>
                       </button>
-                    </ul>
-                  </div>
-                </td>
-              </tr>
-            );
-          }) }
+                      <ul className="dropdown-menu">
+                        <li className="dropdown-header">
+                          {t('user_management.user_table.edit_menu')}
+                        </li>
+                        <button
+                          className="dropdown-item"
+                          type="button"
+                          onClick={() => removeExtenalAccount(ea._id)}
+                        >
+                          <span className="material-symbols-outlined text-danger">
+                            delete_forever
+                          </span>{' '}
+                          {t('Delete')}
+                        </button>
+                      </ul>
+                    </div>
+                  </td>
+                </tr>
+              );
+            },
+          )}
         </tbody>
         </tbody>
       </table>
       </table>
     </div>
     </div>
-
   );
   );
 };
 };
 
 
-const ExternalAccountTableWrapper = withUnstatedContainers(ExternalAccountTable, [AdminExternalAccountsContainer]);
+const ExternalAccountTableWrapper = withUnstatedContainers(
+  ExternalAccountTable,
+  [AdminExternalAccountsContainer],
+);
 
 
 export default ExternalAccountTableWrapper;
 export default ExternalAccountTableWrapper;

+ 17 - 14
apps/app/src/client/components/Admin/Users/GrantAdminButton.tsx

@@ -1,45 +1,48 @@
-import React, { useCallback, type JSX } from 'react';
-
+import React, { type JSX, useCallback } from 'react';
 import type { IUserHasId } from '@growi/core';
 import type { IUserHasId } from '@growi/core';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 
 
 import AdminUsersContainer from '~/client/services/AdminUsersContainer';
 import AdminUsersContainer from '~/client/services/AdminUsersContainer';
-import { toastSuccess, toastError } from '~/client/util/toastr';
+import { toastError, toastSuccess } from '~/client/util/toastr';
 
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
 import { withUnstatedContainers } from '../../UnstatedUtils';
 
 
 type GrantAdminButtonProps = {
 type GrantAdminButtonProps = {
-  adminUsersContainer: AdminUsersContainer,
-  user: IUserHasId,
-}
+  adminUsersContainer: AdminUsersContainer;
+  user: IUserHasId;
+};
 
 
 const GrantAdminButton = (props: GrantAdminButtonProps): JSX.Element => {
 const GrantAdminButton = (props: GrantAdminButtonProps): JSX.Element => {
-
   const { t } = useTranslation('admin');
   const { t } = useTranslation('admin');
   const { adminUsersContainer, user } = props;
   const { adminUsersContainer, user } = props;
 
 
-  const onClickGrantAdminBtnHandler = useCallback(async() => {
+  const onClickGrantAdminBtnHandler = useCallback(async () => {
     try {
     try {
       const username = await adminUsersContainer.grantUserAdmin(user._id);
       const username = await adminUsersContainer.grantUserAdmin(user._id);
       toastSuccess(t('toaster.grant_user_admin', { username }));
       toastSuccess(t('toaster.grant_user_admin', { username }));
-    }
-    catch (err) {
+    } catch (err) {
       toastError(err);
       toastError(err);
     }
     }
   }, [adminUsersContainer, t, user._id]);
   }, [adminUsersContainer, t, user._id]);
 
 
   return (
   return (
-    <button className="dropdown-item" type="button" onClick={() => onClickGrantAdminBtnHandler()}>
-      <span className="material-symbols-outlined me-1">person_add</span>{t('user_management.user_table.grant_admin_access')}
+    <button
+      className="dropdown-item"
+      type="button"
+      onClick={() => onClickGrantAdminBtnHandler()}
+    >
+      <span className="material-symbols-outlined me-1">person_add</span>
+      {t('user_management.user_table.grant_admin_access')}
     </button>
     </button>
   );
   );
-
 };
 };
 
 
 /**
 /**
  * Wrapper component for using unstated
  * Wrapper component for using unstated
  */
  */
 // eslint-disable-next-line max-len
 // eslint-disable-next-line max-len
-const GrantAdminButtonWrapper: React.ForwardRefExoticComponent<Pick<any, string | number | symbol> & React.RefAttributes<any>> = withUnstatedContainers(GrantAdminButton, [AdminUsersContainer]);
+const GrantAdminButtonWrapper: React.ForwardRefExoticComponent<
+  Pick<any, string | number | symbol> & React.RefAttributes<any>
+> = withUnstatedContainers(GrantAdminButton, [AdminUsersContainer]);
 
 
 export default GrantAdminButtonWrapper;
 export default GrantAdminButtonWrapper;

+ 16 - 11
apps/app/src/client/components/Admin/Users/GrantReadOnlyButton.tsx

@@ -1,32 +1,35 @@
-import React, { useCallback, type JSX } from 'react';
-
+import React, { type JSX, useCallback } from 'react';
 import type { IUserHasId } from '@growi/core';
 import type { IUserHasId } from '@growi/core';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 
 
 import AdminUsersContainer from '~/client/services/AdminUsersContainer';
 import AdminUsersContainer from '~/client/services/AdminUsersContainer';
-import { toastSuccess, toastError } from '~/client/util/toastr';
+import { toastError, toastSuccess } from '~/client/util/toastr';
 
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
 import { withUnstatedContainers } from '../../UnstatedUtils';
 
 
 const GrantReadOnlyButton: React.FC<{
 const GrantReadOnlyButton: React.FC<{
-  adminUsersContainer: AdminUsersContainer,
-  user: IUserHasId,
+  adminUsersContainer: AdminUsersContainer;
+  user: IUserHasId;
 }> = ({ adminUsersContainer, user }): JSX.Element => {
 }> = ({ adminUsersContainer, user }): JSX.Element => {
   const { t } = useTranslation('admin');
   const { t } = useTranslation('admin');
 
 
-  const onClickGrantReadOnlyBtnHandler = useCallback(async() => {
+  const onClickGrantReadOnlyBtnHandler = useCallback(async () => {
     try {
     try {
       const username = await adminUsersContainer.grantUserReadOnly(user._id);
       const username = await adminUsersContainer.grantUserReadOnly(user._id);
       toastSuccess(t('toaster.grant_user_read_only', { username }));
       toastSuccess(t('toaster.grant_user_read_only', { username }));
-    }
-    catch (err) {
+    } catch (err) {
       toastError(err);
       toastError(err);
     }
     }
   }, [adminUsersContainer, t, user._id]);
   }, [adminUsersContainer, t, user._id]);
 
 
   return (
   return (
-    <button className="dropdown-item" type="button" onClick={onClickGrantReadOnlyBtnHandler}>
-      <span className="material-symbols-outlined me-1">person_add</span>{t('user_management.user_table.grant_read_only_access')}
+    <button
+      className="dropdown-item"
+      type="button"
+      onClick={onClickGrantReadOnlyBtnHandler}
+    >
+      <span className="material-symbols-outlined me-1">person_add</span>
+      {t('user_management.user_table.grant_read_only_access')}
     </button>
     </button>
   );
   );
 };
 };
@@ -35,6 +38,8 @@ const GrantReadOnlyButton: React.FC<{
  * Wrapper component for using unstated
  * Wrapper component for using unstated
  */
  */
 // eslint-disable-next-line max-len
 // eslint-disable-next-line max-len
-const GrantReadOnlyButtonWrapper: React.ForwardRefExoticComponent<Pick<any, string | number | symbol> & React.RefAttributes<any>> = withUnstatedContainers(GrantReadOnlyButton, [AdminUsersContainer]);
+const GrantReadOnlyButtonWrapper: React.ForwardRefExoticComponent<
+  Pick<any, string | number | symbol> & React.RefAttributes<any>
+> = withUnstatedContainers(GrantReadOnlyButton, [AdminUsersContainer]);
 
 
 export default GrantReadOnlyButtonWrapper;
 export default GrantReadOnlyButtonWrapper;

+ 9 - 6
apps/app/src/client/components/Admin/Users/InviteUserControl.jsx

@@ -1,29 +1,29 @@
 import React, { Fragment } from 'react';
 import React, { Fragment } from 'react';
-
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
 
 
 import AdminUsersContainer from '~/client/services/AdminUsersContainer';
 import AdminUsersContainer from '~/client/services/AdminUsersContainer';
 
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
 import { withUnstatedContainers } from '../../UnstatedUtils';
-
 import UserInviteModal from './UserInviteModal';
 import UserInviteModal from './UserInviteModal';
 
 
 class InviteUserControl extends React.Component {
 class InviteUserControl extends React.Component {
-
   render() {
   render() {
     const { t, adminUsersContainer } = this.props;
     const { t, adminUsersContainer } = this.props;
 
 
     return (
     return (
       <Fragment>
       <Fragment>
-        <button type="button" className="btn btn-outline-secondary" onClick={adminUsersContainer.toggleUserInviteModal}>
+        <button
+          type="button"
+          className="btn btn-outline-secondary"
+          onClick={adminUsersContainer.toggleUserInviteModal}
+        >
           {t('admin:user_management.invite_users')}
           {t('admin:user_management.invite_users')}
         </button>
         </button>
         <UserInviteModal />
         <UserInviteModal />
       </Fragment>
       </Fragment>
     );
     );
   }
   }
-
 }
 }
 
 
 InviteUserControl.propTypes = {
 InviteUserControl.propTypes = {
@@ -39,6 +39,9 @@ const InviteUserControlWrapperFC = (props) => {
 /**
 /**
  * Wrapper component for using unstated
  * Wrapper component for using unstated
  */
  */
-const InviteUserControlWrapper = withUnstatedContainers(InviteUserControlWrapperFC, [AdminUsersContainer]);
+const InviteUserControlWrapper = withUnstatedContainers(
+  InviteUserControlWrapperFC,
+  [AdminUsersContainer],
+);
 
 
 export default InviteUserControlWrapper;
 export default InviteUserControlWrapper;

+ 93 - 39
apps/app/src/client/components/Admin/Users/PasswordResetModal.jsx

@@ -1,12 +1,15 @@
 import React from 'react';
 import React from 'react';
-
 import { LoadingSpinner } from '@growi/ui/dist/components';
 import { LoadingSpinner } from '@growi/ui/dist/components';
 import { useAtomValue } from 'jotai';
 import { useAtomValue } from 'jotai';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
 import { CopyToClipboard } from 'react-copy-to-clipboard';
 import { CopyToClipboard } from 'react-copy-to-clipboard';
 import {
 import {
-  Modal, ModalHeader, ModalBody, ModalFooter, Tooltip,
+  Modal,
+  ModalBody,
+  ModalFooter,
+  ModalHeader,
+  Tooltip,
 } from 'reactstrap';
 } from 'reactstrap';
 
 
 import AdminUsersContainer from '~/client/services/AdminUsersContainer';
 import AdminUsersContainer from '~/client/services/AdminUsersContainer';
@@ -15,7 +18,6 @@ import { toastError } from '~/client/util/toastr';
 import { isMailerSetupAtom } from '~/states/server-configurations';
 import { isMailerSetupAtom } from '~/states/server-configurations';
 
 
 class PasswordResetModal extends React.Component {
 class PasswordResetModal extends React.Component {
-
   constructor(props) {
   constructor(props) {
     super(props);
     super(props);
 
 
@@ -28,17 +30,22 @@ class PasswordResetModal extends React.Component {
     };
     };
 
 
     this.resetPassword = this.resetPassword.bind(this);
     this.resetPassword = this.resetPassword.bind(this);
-    this.onClickSendNewPasswordButton = this.onClickSendNewPasswordButton.bind(this);
+    this.onClickSendNewPasswordButton =
+      this.onClickSendNewPasswordButton.bind(this);
   }
   }
 
 
   async resetPassword() {
   async resetPassword() {
     const { userForPasswordResetModal } = this.props;
     const { userForPasswordResetModal } = this.props;
     try {
     try {
-      const res = await apiv3Put('/users/reset-password', { id: userForPasswordResetModal._id });
+      const res = await apiv3Put('/users/reset-password', {
+        id: userForPasswordResetModal._id,
+      });
       const { newPassword } = res.data;
       const { newPassword } = res.data;
-      this.setState({ temporaryPassword: newPassword, isPasswordResetDone: true });
-    }
-    catch (err) {
+      this.setState({
+        temporaryPassword: newPassword,
+        isPasswordResetDone: true,
+      });
+    } catch (err) {
       toastError(err);
       toastError(err);
     }
     }
   }
   }
@@ -56,9 +63,14 @@ class PasswordResetModal extends React.Component {
           disabled={!isMailerSetup || isEmailSending || isEmailSent}
           disabled={!isMailerSetup || isEmailSending || isEmailSent}
         >
         >
           {isEmailSending && <LoadingSpinner className="mx-2" />}
           {isEmailSending && <LoadingSpinner className="mx-2" />}
-          {!isEmailSending && (isEmailSent ? t('commons:Done') : t('commons:Send'))}
+          {!isEmailSending &&
+            (isEmailSent ? t('commons:Done') : t('commons:Send'))}
         </button>
         </button>
-        <button type="submit" className="btn btn-danger" onClick={this.props.onClose}>
+        <button
+          type="submit"
+          className="btn btn-danger"
+          onClick={this.props.onClose}
+        >
           {t('commons:Close')}
           {t('commons:Close')}
         </button>
         </button>
       </>
       </>
@@ -71,7 +83,14 @@ class PasswordResetModal extends React.Component {
     return (
     return (
       <div className="d-flex col text-start ms-1 ps-0">
       <div className="d-flex col text-start ms-1 ps-0">
         {!isMailerSetup ? (
         {!isMailerSetup ? (
-          <label className="form-label form-text text-muted" dangerouslySetInnerHTML={{ __html: t('admin:mailer_setup_required') }} />
+          <p
+            className="form-label form-text text-muted mb-0"
+            // eslint-disable-next-line react/no-danger
+            // biome-ignore lint/security/noDangerouslySetInnerHtml: includes markup from i18n strings
+            dangerouslySetInnerHTML={{
+              __html: t('admin:mailer_setup_required'),
+            }}
+          />
         ) : (
         ) : (
           <>
           <>
             <p className="me-2">To:</p>
             <p className="me-2">To:</p>
@@ -91,11 +110,15 @@ class PasswordResetModal extends React.Component {
     return (
     return (
       <>
       <>
         <p>
         <p>
-          {t('user_management.reset_password_modal.password_never_seen')}<br />
-          <span className="text-danger">{t('user_management.reset_password_modal.send_new_password')}</span>
+          {t('user_management.reset_password_modal.password_never_seen')}
+          <br />
+          <span className="text-danger">
+            {t('user_management.reset_password_modal.send_new_password')}
+          </span>
         </p>
         </p>
         <p>
         <p>
-          {t('user_management.reset_password_modal.target_user')}: <code>{userForPasswordResetModal.email}</code>
+          {t('user_management.reset_password_modal.target_user')}:{' '}
+          <code>{userForPasswordResetModal.email}</code>
         </p>
         </p>
       </>
       </>
     );
     );
@@ -111,23 +134,41 @@ class PasswordResetModal extends React.Component {
 
 
     return (
     return (
       <>
       <>
-        <p className="text-danger">{t('user_management.reset_password_modal.password_reset_message')}</p>
+        <p className="text-danger">
+          {t('user_management.reset_password_modal.password_reset_message')}
+        </p>
         <p>
         <p>
-          {t('user_management.reset_password_modal.target_user')}: <code>{userForPasswordResetModal.email}</code>
+          {t('user_management.reset_password_modal.target_user')}:{' '}
+          <code>{userForPasswordResetModal.email}</code>
         </p>
         </p>
         <p>
         <p>
           {t('user_management.reset_password_modal.new_password')}:{' '}
           {t('user_management.reset_password_modal.new_password')}:{' '}
           <code>
           <code>
-            <span
+            <button
+              type="button"
+              className="btn btn-link p-0 align-baseline"
               onMouseEnter={() => this.setState({ showPassword: true })}
               onMouseEnter={() => this.setState({ showPassword: true })}
               onMouseLeave={() => this.setState({ showPassword: false })}
               onMouseLeave={() => this.setState({ showPassword: false })}
+              aria-pressed={showPassword}
+              aria-label={t(
+                'user_management.reset_password_modal.new_password',
+              )}
             >
             >
               {showPassword ? temporaryPassword : maskedPassword}
               {showPassword ? temporaryPassword : maskedPassword}
-            </span>
+            </button>
           </code>
           </code>
-          <CopyToClipboard text={temporaryPassword} onCopy={() => this.setState({ showTooltip: true })}>
-            <button id="copy-tooltip" type="button" className="btn btn-outline-secondary border-0">
-              <span className="material-symbols-outlined" aria-hidden="true">content_copy</span>
+          <CopyToClipboard
+            text={temporaryPassword}
+            onCopy={() => this.setState({ showTooltip: true })}
+          >
+            <button
+              id="copy-tooltip"
+              type="button"
+              className="btn btn-outline-secondary border-0"
+            >
+              <span className="material-symbols-outlined" aria-hidden="true">
+                content_copy
+              </span>
             </button>
             </button>
           </CopyToClipboard>
           </CopyToClipboard>
           <Tooltip
           <Tooltip
@@ -146,7 +187,11 @@ class PasswordResetModal extends React.Component {
   returnModalFooterBeforeReset() {
   returnModalFooterBeforeReset() {
     const { t } = this.props;
     const { t } = this.props;
     return (
     return (
-      <button type="submit" className="btn btn-danger" onClick={this.resetPassword}>
+      <button
+        type="submit"
+        className="btn btn-danger"
+        onClick={this.resetPassword}
+      >
         {t('user_management.reset_password')}
         {t('user_management.reset_password')}
       </button>
       </button>
     );
     );
@@ -162,51 +207,61 @@ class PasswordResetModal extends React.Component {
   }
   }
 
 
   async onClickSendNewPasswordButton() {
   async onClickSendNewPasswordButton() {
-
-    const {
-      userForPasswordResetModal,
-    } = this.props;
+    const { userForPasswordResetModal } = this.props;
 
 
     this.setState({ isEmailSending: true });
     this.setState({ isEmailSending: true });
 
 
     try {
     try {
-      await apiv3Put('/users/reset-password-email', { id: userForPasswordResetModal._id, newPassword: this.state.temporaryPassword });
+      await apiv3Put('/users/reset-password-email', {
+        id: userForPasswordResetModal._id,
+        newPassword: this.state.temporaryPassword,
+      });
       this.setState({ isEmailSent: true });
       this.setState({ isEmailSent: true });
-    }
-    catch (err) {
+    } catch (err) {
       this.setState({ isEmailSent: false });
       this.setState({ isEmailSent: false });
       toastError(err);
       toastError(err);
-    }
-    finally {
+    } finally {
       this.setState({ isEmailSending: false });
       this.setState({ isEmailSending: false });
     }
     }
   }
   }
 
 
-
   render() {
   render() {
     const { t } = this.props;
     const { t } = this.props;
 
 
     return (
     return (
       <Modal isOpen={this.props.isOpen} toggle={this.props.onClose}>
       <Modal isOpen={this.props.isOpen} toggle={this.props.onClose}>
-        <ModalHeader tag="h4" toggle={this.props.onClose} className="text-warning">
-          {t('user_management.reset_password') }
+        <ModalHeader
+          tag="h4"
+          toggle={this.props.onClose}
+          className="text-warning"
+        >
+          {t('user_management.reset_password')}
         </ModalHeader>
         </ModalHeader>
         <ModalBody>
         <ModalBody>
-          {this.state.isPasswordResetDone ? this.returnModalBodyAfterReset() : this.renderModalBodyBeforeReset()}
+          {this.state.isPasswordResetDone
+            ? this.returnModalBodyAfterReset()
+            : this.renderModalBodyBeforeReset()}
         </ModalBody>
         </ModalBody>
         <ModalFooter>
         <ModalFooter>
-          {this.state.isPasswordResetDone ? this.returnModalFooterAfterReset() : this.returnModalFooterBeforeReset()}
+          {this.state.isPasswordResetDone
+            ? this.returnModalFooterAfterReset()
+            : this.returnModalFooterBeforeReset()}
         </ModalFooter>
         </ModalFooter>
       </Modal>
       </Modal>
     );
     );
   }
   }
-
 }
 }
 
 
 const PasswordResetModalWrapperFC = (props) => {
 const PasswordResetModalWrapperFC = (props) => {
   const { t } = useTranslation('admin');
   const { t } = useTranslation('admin');
   const isMailerSetup = useAtomValue(isMailerSetupAtom);
   const isMailerSetup = useAtomValue(isMailerSetupAtom);
-  return <PasswordResetModal t={t} isMailerSetup={isMailerSetup ?? false} {...props} />;
+  return (
+    <PasswordResetModal
+      t={t}
+      isMailerSetup={isMailerSetup ?? false}
+      {...props}
+    />
+  );
 };
 };
 
 
 /**
 /**
@@ -222,7 +277,6 @@ PasswordResetModal.propTypes = {
   userForPasswordResetModal: PropTypes.object,
   userForPasswordResetModal: PropTypes.object,
   onSuccessfullySentNewPasswordEmail: PropTypes.func.isRequired,
   onSuccessfullySentNewPasswordEmail: PropTypes.func.isRequired,
   adminUsersContainer: PropTypes.instanceOf(AdminUsersContainer).isRequired,
   adminUsersContainer: PropTypes.instanceOf(AdminUsersContainer).isRequired,
-
 };
 };
 
 
 export default PasswordResetModalWrapperFC;
 export default PasswordResetModalWrapperFC;

+ 28 - 18
apps/app/src/client/components/Admin/Users/RevokeAdminButton.tsx

@@ -1,39 +1,41 @@
-import React, { useCallback, type JSX } from 'react';
-
+import React, { type JSX, useCallback } from 'react';
 import type { IUserHasId } from '@growi/core';
 import type { IUserHasId } from '@growi/core';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 
 
 import AdminUsersContainer from '~/client/services/AdminUsersContainer';
 import AdminUsersContainer from '~/client/services/AdminUsersContainer';
-import { toastSuccess, toastError } from '~/client/util/toastr';
+import { toastError, toastSuccess } from '~/client/util/toastr';
 import { useCurrentUser } from '~/states/global';
 import { useCurrentUser } from '~/states/global';
 
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
 import { withUnstatedContainers } from '../../UnstatedUtils';
 
 
 type RevokeAdminButtonProps = {
 type RevokeAdminButtonProps = {
-  adminUsersContainer: AdminUsersContainer,
-  user: IUserHasId,
-}
+  adminUsersContainer: AdminUsersContainer;
+  user: IUserHasId;
+};
 
 
 const RevokeAdminButton = (props: RevokeAdminButtonProps): JSX.Element => {
 const RevokeAdminButton = (props: RevokeAdminButtonProps): JSX.Element => {
-
   const { t } = useTranslation('admin');
   const { t } = useTranslation('admin');
   const currentUser = useCurrentUser(); // hook returns single value now
   const currentUser = useCurrentUser(); // hook returns single value now
   const { adminUsersContainer, user } = props;
   const { adminUsersContainer, user } = props;
 
 
-  const onClickRevokeAdminBtnHandler = useCallback(async() => {
+  const onClickRevokeAdminBtnHandler = useCallback(async () => {
     try {
     try {
       const username = await adminUsersContainer.revokeUserAdmin(user._id);
       const username = await adminUsersContainer.revokeUserAdmin(user._id);
       toastSuccess(t('toaster.revoke_user_admin', { username }));
       toastSuccess(t('toaster.revoke_user_admin', { username }));
-    }
-    catch (err) {
+    } catch (err) {
       toastError(err);
       toastError(err);
     }
     }
   }, [adminUsersContainer, t, user._id]);
   }, [adminUsersContainer, t, user._id]);
 
 
   const renderRevokeAdminBtn = () => {
   const renderRevokeAdminBtn = () => {
     return (
     return (
-      <button className="dropdown-item" type="button" onClick={() => onClickRevokeAdminBtnHandler()}>
-        <span className="material-symbols-outlined me-1">person_remove</span>{t('user_management.user_table.revoke_admin_access')}
+      <button
+        className="dropdown-item"
+        type="button"
+        onClick={() => onClickRevokeAdminBtnHandler()}
+      >
+        <span className="material-symbols-outlined me-1">person_remove</span>
+        {t('user_management.user_table.revoke_admin_access')}
       </button>
       </button>
     );
     );
   };
   };
@@ -41,8 +43,13 @@ const RevokeAdminButton = (props: RevokeAdminButtonProps): JSX.Element => {
   const renderRevokeAdminAlert = () => {
   const renderRevokeAdminAlert = () => {
     return (
     return (
       <div className="px-4">
       <div className="px-4">
-        <span className="material-symbols-outlined me-1 mb-2">person_remove</span>{t('user_management.user_table.revoke_admin_access')}
-        <p className="alert alert-danger">{t('user_management.user_table.cannot_revoke')}</p>
+        <span className="material-symbols-outlined me-1 mb-2">
+          person_remove
+        </span>
+        {t('user_management.user_table.revoke_admin_access')}
+        <p className="alert alert-danger">
+          {t('user_management.user_table.cannot_revoke')}
+        </p>
       </div>
       </div>
     );
     );
   };
   };
@@ -53,15 +60,18 @@ const RevokeAdminButton = (props: RevokeAdminButtonProps): JSX.Element => {
 
 
   return (
   return (
     <>
     <>
-      {user.username !== currentUser.username ? renderRevokeAdminBtn()
+      {user.username !== currentUser.username
+        ? renderRevokeAdminBtn()
         : renderRevokeAdminAlert()}
         : renderRevokeAdminAlert()}
     </>
     </>
   );
   );
 };
 };
 
 
 /**
 /**
-* Wrapper component for using unstated
-*/
-const RevokeAdminButtonWrapper = withUnstatedContainers(RevokeAdminButton, [AdminUsersContainer]);
+ * Wrapper component for using unstated
+ */
+const RevokeAdminButtonWrapper = withUnstatedContainers(RevokeAdminButton, [
+  AdminUsersContainer,
+]);
 
 
 export default RevokeAdminButtonWrapper;
 export default RevokeAdminButtonWrapper;

+ 29 - 24
apps/app/src/client/components/Admin/Users/RevokeAdminMenuItem.tsx

@@ -1,32 +1,32 @@
-import React, { useCallback, type JSX } from 'react';
-
+import React, { type JSX, useCallback } from 'react';
 import type { IUserHasId } from '@growi/core';
 import type { IUserHasId } from '@growi/core';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 
 
 import AdminUsersContainer from '~/client/services/AdminUsersContainer';
 import AdminUsersContainer from '~/client/services/AdminUsersContainer';
-import { toastSuccess, toastError } from '~/client/util/toastr';
+import { toastError, toastSuccess } from '~/client/util/toastr';
 import { useCurrentUser } from '~/states/global';
 import { useCurrentUser } from '~/states/global';
 
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
 import { withUnstatedContainers } from '../../UnstatedUtils';
 
 
-
 const RevokeAdminAlert = React.memo((): JSX.Element => {
 const RevokeAdminAlert = React.memo((): JSX.Element => {
   const { t } = useTranslation();
   const { t } = useTranslation();
 
 
   return (
   return (
     <div className="px-4">
     <div className="px-4">
-      <span className="material-symbols-outlined me-1 mb-2">person_remove</span>{t('admin:user_management.user_table.revoke_admin_access')}
-      <p className="alert alert-danger">{t('admin:user_management.user_table.cannot_revoke')}</p>
+      <span className="material-symbols-outlined me-1 mb-2">person_remove</span>
+      {t('admin:user_management.user_table.revoke_admin_access')}
+      <p className="alert alert-danger">
+        {t('admin:user_management.user_table.cannot_revoke')}
+      </p>
     </div>
     </div>
   );
   );
 });
 });
 RevokeAdminAlert.displayName = 'RevokeAdminAlert';
 RevokeAdminAlert.displayName = 'RevokeAdminAlert';
 
 
-
 type Props = {
 type Props = {
-  adminUsersContainer: AdminUsersContainer,
-  user: IUserHasId,
-}
+  adminUsersContainer: AdminUsersContainer;
+  user: IUserHasId;
+};
 
 
 const RevokeAdminMenuItem = (props: Props): JSX.Element => {
 const RevokeAdminMenuItem = (props: Props): JSX.Element => {
   const { t } = useTranslation('admin');
   const { t } = useTranslation('admin');
@@ -35,30 +35,35 @@ const RevokeAdminMenuItem = (props: Props): JSX.Element => {
 
 
   const currentUser = useCurrentUser();
   const currentUser = useCurrentUser();
 
 
-  const clickRevokeAdminBtnHandler = useCallback(async() => {
+  const clickRevokeAdminBtnHandler = useCallback(async () => {
     try {
     try {
       const username = await adminUsersContainer.revokeUserAdmin(user._id);
       const username = await adminUsersContainer.revokeUserAdmin(user._id);
       toastSuccess(t('toaster.revoke_user_admin', { username }));
       toastSuccess(t('toaster.revoke_user_admin', { username }));
-    }
-    catch (err) {
+    } catch (err) {
       toastError(err);
       toastError(err);
     }
     }
   }, [adminUsersContainer, t, user._id]);
   }, [adminUsersContainer, t, user._id]);
 
 
-
-  return user.username !== currentUser?.username
-    ? (
-      <button className="dropdown-item" type="button" onClick={clickRevokeAdminBtnHandler}>
-        <span className="material-symbols-outlined me-1">person_remove</span> {t('user_management.user_table.revoke_admin_access')}
-      </button>
-    )
-    : <RevokeAdminAlert />;
+  return user.username !== currentUser?.username ? (
+    <button
+      className="dropdown-item"
+      type="button"
+      onClick={clickRevokeAdminBtnHandler}
+    >
+      <span className="material-symbols-outlined me-1">person_remove</span>{' '}
+      {t('user_management.user_table.revoke_admin_access')}
+    </button>
+  ) : (
+    <RevokeAdminAlert />
+  );
 };
 };
 
 
 /**
 /**
-* Wrapper component for using unstated
-*/
+ * Wrapper component for using unstated
+ */
 // eslint-disable-next-line max-len
 // eslint-disable-next-line max-len
-const RevokeAdminMenuItemWrapper: React.ForwardRefExoticComponent<Pick<any, string | number | symbol> & React.RefAttributes<any>> = withUnstatedContainers(RevokeAdminMenuItem, [AdminUsersContainer]);
+const RevokeAdminMenuItemWrapper: React.ForwardRefExoticComponent<
+  Pick<any, string | number | symbol> & React.RefAttributes<any>
+> = withUnstatedContainers(RevokeAdminMenuItem, [AdminUsersContainer]);
 
 
 export default RevokeAdminMenuItemWrapper;
 export default RevokeAdminMenuItemWrapper;

+ 18 - 13
apps/app/src/client/components/Admin/Users/RevokeReadOnlyMenuItem.tsx

@@ -1,40 +1,45 @@
-import React, { useCallback, type JSX } from 'react';
-
+import React, { type JSX, useCallback } from 'react';
 import type { IUserHasId } from '@growi/core';
 import type { IUserHasId } from '@growi/core';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 
 
 import AdminUsersContainer from '~/client/services/AdminUsersContainer';
 import AdminUsersContainer from '~/client/services/AdminUsersContainer';
-import { toastSuccess, toastError } from '~/client/util/toastr';
+import { toastError, toastSuccess } from '~/client/util/toastr';
 
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
 import { withUnstatedContainers } from '../../UnstatedUtils';
 
 
 const RevokeReadOnlyMenuItem: React.FC<{
 const RevokeReadOnlyMenuItem: React.FC<{
-  adminUsersContainer: AdminUsersContainer,
-  user: IUserHasId,
+  adminUsersContainer: AdminUsersContainer;
+  user: IUserHasId;
 }> = ({ adminUsersContainer, user }): JSX.Element => {
 }> = ({ adminUsersContainer, user }): JSX.Element => {
   const { t } = useTranslation('admin');
   const { t } = useTranslation('admin');
 
 
-  const clickRevokeReadOnlyBtnHandler = useCallback(async() => {
+  const clickRevokeReadOnlyBtnHandler = useCallback(async () => {
     try {
     try {
       const username = await adminUsersContainer.revokeUserReadOnly(user._id);
       const username = await adminUsersContainer.revokeUserReadOnly(user._id);
       toastSuccess(t('toaster.revoke_user_read_only', { username }));
       toastSuccess(t('toaster.revoke_user_read_only', { username }));
-    }
-    catch (err) {
+    } catch (err) {
       toastError(err);
       toastError(err);
     }
     }
   }, [adminUsersContainer, t, user._id]);
   }, [adminUsersContainer, t, user._id]);
 
 
   return (
   return (
-    <button className="dropdown-item" type="button" onClick={clickRevokeReadOnlyBtnHandler}>
-      <span className="material-symbols-outlined me-1">person_remove</span> {t('user_management.user_table.revoke_read_only_access')}
+    <button
+      className="dropdown-item"
+      type="button"
+      onClick={clickRevokeReadOnlyBtnHandler}
+    >
+      <span className="material-symbols-outlined me-1">person_remove</span>{' '}
+      {t('user_management.user_table.revoke_read_only_access')}
     </button>
     </button>
   );
   );
 };
 };
 
 
 /**
 /**
-* Wrapper component for using unstated
-*/
+ * Wrapper component for using unstated
+ */
 // eslint-disable-next-line max-len
 // eslint-disable-next-line max-len
-const RevokeReadOnlyMenuItemWrapper: React.ForwardRefExoticComponent<Pick<any, string | number | symbol> & React.RefAttributes<any>> = withUnstatedContainers(RevokeReadOnlyMenuItem, [AdminUsersContainer]);
+const RevokeReadOnlyMenuItemWrapper: React.ForwardRefExoticComponent<
+  Pick<any, string | number | symbol> & React.RefAttributes<any>
+> = withUnstatedContainers(RevokeReadOnlyMenuItem, [AdminUsersContainer]);
 
 
 export default RevokeReadOnlyMenuItemWrapper;
 export default RevokeReadOnlyMenuItemWrapper;

+ 29 - 16
apps/app/src/client/components/Admin/Users/SendInvitationEmailButton.jsx

@@ -1,51 +1,64 @@
 import React from 'react';
 import React from 'react';
-
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
 
 
 import AdminUsersContainer from '~/client/services/AdminUsersContainer';
 import AdminUsersContainer from '~/client/services/AdminUsersContainer';
 import { apiv3Put } from '~/client/util/apiv3-client';
 import { apiv3Put } from '~/client/util/apiv3-client';
-import { toastSuccess, toastError } from '~/client/util/toastr';
+import { toastError, toastSuccess } from '~/client/util/toastr';
 
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
 import { withUnstatedContainers } from '../../UnstatedUtils';
 
 
 const SendInvitationEmailButton = (props) => {
 const SendInvitationEmailButton = (props) => {
-  const {
-    user, isInvitationEmailSended, onSuccessfullySentInvitationEmail,
-  } = props;
+  const { user, isInvitationEmailSended, onSuccessfullySentInvitationEmail } =
+    props;
   const { t } = useTranslation();
   const { t } = useTranslation();
 
 
   const textColor = !isInvitationEmailSended ? 'text-danger' : '';
   const textColor = !isInvitationEmailSended ? 'text-danger' : '';
 
 
-  const onClickSendInvitationEmailButton = async() => {
+  const onClickSendInvitationEmailButton = async () => {
     try {
     try {
-      const res = await apiv3Put('/users/send-invitation-email', { id: user._id });
+      const res = await apiv3Put('/users/send-invitation-email', {
+        id: user._id,
+      });
       const { failedToSendEmail } = res.data;
       const { failedToSendEmail } = res.data;
       if (failedToSendEmail == null) {
       if (failedToSendEmail == null) {
         const msg = `Email has been sent<br>・${user.email}`;
         const msg = `Email has been sent<br>・${user.email}`;
         toastSuccess(msg);
         toastSuccess(msg);
         onSuccessfullySentInvitationEmail();
         onSuccessfullySentInvitationEmail();
-      }
-      else {
-        const msg = { message: `email: ${failedToSendEmail.email}<br>reason: ${failedToSendEmail.reason}` };
+      } else {
+        const msg = {
+          message: `email: ${failedToSendEmail.email}<br>reason: ${failedToSendEmail.reason}`,
+        };
         toastError(msg);
         toastError(msg);
       }
       }
-    }
-    catch (err) {
+    } catch (err) {
       toastError(err);
       toastError(err);
     }
     }
   };
   };
 
 
   return (
   return (
-    <button className={`dropdown-item ${textColor}`} type="button" onClick={() => { onClickSendInvitationEmailButton() }}>
+    <button
+      className={`dropdown-item ${textColor}`}
+      type="button"
+      onClick={() => {
+        onClickSendInvitationEmailButton();
+      }}
+    >
       <span className="material-symbols-outlined me-1">mail</span>
       <span className="material-symbols-outlined me-1">mail</span>
-      {isInvitationEmailSended && (<>{t('admin:user_management.user_table.resend_invitation_email')}</>)}
-      {!isInvitationEmailSended && (<>{t('admin:user_management.user_table.send_invitation_email')}</>)}
+      {isInvitationEmailSended && (
+        <>{t('admin:user_management.user_table.resend_invitation_email')}</>
+      )}
+      {!isInvitationEmailSended && (
+        <>{t('admin:user_management.user_table.send_invitation_email')}</>
+      )}
     </button>
     </button>
   );
   );
 };
 };
 
 
-const SendInvitationEmailButtonWrapper = withUnstatedContainers(SendInvitationEmailButton, [AdminUsersContainer]);
+const SendInvitationEmailButtonWrapper = withUnstatedContainers(
+  SendInvitationEmailButton,
+  [AdminUsersContainer],
+);
 
 
 SendInvitationEmailButton.propTypes = {
 SendInvitationEmailButton.propTypes = {
   user: PropTypes.object.isRequired,
   user: PropTypes.object.isRequired,

+ 16 - 13
apps/app/src/client/components/Admin/Users/SortIcons.tsx

@@ -1,31 +1,34 @@
 import React, { type JSX } from 'react';
 import React, { type JSX } from 'react';
 
 
 type SortIconsProps = {
 type SortIconsProps = {
-  onClick: (sortOrder: string) => void,
-  isSelected: boolean,
-  isAsc: boolean,
-}
+  onClick: (sortOrder: string) => void;
+  isSelected: boolean;
+  isAsc: boolean;
+};
 
 
 export const SortIcons = (props: SortIconsProps): JSX.Element => {
 export const SortIcons = (props: SortIconsProps): JSX.Element => {
-
   const { onClick, isSelected, isAsc } = props;
   const { onClick, isSelected, isAsc } = props;
 
 
   return (
   return (
     <div className="d-flex flex-column text-center">
     <div className="d-flex flex-column text-center">
-      <a
-        className={`${isSelected && isAsc ? 'text-primary' : 'text-muted'}`}
-        aria-hidden="true"
+      <button
+        type="button"
+        className={`${isSelected && isAsc ? 'text-primary' : 'text-muted'} btn btn-link p-0`}
         onClick={() => onClick('asc')}
         onClick={() => onClick('asc')}
+        aria-pressed={isSelected && isAsc}
+        aria-label="Sort ascending"
       >
       >
         <span className="material-symbols-outlined">expand_less</span>
         <span className="material-symbols-outlined">expand_less</span>
-      </a>
-      <a
-        className={`${isSelected && !isAsc ? 'text-primary' : 'text-muted'}`}
-        aria-hidden="true"
+      </button>
+      <button
+        type="button"
+        className={`${isSelected && !isAsc ? 'text-primary' : 'text-muted'} btn btn-link p-0`}
         onClick={() => onClick('desc')}
         onClick={() => onClick('desc')}
+        aria-pressed={isSelected && !isAsc}
+        aria-label="Sort descending"
       >
       >
         <span className="material-symbols-outlined">expand_more</span>
         <span className="material-symbols-outlined">expand_more</span>
-      </a>
+      </button>
     </div>
     </div>
   );
   );
 };
 };

+ 18 - 10
apps/app/src/client/components/Admin/Users/StatusActivateButton.jsx

@@ -1,15 +1,13 @@
 import React from 'react';
 import React from 'react';
-
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
 
 
 import AdminUsersContainer from '~/client/services/AdminUsersContainer';
 import AdminUsersContainer from '~/client/services/AdminUsersContainer';
-import { toastSuccess, toastError } from '~/client/util/toastr';
+import { toastError, toastSuccess } from '~/client/util/toastr';
 
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
 import { withUnstatedContainers } from '../../UnstatedUtils';
 
 
 class StatusActivateButton extends React.Component {
 class StatusActivateButton extends React.Component {
-
   constructor(props) {
   constructor(props) {
     super(props);
     super(props);
 
 
@@ -20,10 +18,11 @@ class StatusActivateButton extends React.Component {
     const { t } = this.props;
     const { t } = this.props;
 
 
     try {
     try {
-      const username = await this.props.adminUsersContainer.activateUser(this.props.user._id);
+      const username = await this.props.adminUsersContainer.activateUser(
+        this.props.user._id,
+      );
       toastSuccess(t('toaster.activate_user_success', { username }));
       toastSuccess(t('toaster.activate_user_success', { username }));
-    }
-    catch (err) {
+    } catch (err) {
       toastError(err);
       toastError(err);
     }
     }
   }
   }
@@ -32,12 +31,18 @@ class StatusActivateButton extends React.Component {
     const { t } = this.props;
     const { t } = this.props;
 
 
     return (
     return (
-      <button className="dropdown-item" type="button" onClick={() => { this.onClickAcceptBtn() }}>
-        <span className="material-symbols-outlined me-1">person_add</span>{t('user_management.user_table.accept')}
+      <button
+        className="dropdown-item"
+        type="button"
+        onClick={() => {
+          this.onClickAcceptBtn();
+        }}
+      >
+        <span className="material-symbols-outlined me-1">person_add</span>
+        {t('user_management.user_table.accept')}
       </button>
       </button>
     );
     );
   }
   }
-
 }
 }
 
 
 const StatusActivateFormWrapperFC = (props) => {
 const StatusActivateFormWrapperFC = (props) => {
@@ -48,7 +53,10 @@ const StatusActivateFormWrapperFC = (props) => {
 /**
 /**
  * Wrapper component for using unstated
  * Wrapper component for using unstated
  */
  */
-const StatusActivateFormWrapper = withUnstatedContainers(StatusActivateFormWrapperFC, [AdminUsersContainer]);
+const StatusActivateFormWrapper = withUnstatedContainers(
+  StatusActivateFormWrapperFC,
+  [AdminUsersContainer],
+);
 
 
 StatusActivateButton.propTypes = {
 StatusActivateButton.propTypes = {
   t: PropTypes.func.isRequired, // i18next
   t: PropTypes.func.isRequired, // i18next

+ 27 - 20
apps/app/src/client/components/Admin/Users/StatusSuspendMenuItem.tsx

@@ -1,21 +1,22 @@
-import React, { useCallback, type JSX } from 'react';
-
+import React, { type JSX, useCallback } from 'react';
 import type { IUserHasId } from '@growi/core';
 import type { IUserHasId } from '@growi/core';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 
 
 import { withUnstatedContainers } from '~/client/components/UnstatedUtils';
 import { withUnstatedContainers } from '~/client/components/UnstatedUtils';
 import AdminUsersContainer from '~/client/services/AdminUsersContainer';
 import AdminUsersContainer from '~/client/services/AdminUsersContainer';
-import { toastSuccess, toastError } from '~/client/util/toastr';
+import { toastError, toastSuccess } from '~/client/util/toastr';
 import { useCurrentUser } from '~/states/global';
 import { useCurrentUser } from '~/states/global';
 
 
-
 const SuspendAlert = React.memo((): JSX.Element => {
 const SuspendAlert = React.memo((): JSX.Element => {
   const { t } = useTranslation();
   const { t } = useTranslation();
 
 
   return (
   return (
     <div className="px-4">
     <div className="px-4">
-      <span className="material-symbols-outlined me-1 mb-2">cancel</span>{t('admin:user_management.user_table.deactivate_account')}
-      <p className="alert alert-danger">{t('admin:user_management.user_table.your_own')}</p>
+      <span className="material-symbols-outlined me-1 mb-2">cancel</span>
+      {t('admin:user_management.user_table.deactivate_account')}
+      <p className="alert alert-danger">
+        {t('admin:user_management.user_table.your_own')}
+      </p>
     </div>
     </div>
   );
   );
 });
 });
@@ -23,9 +24,9 @@ const SuspendAlert = React.memo((): JSX.Element => {
 SuspendAlert.displayName = 'SuspendAlert';
 SuspendAlert.displayName = 'SuspendAlert';
 
 
 type Props = {
 type Props = {
-  adminUsersContainer: AdminUsersContainer,
-  user: IUserHasId,
-}
+  adminUsersContainer: AdminUsersContainer;
+  user: IUserHasId;
+};
 
 
 const StatusSuspendMenuItem = (props: Props): JSX.Element => {
 const StatusSuspendMenuItem = (props: Props): JSX.Element => {
   const { t } = useTranslation('admin');
   const { t } = useTranslation('admin');
@@ -34,29 +35,35 @@ const StatusSuspendMenuItem = (props: Props): JSX.Element => {
 
 
   const currentUser = useCurrentUser(); // custom hook now returns single value
   const currentUser = useCurrentUser(); // custom hook now returns single value
 
 
-  const clickDeactiveBtnHandler = useCallback(async() => {
+  const clickDeactiveBtnHandler = useCallback(async () => {
     try {
     try {
       const username = await adminUsersContainer.deactivateUser(user._id);
       const username = await adminUsersContainer.deactivateUser(user._id);
       toastSuccess(t('toaster.deactivate_user_success', { username }));
       toastSuccess(t('toaster.deactivate_user_success', { username }));
-    }
-    catch (err) {
+    } catch (err) {
       toastError(err);
       toastError(err);
     }
     }
   }, [adminUsersContainer, t, user._id]);
   }, [adminUsersContainer, t, user._id]);
 
 
-  return user.username !== currentUser?.username
-    ? (
-      <button className="dropdown-item" type="button" onClick={clickDeactiveBtnHandler}>
-        <span className="material-symbols-outlined me-1">cancel</span> {t('user_management.user_table.deactivate_account')}
-      </button>
-    )
-    : <SuspendAlert />;
+  return user.username !== currentUser?.username ? (
+    <button
+      className="dropdown-item"
+      type="button"
+      onClick={clickDeactiveBtnHandler}
+    >
+      <span className="material-symbols-outlined me-1">cancel</span>{' '}
+      {t('user_management.user_table.deactivate_account')}
+    </button>
+  ) : (
+    <SuspendAlert />
+  );
 };
 };
 
 
 /**
 /**
  * Wrapper component for using unstated
  * Wrapper component for using unstated
  */
  */
 // eslint-disable-next-line max-len
 // eslint-disable-next-line max-len
-const StatusSuspendMenuItemWrapper: React.ForwardRefExoticComponent<Pick<any, string | number | symbol> & React.RefAttributes<any>> = withUnstatedContainers(StatusSuspendMenuItem, [AdminUsersContainer]);
+const StatusSuspendMenuItemWrapper: React.ForwardRefExoticComponent<
+  Pick<any, string | number | symbol> & React.RefAttributes<any>
+> = withUnstatedContainers(StatusSuspendMenuItem, [AdminUsersContainer]);
 
 
 export default StatusSuspendMenuItemWrapper;
 export default StatusSuspendMenuItemWrapper;

+ 88 - 44
apps/app/src/client/components/Admin/Users/UserInviteModal.jsx

@@ -1,22 +1,18 @@
 import React from 'react';
 import React from 'react';
-
 import { useAtomValue } from 'jotai';
 import { useAtomValue } from 'jotai';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
 import { CopyToClipboard } from 'react-copy-to-clipboard';
 import { CopyToClipboard } from 'react-copy-to-clipboard';
 // import Button from 'react-bootstrap/es/Button';
 // import Button from 'react-bootstrap/es/Button';
-import {
-  Modal, ModalHeader, ModalBody, ModalFooter,
-} from 'reactstrap';
+import { Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
 
 
 import AdminUsersContainer from '~/client/services/AdminUsersContainer';
 import AdminUsersContainer from '~/client/services/AdminUsersContainer';
-import { toastSuccess, toastError, toastWarning } from '~/client/util/toastr';
+import { toastError, toastSuccess, toastWarning } from '~/client/util/toastr';
 import { isMailerSetupAtom } from '~/states/server-configurations';
 import { isMailerSetupAtom } from '~/states/server-configurations';
 
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
 import { withUnstatedContainers } from '../../UnstatedUtils';
 
 
 class UserInviteModal extends React.Component {
 class UserInviteModal extends React.Component {
-
   constructor(props) {
   constructor(props) {
     super(props);
     super(props);
 
 
@@ -67,7 +63,9 @@ class UserInviteModal extends React.Component {
 
 
     return (
     return (
       <>
       <>
-        <label className="form-label">{t('admin:user_management.invite_modal.emails')}</label>
+        <label className="form-label" htmlFor="admin-invite-emails">
+          {t('admin:user_management.invite_modal.emails')}
+        </label>
         <p>
         <p>
           {t('admin:user_management.invite_modal.description1')}
           {t('admin:user_management.invite_modal.description1')}
           <br />
           <br />
@@ -75,12 +73,17 @@ class UserInviteModal extends React.Component {
         </p>
         </p>
         <textarea
         <textarea
           className="form-control"
           className="form-control"
+          id="admin-invite-emails"
           placeholder="e.g.&#13;&#10;user1@growi.org&#13;&#10;user2@growi.org"
           placeholder="e.g.&#13;&#10;user1@growi.org&#13;&#10;user2@growi.org"
           style={{ height: '200px' }}
           style={{ height: '200px' }}
           value={this.state.emailInputValue}
           value={this.state.emailInputValue}
           onChange={this.handleInput}
           onChange={this.handleInput}
         />
         />
-        {!this.validEmail() && <p className="m-2 text-danger">{t('admin:user_management.invite_modal.valid_email')}</p>}
+        {!this.validEmail() && (
+          <p className="m-2 text-danger">
+            {t('admin:user_management.invite_modal.valid_email')}
+          </p>
+        )}
       </>
       </>
     );
     );
   }
   }
@@ -93,8 +96,10 @@ class UserInviteModal extends React.Component {
       <>
       <>
         <p>{t('admin:user_management.invite_modal.temporary_password')}</p>
         <p>{t('admin:user_management.invite_modal.temporary_password')}</p>
         <p>{t('admin:user_management.invite_modal.send_new_password')}</p>
         <p>{t('admin:user_management.invite_modal.send_new_password')}</p>
-        {invitedEmailList.createdUserList.length > 0 && this.renderCreatedEmail(invitedEmailList.createdUserList)}
-        {invitedEmailList.existingEmailList.length > 0 && this.renderExistingEmail(invitedEmailList.existingEmailList)}
+        {invitedEmailList.createdUserList.length > 0 &&
+          this.renderCreatedEmail(invitedEmailList.createdUserList)}
+        {invitedEmailList.existingEmailList.length > 0 &&
+          this.renderExistingEmail(invitedEmailList.existingEmailList)}
       </>
       </>
     );
     );
   }
   }
@@ -105,7 +110,10 @@ class UserInviteModal extends React.Component {
 
 
     return (
     return (
       <>
       <>
-        <div className="col text-start form-check form-check-info" onChange={this.handleCheckBox}>
+        <div
+          className="col text-start form-check form-check-info"
+          onChange={this.handleCheckBox}
+        >
           <input
           <input
             type="checkbox"
             type="checkbox"
             id="sendEmail"
             id="sendEmail"
@@ -117,12 +125,28 @@ class UserInviteModal extends React.Component {
           <label className="form-label form-check-label" htmlFor="sendEmail">
           <label className="form-label form-check-label" htmlFor="sendEmail">
             {t('admin:user_management.invite_modal.invite_thru_email')}
             {t('admin:user_management.invite_modal.invite_thru_email')}
           </label>
           </label>
-          {isMailerSetup
+          {isMailerSetup ? (
             // eslint-disable-next-line react/no-danger
             // eslint-disable-next-line react/no-danger
-            ? <p className="form-text text-muted" dangerouslySetInnerHTML={{ __html: t('admin:user_management.invite_modal.mail_setting_link') }} />
+            <p
+              className="form-text text-muted"
+              // eslint-disable-next-line react/no-danger
+              // biome-ignore lint/security/noDangerouslySetInnerHtml: includes markup from i18n strings
+              dangerouslySetInnerHTML={{
+                __html: t(
+                  'admin:user_management.invite_modal.mail_setting_link',
+                ),
+              }}
+            />
+          ) : (
             // eslint-disable-next-line react/no-danger
             // eslint-disable-next-line react/no-danger
-            : <p className="form-text text-muted" dangerouslySetInnerHTML={{ __html: t('admin:mailer_setup_required') }} />
-          }
+            <p
+              className="form-text text-muted"
+              // biome-ignore lint/security/noDangerouslySetInnerHtml: includes markup from i18n strings
+              dangerouslySetInnerHTML={{
+                __html: t('admin:mailer_setup_required'),
+              }}
+            />
+          )}
         </div>
         </div>
         <div>
         <div>
           <button
           <button
@@ -151,10 +175,12 @@ class UserInviteModal extends React.Component {
 
 
     return (
     return (
       <>
       <>
-        <label className="form-label me-3 text-start" style={{ flex: 1 }}>
-          <text className="text-danger">{t('admin:user_management.invite_modal.send_temporary_password')}</text>
+        <div className="form-label me-3 text-start" style={{ flex: 1 }}>
+          <text className="text-danger">
+            {t('admin:user_management.invite_modal.send_temporary_password')}
+          </text>
           <text>{t('admin:user_management.invite_modal.send_email')}</text>
           <text>{t('admin:user_management.invite_modal.send_email')}</text>
-        </label>
+        </div>
         <button
         <button
           type="button"
           type="button"
           className="btn btn-outline-secondary"
           className="btn btn-outline-secondary"
@@ -175,7 +201,8 @@ class UserInviteModal extends React.Component {
             <div className="my-1" key={user.email}>
             <div className="my-1" key={user.email}>
               <CopyToClipboard text={copyText} onCopy={this.showToaster}>
               <CopyToClipboard text={copyText} onCopy={this.showToaster}>
                 <li className="btn btn-outline-secondary">
                 <li className="btn btn-outline-secondary">
-                  Email: <strong className="me-3">{user.email}</strong> Password: <strong>{user.password}</strong>
+                  Email: <strong className="me-3">{user.email}</strong>{' '}
+                  Password: <strong>{user.password}</strong>
                 </li>
                 </li>
               </CopyToClipboard>
               </CopyToClipboard>
             </div>
             </div>
@@ -190,11 +217,15 @@ class UserInviteModal extends React.Component {
 
 
     return (
     return (
       <>
       <>
-        <p className="text-warning">{t('admin:user_management.invite_modal.existing_email')}</p>
+        <p className="text-warning">
+          {t('admin:user_management.invite_modal.existing_email')}
+        </p>
         <ul>
         <ul>
           {emailList.map((user) => {
           {emailList.map((user) => {
             return (
             return (
-              <li key={user}><strong>{user}</strong></li>
+              <li key={user}>
+                <strong>{user}</strong>
+              </li>
             );
             );
           })}
           })}
         </ul>
         </ul>
@@ -212,36 +243,45 @@ class UserInviteModal extends React.Component {
     this.setState({ isCreateUserButtonPushed: true });
     this.setState({ isCreateUserButtonPushed: true });
 
 
     const array = this.state.emailInputValue.split('\n');
     const array = this.state.emailInputValue.split('\n');
-    const emailList = array.filter((element) => { return element.match(/.+@.+\..+/) });
-    const shapedEmailList = emailList.map((email) => { return email.trim() });
+    const emailList = array.filter((element) => {
+      return element.match(/.+@.+\..+/);
+    });
+    const shapedEmailList = emailList.map((email) => {
+      return email.trim();
+    });
 
 
     try {
     try {
-      const emailList = await adminUsersContainer.createUserInvited(shapedEmailList, this.state.sendEmail);
+      const emailList = await adminUsersContainer.createUserInvited(
+        shapedEmailList,
+        this.state.sendEmail,
+      );
       this.setState({ emailInputValue: '' });
       this.setState({ emailInputValue: '' });
       this.setState({ invitedEmailList: emailList });
       this.setState({ invitedEmailList: emailList });
 
 
       if (emailList.createdUserList.length > 0) {
       if (emailList.createdUserList.length > 0) {
-        const createdEmailList = emailList.createdUserList.map((user) => { return user.email });
+        const createdEmailList = emailList.createdUserList.map((user) => {
+          return user.email;
+        });
         this.showToasterByEmailList(createdEmailList, 'success');
         this.showToasterByEmailList(createdEmailList, 'success');
       }
       }
       if (emailList.existingEmailList.length > 0) {
       if (emailList.existingEmailList.length > 0) {
         this.showToasterByEmailList(emailList.existingEmailList, 'warning');
         this.showToasterByEmailList(emailList.existingEmailList, 'warning');
       }
       }
       if (emailList.failedEmailList.length > 0) {
       if (emailList.failedEmailList.length > 0) {
-        const failedEmailList = emailList.failedEmailList.map((failed, index) => {
-          let messgage = `email: ${failed.email}<br>・reason: ${failed.reason}`;
-          if (index !== emailList.failedEmailList.length - 1) {
-            messgage += '<br>';
-          }
-          return messgage;
-        });
+        const failedEmailList = emailList.failedEmailList.map(
+          (failed, index) => {
+            let messgage = `email: ${failed.email}<br>・reason: ${failed.reason}`;
+            if (index !== emailList.failedEmailList.length - 1) {
+              messgage += '<br>';
+            }
+            return messgage;
+          },
+        );
         this.showToasterByEmailList(failedEmailList, 'error');
         this.showToasterByEmailList(failedEmailList, 'error');
       }
       }
-    }
-    catch (err) {
+    } catch (err) {
       toastError(err);
       toastError(err);
-    }
-    finally {
+    } finally {
       this.setState({ isCreateUserButtonPushed: false });
       this.setState({ isCreateUserButtonPushed: false });
     }
     }
   }
   }
@@ -258,37 +298,41 @@ class UserInviteModal extends React.Component {
     const { t, adminUsersContainer } = this.props;
     const { t, adminUsersContainer } = this.props;
     const { invitedEmailList } = this.state;
     const { invitedEmailList } = this.state;
 
 
-
     return (
     return (
       <Modal isOpen={adminUsersContainer.state.isUserInviteModalShown}>
       <Modal isOpen={adminUsersContainer.state.isUserInviteModalShown}>
         <ModalHeader tag="h4" toggle={this.onToggleModal} className="text-info">
         <ModalHeader tag="h4" toggle={this.onToggleModal} className="text-info">
-          {t('admin:user_management.invite_users') }
+          {t('admin:user_management.invite_users')}
         </ModalHeader>
         </ModalHeader>
         <ModalBody>
         <ModalBody>
-          {invitedEmailList == null ? this.renderModalBody()
+          {invitedEmailList == null
+            ? this.renderModalBody()
             : this.renderCreatedModalBody()}
             : this.renderCreatedModalBody()}
         </ModalBody>
         </ModalBody>
         <ModalFooter className="d-flex">
         <ModalFooter className="d-flex">
-          {invitedEmailList == null ? this.renderModalFooter()
+          {invitedEmailList == null
+            ? this.renderModalFooter()
             : this.renderCreatedModalFooter()}
             : this.renderCreatedModalFooter()}
         </ModalFooter>
         </ModalFooter>
       </Modal>
       </Modal>
     );
     );
   }
   }
-
 }
 }
 
 
 const UserInviteModalWrapperFC = (props) => {
 const UserInviteModalWrapperFC = (props) => {
   const { t } = useTranslation();
   const { t } = useTranslation();
   const isMailerSetup = useAtomValue(isMailerSetupAtom);
   const isMailerSetup = useAtomValue(isMailerSetupAtom);
-  return <UserInviteModal t={t} isMailerSetup={isMailerSetup ?? false} {...props} />;
+  return (
+    <UserInviteModal t={t} isMailerSetup={isMailerSetup ?? false} {...props} />
+  );
 };
 };
 
 
 /**
 /**
  * Wrapper component for using unstated
  * Wrapper component for using unstated
  */
  */
-const UserInviteModalWrapper = withUnstatedContainers(UserInviteModalWrapperFC, [AdminUsersContainer]);
-
+const UserInviteModalWrapper = withUnstatedContainers(
+  UserInviteModalWrapperFC,
+  [AdminUsersContainer],
+);
 
 
 UserInviteModal.propTypes = {
 UserInviteModal.propTypes = {
   t: PropTypes.func.isRequired, // i18next
   t: PropTypes.func.isRequired, // i18next

+ 62 - 30
apps/app/src/client/components/Admin/Users/UserMenu.tsx

@@ -1,15 +1,12 @@
-import React, { useState, useCallback } from 'react';
-
+import type React from 'react';
+import { useCallback, useState } from 'react';
 import { type IUserHasId, USER_STATUS } from '@growi/core';
 import { type IUserHasId, USER_STATUS } from '@growi/core';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
-import {
-  UncontrolledDropdown, DropdownToggle, DropdownMenu,
-} from 'reactstrap';
+import { DropdownMenu, DropdownToggle, UncontrolledDropdown } from 'reactstrap';
 
 
 import AdminUsersContainer from '~/client/services/AdminUsersContainer';
 import AdminUsersContainer from '~/client/services/AdminUsersContainer';
 
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
 import { withUnstatedContainers } from '../../UnstatedUtils';
-
 import GrantAdminButton from './GrantAdminButton';
 import GrantAdminButton from './GrantAdminButton';
 import GrantReadOnlyButton from './GrantReadOnlyButton';
 import GrantReadOnlyButton from './GrantReadOnlyButton';
 import RevokeAdminMenuItem from './RevokeAdminMenuItem';
 import RevokeAdminMenuItem from './RevokeAdminMenuItem';
@@ -22,18 +19,19 @@ import UserRemoveButton from './UserRemoveButton';
 import styles from './UserMenu.module.scss';
 import styles from './UserMenu.module.scss';
 
 
 type UserMenuProps = {
 type UserMenuProps = {
-  adminUsersContainer: AdminUsersContainer,
-  user: IUserHasId,
-}
+  adminUsersContainer: AdminUsersContainer;
+  user: IUserHasId;
+};
 
 
 const UserMenu = (props: UserMenuProps) => {
 const UserMenu = (props: UserMenuProps) => {
   const { t } = useTranslation('admin');
   const { t } = useTranslation('admin');
 
 
   const { adminUsersContainer, user } = props;
   const { adminUsersContainer, user } = props;
 
 
-  const [isInvitationEmailSended, setIsInvitationEmailSended] = useState<boolean>(user.isInvitationEmailSended);
+  const [isInvitationEmailSended, setIsInvitationEmailSended] =
+    useState<boolean>(user.isInvitationEmailSended);
 
 
-  const onClickPasswordResetHandler = useCallback(async() => {
+  const onClickPasswordResetHandler = useCallback(async () => {
     await adminUsersContainer.showPasswordResetModal(user);
     await adminUsersContainer.showPasswordResetModal(user);
   }, [adminUsersContainer, user]);
   }, [adminUsersContainer, user]);
 
 
@@ -45,10 +43,17 @@ const UserMenu = (props: UserMenuProps) => {
     return (
     return (
       <>
       <>
         <li className="dropdown-divider"></li>
         <li className="dropdown-divider"></li>
-        <li className="dropdown-header">{t('user_management.user_table.edit_menu')}</li>
+        <li className="dropdown-header">
+          {t('user_management.user_table.edit_menu')}
+        </li>
         <li>
         <li>
-          <button className="dropdown-item" type="button" onClick={onClickPasswordResetHandler}>
-            <span className="material-symbols-outlined me-1">key</span>{ t('user_management.reset_password') }
+          <button
+            className="dropdown-item"
+            type="button"
+            onClick={onClickPasswordResetHandler}
+          >
+            <span className="material-symbols-outlined me-1">key</span>
+            {t('user_management.reset_password')}
           </button>
           </button>
         </li>
         </li>
       </>
       </>
@@ -61,32 +66,57 @@ const UserMenu = (props: UserMenuProps) => {
         <li className="dropdown-divider"></li>
         <li className="dropdown-divider"></li>
         <li className="dropdown-header">{t('user_management.status')}</li>
         <li className="dropdown-header">{t('user_management.status')}</li>
         <li>
         <li>
-          {(user.status === USER_STATUS.REGISTERED || user.status === USER_STATUS.SUSPENDED) && <StatusActivateButton user={user} />}
-          {user.status === USER_STATUS.ACTIVE && <StatusSuspendedMenuItem user={user} />}
+          {(user.status === USER_STATUS.REGISTERED ||
+            user.status === USER_STATUS.SUSPENDED) && (
+            <StatusActivateButton user={user} />
+          )}
+          {user.status === USER_STATUS.ACTIVE && (
+            <StatusSuspendedMenuItem user={user} />
+          )}
           {user.status === USER_STATUS.INVITED && (
           {user.status === USER_STATUS.INVITED && (
             <SendInvitationEmailButton
             <SendInvitationEmailButton
               user={user}
               user={user}
               isInvitationEmailSended={isInvitationEmailSended}
               isInvitationEmailSended={isInvitationEmailSended}
-              onSuccessfullySentInvitationEmail={onSuccessfullySentInvitationEmailHandler}
+              onSuccessfullySentInvitationEmail={
+                onSuccessfullySentInvitationEmailHandler
+              }
             />
             />
           )}
           )}
-          {(user.status === USER_STATUS.REGISTERED || user.status === USER_STATUS.SUSPENDED || user.status === USER_STATUS.INVITED)
-          && <UserRemoveButton user={user} />}
+          {(user.status === USER_STATUS.REGISTERED ||
+            user.status === USER_STATUS.SUSPENDED ||
+            user.status === USER_STATUS.INVITED) && (
+            <UserRemoveButton user={user} />
+          )}
         </li>
         </li>
       </>
       </>
     );
     );
-  }, [isInvitationEmailSended, onSuccessfullySentInvitationEmailHandler, t, user]);
+  }, [
+    isInvitationEmailSended,
+    onSuccessfullySentInvitationEmailHandler,
+    t,
+    user,
+  ]);
 
 
   const renderAdminMenu = useCallback(() => {
   const renderAdminMenu = useCallback(() => {
     return (
     return (
       <>
       <>
         <li className="dropdown-divider ps-0"></li>
         <li className="dropdown-divider ps-0"></li>
-        <li className="dropdown-header">{t('user_management.user_table.administrator_menu')}</li>
+        <li className="dropdown-header">
+          {t('user_management.user_table.administrator_menu')}
+        </li>
         <li>
         <li>
-          {user.admin ? <RevokeAdminMenuItem user={user} /> : <GrantAdminButton user={user} />}
+          {user.admin ? (
+            <RevokeAdminMenuItem user={user} />
+          ) : (
+            <GrantAdminButton user={user} />
+          )}
         </li>
         </li>
         <li>
         <li>
-          {user.readOnly ? <RevokeReadOnlyMenuItem user={user} /> : <GrantReadOnlyButton user={user} />}
+          {user.readOnly ? (
+            <RevokeReadOnlyMenuItem user={user} />
+          ) : (
+            <GrantReadOnlyButton user={user} />
+          )}
         </li>
         </li>
       </>
       </>
     );
     );
@@ -96,9 +126,10 @@ const UserMenu = (props: UserMenuProps) => {
     <UncontrolledDropdown id="userMenu" size="sm">
     <UncontrolledDropdown id="userMenu" size="sm">
       <DropdownToggle caret color="secondary" outline>
       <DropdownToggle caret color="secondary" outline>
         <span className="material-symbols-outlined fs-5">settings</span>
         <span className="material-symbols-outlined fs-5">settings</span>
-        {(user.status === USER_STATUS.INVITED && !isInvitationEmailSended)
-        && (
-          <span className={`material-symbols-outlined fill fs-6 text-danger grw-usermenu-notification-icon ${styles['grw-usermenu-notification-icon']}`}>
+        {user.status === USER_STATUS.INVITED && !isInvitationEmailSended && (
+          <span
+            className={`material-symbols-outlined fill fs-6 text-danger grw-usermenu-notification-icon ${styles['grw-usermenu-notification-icon']}`}
+          >
             circle
             circle
           </span>
           </span>
         )}
         )}
@@ -110,13 +141,14 @@ const UserMenu = (props: UserMenuProps) => {
       </DropdownMenu>
       </DropdownMenu>
     </UncontrolledDropdown>
     </UncontrolledDropdown>
   );
   );
-
 };
 };
 
 
 /**
 /**
-* Wrapper component for using unstated
-*/
+ * Wrapper component for using unstated
+ */
 // eslint-disable-next-line max-len
 // eslint-disable-next-line max-len
-const UserMenuWrapper: React.ForwardRefExoticComponent<Pick<any, string | number | symbol> & React.RefAttributes<any>> = withUnstatedContainers(UserMenu, [AdminUsersContainer]);
+const UserMenuWrapper: React.ForwardRefExoticComponent<
+  Pick<any, string | number | symbol> & React.RefAttributes<any>
+> = withUnstatedContainers(UserMenu, [AdminUsersContainer]);
 
 
 export default UserMenuWrapper;
 export default UserMenuWrapper;

+ 17 - 9
apps/app/src/client/components/Admin/Users/UserRemoveButton.jsx

@@ -1,15 +1,13 @@
 import React from 'react';
 import React from 'react';
-
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
 
 
 import AdminUsersContainer from '~/client/services/AdminUsersContainer';
 import AdminUsersContainer from '~/client/services/AdminUsersContainer';
-import { toastSuccess, toastError } from '~/client/util/toastr';
+import { toastError, toastSuccess } from '~/client/util/toastr';
 
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
 import { withUnstatedContainers } from '../../UnstatedUtils';
 
 
 class UserRemoveButton extends React.Component {
 class UserRemoveButton extends React.Component {
-
   constructor(props) {
   constructor(props) {
     super(props);
     super(props);
 
 
@@ -23,8 +21,7 @@ class UserRemoveButton extends React.Component {
       await this.props.adminUsersContainer.removeUser(this.props.user._id);
       await this.props.adminUsersContainer.removeUser(this.props.user._id);
       const { username } = this.props.user;
       const { username } = this.props.user;
       toastSuccess(t('toaster.remove_user_success', { username }));
       toastSuccess(t('toaster.remove_user_success', { username }));
-    }
-    catch (err) {
+    } catch (err) {
       toastError(err);
       toastError(err);
     }
     }
   }
   }
@@ -33,12 +30,20 @@ class UserRemoveButton extends React.Component {
     const { t } = this.props;
     const { t } = this.props;
 
 
     return (
     return (
-      <button className="dropdown-item" type="button" onClick={() => { this.onClickDeleteBtn() }}>
-        <span className="material-symbols-outlined text-danger">delete_forever</span> {t('Delete')}
+      <button
+        className="dropdown-item"
+        type="button"
+        onClick={() => {
+          this.onClickDeleteBtn();
+        }}
+      >
+        <span className="material-symbols-outlined text-danger">
+          delete_forever
+        </span>{' '}
+        {t('Delete')}
       </button>
       </button>
     );
     );
   }
   }
-
 }
 }
 
 
 UserRemoveButton.propTypes = {
 UserRemoveButton.propTypes = {
@@ -56,6 +61,9 @@ const UserRemoveButtonWrapperFC = (props) => {
 /**
 /**
  * Wrapper component for using unstated
  * Wrapper component for using unstated
  */
  */
-const UserRemoveButtonWrapper = withUnstatedContainers(UserRemoveButtonWrapperFC, [AdminUsersContainer]);
+const UserRemoveButtonWrapper = withUnstatedContainers(
+  UserRemoveButtonWrapperFC,
+  [AdminUsersContainer],
+);
 
 
 export default UserRemoveButtonWrapper;
 export default UserRemoveButtonWrapper;

+ 13 - 8
apps/app/src/client/components/Admin/Users/UserStatisticsTable.tsx

@@ -1,5 +1,4 @@
-import React from 'react';
-
+import type React from 'react';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 
 
 type UserStatistics = {
 type UserStatistics = {
@@ -20,16 +19,22 @@ const UserStatisticsTable: React.FC<Props> = ({ userStatistics }) => {
     <table className="table table-bordered w-100">
     <table className="table table-bordered w-100">
       <tbody>
       <tbody>
         <tr>
         <tr>
-          <th className="col-sm-4 align-top">{t('user_management.user_statistics.total')}</th>
-          <td className="align-top">{ userStatistics.total }</td>
+          <th className="col-sm-4 align-top">
+            {t('user_management.user_statistics.total')}
+          </th>
+          <td className="align-top">{userStatistics.total}</td>
         </tr>
         </tr>
         <tr>
         <tr>
-          <th className="col-sm-4 align-top">{t('user_management.user_statistics.active')}</th>
-          <td className="align-top">{ userStatistics.active.total }</td>
+          <th className="col-sm-4 align-top">
+            {t('user_management.user_statistics.active')}
+          </th>
+          <td className="align-top">{userStatistics.active.total}</td>
         </tr>
         </tr>
         <tr>
         <tr>
-          <th className="col-sm-4 align-top">{t('user_management.user_statistics.inactive')}</th>
-          <td className="align-top">{ userStatistics.inactive.total }</td>
+          <th className="col-sm-4 align-top">
+            {t('user_management.user_statistics.inactive')}
+          </th>
+          <td className="align-top">{userStatistics.inactive.total}</td>
         </tr>
         </tr>
       </tbody>
       </tbody>
     </table>
     </table>

+ 48 - 42
apps/app/src/client/components/Admin/Users/UserTable.tsx

@@ -1,5 +1,4 @@
 import React, { useCallback } from 'react';
 import React, { useCallback } from 'react';
-
 import type { IUserHasId } from '@growi/core';
 import type { IUserHasId } from '@growi/core';
 import { UserPicture } from '@growi/ui/dist/components';
 import { UserPicture } from '@growi/ui/dist/components';
 import { format as dateFnsFormat } from 'date-fns/format';
 import { format as dateFnsFormat } from 'date-fns/format';
@@ -8,16 +7,14 @@ import { useTranslation } from 'next-i18next';
 import AdminUsersContainer from '~/client/services/AdminUsersContainer';
 import AdminUsersContainer from '~/client/services/AdminUsersContainer';
 
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
 import { withUnstatedContainers } from '../../UnstatedUtils';
-
 import { SortIcons } from './SortIcons';
 import { SortIcons } from './SortIcons';
 import UserMenu from './UserMenu';
 import UserMenu from './UserMenu';
 
 
 type UserTableProps = {
 type UserTableProps = {
-  adminUsersContainer: AdminUsersContainer,
-}
+  adminUsersContainer: AdminUsersContainer;
+};
 
 
 const UserTable = (props: UserTableProps) => {
 const UserTable = (props: UserTableProps) => {
-
   const { t } = useTranslation('admin');
   const { t } = useTranslation('admin');
   const { adminUsersContainer } = props;
   const { adminUsersContainer } = props;
 
 
@@ -48,17 +45,16 @@ const UserTable = (props: UserTableProps) => {
         break;
         break;
     }
     }
 
 
-    return (
-      <span className={`badge ${additionalClassName}`}>
-        {text}
-      </span>
-    );
+    return <span className={`badge ${additionalClassName}`}>{text}</span>;
   };
   };
 
 
-  const sortIconsClickedHandler = useCallback(async(sort: string, sortOrder: string) => {
-    const isAsc = sortOrder === 'asc';
-    await adminUsersContainer.sort(sort, isAsc);
-  }, [adminUsersContainer]);
+  const sortIconsClickedHandler = useCallback(
+    async (sort: string, sortOrder: string) => {
+      const isAsc = sortOrder === 'asc';
+      await adminUsersContainer.sort(sort, isAsc);
+    },
+    [adminUsersContainer],
+  );
 
 
   const isCurrentSortOrderAsc = adminUsersContainer.state.sortOrder === 'asc';
   const isCurrentSortOrderAsc = adminUsersContainer.state.sortOrder === 'asc';
 
 
@@ -70,13 +66,13 @@ const UserTable = (props: UserTableProps) => {
             <th style={{ width: '100px' }}>#</th>
             <th style={{ width: '100px' }}>#</th>
             <th>
             <th>
               <div className="d-flex align-items-center">
               <div className="d-flex align-items-center">
-                <div className="me-3">
-                  {t('user_management.status')}
-                </div>
+                <div className="me-3">{t('user_management.status')}</div>
                 <SortIcons
                 <SortIcons
                   isSelected={adminUsersContainer.state.sort === 'status'}
                   isSelected={adminUsersContainer.state.sort === 'status'}
                   isAsc={isCurrentSortOrderAsc}
                   isAsc={isCurrentSortOrderAsc}
-                  onClick={sortOrder => sortIconsClickedHandler('status', sortOrder)}
+                  onClick={(sortOrder) =>
+                    sortIconsClickedHandler('status', sortOrder)
+                  }
                 />
                 />
               </div>
               </div>
             </th>
             </th>
@@ -88,55 +84,57 @@ const UserTable = (props: UserTableProps) => {
                 <SortIcons
                 <SortIcons
                   isSelected={adminUsersContainer.state.sort === 'username'}
                   isSelected={adminUsersContainer.state.sort === 'username'}
                   isAsc={isCurrentSortOrderAsc}
                   isAsc={isCurrentSortOrderAsc}
-                  onClick={sortOrder => sortIconsClickedHandler('username', sortOrder)}
+                  onClick={(sortOrder) =>
+                    sortIconsClickedHandler('username', sortOrder)
+                  }
                 />
                 />
               </div>
               </div>
             </th>
             </th>
             <th>
             <th>
               <div className="d-flex align-items-center">
               <div className="d-flex align-items-center">
-                <div className="me-3">
-                  {t('Name')}
-                </div>
+                <div className="me-3">{t('Name')}</div>
                 <SortIcons
                 <SortIcons
                   isSelected={adminUsersContainer.state.sort === 'name'}
                   isSelected={adminUsersContainer.state.sort === 'name'}
                   isAsc={isCurrentSortOrderAsc}
                   isAsc={isCurrentSortOrderAsc}
-                  onClick={sortOrder => sortIconsClickedHandler('name', sortOrder)}
+                  onClick={(sortOrder) =>
+                    sortIconsClickedHandler('name', sortOrder)
+                  }
                 />
                 />
               </div>
               </div>
             </th>
             </th>
             <th>
             <th>
               <div className="d-flex align-items-center">
               <div className="d-flex align-items-center">
-                <div className="me-3">
-                  {t('Email')}
-                </div>
+                <div className="me-3">{t('Email')}</div>
                 <SortIcons
                 <SortIcons
                   isSelected={adminUsersContainer.state.sort === 'email'}
                   isSelected={adminUsersContainer.state.sort === 'email'}
                   isAsc={isCurrentSortOrderAsc}
                   isAsc={isCurrentSortOrderAsc}
-                  onClick={sortOrder => sortIconsClickedHandler('email', sortOrder)}
+                  onClick={(sortOrder) =>
+                    sortIconsClickedHandler('email', sortOrder)
+                  }
                 />
                 />
               </div>
               </div>
             </th>
             </th>
             <th style={{ width: '100px' }}>
             <th style={{ width: '100px' }}>
               <div className="d-flex align-items-center">
               <div className="d-flex align-items-center">
-                <div className="me-3">
-                  {t('Created')}
-                </div>
+                <div className="me-3">{t('Created')}</div>
                 <SortIcons
                 <SortIcons
                   isSelected={adminUsersContainer.state.sort === 'createdAt'}
                   isSelected={adminUsersContainer.state.sort === 'createdAt'}
                   isAsc={isCurrentSortOrderAsc}
                   isAsc={isCurrentSortOrderAsc}
-                  onClick={sortOrder => sortIconsClickedHandler('createdAt', sortOrder)}
+                  onClick={(sortOrder) =>
+                    sortIconsClickedHandler('createdAt', sortOrder)
+                  }
                 />
                 />
               </div>
               </div>
             </th>
             </th>
             <th style={{ width: '150px' }}>
             <th style={{ width: '150px' }}>
               <div className="d-flex align-items-center">
               <div className="d-flex align-items-center">
-                <div className="me-3">
-                  {t('last_login')}
-                </div>
+                <div className="me-3">{t('last_login')}</div>
                 <SortIcons
                 <SortIcons
                   isSelected={adminUsersContainer.state.sort === 'lastLoginAt'}
                   isSelected={adminUsersContainer.state.sort === 'lastLoginAt'}
                   isAsc={isCurrentSortOrderAsc}
                   isAsc={isCurrentSortOrderAsc}
-                  onClick={sortOrder => sortIconsClickedHandler('lastLoginAt', sortOrder)}
+                  onClick={(sortOrder) =>
+                    sortIconsClickedHandler('lastLoginAt', sortOrder)
+                  }
                 />
                 />
               </div>
               </div>
             </th>
             </th>
@@ -144,7 +142,7 @@ const UserTable = (props: UserTableProps) => {
           </tr>
           </tr>
         </thead>
         </thead>
         <tbody>
         <tbody>
-          { adminUsersContainer.state.users.map((user: IUserHasId) => {
+          {adminUsersContainer.state.users.map((user: IUserHasId) => {
             return (
             return (
               <tr data-testid="user-table-tr" key={user._id}>
               <tr data-testid="user-table-tr" key={user._id}>
                 <td>
                 <td>
@@ -152,12 +150,12 @@ const UserTable = (props: UserTableProps) => {
                 </td>
                 </td>
                 <td>
                 <td>
                   {getUserStatusLabel(user.status)}
                   {getUserStatusLabel(user.status)}
-                  {(user.admin) && (
+                  {user.admin && (
                     <span className="badge text-bg-secondary ms-2">
                     <span className="badge text-bg-secondary ms-2">
                       {t('admin:user_management.user_table.administrator')}
                       {t('admin:user_management.user_table.administrator')}
                     </span>
                     </span>
                   )}
                   )}
-                  {(user.readOnly) && (
+                  {user.readOnly && (
                     <span className="badge text-bg-light ms-2">
                     <span className="badge text-bg-light ms-2">
                       {t('admin:user_management.user_table.read_only')}
                       {t('admin:user_management.user_table.read_only')}
                     </span>
                     </span>
@@ -170,21 +168,29 @@ const UserTable = (props: UserTableProps) => {
                 <td>{user.email}</td>
                 <td>{user.email}</td>
                 <td>{dateFnsFormat(user.createdAt, 'yyyy-MM-dd')}</td>
                 <td>{dateFnsFormat(user.createdAt, 'yyyy-MM-dd')}</td>
                 <td>
                 <td>
-                  {user.lastLoginAt && <span>{dateFnsFormat(new Date(user.lastLoginAt), 'yyyy-MM-dd HH:mm')}</span>}
+                  {user.lastLoginAt && (
+                    <span>
+                      {dateFnsFormat(
+                        new Date(user.lastLoginAt),
+                        'yyyy-MM-dd HH:mm',
+                      )}
+                    </span>
+                  )}
                 </td>
                 </td>
                 <td>
                 <td>
                   <UserMenu user={user} />
                   <UserMenu user={user} />
                 </td>
                 </td>
               </tr>
               </tr>
             );
             );
-          }) }
+          })}
         </tbody>
         </tbody>
       </table>
       </table>
     </div>
     </div>
   );
   );
-
 };
 };
 
 
-const UserTableWrapper = withUnstatedContainers(UserTable, [AdminUsersContainer]);
+const UserTableWrapper = withUnstatedContainers(UserTable, [
+  AdminUsersContainer,
+]);
 
 
 export default UserTableWrapper;
 export default UserTableWrapper;

+ 14 - 1
biome.json

@@ -28,7 +28,20 @@
       "!apps/slackbot-proxy/src/public/bootstrap",
       "!apps/slackbot-proxy/src/public/bootstrap",
       "!packages/pdf-converter-client/src/index.ts",
       "!packages/pdf-converter-client/src/index.ts",
       "!packages/pdf-converter-client/specs",
       "!packages/pdf-converter-client/specs",
-      "!apps/app/src/client/components/Admin"
+      "!apps/app/src/client/components/Admin/*.ts",
+      "!apps/app/src/client/components/Admin/*.tsx",
+      "!apps/app/src/client/components/Admin/*.scss",
+      "!apps/app/src/client/components/Admin/AdminHome",
+      "!apps/app/src/client/components/Admin/AuditLog",
+      "!apps/app/src/client/components/Admin/Common",
+      "!apps/app/src/client/components/Admin/Customize",
+      "!apps/app/src/client/components/Admin/ElasticsearchManagement",
+      "!apps/app/src/client/components/Admin/ExportArchiveData",
+      "!apps/app/src/client/components/Admin/ImportData",
+      "!apps/app/src/client/components/Admin/LegacySlackIntegration",
+      "!apps/app/src/client/components/Admin/MarkdownSetting",
+      "!apps/app/src/client/components/Admin/Notification",
+      "!apps/app/src/client/components/Admin/Security"
     ]
     ]
   },
   },
   "formatter": {
   "formatter": {