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

Merge pull request #10663 from growilabs/master

Release v7.4.2
mergify[bot] 2 месяцев назад
Родитель
Сommit
ab2d8afc90
100 измененных файлов с 4621 добавлено и 2384 удалено
  1. 0 1
      .github/mergify.yml
  2. 37 0
      .serena/memories/apps-app-google-workspace-oauth2-mail.md
  3. 105 0
      .serena/memories/apps-app-page-path-nav-and-sub-navigation-layering.md
  4. 63 0
      apps/app/.eslintrc.js
  5. 3 3
      apps/app/package.json
  6. 7 4
      apps/app/playwright/utils/Login.ts
  7. 1 1
      apps/app/public/static/locales/en_US/admin.json
  8. 5 0
      apps/app/public/static/locales/en_US/translation.json
  9. 1 1
      apps/app/public/static/locales/fr_FR/admin.json
  10. 5 0
      apps/app/public/static/locales/fr_FR/translation.json
  11. 1 1
      apps/app/public/static/locales/ja_JP/admin.json
  12. 5 0
      apps/app/public/static/locales/ja_JP/translation.json
  13. 1 1
      apps/app/public/static/locales/ko_KR/admin.json
  14. 5 0
      apps/app/public/static/locales/ko_KR/translation.json
  15. 1 1
      apps/app/public/static/locales/zh_CN/admin.json
  16. 5 0
      apps/app/public/static/locales/zh_CN/translation.json
  17. 57 29
      apps/app/src/client/components/Admin/AdminHome/AdminHome.jsx
  18. 8 8
      apps/app/src/client/components/Admin/AdminHome/EnvVarsTable.tsx
  19. 22 15
      apps/app/src/client/components/Admin/AdminHome/SystemInfomationTable.tsx
  20. 110 75
      apps/app/src/client/components/Admin/App/AppSetting.jsx
  21. 54 32
      apps/app/src/client/components/Admin/App/AppSettingsPageContents.tsx
  22. 50 19
      apps/app/src/client/components/Admin/App/AwsSetting.tsx
  23. 111 30
      apps/app/src/client/components/Admin/App/AzureSetting.tsx
  24. 22 25
      apps/app/src/client/components/Admin/App/ConfirmModal.tsx
  25. 60 49
      apps/app/src/client/components/Admin/App/FileUploadSetting.tsx
  26. 36 30
      apps/app/src/client/components/Admin/App/FileUploadSetting.types.ts
  27. 80 24
      apps/app/src/client/components/Admin/App/GcsSetting.tsx
  28. 76 50
      apps/app/src/client/components/Admin/App/MailSetting.tsx
  29. 53 20
      apps/app/src/client/components/Admin/App/MaintenanceMode.tsx
  30. 26 21
      apps/app/src/client/components/Admin/App/MaskedInput.tsx
  31. 64 34
      apps/app/src/client/components/Admin/App/PageBulkExportSettings.tsx
  32. 16 10
      apps/app/src/client/components/Admin/App/SesSetting.tsx
  33. 61 34
      apps/app/src/client/components/Admin/App/SiteUrlSetting.tsx
  34. 26 11
      apps/app/src/client/components/Admin/App/SmtpSetting.tsx
  35. 43 31
      apps/app/src/client/components/Admin/App/V5PageMigration.tsx
  36. 42 18
      apps/app/src/client/components/Admin/App/useFileUploadSettings.spec.ts
  37. 54 28
      apps/app/src/client/components/Admin/App/useFileUploadSettings.ts
  38. 32 13
      apps/app/src/client/components/Admin/AuditLog/ActivityTable.tsx
  39. 10 4
      apps/app/src/client/components/Admin/AuditLog/AuditLogDisableMode.tsx
  40. 38 14
      apps/app/src/client/components/Admin/AuditLog/AuditLogSettings.tsx
  41. 49 44
      apps/app/src/client/components/Admin/AuditLog/DateRangePicker.tsx
  42. 54 26
      apps/app/src/client/components/Admin/AuditLog/SearchUsernameTypeahead.tsx
  43. 151 88
      apps/app/src/client/components/Admin/AuditLog/SelectActionDropdown.tsx
  44. 143 73
      apps/app/src/client/components/Admin/AuditLogManagement.tsx
  45. 2 6
      apps/app/src/client/components/Admin/Common/Accordion.jsx
  46. 11 5
      apps/app/src/client/components/Admin/Common/AdminInstallButtonRow.tsx
  47. 5 6
      apps/app/src/client/components/Admin/Common/AdminUpdateButtonRow.tsx
  48. 17 12
      apps/app/src/client/components/Admin/Common/LabeledProgressBar.tsx
  49. 8 10
      apps/app/src/client/components/Admin/Customize/Customize.jsx
  50. 36 28
      apps/app/src/client/components/Admin/Customize/CustomizeCssSetting.tsx
  51. 7 11
      apps/app/src/client/components/Admin/Customize/CustomizeFunctionOption.tsx
  52. 105 44
      apps/app/src/client/components/Admin/Customize/CustomizeFunctionSetting.tsx
  53. 36 21
      apps/app/src/client/components/Admin/Customize/CustomizeLayoutSetting.tsx
  54. 100 46
      apps/app/src/client/components/Admin/Customize/CustomizeLogoSetting.tsx
  55. 54 38
      apps/app/src/client/components/Admin/Customize/CustomizeNoscriptSetting.tsx
  56. 36 18
      apps/app/src/client/components/Admin/Customize/CustomizePresentationSetting.tsx
  57. 52 38
      apps/app/src/client/components/Admin/Customize/CustomizeScriptSetting.tsx
  58. 32 27
      apps/app/src/client/components/Admin/Customize/CustomizeSidebarSetting.tsx
  59. 19 15
      apps/app/src/client/components/Admin/Customize/CustomizeThemeOptions.tsx
  60. 25 19
      apps/app/src/client/components/Admin/Customize/CustomizeThemeSetting.tsx
  61. 62 30
      apps/app/src/client/components/Admin/Customize/CustomizeTitle.tsx
  62. 12 11
      apps/app/src/client/components/Admin/Customize/PagingSizeUncontrolledDropdown.jsx
  63. 53 25
      apps/app/src/client/components/Admin/Customize/ThemeColorBox.tsx
  64. 29 34
      apps/app/src/client/components/Admin/ElasticsearchManagement/ElasticsearchManagement.tsx
  65. 12 9
      apps/app/src/client/components/Admin/ElasticsearchManagement/NormalizeIndicesControls.tsx
  66. 11 15
      apps/app/src/client/components/Admin/ElasticsearchManagement/RebuildIndexControls.jsx
  67. 11 11
      apps/app/src/client/components/Admin/ElasticsearchManagement/ReconnectControls.tsx
  68. 83 44
      apps/app/src/client/components/Admin/ElasticsearchManagement/StatusTable.jsx
  69. 13 6
      apps/app/src/client/components/Admin/ExportArchiveData/ArchiveFilesTable.tsx
  70. 35 12
      apps/app/src/client/components/Admin/ExportArchiveData/ArchiveFilesTableMenu.tsx
  71. 137 83
      apps/app/src/client/components/Admin/ExportArchiveData/SelectCollectionsModal.tsx
  72. 40 27
      apps/app/src/client/components/Admin/ExportArchiveDataPage.tsx
  73. 0 2
      apps/app/src/client/components/Admin/ForbiddenPage.tsx
  74. 4 2
      apps/app/src/client/components/Admin/FullTextSearchManagement.tsx
  75. 103 49
      apps/app/src/client/components/Admin/G2GDataTransfer.tsx
  76. 309 136
      apps/app/src/client/components/Admin/G2GDataTransferExportForm.tsx
  77. 49 10
      apps/app/src/client/components/Admin/G2GDataTransferStatusIcon.tsx
  78. 12 7
      apps/app/src/client/components/Admin/ImportData/GrowiArchive/ErrorViewer.tsx
  79. 106 36
      apps/app/src/client/components/Admin/ImportData/GrowiArchive/ImportCollectionConfigurationModal.jsx
  80. 86 34
      apps/app/src/client/components/Admin/ImportData/GrowiArchive/ImportCollectionItem.jsx
  81. 164 76
      apps/app/src/client/components/Admin/ImportData/GrowiArchive/ImportForm.jsx
  82. 21 15
      apps/app/src/client/components/Admin/ImportData/GrowiArchive/UploadForm.jsx
  83. 25 24
      apps/app/src/client/components/Admin/ImportData/GrowiArchiveSection.jsx
  84. 4 2
      apps/app/src/client/components/Admin/ImportData/ImportDataPageContents.jsx
  85. 25 15
      apps/app/src/client/components/Admin/LegacySlackIntegration/LegacySlackIntegration.jsx
  86. 151 64
      apps/app/src/client/components/Admin/LegacySlackIntegration/SlackConfiguration.jsx
  87. 32 27
      apps/app/src/client/components/Admin/ManageExternalAccount.tsx
  88. 60 25
      apps/app/src/client/components/Admin/MarkdownSetting/IndentForm.tsx
  89. 57 21
      apps/app/src/client/components/Admin/MarkdownSetting/LineBreakForm.jsx
  90. 28 18
      apps/app/src/client/components/Admin/MarkdownSetting/MarkDownSettingContents.tsx
  91. 33 19
      apps/app/src/client/components/Admin/MarkdownSetting/WhitelistInput.tsx
  92. 70 37
      apps/app/src/client/components/Admin/MarkdownSetting/XssForm.jsx
  93. 1 4
      apps/app/src/client/components/Admin/NotFoundPage.tsx
  94. 71 24
      apps/app/src/client/components/Admin/Notification/GlobalNotification.jsx
  95. 90 40
      apps/app/src/client/components/Admin/Notification/GlobalNotificationList.jsx
  96. 160 102
      apps/app/src/client/components/Admin/Notification/ManageGlobalNotification.tsx
  97. 18 11
      apps/app/src/client/components/Admin/Notification/NotificationDeleteModal.jsx
  98. 93 54
      apps/app/src/client/components/Admin/Notification/NotificationSetting.jsx
  99. 8 6
      apps/app/src/client/components/Admin/Notification/NotificationTypeIcon.tsx
  100. 5 5
      apps/app/src/client/components/Admin/Notification/TriggerEventCheckBox.jsx

+ 0 - 1
.github/mergify.yml

@@ -1,6 +1,5 @@
 queue_rules:
   - name: default
-    allow_inplace_checks: false
     queue_conditions:
       - check-success ~= ci-app-lint
       - check-success ~= ci-app-test

+ 37 - 0
.serena/memories/apps-app-google-workspace-oauth2-mail.md

@@ -0,0 +1,37 @@
+# Google Workspace OAuth 2.0 メール送信機能実装計画
+
+## 概要
+
+Google Workspace (Gmail) の OAuth 2.0 (XOAUTH2) 認証を使ったメール送信機能を実装する。2025年5月1日以降、Gmail SMTP ではユーザー名とパスワード認証がサポートされなくなったため、OAuth 2.0 への移行が必要。
+
+## 背景
+
+- **問題**: Gmail SMTP でのユーザー名・パスワード認証が2025年5月1日にサポート終了
+- **解決策**: OAuth 2.0 (XOAUTH2) 認証方式の実装
+- **参考**: https://support.google.com/a/answer/2956491?hl=ja
+- **ライブラリ**: nodemailer v6.9.15 は OAuth 2.0 をサポート済み(バージョンアップ不要)
+
+## 技術仕様
+
+### 必須設定パラメータ
+
+| パラメータ | 説明 | セキュリティ |
+|-----------|------|------------|
+| `mail:oauth2ClientId` | Google Cloud Console で取得する OAuth 2.0 クライアント ID | 通常 |
+| `mail:oauth2ClientSecret` | OAuth 2.0 クライアントシークレット | `isSecret: true` |
+| `mail:oauth2RefreshToken` | OAuth 2.0 リフレッシュトークン | `isSecret: true` |
+| `mail:oauth2User` | 送信者のGmailアドレス | 通常 |
+
+### nodemailer 設定例
+
+```typescript
+const transportOptions = {
+  service: 'gmail',
+  auth: {
+    type: 'OAuth2',
+    user: 'user@example.com',
+    clientId: 'CLIENT_ID',
+    clientSecret: 'CLIENT_SECRET',
+    refreshToken: 'REFRESH_TOKEN',
+  },
+};

+ 105 - 0
.serena/memories/apps-app-page-path-nav-and-sub-navigation-layering.md

@@ -0,0 +1,105 @@
+# PagePathNav と SubNavigation の z-index レイヤリング
+
+## 概要
+
+PagePathNav(ページパス表示)と GrowiContextualSubNavigation(PageControls等を含むサブナビゲーション)の
+Sticky 状態における z-index の重なり順を修正した際の知見。
+
+## 修正したバグ
+
+### 症状
+スクロールしていって PagePathNav がウィンドウ上端に近づいたときに、PageControls のボタンが
+PagePathNav の要素の裏側に回ってしまい、クリックできなくなる。
+
+### 原因
+z-index 的に以下のように重なっていたため:
+
+**[Before]** 下層から順に:
+1. PageView の children - z-0
+2. ( GroundGlassBar = PageControls ) ← 同じ層 z-1
+3. PagePathNav
+
+PageControls が PagePathNav より下層にいたため、sticky 境界付近でクリック不能になっていた。
+
+## 修正後の構成
+
+**[After]** 下層から順に:
+1. PageView の children - z-0
+2. GroundGlassBar(磨りガラス背景)- z-1
+3. PagePathNav - z-2(通常時)/ z-3(sticky時)
+4. PageControls(nav要素)- z-3
+
+### ファイル構成
+
+- `GrowiContextualSubNavigation.tsx` - GroundGlassBar を分離してレンダリング
+  - 1つ目: GroundGlassBar のみ(`position-fixed`, `z-1`)
+  - 2つ目: nav 要素(`z-3`)
+- `PagePathNavSticky.tsx` - z-index を動的に切り替え
+  - 通常時: `z-2`
+  - sticky時: `z-3`
+
+## 実装のポイント
+
+### GroundGlassBar を分離した理由
+GroundGlassBar を `position-fixed` で常に固定表示にすることで、
+PageControls と切り離して独立した z-index 層として扱えるようにした。
+
+これにより、GroundGlassBar → PagePathNav → PageControls という
+理想的なレイヤー構造を実現できた。
+
+## CopyDropdown が z-2 で動作しない理由(解決済み)
+
+### 問題
+
+`PagePathNavSticky.tsx` の sticky 時の z-index について:
+
+```tsx
+// これだと CopyDropdown(マウスオーバーで表示されるドロップダウン)が出ない
+innerActiveClass="active z-2 mt-1"
+
+// これだと正常に動作する
+innerActiveClass="active z-3 mt-1"
+```
+
+### 原因
+
+1. `GrowiContextualSubNavigation` の sticky-inner-wrapper は `z-3` かつ横幅いっぱい(Flex アイテム)
+2. この要素が PagePathNavSticky(`z-2`)の上に重なる
+3. CopyDropdown は `.grw-page-path-nav-layout:hover` で `visibility: visible` になる仕組み
+   (参照: `PagePathNavLayout.module.scss`)
+4. **z-3 の要素が上に被さっているため、hover イベントが PagePathNavSticky に届かない**
+5. 結果、CopyDropdown のアイコンが表示されない
+
+### なぜ z-3 で動作するか
+
+- 同じ z-index: 3 になるため、DOM 順序で前後が決まる
+- PagePathNavSticky は GrowiContextualSubNavigation より後にレンダリングされるため前面に来る
+- hover イベントが正常に届き、CopyDropdown が表示される
+
+### 結論
+
+PagePathNavSticky の sticky 時の z-index は `z-3` である必要がある。
+これは GrowiContextualSubNavigation と同じ層に置くことで、DOM 順序による前後関係を利用するため。
+
+## 関連ファイル
+
+- `apps/app/src/client/components/PageView/PageView.tsx`
+- `apps/app/src/client/components/Navbar/GrowiContextualSubNavigation.tsx`
+- `apps/app/src/client/components/Navbar/GrowiContextualSubNavigation.module.scss`
+- `apps/app/src/client/components/PagePathNavSticky/PagePathNavSticky.tsx`
+- `apps/app/src/client/components/PagePathNavSticky/PagePathNavSticky.module.scss`
+- `apps/app/src/components/Common/PagePathNav/PagePathNavLayout.tsx`(CopyDropdown を含む)
+
+## ライブラリの注意事項
+
+### react-stickynode の deprecation
+`react-stickynode` は **2025-12-31 で deprecated** となる予定。
+https://github.com/yahoo/react-stickynode
+
+将来的には CSS `position: sticky` + `IntersectionObserver` への移行を検討する必要がある。
+
+## 注意事項
+
+- z-index の値を変更する際は、上記のレイヤー構造を壊さないよう注意
+- Sticky コンポーネントの `innerActiveClass` で z-index を指定する際、
+  他のコンポーネントとの相互作用を確認すること

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

@@ -37,10 +37,73 @@ module.exports = {
     'src/interfaces/**',
     'src/utils/**',
     'src/components/**',
+    'src/client/components/DescendantsPageListModal/**',
+    'src/client/components/ItemsTree/**',
+    'src/client/components/LoginForm/**',
+    'src/client/components/Page/**',
+    'src/client/components/PageAttachment/**',
+    'src/client/components/PageDeleteModal/**',
+    'src/client/components/PageDuplicateModal/**',
+    'src/client/components/PageList/**',
+    'src/client/components/PageManagement/**',
+    'src/client/components/PagePathNavSticky/**',
+    'src/client/components/PagePresentationModal/**',
+    'src/client/components/PageRenameModal/**',
+    'src/client/components/PageSelectModal/**',
+    'src/client/components/PageSideContents/**',
     'src/client/components/*.tsx',
     'src/client/components/*.jsx',
     'src/client/components/*.ts',
     'src/client/components/*.js',
+    'src/client/components/Admin/*.ts',
+    'src/client/components/Admin/*.tsx',
+    'src/client/components/Admin/*.scss',
+    'src/client/components/Admin/AdminHome/**',
+    'src/client/components/Admin/Common/**',
+    'src/client/components/Admin/ElasticsearchManagement/**',
+    'src/client/components/Admin/ExportArchiveData/**',
+    'src/client/components/Admin/ImportData/**',
+    'src/client/components/Admin/LegacySlackIntegration/**',
+    'src/client/components/Admin/MarkdownSetting/**',
+    'src/client/components/Admin/App/**',
+    'src/client/components/Admin/AuditLog/**',
+    'src/client/components/Admin/Customize/**',
+    'src/client/components/Admin/Notification/**',
+    'src/client/components/Admin/Security/**',
+    '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/Bookmarks/**',
+    'src/client/components/InAppNotification/**',
+    'src/client/components/PageTags/**',
+    'src/client/components/ReactMarkdownComponents/**',
+    'src/client/components/AuthorInfo/**',
+    'src/client/components/Common/**',
+    'src/client/components/CreateTemplateModal/**',
+    'src/client/components/CustomNavigation/**',
+    'src/client/components/DeleteBookmarkFolderModal/**',
+    'src/client/components/EmptyTrashModal/**',
+    'src/client/components/GrantedGroupsInheritanceSelectModal/**',
+    'src/client/components/Icons/**',
+    'src/client/components/Maintenance/**',
+    'src/client/components/PageControls/**',
+    'src/client/components/PageComment/**',
+    'src/client/components/PageAccessoriesModal/**',
+    'src/client/components/PageHistory/**',
+    'src/client/components/Presentation/**',
+    'src/client/components/PutbackPageModal/**',
+    'src/client/components/RecentActivity/**',
+    'src/client/components/RecentCreated/**',
+    'src/client/components/RevisionComparer/**',
+    'src/client/components/ShortcutsModal/**',
+    'src/client/components/StaffCredit/**',
+    'src/client/components/TemplateModal/**',
+    'src/client/components/PageEditor/**',
+    'src/client/components/Hotkeys/**',
+    'src/client/components/Navbar/**',
+    'src/client/components/PageHeader/**',
     'src/client/components/Sidebar/**',
     'src/services/**',
     'src/states/**',

+ 3 - 3
apps/app/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/app",
-  "version": "7.4.1",
+  "version": "7.4.2-RC.0",
   "license": "MIT",
   "private": "true",
   "scripts": {
@@ -173,7 +173,7 @@
     "multer": "~1.4.0",
     "multer-autoreap": "^1.0.3",
     "mustache": "^4.2.0",
-    "next": "^14.2.32",
+    "next": "^14.2.35",
     "next-dynamic-loading-props": "^0.1.1",
     "next-i18next": "^15.3.1",
     "next-superjson": "^1.0.7",
@@ -193,7 +193,7 @@
     "passport-saml": "^3.2.0",
     "pathe": "^2.0.3",
     "prop-types": "^15.8.1",
-    "qs": "^6.11.1",
+    "qs": "^6.14.1",
     "rate-limiter-flexible": "^2.3.7",
     "react": "^18.2.0",
     "react-bootstrap-typeahead": "^6.3.2",

+ 7 - 4
apps/app/playwright/utils/Login.ts

@@ -7,12 +7,15 @@ export const login = async (page: Page): Promise<void> => {
   // Perform authentication steps. Replace these actions with your own.
   await page.goto('/admin');
 
-  const loginForm = await page.getByRole('form');
+  const loginForm = await page.getByTestId('login-form');
 
   if (loginForm != null) {
-    await page.getByLabel('Username or E-mail').fill('admin');
-    await page.getByLabel('Password').fill('adminadmin');
-    await page.locator('[type=submit]').filter({ hasText: 'Login' }).click();
+    await loginForm.getByPlaceholder('Username or E-mail').fill('admin');
+    await loginForm.getByPlaceholder('Password').fill('adminadmin');
+    await loginForm
+      .locator('[type=submit]')
+      .filter({ hasText: 'Login' })
+      .click();
   }
 
   await page.waitForURL('/admin');

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

@@ -731,7 +731,7 @@
       "description1": "Temporarily issue new users by email addresses.",
       "description2": "A temporary password will be generated for the first login.",
       "invite_thru_email": "Send invitation email",
-      "mail_setting_link": "<span className='material-symbols-outlined me-2'>settings</span><a href='/admin/app'>Email settings</a>",
+      "mail_setting_link": "<span class='material-symbols-outlined me-2'>settings</span><a href='/admin/app'>Email settings</a>",
       "valid_email": "Valid email address is required",
       "temporary_password": "The created user has a temporary password",
       "send_new_password": "Please send the new password to the user.",

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

@@ -786,6 +786,11 @@
       "updatedAt": "Last update date"
     }
   },
+  "help_dropdown": {
+    "show_shortcuts": "Show shortcuts",
+    "growi_cloud_help": "GROWI.cloud Help",
+    "growi_version": "GROWI version"
+  },
   "private_legacy_pages": {
     "title": "Private Legacy Pages",
     "bulk_operation": "Bulk operation",

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

@@ -731,7 +731,7 @@
       "description1": "Créer des utilisateurs temporaires avec une adresse courriel.",
       "description2": "Un mot de passe temporaire est généré automatiquement.",
       "invite_thru_email": "Courriel d'invitation",
-      "mail_setting_link": "<span className='material-symbols-outlined me-2'>settings</span><a href='/admin/app'>Paramètres courriel</a>",
+      "mail_setting_link": "<span class='material-symbols-outlined me-2'>settings</span><a href='/admin/app'>Paramètres courriel</a>",
       "valid_email": "Adresse courriel valide requise",
       "temporary_password": "Cette utilisateur a un mot de passe temporaire",
       "send_new_password": "Envoyez le nouveau mot de passe à l'utilisateur.",

+ 5 - 0
apps/app/public/static/locales/fr_FR/translation.json

@@ -780,6 +780,11 @@
       "updatedAt": "Dernière modification"
     }
   },
+  "help_dropdown": {
+    "show_shortcuts": "Afficher les raccourcis",
+    "growi_cloud_help": "Aide GROWI.cloud",
+    "growi_version": "Version GROWI"
+  },
   "private_legacy_pages": {
     "title": "Anciennes pages privées",
     "bulk_operation": "Opération de masse",

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

@@ -740,7 +740,7 @@
       "description1": "メールアドレスを使用して新規ユーザーを仮発行します。",
       "description2": "初回のログイン時に使用する仮パスワードが生成されます。",
       "invite_thru_email": "招待メールを送信する",
-      "mail_setting_link": "<span className='material-symbols-outlined me-2'>settings</span><a href='/admin/app'>メールの設定</a>",
+      "mail_setting_link": "<span class='material-symbols-outlined me-2'>settings</span><a href='/admin/app'>メールの設定</a>",
       "valid_email": "メールアドレスを入力してください。",
       "temporary_password": "作成したユーザーは仮パスワードが設定されています。",
       "send_new_password": "新規発行したパスワードを、対象ユーザーへ連絡してください。",

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

@@ -819,6 +819,11 @@
       "updatedAt": "更新日時"
     }
   },
+  "help_dropdown": {
+    "show_shortcuts": "ショートカットを表示",
+    "growi_cloud_help": "GROWI.cloud ヘルプ",
+    "growi_version": "GROWI バージョン"
+  },
   "private_legacy_pages": {
     "title": "旧形式のプライベートページ",
     "bulk_operation": "一括操作",

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

@@ -731,7 +731,7 @@
       "description1": "이메일 주소로 새 사용자를 임시 발급합니다.",
       "description2": "첫 로그인 시 임시 비밀번호가 생성됩니다.",
       "invite_thru_email": "초대 이메일 전송",
-      "mail_setting_link": "<span className='material-symbols-outlined me-2'>settings</span><a href='/admin/app'>이메일 설정</a>",
+      "mail_setting_link": "<span class='material-symbols-outlined me-2'>settings</span><a href='/admin/app'>이메일 설정</a>",
       "valid_email": "유효한 이메일 주소가 필요합니다.",
       "temporary_password": "생성된 사용자에게는 임시 비밀번호가 있습니다.",
       "send_new_password": "새 비밀번호를 사용자에게 보내주십시오.",

+ 5 - 0
apps/app/public/static/locales/ko_KR/translation.json

@@ -746,6 +746,11 @@
       "updatedAt": "마지막 업데이트일"
     }
   },
+  "help_dropdown": {
+    "show_shortcuts": "단축키 표시",
+    "growi_cloud_help": "GROWI.cloud 도움말",
+    "growi_version": "GROWI 버전"
+  },
   "private_legacy_pages": {
     "title": "비공개 레거시 페이지",
     "bulk_operation": "대량 작업",

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

@@ -739,7 +739,7 @@
       "emails": "电子邮件",
       "description1": "通过电子邮件地址临时发布新用户。",
       "description2": "将为首次登录生成一个临时密码。",
-      "mail_setting_link": "<span className='material-symbols-outlined me-2'>settings</span><a href='/admin/app'>Email settings</a>",
+      "mail_setting_link": "<span class='material-symbols-outlined me-2'>settings</span><a href='/admin/app'>Email settings</a>",
       "valid_email": "需要有效的电子邮件地址",
       "invite_thru_email": "发送邀请电子邮件",
       "temporary_password": "创建的用户具有临时密码",

+ 5 - 0
apps/app/public/static/locales/zh_CN/translation.json

@@ -791,6 +791,11 @@
       "updatedAt": "按更新日期排序"
     }
   },
+  "help_dropdown": {
+    "show_shortcuts": "显示快捷键",
+    "growi_cloud_help": "GROWI.cloud 帮助",
+    "growi_version": "GROWI 版本"
+  },
   "private_legacy_pages": {
     "title": "私人遗留页面",
     "bulk_operation": "批量操作",

+ 57 - 29
apps/app/src/client/components/Admin/AdminHome/AdminHome.jsx

@@ -1,5 +1,4 @@
-import React, { useEffect, useCallback } from 'react';
-
+import React, { useCallback, useEffect } from 'react';
 import { useTranslation } from 'next-i18next';
 import PropTypes from 'prop-types';
 import { CopyToClipboard } from 'react-copy-to-clipboard';
@@ -10,14 +9,10 @@ import { toastError } from '~/client/util/toastr';
 import { useSWRxV5MigrationStatus } from '~/stores/page-listing';
 import loggerFactory from '~/utils/logger';
 
-
 import { withUnstatedContainers } from '../../UnstatedUtils';
-
-
 import { EnvVarsTable } from './EnvVarsTable';
 import SystemInfomationTable from './SystemInfomationTable';
 
-
 const logger = loggerFactory('growi:admin');
 
 const AdminHome = (props) => {
@@ -25,11 +20,10 @@ const AdminHome = (props) => {
   const { t } = useTranslation();
   const { data: migrationStatus } = useSWRxV5MigrationStatus();
 
-  const fetchAdminHomeData = useCallback(async() => {
+  const fetchAdminHomeData = useCallback(async () => {
     try {
       await adminHomeContainer.retrieveAdminHomeData();
-    }
-    catch (err) {
+    } catch (err) {
       toastError(err);
       logger.error(err);
     }
@@ -48,25 +42,36 @@ const AdminHome = (props) => {
             <h3 className="alert-heading">
               {t('admin:maintenance_mode.maintenance_mode')}
             </h3>
-            <p>
-              {t('admin:maintenance_mode.description')}
-            </p>
+            <p>{t('admin:maintenance_mode.description')}</p>
             <hr />
             <a className="btn-link" href="/admin/app" rel="noopener noreferrer">
-              <span className="material-symbols-outlined ms-1" aria-hidden="true">link</span>
-              <strong>{t('admin:maintenance_mode.end_maintenance_mode')}</strong>
+              <span
+                className="material-symbols-outlined ms-1"
+                aria-hidden="true"
+              >
+                link
+              </span>
+              <strong>
+                {t('admin:maintenance_mode.end_maintenance_mode')}
+              </strong>
             </a>
           </div>
         )
       }
       {
         // Alert message will be displayed in case that V5 migration has not been compleated
-        (migrationStatus != null && !migrationStatus.isV5Compatible)
-        && (
-          <div className={`alert ${migrationStatus.isV5Compatible == null ? 'alert-warning' : 'alert-info'}`}>
+        migrationStatus != null && !migrationStatus.isV5Compatible && (
+          <div
+            className={`alert ${migrationStatus.isV5Compatible == null ? 'alert-warning' : 'alert-info'}`}
+          >
             {t('admin:v5_page_migration.migration_desc')}
             <a className="btn-link" href="/admin/app" rel="noopener noreferrer">
-              <span className="material-symbols-outlined ms-1" aria-hidden="true">link</span>
+              <span
+                className="material-symbols-outlined ms-1"
+                aria-hidden="true"
+              >
+                link
+              </span>
               <strong>{t('admin:v5_page_migration.upgrade_to_v5')}</strong>
             </a>
           </div>
@@ -80,43 +85,65 @@ const AdminHome = (props) => {
 
       <div className="row mb-5">
         <div className="col-lg-12">
-          <h2 className="admin-setting-header">{t('admin:admin_top.system_information')}</h2>
+          <h2 className="admin-setting-header">
+            {t('admin:admin_top.system_information')}
+          </h2>
           <SystemInfomationTable />
         </div>
       </div>
 
       <div className="row mb-5">
         <div className="col-md-12">
-          <h2 className="admin-setting-header">{t('admin:admin_top.list_of_env_vars')}</h2>
+          <h2 className="admin-setting-header">
+            {t('admin:admin_top.list_of_env_vars')}
+          </h2>
           <p>{t('admin:admin_top.env_var_priority')}</p>
-          {/* eslint-disable-next-line react/no-danger */}
-          <p dangerouslySetInnerHTML={{ __html: t('admin:admin_top.about_security') }} />
+          <p
+            // biome-ignore lint/security/noDangerouslySetInnerHtml: ignore
+            dangerouslySetInnerHTML={{
+              __html: t('admin:admin_top.about_security'),
+            }}
+          />
           <EnvVarsTable envVars={adminHomeContainer.state.envVars} />
         </div>
       </div>
 
       <div className="row mb-5">
         <div className="col-md-12">
-          <h2 className="admin-setting-header">{t('admin:admin_top.bug_report')}</h2>
+          <h2 className="admin-setting-header">
+            {t('admin:admin_top.bug_report')}
+          </h2>
           <div className="d-flex align-items-center">
             <CopyToClipboard
               text={adminHomeContainer.generatePrefilledHostInformationMarkdown()}
               onCopy={() => adminHomeContainer.onCopyPrefilledHostInformation()}
             >
-              <button id="prefilledHostInformationButton" type="button" className="btn btn-primary">
+              <button
+                id="prefilledHostInformationButton"
+                type="button"
+                className="btn btn-primary"
+              >
                 {t('admin:admin_top:copy_prefilled_host_information:default')}
               </button>
             </CopyToClipboard>
             <Tooltip
               placement="bottom"
-              isOpen={adminHomeContainer.state.copyState === adminHomeContainer.copyStateValues.DONE}
+              isOpen={
+                adminHomeContainer.state.copyState ===
+                adminHomeContainer.copyStateValues.DONE
+              }
               target="prefilledHostInformationButton"
               fade={false}
             >
               {t('admin:admin_top:copy_prefilled_host_information:done')}
             </Tooltip>
-            {/* eslint-disable-next-line react/no-danger */}
-            <span className="ms-2" dangerouslySetInnerHTML={{ __html: t('admin:admin_top:submit_bug_report') }} />
+            <span
+              className="ms-2"
+              // biome-ignore lint/security/noDangerouslySetInnerHtml: ignore
+              dangerouslySetInnerHTML={{
+                __html: t('admin:admin_top:submit_bug_report'),
+              }}
+            />
           </div>
         </div>
       </div>
@@ -124,8 +151,9 @@ const AdminHome = (props) => {
   );
 };
 
-
-const AdminHomeWrapper = withUnstatedContainers(AdminHome, [AdminHomeContainer]);
+const AdminHomeWrapper = withUnstatedContainers(AdminHome, [
+  AdminHomeContainer,
+]);
 
 AdminHome.propTypes = {
   adminHomeContainer: PropTypes.instanceOf(AdminHomeContainer).isRequired,

+ 8 - 8
apps/app/src/client/components/Admin/AdminHome/EnvVarsTable.tsx

@@ -1,12 +1,14 @@
-import React, { type JSX } from 'react';
-
+import type React from 'react';
+import type { JSX } from 'react';
 import { LoadingSpinner } from '@growi/ui/dist/components';
 
 type EnvVarsTableProps = {
-  envVars?: Record<string, string | number | boolean>,
-}
+  envVars?: Record<string, string | number | boolean>;
+};
 
-export const EnvVarsTable: React.FC<EnvVarsTableProps> = (props: EnvVarsTableProps) => {
+export const EnvVarsTable: React.FC<EnvVarsTableProps> = (
+  props: EnvVarsTableProps,
+) => {
   const { envVars } = props;
   if (envVars == null) {
     return <LoadingSpinner />;
@@ -27,9 +29,7 @@ export const EnvVarsTable: React.FC<EnvVarsTableProps> = (props: EnvVarsTablePro
 
   return (
     <table className="table table-bordered">
-      <tbody>
-        {envVarRows}
-      </tbody>
+      <tbody>{envVarRows}</tbody>
     </table>
   );
 };

+ 22 - 15
apps/app/src/client/components/Admin/AdminHome/SystemInfomationTable.tsx

@@ -1,55 +1,62 @@
 import React from 'react';
-
 import { LoadingSpinner } from '@growi/ui/dist/components';
 
 import AdminHomeContainer from '~/client/services/AdminHomeContainer';
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
 
-
 type Props = {
-  adminHomeContainer: AdminHomeContainer,
-}
+  adminHomeContainer: AdminHomeContainer;
+};
 
 const SystemInformationTable = (props: Props) => {
   const { adminHomeContainer } = props;
 
-  const {
-    growiVersion, nodeVersion, npmVersion, pnpmVersion,
-  } = adminHomeContainer.state;
+  const { growiVersion, nodeVersion, npmVersion, pnpmVersion } =
+    adminHomeContainer.state;
 
-  if (growiVersion == null || nodeVersion == null || npmVersion == null || pnpmVersion == null) {
+  if (
+    growiVersion == null ||
+    nodeVersion == null ||
+    npmVersion == null ||
+    pnpmVersion == null
+  ) {
     return <LoadingSpinner />;
   }
 
   return (
-    <table data-testid="admin-system-information-table" className="table table-bordered">
+    <table
+      data-testid="admin-system-information-table"
+      className="table table-bordered"
+    >
       <tbody>
         <tr>
           <th>GROWI</th>
-          <td data-vrt-blackout>{ growiVersion }</td>
+          <td data-vrt-blackout>{growiVersion}</td>
         </tr>
         <tr>
           <th>node.js</th>
-          <td>{ nodeVersion }</td>
+          <td>{nodeVersion}</td>
         </tr>
         <tr>
           <th>npm</th>
-          <td>{ npmVersion }</td>
+          <td>{npmVersion}</td>
         </tr>
         <tr>
           <th>pnpm</th>
-          <td>{ pnpmVersion }</td>
+          <td>{pnpmVersion}</td>
         </tr>
       </tbody>
     </table>
   );
-
 };
 
 /**
  * Wrapper component for using unstated
  */
-const SystemInformationTableWrapper = withUnstatedContainers(SystemInformationTable, [AdminHomeContainer]);
+const SystemInformationTableWrapper = withUnstatedContainers(
+  SystemInformationTable,
+  [AdminHomeContainer],
+);
 
 export default SystemInformationTableWrapper;

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

@@ -1,31 +1,24 @@
 import React, { useCallback, useEffect } from 'react';
-
-import { useTranslation, i18n } from 'next-i18next';
+import { i18n, useTranslation } from 'next-i18next';
 import PropTypes from 'prop-types';
 import { useForm } from 'react-hook-form';
 
 import { i18n as i18nConfig } from '^/config/next-i18next.config';
 
 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 { withUnstatedContainers } from '../../UnstatedUtils';
 import AdminUpdateButtonRow from '../Common/AdminUpdateButtonRow';
 
 const logger = loggerFactory('growi:appSettings');
 
-
 const AppSetting = (props) => {
   const { adminAppContainer } = props;
   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)
   useEffect(() => {
@@ -34,8 +27,11 @@ const AppSetting = (props) => {
       confidential: adminAppContainer.state.confidential || '',
       globalLang: adminAppContainer.state.globalLang || 'en-US',
       // 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,
@@ -46,47 +42,67 @@ const AppSetting = (props) => {
     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 (
     <form onSubmit={handleSubmit(onSubmit)}>
       <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">
           <input
             className="form-control"
             type="text"
             placeholder="GROWI"
+            id="admin-app-setting-site-name"
             {...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 className="row mb-5">
         <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')}
         </label>
@@ -95,49 +111,52 @@ const AppSetting = (props) => {
             className="form-control"
             type="text"
             placeholder={t('admin:app_setting.confidential_example')}
+            id="admin-app-setting-confidential-name"
             {...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 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')}
-        </label>
+        </span>
         <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 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')}
-        </label>
+        </span>
         <div className="col-md-6 py-2">
-
           <div className="form-check form-check-inline">
             <input
               type="radio"
@@ -146,7 +165,12 @@ const AppSetting = (props) => {
               value="true"
               {...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 className="form-check form-check-inline">
@@ -157,20 +181,24 @@ const AppSetting = (props) => {
               value="false"
               {...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 className="row mb-5">
         <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')}
         </label>
         <div className="col-md-6 py-2">
-
           <div className="form-check form-check-inline">
             <input
               type="checkbox"
@@ -178,26 +206,33 @@ const AppSetting = (props) => {
               className="form-check-input"
               {...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>
 
-      <AdminUpdateButtonRow type="submit" disabled={adminAppContainer.state.retrieveError != null} />
+      <AdminUpdateButtonRow
+        type="submit"
+        disabled={adminAppContainer.state.retrieveError != null}
+      />
     </form>
   );
-
 };
 
-
 /**
  * Wrapper component for using unstated
  */
-const AppSettingWrapper = withUnstatedContainers(AppSetting, [AdminAppContainer]);
+const AppSettingWrapper = withUnstatedContainers(AppSetting, [
+  AdminAppContainer,
+]);
 
 AppSetting.propTypes = {
   adminAppContainer: PropTypes.instanceOf(AdminAppContainer).isRequired,
 };
 
-
 export default AppSettingWrapper;

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

@@ -1,5 +1,4 @@
 import React, { useEffect } from 'react';
-
 import { useTranslation } from 'next-i18next';
 
 import AdminAppContainer from '~/client/services/AdminAppContainer';
@@ -9,7 +8,6 @@ import { toArrayIfNot } from '~/utils/array-utils';
 import loggerFactory from '~/utils/logger';
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
-
 import AppSetting from './AppSetting';
 import FileUploadSetting from './FileUploadSetting';
 import MailSetting from './MailSetting';
@@ -18,12 +16,11 @@ import PageBulkExportSettings from './PageBulkExportSettings';
 import SiteUrlSetting from './SiteUrlSetting';
 import V5PageMigration from './V5PageMigration';
 
-
 const logger = loggerFactory('growi:appSettings');
 
 type Props = {
-  adminAppContainer: AdminAppContainer,
-}
+  adminAppContainer: AdminAppContainer;
+};
 
 const AppSettingsPageContents = (props: Props) => {
   const { t } = useTranslation('admin');
@@ -34,14 +31,13 @@ const AppSettingsPageContents = (props: Props) => {
   const { isV5Compatible } = adminAppContainer.state;
 
   useEffect(() => {
-    const fetchAppSettingsData = async() => {
+    const fetchAppSettingsData = async () => {
       await adminAppContainer.retrieveAppSettingsData();
     };
 
     try {
       fetchAppSettingsData();
-    }
-    catch (err) {
+    } catch (err) {
       const errs = toArrayIfNot(err);
       toastError(errs);
       logger.error(errs);
@@ -57,67 +53,90 @@ const AppSettingsPageContents = (props: Props) => {
             <h3 className="alert-heading">
               {t('admin:maintenance_mode.maintenance_mode')}
             </h3>
-            <p>
-              {t('admin:maintenance_mode.description')}
-            </p>
+            <p>{t('admin:maintenance_mode.description')}</p>
             <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>
           </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="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 />
         </div>
       </div>
 
       <div className="row mt-5">
         <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 />
         </div>
       </div>
 
       <div className="row mt-5">
         <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 />
         </div>
       </div>
 
       <div className="row mt-5">
         <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 />
         </div>
       </div>
 
       <div className="row mt-5">
         <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 />
         </div>
       </div>
 
       <div className="row">
         <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 />
         </div>
       </div>
@@ -128,6 +147,9 @@ const AppSettingsPageContents = (props: Props) => {
 /**
  * Wrapper component for using unstated
  */
-const AppSettingsPageContentsWrapper = withUnstatedContainers(AppSettingsPageContents, [AdminAppContainer]);
+const AppSettingsPageContentsWrapper = withUnstatedContainers(
+  AppSettingsPageContents,
+  [AdminAppContainer],
+);
 
 export default AppSettingsPageContentsWrapper;

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

@@ -1,25 +1,26 @@
 import type { JSX } from 'react';
-
 import { useTranslation } from 'next-i18next';
 import type { UseFormRegister } from 'react-hook-form';
 
 import type { FileUploadFormValues } from './FileUploadSetting.types';
 
 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();
 
   return (
     <>
       <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')}
-        </label>
+        </span>
 
         <div className="col-md-6">
           <div className="dropdown">
@@ -31,21 +32,27 @@ export const AwsSettingMolecule = (props: AwsSettingMoleculeProps): JSX.Element
               aria-haspopup="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>
-            <div className="dropdown-menu" aria-labelledby="ddS3ReferenceFileWithRelayMode">
+            <div className="dropdown-menu">
               <button
                 className="dropdown-item"
                 type="button"
-                onClick={() => { props.onChangeS3ReferenceFileWithRelayMode(true) }}
+                onClick={() => {
+                  props.onChangeS3ReferenceFileWithRelayMode(true);
+                }}
               >
                 {t('admin:app_setting.file_delivery_method_relay')}
               </button>
               <button
                 className="dropdown-item"
                 type="button"
-                onClick={() => { props.onChangeS3ReferenceFileWithRelayMode(false) }}
+                onClick={() => {
+                  props.onChangeS3ReferenceFileWithRelayMode(false);
+                }}
               >
                 {t('admin:app_setting.file_delivery_method_redirect')}
               </button>
@@ -61,20 +68,27 @@ export const AwsSettingMolecule = (props: AwsSettingMoleculeProps): JSX.Element
       </div>
 
       <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')}
         </label>
         <div className="col-md-6">
           <input
             className="form-control"
             placeholder={`${t('eg')} ap-northeast-1`}
+            id="admin-aws-setting-region"
             {...props.register('s3Region')}
           />
         </div>
       </div>
 
       <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')}
         </label>
         <div className="col-md-6">
@@ -82,14 +96,20 @@ export const AwsSettingMolecule = (props: AwsSettingMoleculeProps): JSX.Element
             className="form-control"
             type="text"
             placeholder={`${t('eg')} http://localhost:9000`}
+            id="admin-aws-setting-custom-endpoint"
             {...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 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')}
         </label>
         <div className="col-md-6">
@@ -97,35 +117,46 @@ export const AwsSettingMolecule = (props: AwsSettingMoleculeProps): JSX.Element
             className="form-control"
             type="text"
             placeholder={`${t('eg')} crowi`}
+            id="admin-aws-setting-bucket-name"
             {...props.register('s3Bucket')}
           />
         </div>
       </div>
 
       <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
         </label>
         <div className="col-md-6">
           <input
             className="form-control"
             type="text"
+            id="admin-aws-setting-access-key-id"
             {...props.register('s3AccessKeyId')}
           />
         </div>
       </div>
 
       <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
         </label>
         <div className="col-md-6">
           <input
             className="form-control"
             type="text"
+            id="admin-aws-setting-secret-access-key"
             {...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>
     </>

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

@@ -1,5 +1,4 @@
 import type { JSX } from 'react';
-
 import { useTranslation } from 'next-i18next';
 import type { UseFormRegister } from 'react-hook-form';
 
@@ -7,18 +6,20 @@ import type { FileUploadFormValues } from './FileUploadSetting.types';
 import MaskedInput from './MaskedInput';
 
 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 {
@@ -34,9 +35,9 @@ export const AzureSettingMolecule = (props: AzureSettingMoleculeProps): JSX.Elem
   return (
     <>
       <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')}
-        </label>
+        </span>
 
         <div className="col-md-6">
           <div className="dropdown">
@@ -48,21 +49,27 @@ export const AzureSettingMolecule = (props: AzureSettingMoleculeProps): JSX.Elem
               aria-haspopup="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>
-            <div className="dropdown-menu" aria-labelledby="ddAzureReferenceFileWithRelayMode">
+            <div className="dropdown-menu">
               <button
                 className="dropdown-item"
                 type="button"
-                onClick={() => { props.onChangeAzureReferenceFileWithRelayMode(true) }}
+                onClick={() => {
+                  props.onChangeAzureReferenceFileWithRelayMode(true);
+                }}
               >
                 {t('admin:app_setting.file_delivery_method_relay')}
               </button>
               <button
                 className="dropdown-item"
                 type="button"
-                onClick={() => { props.onChangeAzureReferenceFileWithRelayMode(false) }}
+                onClick={() => {
+                  props.onChangeAzureReferenceFileWithRelayMode(false);
+                }}
               >
                 {t('admin:app_setting.file_delivery_method_redirect')}
               </button>
@@ -81,10 +88,17 @@ export const AzureSettingMolecule = (props: AzureSettingMoleculeProps): JSX.Elem
         <p
           className="alert alert-info"
           // eslint-disable-next-line react/no-danger
-          dangerouslySetInnerHTML={{ __html: t('admin:app_setting.azure_note_for_the_only_env_option', { env: 'AZURE_USES_ONLY_ENV_VARS_FOR_SOME_OPTIONS' }) }}
+          // 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>
           <col className="item-name" />
           <col className="from-db" />
@@ -108,10 +122,23 @@ export const AzureSettingMolecule = (props: AzureSettingMoleculeProps): JSX.Elem
               />
             </td>
             <td>
-              <MaskedInput name="envAzureTenantId" value={envAzureTenantId || ''} readOnly tabIndex={-1} />
+              <MaskedInput
+                name="envAzureTenantId"
+                value={envAzureTenantId || ''}
+                readOnly
+                tabIndex={-1}
+              />
               <p className="form-text text-muted">
                 {/* eslint-disable-next-line react/no-danger */}
-                <small dangerouslySetInnerHTML={{ __html: t('admin:app_setting.use_env_var_if_empty', { variable: 'AZURE_TENANT_ID' }) }} />
+                <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>
             </td>
           </tr>
@@ -125,10 +152,23 @@ export const AzureSettingMolecule = (props: AzureSettingMoleculeProps): JSX.Elem
               />
             </td>
             <td>
-              <MaskedInput name="envAzureClientId" value={envAzureClientId || ''} readOnly tabIndex={-1} />
+              <MaskedInput
+                name="envAzureClientId"
+                value={envAzureClientId || ''}
+                readOnly
+                tabIndex={-1}
+              />
               <p className="form-text text-muted">
                 {/* eslint-disable-next-line react/no-danger */}
-                <small dangerouslySetInnerHTML={{ __html: t('admin:app_setting.use_env_var_if_empty', { variable: 'AZURE_CLIENT_ID' }) }} />
+                <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>
             </td>
           </tr>
@@ -142,10 +182,23 @@ export const AzureSettingMolecule = (props: AzureSettingMoleculeProps): JSX.Elem
               />
             </td>
             <td>
-              <MaskedInput name="envAzureClientSecret" value={envAzureClientSecret || ''} readOnly tabIndex={-1} />
+              <MaskedInput
+                name="envAzureClientSecret"
+                value={envAzureClientSecret || ''}
+                readOnly
+                tabIndex={-1}
+              />
               <p className="form-text text-muted">
                 {/* eslint-disable-next-line react/no-danger */}
-                <small dangerouslySetInnerHTML={{ __html: t('admin:app_setting.use_env_var_if_empty', { variable: 'AZURE_CLIENT_SECRET' }) }} />
+                <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>
             </td>
           </tr>
@@ -160,10 +213,24 @@ export const AzureSettingMolecule = (props: AzureSettingMoleculeProps): JSX.Elem
               />
             </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">
                 {/* 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>
             </td>
           </tr>
@@ -178,10 +245,24 @@ export const AzureSettingMolecule = (props: AzureSettingMoleculeProps): JSX.Elem
               />
             </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">
                 {/* 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>
             </td>
           </tr>

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

@@ -1,21 +1,20 @@
 import type { FC } from 'react';
 import React from 'react';
-
 import { useTranslation } from 'next-i18next';
-import {
-  Modal, ModalHeader, ModalBody, ModalFooter,
-} from 'reactstrap';
+import { Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
 
 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 onCancel = () => {
@@ -38,20 +37,18 @@ export const ConfirmModal: FC<ConfirmModalProps> = (props: ConfirmModalProps) =>
       </ModalHeader>
       <ModalBody>
         {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>
       <ModalFooter>
         <button

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

@@ -1,14 +1,12 @@
 import type { JSX } from 'react';
 import { useCallback } from 'react';
-
 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 AdminUpdateButtonRow from '../Common/AdminUpdateButtonRow';
-
 import { AwsSettingMolecule } from './AwsSetting';
 import { AzureSettingMolecule } from './AzureSetting';
 import type { FileUploadFormValues } from './FileUploadSetting.types';
@@ -17,33 +15,33 @@ import { useFileUploadSettings } from './useFileUploadSettings';
 
 const FileUploadSetting = (): JSX.Element => {
   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
   const { field: fileUploadTypeField } = useController({
@@ -69,15 +67,22 @@ const FileUploadSetting = (): JSX.Element => {
 
   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) {
     return <div>Loading...</div>;
@@ -98,9 +103,9 @@ const FileUploadSetting = (): JSX.Element => {
       </p>
 
       <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')}
-        </label>
+        </span>
 
         <div className="col-md-6 py-2">
           {Object.values(FileUploadType).map((type) => {
@@ -115,7 +120,10 @@ const FileUploadSetting = (): JSX.Element => {
                   disabled={data.isFixedFileUploadByEnvVar}
                   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`)}
                 </label>
               </div>
@@ -128,12 +136,15 @@ const FileUploadSetting = (): JSX.Element => {
             <b>FIXED</b>
             <br />
             {/* 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>
         )}

+ 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 = {
-  fileUploadType: FileUploadType
+  fileUploadType: FileUploadType;
   // 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
-  gcsApiKeyJsonPath: string
-  gcsBucket: string
-  gcsUploadNamespace: string
-  gcsReferenceFileWithRelayMode: boolean
+  gcsApiKeyJsonPath: string;
+  gcsBucket: string;
+  gcsUploadNamespace: string;
+  gcsReferenceFileWithRelayMode: boolean;
   // 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 & {
-  isFixedFileUploadByEnvVar: boolean
-  envFileUploadType?: string
+  isFixedFileUploadByEnvVar: boolean;
+  envFileUploadType?: string;
   // GCS env vars
-  gcsUseOnlyEnvVars: boolean
-  envGcsApiKeyJsonPath?: string
-  envGcsBucket?: string
-  envGcsUploadNamespace?: string
+  gcsUseOnlyEnvVars: boolean;
+  envGcsApiKeyJsonPath?: string;
+  envGcsBucket?: string;
+  envGcsUploadNamespace?: string;
   // 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 { useTranslation } from 'next-i18next';
 import type { UseFormRegister } from 'react-hook-form';
 
 import type { FileUploadFormValues } from './FileUploadSetting.types';
 
 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 {
@@ -29,9 +30,9 @@ export const GcsSettingMolecule = (props: GcsSettingMoleculeProps): JSX.Element
   return (
     <>
       <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')}
-        </label>
+        </span>
 
         <div className="col-md-6">
           <div className="dropdown">
@@ -43,21 +44,27 @@ export const GcsSettingMolecule = (props: GcsSettingMoleculeProps): JSX.Element
               aria-haspopup="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>
-            <div className="dropdown-menu" aria-labelledby="ddGcsReferenceFileWithRelayMode">
+            <div className="dropdown-menu">
               <button
                 className="dropdown-item"
                 type="button"
-                onClick={() => { props.onChangeGcsReferenceFileWithRelayMode(true) }}
+                onClick={() => {
+                  props.onChangeGcsReferenceFileWithRelayMode(true);
+                }}
               >
                 {t('admin:app_setting.file_delivery_method_relay')}
               </button>
               <button
                 className="dropdown-item"
                 type="button"
-                onClick={() => { props.onChangeGcsReferenceFileWithRelayMode(false) }}
+                onClick={() => {
+                  props.onChangeGcsReferenceFileWithRelayMode(false);
+                }}
               >
                 {t('admin:app_setting.file_delivery_method_redirect')}
               </button>
@@ -76,10 +83,17 @@ export const GcsSettingMolecule = (props: GcsSettingMoleculeProps): JSX.Element
         <p
           className="alert alert-info"
           // 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>
           <col className="item-name" />
           <col className="from-db" />
@@ -104,10 +118,24 @@ export const GcsSettingMolecule = (props: GcsSettingMoleculeProps): JSX.Element
               />
             </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">
                 {/* 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>
             </td>
           </tr>
@@ -122,10 +150,24 @@ export const GcsSettingMolecule = (props: GcsSettingMoleculeProps): JSX.Element
               />
             </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">
                 {/* 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>
             </td>
           </tr>
@@ -140,10 +182,24 @@ export const GcsSettingMolecule = (props: GcsSettingMoleculeProps): JSX.Element
               />
             </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">
                 {/* 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>
             </td>
           </tr>

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

@@ -1,21 +1,17 @@
 import React, { useCallback, useEffect } from 'react';
-
 import { useTranslation } from 'next-i18next';
 import { useForm } from 'react-hook-form';
 
 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 { SesSetting } from './SesSetting';
 import { SmtpSetting } from './SmtpSetting';
 
-
 type Props = {
-  adminAppContainer: AdminAppContainer,
-}
-
+  adminAppContainer: AdminAppContainer;
+};
 
 const MailSetting = (props: Props) => {
   const { t } = useTranslation(['admin', 'commons']);
@@ -23,15 +19,13 @@ const MailSetting = (props: Props) => {
 
   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
-  const currentTransmissionMethod = watch('transmissionMethod', adminAppContainer.state.transmissionMethod || 'smtp');
+  const currentTransmissionMethod = watch(
+    'transmissionMethod',
+    adminAppContainer.state.transmissionMethod || 'smtp',
+  );
 
   // Reset form when adminAppContainer state changes
   useEffect(() => {
@@ -57,61 +51,75 @@ const MailSetting = (props: Props) => {
     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() {
     const { adminAppContainer } = props;
     try {
       await adminAppContainer.sendTestEmail();
       toastSuccess(t('admin:app_setting.success_to_send_test_email'));
-    }
-    catch (err) {
+    } catch (err) {
       toastError(err);
     }
   }
 
-
   return (
     <form onSubmit={handleSubmit(onSubmit)}>
       {!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">
-        <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">
           <input
             className="form-control"
             type="text"
             placeholder={`${t('eg')} mail@growi.org`}
+            id="admin-mail-setting-from-address"
             {...register('fromAddress')}
           />
         </div>
       </div>
 
       <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')}
-        </label>
+        </span>
         <div className="col-md-6 py-2">
           {transmissionMethods.map((method) => {
             return (
@@ -123,24 +131,41 @@ const MailSetting = (props: Props) => {
                   value={method}
                   {...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>
 
-      {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="col-md-3"></div>
         <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>
           {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')}
             </button>
           )}
@@ -148,12 +173,13 @@ const MailSetting = (props: Props) => {
       </div>
     </form>
   );
-
 };
 
 /**
  * Wrapper component for using unstated
  */
-const MailSettingWrapper = withUnstatedContainers(MailSetting, [AdminAppContainer]);
+const MailSettingWrapper = withUnstatedContainers(MailSetting, [
+  AdminAppContainer,
+]);
 
 export default MailSettingWrapper;

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

@@ -1,54 +1,81 @@
 import type { FC } from 'react';
-import React, { useState, useCallback } from 'react';
-
+import React, { useCallback, useState } from 'react';
 import { useTranslation } from 'next-i18next';
 
 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 { ConfirmModal } from './ConfirmModal';
 
-
 export const MaintenanceMode: FC = () => {
   const { t } = useTranslation();
 
   const isMaintenanceMode = useIsMaintenanceMode();
-  const { start: startMaintenanceMode, end: endMaintenanceMode } = useMaintenanceModeActions();
+  const { start: startMaintenanceMode, end: endMaintenanceMode } =
+    useMaintenanceModeActions();
 
   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();
 
     try {
       if (isMaintenanceMode) {
         endMaintenanceMode();
-      }
-      else {
+      } else {
         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
-    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 (
     <div className="mb-5">
       <ConfirmModal
         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
-        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}
         onCancel={() => closeModal()}
       />
@@ -60,8 +87,14 @@ export const MaintenanceMode: FC = () => {
         </span>
       </p>
       <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>
       </div>
     </div>

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

@@ -1,19 +1,18 @@
 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 styles from './MaskedInput.module.scss';
 
 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
-  register?: UseFormRegister<any>
-  fieldName?: string
+  register?: UseFormRegister<any>;
+  fieldName?: string;
 };
 
 export default function MaskedInput(props: Props): JSX.Element {
@@ -22,18 +21,18 @@ export default function MaskedInput(props: Props): JSX.Element {
     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
-  const inputProps = register && fieldName
-    ? register(fieldName)
-    : {
-      name,
-      value,
-      onChange,
-    };
+  const inputProps =
+    register && fieldName
+      ? register(fieldName)
+      : {
+          name,
+          value,
+          onChange,
+        };
 
   return (
     <div className={styles.MaskedInput}>
@@ -44,13 +43,19 @@ export default function MaskedInput(props: Props): JSX.Element {
         tabIndex={tabIndex}
         {...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 ? (
           <span className="material-symbols-outlined">visibility</span>
         ) : (
           <span className="material-symbols-outlined">visibility_off</span>
         )}
-      </span>
+      </button>
     </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 { useTranslation } from 'next-i18next';
 
 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 AdminUpdateButtonRow from '../Common/AdminUpdateButtonRow';
@@ -16,36 +13,53 @@ const PageBulkExportSettings = (): JSX.Element => {
 
   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);
   };
 
-  const onSubmitHandler = useCallback(async() => {
+  const onSubmitHandler = useCallback(async () => {
     try {
       await apiv3Put('/app-settings/page-bulk-export-settings', {
         isBulkExportPagesEnabled,
         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);
     }
     mutate();
-  }, [isBulkExportPagesEnabled, bulkExportDownloadExpirationSeconds, mutate, t]);
+  }, [
+    isBulkExportPagesEnabled,
+    bulkExportDownloadExpirationSeconds,
+    mutate,
+    t,
+  ]);
 
   useEffect(() => {
     if (data?.useOnlyEnvVarForFileUploadType) {
       setIsBulkExportPagesEnabled(data?.envIsBulkExportPagesEnabled);
-    }
-    else {
+    } else {
       setIsBulkExportPagesEnabled(data?.isBulkExportPagesEnabled);
     }
-    setBulkExportDownloadExpirationSeconds(data?.bulkExportDownloadExpirationSeconds);
+    setBulkExportDownloadExpirationSeconds(
+      data?.bulkExportDownloadExpirationSeconds,
+    );
   }, [data]);
 
   const isLoading = data === undefined && error === undefined;
@@ -68,10 +82,7 @@ const PageBulkExportSettings = (): JSX.Element => {
           </p>
 
           <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="form-check form-switch form-check-info">
@@ -81,21 +92,29 @@ const PageBulkExportSettings = (): JSX.Element => {
                   id="cbIsPageBulkExportEnabled"
                   checked={isBulkExportPagesEnabled}
                   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')}
                 </label>
               </div>
               {data?.useOnlyEnvVarsForIsBulkExportPagesEnabled && (
                 <p className="form-text text-muted">
                   {/* 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>
               )}
@@ -106,6 +125,7 @@ const PageBulkExportSettings = (): JSX.Element => {
             <div className="row">
               <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')}
               </label>
@@ -113,11 +133,21 @@ const PageBulkExportSettings = (): JSX.Element => {
               <div className="col-md-2">
                 <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')}
                     </option>
                   ))}

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

@@ -1,6 +1,4 @@
-
 import React from 'react';
-
 import type { UseFormRegister } from 'react-hook-form';
 
 import AdminAppContainer from '~/client/services/AdminAppContainer';
@@ -8,10 +6,10 @@ import AdminAppContainer from '~/client/services/AdminAppContainer';
 import { withUnstatedContainers } from '../../UnstatedUtils';
 
 type Props = {
-  adminAppContainer?: AdminAppContainer,
+  adminAppContainer?: AdminAppContainer;
   // eslint-disable-next-line @typescript-eslint/no-explicit-any
-  register: UseFormRegister<any>,
-}
+  register: UseFormRegister<any>;
+};
 
 const SesSetting = (props: Props): JSX.Element => {
   const { register } = props;
@@ -19,34 +17,40 @@ const SesSetting = (props: Props): JSX.Element => {
   return (
     <React.Fragment>
       <div id="mail-ses" className="tab-pane active">
-
         <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
           </label>
           <div className="col-md-6">
             <input
               className="form-control"
               type="text"
+              id="admin-ses-access-key-id"
               {...register('sesAccessKeyId')}
             />
           </div>
         </div>
 
         <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
           </label>
           <div className="col-md-6">
             <input
               className="form-control"
               type="text"
+              id="admin-ses-secret-access-key"
               {...register('sesSecretAccessKey')}
             />
           </div>
         </div>
       </div>
-
     </React.Fragment>
   );
 };
@@ -56,6 +60,8 @@ export { SesSetting };
 /**
  * Wrapper component for using unstated
  */
-const SesSettingWrapper = withUnstatedContainers(SesSetting, [AdminAppContainer]);
+const SesSettingWrapper = withUnstatedContainers(SesSetting, [
+  AdminAppContainer,
+]);
 
 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 { useTranslation } from 'next-i18next';
 import { useForm } from 'react-hook-form';
 
 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 { withUnstatedContainers } from '../../UnstatedUtils';
@@ -12,21 +11,16 @@ import AdminUpdateButtonRow from '../Common/AdminUpdateButtonRow';
 
 const logger = loggerFactory('growi:appSettings');
 
-
 type Props = {
-  adminAppContainer: AdminAppContainer,
-}
+  adminAppContainer: AdminAppContainer;
+};
 
 const SiteUrlSetting = (props: Props) => {
   const { t } = useTranslation('admin', { keyPrefix: 'app_setting' });
   const { t: tCommon } = useTranslation('commons');
   const { adminAppContainer } = props;
 
-  const {
-    register,
-    handleSubmit,
-    reset,
-  } = useForm();
+  const { register, handleSubmit, reset } = useForm();
 
   // Reset form when adminAppContainer state changes
   useEffect(() => {
@@ -35,36 +29,47 @@ const SiteUrlSetting = (props: Props) => {
     });
   }, [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 (
     <form onSubmit={handleSubmit(onSubmit)}>
       <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">
           <p
             className="alert alert-info"
             // eslint-disable-next-line react/no-danger
+            // biome-ignore lint/security/noDangerouslySetInnerHtml: includes markup from i18n strings
             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 className="row">
         <table className="table settings-table">
@@ -84,20 +89,37 @@ const SiteUrlSetting = (props: Props) => {
                 <input
                   className="form-control"
                   type="text"
-                  readOnly={adminAppContainer.state.siteUrlUseOnlyEnvVars ?? true}
+                  readOnly={
+                    adminAppContainer.state.siteUrlUseOnlyEnvVars ?? true
+                  }
                   placeholder="e.g. https://my.growi.org"
                   {...register('siteUrl')}
                 />
                 <p className="form-text text-muted">
                   {/* 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>
               </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">
                   {/* 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>
               </td>
             </tr>
@@ -105,7 +127,10 @@ const SiteUrlSetting = (props: Props) => {
         </table>
       </div>
 
-      <AdminUpdateButtonRow type="submit" disabled={adminAppContainer.state.retrieveError != null} />
+      <AdminUpdateButtonRow
+        type="submit"
+        disabled={adminAppContainer.state.retrieveError != null}
+      />
     </form>
   );
 };
@@ -113,6 +138,8 @@ const SiteUrlSetting = (props: Props) => {
 /**
  * Wrapper component for using unstated
  */
-const SiteUrlSettingWrapper = withUnstatedContainers(SiteUrlSetting, [AdminAppContainer]);
+const SiteUrlSettingWrapper = withUnstatedContainers(SiteUrlSetting, [
+  AdminAppContainer,
+]);
 
 export default SiteUrlSettingWrapper;

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

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

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

@@ -1,33 +1,36 @@
 import type { FC } from 'react';
 import React, { useCallback, useEffect, useState } from 'react';
-
 import { useTranslation } from 'next-i18next';
 
 import { toastError, toastSuccess } from '~/client/util/toastr';
 import { useAdminSocket } from '~/features/admin/states/socket-io';
 import type {
-  PMStartedData, PMMigratingData, PMErrorCountData, PMEndedData,
-} from '~/interfaces/websocket';
-import {
-  SocketEventName,
+  PMEndedData,
+  PMErrorCountData,
+  PMMigratingData,
+  PMStartedData,
 } from '~/interfaces/websocket';
+import { SocketEventName } from '~/interfaces/websocket';
 
 import AdminAppContainer from '../../../services/AdminAppContainer';
 import { withUnstatedContainers } from '../../UnstatedUtils';
 import LabeledProgressBar from '../Common/LabeledProgressBar';
-
 import { ConfirmModal } from './ConfirmModal';
 
-
 type Props = {
-  adminAppContainer: typeof AdminAppContainer & { v5PageMigrationHandler: () => Promise<{ isV5Compatible: boolean }> },
-}
+  adminAppContainer: typeof AdminAppContainer & {
+    v5PageMigrationHandler: () => Promise<{ isV5Compatible: boolean }>;
+  };
+};
 
 const V5PageMigration: FC<Props> = (props: Props) => {
   // Modal
-  const [isV5PageMigrationModalShown, setIsV5PageMigrationModalShown] = useState(false);
+  const [isV5PageMigrationModalShown, setIsV5PageMigrationModalShown] =
+    useState(false);
   // 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 [skip, setSkip] = useState<number>(0);
   const [current, setCurrent] = useState<number>(0);
@@ -41,17 +44,24 @@ const V5PageMigration: FC<Props> = (props: Props) => {
   /*
    * 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 = () => {
     if (isInProgress == null) {
@@ -60,9 +70,7 @@ const V5PageMigration: FC<Props> = (props: Props) => {
 
     return (
       <>
-        {
-          isSucceeded != null && renderResultMessage(isSucceeded)
-        }
+        {isSucceeded != null && renderResultMessage(isSucceeded)}
         <LabeledProgressBar
           header={t('admin:v5_page_migration.header_upgrading_progress')}
           currentCount={current}
@@ -76,17 +84,16 @@ const V5PageMigration: FC<Props> = (props: Props) => {
   /*
    * Functions
    */
-  const onConfirm = async() => {
+  const onConfirm = async () => {
     setIsV5PageMigrationModalShown(false);
     try {
-      const { isV5Compatible } = await adminAppContainer.v5PageMigrationHandler();
+      const { isV5Compatible } =
+        await adminAppContainer.v5PageMigrationHandler();
       if (isV5Compatible) {
-
         return toastSuccess(t('admin:v5_page_migration.already_upgraded'));
       }
       toastSuccess(t('admin:v5_page_migration.successfully_started'));
-    }
-    catch (err) {
+    } catch (err) {
       toastError(err);
     }
   };
@@ -146,7 +153,12 @@ const V5PageMigration: FC<Props> = (props: Props) => {
       {renderProgressBar()}
       <div className="row my-3">
         <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')}
           </button>
         </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)
  */
-function buildSettingsData(appSettingsParams: Record<string, any>): FileUploadSettingsData {
+function buildSettingsData(
+  appSettingsParams: Record<string, any>,
+): FileUploadSettingsData {
   return {
     // File upload type
     fileUploadType: appSettingsParams.useOnlyEnvVarForFileUploadType
       ? appSettingsParams.envFileUploadType
       : appSettingsParams.fileUploadType,
-    isFixedFileUploadByEnvVar: appSettingsParams.useOnlyEnvVarForFileUploadType || false,
+    isFixedFileUploadByEnvVar:
+      appSettingsParams.useOnlyEnvVarForFileUploadType || false,
     envFileUploadType: appSettingsParams.envFileUploadType,
 
     // AWS S3
@@ -20,13 +26,15 @@ function buildSettingsData(appSettingsParams: Record<string, any>): FileUploadSe
     s3Bucket: appSettingsParams.s3Bucket || '',
     s3AccessKeyId: appSettingsParams.s3AccessKeyId || '',
     s3SecretAccessKey: appSettingsParams.s3SecretAccessKey || '',
-    s3ReferenceFileWithRelayMode: appSettingsParams.s3ReferenceFileWithRelayMode || false,
+    s3ReferenceFileWithRelayMode:
+      appSettingsParams.s3ReferenceFileWithRelayMode || false,
 
     // GCS
     gcsApiKeyJsonPath: appSettingsParams.gcsApiKeyJsonPath || '',
     gcsBucket: appSettingsParams.gcsBucket || '',
     gcsUploadNamespace: appSettingsParams.gcsUploadNamespace || '',
-    gcsReferenceFileWithRelayMode: appSettingsParams.gcsReferenceFileWithRelayMode || false,
+    gcsReferenceFileWithRelayMode:
+      appSettingsParams.gcsReferenceFileWithRelayMode || false,
     gcsUseOnlyEnvVars: appSettingsParams.gcsUseOnlyEnvVars || false,
     envGcsApiKeyJsonPath: appSettingsParams.envGcsApiKeyJsonPath,
     envGcsBucket: appSettingsParams.envGcsBucket,
@@ -37,14 +45,17 @@ function buildSettingsData(appSettingsParams: Record<string, any>): FileUploadSe
     azureClientId: appSettingsParams.azureClientId || '',
     azureClientSecret: appSettingsParams.azureClientSecret || '',
     azureStorageAccountName: appSettingsParams.azureStorageAccountName || '',
-    azureStorageContainerName: appSettingsParams.azureStorageContainerName || '',
-    azureReferenceFileWithRelayMode: appSettingsParams.azureReferenceFileWithRelayMode || false,
+    azureStorageContainerName:
+      appSettingsParams.azureStorageContainerName || '',
+    azureReferenceFileWithRelayMode:
+      appSettingsParams.azureReferenceFileWithRelayMode || false,
     azureUseOnlyEnvVars: appSettingsParams.azureUseOnlyEnvVars || false,
     envAzureTenantId: appSettingsParams.envAzureTenantId,
     envAzureClientId: appSettingsParams.envAzureClientId,
     envAzureClientSecret: appSettingsParams.envAzureClientSecret,
     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)
  */
 function buildRequestParams(
-    formData: FileUploadFormValues,
-    dirtyFields: Partial<Record<keyof FileUploadFormValues, boolean>>,
+  formData: FileUploadFormValues,
+  dirtyFields: Partial<Record<keyof FileUploadFormValues, boolean>>,
 ): Record<string, any> {
   const { fileUploadType } = formData;
 
@@ -70,14 +81,16 @@ function buildRequestParams(
     if (dirtyFields.s3SecretAccessKey) {
       requestParams.s3SecretAccessKey = formData.s3SecretAccessKey;
     }
-    requestParams.s3ReferenceFileWithRelayMode = formData.s3ReferenceFileWithRelayMode;
+    requestParams.s3ReferenceFileWithRelayMode =
+      formData.s3ReferenceFileWithRelayMode;
   }
 
   if (fileUploadType === 'gcs') {
     requestParams.gcsApiKeyJsonPath = formData.gcsApiKeyJsonPath;
     requestParams.gcsBucket = formData.gcsBucket;
     requestParams.gcsUploadNamespace = formData.gcsUploadNamespace;
-    requestParams.gcsReferenceFileWithRelayMode = formData.gcsReferenceFileWithRelayMode;
+    requestParams.gcsReferenceFileWithRelayMode =
+      formData.gcsReferenceFileWithRelayMode;
   }
 
   if (fileUploadType === 'azure') {
@@ -92,8 +105,10 @@ function buildRequestParams(
       requestParams.azureClientSecret = formData.azureClientSecret;
     }
     requestParams.azureStorageAccountName = formData.azureStorageAccountName;
-    requestParams.azureStorageContainerName = formData.azureStorageContainerName;
-    requestParams.azureReferenceFileWithRelayMode = formData.azureReferenceFileWithRelayMode;
+    requestParams.azureStorageContainerName =
+      formData.azureStorageContainerName;
+    requestParams.azureReferenceFileWithRelayMode =
+      formData.azureReferenceFileWithRelayMode;
   }
 
   return requestParams;
@@ -309,8 +324,14 @@ describe('useFileUploadSettings - secret field dirty tracking', () => {
     expect(requestParams).not.toHaveProperty('azureTenantId');
     expect(requestParams).not.toHaveProperty('azureClientId');
     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', () => {
@@ -390,6 +411,9 @@ describe('useFileUploadSettings - secret field dirty tracking', () => {
 
     expect(requestParams).toHaveProperty('azureTenantId', 'new-tenant-id');
     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 { apiv3Get, apiv3Put } from '~/client/util/apiv3-client';
 
-import type { FileUploadSettingsData, FileUploadFormValues } from './FileUploadSetting.types';
+import type {
+  FileUploadFormValues,
+  FileUploadSettingsData,
+} from './FileUploadSetting.types';
 
 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 {
@@ -19,7 +24,7 @@ export function useFileUploadSettings(): UseFileUploadSettingsReturn {
   const [error, setError] = useState<Error | null>(null);
 
   useEffect(() => {
-    const fetchData = async() => {
+    const fetchData = async () => {
       try {
         setIsLoading(true);
         const response = await apiv3Get('/app-settings/');
@@ -30,7 +35,8 @@ export function useFileUploadSettings(): UseFileUploadSettingsReturn {
           fileUploadType: appSettingsParams.useOnlyEnvVarForFileUploadType
             ? appSettingsParams.envFileUploadType
             : appSettingsParams.fileUploadType,
-          isFixedFileUploadByEnvVar: appSettingsParams.useOnlyEnvVarForFileUploadType || false,
+          isFixedFileUploadByEnvVar:
+            appSettingsParams.useOnlyEnvVarForFileUploadType || false,
           envFileUploadType: appSettingsParams.envFileUploadType,
 
           // AWS S3
@@ -39,13 +45,15 @@ export function useFileUploadSettings(): UseFileUploadSettingsReturn {
           s3Bucket: appSettingsParams.s3Bucket || '',
           s3AccessKeyId: appSettingsParams.s3AccessKeyId || '',
           s3SecretAccessKey: appSettingsParams.s3SecretAccessKey || '',
-          s3ReferenceFileWithRelayMode: appSettingsParams.s3ReferenceFileWithRelayMode || false,
+          s3ReferenceFileWithRelayMode:
+            appSettingsParams.s3ReferenceFileWithRelayMode || false,
 
           // GCS
           gcsApiKeyJsonPath: appSettingsParams.gcsApiKeyJsonPath || '',
           gcsBucket: appSettingsParams.gcsBucket || '',
           gcsUploadNamespace: appSettingsParams.gcsUploadNamespace || '',
-          gcsReferenceFileWithRelayMode: appSettingsParams.gcsReferenceFileWithRelayMode || false,
+          gcsReferenceFileWithRelayMode:
+            appSettingsParams.gcsReferenceFileWithRelayMode || false,
           gcsUseOnlyEnvVars: appSettingsParams.gcsUseOnlyEnvVars || false,
           envGcsApiKeyJsonPath: appSettingsParams.envGcsApiKeyJsonPath,
           envGcsBucket: appSettingsParams.envGcsBucket,
@@ -55,24 +63,29 @@ export function useFileUploadSettings(): UseFileUploadSettingsReturn {
           azureTenantId: appSettingsParams.azureTenantId || '',
           azureClientId: appSettingsParams.azureClientId || '',
           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,
           envAzureTenantId: appSettingsParams.envAzureTenantId,
           envAzureClientId: appSettingsParams.envAzureClientId,
           envAzureClientSecret: appSettingsParams.envAzureClientSecret,
-          envAzureStorageAccountName: appSettingsParams.envAzureStorageAccountName,
-          envAzureStorageContainerName: appSettingsParams.envAzureStorageContainerName,
+          envAzureStorageAccountName:
+            appSettingsParams.envAzureStorageAccountName,
+          envAzureStorageContainerName:
+            appSettingsParams.envAzureStorageContainerName,
         };
 
         setData(settingsData);
         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);
       }
     };
@@ -80,7 +93,10 @@ export function useFileUploadSettings(): UseFileUploadSettingsReturn {
     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 requestParams: Record<string, any> = {
@@ -97,14 +113,16 @@ export function useFileUploadSettings(): UseFileUploadSettingsReturn {
       if (dirtyFields.s3SecretAccessKey) {
         requestParams.s3SecretAccessKey = formData.s3SecretAccessKey;
       }
-      requestParams.s3ReferenceFileWithRelayMode = formData.s3ReferenceFileWithRelayMode;
+      requestParams.s3ReferenceFileWithRelayMode =
+        formData.s3ReferenceFileWithRelayMode;
     }
 
     if (fileUploadType === 'gcs') {
       requestParams.gcsApiKeyJsonPath = formData.gcsApiKeyJsonPath;
       requestParams.gcsBucket = formData.gcsBucket;
       requestParams.gcsUploadNamespace = formData.gcsUploadNamespace;
-      requestParams.gcsReferenceFileWithRelayMode = formData.gcsReferenceFileWithRelayMode;
+      requestParams.gcsReferenceFileWithRelayMode =
+        formData.gcsReferenceFileWithRelayMode;
     }
 
     if (fileUploadType === 'azure') {
@@ -119,11 +137,16 @@ export function useFileUploadSettings(): UseFileUploadSettingsReturn {
         requestParams.azureClientSecret = formData.azureClientSecret;
       }
       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;
 
     // Update local state with response
@@ -136,6 +159,9 @@ export function useFileUploadSettings(): UseFileUploadSettingsReturn {
   };
 
   return {
-    data, isLoading, error, updateSettings,
+    data,
+    isLoading,
+    error,
+    updateSettings,
   };
 }

+ 32 - 13
apps/app/src/client/components/Admin/AuditLog/ActivityTable.tsx

@@ -1,6 +1,5 @@
 import type { FC } from 'react';
-import React, { useState, useCallback } from 'react';
-
+import React, { useCallback, useState } from 'react';
 import { isPopulated } from '@growi/core';
 import { pagePathUtils } from '@growi/core/dist/utils';
 import { UserPicture } from '@growi/ui/dist/components';
@@ -11,19 +10,18 @@ import { Tooltip } from 'reactstrap';
 
 import type { IActivityHasId } from '~/interfaces/activity';
 
- type Props = {
-   activityList: IActivityHasId[]
- }
+type Props = {
+  activityList: IActivityHasId[];
+};
 
 const formatDate = (date: Date): string => {
   return format(new Date(date), 'yyyy/MM/dd HH:mm:ss');
 };
 
-export const ActivityTable : FC<Props> = (props: Props) => {
+export const ActivityTable: FC<Props> = (props: Props) => {
   const { t } = useTranslation();
   const [activeTooltipId, setActiveTooltipId] = useState<string | null>(null);
 
-
   const showToolTip = useCallback((id: string) => {
     setActiveTooltipId(id);
     setTimeout(() => {
@@ -48,12 +46,16 @@ export const ActivityTable : FC<Props> = (props: Props) => {
             return (
               <tr data-testid="activity-table" key={activity._id}>
                 <td>
-                  { activity.user != null && (
+                  {activity.user != null && (
                     <>
                       <UserPicture user={activity.user} />
                       <a
                         className="ms-2"
-                        href={isPopulated(activity.user) ? pagePathUtils.userHomepagePath(activity.user) : undefined}
+                        href={
+                          isPopulated(activity.user)
+                            ? pagePathUtils.userHomepagePath(activity.user)
+                            : undefined
+                        }
                       >
                         {activity.snapshot?.username}
                       </a>
@@ -68,12 +70,29 @@ export const ActivityTable : FC<Props> = (props: Props) => {
                     <span className="flex-grow-1 text-truncate">
                       {activity.endpoint}
                     </span>
-                    <CopyToClipboard text={activity.endpoint} onCopy={() => showToolTip(activity._id)}>
-                      <button type="button" className="btn btn-outline-secondary border-0 ms-2" id={`tooltipTarget-${activity._id}`}>
-                        <span className="material-symbols-outlined" aria-hidden="true">content_paste</span>
+                    <CopyToClipboard
+                      text={activity.endpoint}
+                      onCopy={() => showToolTip(activity._id)}
+                    >
+                      <button
+                        type="button"
+                        className="btn btn-outline-secondary border-0 ms-2"
+                        id={`tooltipTarget-${activity._id}`}
+                      >
+                        <span
+                          className="material-symbols-outlined"
+                          aria-hidden="true"
+                        >
+                          content_paste
+                        </span>
                       </button>
                     </CopyToClipboard>
-                    <Tooltip placement="top" isOpen={activeTooltipId === activity._id} fade={false} target={`tooltipTarget-${activity._id}`}>
+                    <Tooltip
+                      placement="top"
+                      isOpen={activeTooltipId === activity._id}
+                      fade={false}
+                      target={`tooltipTarget-${activity._id}`}
+                    >
                       copied!
                     </Tooltip>
                   </div>

+ 10 - 4
apps/app/src/client/components/Admin/AuditLog/AuditLogDisableMode.tsx

@@ -1,6 +1,5 @@
 import type { FC } from 'react';
 import React from 'react';
-
 import { useTranslation } from 'react-i18next';
 
 export const AuditLogDisableMode: FC = () => {
@@ -13,11 +12,18 @@ export const AuditLogDisableMode: FC = () => {
           <div className="col-md-6 mt-5">
             <div className="text-center">
               {/* error icon large */}
-              <h1><span className="material-symbols-outlined">error</span></h1>
-              <h1 className="text-center">{t('audit_log_management.audit_log')}</h1>
+              <h1>
+                <span className="material-symbols-outlined">error</span>
+              </h1>
+              <h1 className="text-center">
+                {t('audit_log_management.audit_log')}
+              </h1>
               <h3
                 // eslint-disable-next-line react/no-danger
-                dangerouslySetInnerHTML={{ __html: t('audit_log_management.disable_mode_explanation') }}
+                // biome-ignore lint/security/noDangerouslySetInnerHtml: trusted translation markup
+                dangerouslySetInnerHTML={{
+                  __html: t('audit_log_management.disable_mode_explanation'),
+                }}
               />
             </div>
           </div>

+ 38 - 14
apps/app/src/client/components/Admin/AuditLog/AuditLogSettings.tsx

@@ -1,36 +1,45 @@
 import type { FC } from 'react';
 import React, { useState } from 'react';
-
 import { useAtomValue } from 'jotai';
 import { useTranslation } from 'react-i18next';
 import { Collapse } from 'reactstrap';
 
 import { AllSupportedActions } from '~/interfaces/activity';
-import { activityExpirationSecondsAtom, auditLogAvailableActionsAtom } from '~/states/server-configurations';
+import {
+  activityExpirationSecondsAtom,
+  auditLogAvailableActionsAtom,
+} from '~/states/server-configurations';
 
 export const AuditLogSettings: FC = () => {
   const { t } = useTranslation();
 
   const [isExpandActionList, setIsExpandActionList] = useState(false);
 
-  const activityExpirationSeconds = useAtomValue(activityExpirationSecondsAtom) || 2592000;
+  const activityExpirationSeconds =
+    useAtomValue(activityExpirationSecondsAtom) || 2592000;
 
   const availableActions = useAtomValue(auditLogAvailableActionsAtom);
 
   return (
     <>
-      <h4 className="mt-4">{t('admin:audit_log_management.activity_expiration_date')}</h4>
+      <h4 className="mt-4">
+        {t('admin:audit_log_management.activity_expiration_date')}
+      </h4>
       <p className="form-text text-muted">
         {t('admin:audit_log_management.activity_expiration_date_explanation')}
       </p>
       <p className="alert alert-warning col-6">
         <span className="material-symbols-outlined">error</span>
-        <b>FIXED</b><br />
+        <b>FIXED</b>
+        <br />
         <b
           // eslint-disable-next-line react/no-danger
+          // biome-ignore lint/security/noDangerouslySetInnerHtml: trusted translation markup
           dangerouslySetInnerHTML={{
-            __html: t('admin:audit_log_management.fixed_by_env_var',
-              { key: 'ACTIVITY_EXPIRATION_SECONDS', value: activityExpirationSeconds }),
+            __html: t('admin:audit_log_management.fixed_by_env_var', {
+              key: 'ACTIVITY_EXPIRATION_SECONDS',
+              value: activityExpirationSeconds,
+            }),
           }}
         />
       </p>
@@ -45,24 +54,39 @@ export const AuditLogSettings: FC = () => {
           href={t('admin:audit_log_management.docs_url.log_type')}
           target="_blank"
           rel="noopener noreferrer"
+          aria-label="Help"
         >
-          <span className="material-symbols-outlined" aria-hidden="true">help</span>
+          <span className="material-symbols-outlined" aria-hidden="true">
+            help
+          </span>
+          <span className="visually-hidden">Help</span>
         </a>
       </h4>
       <p className="form-text text-muted">
         {t('admin:audit_log_management.available_action_list_explanation')}
       </p>
       <p className="mt-1">
-        <button type="button" className="btn btn-link p-0" aria-expanded="false" onClick={() => setIsExpandActionList(!isExpandActionList)}>
-          <span className={`material-symbols-outlined me-1 ${isExpandActionList ? 'rotate-90' : ''}`}>navigate_next</span>
-          { t('admin:audit_log_management.action_list') }
+        <button
+          type="button"
+          className="btn btn-link p-0"
+          aria-expanded="false"
+          onClick={() => setIsExpandActionList(!isExpandActionList)}
+        >
+          <span
+            className={`material-symbols-outlined me-1 ${isExpandActionList ? 'rotate-90' : ''}`}
+          >
+            navigate_next
+          </span>
+          {t('admin:audit_log_management.action_list')}
         </button>
       </p>
       <Collapse isOpen={isExpandActionList}>
         <ul className="list-group">
-          { availableActions.map(action => (
-            <li key={action} className="list-group-item">{t(`admin:audit_log_action.${action}`)}</li>
-          )) }
+          {availableActions.map((action) => (
+            <li key={action} className="list-group-item">
+              {t(`admin:audit_log_action.${action}`)}
+            </li>
+          ))}
         </ul>
       </Collapse>
     </>

+ 49 - 44
apps/app/src/client/components/Admin/AuditLog/DateRangePicker.tsx

@@ -1,64 +1,69 @@
 import type { FC } from 'react';
 import React, { forwardRef, useCallback } from 'react';
-
 import { addDays, format } from 'date-fns';
 import DatePicker from 'react-datepicker';
 import 'react-datepicker/dist/react-datepicker.css';
 
-
 type CustomInputProps = {
-  value?: string
-  onChange?: () => void
-  onFocus?: () => void
-}
+  value?: string;
+  onChange?: () => void;
+  onFocus?: () => void;
+};
 
-const CustomInput = forwardRef<HTMLInputElement, CustomInputProps>((props: CustomInputProps, ref) => {
-  const dateFormat = 'MM/dd/yyyy';
-  const date = new Date();
-  const placeholder = `${format(date, dateFormat)} - ${format(addDays(date, 1), dateFormat)}`;
+const CustomInput = forwardRef<HTMLInputElement, CustomInputProps>(
+  (props: CustomInputProps, ref) => {
+    const dateFormat = 'MM/dd/yyyy';
+    const date = new Date();
+    const placeholder = `${format(date, dateFormat)} - ${format(addDays(date, 1), dateFormat)}`;
 
-  return (
-    <div className="input-group admin-audit-log">
-      <span className="input-group-text">
-        <span className="material-symbols-outlined me-1">calendar_month</span>
-      </span>
-      <input
-        ref={ref}
-        type="text"
-        value={props?.value}
-        onFocus={props?.onFocus}
-        onChange={props?.onChange}
-        placeholder={placeholder}
-        className="form-control date-range-picker"
-        aria-describedby="basic-addon1"
-      />
-    </div>
-  );
-});
+    return (
+      <div className="input-group admin-audit-log">
+        <span className="input-group-text">
+          <span className="material-symbols-outlined me-1">calendar_month</span>
+        </span>
+        <input
+          ref={ref}
+          type="text"
+          value={props?.value}
+          onFocus={props?.onFocus}
+          onChange={props?.onChange}
+          placeholder={placeholder}
+          className="form-control date-range-picker"
+          aria-describedby="basic-addon1"
+        />
+      </div>
+    );
+  },
+);
 
 CustomInput.displayName = 'CustomInput';
 
 type DateRangePickerProps = {
-  startDate: Date | null
-  endDate: Date | null
-  onChange: (dateList: Date[] | null[]) => void
-}
+  startDate: Date | null;
+  endDate: Date | null;
+  onChange: (dateList: Date[] | null[]) => void;
+};
 
-export const DateRangePicker: FC<DateRangePickerProps> = (props: DateRangePickerProps) => {
+export const DateRangePicker: FC<DateRangePickerProps> = (
+  props: DateRangePickerProps,
+) => {
   const { startDate, endDate, onChange } = props;
 
-  const changeHandler = useCallback((dateList: Date[] | null[]) => {
-    if (onChange != null) {
-      const [start, end] = dateList;
-      const isSameTime = (start != null && end != null) && (start.getTime() === end.getTime());
-      if (isSameTime) {
-        onChange([null, null]);
+  const changeHandler = useCallback(
+    (dateList: Date[] | null[]) => {
+      if (onChange != null) {
+        const [start, end] = dateList;
+        const isSameTime =
+          start != null && end != null && start.getTime() === end.getTime();
+        if (isSameTime) {
+          onChange([null, null]);
+        } else {
+          onChange(dateList);
+        }
       }
-      else {
-        onChange(dateList);
-      }
-    }
-  }, [onChange]);
+    },
+    [onChange],
+  );
 
   return (
     <div className="me-2">

+ 54 - 26
apps/app/src/client/components/Admin/AuditLog/SearchUsernameTypeahead.tsx

@@ -1,8 +1,12 @@
 import type { ForwardRefRenderFunction } from 'react';
 import React, {
-  Fragment, useState, useCallback, forwardRef, useRef, useImperativeHandle,
+  Fragment,
+  forwardRef,
+  useCallback,
+  useImperativeHandle,
+  useRef,
+  useState,
 } from 'react';
-
 import type { TypeaheadRef } from 'react-bootstrap-typeahead';
 import { AsyncTypeahead, Menu, MenuItem } from 'react-bootstrap-typeahead';
 import { useTranslation } from 'react-i18next';
@@ -10,25 +14,27 @@ import { useTranslation } from 'react-i18next';
 import type { IClearable } from '~/client/interfaces/clearable';
 import { useSWRxUsernames } from '~/stores/user';
 
-
 const Categories = {
   activeUser: 'Active User',
   inactiveUser: 'Inactive User',
   activitySnapshotUser: 'Activity Snapshot User',
 } as const;
 
-type CategoryType = typeof Categories[keyof typeof Categories]
+type CategoryType = (typeof Categories)[keyof typeof Categories];
 
 type UserDataType = {
-  username: string
-  category: CategoryType
-}
+  username: string;
+  category: CategoryType;
+};
 
 type Props = {
-  onChange: (text: string[]) => void
-}
+  onChange: (text: string[]) => void;
+};
 
-const SearchUsernameTypeaheadSubstance: ForwardRefRenderFunction<IClearable, Props> = ((props: Props, ref) => {
+const SearchUsernameTypeaheadSubstance: ForwardRefRenderFunction<
+  IClearable,
+  Props
+> = (props: Props, ref) => {
   const { onChange } = props;
   const { t } = useTranslation();
 
@@ -42,16 +48,35 @@ const SearchUsernameTypeaheadSubstance: ForwardRefRenderFunction<IClearable, Pro
   /*
    * Fetch
    */
-  const requestOptions = { isIncludeActiveUser: true, isIncludeInactiveUser: true, isIncludeActivitySnapshotUser: true };
-  const { data: usernameData, error, isLoading: _isLoading } = useSWRxUsernames(searchKeyword, 0, 5, requestOptions);
-  const activeUsernames = usernameData?.activeUser?.usernames != null ? usernameData.activeUser.usernames : [];
-  const inactiveUsernames = usernameData?.inactiveUser?.usernames != null ? usernameData.inactiveUser.usernames : [];
-  const activitySnapshotUsernames = usernameData?.activitySnapshotUser?.usernames != null ? usernameData.activitySnapshotUser.usernames : [];
+  const requestOptions = {
+    isIncludeActiveUser: true,
+    isIncludeInactiveUser: true,
+    isIncludeActivitySnapshotUser: true,
+  };
+  const {
+    data: usernameData,
+    error,
+    isLoading: _isLoading,
+  } = useSWRxUsernames(searchKeyword, 0, 5, requestOptions);
+  const activeUsernames =
+    usernameData?.activeUser?.usernames != null
+      ? usernameData.activeUser.usernames
+      : [];
+  const inactiveUsernames =
+    usernameData?.inactiveUser?.usernames != null
+      ? usernameData.inactiveUser.usernames
+      : [];
+  const activitySnapshotUsernames =
+    usernameData?.activitySnapshotUser?.usernames != null
+      ? usernameData.activitySnapshotUser.usernames
+      : [];
   const isLoading = _isLoading === true && error == null;
 
   const allUser: UserDataType[] = [];
   const pushToAllUser = (usernames: string[], category: CategoryType) => {
-    usernames.forEach(username => allUser.push({ username, category }));
+    usernames.forEach((username) => {
+      allUser.push({ username, category });
+    });
   };
   pushToAllUser(activeUsernames, Categories.activeUser);
   pushToAllUser(inactiveUsernames, Categories.inactiveUser);
@@ -60,10 +85,13 @@ const SearchUsernameTypeaheadSubstance: ForwardRefRenderFunction<IClearable, Pro
   /*
    * Functions
    */
-  const changeHandler = useCallback((userData: UserDataType[]) => {
-    const usernames = userData.map(user => user.username);
-    onChange(usernames);
-  }, [onChange]);
+  const changeHandler = useCallback(
+    (userData: UserDataType[]) => {
+      const usernames = userData.map((user) => user.username);
+      onChange(usernames);
+    },
+    [onChange],
+  );
 
   const searchHandler = useCallback((text: string) => {
     setSearchKeyword(text);
@@ -76,7 +104,7 @@ const SearchUsernameTypeaheadSubstance: ForwardRefRenderFunction<IClearable, Pro
 
     let index = 0;
     const items = Object.values(Categories).map((category) => {
-      const userData = allUser.filter(user => user.category === category);
+      const userData = allUser.filter((user) => user.category === category);
       return (
         <Fragment key={category}>
           {index !== 0 && <Menu.Divider />}
@@ -94,9 +122,7 @@ const SearchUsernameTypeaheadSubstance: ForwardRefRenderFunction<IClearable, Pro
       );
     });
 
-    return (
-      <Menu {...menuProps}>{items}</Menu>
-    );
+    return <Menu {...menuProps}>{items}</Menu>;
   }, []);
 
   useImperativeHandle(ref, () => ({
@@ -129,6 +155,8 @@ const SearchUsernameTypeaheadSubstance: ForwardRefRenderFunction<IClearable, Pro
       />
     </div>
   );
-});
+};
 
-export const SearchUsernameTypeahead = forwardRef(SearchUsernameTypeaheadSubstance);
+export const SearchUsernameTypeahead = forwardRef(
+  SearchUsernameTypeaheadSubstance,
+);

+ 151 - 88
apps/app/src/client/components/Admin/AuditLog/SelectActionDropdown.tsx

@@ -1,122 +1,185 @@
 import type { FC } from 'react';
-import React, { useMemo, useCallback } from 'react';
-
+import React, { useCallback, useMemo } from 'react';
 import { useTranslation } from 'react-i18next';
 
-import type { SupportedActionType, SupportedActionCategoryType } from '~/interfaces/activity';
+import type {
+  SupportedActionCategoryType,
+  SupportedActionType,
+} from '~/interfaces/activity';
 import {
+  AdminActions,
+  AttachmentActions,
+  CommentActions,
+  InAppNotificationActions,
+  PageActions,
+  SearchActions,
+  ShareLinkActions,
   SupportedActionCategory,
-  PageActions, CommentActions, TagActions, ShareLinkActions, AttachmentActions, InAppNotificationActions, SearchActions, UserActions, AdminActions,
+  TagActions,
+  UserActions,
 } from '~/interfaces/activity';
 
 type Props = {
-  actionMap: Map<SupportedActionType, boolean>
-  availableActions: SupportedActionType[]
-  onChangeAction: (action: SupportedActionType) => void
-  onChangeMultipleAction: (actions: SupportedActionType[], isChecked: boolean) => void
-}
+  actionMap: Map<SupportedActionType, boolean>;
+  availableActions: SupportedActionType[];
+  onChangeAction: (action: SupportedActionType) => void;
+  onChangeMultipleAction: (
+    actions: SupportedActionType[],
+    isChecked: boolean,
+  ) => void;
+};
 
 export const SelectActionDropdown: FC<Props> = (props: Props) => {
   const { t } = useTranslation();
   const {
-    actionMap, availableActions, onChangeAction, onChangeMultipleAction,
+    actionMap,
+    availableActions,
+    onChangeAction,
+    onChangeMultipleAction,
   } = props;
 
-  const dropdownItems = useMemo<Array<{actionCategory: SupportedActionCategoryType, actions: SupportedActionType[]}>>(() => {
-    return (
-      [
-        {
-          actionCategory: SupportedActionCategory.PAGE,
-          actions: PageActions.filter(action => availableActions.includes(action)),
-        },
-        {
-          actionCategory: SupportedActionCategory.COMMENT,
-          actions: CommentActions.filter(action => availableActions.includes(action)),
-        },
-        {
-          actionCategory: SupportedActionCategory.TAG,
-          actions: TagActions.filter(action => availableActions.includes(action)),
-        },
-        {
-          actionCategory: SupportedActionCategory.ATTACHMENT,
-          actions: AttachmentActions.filter(action => availableActions.includes(action)),
-        },
-        {
-          actionCategory: SupportedActionCategory.SHARE_LINK,
-          actions: ShareLinkActions.filter(action => availableActions.includes(action)),
-        },
-        {
-          actionCategory: SupportedActionCategory.IN_APP_NOTIFICATION,
-          actions: InAppNotificationActions.filter(action => availableActions.includes(action)),
-        },
-        {
-          actionCategory: SupportedActionCategory.SEARCH,
-          actions: SearchActions.filter(action => availableActions.includes(action)),
-        },
-        {
-          actionCategory: SupportedActionCategory.USER,
-          actions: UserActions.filter(action => availableActions.includes(action)),
-        },
-        {
-          actionCategory: SupportedActionCategory.ADMIN,
-          actions: AdminActions.filter(action => availableActions.includes(action)),
-        },
-      ]
-    );
-  }, [availableActions]).filter(item => item.actions.length !== 0);
+  const dropdownItems = useMemo<
+    Array<{
+      actionCategory: SupportedActionCategoryType;
+      actions: SupportedActionType[];
+    }>
+  >(() => {
+    return [
+      {
+        actionCategory: SupportedActionCategory.PAGE,
+        actions: PageActions.filter((action) =>
+          availableActions.includes(action),
+        ),
+      },
+      {
+        actionCategory: SupportedActionCategory.COMMENT,
+        actions: CommentActions.filter((action) =>
+          availableActions.includes(action),
+        ),
+      },
+      {
+        actionCategory: SupportedActionCategory.TAG,
+        actions: TagActions.filter((action) =>
+          availableActions.includes(action),
+        ),
+      },
+      {
+        actionCategory: SupportedActionCategory.ATTACHMENT,
+        actions: AttachmentActions.filter((action) =>
+          availableActions.includes(action),
+        ),
+      },
+      {
+        actionCategory: SupportedActionCategory.SHARE_LINK,
+        actions: ShareLinkActions.filter((action) =>
+          availableActions.includes(action),
+        ),
+      },
+      {
+        actionCategory: SupportedActionCategory.IN_APP_NOTIFICATION,
+        actions: InAppNotificationActions.filter((action) =>
+          availableActions.includes(action),
+        ),
+      },
+      {
+        actionCategory: SupportedActionCategory.SEARCH,
+        actions: SearchActions.filter((action) =>
+          availableActions.includes(action),
+        ),
+      },
+      {
+        actionCategory: SupportedActionCategory.USER,
+        actions: UserActions.filter((action) =>
+          availableActions.includes(action),
+        ),
+      },
+      {
+        actionCategory: SupportedActionCategory.ADMIN,
+        actions: AdminActions.filter((action) =>
+          availableActions.includes(action),
+        ),
+      },
+    ];
+  }, [availableActions]).filter((item) => item.actions.length !== 0);
 
-  const actionCheckboxChangedHandler = useCallback((action) => {
-    if (onChangeAction != null) {
-      onChangeAction(action);
-    }
-  }, [onChangeAction]);
+  const actionCheckboxChangedHandler = useCallback(
+    (action) => {
+      if (onChangeAction != null) {
+        onChangeAction(action);
+      }
+    },
+    [onChangeAction],
+  );
 
-  const multipleActionCheckboxChangedHandler = useCallback((actions, isChecked) => {
-    if (onChangeMultipleAction != null) {
-      onChangeMultipleAction(actions, isChecked);
-    }
-  }, [onChangeMultipleAction]);
+  const multipleActionCheckboxChangedHandler = useCallback(
+    (actions, isChecked) => {
+      if (onChangeMultipleAction != null) {
+        onChangeMultipleAction(actions, isChecked);
+      }
+    },
+    [onChangeMultipleAction],
+  );
 
   return (
     <div className="btn-group me-2 admin-audit-log">
-      <button className="btn btn-outline-secondary dropdown-toggle" type="button" id="dropdownMenuButton" data-bs-toggle="dropdown">
-        <span className="material-symbols-outlined me-1">bolt</span>{t('admin:audit_log_management.action')}
+      <button
+        className="btn btn-outline-secondary dropdown-toggle"
+        type="button"
+        id="dropdownMenuButton"
+        data-bs-toggle="dropdown"
+      >
+        <span className="material-symbols-outlined me-1">bolt</span>
+        {t('admin:audit_log_management.action')}
       </button>
-      <ul className="dropdown-menu select-action-dropdown" aria-labelledby="dropdownMenuButton">
-        {dropdownItems.map(item => (
+      <ul
+        className="dropdown-menu select-action-dropdown"
+        aria-labelledby="dropdownMenuButton"
+      >
+        {dropdownItems.map((item) => (
           <div key={item.actionCategory}>
             <div className="dropdown-item">
               <div className="px-2 m-0">
                 <input
                   type="checkbox"
                   className="form-check-input"
+                  id={`checkboxCategory${item.actionCategory}`}
                   defaultChecked
-                  onChange={(e) => { multipleActionCheckboxChangedHandler(item.actions, e.target.checked) }}
+                  onChange={(e) => {
+                    multipleActionCheckboxChangedHandler(
+                      item.actions,
+                      e.target.checked,
+                    );
+                  }}
                 />
-                <label className="form-label form-check-label">{t(`admin:audit_log_action_category.${item.actionCategory}`)}</label>
+                <label
+                  className="form-label form-check-label"
+                  htmlFor={`checkboxCategory${item.actionCategory}`}
+                >
+                  {t(`admin:audit_log_action_category.${item.actionCategory}`)}
+                </label>
               </div>
             </div>
-            {
-              item.actions.map(action => (
-                <div className="dropdown-item" key={action}>
-                  <div className="px-4 m-0">
-                    <input
-                      type="checkbox"
-                      className="form-check-input"
-                      id={`checkbox${action}`}
-                      onChange={() => { actionCheckboxChangedHandler(action) }}
-                      checked={actionMap.get(action)}
-                    />
-                    <label
-                      className="form-check-label"
-                      htmlFor={`checkbox${action}`}
-                    >
-                      {t(`admin:audit_log_action.${action}`)}
-                    </label>
-                  </div>
+            {item.actions.map((action) => (
+              <div className="dropdown-item" key={action}>
+                <div className="px-4 m-0">
+                  <input
+                    type="checkbox"
+                    className="form-check-input"
+                    id={`checkbox${action}`}
+                    onChange={() => {
+                      actionCheckboxChangedHandler(action);
+                    }}
+                    checked={actionMap.get(action)}
+                  />
+                  <label
+                    className="form-check-label"
+                    htmlFor={`checkbox${action}`}
+                  >
+                    {t(`admin:audit_log_action.${action}`)}
+                  </label>
                 </div>
-              ))
-            }
+              </div>
+            ))}
           </div>
         ))}
       </ul>

+ 143 - 73
apps/app/src/client/components/Admin/AuditLogManagement.tsx

@@ -1,6 +1,6 @@
+import type React from 'react';
 import type { FC } from 'react';
-import React, { useState, useCallback, useRef } from 'react';
-
+import { useCallback, useRef, useState } from 'react';
 import { LoadingSpinner } from '@growi/ui/dist/components';
 import { format } from 'date-fns/format';
 import { useAtomValue } from 'jotai';
@@ -9,11 +9,13 @@ import { useTranslation } from 'react-i18next';
 import type { IClearable } from '~/client/interfaces/clearable';
 import { toastError } from '~/client/util/toastr';
 import type { SupportedActionType } from '~/interfaces/activity';
-import { auditLogEnabledAtom, auditLogAvailableActionsAtom } from '~/states/server-configurations';
+import {
+  auditLogAvailableActionsAtom,
+  auditLogEnabledAtom,
+} from '~/states/server-configurations';
 import { useSWRxActivity } from '~/stores/activity';
 
 import PaginationWrapper from '../PaginationWrapper';
-
 import { ActivityTable } from './AuditLog/ActivityTable';
 import { AuditLogDisableMode } from './AuditLog/AuditLogDisableMode';
 import { AuditLogSettings } from './AuditLog/AuditLogSettings';
@@ -35,7 +37,9 @@ export const AuditLogManagement: FC = () => {
 
   const typeaheadRef = useRef<IClearable>(null);
 
-  const auditLogAvailableActionsData = useAtomValue(auditLogAvailableActionsAtom);
+  const auditLogAvailableActionsData = useAtomValue(
+    auditLogAvailableActionsAtom,
+  );
 
   /*
    * State
@@ -48,20 +52,39 @@ export const AuditLogManagement: FC = () => {
   const [endDate, setEndDate] = useState<Date | null>(null);
   const [selectedUsernames, setSelectedUsernames] = useState<string[]>([]);
   const [actionMap, setActionMap] = useState(
-    new Map<SupportedActionType, boolean>(auditLogAvailableActionsData != null ? auditLogAvailableActionsData.map(action => [action, true]) : []),
+    new Map<SupportedActionType, boolean>(
+      auditLogAvailableActionsData != null
+        ? auditLogAvailableActionsData.map((action) => [action, true])
+        : [],
+    ),
   );
 
   /*
    * Fetch
    */
-  const selectedDate = { startDate: formatDate(startDate), endDate: formatDate(endDate) };
-  const selectedActionList = Array.from(actionMap.entries()).filter(v => v[1]).map(v => v[0]);
-  const searchFilter = { actions: selectedActionList, dates: selectedDate, usernames: selectedUsernames };
-
-  const { data: activityData, mutate: mutateActivity, error } = useSWRxActivity(PAGING_LIMIT, offset, searchFilter);
+  const selectedDate = {
+    startDate: formatDate(startDate),
+    endDate: formatDate(endDate),
+  };
+  const selectedActionList = Array.from(actionMap.entries())
+    .filter((v) => v[1])
+    .map((v) => v[0]);
+  const searchFilter = {
+    actions: selectedActionList,
+    dates: selectedDate,
+    usernames: selectedUsernames,
+  };
+
+  const {
+    data: activityData,
+    mutate: mutateActivity,
+    error,
+  } = useSWRxActivity(PAGING_LIMIT, offset, searchFilter);
   const activityList = activityData?.docs != null ? activityData.docs : [];
-  const totalActivityNum = activityData?.totalDocs != null ? activityData.totalDocs : 0;
-  const totalPagingPages = activityData?.totalPages != null ? activityData.totalPages : 0;
+  const totalActivityNum =
+    activityData?.totalDocs != null ? activityData.totalDocs : 0;
+  const totalPagingPages =
+    activityData?.totalPages != null ? activityData.totalPages : 0;
   const isLoading = activityData === undefined && error == null;
 
   if (error != null) {
@@ -83,17 +106,25 @@ export const AuditLogManagement: FC = () => {
     setEndDate(dateList[1]);
   }, []);
 
-  const actionCheckboxChangedHandler = useCallback((action: SupportedActionType) => {
-    setActivePageNumber(1);
-    actionMap.set(action, !actionMap.get(action));
-    setActionMap(new Map(actionMap.entries()));
-  }, [actionMap, setActionMap]);
+  const actionCheckboxChangedHandler = useCallback(
+    (action: SupportedActionType) => {
+      setActivePageNumber(1);
+      actionMap.set(action, !actionMap.get(action));
+      setActionMap(new Map(actionMap.entries()));
+    },
+    [actionMap],
+  );
 
-  const multipleActionCheckboxChangedHandler = useCallback((actions: SupportedActionType[], isChecked) => {
-    setActivePageNumber(1);
-    actions.forEach(action => actionMap.set(action, isChecked));
-    setActionMap(new Map(actionMap.entries()));
-  }, [actionMap, setActionMap]);
+  const multipleActionCheckboxChangedHandler = useCallback(
+    (actions: SupportedActionType[], isChecked) => {
+      setActivePageNumber(1);
+      actions.forEach((action) => {
+        actionMap.set(action, isChecked);
+      });
+      setActionMap(new Map(actionMap.entries()));
+    },
+    [actionMap],
+  );
 
   const setUsernamesHandler = useCallback((usernames: string[]) => {
     setActivePageNumber(1);
@@ -108,41 +139,55 @@ export const AuditLogManagement: FC = () => {
     typeaheadRef.current?.clear();
 
     if (auditLogAvailableActionsData != null) {
-      setActionMap(new Map<SupportedActionType, boolean>(auditLogAvailableActionsData.map(action => [action, true])));
+      setActionMap(
+        new Map<SupportedActionType, boolean>(
+          auditLogAvailableActionsData.map((action) => [action, true]),
+        ),
+      );
     }
-  }, [setActivePageNumber, setStartDate, setEndDate, setSelectedUsernames, setActionMap, auditLogAvailableActionsData]);
+  }, [auditLogAvailableActionsData]);
 
   const reloadButtonPushedHandler = useCallback(() => {
     setActivePageNumber(1);
     mutateActivity();
   }, [mutateActivity]);
 
-  const jumpPageInputChangeHandler = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
-    const inputNumber = Number(e.target.value);
-    const isNan = Number.isNaN(inputNumber);
-
-    if (!isNan) {
-      // eslint-disable-next-line no-nested-ternary
-      const jumpPageNumber = inputNumber > totalPagingPages ? totalPagingPages : inputNumber <= 0 ? activePageNumber : inputNumber;
-      setJumpPageNumber(jumpPageNumber);
-    }
-    else {
-      setJumpPageNumber(activePageNumber);
-    }
-  }, [totalPagingPages, activePageNumber, setJumpPageNumber]);
+  const jumpPageInputChangeHandler = useCallback(
+    (e: React.ChangeEvent<HTMLInputElement>) => {
+      const inputNumber = Number(e.target.value);
+      const isNan = Number.isNaN(inputNumber);
+
+      if (!isNan) {
+        // eslint-disable-next-line no-nested-ternary
+        const jumpPageNumber =
+          inputNumber > totalPagingPages
+            ? totalPagingPages
+            : inputNumber <= 0
+              ? activePageNumber
+              : inputNumber;
+        setJumpPageNumber(jumpPageNumber);
+      } else {
+        setJumpPageNumber(activePageNumber);
+      }
+    },
+    [totalPagingPages, activePageNumber],
+  );
 
-  const jumpPageInputKeyDownHandler = useCallback((e) => {
-    if (e.key === 'Enter') {
-      setActivePageNumber(jumpPageNumber);
-    }
-  }, [setActivePageNumber, jumpPageNumber]);
+  const jumpPageInputKeyDownHandler = useCallback(
+    (e) => {
+      if (e.key === 'Enter') {
+        setActivePageNumber(jumpPageNumber);
+      }
+    },
+    [jumpPageNumber],
+  );
 
   const jumpPageButtonPushedHandler = useCallback(() => {
     setActivePageNumber(jumpPageNumber);
   }, [jumpPageNumber]);
 
-  // eslint-disable-next-line max-len
-  const activityCounter = `<b>${activityList.length === 0 ? 0 : offset + 1}</b> - <b>${(PAGING_LIMIT * activePageNumber) - (PAGING_LIMIT - activityList.length)}</b> of <b>${totalActivityNum}<b/>`;
+  const startIndex = activityList.length === 0 ? 0 : offset + 1;
+  const endIndex = activityList.length === 0 ? 0 : offset + activityList.length;
 
   if (!auditLogEnabled) {
     return <AuditLogDisableMode />;
@@ -150,20 +195,36 @@ export const AuditLogManagement: FC = () => {
 
   return (
     <div data-testid="admin-auditlog">
-      <button type="button" className="btn btn-outline-secondary mb-4" onClick={() => setIsSettingPage(!isSettingPage)}>
-        {
-          isSettingPage
-            ? <><span className="material-symbols-outlined">arrow_left_alt</span>{t('admin:audit_log_management.return')}</>
-            : <><span className="material-symbols-outlined">settings</span>{t('admin:audit_log_management.settings')}</>
-        }
+      <button
+        type="button"
+        className="btn btn-outline-secondary mb-4"
+        onClick={() => setIsSettingPage(!isSettingPage)}
+      >
+        {isSettingPage ? (
+          <>
+            <span className="material-symbols-outlined">arrow_left_alt</span>
+            {t('admin:audit_log_management.return')}
+          </>
+        ) : (
+          <>
+            <span className="material-symbols-outlined">settings</span>
+            {t('admin:audit_log_management.settings')}
+          </>
+        )}
       </button>
 
       <h2 className="admin-setting-header mb-3">
         <span>
-          {isSettingPage ? t('audit_log_management.audit_log_settings') : t('audit_log_management.audit_log')}
+          {isSettingPage
+            ? t('audit_log_management.audit_log_settings')
+            : t('audit_log_management.audit_log')}
         </span>
-        { !isSettingPage && (
-          <button type="button" className="btn btn-sm ms-auto grw-btn-reload" onClick={reloadButtonPushedHandler}>
+        {!isSettingPage && (
+          <button
+            type="button"
+            className="btn btn-sm ms-auto grw-btn-reload"
+            onClick={reloadButtonPushedHandler}
+          >
             <span className="material-symbols-outlined">refresh</span>
           </button>
         )}
@@ -199,28 +260,28 @@ export const AuditLogManagement: FC = () => {
             </div>
 
             <div className="col-12">
-              <button type="button" className="btn btn-link" onClick={clearButtonPushedHandler}>
+              <button
+                type="button"
+                className="btn btn-link"
+                onClick={clearButtonPushedHandler}
+              >
                 {t('admin:audit_log_management.clear')}
               </button>
             </div>
           </div>
 
-          <p
-            className="ms-2"
-            // eslint-disable-next-line react/no-danger
-            dangerouslySetInnerHTML={{ __html: activityCounter }}
-          />
-
-          { isLoading
-            ? (
-              <div className="text-muted text-center mb-5">
-                <LoadingSpinner className="me-1 fs-3" />
-              </div>
-            )
-            : (
-              <ActivityTable activityList={activityList} />
-            )
-          }
+          <p className="ms-2">
+            <strong>{startIndex}</strong> - <strong>{endIndex}</strong> of{' '}
+            <strong>{totalActivityNum}</strong>
+          </p>
+
+          {isLoading ? (
+            <div className="text-muted text-center mb-5">
+              <LoadingSpinner className="me-1 fs-3" />
+            </div>
+          ) : (
+            <ActivityTable activityList={activityList} />
+          )}
 
           <div className="d-flex flex-row justify-content-center">
             <PaginationWrapper
@@ -233,7 +294,12 @@ export const AuditLogManagement: FC = () => {
             />
 
             <div className="admin-audit-log ms-3">
-              <label htmlFor="jumpPageInput" className="form-label me-1 text-secondary">Jump To Page</label>
+              <label
+                htmlFor="jumpPageInput"
+                className="form-label me-1 text-secondary"
+              >
+                Jump To Page
+              </label>
               <input
                 id="jumpPageInput"
                 type="text"
@@ -241,7 +307,11 @@ export const AuditLogManagement: FC = () => {
                 onChange={jumpPageInputChangeHandler}
                 onKeyDown={jumpPageInputKeyDownHandler}
               />
-              <button className="btn btn-sm" type="button" onClick={jumpPageButtonPushedHandler}>
+              <button
+                className="btn btn-sm"
+                type="button"
+                onClick={jumpPageButtonPushedHandler}
+              >
                 <b>Go</b>
               </button>
             </div>

+ 2 - 6
apps/app/src/client/components/Admin/Common/Accordion.jsx

@@ -1,9 +1,7 @@
 import React, { useState } from 'react';
-
 import PropTypes from 'prop-types';
 import { Collapse } from 'reactstrap';
 
-
 const Accordion = (props) => {
   const [isOpen, setIsOpen] = useState(props.isOpenDefault);
   return (
@@ -14,15 +12,13 @@ const Accordion = (props) => {
           type="button"
           data-bs-toggle="collapse"
           aria-expanded="true"
-          onClick={() => setIsOpen(prevState => !prevState)}
+          onClick={() => setIsOpen((prevState) => !prevState)}
         >
           {props.title}
         </button>
       </p>
       <Collapse isOpen={isOpen}>
-        <div className="accordion-body">
-          {props.children}
-        </div>
+        <div className="accordion-body">{props.children}</div>
       </Collapse>
     </div>
   );

+ 11 - 5
apps/app/src/client/components/Admin/Common/AdminInstallButtonRow.tsx

@@ -1,16 +1,22 @@
 import React, { type JSX } from 'react';
 
 type Props = {
-  onClick: () => void,
-  disabled: boolean,
-
-}
+  onClick: () => void;
+  disabled: boolean;
+};
 
 export const AdminInstallButtonRow = (props: Props): JSX.Element => {
   return (
     <div className="row my-3">
       <div className="mx-auto">
-        <button type="button" className="btn btn-primary" onClick={props.onClick} disabled={props.disabled}>Install</button>
+        <button
+          type="button"
+          className="btn btn-primary"
+          onClick={props.onClick}
+          disabled={props.disabled}
+        >
+          Install
+        </button>
       </div>
     </div>
   );

+ 5 - 6
apps/app/src/client/components/Admin/Common/AdminUpdateButtonRow.tsx

@@ -1,12 +1,11 @@
 import React, { type JSX } from 'react';
-
 import { useTranslation } from 'next-i18next';
 
 type Props = {
-  onClick?: () => void,
-  disabled?: boolean,
-  type?: 'button' | 'submit' | 'reset',
-}
+  onClick?: () => void;
+  disabled?: boolean;
+  type?: 'button' | 'submit' | 'reset';
+};
 
 const AdminUpdateButtonRow = (props: Props): JSX.Element => {
   const { t } = useTranslation('admin');
@@ -22,7 +21,7 @@ const AdminUpdateButtonRow = (props: Props): JSX.Element => {
           onClick={props.onClick}
           disabled={props.disabled ?? false}
         >
-          { t('Update') }
+          {t('Update')}
         </button>
       </div>
     </div>

+ 17 - 12
apps/app/src/client/components/Admin/Common/LabeledProgressBar.tsx

@@ -1,18 +1,15 @@
 import React, { type JSX } from 'react';
-
 import { Progress } from 'reactstrap';
 
 type Props = {
-  header: string,
-  currentCount: number,
-  totalCount: number,
-  isInProgress?: boolean,
-}
+  header: string;
+  currentCount: number;
+  totalCount: number;
+  isInProgress?: boolean;
+};
 
 const LabeledProgressBar = (props: Props): JSX.Element => {
-  const {
-    header, currentCount, totalCount, isInProgress,
-  } = props;
+  const { header, currentCount, totalCount, isInProgress } = props;
 
   const progressingColor = isInProgress ? 'info' : 'success';
 
@@ -20,14 +17,22 @@ const LabeledProgressBar = (props: Props): JSX.Element => {
     <>
       <h6 className="my-1">
         {header}
-        <div className="float-end">{currentCount} / {totalCount}</div>
+        <div className="float-end">
+          {currentCount} / {totalCount}
+        </div>
       </h6>
       <Progress multi>
-        <Progress bar max={totalCount} color={progressingColor} striped={isInProgress} animated={isInProgress} value={currentCount} />
+        <Progress
+          bar
+          max={totalCount}
+          color={progressingColor}
+          striped={isInProgress}
+          animated={isInProgress}
+          value={currentCount}
+        />
       </Progress>
     </>
   );
-
 };
 
 export default LabeledProgressBar;

+ 8 - 10
apps/app/src/client/components/Admin/Customize/Customize.jsx

@@ -1,6 +1,4 @@
-
-import React, { useEffect, useCallback } from 'react';
-
+import React, { useCallback, useEffect } from 'react';
 import PropTypes from 'prop-types';
 
 import AdminCustomizeContainer from '~/client/services/AdminCustomizeContainer';
@@ -9,7 +7,6 @@ import { toArrayIfNot } from '~/utils/array-utils';
 import loggerFactory from '~/utils/logger';
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
-
 import CustomizeCssSetting from './CustomizeCssSetting';
 import CustomizeFunctionSetting from './CustomizeFunctionSetting';
 import CustomizeLayoutSetting from './CustomizeLayoutSetting';
@@ -26,11 +23,10 @@ const logger = loggerFactory('growi:services:AdminCustomizePage');
 function Customize(props) {
   const { adminCustomizeContainer } = props;
 
-  const fetchCustomizeSettingsData = useCallback(async() => {
+  const fetchCustomizeSettingsData = useCallback(async () => {
     try {
       await adminCustomizeContainer.retrieveCustomizeData();
-    }
-    catch (err) {
+    } catch (err) {
       const errs = toArrayIfNot(err);
       toastError(errs);
       logger.error(errs);
@@ -41,7 +37,6 @@ function Customize(props) {
     fetchCustomizeSettingsData();
   }, [fetchCustomizeSettingsData]);
 
-
   return (
     <div data-testid="admin-customize">
       <div className="mb-5">
@@ -78,10 +73,13 @@ function Customize(props) {
   );
 }
 
-const CustomizePageWithUnstatedContainer = withUnstatedContainers(Customize, [AdminCustomizeContainer]);
+const CustomizePageWithUnstatedContainer = withUnstatedContainers(Customize, [
+  AdminCustomizeContainer,
+]);
 
 Customize.propTypes = {
-  adminCustomizeContainer: PropTypes.instanceOf(AdminCustomizeContainer).isRequired,
+  adminCustomizeContainer: PropTypes.instanceOf(AdminCustomizeContainer)
+    .isRequired,
 };
 
 export default CustomizePageWithUnstatedContainer;

+ 36 - 28
apps/app/src/client/components/Admin/Customize/CustomizeCssSetting.tsx

@@ -1,29 +1,23 @@
-import React, { useCallback, useEffect, type JSX } from 'react';
-
+import React, { type JSX, useCallback, useEffect } from 'react';
 import { useTranslation } from 'next-i18next';
 import { useForm } from 'react-hook-form';
 import { Card, CardBody } from 'reactstrap';
 
 import AdminCustomizeContainer from '~/client/services/AdminCustomizeContainer';
-import { toastSuccess, toastError } from '~/client/util/toastr';
+import { toastError, toastSuccess } from '~/client/util/toastr';
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
 import AdminUpdateButtonRow from '../Common/AdminUpdateButtonRow';
 
 type Props = {
-  adminCustomizeContainer: AdminCustomizeContainer
-}
+  adminCustomizeContainer: AdminCustomizeContainer;
+};
 
 const CustomizeCssSetting = (props: Props): JSX.Element => {
-
   const { adminCustomizeContainer } = props;
   const { t } = useTranslation();
 
-  const {
-    register,
-    handleSubmit,
-    reset,
-  } = useForm();
+  const { register, handleSubmit, reset } = useForm();
 
   // Sync form with container state
   useEffect(() => {
@@ -32,28 +26,38 @@ const CustomizeCssSetting = (props: Props): JSX.Element => {
     });
   }, [adminCustomizeContainer.state.currentCustomizeCss, reset]);
 
-  const onSubmit = useCallback(async(data) => {
-    try {
-      // Update container state before API call
-      await adminCustomizeContainer.changeCustomizeCss(data.customizeCss);
-      await adminCustomizeContainer.updateCustomizeCss();
-      toastSuccess(t('toaster.update_successed', { target: t('admin:customize_settings.custom_css'), ns: 'commons' }));
-    }
-    catch (err) {
-      toastError(err);
-    }
-  }, [t, adminCustomizeContainer]);
+  const onSubmit = useCallback(
+    async (data) => {
+      try {
+        // Update container state before API call
+        await adminCustomizeContainer.changeCustomizeCss(data.customizeCss);
+        await adminCustomizeContainer.updateCustomizeCss();
+        toastSuccess(
+          t('toaster.update_successed', {
+            target: t('admin:customize_settings.custom_css'),
+            ns: 'commons',
+          }),
+        );
+      } catch (err) {
+        toastError(err);
+      }
+    },
+    [t, adminCustomizeContainer],
+  );
 
   return (
     <React.Fragment>
       <div className="row">
         <div className="col-12">
-          <h2 className="admin-setting-header">{t('admin:customize_settings.custom_css')}</h2>
+          <h2 className="admin-setting-header">
+            {t('admin:customize_settings.custom_css')}
+          </h2>
 
           <Card className="card custom-card bg-body-tertiary my-3">
             <CardBody className="px-0 py-2">
-              { t('admin:customize_settings.write_css') }<br />
-              { t('admin:customize_settings.reflect_change') }
+              {t('admin:customize_settings.write_css')}
+              <br />
+              {t('admin:customize_settings.reflect_change')}
             </CardBody>
           </Card>
 
@@ -66,15 +70,19 @@ const CustomizeCssSetting = (props: Props): JSX.Element => {
               />
             </div>
 
-            <AdminUpdateButtonRow type="submit" disabled={adminCustomizeContainer.state.retrieveError != null} />
+            <AdminUpdateButtonRow
+              type="submit"
+              disabled={adminCustomizeContainer.state.retrieveError != null}
+            />
           </form>
         </div>
       </div>
     </React.Fragment>
   );
-
 };
 
-const CustomizeCssSettingWrapper = withUnstatedContainers(CustomizeCssSetting, [AdminCustomizeContainer]);
+const CustomizeCssSettingWrapper = withUnstatedContainers(CustomizeCssSetting, [
+  AdminCustomizeContainer,
+]);
 
 export default CustomizeCssSettingWrapper;

+ 7 - 11
apps/app/src/client/components/Admin/Customize/CustomizeFunctionOption.tsx

@@ -1,18 +1,15 @@
 import React, { type JSX } from 'react';
 
 type Props = {
-  optionId: string
-  label: string,
-  isChecked: boolean,
-  onChecked: () => void,
-  children: React.ReactNode,
-}
+  optionId: string;
+  label: string;
+  isChecked: boolean;
+  onChecked: () => void;
+  children: React.ReactNode;
+};
 
 const CustomizeFunctionOption = (props: Props): JSX.Element => {
-
-  const {
-    optionId, label, isChecked, onChecked, children,
-  } = props;
+  const { optionId, label, isChecked, onChecked, children } = props;
 
   return (
     <React.Fragment>
@@ -31,7 +28,6 @@ const CustomizeFunctionOption = (props: Props): JSX.Element => {
       {children}
     </React.Fragment>
   );
-
 };
 
 export default CustomizeFunctionOption;

+ 105 - 44
apps/app/src/client/components/Admin/Customize/CustomizeFunctionSetting.tsx

@@ -1,33 +1,33 @@
-import React, { useCallback, type JSX } from 'react';
-
+import React, { type JSX, useCallback } from 'react';
 import { useTranslation } from 'next-i18next';
 import { Card, CardBody } from 'reactstrap';
 
 import AdminCustomizeContainer from '~/client/services/AdminCustomizeContainer';
-import { toastSuccess, toastError } from '~/client/util/toastr';
+import { toastError, toastSuccess } from '~/client/util/toastr';
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
 import AdminUpdateButtonRow from '../Common/AdminUpdateButtonRow';
-
 import CustomizeFunctionOption from './CustomizeFunctionOption';
 import PagingSizeUncontrolledDropdown from './PagingSizeUncontrolledDropdown';
 
 type Props = {
-  adminCustomizeContainer: AdminCustomizeContainer
-}
+  adminCustomizeContainer: AdminCustomizeContainer;
+};
 
 const CustomizeFunctionSetting = (props: Props): JSX.Element => {
-
   const { adminCustomizeContainer } = props;
   const { t } = useTranslation();
 
-  const onClickSubmit = useCallback(async() => {
-
+  const onClickSubmit = useCallback(async () => {
     try {
       await adminCustomizeContainer.updateCustomizeFunction();
-      toastSuccess(t('toaster.update_successed', { target: t('admin:customize_settings.function'), ns: 'commons' }));
-    }
-    catch (err) {
+      toastSuccess(
+        t('toaster.update_successed', {
+          target: t('admin:customize_settings.function'),
+          ns: 'commons',
+        }),
+      );
+    } catch (err) {
       toastError(err);
     }
   }, [t, adminCustomizeContainer]);
@@ -36,24 +36,33 @@ const CustomizeFunctionSetting = (props: Props): JSX.Element => {
     <React.Fragment>
       <div className="row">
         <div className="col-12">
-          <h2 className="admin-setting-header">{t('admin:customize_settings.function')}</h2>
+          <h2 className="admin-setting-header">
+            {t('admin:customize_settings.function')}
+          </h2>
           <Card className="card custom-card bg-body-tertiary my-3">
             <CardBody className="px-0 py-2">
               {t('admin:customize_settings.function_desc')}
             </CardBody>
           </Card>
 
-
           <div className="row mt-4">
             <div className="offset-md-2 col-md-7 text-start">
               <CustomizeFunctionOption
                 optionId="isEnabledAttachTitleHeader"
-                label={t('admin:customize_settings.function_options.attach_title_header')}
-                isChecked={adminCustomizeContainer.state.isEnabledAttachTitleHeader}
-                onChecked={() => { adminCustomizeContainer.switchEnabledAttachTitleHeader() }}
+                label={t(
+                  'admin:customize_settings.function_options.attach_title_header',
+                )}
+                isChecked={
+                  adminCustomizeContainer.state.isEnabledAttachTitleHeader
+                }
+                onChecked={() => {
+                  adminCustomizeContainer.switchEnabledAttachTitleHeader();
+                }}
               >
                 <p className="form-text text-muted">
-                  {t('admin:customize_settings.function_options.attach_title_header_desc')}
+                  {t(
+                    'admin:customize_settings.function_options.attach_title_header_desc',
+                  )}
                 </p>
               </CustomizeFunctionOption>
             </div>
@@ -61,43 +70,67 @@ const CustomizeFunctionSetting = (props: Props): JSX.Element => {
 
           <PagingSizeUncontrolledDropdown
             label={t('admin:customize_settings.function_options.list_num_s')}
-            desc={t('admin:customize_settings.function_options.list_num_desc_s')}
+            desc={t(
+              'admin:customize_settings.function_options.list_num_desc_s',
+            )}
             toggleLabel={adminCustomizeContainer.state.pageLimitationS || 20}
             dropdownItemSize={[10, 20, 50, 100]}
-            onChangeDropdownItem={adminCustomizeContainer.switchPageListLimitationS}
+            onChangeDropdownItem={
+              adminCustomizeContainer.switchPageListLimitationS
+            }
           />
           <PagingSizeUncontrolledDropdown
             label={t('admin:customize_settings.function_options.list_num_m')}
-            desc={t('admin:customize_settings.function_options.list_num_desc_m')}
+            desc={t(
+              'admin:customize_settings.function_options.list_num_desc_m',
+            )}
             toggleLabel={adminCustomizeContainer.state.pageLimitationM || 10}
             dropdownItemSize={[5, 10, 20, 50, 100]}
-            onChangeDropdownItem={adminCustomizeContainer.switchPageListLimitationM}
+            onChangeDropdownItem={
+              adminCustomizeContainer.switchPageListLimitationM
+            }
           />
           <PagingSizeUncontrolledDropdown
             label={t('admin:customize_settings.function_options.list_num_l')}
-            desc={t('admin:customize_settings.function_options.list_num_desc_l')}
+            desc={t(
+              'admin:customize_settings.function_options.list_num_desc_l',
+            )}
             toggleLabel={adminCustomizeContainer.state.pageLimitationL || 50}
             dropdownItemSize={[20, 50, 100, 200]}
-            onChangeDropdownItem={adminCustomizeContainer.switchPageListLimitationL}
+            onChangeDropdownItem={
+              adminCustomizeContainer.switchPageListLimitationL
+            }
           />
           <PagingSizeUncontrolledDropdown
             label={t('admin:customize_settings.function_options.list_num_xl')}
-            desc={t('admin:customize_settings.function_options.list_num_desc_xl')}
+            desc={t(
+              'admin:customize_settings.function_options.list_num_desc_xl',
+            )}
             toggleLabel={adminCustomizeContainer.state.pageLimitationXL || 20}
             dropdownItemSize={[5, 10, 20, 50, 100]}
-            onChangeDropdownItem={adminCustomizeContainer.switchPageListLimitationXL}
+            onChangeDropdownItem={
+              adminCustomizeContainer.switchPageListLimitationXL
+            }
           />
 
           <div className="row">
             <div className="offset-md-2 col-md-7 text-start">
               <CustomizeFunctionOption
                 optionId="isEnabledStaleNotification"
-                label={t('admin:customize_settings.function_options.stale_notification')}
-                isChecked={adminCustomizeContainer.state.isEnabledStaleNotification}
-                onChecked={() => { adminCustomizeContainer.switchEnableStaleNotification() }}
+                label={t(
+                  'admin:customize_settings.function_options.stale_notification',
+                )}
+                isChecked={
+                  adminCustomizeContainer.state.isEnabledStaleNotification
+                }
+                onChecked={() => {
+                  adminCustomizeContainer.switchEnableStaleNotification();
+                }}
               >
                 <p className="form-text text-muted">
-                  {t('admin:customize_settings.function_options.stale_notification_desc')}
+                  {t(
+                    'admin:customize_settings.function_options.stale_notification_desc',
+                  )}
                 </p>
               </CustomizeFunctionOption>
             </div>
@@ -107,12 +140,20 @@ const CustomizeFunctionSetting = (props: Props): JSX.Element => {
             <div className="offset-md-2 col-md-7 text-start">
               <CustomizeFunctionOption
                 optionId="isAllReplyShown"
-                label={t('admin:customize_settings.function_options.show_all_reply_comments')}
-                isChecked={adminCustomizeContainer.state.isAllReplyShown || false}
-                onChecked={() => { adminCustomizeContainer.switchIsAllReplyShown() }}
+                label={t(
+                  'admin:customize_settings.function_options.show_all_reply_comments',
+                )}
+                isChecked={
+                  adminCustomizeContainer.state.isAllReplyShown || false
+                }
+                onChecked={() => {
+                  adminCustomizeContainer.switchIsAllReplyShown();
+                }}
               >
                 <p className="form-text text-muted">
-                  {t('admin:customize_settings.function_options.show_all_reply_comments_desc')}
+                  {t(
+                    'admin:customize_settings.function_options.show_all_reply_comments_desc',
+                  )}
                 </p>
               </CustomizeFunctionOption>
             </div>
@@ -122,12 +163,21 @@ const CustomizeFunctionSetting = (props: Props): JSX.Element => {
             <div className="offset-md-2 col-md-7 text-start">
               <CustomizeFunctionOption
                 optionId="isSearchScopeChildrenAsDefault"
-                label={t('admin:customize_settings.function_options.select_search_scope_children_as_default')}
-                isChecked={adminCustomizeContainer.state.isSearchScopeChildrenAsDefault || false}
-                onChecked={() => { adminCustomizeContainer.switchIsSearchScopeChildrenAsDefault() }}
+                label={t(
+                  'admin:customize_settings.function_options.select_search_scope_children_as_default',
+                )}
+                isChecked={
+                  adminCustomizeContainer.state
+                    .isSearchScopeChildrenAsDefault || false
+                }
+                onChecked={() => {
+                  adminCustomizeContainer.switchIsSearchScopeChildrenAsDefault();
+                }}
               >
                 <p className="form-text text-muted">
-                  {t('admin:customize_settings.function_options.select_search_scope_children_as_default_desc')}
+                  {t(
+                    'admin:customize_settings.function_options.select_search_scope_children_as_default_desc',
+                  )}
                 </p>
               </CustomizeFunctionOption>
             </div>
@@ -137,25 +187,36 @@ const CustomizeFunctionSetting = (props: Props): JSX.Element => {
             <div className="offset-md-2 col-md-7 text-start">
               <CustomizeFunctionOption
                 optionId="showPageSideAuthors"
-                label={t('admin:customize_settings.function_options.show_page_side_authors')}
+                label={t(
+                  'admin:customize_settings.function_options.show_page_side_authors',
+                )}
                 isChecked={adminCustomizeContainer.state.showPageSideAuthors}
-                onChecked={() => { adminCustomizeContainer.switchShowPageSideAuthors() }}
+                onChecked={() => {
+                  adminCustomizeContainer.switchShowPageSideAuthors();
+                }}
               >
                 <p className="form-text text-muted">
-                  {t('admin:customize_settings.function_options.show_page_side_authors_desc')}
+                  {t(
+                    'admin:customize_settings.function_options.show_page_side_authors_desc',
+                  )}
                 </p>
               </CustomizeFunctionOption>
             </div>
           </div>
 
-          <AdminUpdateButtonRow onClick={onClickSubmit} disabled={adminCustomizeContainer.state.retrieveError != null} />
+          <AdminUpdateButtonRow
+            onClick={onClickSubmit}
+            disabled={adminCustomizeContainer.state.retrieveError != null}
+          />
         </div>
       </div>
     </React.Fragment>
   );
-
 };
 
-const CustomizeFunctionSettingWrapper = withUnstatedContainers(CustomizeFunctionSetting, [AdminCustomizeContainer]);
+const CustomizeFunctionSettingWrapper = withUnstatedContainers(
+  CustomizeFunctionSetting,
+  [AdminCustomizeContainer],
+);
 
 export default CustomizeFunctionSettingWrapper;

+ 36 - 21
apps/app/src/client/components/Admin/Customize/CustomizeLayoutSetting.tsx

@@ -1,16 +1,14 @@
-import React, {
-  useCallback, useEffect, useState, type JSX,
-} from 'react';
-
+import React, { type JSX, useCallback, useEffect, useState } from 'react';
 import { LoadingSpinner } from '@growi/ui/dist/components';
 import { useTranslation } from 'next-i18next';
 
-import { toastSuccess, toastError } from '~/client/util/toastr';
-import { useNextThemes } from '~/stores-universal/use-next-themes';
+import { toastError, toastSuccess } from '~/client/util/toastr';
 import { useSWRxLayoutSetting } from '~/stores/admin/customize';
+import { useNextThemes } from '~/stores-universal/use-next-themes';
 
 const useIsContainerFluid = () => {
-  const { data: layoutSetting, update: updateLayoutSetting } = useSWRxLayoutSetting();
+  const { data: layoutSetting, update: updateLayoutSetting } =
+    useSWRxLayoutSetting();
   const [isContainerFluid, setIsContainerFluid] = useState<boolean>();
 
   useEffect(() => {
@@ -29,15 +27,22 @@ const CustomizeLayoutSetting = (): JSX.Element => {
 
   const { resolvedTheme } = useNextThemes();
 
-  const { isContainerFluid, setIsContainerFluid, updateLayoutSetting } = useIsContainerFluid();
+  const { isContainerFluid, setIsContainerFluid, updateLayoutSetting } =
+    useIsContainerFluid();
 
-  const onClickSubmit = useCallback(async() => {
-    if (isContainerFluid == null) { return }
+  const onClickSubmit = useCallback(async () => {
+    if (isContainerFluid == null) {
+      return;
+    }
     try {
       await updateLayoutSetting({ isContainerFluid });
-      toastSuccess(t('toaster.update_successed', { target: t('customize_settings.layout'), ns: 'commons' }));
-    }
-    catch (err) {
+      toastSuccess(
+        t('toaster.update_successed', {
+          target: t('customize_settings.layout'),
+          ns: 'commons',
+        }),
+      );
+    } catch (err) {
       toastError(err);
     }
   }, [isContainerFluid, updateLayoutSetting, t]);
@@ -54,15 +59,18 @@ const CustomizeLayoutSetting = (): JSX.Element => {
     <React.Fragment>
       <div className="row">
         <div className="col-12">
-          <h2 className="admin-setting-header">{t('customize_settings.layout')}</h2>
+          <h2 className="admin-setting-header">
+            {t('customize_settings.layout')}
+          </h2>
 
           <div className="d-flex justify-content-around mt-5">
             <div className="row row-cols-2">
               <div className="col">
-                <div
+                <button
+                  type="button"
                   className={`card border border-4 ${!isContainerFluid ? 'border-primary' : ''}`}
                   onClick={() => setIsContainerFluid(false)}
-                  role="button"
+                  aria-pressed={!isContainerFluid}
                 >
                   {/* eslint-disable-next-line @next/next/no-img-element */}
                   <img
@@ -73,13 +81,14 @@ const CustomizeLayoutSetting = (): JSX.Element => {
                   <div className="card-body text-center">
                     {t('customize_settings.layout_options.default')}
                   </div>
-                </div>
+                </button>
               </div>
               <div className="col">
-                <div
+                <button
+                  type="button"
                   className={`card border border-4 ${isContainerFluid ? 'border-primary' : ''}`}
                   onClick={() => setIsContainerFluid(true)}
-                  role="button"
+                  aria-pressed={isContainerFluid}
                 >
                   {/* eslint-disable-next-line @next/next/no-img-element */}
                   <img
@@ -90,14 +99,20 @@ const CustomizeLayoutSetting = (): JSX.Element => {
                   <div className="card-body text-center">
                     {t('customize_settings.layout_options.expanded')}
                   </div>
-                </div>
+                </button>
               </div>
             </div>
           </div>
 
           <div className="row my-3">
             <div className="mx-auto">
-              <button type="button" className="btn btn-primary" onClick={onClickSubmit}>{ t('Update') }</button>
+              <button
+                type="button"
+                className="btn btn-primary"
+                onClick={onClickSubmit}
+              >
+                {t('Update')}
+              </button>
             </div>
           </div>
         </div>

+ 100 - 46
apps/app/src/client/components/Admin/Customize/CustomizeLogoSetting.tsx

@@ -1,11 +1,12 @@
-import React, { useCallback, useState, type JSX } from 'react';
-
+import React, { type JSX, useCallback, useState } from 'react';
 import { useAtomValue, useSetAtom } from 'jotai';
 import { useTranslation } from 'react-i18next';
 
 import ImageCropModal from '~/client/components/Common/ImageCropModal';
 import {
-  apiv3Delete, apiv3PostForm, apiv3Put,
+  apiv3Delete,
+  apiv3PostForm,
+  apiv3Put,
 } from '~/client/util/apiv3-client';
 import { toastError, toastSuccess } from '~/client/util/toastr';
 import { useIsDefaultLogo } from '~/states/global';
@@ -13,20 +14,23 @@ import { isCustomizedLogoUploadedAtom } from '~/states/server-configurations';
 
 import AdminUpdateButtonRow from '../Common/AdminUpdateButtonRow';
 
-
 const DEFAULT_LOGO = '/images/logo.svg';
 const CUSTOMIZED_LOGO = '/attachment/brand-logo';
 
 const CustomizeLogoSetting = (): JSX.Element => {
-
   const { t } = useTranslation();
   const isDefaultLogo = useIsDefaultLogo();
   const isCustomizedLogoUploaded = useAtomValue(isCustomizedLogoUploadedAtom);
   const setIsCustomizedLogoUploaded = useSetAtom(isCustomizedLogoUploadedAtom);
 
-  const [uploadLogoSrc, setUploadLogoSrc] = useState<ArrayBuffer | string | null>(null);
-  const [isImageCropModalShow, setIsImageCropModalShow] = useState<boolean>(false);
-  const [isDefaultLogoSelected, setIsDefaultLogoSelected] = useState<boolean>(isDefaultLogo ?? true);
+  const [uploadLogoSrc, setUploadLogoSrc] = useState<
+    ArrayBuffer | string | null
+  >(null);
+  const [isImageCropModalShow, setIsImageCropModalShow] =
+    useState<boolean>(false);
+  const [isDefaultLogoSelected, setIsDefaultLogoSelected] = useState<boolean>(
+    isDefaultLogo ?? true,
+  );
   const [retrieveError, setRetrieveError] = useState<any>();
 
   const onSelectFile = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
@@ -40,10 +44,16 @@ const CustomizeLogoSetting = (): JSX.Element => {
 
   const onClickSubmit = useCallback(async () => {
     try {
-      await apiv3Put('/customize-setting/customize-logo', { isDefaultLogo: isDefaultLogoSelected });
-      toastSuccess(t('toaster.update_successed', { target: t('admin:customize_settings.custom_logo'), ns: 'commons' }));
-    }
-    catch (err) {
+      await apiv3Put('/customize-setting/customize-logo', {
+        isDefaultLogo: isDefaultLogoSelected,
+      });
+      toastSuccess(
+        t('toaster.update_successed', {
+          target: t('admin:customize_settings.custom_logo'),
+          ns: 'commons',
+        }),
+      );
+    } catch (err) {
       toastError(err);
     }
   }, [t, isDefaultLogoSelected]);
@@ -52,37 +62,49 @@ const CustomizeLogoSetting = (): JSX.Element => {
     try {
       await apiv3Delete('/customize-setting/delete-brand-logo');
       setIsCustomizedLogoUploaded(false);
-      toastSuccess(t('toaster.update_successed', { target: t('admin:customize_settings.current_logo'), ns: 'commons' }));
-    }
-    catch (err) {
+      toastSuccess(
+        t('toaster.update_successed', {
+          target: t('admin:customize_settings.current_logo'),
+          ns: 'commons',
+        }),
+      );
+    } catch (err) {
       toastError(err);
       setRetrieveError(err);
       throw new Error('Failed to delete logo');
     }
   }, [setIsCustomizedLogoUploaded, t]);
 
-
-  const processImageCompletedHandler = useCallback(async (croppedImage) => {
-    try {
-      const formData = new FormData();
-      formData.append('file', croppedImage);
-      await apiv3PostForm('/customize-setting/upload-brand-logo', formData);
-      setIsCustomizedLogoUploaded(true);
-      toastSuccess(t('toaster.update_successed', { target: t('admin:customize_settings.current_logo'), ns: 'commons' }));
-    }
-    catch (err) {
-      toastError(err);
-      setRetrieveError(err);
-      throw new Error('Failed to upload brand logo');
-    }
-  }, [setIsCustomizedLogoUploaded, t]);
+  const processImageCompletedHandler = useCallback(
+    async (croppedImage) => {
+      try {
+        const formData = new FormData();
+        formData.append('file', croppedImage);
+        await apiv3PostForm('/customize-setting/upload-brand-logo', formData);
+        setIsCustomizedLogoUploaded(true);
+        toastSuccess(
+          t('toaster.update_successed', {
+            target: t('admin:customize_settings.current_logo'),
+            ns: 'commons',
+          }),
+        );
+      } catch (err) {
+        toastError(err);
+        setRetrieveError(err);
+        throw new Error('Failed to upload brand logo');
+      }
+    },
+    [setIsCustomizedLogoUploaded, t],
+  );
 
   return (
     <React.Fragment>
       <div className="row">
         <div className="col-12">
           <div className="mb-5">
-            <h2 className="border-bottom my-4 admin-setting-header">{t('admin:customize_settings.custom_logo')}</h2>
+            <h2 className="border-bottom my-4 admin-setting-header">
+              {t('admin:customize_settings.custom_logo')}
+            </h2>
             <div className="row">
               <div className="col-md-6 col-12 mb-3 mb-md-0">
                 <h4>
@@ -94,14 +116,23 @@ const CustomizeLogoSetting = (): JSX.Element => {
                       form="formImageType"
                       name="imagetypeForm[isDefaultLogo]"
                       checked={isDefaultLogoSelected}
-                      onChange={() => { setIsDefaultLogoSelected(true) }}
+                      onChange={() => {
+                        setIsDefaultLogoSelected(true);
+                      }}
                     />
-                    <label className="form-check-label" htmlFor="radioDefaultLogo">
+                    <label
+                      className="form-check-label"
+                      htmlFor="radioDefaultLogo"
+                    >
                       {t('admin:customize_settings.default_logo')}
                     </label>
                   </div>
                 </h4>
-                <img src={DEFAULT_LOGO} width="64" />
+                <img
+                  src={DEFAULT_LOGO}
+                  width="64"
+                  alt={t('admin:customize_settings.default_logo')}
+                />
               </div>
               <div className="col-md-6 col-12">
                 <h4>
@@ -113,24 +144,38 @@ const CustomizeLogoSetting = (): JSX.Element => {
                       form="formImageType"
                       name="imagetypeForm[isDefaultLogo]"
                       checked={!isDefaultLogoSelected}
-                      onChange={() => { setIsDefaultLogoSelected(false) }}
+                      onChange={() => {
+                        setIsDefaultLogoSelected(false);
+                      }}
                     />
-                    <label className="form-check-label" htmlFor="radioUploadLogo">
+                    <label
+                      className="form-check-label"
+                      htmlFor="radioUploadLogo"
+                    >
                       {t('admin:customize_settings.upload_logo')}
                     </label>
                   </div>
                 </h4>
                 <div className="row mb-3">
-                  <label className="col-sm-4 col-12 col-form-label text-start">
+                  <span className="col-sm-4 col-12 col-form-label text-start">
                     {t('admin:customize_settings.current_logo')}
-                  </label>
+                  </span>
                   <div className="col-sm-8 col-12">
                     {isCustomizedLogoUploaded && (
                       <>
                         <p>
-                          <img src={CUSTOMIZED_LOGO} id="settingBrandLogo" width="64" />
+                          <img
+                            src={CUSTOMIZED_LOGO}
+                            id="settingBrandLogo"
+                            width="64"
+                            alt={t('admin:customize_settings.current_logo')}
+                          />
                         </p>
-                        <button type="button" className="btn btn-danger" onClick={onClickDeleteBtn}>
+                        <button
+                          type="button"
+                          className="btn btn-danger"
+                          onClick={onClickDeleteBtn}
+                        >
                           {t('admin:customize_settings.delete_logo')}
                         </button>
                       </>
@@ -138,16 +183,28 @@ const CustomizeLogoSetting = (): JSX.Element => {
                   </div>
                 </div>
                 <div className="row">
-                  <label className="col-sm-4 col-12 col-form-label text-start">
+                  <label
+                    className="col-sm-4 col-12 col-form-label text-start"
+                    htmlFor="uploadLogoInput"
+                  >
                     {t('admin:customize_settings.upload_new_logo')}
                   </label>
                   <div className="col-sm-8 col-12">
-                    <input type="file" onChange={onSelectFile} name="brandLogo" accept="image/*" />
+                    <input
+                      id="uploadLogoInput"
+                      type="file"
+                      onChange={onSelectFile}
+                      name="brandLogo"
+                      accept="image/*"
+                    />
                   </div>
                 </div>
               </div>
             </div>
-            <AdminUpdateButtonRow onClick={onClickSubmit} disabled={retrieveError != null} />
+            <AdminUpdateButtonRow
+              onClick={onClickSubmit}
+              disabled={retrieveError != null}
+            />
           </div>
         </div>
       </div>
@@ -162,9 +219,6 @@ const CustomizeLogoSetting = (): JSX.Element => {
       />
     </React.Fragment>
   );
-
-
 };
 
-
 export default CustomizeLogoSetting;

+ 54 - 38
apps/app/src/client/components/Admin/Customize/CustomizeNoscriptSetting.tsx

@@ -1,5 +1,4 @@
-import React, { useCallback, useEffect, type JSX } from 'react';
-
+import React, { type JSX, useCallback, useEffect } from 'react';
 import { useTranslation } from 'next-i18next';
 import { useForm } from 'react-hook-form';
 import { PrismAsyncLight } from 'react-syntax-highlighter';
@@ -7,56 +6,66 @@ import { oneDark } from 'react-syntax-highlighter/dist/cjs/styles/prism';
 import { Card, CardBody } from 'reactstrap';
 
 import AdminCustomizeContainer from '~/client/services/AdminCustomizeContainer';
-import { toastSuccess, toastError } from '~/client/util/toastr';
+import { toastError, toastSuccess } from '~/client/util/toastr';
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
 import AdminUpdateButtonRow from '../Common/AdminUpdateButtonRow';
 
 type Props = {
-  adminCustomizeContainer: AdminCustomizeContainer
-}
+  adminCustomizeContainer: AdminCustomizeContainer;
+};
 
 const CustomizeNoscriptSetting = (props: Props): JSX.Element => {
-
   const { adminCustomizeContainer } = props;
   const { t } = useTranslation();
 
-  const {
-    register,
-    handleSubmit,
-    reset,
-  } = useForm();
+  const { register, handleSubmit, reset } = useForm();
 
   // Sync form with container state
   useEffect(() => {
     reset({
-      customizeNoscript: adminCustomizeContainer.state.currentCustomizeNoscript || '',
+      customizeNoscript:
+        adminCustomizeContainer.state.currentCustomizeNoscript || '',
     });
   }, [adminCustomizeContainer.state.currentCustomizeNoscript, reset]);
 
-  const onSubmit = useCallback(async(data) => {
-    try {
-      // Update container state before API call
-      await adminCustomizeContainer.changeCustomizeNoscript(data.customizeNoscript);
-      await adminCustomizeContainer.updateCustomizeNoscript();
-      toastSuccess(t('toaster.update_successed', { target: t('admin:customize_settings.custom_noscript'), ns: 'commons' }));
-    }
-    catch (err) {
-      toastError(err);
-    }
-  }, [t, adminCustomizeContainer]);
+  const onSubmit = useCallback(
+    async (data) => {
+      try {
+        // Update container state before API call
+        await adminCustomizeContainer.changeCustomizeNoscript(
+          data.customizeNoscript,
+        );
+        await adminCustomizeContainer.updateCustomizeNoscript();
+        toastSuccess(
+          t('toaster.update_successed', {
+            target: t('admin:customize_settings.custom_noscript'),
+            ns: 'commons',
+          }),
+        );
+      } catch (err) {
+        toastError(err);
+      }
+    },
+    [t, adminCustomizeContainer],
+  );
 
   return (
     <React.Fragment>
       <div className="row">
         <div className="col-12">
-          <h2 className="admin-setting-header">{t('admin:customize_settings.custom_noscript')}</h2>
+          <h2 className="admin-setting-header">
+            {t('admin:customize_settings.custom_noscript')}
+          </h2>
 
           <Card className="card custom-card bg-body-tertiary my-3">
             <CardBody className="px-0 py-2">
               <span
                 // eslint-disable-next-line react/no-danger
-                dangerouslySetInnerHTML={{ __html: t('admin:customize_settings.custom_noscript_detail') }}
+                // biome-ignore lint/security/noDangerouslySetInnerHtml: trusted translation markup
+                dangerouslySetInnerHTML={{
+                  __html: t('admin:customize_settings.custom_noscript_detail'),
+                }}
               />
             </CardBody>
           </Card>
@@ -70,22 +79,24 @@ const CustomizeNoscriptSetting = (props: Props): JSX.Element => {
               />
             </div>
 
-            <a
-              className="text-muted"
+            <button
+              type="button"
+              className="btn btn-link text-muted p-0"
               data-bs-toggle="collapse"
-              href="#collapseExampleHtml"
-              role="button"
+              data-bs-target="#collapseExampleHtml"
               aria-expanded="false"
               aria-controls="collapseExampleHtml"
             >
-              <span className="material-symbols-outlined me-1" aria-hidden="true">navigate_next</span>
+              <span
+                className="material-symbols-outlined me-1"
+                aria-hidden="true"
+              >
+                navigate_next
+              </span>
               Example for Google Tag Manager
-            </a>
+            </button>
             <div className="collapse" id="collapseExampleHtml">
-              <PrismAsyncLight
-                style={oneDark}
-                language="javascript"
-              >
+              <PrismAsyncLight style={oneDark} language="javascript">
                 {`<iframe src="https://www.googletagmanager.com/ns.html?id=GTM-XXXXXXX"
   height="0"
   width="0"
@@ -93,15 +104,20 @@ const CustomizeNoscriptSetting = (props: Props): JSX.Element => {
               </PrismAsyncLight>
             </div>
 
-            <AdminUpdateButtonRow type="submit" disabled={adminCustomizeContainer.state.retrieveError != null} />
+            <AdminUpdateButtonRow
+              type="submit"
+              disabled={adminCustomizeContainer.state.retrieveError != null}
+            />
           </form>
         </div>
       </div>
     </React.Fragment>
   );
-
 };
 
-const CustomizeNoscriptSettingWrapper = withUnstatedContainers(CustomizeNoscriptSetting, [AdminCustomizeContainer]);
+const CustomizeNoscriptSettingWrapper = withUnstatedContainers(
+  CustomizeNoscriptSetting,
+  [AdminCustomizeContainer],
+);
 
 export default CustomizeNoscriptSettingWrapper;

+ 36 - 18
apps/app/src/client/components/Admin/Customize/CustomizePresentationSetting.tsx

@@ -1,69 +1,87 @@
-import React, { useCallback, type JSX } from 'react';
-
+import React, { type JSX, useCallback } from 'react';
 import { useTranslation } from 'next-i18next';
 
 import AdminCustomizeContainer from '~/client/services/AdminCustomizeContainer';
-import { toastSuccess, toastError } from '~/client/util/toastr';
+import { toastError, toastSuccess } from '~/client/util/toastr';
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
 import AdminUpdateButtonRow from '../Common/AdminUpdateButtonRow';
-
 import CustomizePresentationOption from './CustomizeFunctionOption';
 
 type Props = {
-  adminCustomizeContainer: AdminCustomizeContainer
-}
+  adminCustomizeContainer: AdminCustomizeContainer;
+};
 
 const CustomizePresentationSetting = (props: Props): JSX.Element => {
   const { adminCustomizeContainer } = props;
   const { t } = useTranslation();
 
-  const onClickSubmit = useCallback(async() => {
+  const onClickSubmit = useCallback(async () => {
     try {
       await adminCustomizeContainer.updateCustomizePresentation();
-      toastSuccess(t('toaster.update_successed', { target: t('admin:customize_settings.presentation'), ns: 'commons' }));
-    }
-    catch (err) {
+      toastSuccess(
+        t('toaster.update_successed', {
+          target: t('admin:customize_settings.presentation'),
+          ns: 'commons',
+        }),
+      );
+    } catch (err) {
       toastError(err);
     }
   }, [adminCustomizeContainer, t]);
 
   return (
     <React.Fragment>
-      <h2 className="admin-setting-header">{t('admin:customize_settings.custom_presentation')}</h2>
+      <h2 className="admin-setting-header">
+        {t('admin:customize_settings.custom_presentation')}
+      </h2>
       <div className="form-group row">
         <div className="offset-md-3 col-md-6 text-left">
           <CustomizePresentationOption
             optionId="isEnabledMarp"
-            label={t('admin:customize_settings.presentation_options.enable_marp')}
+            label={t(
+              'admin:customize_settings.presentation_options.enable_marp',
+            )}
             isChecked={adminCustomizeContainer?.state.isEnabledMarp || false}
-            onChecked={() => { adminCustomizeContainer.switchIsEnabledMarp() }}
+            onChecked={() => {
+              adminCustomizeContainer.switchIsEnabledMarp();
+            }}
           >
             <p className="form-text text-muted">
-              {t('admin:customize_settings.presentation_options.enable_marp_desc')}
+              {t(
+                'admin:customize_settings.presentation_options.enable_marp_desc',
+              )}
               <br></br>
               <a
                 href={`${t('admin:customize_settings.presentation_options.marp_official_site_link')}`}
                 target="_blank"
                 rel="noopener noreferrer"
-              >{`${t('admin:customize_settings.presentation_options.marp_official_site')}`}
+              >
+                {`${t('admin:customize_settings.presentation_options.marp_official_site')}`}
               </a>
               <br></br>
               <a
                 href={`${t('admin:customize_settings.presentation_options.marp_in_gorwi_link')}`}
                 target="_blank"
                 rel="noopener noreferrer"
-              >{`${t('admin:customize_settings.presentation_options.marp_in_growi')}`}
+              >
+                {`${t('admin:customize_settings.presentation_options.marp_in_growi')}`}
               </a>
             </p>
           </CustomizePresentationOption>
         </div>
       </div>
 
-      <AdminUpdateButtonRow onClick={onClickSubmit} disabled={adminCustomizeContainer.state.retrieveError != null} />
+      <AdminUpdateButtonRow
+        onClick={onClickSubmit}
+        disabled={adminCustomizeContainer.state.retrieveError != null}
+      />
     </React.Fragment>
   );
 };
-const CustomizePresentationSettingWrapper = withUnstatedContainers(CustomizePresentationSetting, [AdminCustomizeContainer]);
+const CustomizePresentationSettingWrapper = withUnstatedContainers(
+  CustomizePresentationSetting,
+  [AdminCustomizeContainer],
+);
 
 export default CustomizePresentationSettingWrapper;

+ 52 - 38
apps/app/src/client/components/Admin/Customize/CustomizeScriptSetting.tsx

@@ -1,5 +1,4 @@
-import React, { useCallback, useEffect, type JSX } from 'react';
-
+import React, { type JSX, useCallback, useEffect } from 'react';
 import { useTranslation } from 'next-i18next';
 import { useForm } from 'react-hook-form';
 import { PrismAsyncLight } from 'react-syntax-highlighter';
@@ -7,53 +6,61 @@ import { oneDark } from 'react-syntax-highlighter/dist/cjs/styles/prism';
 import { Card, CardBody } from 'reactstrap';
 
 import AdminCustomizeContainer from '~/client/services/AdminCustomizeContainer';
-import { toastSuccess, toastError } from '~/client/util/toastr';
+import { toastError, toastSuccess } from '~/client/util/toastr';
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
 import AdminUpdateButtonRow from '../Common/AdminUpdateButtonRow';
 
 type Props = {
-  adminCustomizeContainer: AdminCustomizeContainer
-}
+  adminCustomizeContainer: AdminCustomizeContainer;
+};
 
 const CustomizeScriptSetting = (props: Props): JSX.Element => {
-
   const { adminCustomizeContainer } = props;
   const { t } = useTranslation();
 
-  const {
-    register,
-    handleSubmit,
-    reset,
-  } = useForm();
+  const { register, handleSubmit, reset } = useForm();
 
   // Sync form with container state
   useEffect(() => {
     reset({
-      customizeScript: adminCustomizeContainer.state.currentCustomizeScript || '',
+      customizeScript:
+        adminCustomizeContainer.state.currentCustomizeScript || '',
     });
   }, [adminCustomizeContainer.state.currentCustomizeScript, reset]);
 
-  const onSubmit = useCallback(async(data) => {
-    try {
-      // Update container state before API call
-      await adminCustomizeContainer.changeCustomizeScript(data.customizeScript);
-      await adminCustomizeContainer.updateCustomizeScript();
-      toastSuccess(t('toaster.update_successed', { target: t('admin:customize_settings.custom_script'), ns: 'commons' }));
-    }
-    catch (err) {
-      toastError(err);
-    }
-  }, [t, adminCustomizeContainer]);
+  const onSubmit = useCallback(
+    async (data) => {
+      try {
+        // Update container state before API call
+        await adminCustomizeContainer.changeCustomizeScript(
+          data.customizeScript,
+        );
+        await adminCustomizeContainer.updateCustomizeScript();
+        toastSuccess(
+          t('toaster.update_successed', {
+            target: t('admin:customize_settings.custom_script'),
+            ns: 'commons',
+          }),
+        );
+      } catch (err) {
+        toastError(err);
+      }
+    },
+    [t, adminCustomizeContainer],
+  );
 
   return (
     <React.Fragment>
       <div className="row">
         <div className="col-12">
-          <h2 className="admin-setting-header">{t('admin:customize_settings.custom_script')}</h2>
+          <h2 className="admin-setting-header">
+            {t('admin:customize_settings.custom_script')}
+          </h2>
           <Card className="card custom-card bg-body-tertiary mb-3">
             <CardBody className="px-0 py-2">
-              {t('admin:customize_settings.write_java')}<br />
+              {t('admin:customize_settings.write_java')}
+              <br />
               {t('admin:customize_settings.reflect_change')}
             </CardBody>
           </Card>
@@ -67,22 +74,24 @@ const CustomizeScriptSetting = (props: Props): JSX.Element => {
               />
             </div>
 
-            <a
-              className="text-muted"
+            <button
+              type="button"
+              className="btn btn-link text-muted p-0"
               data-bs-toggle="collapse"
-              href="#collapseExampleScript"
-              role="button"
+              data-bs-target="#collapseExampleScript"
               aria-expanded="false"
               aria-controls="collapseExampleScript"
             >
-              <span className="material-symbols-outlined me-1" aria-hidden="true">navigate_next</span>
+              <span
+                className="material-symbols-outlined me-1"
+                aria-hidden="true"
+              >
+                navigate_next
+              </span>
               Example for Google Tag Manager
-            </a>
+            </button>
             <div className="collapse" id="collapseExampleScript">
-              <PrismAsyncLight
-                style={oneDark}
-                language="javascript"
-              >
+              <PrismAsyncLight style={oneDark} language="javascript">
                 {`(function(w,d,s,l,i){
 w[l]=w[l]||[];
 w[l].push({'gtm.start':new Date().getTime(),event:'gtm.js'});
@@ -95,15 +104,20 @@ j.src='https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefo
               </PrismAsyncLight>
             </div>
 
-            <AdminUpdateButtonRow type="submit" disabled={adminCustomizeContainer.state.retrieveError != null} />
+            <AdminUpdateButtonRow
+              type="submit"
+              disabled={adminCustomizeContainer.state.retrieveError != null}
+            />
           </form>
         </div>
       </div>
     </React.Fragment>
   );
-
 };
 
-const CustomizeScriptSettingWrapper = withUnstatedContainers(CustomizeScriptSetting, [AdminCustomizeContainer]);
+const CustomizeScriptSettingWrapper = withUnstatedContainers(
+  CustomizeScriptSetting,
+  [AdminCustomizeContainer],
+);
 
 export default CustomizeScriptSettingWrapper;

+ 32 - 27
apps/app/src/client/components/Admin/Customize/CustomizeSidebarSetting.tsx

@@ -1,30 +1,31 @@
-import React, { useCallback, type JSX } from 'react';
-
+import React, { type JSX, useCallback } from 'react';
 import { LoadingSpinner } from '@growi/ui/dist/components';
 import { useTranslation } from 'next-i18next';
 import { Card, CardBody } from 'reactstrap';
 
-import { toastSuccess, toastError } from '~/client/util/toastr';
-import { useNextThemes } from '~/stores-universal/use-next-themes';
+import { toastError, toastSuccess } from '~/client/util/toastr';
 import { useSWRxSidebarConfig } from '~/stores/admin/sidebar-config';
+import { useNextThemes } from '~/stores-universal/use-next-themes';
 
 const CustomizeSidebarsetting = (): JSX.Element => {
   const { t } = useTranslation(['admin', 'commons']);
 
-  const {
-    data, update, setIsSidebarCollapsedMode,
-  } = useSWRxSidebarConfig();
+  const { data, update, setIsSidebarCollapsedMode } = useSWRxSidebarConfig();
 
   const { resolvedTheme } = useNextThemes();
   const collapsedIconFileName = `/images/customize-settings/collapsed-${resolvedTheme}.svg`;
   const dockIconFileName = `/images/customize-settings/dock-${resolvedTheme}.svg`;
 
-  const onClickSubmit = useCallback(async() => {
+  const onClickSubmit = useCallback(async () => {
     try {
       await update();
-      toastSuccess(t('toaster.update_successed', { target: t('customize_settings.default_sidebar_mode.title'), ns: 'commons' }));
-    }
-    catch (err) {
+      toastSuccess(
+        t('toaster.update_successed', {
+          target: t('customize_settings.default_sidebar_mode.title'),
+          ns: 'commons',
+        }),
+      );
+    } catch (err) {
       toastError(err);
     }
   }, [t, update]);
@@ -39,8 +40,9 @@ const CustomizeSidebarsetting = (): JSX.Element => {
     <React.Fragment>
       <div className="row">
         <div className="col-12">
-
-          <h2 className="admin-setting-header">{t('customize_settings.default_sidebar_mode.title')}</h2>
+          <h2 className="admin-setting-header">
+            {t('customize_settings.default_sidebar_mode.title')}
+          </h2>
 
           <Card className="card custom-card bg-body-tertiary my-3">
             <CardBody className="px-0 py-2">
@@ -51,40 +53,43 @@ const CustomizeSidebarsetting = (): JSX.Element => {
           <div className="d-flex justify-content-around mt-5">
             <div className="row row-cols-2">
               <div className="col">
-                <div
+                <button
+                  type="button"
                   className={`card border border-4 ${isSidebarCollapsedMode ? 'border-primary' : ''}`}
                   onClick={() => setIsSidebarCollapsedMode(true)}
-                  role="button"
+                  aria-pressed={isSidebarCollapsedMode}
                 >
                   {/* eslint-disable-next-line @next/next/no-img-element */}
                   <img src={collapsedIconFileName} alt="Collapsed Mode" />
-                  <div className="card-body text-center">
-                    Collapsed Mode
-                  </div>
-                </div>
+                  <div className="card-body text-center">Collapsed Mode</div>
+                </button>
               </div>
               <div className="col">
-                <div
+                <button
+                  type="button"
                   className={`card border border-4 ${!isSidebarCollapsedMode ? 'border-primary' : ''}`}
                   onClick={() => setIsSidebarCollapsedMode(false)}
-                  role="button"
+                  aria-pressed={!isSidebarCollapsedMode}
                 >
                   {/* eslint-disable-next-line @next/next/no-img-element */}
                   <img src={dockIconFileName} alt="Dock Mode" />
-                  <div className="card-body  text-center">
-                    Dock Mode
-                  </div>
-                </div>
+                  <div className="card-body  text-center">Dock Mode</div>
+                </button>
               </div>
             </div>
           </div>
 
           <div className="row my-3">
             <div className="mx-auto">
-              <button type="button" onClick={onClickSubmit} className="btn btn-primary">{ t('Update') }</button>
+              <button
+                type="button"
+                onClick={onClickSubmit}
+                className="btn btn-primary"
+              >
+                {t('Update')}
+              </button>
             </div>
           </div>
-
         </div>
       </div>
     </React.Fragment>

+ 19 - 15
apps/app/src/client/components/Admin/Customize/CustomizeThemeOptions.tsx

@@ -1,15 +1,13 @@
-import React, { useMemo, type JSX } from 'react';
-
+import React, { type JSX, useMemo } from 'react';
 import { type GrowiThemeMetadata, GrowiThemeSchemeType } from '@growi/core';
 import { useTranslation } from 'next-i18next';
 
 import { ThemeColorBox } from './ThemeColorBox';
 
-
 type Props = {
-  availableThemes: GrowiThemeMetadata[],
-  selectedTheme?: string,
-  onSelected?: (themeName: string) => void,
+  availableThemes: GrowiThemeMetadata[];
+  selectedTheme?: string;
+  onSelected?: (themeName: string) => void;
 };
 
 const CustomizeThemeOptions = (props: Props): JSX.Element => {
@@ -18,24 +16,31 @@ const CustomizeThemeOptions = (props: Props): JSX.Element => {
   const { availableThemes, selectedTheme, onSelected } = props;
 
   const lightNDarkThemes = useMemo(() => {
-    return availableThemes.filter(s => s.schemeType === GrowiThemeSchemeType.BOTH);
+    return availableThemes.filter(
+      (s) => s.schemeType === GrowiThemeSchemeType.BOTH,
+    );
   }, [availableThemes]);
   const oneModeThemes = useMemo(() => {
-    return availableThemes.filter(s => s.schemeType !== GrowiThemeSchemeType.BOTH);
+    return availableThemes.filter(
+      (s) => s.schemeType !== GrowiThemeSchemeType.BOTH,
+    );
   }, [availableThemes]);
 
   return (
     <>
-
       {/* Light and Dark Themes */}
       <div>
-        <h3 className="mb-3">{t('customize_settings.theme_desc.light_and_dark')}</h3>
+        <h3 className="mb-3">
+          {t('customize_settings.theme_desc.light_and_dark')}
+        </h3>
         <div className="hstack gap-3 flex-wrap">
           {lightNDarkThemes.map((theme) => {
             return (
               <ThemeColorBox
                 key={theme.name}
-                isSelected={selectedTheme != null && selectedTheme === theme.name}
+                isSelected={
+                  selectedTheme != null && selectedTheme === theme.name
+                }
                 metadata={theme}
                 onSelected={() => onSelected?.(theme.name)}
               />
@@ -52,7 +57,9 @@ const CustomizeThemeOptions = (props: Props): JSX.Element => {
             return (
               <ThemeColorBox
                 key={theme.name}
-                isSelected={selectedTheme != null && selectedTheme === theme.name}
+                isSelected={
+                  selectedTheme != null && selectedTheme === theme.name
+                }
                 metadata={theme}
                 onSelected={() => onSelected?.(theme.name)}
               />
@@ -60,11 +67,8 @@ const CustomizeThemeOptions = (props: Props): JSX.Element => {
           })}
         </div>
       </div>
-
     </>
   );
-
 };
 
-
 export default CustomizeThemeOptions;

+ 25 - 19
apps/app/src/client/components/Admin/Customize/CustomizeThemeSetting.tsx

@@ -1,21 +1,15 @@
-import React, {
-  useCallback, useEffect, useState, type JSX,
-} from 'react';
-
+import React, { type JSX, useCallback, useEffect, useState } from 'react';
 import { PresetThemes, PresetThemesMetadatas } from '@growi/preset-themes';
 import { useTranslation } from 'next-i18next';
 
-import { toastSuccess, toastError, toastWarning } from '~/client/util/toastr';
+import { toastError, toastSuccess, toastWarning } from '~/client/util/toastr';
 import { useSWRxGrowiThemeSetting } from '~/stores/admin/customize';
 
 import AdminUpdateButtonRow from '../Common/AdminUpdateButtonRow';
-
 import CustomizeThemeOptions from './CustomizeThemeOptions';
 
-
 // eslint-disable-next-line @typescript-eslint/ban-types
-type Props = {
-}
+type Props = {};
 
 // eslint-disable-next-line @typescript-eslint/no-unused-vars
 const CustomizeThemeSetting = (props: Props): JSX.Element => {
@@ -32,7 +26,7 @@ const CustomizeThemeSetting = (props: Props): JSX.Element => {
     setCurrentTheme(themeName);
   }, []);
 
-  const submitHandler = useCallback(async() => {
+  const submitHandler = useCallback(async () => {
     if (currentTheme == null) {
       toastWarning('The selected theme is undefined');
       return;
@@ -43,29 +37,41 @@ const CustomizeThemeSetting = (props: Props): JSX.Element => {
         theme: currentTheme,
       });
 
-      toastSuccess(t('toaster.update_successed', { target: t('admin:customize_settings.theme'), ns: 'commons' }));
-    }
-    catch (err) {
+      toastSuccess(
+        t('toaster.update_successed', {
+          target: t('admin:customize_settings.theme'),
+          ns: 'commons',
+        }),
+      );
+    } catch (err) {
       toastError(err);
     }
   }, [currentTheme, t, update]);
 
-  const availableThemes = data?.pluginThemesMetadatas == null
-    ? PresetThemesMetadatas
-    : PresetThemesMetadatas.concat(data.pluginThemesMetadatas);
+  const availableThemes =
+    data?.pluginThemesMetadatas == null
+      ? PresetThemesMetadatas
+      : PresetThemesMetadatas.concat(data.pluginThemesMetadatas);
 
-  const selectedTheme = availableThemes.find(t => t.name === currentTheme)?.name ?? PresetThemes.DEFAULT;
+  const selectedTheme =
+    availableThemes.find((t) => t.name === currentTheme)?.name ??
+    PresetThemes.DEFAULT;
 
   return (
     <div className="row">
       <div className="col-12">
-        <h2 className="admin-setting-header">{t('admin:customize_settings.theme')}</h2>
+        <h2 className="admin-setting-header">
+          {t('admin:customize_settings.theme')}
+        </h2>
         <CustomizeThemeOptions
           onSelected={selectedHandler}
           availableThemes={availableThemes}
           selectedTheme={selectedTheme}
         />
-        <AdminUpdateButtonRow onClick={submitHandler} disabled={error != null} />
+        <AdminUpdateButtonRow
+          onClick={submitHandler}
+          disabled={error != null}
+        />
       </div>
     </div>
   );

+ 62 - 30
apps/app/src/client/components/Admin/Customize/CustomizeTitle.tsx

@@ -1,27 +1,21 @@
 import type { FC } from 'react';
 import React, { useCallback, useEffect } from 'react';
-
 import { useTranslation } from 'next-i18next';
 import { useForm } from 'react-hook-form';
 import { Card, CardBody } from 'reactstrap';
 
 import { apiv3Put } from '~/client/util/apiv3-client';
-import { toastSuccess, toastError } from '~/client/util/toastr';
+import { toastError, toastSuccess } from '~/client/util/toastr';
 import { useCustomTitleTemplate } from '~/states/global';
 
 import AdminUpdateButtonRow from '../Common/AdminUpdateButtonRow';
 
 export const CustomizeTitle: FC = () => {
-
   const { t } = useTranslation('admin');
 
   const customTitleTemplate = useCustomTitleTemplate();
 
-  const {
-    register,
-    handleSubmit,
-    reset,
-  } = useForm();
+  const { register, handleSubmit, reset } = useForm();
 
   // Sync form with store data
   useEffect(() => {
@@ -30,39 +24,74 @@ export const CustomizeTitle: FC = () => {
     });
   }, [customTitleTemplate, reset]);
 
-  const onSubmit = useCallback(async(data) => {
-    try {
-      await apiv3Put('/customize-setting/customize-title', {
-        customizeTitle: data.customizeTitle,
-      });
-      toastSuccess(t('toaster.update_successed', { target: t('admin:customize_settings.custom_title'), ns: 'commons' }));
-    }
-    catch (err) {
-      toastError(err);
-    }
-  }, [t]);
+  const onSubmit = useCallback(
+    async (data) => {
+      try {
+        await apiv3Put('/customize-setting/customize-title', {
+          customizeTitle: data.customizeTitle,
+        });
+        toastSuccess(
+          t('toaster.update_successed', {
+            target: t('admin:customize_settings.custom_title'),
+            ns: 'commons',
+          }),
+        );
+      } catch (err) {
+        toastError(err);
+      }
+    },
+    [t],
+  );
 
   return (
     <React.Fragment>
       <div className="row">
         <div className="col-12">
-          <h2 className="admin-setting-header">{t('admin:customize_settings.custom_title')}</h2>
+          <h2 className="admin-setting-header">
+            {t('admin:customize_settings.custom_title')}
+          </h2>
         </div>
 
         <div className="col-12">
           <Card className="card custom-card bg-body-tertiary mb-3">
             <CardBody className="px-0 py-2">
               {/* eslint-disable react/no-danger */}
-              <p dangerouslySetInnerHTML={{ __html: t('admin:customize_settings.custom_title_detail') }} />
+              <p
+                // biome-ignore lint/security/noDangerouslySetInnerHtml: trusted translation markup
+                dangerouslySetInnerHTML={{
+                  __html: t('admin:customize_settings.custom_title_detail'),
+                }}
+              />
               <ul>
                 <li>
-                  <span dangerouslySetInnerHTML={{ __html: t('admin:customize_settings.custom_title_detail_placeholder1') }} />
+                  <span
+                    // biome-ignore lint/security/noDangerouslySetInnerHtml: trusted translation markup
+                    dangerouslySetInnerHTML={{
+                      __html: t(
+                        'admin:customize_settings.custom_title_detail_placeholder1',
+                      ),
+                    }}
+                  />
                 </li>
                 <li>
-                  <span dangerouslySetInnerHTML={{ __html: t('admin:customize_settings.custom_title_detail_placeholder2') }} />
+                  <span
+                    // biome-ignore lint/security/noDangerouslySetInnerHtml: trusted translation markup
+                    dangerouslySetInnerHTML={{
+                      __html: t(
+                        'admin:customize_settings.custom_title_detail_placeholder2',
+                      ),
+                    }}
+                  />
                 </li>
                 <li>
-                  <span dangerouslySetInnerHTML={{ __html: t('admin:customize_settings.custom_title_detail_placeholder3') }} />
+                  <span
+                    // biome-ignore lint/security/noDangerouslySetInnerHtml: trusted translation markup
+                    dangerouslySetInnerHTML={{
+                      __html: t(
+                        'admin:customize_settings.custom_title_detail_placeholder3',
+                      ),
+                    }}
+                  />
                 </li>
               </ul>
               {/* eslint-enable react/no-danger */}
@@ -72,16 +101,19 @@ export const CustomizeTitle: FC = () => {
 
         {/* TODO i18n */}
         <div className="form-text text-muted col-12 mb-3">
-          Default Value: <code>&#123;&#123;pagename&#125;&#125; - &#123;&#123;sitename&#125;&#125;</code>
+          Default Value:{' '}
+          <code>
+            &#123;&#123;pagename&#125;&#125; - &#123;&#123;sitename&#125;&#125;
+          </code>
           <br />
-          Default Output Example: <code className="xml">&lt;title&gt;Page name - My GROWI&lt;&#047;title&gt;</code>
+          Default Output Example:{' '}
+          <code className="xml">
+            &lt;title&gt;Page name - My GROWI&lt;&#047;title&gt;
+          </code>
         </div>
         <form onSubmit={handleSubmit(onSubmit)}>
           <div className="col-12">
-            <input
-              className="form-control"
-              {...register('customizeTitle')}
-            />
+            <input className="form-control" {...register('customizeTitle')} />
           </div>
           <div className="col-12">
             <AdminUpdateButtonRow type="submit" disabled={false} />

+ 12 - 11
apps/app/src/client/components/Admin/Customize/PagingSizeUncontrolledDropdown.jsx

@@ -1,13 +1,13 @@
 import React from 'react';
-
 import PropTypes from 'prop-types';
 import {
-  UncontrolledDropdown, DropdownToggle, DropdownMenu, DropdownItem,
+  DropdownItem,
+  DropdownMenu,
+  DropdownToggle,
+  UncontrolledDropdown,
 } from 'reactstrap';
 
-
 const PagingSizeUncontrolledDropdown = (props) => {
-
   function dropdownItemOnClickHandler(num) {
     if (props.onChangeDropdownItem === null) {
       return;
@@ -20,7 +20,7 @@ const PagingSizeUncontrolledDropdown = (props) => {
       <div className="row">
         <div className="offset-md-2 col-md-7 text-start">
           <div className="my-0 w-100">
-            <label className="form-label">{props.label}</label>
+            <span className="form-label">{props.label}</span>
           </div>
           <UncontrolledDropdown>
             <DropdownToggle className="text-end col-6" caret>
@@ -29,23 +29,24 @@ const PagingSizeUncontrolledDropdown = (props) => {
             <DropdownMenu className="dropdown-menu" role="menu">
               {props.dropdownItemSize.map((num) => {
                 return (
-                  <DropdownItem key={num} role="presentation" onClick={() => dropdownItemOnClickHandler(num)}>
-                    <a role="menuitem">{num}</a>
+                  <DropdownItem
+                    key={num}
+                    role="presentation"
+                    onClick={() => dropdownItemOnClickHandler(num)}
+                  >
+                    {num}
                   </DropdownItem>
                 );
               })}
             </DropdownMenu>
           </UncontrolledDropdown>
-          <p className="form-text text-muted">
-            {props.desc}
-          </p>
+          <p className="form-text text-muted">{props.desc}</p>
         </div>
       </div>
     </React.Fragment>
   );
 };
 
-
 PagingSizeUncontrolledDropdown.propTypes = {
   label: PropTypes.string,
   toggleLabel: PropTypes.number,

+ 53 - 25
apps/app/src/client/components/Admin/Customize/ThemeColorBox.tsx

@@ -1,42 +1,52 @@
 import React, { type JSX } from 'react';
-
 import type { GrowiThemeMetadata } from '@growi/core';
 
 import styles from './ThemeColorBox.module.scss';
 
 const themeOptionClass = styles['theme-option-container'];
 
-
 type Props = {
-  isSelected: boolean,
-  metadata: GrowiThemeMetadata,
-  onSelected?: () => void,
+  isSelected: boolean;
+  metadata: GrowiThemeMetadata;
+  onSelected?: () => void;
 };
 
 export const ThemeColorBox = (props: Props): JSX.Element => {
-
+  const { isSelected, metadata, onSelected } = props;
   const {
-    isSelected, metadata, onSelected,
-  } = props;
-  const {
-    name, lightBg, darkBg, lightSidebar, darkSidebar, lightIcon, darkIcon, createBtn, isPresetTheme,
+    name,
+    lightBg,
+    darkBg,
+    lightSidebar,
+    darkSidebar,
+    lightIcon,
+    darkIcon,
+    createBtn,
+    isPresetTheme,
   } = metadata;
 
   return (
-    <div
+    <button
+      type="button"
       id={`theme-option-${name}`}
-      className={`${themeOptionClass} d-flex flex-column align-items-center ${isSelected ? 'active' : ''}`}
+      className={`${themeOptionClass} d-flex flex-column align-items-center ${isSelected ? 'active' : ''} border-0 bg-transparent`}
       onClick={onSelected}
+      aria-pressed={isSelected}
     >
-      <a
+      <div
         id={name}
-        role="button"
         className={`
           m-0 rounded rounded-3
-          border border-4 border-primary ${isSelected ? '' : 'border-opacity-10'}`
-        }
+          border border-4 border-primary ${isSelected ? '' : 'border-opacity-10'}`}
       >
-        <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" className="rounded">
+        <svg
+          xmlns="http://www.w3.org/2000/svg"
+          viewBox="0 0 64 64"
+          width="64"
+          height="64"
+          className="rounded"
+        >
+          <title>{name}</title>
           <path d="M32.5,0V36.364L64,20.437V0Z" fill={lightBg} />
           <path d="M32.5,36.364V64H64V20.438Z" fill={darkBg} />
           <path
@@ -47,17 +57,35 @@ export const ThemeColorBox = (props: Props): JSX.Element => {
             d="M6.436,53.44H26.065V55.5H6.436Zm14.831-11.4h4.8v2.061H17.189L10,47.743H26.065V49.8l-19.629,0v-.259L0,52.8V64H32.5V36.364Z"
             fill={darkSidebar}
           />
-          <path d="M22.338,31.19l6.087-10.543L22.338,10.1H10.163L4.077,20.647,10.163,31.19Z" fill={createBtn} />
+          <path
+            d="M22.338,31.19l6.087-10.543L22.338,10.1H10.163L4.077,20.647,10.163,31.19Z"
+            fill={createBtn}
+          />
           <path d="M6.436,49.543,10,47.742H6.436Z" fill={lightIcon} />
           <path d="M6.436,44.106H17.189l4.078-2.062H6.436Z" fill={lightIcon} />
-          <path d="M6.436,49.8l19.629,0V47.742H10l-3.561,1.8Z" fill={darkIcon} />
+          <path
+            d="M6.436,49.8l19.629,0V47.742H10l-3.561,1.8Z"
+            fill={darkIcon}
+          />
           <path d="M26.065,44.106V42.044h-4.8L17.19,44.106Z" fill={darkIcon} />
-          <rect width="19.629" height="2.062" transform="translate(6.436 53.439)" fill={darkIcon} />
+          <rect
+            width="19.629"
+            height="2.062"
+            transform="translate(6.436 53.439)"
+            fill={darkIcon}
+          />
         </svg>
-      </a>
-      <span className={`mt-2 ${isSelected ? '' : 'opacity-25'}`}><b>{ name }</b></span>
-      { !isPresetTheme && <span className={`badge bg-primary mt-1 ${isSelected ? '' : 'opacity-25'}`}>Plugin</span> }
-    </div>
+      </div>
+      <span className={`mt-2 ${isSelected ? '' : 'opacity-25'}`}>
+        <b>{name}</b>
+      </span>
+      {!isPresetTheme && (
+        <span
+          className={`badge bg-primary mt-1 ${isSelected ? '' : 'opacity-25'}`}
+        >
+          Plugin
+        </span>
+      )}
+    </button>
   );
-
 };

+ 29 - 34
apps/app/src/client/components/Admin/ElasticsearchManagement/ElasticsearchManagement.tsx

@@ -1,10 +1,9 @@
-import React, { useEffect, useState, useCallback } from 'react';
-
+import React, { useCallback, useEffect, useState } from 'react';
 import { useAtomValue } from 'jotai';
 import { useTranslation } from 'next-i18next';
 
 import { apiv3Get, apiv3Post, apiv3Put } from '~/client/util/apiv3-client';
-import { toastSuccess, toastError } from '~/client/util/toastr';
+import { toastError, toastSuccess } from '~/client/util/toastr';
 import { useAdminSocket } from '~/features/admin/states/socket-io';
 import { SocketEventName } from '~/interfaces/websocket';
 import { isSearchServiceReachableAtom } from '~/states/server-configurations';
@@ -24,7 +23,8 @@ const ElasticsearchManagement = (): JSX.Element => {
 
   const [isConnected, setIsConnected] = useState(false);
   const [isConfigured, setIsConfigured] = useState(false);
-  const [isReconnectingProcessing, setIsReconnectingProcessing] = useState(false);
+  const [isReconnectingProcessing, setIsReconnectingProcessing] =
+    useState(false);
   const [isRebuildingProcessing, setIsRebuildingProcessing] = useState(false);
   const [isRebuildingCompleted, setIsRebuildingCompleted] = useState(false);
 
@@ -32,8 +32,7 @@ const ElasticsearchManagement = (): JSX.Element => {
   const [indicesData, setIndicesData] = useState(null);
   const [aliasesData, setAliasesData] = useState(null);
 
-
-  const retrieveIndicesStatus = useCallback(async() => {
+  const retrieveIndicesStatus = useCallback(async () => {
     try {
       const { data } = await apiv3Get('/search/indices');
       const { info } = data;
@@ -46,8 +45,7 @@ const ElasticsearchManagement = (): JSX.Element => {
       setIsNormalized(info.isNormalized);
 
       return info.isNormalized;
-    }
-    catch (errors: unknown) {
+    } catch (errors: unknown) {
       setIsConnected(false);
 
       // evaluate whether configured or not
@@ -58,14 +56,12 @@ const ElasticsearchManagement = (): JSX.Element => {
           }
         }
         toastError(errors as Error[]);
-      }
-      else {
+      } else {
         toastError(errors as Error);
       }
 
       return false;
-    }
-    finally {
+    } finally {
       setIsInitialized(true);
     }
   }, []);
@@ -82,12 +78,12 @@ const ElasticsearchManagement = (): JSX.Element => {
       setIsRebuildingProcessing(true);
     });
 
-    socket.on(SocketEventName.FinishAddPage, async(data) => {
+    socket.on(SocketEventName.FinishAddPage, async (data) => {
       let retryCount = 0;
       const maxRetries = 5;
       const retryDelay = 500;
 
-      const retrieveIndicesStatusWithRetry = async() => {
+      const retrieveIndicesStatusWithRetry = async () => {
         const isNormalizedResult = await retrieveIndicesStatus();
         if (!isNormalizedResult && retryCount < maxRetries) {
           retryCount++;
@@ -111,13 +107,12 @@ const ElasticsearchManagement = (): JSX.Element => {
     };
   }, [retrieveIndicesStatus, socket]);
 
-  const reconnect = async() => {
+  const reconnect = async () => {
     setIsReconnectingProcessing(true);
 
     try {
       await apiv3Post('/search/connection');
-    }
-    catch (e) {
+    } catch (e) {
       toastError(e);
       return;
     }
@@ -126,12 +121,10 @@ const ElasticsearchManagement = (): JSX.Element => {
     window.location.reload();
   };
 
-  const normalizeIndices = async() => {
-
+  const normalizeIndices = async () => {
     try {
       await apiv3Put('/search/indices', { operation: 'normalize' });
-    }
-    catch (e) {
+    } catch (e) {
       toastError(e);
     }
 
@@ -140,14 +133,13 @@ const ElasticsearchManagement = (): JSX.Element => {
     toastSuccess('Normalizing has succeeded');
   };
 
-  const rebuildIndices = async() => {
+  const rebuildIndices = async () => {
     setIsRebuildingProcessing(true);
 
     try {
       await apiv3Put('/search/indices', { operation: 'rebuild' });
       toastSuccess('Rebuilding is requested');
-    }
-    catch (e) {
+    } catch (e) {
       toastError(e);
     }
 
@@ -156,7 +148,9 @@ const ElasticsearchManagement = (): JSX.Element => {
 
   const isErrorOccuredOnSearchService = !isSearchServiceReachable;
 
-  const isReconnectBtnEnabled = !isReconnectingProcessing && (!isInitialized || !isConnected || isErrorOccuredOnSearchService);
+  const isReconnectBtnEnabled =
+    !isReconnectingProcessing &&
+    (!isInitialized || !isConnected || isErrorOccuredOnSearchService);
 
   return (
     <>
@@ -178,7 +172,9 @@ const ElasticsearchManagement = (): JSX.Element => {
 
       {/* Controls */}
       <div className="row">
-        <label className="col-md-3 col-form-label text-start text-md-end">{ t('full_text_search_management.reconnect') }</label>
+        <div className="col-md-3 col-form-label text-start text-md-end">
+          {t('full_text_search_management.reconnect')}
+        </div>
         <div className="col-md-6">
           <ReconnectControls
             isEnabled={isReconnectBtnEnabled}
@@ -191,7 +187,9 @@ const ElasticsearchManagement = (): JSX.Element => {
       <hr />
 
       <div className="row">
-        <label className="col-md-3 col-form-label text-start text-md-end">{ t('full_text_search_management.normalize') }</label>
+        <div className="col-md-3 col-form-label text-start text-md-end">
+          {t('full_text_search_management.normalize')}
+        </div>
         <div className="col-md-6">
           <NormalizeIndicesControls
             isRebuildingProcessing={isRebuildingProcessing}
@@ -204,7 +202,9 @@ const ElasticsearchManagement = (): JSX.Element => {
       <hr />
 
       <div className="row">
-        <label className="col-md-3 col-form-label text-start text-md-end">{ t('full_text_search_management.rebuild') }</label>
+        <div className="col-md-3 col-form-label text-start text-md-end">
+          {t('full_text_search_management.rebuild')}
+        </div>
         <div className="col-md-6">
           <RebuildIndexControls
             isRebuildingProcessing={isRebuildingProcessing}
@@ -214,15 +214,10 @@ const ElasticsearchManagement = (): JSX.Element => {
           />
         </div>
       </div>
-
     </>
   );
-
 };
 
-
-ElasticsearchManagement.propTypes = {
-
-};
+ElasticsearchManagement.propTypes = {};
 
 export default ElasticsearchManagement;

+ 12 - 9
apps/app/src/client/components/Admin/ElasticsearchManagement/NormalizeIndicesControls.tsx

@@ -1,32 +1,35 @@
 import React, { type JSX } from 'react';
-
 import { useTranslation } from 'next-i18next';
 
 type Props = {
-  isRebuildingProcessing: boolean,
-  onNormalizingRequested: () => void,
-  isNormalized?: boolean,
-}
+  isRebuildingProcessing: boolean;
+  onNormalizingRequested: () => void;
+  isNormalized?: boolean;
+};
 
 const NormalizeIndicesControls = (props: Props): JSX.Element => {
   const { t } = useTranslation('admin');
   const { isNormalized, isRebuildingProcessing } = props;
 
-  const isEnabled = (isNormalized != null) && !isNormalized && !isRebuildingProcessing;
+  const isEnabled =
+    isNormalized != null && !isNormalized && !isRebuildingProcessing;
 
   return (
     <>
       <button
         type="submit"
         className={`btn ${isEnabled ? 'btn-outline-info' : 'btn-outline-secondary'}`}
-        onClick={() => { props.onNormalizingRequested() }}
+        onClick={() => {
+          props.onNormalizingRequested();
+        }}
         disabled={!isEnabled}
       >
-        { t('full_text_search_management.normalize_button') }
+        {t('full_text_search_management.normalize_button')}
       </button>
 
       <p className="form-text text-muted">
-        { t('full_text_search_management.normalize_description') }<br />
+        {t('full_text_search_management.normalize_description')}
+        <br />
       </p>
     </>
   );

+ 11 - 15
apps/app/src/client/components/Admin/ElasticsearchManagement/RebuildIndexControls.jsx

@@ -1,5 +1,4 @@
 import React from 'react';
-
 import { useTranslation } from 'next-i18next';
 import PropTypes from 'prop-types';
 
@@ -9,7 +8,6 @@ import { SocketEventName } from '~/interfaces/websocket';
 import LabeledProgressBar from '../Common/LabeledProgressBar';
 
 class RebuildIndexControls extends React.Component {
-
   constructor(props) {
     super(props);
 
@@ -44,12 +42,8 @@ class RebuildIndexControls extends React.Component {
   }
 
   renderProgressBar() {
-    const {
-      isRebuildingProcessing, isRebuildingCompleted,
-    } = this.props;
-    const {
-      total, current,
-    } = this.state;
+    const { isRebuildingProcessing, isRebuildingCompleted } = this.props;
+    const { total, current } = this.state;
     const showProgressBar = isRebuildingProcessing || isRebuildingCompleted;
 
     if (!showProgressBar) {
@@ -76,25 +70,28 @@ class RebuildIndexControls extends React.Component {
 
     return (
       <>
-        { this.renderProgressBar() }
+        {this.renderProgressBar()}
 
         <button
           type="submit"
           className="btn btn-primary"
-          onClick={() => { this.props.onRebuildingRequested() }}
+          onClick={() => {
+            this.props.onRebuildingRequested();
+          }}
           disabled={!isEnabled}
         >
-          { t('full_text_search_management.rebuild_button') }
+          {t('full_text_search_management.rebuild_button')}
         </button>
 
         <p className="form-text text-muted">
-          { t('full_text_search_management.rebuild_description_1') }<br />
-          { t('full_text_search_management.rebuild_description_2') }<br />
+          {t('full_text_search_management.rebuild_description_1')}
+          <br />
+          {t('full_text_search_management.rebuild_description_2')}
+          <br />
         </p>
       </>
     );
   }
-
 }
 
 const RebuildIndexControlsFC = (props) => {
@@ -103,7 +100,6 @@ const RebuildIndexControlsFC = (props) => {
   return <RebuildIndexControls t={t} socket={socket} {...props} />;
 };
 
-
 RebuildIndexControls.propTypes = {
   t: PropTypes.func.isRequired, // i18next
 

+ 11 - 11
apps/app/src/client/components/Admin/ElasticsearchManagement/ReconnectControls.tsx

@@ -1,14 +1,12 @@
 import React, { type JSX } from 'react';
-
 import { LoadingSpinner } from '@growi/ui/dist/components';
 import { useTranslation } from 'next-i18next';
 
-
 type Props = {
-  isEnabled?: boolean,
-  isProcessing?: boolean,
-  onReconnectingRequested: () => void,
-}
+  isEnabled?: boolean;
+  isProcessing?: boolean;
+  onReconnectingRequested: () => void;
+};
 
 const ReconnectControls = (props: Props): JSX.Element => {
   const { t } = useTranslation('admin');
@@ -20,19 +18,21 @@ const ReconnectControls = (props: Props): JSX.Element => {
       <button
         type="submit"
         className={`btn ${isEnabled ? 'btn-outline-success' : 'btn-outline-secondary'}`}
-        onClick={() => { props.onReconnectingRequested() }}
+        onClick={() => {
+          props.onReconnectingRequested();
+        }}
         disabled={!isEnabled}
       >
-        { isProcessing && <LoadingSpinner className="me-2" /> }
-        { t('full_text_search_management.reconnect_button') }
+        {isProcessing && <LoadingSpinner className="me-2" />}
+        {t('full_text_search_management.reconnect_button')}
       </button>
 
       <p className="form-text text-muted">
-        { t('full_text_search_management.reconnect_description') }<br />
+        {t('full_text_search_management.reconnect_description')}
+        <br />
       </p>
     </>
   );
-
 };
 
 export default ReconnectControls;

+ 83 - 44
apps/app/src/client/components/Admin/ElasticsearchManagement/StatusTable.jsx

@@ -1,43 +1,54 @@
 import React from 'react';
-
 import { useTranslation } from 'next-i18next';
 import PropTypes from 'prop-types';
 
 class StatusTable extends React.PureComponent {
-
   renderPreInitializedLabel() {
     return <span className="badge text-bg-default">――</span>;
   }
 
   renderConnectionStatusLabels() {
     const { t } = this.props;
-    const {
-      isErrorOccuredOnSearchService,
-      isConnected, isConfigured,
-    } = this.props;
+    const { isErrorOccuredOnSearchService, isConnected, isConfigured } =
+      this.props;
 
-    const errorOccuredLabel = isErrorOccuredOnSearchService
-      ? <span className="badge text-bg-danger ms-2">{ t('full_text_search_management.connection_status_label_erroroccured') }</span>
-      : null;
+    const errorOccuredLabel = isErrorOccuredOnSearchService ? (
+      <span className="badge text-bg-danger ms-2">
+        {t('full_text_search_management.connection_status_label_erroroccured')}
+      </span>
+    ) : null;
 
     let connectionStatusLabel = null;
     if (!isConfigured) {
       connectionStatusLabel = (
         <span className="badge text-bg-default">
-          { t('full_text_search_management.connection_status_label_unconfigured') }
+          {t(
+            'full_text_search_management.connection_status_label_unconfigured',
+          )}
         </span>
       );
-    }
-    else {
-      connectionStatusLabel = isConnected
+    } else {
+      connectionStatusLabel = isConnected ? (
         // eslint-disable-next-line max-len
-        ? <span data-testid="connection-status-badge-connected" className="badge text-bg-success">{ t('full_text_search_management.connection_status_label_connected') }</span>
-        : <span className="badge text-bg-danger">{ t('full_text_search_management.connection_status_label_disconnected') }</span>;
+        <span
+          data-testid="connection-status-badge-connected"
+          className="badge text-bg-success"
+        >
+          {t('full_text_search_management.connection_status_label_connected')}
+        </span>
+      ) : (
+        <span className="badge text-bg-danger">
+          {t(
+            'full_text_search_management.connection_status_label_disconnected',
+          )}
+        </span>
+      );
     }
 
     return (
       <>
-        {connectionStatusLabel}{errorOccuredLabel}
+        {connectionStatusLabel}
+        {errorOccuredLabel}
       </>
     );
   }
@@ -45,9 +56,15 @@ class StatusTable extends React.PureComponent {
   renderIndicesStatusLabel() {
     const { t, isNormalized } = this.props;
 
-    return isNormalized
-      ? <span className="badge text-bg-info">{ t('full_text_search_management.indices_status_label_normalized') }</span>
-      : <span className="badge text-bg-warning">{ t('full_text_search_management.indices_status_label_unnormalized') }</span>;
+    return isNormalized ? (
+      <span className="badge text-bg-info">
+        {t('full_text_search_management.indices_status_label_normalized')}
+      </span>
+    ) : (
+      <span className="badge text-bg-warning">
+        {t('full_text_search_management.indices_status_label_unnormalized')}
+      </span>
+    );
   }
 
   renderIndexInfoPanel(indexName, body = {}, aliases = []) {
@@ -55,7 +72,10 @@ class StatusTable extends React.PureComponent {
 
     const aliasLabels = aliases.map((aliasName) => {
       return (
-        <span key={`badge-${indexName}-${aliasName}`} className="badge text-bg-primary me-2">
+        <span
+          key={`badge-${indexName}-${aliasName}`}
+          className="badge text-bg-primary me-2"
+        >
           <span className="material-symbols-outlined">sell</span>
           <span>{aliasName}</span>
         </span>
@@ -65,17 +85,22 @@ class StatusTable extends React.PureComponent {
     return (
       <div className="card">
         <div className="card-header">
-
-          <a role="button" className="text-nowrap me-2" data-bs-toggle="collapse" href={`#${collapseId}`} aria-expanded="true" aria-controls={collapseId}>
-            <span className="material-symbols-outlined">database</span> {indexName}
-          </a>
+          <button
+            type="button"
+            className="text-nowrap me-2 btn btn-link p-0"
+            data-bs-toggle="collapse"
+            data-bs-target={`#${collapseId}`}
+            aria-expanded="true"
+            aria-controls={collapseId}
+          >
+            <span className="material-symbols-outlined">database</span>{' '}
+            {indexName}
+          </button>
           <span className="ms-md-3">{aliasLabels}</span>
         </div>
         <div id={collapseId} className="collapse">
           <div className="card-body">
-            <pre>
-              {JSON.stringify(body, null, 2)}
-            </pre>
+            <pre>{JSON.stringify(body, null, 2)}</pre>
           </div>
         </div>
       </div>
@@ -83,10 +108,7 @@ class StatusTable extends React.PureComponent {
   }
 
   renderIndexInfoPanels() {
-    const {
-      indicesData,
-      aliasesData,
-    } = this.props;
+    const { indicesData, aliasesData } = this.props;
 
     // data is null
     if (indicesData == null) {
@@ -126,43 +148,60 @@ class StatusTable extends React.PureComponent {
 
     return (
       <div className="row">
-        { Object.keys(indexNameToDataMap).map((indexName) => {
+        {Object.keys(indexNameToDataMap).map((indexName) => {
           return (
             <div key={`col-${indexName}`} className="col-md-6">
-              { this.renderIndexInfoPanel(indexName, indexNameToDataMap[indexName], indexNameToAliasMap[indexName]) }
+              {this.renderIndexInfoPanel(
+                indexName,
+                indexNameToDataMap[indexName],
+                indexNameToAliasMap[indexName],
+              )}
             </div>
           );
-        }) }
+        })}
       </div>
     );
   }
 
   render() {
     const { t } = this.props;
-    const {
-      isInitialized,
-    } = this.props;
+    const { isInitialized } = this.props;
 
     return (
       <table className="table table-bordered">
         <tbody>
           <tr>
-            <th className="w-25">{t('full_text_search_management.connection_status')}</th>
-            <td className="w-75">{ isInitialized ? this.renderConnectionStatusLabels() : this.renderPreInitializedLabel() }</td>
+            <th className="w-25">
+              {t('full_text_search_management.connection_status')}
+            </th>
+            <td className="w-75">
+              {isInitialized
+                ? this.renderConnectionStatusLabels()
+                : this.renderPreInitializedLabel()}
+            </td>
           </tr>
           <tr>
-            <th className="w-25">{t('full_text_search_management.indices_status')}</th>
-            <td className="w-75">{ isInitialized ? this.renderIndicesStatusLabel() : this.renderPreInitializedLabel() }</td>
+            <th className="w-25">
+              {t('full_text_search_management.indices_status')}
+            </th>
+            <td className="w-75">
+              {isInitialized
+                ? this.renderIndicesStatusLabel()
+                : this.renderPreInitializedLabel()}
+            </td>
           </tr>
           <tr>
-            <th className="w-25">{t('full_text_search_management.indices_summary')}</th>
-            <td className="p-4 w-75">{ isInitialized && this.renderIndexInfoPanels() }</td>
+            <th className="w-25">
+              {t('full_text_search_management.indices_summary')}
+            </th>
+            <td className="p-4 w-75">
+              {isInitialized && this.renderIndexInfoPanels()}
+            </td>
           </tr>
         </tbody>
       </table>
     );
   }
-
 }
 
 const StatusTableWrapperFC = (props) => {

+ 13 - 6
apps/app/src/client/components/Admin/ExportArchiveData/ArchiveFilesTable.tsx

@@ -1,14 +1,13 @@
 import React, { type JSX } from 'react';
-
 import { format } from 'date-fns/format';
 import { useTranslation } from 'next-i18next';
 
 import ArchiveFilesTableMenu from './ArchiveFilesTableMenu';
 
 type ArchiveFilesTableProps = {
-  zipFileStats: any[],
-  onZipFileStatRemove: (fileName: string) => void,
-}
+  zipFileStats: any[];
+  onZipFileStatRemove: (fileName: string) => void;
+};
 
 const ArchiveFilesTable = (props: ArchiveFilesTableProps): JSX.Element => {
   const { t } = useTranslation();
@@ -30,8 +29,16 @@ const ArchiveFilesTable = (props: ArchiveFilesTableProps): JSX.Element => {
             <tr key={fileName}>
               <th>{fileName}</th>
               <td>{meta.version}</td>
-              <td className="text-capitalize">{innerFileStats.map(fileStat => fileStat.collectionName).join(', ')}</td>
-              <td>{meta.exportedAt ? format(new Date(meta.exportedAt), 'yyyy/MM/dd HH:mm:ss') : ''}</td>
+              <td className="text-capitalize">
+                {innerFileStats
+                  .map((fileStat) => fileStat.collectionName)
+                  .join(', ')}
+              </td>
+              <td>
+                {meta.exportedAt
+                  ? format(new Date(meta.exportedAt), 'yyyy/MM/dd HH:mm:ss')
+                  : ''}
+              </td>
               <td>
                 <ArchiveFilesTableMenu
                   fileName={fileName}

+ 35 - 12
apps/app/src/client/components/Admin/ExportArchiveData/ArchiveFilesTableMenu.tsx

@@ -1,29 +1,52 @@
 import React, { type JSX } from 'react';
-
 import { useTranslation } from 'next-i18next';
 
 // import { toastSuccess, toastError } from '~/client/util/toastr';
 
 type ArchiveFilesTableMenuProps = {
-  fileName: string,
-  onZipFileStatRemove: (fileName: string) => void,
-}
+  fileName: string;
+  onZipFileStatRemove: (fileName: string) => void;
+};
 
-const ArchiveFilesTableMenu = (props: ArchiveFilesTableMenuProps):JSX.Element => {
+const ArchiveFilesTableMenu = (
+  props: ArchiveFilesTableMenuProps,
+): JSX.Element => {
   const { t } = useTranslation();
 
   return (
     <div className="dropdown">
-      <button type="button" className="btn btn-sm btn-outline-secondary dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">
-        <span className="material-symbols-outlined">settings</span> <span className="caret"></span>
+      <button
+        type="button"
+        className="btn btn-sm btn-outline-secondary dropdown-toggle"
+        data-bs-toggle="dropdown"
+        aria-expanded="false"
+      >
+        <span className="material-symbols-outlined">settings</span>{' '}
+        <span className="caret"></span>
       </button>
       <ul className="dropdown-menu dropdown-menu-end">
-        <li className="dropdown-header">{t('admin:export_management.export_menu')}</li>
-        <button type="button" className="dropdown-item" onClick={() => { window.location.href = `/admin/export/${props.fileName}` }}>
-          <span className="material-symbols-outlined">cloud_download</span> {t('admin:export_management.download')}
+        <li className="dropdown-header">
+          {t('admin:export_management.export_menu')}
+        </li>
+        <button
+          type="button"
+          className="dropdown-item"
+          onClick={() => {
+            window.location.href = `/admin/export/${props.fileName}`;
+          }}
+        >
+          <span className="material-symbols-outlined">cloud_download</span>{' '}
+          {t('admin:export_management.download')}
         </button>
-        <button type="button" className="dropdown-item" role="button" onClick={() => props.onZipFileStatRemove(props.fileName)}>
-          <span className="text-danger"><span className="material-symbols-outlined">delete</span> {t('admin:export_management.delete')}</span>
+        <button
+          type="button"
+          className="dropdown-item"
+          onClick={() => props.onZipFileStatRemove(props.fileName)}
+        >
+          <span className="text-danger">
+            <span className="material-symbols-outlined">delete</span>{' '}
+            {t('admin:export_management.delete')}
+          </span>
         </button>
       </ul>
     </div>

+ 137 - 83
apps/app/src/client/components/Admin/ExportArchiveData/SelectCollectionsModal.tsx

@@ -1,47 +1,61 @@
-import React, {
-  useCallback, useState, useEffect, type JSX,
-} from 'react';
-
+import React, { type JSX, useCallback, useEffect, useState } from 'react';
 import { useTranslation } from 'next-i18next';
-import {
-  Modal, ModalHeader, ModalBody, ModalFooter,
-} from 'reactstrap';
+import { Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
 
 import { apiPost } from '~/client/util/apiv1-client';
 import { toastError, toastSuccess } from '~/client/util/toastr';
 
-
 const GROUPS_PAGE = [
-  'pages', 'revisions', 'tags', 'pagetagrelations', 'pageredirects', 'comments', 'sharelinks',
+  'pages',
+  'revisions',
+  'tags',
+  'pagetagrelations',
+  'pageredirects',
+  'comments',
+  'sharelinks',
 ];
 const GROUPS_USER = [
-  'users', 'externalaccounts', 'usergroups', 'usergrouprelations',
-  'externalusergroups', 'externalusergrouprelations',
-  'useruisettings', 'editorsettings', 'bookmarks', 'bookmarkfolders', 'subscriptions',
+  'users',
+  'externalaccounts',
+  'usergroups',
+  'usergrouprelations',
+  'externalusergroups',
+  'externalusergrouprelations',
+  'useruisettings',
+  'editorsettings',
+  'bookmarks',
+  'bookmarkfolders',
+  'subscriptions',
   'inappnotificationsettings',
 ];
 const GROUPS_CONFIG = [
-  'configs', 'migrations', 'updateposts', 'globalnotificationsettings', 'slackappintegrations',
+  'configs',
+  'migrations',
+  'updateposts',
+  'globalnotificationsettings',
+  'slackappintegrations',
   'growiplugins',
 ];
-const ALL_GROUPED_COLLECTIONS = GROUPS_PAGE.concat(GROUPS_USER).concat(GROUPS_CONFIG);
+const ALL_GROUPED_COLLECTIONS =
+  GROUPS_PAGE.concat(GROUPS_USER).concat(GROUPS_CONFIG);
 
 type Props = {
-  isOpen: boolean,
-  onExportingRequested: () => void,
-  onClose: () => void,
-  collections: string[],
-  isAllChecked?: boolean,
+  isOpen: boolean;
+  onExportingRequested: () => void;
+  onClose: () => void;
+  collections: string[];
+  isAllChecked?: boolean;
 };
 
 const SelectCollectionsModal = (props: Props): JSX.Element => {
   const { t } = useTranslation();
 
-  const {
-    isOpen, onExportingRequested, onClose, collections, isAllChecked,
-  } = props;
+  const { isOpen, onExportingRequested, onClose, collections, isAllChecked } =
+    props;
 
-  const [selectedCollections, setSelectedCollections] = useState<Set<string>>(new Set());
+  const [selectedCollections, setSelectedCollections] = useState<Set<string>>(
+    new Set(),
+  );
 
   const toggleCheckbox = useCallback((e) => {
     const { target } = e;
@@ -51,8 +65,7 @@ const SelectCollectionsModal = (props: Props): JSX.Element => {
       const selectedCollections = new Set(prevState);
       if (checked) {
         selectedCollections.add(name);
-      }
-      else {
+      } else {
         selectedCollections.delete(name);
       }
 
@@ -68,27 +81,31 @@ const SelectCollectionsModal = (props: Props): JSX.Element => {
     setSelectedCollections(new Set());
   }, []);
 
-  const doExport = useCallback(async(e) => {
-    e.preventDefault();
+  const doExport = useCallback(
+    async (e) => {
+      e.preventDefault();
 
-    try {
-      // TODO: use apiv3Post
-      const result = await apiPost<any>('/v3/export', { collections: Array.from(selectedCollections) });
+      try {
+        // TODO: use apiv3Post
+        const result = await apiPost<any>('/v3/export', {
+          collections: Array.from(selectedCollections),
+        });
 
-      if (!result.ok) {
-        throw new Error('Error occured.');
-      }
+        if (!result.ok) {
+          throw new Error('Error occured.');
+        }
 
-      toastSuccess('Export process has requested.');
+        toastSuccess('Export process has requested.');
 
-      onExportingRequested();
-      onClose();
-      uncheckAll();
-    }
-    catch (err) {
-      toastError(err);
-    }
-  }, [onClose, onExportingRequested, selectedCollections, uncheckAll]);
+        onExportingRequested();
+        onClose();
+        uncheckAll();
+      } catch (err) {
+        toastError(err);
+      }
+    },
+    [onClose, onExportingRequested, selectedCollections, uncheckAll],
+  );
 
   const validateForm = useCallback(() => {
     return selectedCollections.size > 0;
@@ -109,49 +126,58 @@ const SelectCollectionsModal = (props: Props): JSX.Element => {
     return (
       <div className="card">
         <div className="card-body">
-          {/* eslint-disable-next-line react/no-danger */}
+          {/** biome-ignore lint/security/noDangerouslySetInnerHtml: ignore */}
           <p className="card-text" dangerouslySetInnerHTML={{ __html: html }} />
         </div>
       </div>
     );
   }, [selectedCollections, t]);
 
-  const renderCheckboxes = useCallback((collectionNames, color?) => {
-    const checkboxColor = color ? `form-check-${color}` : 'form-check-info';
+  const renderCheckboxes = useCallback(
+    (collectionNames, color?) => {
+      const checkboxColor = color ? `form-check-${color}` : 'form-check-info';
 
-    return (
-      <div className={`form-check ${checkboxColor}`}>
-        <div className="row">
-          {collectionNames.map((collectionName) => {
-            return (
-              <div className="col-sm-6 my-1" key={collectionName}>
-                <input
-                  type="checkbox"
-                  className="form-check-input"
-                  id={collectionName}
-                  name={collectionName}
-                  value={collectionName}
-                  checked={selectedCollections.has(collectionName)}
-                  onChange={toggleCheckbox}
-                />
-                <label className="form-label text-capitalize form-check-label ms-3" htmlFor={collectionName}>
-                  {collectionName}
-                </label>
-              </div>
-            );
-          })}
+      return (
+        <div className={`form-check ${checkboxColor}`}>
+          <div className="row">
+            {collectionNames.map((collectionName) => {
+              return (
+                <div className="col-sm-6 my-1" key={collectionName}>
+                  <input
+                    type="checkbox"
+                    className="form-check-input"
+                    id={collectionName}
+                    name={collectionName}
+                    value={collectionName}
+                    checked={selectedCollections.has(collectionName)}
+                    onChange={toggleCheckbox}
+                  />
+                  <label
+                    className="form-label text-capitalize form-check-label ms-3"
+                    htmlFor={collectionName}
+                  >
+                    {collectionName}
+                  </label>
+                </div>
+              );
+            })}
+          </div>
         </div>
-      </div>
-    );
-  }, [selectedCollections, toggleCheckbox]);
+      );
+    },
+    [selectedCollections, toggleCheckbox],
+  );
 
-  const renderGroups = useCallback((groupList, color?) => {
-    const collectionNames = groupList.filter((collectionName) => {
-      return collections.includes(collectionName);
-    });
+  const renderGroups = useCallback(
+    (groupList, color?) => {
+      const collectionNames = groupList.filter((collectionName) => {
+        return collections.includes(collectionName);
+      });
 
-    return renderCheckboxes(collectionNames, color);
-  }, [collections, renderCheckboxes]);
+      return renderCheckboxes(collectionNames, color);
+    },
+    [collections, renderCheckboxes],
+  );
 
   const renderOthers = useCallback(() => {
     const collectionNames = collections.filter((collectionName) => {
@@ -175,11 +201,23 @@ const SelectCollectionsModal = (props: Props): JSX.Element => {
         <ModalBody>
           <div className="row">
             <div className="col-sm-12">
-              <button type="button" className="btn btn-sm btn-outline-secondary me-2" onClick={checkAll}>
-                <span className="material-symbols-outlined">check_box</span> {t('admin:export_management.check_all')}
+              <button
+                type="button"
+                className="btn btn-sm btn-outline-secondary me-2"
+                onClick={checkAll}
+              >
+                <span className="material-symbols-outlined">check_box</span>{' '}
+                {t('admin:export_management.check_all')}
               </button>
-              <button type="button" className="btn btn-sm btn-outline-secondary me-2" onClick={uncheckAll}>
-                <span className="material-symbols-outlined">check_box_outline_blank</span> {t('admin:export_management.uncheck_all')}
+              <button
+                type="button"
+                className="btn btn-sm btn-outline-secondary me-2"
+                onClick={uncheckAll}
+              >
+                <span className="material-symbols-outlined">
+                  check_box_outline_blank
+                </span>{' '}
+                {t('admin:export_management.uncheck_all')}
               </button>
             </div>
           </div>
@@ -198,21 +236,37 @@ const SelectCollectionsModal = (props: Props): JSX.Element => {
           </div>
           <div className="row mt-4">
             <div className="col-sm-12">
-              <h3 className="admin-setting-header">MongoDB Config Collections</h3>
+              <h3 className="admin-setting-header">
+                MongoDB Config Collections
+              </h3>
               {renderGroups(GROUPS_CONFIG)}
             </div>
           </div>
           <div className="row mt-4">
             <div className="col-sm-12">
-              <h3 className="admin-setting-header">MongoDB Other Collections</h3>
+              <h3 className="admin-setting-header">
+                MongoDB Other Collections
+              </h3>
               {renderOthers()}
             </div>
           </div>
         </ModalBody>
 
         <ModalFooter>
-          <button type="button" className="btn btn-sm btn-outline-secondary" onClick={onClose}>{t('admin:export_management.cancel')}</button>
-          <button type="submit" className="btn btn-sm btn-primary" disabled={!validateForm()}>{t('admin:export_management.export')}</button>
+          <button
+            type="button"
+            className="btn btn-sm btn-outline-secondary"
+            onClick={onClose}
+          >
+            {t('admin:export_management.cancel')}
+          </button>
+          <button
+            type="submit"
+            className="btn btn-sm btn-primary"
+            disabled={!validateForm()}
+          >
+            {t('admin:export_management.export')}
+          </button>
         </ModalFooter>
       </form>
     </Modal>

+ 40 - 27
apps/app/src/client/components/Admin/ExportArchiveDataPage.tsx

@@ -1,10 +1,6 @@
-import React, {
-  useCallback, useEffect, useState, type JSX,
-} from 'react';
-
+import React, { type JSX, useCallback, useEffect, useState } from 'react';
 import { useTranslation } from 'react-i18next';
 
-
 import { apiDelete } from '~/client/util/apiv1-client';
 import { apiv3Get } from '~/client/util/apiv3-client';
 import { toastError, toastSuccess } from '~/client/util/toastr';
@@ -14,9 +10,11 @@ import LabeledProgressBar from './Common/LabeledProgressBar';
 import ArchiveFilesTable from './ExportArchiveData/ArchiveFilesTable';
 import SelectCollectionsModal from './ExportArchiveData/SelectCollectionsModal';
 
-
 const IGNORED_COLLECTION_NAMES = [
-  'sessions', 'rlflx', 'yjs-writings', 'transferkeys',
+  'sessions',
+  'rlflx',
+  'yjs-writings',
+  'transferkeys',
 ];
 
 const ExportArchiveDataPage = (): JSX.Element => {
@@ -31,16 +29,26 @@ const ExportArchiveDataPage = (): JSX.Element => {
   const [isZipping, setZipping] = useState(false);
   const [isExported, setExported] = useState(false);
 
-  const fetchData = useCallback(async() => {
-    const [{ data: collectionsData }, { data: statusData }] = await Promise.all([
-      apiv3Get<{collections: any[]}>('/mongo/collections', {}),
-      apiv3Get<{status: { zipFileStats: any[], isExporting: boolean, progressList: any[] }}>('/export/status', {}),
-    ]);
+  const fetchData = useCallback(async () => {
+    const [{ data: collectionsData }, { data: statusData }] = await Promise.all(
+      [
+        apiv3Get<{ collections: any[] }>('/mongo/collections', {}),
+        apiv3Get<{
+          status: {
+            zipFileStats: any[];
+            isExporting: boolean;
+            progressList: any[];
+          };
+        }>('/export/status', {}),
+      ],
+    );
 
     // filter only not ignored collection names
-    const filteredCollections = collectionsData.collections.filter((collectionName) => {
-      return !IGNORED_COLLECTION_NAMES.includes(collectionName);
-    });
+    const filteredCollections = collectionsData.collections.filter(
+      (collectionName) => {
+        return !IGNORED_COLLECTION_NAMES.includes(collectionName);
+      },
+    );
 
     const { zipFileStats, isExporting, progressList } = statusData.status;
     setCollections(filteredCollections);
@@ -67,7 +75,7 @@ const ExportArchiveDataPage = (): JSX.Element => {
       setExporting(false);
       setZipping(false);
       setExported(true);
-      setZipFileStats(prev => prev.concat([addedZipFileStat]));
+      setZipFileStats((prev) => prev.concat([addedZipFileStat]));
 
       toastSuccess(`New Archive Data '${addedZipFileStat.fileName}' is added`);
     };
@@ -83,18 +91,18 @@ const ExportArchiveDataPage = (): JSX.Element => {
       socket.off('admin:onStartZippingForExport', onStartZipping);
       socket.off('admin:onTerminateForExport', onTerminateForExport);
     };
-
   }, [socket]);
 
-  const onZipFileStatRemove = useCallback(async(fileName) => {
+  const onZipFileStatRemove = useCallback(async (fileName) => {
     try {
       await apiDelete(`/v3/export/${fileName}`, {});
 
-      setZipFileStats(prev => prev.filter(stat => stat.fileName !== fileName));
+      setZipFileStats((prev) =>
+        prev.filter((stat) => stat.fileName !== fileName),
+      );
 
       toastSuccess(`Deleted ${fileName}`);
-    }
-    catch (err) {
+    } catch (err) {
       toastError(err);
     }
   }, []);
@@ -148,23 +156,28 @@ const ExportArchiveDataPage = (): JSX.Element => {
     };
   }, [fetchData, setupWebsocketEventHandler]);
 
-  const showExportingData = (isExported || isExporting) && (progressList != null);
+  const showExportingData = (isExported || isExporting) && progressList != null;
 
   return (
     <div data-testid="admin-export-archive-data">
       <h2>{t('export_management.export_archive_data')}</h2>
 
-      <button type="button" className="btn btn-outline-secondary" disabled={isExporting} onClick={() => setExportModalOpen(true)}>
+      <button
+        type="button"
+        className="btn btn-outline-secondary"
+        disabled={isExporting}
+        onClick={() => setExportModalOpen(true)}
+      >
         {t('export_management.create_new_archive_data')}
       </button>
 
-      { showExportingData && (
+      {showExportingData && (
         <div className="mt-5">
           <h3>{t('export_management.exporting_collection_list')}</h3>
-          { renderProgressBarsForCollections() }
-          { renderProgressBarForZipping() }
+          {renderProgressBarsForCollections()}
+          {renderProgressBarForZipping()}
         </div>
-      ) }
+      )}
 
       <div className="mt-5">
         <h3 className="mb-3">{t('export_management.exported_data_list')}</h3>

+ 0 - 2
apps/app/src/client/components/Admin/ForbiddenPage.tsx

@@ -1,9 +1,7 @@
 import React, { type JSX } from 'react';
-
 import DefaultErrorPage from 'next/error';
 import { useTranslation } from 'react-i18next';
 
-
 export const ForbiddenPage = (): JSX.Element => {
   const { t } = useTranslation('admin');
 

+ 4 - 2
apps/app/src/client/components/Admin/FullTextSearchManagement.tsx

@@ -1,5 +1,4 @@
 import React, { type JSX } from 'react';
-
 import { useTranslation } from 'next-i18next';
 
 import ElasticsearchManagement from './ElasticsearchManagement/ElasticsearchManagement';
@@ -9,7 +8,10 @@ export const FullTextSearchManagement = (): JSX.Element => {
 
   return (
     <div data-testid="admin-full-text-search">
-      <h2 className="mb-4"> { t('full_text_search_management.elasticsearch_management') } </h2>
+      <h2 className="mb-4">
+        {' '}
+        {t('full_text_search_management.elasticsearch_management')}{' '}
+      </h2>
       <ElasticsearchManagement />
     </div>
   );

+ 103 - 49
apps/app/src/client/components/Admin/G2GDataTransfer.tsx

@@ -1,24 +1,27 @@
-import React, {
-  useCallback, useEffect, useState, type JSX,
-} from 'react';
-
+import React, { type JSX, useCallback, useEffect, useState } from 'react';
 import { useTranslation } from 'next-i18next';
 
 import { useGenerateTransferKey } from '~/client/services/g2g-transfer';
 import { apiv3Get, apiv3Post } from '~/client/util/apiv3-client';
 import { toastError, toastSuccess } from '~/client/util/toastr';
 import { useAdminSocket } from '~/features/admin/states/socket-io';
-import { G2G_PROGRESS_STATUS, type G2GProgress } from '~/interfaces/g2g-transfer';
+import {
+  G2G_PROGRESS_STATUS,
+  type G2GProgress,
+} from '~/interfaces/g2g-transfer';
 import { useGrowiDocumentationUrl } from '~/states/context';
 
 import CustomCopyToClipBoard from '../Common/CustomCopyToClipBoard';
-
 // import { FileUploadSettingMolecule } from './App/FileUploadSetting';
 import G2GDataTransferExportForm from './G2GDataTransferExportForm';
 import G2GDataTransferStatusIcon from './G2GDataTransferStatusIcon';
 
 const IGNORED_COLLECTION_NAMES = [
-  'sessions', 'rlflx', 'activities', 'attachmentFiles.files', 'attachmentFiles.chunks',
+  'sessions',
+  'rlflx',
+  'activities',
+  'attachmentFiles.files',
+  'attachmentFiles.chunks',
 ];
 
 const G2GDataTransfer = (): JSX.Element => {
@@ -27,7 +30,9 @@ const G2GDataTransfer = (): JSX.Element => {
 
   const [startTransferKey, setStartTransferKey] = useState('');
   const [collections, setCollections] = useState<string[]>([]);
-  const [selectedCollections, setSelectedCollections] = useState<Set<string>>(new Set());
+  const [selectedCollections, setSelectedCollections] = useState<Set<string>>(
+    new Set(),
+  );
   const [optionsMap, setOptionsMap] = useState<any>({});
   const [isShowExportForm, setShowExportForm] = useState(false);
   const [isTransferring, setTransferring] = useState(false);
@@ -61,13 +66,18 @@ const G2GDataTransfer = (): JSX.Element => {
     setStartTransferKey(e.target.value);
   }, []);
 
-  const setCollectionsAndSelectedCollections = useCallback(async() => {
-    const { data: collectionsData } = await apiv3Get<{collections: any[]}>('/mongo/collections', {});
+  const setCollectionsAndSelectedCollections = useCallback(async () => {
+    const { data: collectionsData } = await apiv3Get<{ collections: any[] }>(
+      '/mongo/collections',
+      {},
+    );
 
     // filter only not ignored collection names
-    const filteredCollections = collectionsData.collections.filter((collectionName) => {
-      return !IGNORED_COLLECTION_NAMES.includes(collectionName);
-    });
+    const filteredCollections = collectionsData.collections.filter(
+      (collectionName) => {
+        return !IGNORED_COLLECTION_NAMES.includes(collectionName);
+      },
+    );
 
     setCollections(filteredCollections);
     setSelectedCollections(new Set(filteredCollections));
@@ -78,7 +88,10 @@ const G2GDataTransfer = (): JSX.Element => {
       socket.on('admin:g2gProgress', (g2gProgress: G2GProgress) => {
         setG2GProgress(g2gProgress);
 
-        if (g2gProgress.mongo === G2G_PROGRESS_STATUS.COMPLETED && g2gProgress.attachments === G2G_PROGRESS_STATUS.COMPLETED) {
+        if (
+          g2gProgress.mongo === G2G_PROGRESS_STATUS.COMPLETED &&
+          g2gProgress.attachments === G2G_PROGRESS_STATUS.COMPLETED
+        ) {
           toastSuccess(t('admin:g2g:transfer_success'));
         }
       });
@@ -88,7 +101,7 @@ const G2GDataTransfer = (): JSX.Element => {
         toastError(t(key));
       });
     }
-  }, [socket, t, setTransferring, setG2GProgress]);
+  }, [socket, t]);
 
   const cleanUpWebsocketEventHandler = useCallback(() => {
     if (socket != null) {
@@ -99,30 +112,31 @@ const G2GDataTransfer = (): JSX.Element => {
 
   const { transferKey, generateTransferKey } = useGenerateTransferKey();
 
-  const onClickHandler = useCallback(async() => {
+  const onClickHandler = useCallback(async () => {
     try {
       await generateTransferKey();
-    }
-    catch (errs) {
+    } catch (errs) {
       toastError(errs);
     }
   }, [generateTransferKey]);
 
-  const startTransfer = useCallback(async(e) => {
-    e.preventDefault();
-    setTransferring(true);
-
-    try {
-      await apiv3Post('/g2g-transfer/transfer', {
-        transferKey: startTransferKey,
-        collections: Array.from(selectedCollections),
-        optionsMap,
-      });
-    }
-    catch (errs) {
-      toastError(errs);
-    }
-  }, [setTransferring, startTransferKey, selectedCollections, optionsMap]);
+  const startTransfer = useCallback(
+    async (e) => {
+      e.preventDefault();
+      setTransferring(true);
+
+      try {
+        await apiv3Post('/g2g-transfer/transfer', {
+          transferKey: startTransferKey,
+          collections: Array.from(selectedCollections),
+          optionsMap,
+        });
+      } catch (errs) {
+        toastError(errs);
+      }
+    },
+    [startTransferKey, selectedCollections, optionsMap],
+  );
 
   const documentationUrl = useGrowiDocumentationUrl();
 
@@ -173,7 +187,6 @@ const G2GDataTransfer = (): JSX.Element => {
   //   setGcsUploadNamespace(val);
   // }, []);
 
-
   useEffect(() => {
     setCollectionsAndSelectedCollections();
     setupWebsocketEventHandler();
@@ -181,13 +194,24 @@ const G2GDataTransfer = (): JSX.Element => {
     return () => {
       cleanUpWebsocketEventHandler();
     };
-  }, [setCollectionsAndSelectedCollections, setupWebsocketEventHandler, cleanUpWebsocketEventHandler]);
+  }, [
+    setCollectionsAndSelectedCollections,
+    setupWebsocketEventHandler,
+    cleanUpWebsocketEventHandler,
+  ]);
 
   return (
     <div data-testid="admin-export-archive-data">
-      <h2 className="border-bottom">{t('admin:g2g_data_transfer.transfer_data_to_another_growi')}</h2>
-
-      <button type="button" className="btn btn-outline-secondary mt-4" disabled={isTransferring} onClick={() => setShowExportForm(!isShowExportForm)}>
+      <h2 className="border-bottom">
+        {t('admin:g2g_data_transfer.transfer_data_to_another_growi')}
+      </h2>
+
+      <button
+        type="button"
+        className="btn btn-outline-secondary mt-4"
+        disabled={isTransferring}
+        onClick={() => setShowExportForm(!isShowExportForm)}
+      >
         {t('admin:g2g_data_transfer.advanced_options')}
       </button>
 
@@ -243,7 +267,9 @@ const G2GDataTransfer = (): JSX.Element => {
             />
           </div>
           <div className="col-3">
-            <button type="submit" className="btn btn-primary w-100">{t('admin:g2g_data_transfer.start_transfer')}</button>
+            <button type="submit" className="btn btn-primary w-100">
+              {t('admin:g2g_data_transfer.start_transfer')}
+            </button>
           </div>
         </div>
       </form>
@@ -251,38 +277,66 @@ const G2GDataTransfer = (): JSX.Element => {
       {isTransferring && (
         <div className="border rounded p-4">
           <div className="my-2">
-            <G2GDataTransferStatusIcon className="me-2" status={g2gProgress.mongo} /> MongoDB
+            <G2GDataTransferStatusIcon
+              className="me-2"
+              status={g2gProgress.mongo}
+            />{' '}
+            MongoDB
           </div>
           <div className="my-2">
-            <G2GDataTransferStatusIcon className="me-2" status={g2gProgress.attachments} /> Attachments
+            <G2GDataTransferStatusIcon
+              className="me-2"
+              status={g2gProgress.attachments}
+            />{' '}
+            Attachments
           </div>
         </div>
       )}
 
-      <h2 className="border-bottom mt-5">{t('commons:g2g_data_transfer.transfer_data_to_this_growi')}</h2>
+      <h2 className="border-bottom mt-5">
+        {t('commons:g2g_data_transfer.transfer_data_to_this_growi')}
+      </h2>
 
       <div className="row mt-4">
         <div className="col-md-3">
-          <button type="button" className="btn btn-primary w-100" onClick={onClickHandler}>
+          <button
+            type="button"
+            className="btn btn-primary w-100"
+            onClick={onClickHandler}
+          >
             {t('commons:g2g_data_transfer.publish_transfer_key')}
           </button>
         </div>
         <div className="col-md-9">
           <div className=" mx-1">
-            <input className="form-control" type="text" value={transferKey} readOnly />
-            <CustomCopyToClipBoard textToBeCopied={transferKey} message="admin:slack_integration.copied_to_clipboard" />
+            <input
+              className="form-control"
+              type="text"
+              value={transferKey}
+              readOnly
+            />
+            <CustomCopyToClipBoard
+              textToBeCopied={transferKey}
+              message="admin:slack_integration.copied_to_clipboard"
+            />
           </div>
         </div>
       </div>
 
       <div className="alert alert-warning mt-4">
-        <p className="mb-1">{t('commons:g2g_data_transfer.transfer_key_limit')}</p>
-        <p className="mb-1">{t('commons:g2g_data_transfer.once_transfer_key_used')}</p>
+        <p className="mb-1">
+          {t('commons:g2g_data_transfer.transfer_key_limit')}
+        </p>
+        <p className="mb-1">
+          {t('commons:g2g_data_transfer.once_transfer_key_used')}
+        </p>
         <p
           className="mb-0"
-          // eslint-disable-next-line react/no-danger
+          // biome-ignore lint/security/noDangerouslySetInnerHtml: translation contains HTML link
           dangerouslySetInnerHTML={{
-            __html: t('commons:g2g_data_transfer.transfer_to_growi_cloud', { documentationUrl }),
+            __html: t('commons:g2g_data_transfer.transfer_to_growi_cloud', {
+              documentationUrl,
+            }),
           }}
         />
       </div>

+ 309 - 136
apps/app/src/client/components/Admin/G2GDataTransferExportForm.tsx

@@ -1,7 +1,10 @@
 import React, {
-  useState, useEffect, useCallback, useMemo, type JSX,
+  type JSX,
+  useCallback,
+  useEffect,
+  useMemo,
+  useState,
 } from 'react';
-
 import { useTranslation } from 'next-i18next';
 
 import { GrowiArchiveImportOption } from '~/models/admin/growi-archive-import-option';
@@ -9,41 +12,210 @@ import { ImportOptionForPages } from '~/models/admin/import-option-for-pages';
 import { ImportOptionForRevisions } from '~/models/admin/import-option-for-revisions';
 
 import ImportCollectionConfigurationModal from './ImportData/GrowiArchive/ImportCollectionConfigurationModal';
-import ImportCollectionItem, { DEFAULT_MODE, MODE_RESTRICTED_COLLECTION } from './ImportData/GrowiArchive/ImportCollectionItem';
+import ImportCollectionItem, {
+  DEFAULT_MODE,
+  MODE_RESTRICTED_COLLECTION,
+} from './ImportData/GrowiArchive/ImportCollectionItem';
 
-const GROUPS_PAGE = [
-  'pages', 'revisions', 'tags', 'pagetagrelations',
-];
+const GROUPS_PAGE = ['pages', 'revisions', 'tags', 'pagetagrelations'];
 const GROUPS_USER = [
-  'users', 'externalaccounts', 'usergroups', 'usergrouprelations',
-];
-const GROUPS_CONFIG = [
-  'configs', 'updateposts', 'globalnotificationsettings',
+  'users',
+  'externalaccounts',
+  'usergroups',
+  'usergrouprelations',
 ];
-const ALL_GROUPED_COLLECTIONS = GROUPS_PAGE.concat(GROUPS_USER).concat(GROUPS_CONFIG);
-
-const IMPORT_OPTION_CLASS_MAPPING: Record<string, typeof GrowiArchiveImportOption> = {
+const GROUPS_CONFIG = ['configs', 'updateposts', 'globalnotificationsettings'];
+const ALL_GROUPED_COLLECTIONS =
+  GROUPS_PAGE.concat(GROUPS_USER).concat(GROUPS_CONFIG);
+
+const IMPORT_OPTION_CLASS_MAPPING: Record<
+  string,
+  typeof GrowiArchiveImportOption
+> = {
   pages: ImportOptionForPages,
   revisions: ImportOptionForRevisions,
 };
 
 type Props = {
-  allCollectionNames: string[],
-  selectedCollections: Set<string>,
-  updateSelectedCollections: (newSelectedCollections: Set<string>) => void,
-  optionsMap: any,
-  updateOptionsMap: (newOptionsMap: any) => void,
+  allCollectionNames: string[];
+  selectedCollections: Set<string>;
+  updateSelectedCollections: (newSelectedCollections: Set<string>) => void;
+  optionsMap: any;
+  updateOptionsMap: (newOptionsMap: any) => void;
+};
+
+type ImportItemsProps = {
+  collectionNames: string[];
+  selectedCollections: Set<string>;
+  optionsMap: Record<string, GrowiArchiveImportOption>;
+  onToggleCollection: (collectionName: string, isChecked: boolean) => void;
+  onOptionChange: (collectionName: string, data: any) => void;
+  onConfigButtonClicked: (collectionName: string) => void;
+};
+
+const ImportItems = ({
+  collectionNames,
+  selectedCollections,
+  optionsMap,
+  onToggleCollection,
+  onOptionChange,
+  onConfigButtonClicked,
+}: ImportItemsProps): JSX.Element => {
+  return (
+    <div className="row">
+      {collectionNames.map((collectionName) => {
+        const isConfigButtonAvailable = Object.keys(
+          IMPORT_OPTION_CLASS_MAPPING,
+        ).includes(collectionName);
+
+        if (optionsMap[collectionName] == null) {
+          return null;
+        }
+
+        return (
+          <div className="col-md-6 my-1" key={collectionName}>
+            <ImportCollectionItem
+              isImporting={false}
+              isImported={false}
+              insertedCount={0}
+              modifiedCount={0}
+              errorsCount={0}
+              collectionName={collectionName}
+              isSelected={selectedCollections.has(collectionName)}
+              option={optionsMap[collectionName]}
+              isConfigButtonAvailable={isConfigButtonAvailable}
+              // events
+              onChange={onToggleCollection}
+              onOptionChange={onOptionChange}
+              onConfigButtonClicked={onConfigButtonClicked}
+              // TODO: show progress
+              isHideProgress
+            />
+          </div>
+        );
+      })}
+    </div>
+  );
+};
+
+type WarnForGroupsProps = {
+  errors: Error[];
+};
+
+const WarnForGroups = ({ errors }: WarnForGroupsProps): JSX.Element => {
+  if (errors.length === 0) {
+    return <></>;
+  }
+
+  return (
+    <div className="alert alert-warning">
+      <ul>
+        {errors.map((error, index) => {
+          return <li key={`${error.message}-${index}`}>{error.message}</li>;
+        })}
+      </ul>
+    </div>
+  );
+};
+
+type GroupImportItemsProps = {
+  groupList: string[];
+  groupName: string;
+  errors: Error[];
+  allCollectionNames: string[];
+  selectedCollections: Set<string>;
+  optionsMap: Record<string, GrowiArchiveImportOption>;
+  onToggleCollection: (collectionName: string, isChecked: boolean) => void;
+  onOptionChange: (collectionName: string, data: any) => void;
+  onConfigButtonClicked: (collectionName: string) => void;
+};
+
+const GroupImportItems = ({
+  groupList,
+  groupName,
+  errors,
+  allCollectionNames,
+  selectedCollections,
+  optionsMap,
+  onToggleCollection,
+  onOptionChange,
+  onConfigButtonClicked,
+}: GroupImportItemsProps): JSX.Element => {
+  const collectionNames = groupList.filter((groupCollectionName) => {
+    return allCollectionNames.includes(groupCollectionName);
+  });
+
+  if (collectionNames.length === 0) {
+    return <></>;
+  }
+
+  return (
+    <div className="mt-4">
+      <legend>{groupName} Collections</legend>
+      <ImportItems
+        collectionNames={collectionNames}
+        selectedCollections={selectedCollections}
+        optionsMap={optionsMap}
+        onToggleCollection={onToggleCollection}
+        onOptionChange={onOptionChange}
+        onConfigButtonClicked={onConfigButtonClicked}
+      />
+      <WarnForGroups errors={errors} />
+    </div>
+  );
+};
+
+type OtherImportItemsProps = {
+  allCollectionNames: string[];
+  selectedCollections: Set<string>;
+  optionsMap: Record<string, GrowiArchiveImportOption>;
+  onToggleCollection: (collectionName: string, isChecked: boolean) => void;
+  onOptionChange: (collectionName: string, data: any) => void;
+  onConfigButtonClicked: (collectionName: string) => void;
+};
+
+const OtherImportItems = ({
+  allCollectionNames,
+  selectedCollections,
+  optionsMap,
+  onToggleCollection,
+  onOptionChange,
+  onConfigButtonClicked,
+}: OtherImportItemsProps): JSX.Element => {
+  const collectionNames = allCollectionNames.filter((collectionName) => {
+    return !ALL_GROUPED_COLLECTIONS.includes(collectionName);
+  });
+
+  // TODO: エラー対応
+  return (
+    <GroupImportItems
+      groupList={collectionNames}
+      groupName="Other"
+      errors={[]}
+      allCollectionNames={allCollectionNames}
+      selectedCollections={selectedCollections}
+      optionsMap={optionsMap}
+      onToggleCollection={onToggleCollection}
+      onOptionChange={onOptionChange}
+      onConfigButtonClicked={onConfigButtonClicked}
+    />
+  );
 };
 
 const G2GDataTransferExportForm = (props: Props): JSX.Element => {
   const { t } = useTranslation('admin');
 
   const {
-    allCollectionNames, selectedCollections, updateSelectedCollections, optionsMap, updateOptionsMap,
+    allCollectionNames,
+    selectedCollections,
+    updateSelectedCollections,
+    optionsMap,
+    updateOptionsMap,
   } = props;
 
   const [isConfigurationModalOpen, setConfigurationModalOpen] = useState(false);
-  const [collectionNameForConfiguration, setCollectionNameForConfiguration] = useState<any>();
+  const [collectionNameForConfiguration, setCollectionNameForConfiguration] =
+    useState<any>();
 
   const checkAll = useCallback(() => {
     updateSelectedCollections(new Set(allCollectionNames));
@@ -53,26 +225,28 @@ const G2GDataTransferExportForm = (props: Props): JSX.Element => {
     updateSelectedCollections(new Set());
   }, [updateSelectedCollections]);
 
-  const updateOption = useCallback((collectionName, data) => {
-    const options = optionsMap[collectionName];
+  const updateOption = useCallback(
+    (collectionName, data) => {
+      const options = optionsMap[collectionName];
 
-    // merge
-    Object.assign(options, data);
+      // merge
+      Object.assign(options, data);
 
-    const updatedOptionsMap = {};
-    updatedOptionsMap[collectionName] = options;
-    updateOptionsMap((prev) => {
-      return { ...prev, updatedOptionsMap };
-    });
-  }, [optionsMap, updateOptionsMap]);
+      const updatedOptionsMap = {};
+      updatedOptionsMap[collectionName] = options;
+      updateOptionsMap((prev) => {
+        return { ...prev, updatedOptionsMap };
+      });
+    },
+    [optionsMap, updateOptionsMap],
+  );
 
-  const ImportItems = ({ collectionNames }): JSX.Element => {
-    const toggleCheckbox = (collectionName, bool) => {
+  const toggleCheckbox = useCallback(
+    (collectionName, bool) => {
       const collections = new Set(selectedCollections);
       if (bool) {
         collections.add(collectionName);
-      }
-      else {
+      } else {
         collections.delete(collectionName);
       }
 
@@ -80,90 +254,14 @@ const G2GDataTransferExportForm = (props: Props): JSX.Element => {
 
       // TODO: validation
       // this.validate();
-    };
-
-    const openConfigurationModal = (collectionName) => {
-      setConfigurationModalOpen(true);
-      setCollectionNameForConfiguration(collectionName);
-    };
-
-    return (
-      <div className="row">
-        {collectionNames.map((collectionName) => {
-          const isConfigButtonAvailable = Object.keys(IMPORT_OPTION_CLASS_MAPPING).includes(collectionName);
-
-          if (optionsMap[collectionName] == null) {
-            return null;
-          }
-
-          return (
-            <div className="col-md-6 my-1" key={collectionName}>
-              <ImportCollectionItem
-                isImporting={false}
-                isImported={false}
-                insertedCount={0}
-                modifiedCount={0}
-                errorsCount={0}
-                collectionName={collectionName}
-                isSelected={selectedCollections.has(collectionName)}
-                option={optionsMap[collectionName]}
-                isConfigButtonAvailable={isConfigButtonAvailable}
-                // events
-                onChange={toggleCheckbox}
-                onOptionChange={updateOption}
-                onConfigButtonClicked={openConfigurationModal}
-                // TODO: show progress
-                isHideProgress
-              />
-            </div>
-          );
-        })}
-      </div>
-    );
-  };
-
-  const WarnForGroups = ({ errors }: { errors: Error[] }): JSX.Element => {
-    if (errors.length === 0) {
-      return <></>;
-    }
-
-    return (
-      <div className="alert alert-warning">
-        <ul>
-          {errors.map((error) => {
-            return <li>{error.message}</li>;
-          })}
-        </ul>
-      </div>
-    );
-  };
-
-  const GroupImportItems = ({ groupList, groupName, errors }): JSX.Element => {
-    const collectionNames = groupList.filter((groupCollectionName) => {
-      return allCollectionNames.includes(groupCollectionName);
-    });
-
-    if (collectionNames.length === 0) {
-      return <></>;
-    }
-
-    return (
-      <div className="mt-4">
-        <legend>{groupName} Collections</legend>
-        <ImportItems collectionNames={collectionNames} />
-        <WarnForGroups errors={errors} />
-      </div>
-    );
-  };
-
-  const OtherImportItems = (): JSX.Element => {
-    const collectionNames = allCollectionNames.filter((collectionName) => {
-      return !ALL_GROUPED_COLLECTIONS.includes(collectionName);
-    });
+    },
+    [selectedCollections, updateSelectedCollections],
+  );
 
-    // TODO: エラー対応
-    return <GroupImportItems groupList={collectionNames} groupName="Other" errors={[]} />;
-  };
+  const openConfigurationModal = useCallback((collectionName) => {
+    setConfigurationModalOpen(true);
+    setCollectionNameForConfiguration(collectionName);
+  }, []);
 
   const configurationModal = useMemo(() => {
     if (collectionNameForConfiguration == null) {
@@ -179,55 +277,130 @@ const G2GDataTransferExportForm = (props: Props): JSX.Element => {
         option={optionsMap[collectionNameForConfiguration]}
       />
     );
-  }, [collectionNameForConfiguration, isConfigurationModalOpen, optionsMap, updateOption]);
+  }, [
+    collectionNameForConfiguration,
+    isConfigurationModalOpen,
+    optionsMap,
+    updateOption,
+  ]);
 
   const setInitialOptionsMap = useCallback(() => {
     const initialOptionsMap = {};
     allCollectionNames.forEach((collectionName) => {
-      const initialMode = (MODE_RESTRICTED_COLLECTION[collectionName] != null)
-        ? MODE_RESTRICTED_COLLECTION[collectionName][0]
-        : DEFAULT_MODE;
-      const ImportOption = IMPORT_OPTION_CLASS_MAPPING[collectionName] || GrowiArchiveImportOption;
-      initialOptionsMap[collectionName] = new ImportOption(collectionName, initialMode);
+      const initialMode =
+        MODE_RESTRICTED_COLLECTION[collectionName] != null
+          ? MODE_RESTRICTED_COLLECTION[collectionName][0]
+          : DEFAULT_MODE;
+      const ImportOption =
+        IMPORT_OPTION_CLASS_MAPPING[collectionName] || GrowiArchiveImportOption;
+      initialOptionsMap[collectionName] = new ImportOption(
+        collectionName,
+        initialMode,
+      );
     });
     updateOptionsMap(initialOptionsMap);
   }, [allCollectionNames, updateOptionsMap]);
 
   useEffect(() => {
     setInitialOptionsMap();
-  }, []);
+  }, [setInitialOptionsMap]);
 
   return (
     <>
       <form className="mt-3 row row-cols-lg-auto g-3 align-items-center">
         <div className="col-12">
-          <button type="button" className="btn btn-sm btn-outline-secondary me-2" onClick={checkAll}>
-            <span className="material-symbols-outlined">check_box</span>, {t('admin:export_management.check_all')}
+          <button
+            type="button"
+            className="btn btn-sm btn-outline-secondary me-2"
+            onClick={checkAll}
+          >
+            <span className="material-symbols-outlined">check_box</span>,{' '}
+            {t('admin:export_management.check_all')}
           </button>
         </div>
         <div className="col-12">
-          <button type="button" className="btn btn-sm btn-outline-secondary me-2" onClick={uncheckAll}>
-            <span className="material-symbols-outlined">check_box_outline_blank</span> {t('admin:export_management.uncheck_all')}
+          <button
+            type="button"
+            className="btn btn-sm btn-outline-secondary me-2"
+            onClick={uncheckAll}
+          >
+            <span className="material-symbols-outlined">
+              check_box_outline_blank
+            </span>{' '}
+            {t('admin:export_management.uncheck_all')}
           </button>
         </div>
       </form>
 
       <div className="card custom-card small my-4">
         <ul>
-          <li>{t('admin:importer_management.growi_settings.description_of_import_mode.about')}</li>
+          <li>
+            {t(
+              'admin:importer_management.growi_settings.description_of_import_mode.about',
+            )}
+          </li>
           <ul>
-            <li>{t('admin:importer_management.growi_settings.description_of_import_mode.insert')}</li>
-            <li>{t('admin:importer_management.growi_settings.description_of_import_mode.upsert')}</li>
-            <li>{t('admin:importer_management.growi_settings.description_of_import_mode.flash_and_insert')}</li>
+            <li>
+              {t(
+                'admin:importer_management.growi_settings.description_of_import_mode.insert',
+              )}
+            </li>
+            <li>
+              {t(
+                'admin:importer_management.growi_settings.description_of_import_mode.upsert',
+              )}
+            </li>
+            <li>
+              {t(
+                'admin:importer_management.growi_settings.description_of_import_mode.flash_and_insert',
+              )}
+            </li>
           </ul>
         </ul>
       </div>
 
       {/* TODO: エラー追加 */}
-      <GroupImportItems groupList={GROUPS_PAGE} groupName="Page" errors={[]} />
-      <GroupImportItems groupList={GROUPS_USER} groupName="User" errors={[]} />
-      <GroupImportItems groupList={GROUPS_CONFIG} groupName="Config" errors={[]} />
-      <OtherImportItems />
+      <GroupImportItems
+        groupList={GROUPS_PAGE}
+        groupName="Page"
+        errors={[]}
+        allCollectionNames={allCollectionNames}
+        selectedCollections={selectedCollections}
+        optionsMap={optionsMap}
+        onToggleCollection={toggleCheckbox}
+        onOptionChange={updateOption}
+        onConfigButtonClicked={openConfigurationModal}
+      />
+      <GroupImportItems
+        groupList={GROUPS_USER}
+        groupName="User"
+        errors={[]}
+        allCollectionNames={allCollectionNames}
+        selectedCollections={selectedCollections}
+        optionsMap={optionsMap}
+        onToggleCollection={toggleCheckbox}
+        onOptionChange={updateOption}
+        onConfigButtonClicked={openConfigurationModal}
+      />
+      <GroupImportItems
+        groupList={GROUPS_CONFIG}
+        groupName="Config"
+        errors={[]}
+        allCollectionNames={allCollectionNames}
+        selectedCollections={selectedCollections}
+        optionsMap={optionsMap}
+        onToggleCollection={toggleCheckbox}
+        onOptionChange={updateOption}
+        onConfigButtonClicked={openConfigurationModal}
+      />
+      <OtherImportItems
+        allCollectionNames={allCollectionNames}
+        selectedCollections={selectedCollections}
+        optionsMap={optionsMap}
+        onToggleCollection={toggleCheckbox}
+        onOptionChange={updateOption}
+        onConfigButtonClicked={openConfigurationModal}
+      />
 
       {configurationModal}
     </>

+ 49 - 10
apps/app/src/client/components/Admin/G2GDataTransferStatusIcon.tsx

@@ -1,46 +1,85 @@
 import React, { type ComponentPropsWithoutRef, type JSX } from 'react';
-
 import { LoadingSpinner } from '@growi/ui/dist/components';
 
-import { G2G_PROGRESS_STATUS, type G2GProgressStatus } from '~/interfaces/g2g-transfer';
-
+import {
+  G2G_PROGRESS_STATUS,
+  type G2GProgressStatus,
+} from '~/interfaces/g2g-transfer';
 
 /**
  * Props for {@link G2GDataTransferStatusIcon}
  */
-interface Props extends ComponentPropsWithoutRef<'span'>{
+interface Props extends ComponentPropsWithoutRef<'span'> {
   status: G2GProgressStatus;
 }
 
 /**
  * Icon for G2G transfer status
  */
-const G2GDataTransferStatusIcon = ({ status, className, ...props }: Props): JSX.Element => {
+const G2GDataTransferStatusIcon = ({
+  status,
+  className,
+  ...props
+}: Props): JSX.Element => {
   if (status === G2G_PROGRESS_STATUS.IN_PROGRESS) {
     return (
-      <LoadingSpinner className={`${className}`} aria-label="in progress" {...props} />
+      <LoadingSpinner
+        className={`${className}`}
+        aria-label="in progress"
+        {...props}
+      />
     );
   }
 
   if (status === G2G_PROGRESS_STATUS.COMPLETED) {
     return (
-      <span className={`material-symbols-outlined text-info ${className}`} aria-label="completed" {...props}>check_circle</span>
+      <span
+        className={`material-symbols-outlined text-info ${className}`}
+        role="img"
+        aria-label="completed"
+        {...props}
+      >
+        check_circle
+      </span>
     );
   }
 
   if (status === G2G_PROGRESS_STATUS.ERROR) {
     return (
-      <span className={`material-symbols-outlined text-danger ${className}`} aria-label="error" {...props}>error</span>
+      <span
+        className={`material-symbols-outlined text-danger ${className}`}
+        role="img"
+        aria-label="error"
+        {...props}
+      >
+        error
+      </span>
     );
   }
 
   if (status === G2G_PROGRESS_STATUS.SKIPPED) {
     return (
-      <span className={`material-symbols-outlined ${className}`} aria-label="skipped" {...props}>block</span>
+      <span
+        className={`material-symbols-outlined ${className}`}
+        role="img"
+        aria-label="skipped"
+        {...props}
+      >
+        block
+      </span>
     );
   }
 
-  return <span className={`material-symbols-outlined ${className}`} aria-label="pending" {...props}>circle</span>;
+  return (
+    <span
+      className={`material-symbols-outlined ${className}`}
+      role="img"
+      aria-label="pending"
+      {...props}
+    >
+      circle
+    </span>
+  );
 };
 
 export default G2GDataTransferStatusIcon;

+ 12 - 7
apps/app/src/client/components/Admin/ImportData/GrowiArchive/ErrorViewer.tsx

@@ -1,12 +1,11 @@
 import React, { type JSX } from 'react';
-
-import { Modal, ModalHeader, ModalBody } from 'reactstrap';
+import { Modal, ModalBody, ModalHeader } from 'reactstrap';
 
 type ErrorViewerProps = {
-  isOpen: boolean,
-  errors: any[],
-  onClose: () => void,
-}
+  isOpen: boolean;
+  errors: any[];
+  onClose: () => void;
+};
 
 const ErrorViewer = (props: ErrorViewerProps): JSX.Element => {
   const { errors } = props;
@@ -25,7 +24,13 @@ const ErrorViewer = (props: ErrorViewerProps): JSX.Element => {
         Errors
       </ModalHeader>
       <ModalBody>
-        <textarea className="form-control" rows={8} readOnly wrap="off" defaultValue={value}></textarea>
+        <textarea
+          className="form-control"
+          rows={8}
+          readOnly
+          wrap="off"
+          defaultValue={value}
+        ></textarea>
       </ModalBody>
     </Modal>
   );

+ 106 - 36
apps/app/src/client/components/Admin/ImportData/GrowiArchive/ImportCollectionConfigurationModal.jsx

@@ -1,23 +1,15 @@
 /* eslint-disable react/no-danger */
 
 import React from 'react';
-
 import { useTranslation } from 'next-i18next';
 import PropTypes from 'prop-types';
-import {
-  Modal,
-  ModalHeader,
-  ModalBody,
-  ModalFooter,
-} from 'reactstrap';
+import { Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
 
 import { GrowiArchiveImportOption } from '~/models/admin/growi-archive-import-option';
 
 // import { toastSuccess, toastError } from '~/client/util/toastr';
 
-
 class ImportCollectionConfigurationModal extends React.Component {
-
   constructor(props) {
     super(props);
 
@@ -46,9 +38,7 @@ class ImportCollectionConfigurationModal extends React.Component {
   }
 
   updateOption() {
-    const {
-      collectionName, onOptionChange, onClose,
-    } = this.props;
+    const { collectionName, onOptionChange, onClose } = this.props;
 
     if (onOptionChange != null) {
       onOptionChange(collectionName, this.state.option);
@@ -61,7 +51,8 @@ class ImportCollectionConfigurationModal extends React.Component {
     const { t } = this.props;
     const { option } = this.state;
 
-    const translationBase = 'admin:importer_management.growi_settings.configuration.pages';
+    const translationBase =
+      'admin:importer_management.growi_settings.configuration.pages';
 
     /* eslint-disable react/no-unescaped-entities */
     return (
@@ -72,11 +63,22 @@ class ImportCollectionConfigurationModal extends React.Component {
             type="checkbox"
             className="form-check-input"
             checked={option.isOverwriteAuthorWithCurrentUser || false} // add ' || false' to avoid uncontrolled input warning
-            onChange={() => this.changeHandler({ isOverwriteAuthorWithCurrentUser: !option.isOverwriteAuthorWithCurrentUser })}
+            onChange={() =>
+              this.changeHandler({
+                isOverwriteAuthorWithCurrentUser:
+                  !option.isOverwriteAuthorWithCurrentUser,
+              })
+            }
           />
           <label htmlFor="cbOpt4" className="form-label form-check-label">
             {t(`${translationBase}.overwrite_author.label`)}
-            <p className="form-text text-muted mt-0" dangerouslySetInnerHTML={{ __html: t(`${translationBase}.overwrite_author.desc`) }} />
+            <p
+              className="form-text text-muted mt-0"
+              // biome-ignore lint/security/noDangerouslySetInnerHtml: translation contains HTML markup
+              dangerouslySetInnerHTML={{
+                __html: t(`${translationBase}.overwrite_author.desc`),
+              }}
+            />
           </label>
         </div>
         <div className="form-check form-check-warning">
@@ -85,13 +87,24 @@ class ImportCollectionConfigurationModal extends React.Component {
             type="checkbox"
             className="form-check-input"
             checked={option.makePublicForGrant2 || false} // add ' || false' to avoid uncontrolled input warning
-            onChange={() => this.changeHandler({ makePublicForGrant2: !option.makePublicForGrant2 })}
+            onChange={() =>
+              this.changeHandler({
+                makePublicForGrant2: !option.makePublicForGrant2,
+              })
+            }
           />
           <label htmlFor="cbOpt1" className="form-label form-check-label">
-            {t(`${translationBase}.set_public_to_page.label`, { from: t('Anyone with the link') })}
+            {t(`${translationBase}.set_public_to_page.label`, {
+              from: t('Anyone with the link'),
+            })}
             <p
               className="form-text text-muted mt-0"
-              dangerouslySetInnerHTML={{ __html: t(`${translationBase}.set_public_to_page.desc`, { from: t('Anyone with the link') }) }}
+              // biome-ignore lint/security/noDangerouslySetInnerHtml: translation contains HTML markup
+              dangerouslySetInnerHTML={{
+                __html: t(`${translationBase}.set_public_to_page.desc`, {
+                  from: t('Anyone with the link'),
+                }),
+              }}
             />
           </label>
         </div>
@@ -101,13 +114,24 @@ class ImportCollectionConfigurationModal extends React.Component {
             type="checkbox"
             className="form-check-input"
             checked={option.makePublicForGrant4 || false} // add ' || false' to avoid uncontrolled input warning
-            onChange={() => this.changeHandler({ makePublicForGrant4: !option.makePublicForGrant4 })}
+            onChange={() =>
+              this.changeHandler({
+                makePublicForGrant4: !option.makePublicForGrant4,
+              })
+            }
           />
           <label htmlFor="cbOpt2" className="form-label form-check-label">
-            {t(`${translationBase}.set_public_to_page.label`, { from: t('Only me') })}
+            {t(`${translationBase}.set_public_to_page.label`, {
+              from: t('Only me'),
+            })}
             <p
               className="form-text text-muted mt-0"
-              dangerouslySetInnerHTML={{ __html: t(`${translationBase}.set_public_to_page.desc`, { from: t('Only me') }) }}
+              // biome-ignore lint/security/noDangerouslySetInnerHtml: translation contains HTML markup
+              dangerouslySetInnerHTML={{
+                __html: t(`${translationBase}.set_public_to_page.desc`, {
+                  from: t('Only me'),
+                }),
+              }}
             />
           </label>
         </div>
@@ -117,13 +141,24 @@ class ImportCollectionConfigurationModal extends React.Component {
             type="checkbox"
             className="form-check-input"
             checked={option.makePublicForGrant5 || false} // add ' || false' to avoid uncontrolled input warning
-            onChange={() => this.changeHandler({ makePublicForGrant5: !option.makePublicForGrant5 })}
+            onChange={() =>
+              this.changeHandler({
+                makePublicForGrant5: !option.makePublicForGrant5,
+              })
+            }
           />
           <label htmlFor="cbOpt3" className="form-label form-check-label">
-            {t(`${translationBase}.set_public_to_page.label`, { from: t('Only inside the group') })}
+            {t(`${translationBase}.set_public_to_page.label`, {
+              from: t('Only inside the group'),
+            })}
             <p
               className="form-text text-muted mt-0"
-              dangerouslySetInnerHTML={{ __html: t(`${translationBase}.set_public_to_page.desc`, { from: t('Only inside the group') }) }}
+              // biome-ignore lint/security/noDangerouslySetInnerHtml: translation contains HTML markup
+              dangerouslySetInnerHTML={{
+                __html: t(`${translationBase}.set_public_to_page.desc`, {
+                  from: t('Only inside the group'),
+                }),
+              }}
             />
           </label>
         </div>
@@ -133,11 +168,21 @@ class ImportCollectionConfigurationModal extends React.Component {
             type="checkbox"
             className="form-check-input"
             checked={option.initPageMetadatas || false} // add ' || false' to avoid uncontrolled input warning
-            onChange={() => this.changeHandler({ initPageMetadatas: !option.initPageMetadatas })}
+            onChange={() =>
+              this.changeHandler({
+                initPageMetadatas: !option.initPageMetadatas,
+              })
+            }
           />
           <label htmlFor="cbOpt5" className="form-label form-check-label">
             {t(`${translationBase}.initialize_meta_datas.label`)}
-            <p className="form-text text-muted mt-0" dangerouslySetInnerHTML={{ __html: t(`${translationBase}.initialize_meta_datas.desc`) }} />
+            <p
+              className="form-text text-muted mt-0"
+              // biome-ignore lint/security/noDangerouslySetInnerHtml: translation contains HTML markup
+              dangerouslySetInnerHTML={{
+                __html: t(`${translationBase}.initialize_meta_datas.desc`),
+              }}
+            />
           </label>
         </div>
       </>
@@ -149,7 +194,8 @@ class ImportCollectionConfigurationModal extends React.Component {
     const { t } = this.props;
     const { option } = this.state;
 
-    const translationBase = 'admin:importer_management.growi_settings.configuration.revisions';
+    const translationBase =
+      'admin:importer_management.growi_settings.configuration.revisions';
 
     /* eslint-disable react/no-unescaped-entities */
     return (
@@ -160,11 +206,22 @@ class ImportCollectionConfigurationModal extends React.Component {
             type="checkbox"
             className="form-check-input"
             checked={option.isOverwriteAuthorWithCurrentUser || false} // add ' || false' to avoid uncontrolled input warning
-            onChange={() => this.changeHandler({ isOverwriteAuthorWithCurrentUser: !option.isOverwriteAuthorWithCurrentUser })}
+            onChange={() =>
+              this.changeHandler({
+                isOverwriteAuthorWithCurrentUser:
+                  !option.isOverwriteAuthorWithCurrentUser,
+              })
+            }
           />
           <label htmlFor="cbOpt1" className="form-label form-check-label">
             {t(`${translationBase}.overwrite_author.label`)}
-            <p className="form-text text-muted mt-0" dangerouslySetInnerHTML={{ __html: t(`${translationBase}.overwrite_author.desc`) }} />
+            <p
+              className="form-text text-muted mt-0"
+              // biome-ignore lint/security/noDangerouslySetInnerHtml: translation contains HTML markup
+              dangerouslySetInnerHTML={{
+                __html: t(`${translationBase}.overwrite_author.desc`),
+              }}
+            />
           </label>
         </div>
       </>
@@ -189,23 +246,36 @@ class ImportCollectionConfigurationModal extends React.Component {
     }
 
     return (
-      <Modal isOpen={this.props.isOpen} toggle={this.props.onClose} onEnter={this.initialize}>
+      <Modal
+        isOpen={this.props.isOpen}
+        toggle={this.props.onClose}
+        onEnter={this.initialize}
+      >
         <ModalHeader tag="h4" toggle={this.props.onClose} className="text-info">
           {`'${collectionName}'`} Configuration
         </ModalHeader>
 
-        <ModalBody>
-          {contents}
-        </ModalBody>
+        <ModalBody>{contents}</ModalBody>
 
         <ModalFooter>
-          <button type="button" className="btn btn-sm btn-outline-secondary" onClick={this.props.onClose}>{t('Cancel')}</button>
-          <button type="button" className="btn btn-sm btn-primary" onClick={this.updateOption}>{t('Update')}</button>
+          <button
+            type="button"
+            className="btn btn-sm btn-outline-secondary"
+            onClick={this.props.onClose}
+          >
+            {t('Cancel')}
+          </button>
+          <button
+            type="button"
+            className="btn btn-sm btn-primary"
+            onClick={this.updateOption}
+          >
+            {t('Update')}
+          </button>
         </ModalFooter>
       </Modal>
     );
   }
-
 }
 
 ImportCollectionConfigurationModal.propTypes = {

+ 86 - 34
apps/app/src/client/components/Admin/ImportData/GrowiArchive/ImportCollectionItem.jsx

@@ -1,17 +1,23 @@
 import React from 'react';
-
 import PropTypes from 'prop-types';
 import {
-  Progress, UncontrolledDropdown, DropdownToggle, DropdownMenu, DropdownItem,
+  DropdownItem,
+  DropdownMenu,
+  DropdownToggle,
+  Progress,
+  UncontrolledDropdown,
 } from 'reactstrap';
 
 import { GrowiArchiveImportOption } from '~/models/admin/growi-archive-import-option';
 
-
 const MODE_ATTR_MAP = {
   insert: { color: 'info', icon: 'add_circle', label: 'Insert' },
   upsert: { color: 'success', icon: 'add_circle', label: 'Upsert' },
-  flushAndInsert: { color: 'danger', icon: 'autorenew', label: 'Flush and Insert' },
+  flushAndInsert: {
+    color: 'danger',
+    icon: 'autorenew',
+    label: 'Flush and Insert',
+  },
 };
 
 export const DEFAULT_MODE = 'insert';
@@ -23,13 +29,13 @@ export const MODE_RESTRICTED_COLLECTION = {
 };
 
 export default class ImportCollectionItem extends React.Component {
-
   constructor(props) {
     super(props);
 
     this.changeHandler = this.changeHandler.bind(this);
     this.modeSelectedHandler = this.modeSelectedHandler.bind(this);
-    this.configButtonClickedHandler = this.configButtonClickedHandler.bind(this);
+    this.configButtonClickedHandler =
+      this.configButtonClickedHandler.bind(this);
     this.errorLinkClickedHandler = this.errorLinkClickedHandler.bind(this);
   }
 
@@ -76,13 +82,16 @@ export default class ImportCollectionItem extends React.Component {
   renderModeLabel(mode, isColorized = false) {
     const attrMap = MODE_ATTR_MAP[mode];
     const className = isColorized ? `text-${attrMap.color}` : '';
-    return <span className={`text-nowrap ${className}`}><span className="material-symbols-outlined">{attrMap.icon}</span> {attrMap.label}</span>;
+    return (
+      <span className={`text-nowrap ${className}`}>
+        <span className="material-symbols-outlined">{attrMap.icon}</span>{' '}
+        {attrMap.label}
+      </span>
+    );
   }
 
   renderCheckbox() {
-    const {
-      collectionName, isSelected, isImporting,
-    } = this.props;
+    const { collectionName, isSelected, isImporting } = this.props;
 
     return (
       <div className="form-check form-check-info my-0">
@@ -96,7 +105,10 @@ export default class ImportCollectionItem extends React.Component {
           disabled={isImporting}
           onChange={this.changeHandler}
         />
-        <label className="form-label text-capitalize form-check-label" htmlFor={collectionName}>
+        <label
+          className="form-label text-capitalize form-check-label"
+          htmlFor={collectionName}
+        >
           {collectionName}
         </label>
       </div>
@@ -104,22 +116,26 @@ export default class ImportCollectionItem extends React.Component {
   }
 
   renderModeSelector() {
-    const {
-      collectionName, option, isImporting,
-    } = this.props;
+    const { collectionName, option, isImporting } = this.props;
     const currentMode = option?.mode || 'insert';
     const attrMap = MODE_ATTR_MAP[currentMode];
-    const modes = MODE_RESTRICTED_COLLECTION[collectionName] || Object.keys(MODE_ATTR_MAP);
+    const modes =
+      MODE_RESTRICTED_COLLECTION[collectionName] || Object.keys(MODE_ATTR_MAP);
 
     return (
       <span className="d-inline-flex align-items-center">
         Mode:&nbsp;
         <UncontrolledDropdown size="sm" className="d-inline-block">
-          <DropdownToggle color={attrMap.color} caret disabled={isImporting} id={`ddmMode-${collectionName}`}>
+          <DropdownToggle
+            color={attrMap.color}
+            caret
+            disabled={isImporting}
+            id={`ddmMode-${collectionName}`}
+          >
             {this.renderModeLabel(currentMode)}
           </DropdownToggle>
           <DropdownMenu>
-            {modes.map(mode => (
+            {modes.map((mode) => (
               <DropdownItem
                 key={`buttonMode_${mode}`}
                 onClick={() => this.modeSelectedHandler(mode)}
@@ -141,7 +157,9 @@ export default class ImportCollectionItem extends React.Component {
         type="button"
         className="btn btn-outline-secondary btn-sm p-1 ms-2"
         disabled={isImporting || !isConfigButtonAvailable}
-        onClick={isConfigButtonAvailable ? this.configButtonClickedHandler : null}
+        onClick={
+          isConfigButtonAvailable ? this.configButtonClickedHandler : null
+        }
       >
         <span className="material-symbols-outlined">settings</span>
       </button>
@@ -149,17 +167,37 @@ export default class ImportCollectionItem extends React.Component {
   }
 
   renderProgressBar() {
-    const {
-      isImporting, insertedCount, modifiedCount, errorsCount,
-    } = this.props;
+    const { isImporting, insertedCount, modifiedCount, errorsCount } =
+      this.props;
 
     const total = insertedCount + modifiedCount + errorsCount;
 
     return (
       <Progress multi className="mb-0">
-        <Progress bar max={total} color="info" striped={isImporting} animated={isImporting} value={insertedCount} />
-        <Progress bar max={total} color="success" striped={isImporting} animated={isImporting} value={modifiedCount} />
-        <Progress bar max={total} color="danger" striped={isImporting} animated={isImporting} value={errorsCount} />
+        <Progress
+          bar
+          max={total}
+          color="info"
+          striped={isImporting}
+          animated={isImporting}
+          value={insertedCount}
+        />
+        <Progress
+          bar
+          max={total}
+          color="success"
+          striped={isImporting}
+          animated={isImporting}
+          value={modifiedCount}
+        />
+        <Progress
+          bar
+          max={total}
+          color="danger"
+          striped={isImporting}
+          animated={isImporting}
+          value={errorsCount}
+        />
       </Progress>
     );
   }
@@ -174,20 +212,35 @@ export default class ImportCollectionItem extends React.Component {
     const { insertedCount, modifiedCount, errorsCount } = this.props;
     return (
       <div className="w-100 text-center">
-        <span className="text-info"><strong>{insertedCount}</strong> Inserted</span>,&nbsp;
-        <span className="text-success"><strong>{modifiedCount}</strong> Modified</span>,&nbsp;
-        { errorsCount > 0
-          ? <a className="text-danger" role="button" onClick={this.errorLinkClickedHandler}><u><strong>{errorsCount}</strong> Failed</u></a>
-          : <span className="text-muted"><strong>0</strong> Failed</span>
-        }
+        <span className="text-info">
+          <strong>{insertedCount}</strong> Inserted
+        </span>
+        ,&nbsp;
+        <span className="text-success">
+          <strong>{modifiedCount}</strong> Modified
+        </span>
+        ,&nbsp;
+        {errorsCount > 0 ? (
+          <button
+            type="button"
+            className="btn btn-link text-danger p-0"
+            onClick={this.errorLinkClickedHandler}
+          >
+            <u>
+              <strong>{errorsCount}</strong> Failed
+            </u>
+          </button>
+        ) : (
+          <span className="text-muted">
+            <strong>0</strong> Failed
+          </span>
+        )}
       </div>
     );
   }
 
   render() {
-    const {
-      isSelected, isHideProgress,
-    } = this.props;
+    const { isSelected, isHideProgress } = this.props;
 
     return (
       <div className="card border-light">
@@ -211,7 +264,6 @@ export default class ImportCollectionItem extends React.Component {
       </div>
     );
   }
-
 }
 
 ImportCollectionItem.propTypes = {

+ 164 - 76
apps/app/src/client/components/Admin/ImportData/GrowiArchive/ImportForm.jsx

@@ -1,31 +1,31 @@
 import React from 'react';
-
 import { useTranslation } from 'next-i18next';
 import PropTypes from 'prop-types';
 
 import { apiv3Post } from '~/client/util/apiv3-client';
-import { toastSuccess, toastError } from '~/client/util/toastr';
+import { toastError, toastSuccess } from '~/client/util/toastr';
 import { useAdminSocket } from '~/features/admin/states/socket-io';
 import { GrowiArchiveImportOption } from '~/models/admin/growi-archive-import-option';
 import { ImportOptionForPages } from '~/models/admin/import-option-for-pages';
 import { ImportOptionForRevisions } from '~/models/admin/import-option-for-revisions';
 
-
 import ErrorViewer from './ErrorViewer';
 import ImportCollectionConfigurationModal from './ImportCollectionConfigurationModal';
-import ImportCollectionItem, { DEFAULT_MODE, MODE_RESTRICTED_COLLECTION } from './ImportCollectionItem';
+import ImportCollectionItem, {
+  DEFAULT_MODE,
+  MODE_RESTRICTED_COLLECTION,
+} from './ImportCollectionItem';
 
-
-const GROUPS_PAGE = [
-  'pages', 'revisions', 'tags', 'pagetagrelations',
-];
+const GROUPS_PAGE = ['pages', 'revisions', 'tags', 'pagetagrelations'];
 const GROUPS_USER = [
-  'users', 'externalaccounts', 'usergroups', 'usergrouprelations',
+  'users',
+  'externalaccounts',
+  'usergroups',
+  'usergrouprelations',
 ];
-const GROUPS_CONFIG = [
-  'configs', 'updateposts', 'globalnotificationsettings',
-];
-const ALL_GROUPED_COLLECTIONS = GROUPS_PAGE.concat(GROUPS_USER).concat(GROUPS_CONFIG);
+const GROUPS_CONFIG = ['configs', 'updateposts', 'globalnotificationsettings'];
+const ALL_GROUPED_COLLECTIONS =
+  GROUPS_PAGE.concat(GROUPS_USER).concat(GROUPS_CONFIG);
 
 /** @type Record<string, typeof GrowiArchiveImportOption> */
 const IMPORT_OPTION_CLASS_MAPPING = {
@@ -34,7 +34,6 @@ const IMPORT_OPTION_CLASS_MAPPING = {
 };
 
 class ImportForm extends React.Component {
-
   constructor(props) {
     super(props);
 
@@ -69,12 +68,17 @@ class ImportForm extends React.Component {
       this.initialState.collectionNameToFileNameMap[collectionName] = fileName;
 
       // determine initial mode
-      const initialMode = (MODE_RESTRICTED_COLLECTION[collectionName] != null)
-        ? MODE_RESTRICTED_COLLECTION[collectionName][0]
-        : DEFAULT_MODE;
+      const initialMode =
+        MODE_RESTRICTED_COLLECTION[collectionName] != null
+          ? MODE_RESTRICTED_COLLECTION[collectionName][0]
+          : DEFAULT_MODE;
       // create GrowiArchiveImportOption instance
-      const ImportOption = IMPORT_OPTION_CLASS_MAPPING[collectionName] || GrowiArchiveImportOption;
-      this.initialState.optionsMap[collectionName] = new ImportOption(collectionName, initialMode);
+      const ImportOption =
+        IMPORT_OPTION_CLASS_MAPPING[collectionName] || GrowiArchiveImportOption;
+      this.initialState.optionsMap[collectionName] = new ImportOption(
+        collectionName,
+        initialMode,
+      );
     });
 
     this.state = this.initialState;
@@ -93,6 +97,7 @@ class ImportForm extends React.Component {
     return Object.keys(this.state.collectionNameToFileNameMap);
   }
 
+  // biome-ignore lint/correctness/noNestedComponentDefinitions: lifecycle method on a class component
   UNSAFE_componentWillMount() {
     this.setupWebsocketEventHandler();
   }
@@ -106,21 +111,24 @@ class ImportForm extends React.Component {
 
     // websocket event
     // eslint-disable-next-line object-curly-newline
-    socket.on('admin:onProgressForImport', ({ collectionName, collectionProgress, appendedErrors }) => {
-      const { progressMap, errorsMap } = this.state;
-      progressMap[collectionName] = collectionProgress;
-
-      if (appendedErrors != null) {
-        const errors = errorsMap[collectionName] || [];
-        errorsMap[collectionName] = errors.concat(appendedErrors);
-      }
-
-      this.setState({
-        isImporting: true,
-        progressMap,
-        errorsMap,
-      });
-    });
+    socket.on(
+      'admin:onProgressForImport',
+      ({ collectionName, collectionProgress, appendedErrors }) => {
+        const { progressMap, errorsMap } = this.state;
+        progressMap[collectionName] = collectionProgress;
+
+        if (appendedErrors != null) {
+          const errors = errorsMap[collectionName] || [];
+          errorsMap[collectionName] = errors.concat(appendedErrors);
+        }
+
+        this.setState({
+          isImporting: true,
+          progressMap,
+          errorsMap,
+        });
+      },
+    );
 
     // websocket event
     socket.on('admin:onTerminateForImport', () => {
@@ -154,8 +162,7 @@ class ImportForm extends React.Component {
     const selectedCollections = new Set(this.state.selectedCollections);
     if (bool) {
       selectedCollections.add(collectionName);
-    }
-    else {
+    } else {
       selectedCollections.delete(collectionName);
     }
 
@@ -165,7 +172,9 @@ class ImportForm extends React.Component {
   }
 
   async checkAll() {
-    await this.setState({ selectedCollections: new Set(this.allCollectionNames) });
+    await this.setState({
+      selectedCollections: new Set(this.allCollectionNames),
+    });
     this.validate();
   }
 
@@ -186,11 +195,17 @@ class ImportForm extends React.Component {
   }
 
   openConfigurationModal(collectionName) {
-    this.setState({ isConfigurationModalOpen: true, collectionNameForConfiguration: collectionName });
+    this.setState({
+      isConfigurationModalOpen: true,
+      collectionNameForConfiguration: collectionName,
+    });
   }
 
   showErrorsViewer(collectionName) {
-    this.setState({ isErrorsViewerOpen: true, collectionNameForErrorsViewer: collectionName });
+    this.setState({
+      isErrorsViewerOpen: true,
+      collectionNameForErrorsViewer: collectionName,
+    });
   }
 
   async validate() {
@@ -224,7 +239,9 @@ class ImportForm extends React.Component {
     const { warnForOtherGroups, selectedCollections } = this.state;
 
     if (selectedCollections.size === 0) {
-      warnForOtherGroups.push(t('admin:importer_management.growi_settings.errors.at_least_one'));
+      warnForOtherGroups.push(
+        t('admin:importer_management.growi_settings.errors.at_least_one'),
+      );
     }
 
     this.setState({ warnForOtherGroups });
@@ -234,13 +251,20 @@ class ImportForm extends React.Component {
     const { t } = this.props;
     const { warnForPageGroups, selectedCollections } = this.state;
 
-    const pageRelatedCollectionsLength = ['pages', 'revisions'].filter((collectionName) => {
-      return selectedCollections.has(collectionName);
-    }).length;
+    const pageRelatedCollectionsLength = ['pages', 'revisions'].filter(
+      (collectionName) => {
+        return selectedCollections.has(collectionName);
+      },
+    ).length;
 
     // MUST be included both or neither when importing
-    if (pageRelatedCollectionsLength !== 0 && pageRelatedCollectionsLength !== 2) {
-      warnForPageGroups.push(t('admin:importer_management.growi_settings.errors.page_and_revision'));
+    if (
+      pageRelatedCollectionsLength !== 0 &&
+      pageRelatedCollectionsLength !== 2
+    ) {
+      warnForPageGroups.push(
+        t('admin:importer_management.growi_settings.errors.page_and_revision'),
+      );
     }
 
     this.setState({ warnForPageGroups });
@@ -253,7 +277,12 @@ class ImportForm extends React.Component {
     // MUST include also 'users' if 'externalaccounts' is selected
     if (selectedCollections.has('externalaccounts')) {
       if (!selectedCollections.has('users')) {
-        warnForUserGroups.push(t('admin:importer_management.growi_settings.errors.depends', { target: 'Users', condition: 'Externalaccounts' }));
+        warnForUserGroups.push(
+          t('admin:importer_management.growi_settings.errors.depends', {
+            target: 'Users',
+            condition: 'Externalaccounts',
+          }),
+        );
       }
     }
 
@@ -267,7 +296,12 @@ class ImportForm extends React.Component {
     // MUST include also 'users' if 'usergroups' is selected
     if (selectedCollections.has('usergroups')) {
       if (!selectedCollections.has('users')) {
-        warnForUserGroups.push(t('admin:importer_management.growi_settings.errors.depends', { target: 'Users', condition: 'Usergroups' }));
+        warnForUserGroups.push(
+          t('admin:importer_management.growi_settings.errors.depends', {
+            target: 'Users',
+            condition: 'Usergroups',
+          }),
+        );
       }
     }
 
@@ -281,7 +315,12 @@ class ImportForm extends React.Component {
     // MUST include also 'usergroups' if 'usergrouprelations' is selected
     if (selectedCollections.has('usergrouprelations')) {
       if (!selectedCollections.has('usergroups')) {
-        warnForUserGroups.push(t('admin:importer_management.growi_settings.errors.depends', { target: 'Usergroups', condition: 'Usergrouprelations' }));
+        warnForUserGroups.push(
+          t('admin:importer_management.growi_settings.errors.depends', {
+            target: 'Usergroups',
+            condition: 'Usergrouprelations',
+          }),
+        );
       }
     }
 
@@ -289,9 +328,7 @@ class ImportForm extends React.Component {
   }
 
   async import() {
-    const {
-      fileName, onPostImport, t,
-    } = this.props;
+    const { fileName, onPostImport, t } = this.props;
     const { selectedCollections, optionsMap } = this.state;
 
     // init progress data
@@ -314,8 +351,7 @@ class ImportForm extends React.Component {
       }
 
       toastSuccess(undefined, 'Import process has requested.');
-    }
-    catch (err) {
+    } catch (err) {
       if (err.code === 'only_upsert_available') {
         toastError(t('admin:importer_management.error.only_upsert_available'));
       }
@@ -331,9 +367,8 @@ class ImportForm extends React.Component {
     return (
       <div key={key} className="alert alert-warning">
         <ul>
-          {errors.map((error, index) => {
-            // eslint-disable-next-line react/no-array-index-key
-            return <li key={`${key}-${index}`}>{error}</li>;
+          {errors.map((error) => {
+            return <li key={`${key}-${String(error)}`}>{error}</li>;
           })}
         </ul>
       </div>
@@ -363,7 +398,11 @@ class ImportForm extends React.Component {
       return !ALL_GROUPED_COLLECTIONS.includes(collectionName);
     });
 
-    return this.renderGroups(collectionNames, 'Other', this.state.warnForOtherGroups);
+    return this.renderGroups(
+      collectionNames,
+      'Other',
+      this.state.warnForOtherGroups,
+    );
   }
 
   renderImportItems(collectionNames) {
@@ -382,15 +421,21 @@ class ImportForm extends React.Component {
         {collectionNames.map((collectionName) => {
           const collectionProgress = progressMap[collectionName];
           const errorsCount = errorsMap[collectionName]?.length ?? 0;
-          const isConfigButtonAvailable = Object.keys(IMPORT_OPTION_CLASS_MAPPING).includes(collectionName);
+          const isConfigButtonAvailable = Object.keys(
+            IMPORT_OPTION_CLASS_MAPPING,
+          ).includes(collectionName);
 
           return (
             <div className="col-md-6 my-1" key={collectionName}>
               <ImportCollectionItem
                 isImporting={isImporting}
                 isImported={collectionProgress ? isImported : false}
-                insertedCount={collectionProgress ? collectionProgress.insertedCount : 0}
-                modifiedCount={collectionProgress ? collectionProgress.modifiedCount : 0}
+                insertedCount={
+                  collectionProgress ? collectionProgress.insertedCount : 0
+                }
+                modifiedCount={
+                  collectionProgress ? collectionProgress.modifiedCount : 0
+                }
                 errorsCount={errorsCount}
                 collectionName={collectionName}
                 isSelected={selectedCollections.has(collectionName)}
@@ -410,7 +455,11 @@ class ImportForm extends React.Component {
   }
 
   renderConfigurationModal() {
-    const { isConfigurationModalOpen, collectionNameForConfiguration: collectionName, optionsMap } = this.state;
+    const {
+      isConfigurationModalOpen,
+      collectionNameForConfiguration: collectionName,
+      optionsMap,
+    } = this.state;
 
     if (collectionName == null) {
       return null;
@@ -428,7 +477,8 @@ class ImportForm extends React.Component {
   }
 
   renderErrorsViewer() {
-    const { isErrorsViewerOpen, errorsMap, collectionNameForErrorsViewer } = this.state;
+    const { isErrorsViewerOpen, errorsMap, collectionNameForErrorsViewer } =
+      this.state;
     const errors = errorsMap[collectionNameForErrorsViewer];
 
     return (
@@ -443,32 +493,63 @@ class ImportForm extends React.Component {
   render() {
     const { t } = this.props;
     const {
-      canImport, isImporting,
-      warnForPageGroups, warnForUserGroups, warnForConfigGroups,
+      canImport,
+      isImporting,
+      warnForPageGroups,
+      warnForUserGroups,
+      warnForConfigGroups,
     } = this.state;
 
     return (
       <>
         <form className="row row-cols-lg-auto g-3 align-items-center">
           <div className="col-12">
-            <button type="button" className="btn btn-sm btn-outline-secondary me-2" onClick={this.checkAll}>
-              <span className="material-symbols-outlined">check_box</span> {t('admin:export_management.check_all')}
+            <button
+              type="button"
+              className="btn btn-sm btn-outline-secondary me-2"
+              onClick={this.checkAll}
+            >
+              <span className="material-symbols-outlined">check_box</span>{' '}
+              {t('admin:export_management.check_all')}
             </button>
           </div>
           <div className="col-12">
-            <button type="button" className="btn btn-sm btn-outline-secondary me-2" onClick={this.uncheckAll}>
-              <span className="material-symbols-outlined">check_box_outline_blank</span> {t('admin:export_management.uncheck_all')}
+            <button
+              type="button"
+              className="btn btn-sm btn-outline-secondary me-2"
+              onClick={this.uncheckAll}
+            >
+              <span className="material-symbols-outlined">
+                check_box_outline_blank
+              </span>{' '}
+              {t('admin:export_management.uncheck_all')}
             </button>
           </div>
         </form>
 
         <div className="card custom-card small my-4">
           <ul>
-            <li>{t('admin:importer_management.growi_settings.description_of_import_mode.about')}</li>
+            <li>
+              {t(
+                'admin:importer_management.growi_settings.description_of_import_mode.about',
+              )}
+            </li>
             <ul>
-              <li>{t('admin:importer_management.growi_settings.description_of_import_mode.insert')}</li>
-              <li>{t('admin:importer_management.growi_settings.description_of_import_mode.upsert')}</li>
-              <li>{t('admin:importer_management.growi_settings.description_of_import_mode.flash_and_insert')}</li>
+              <li>
+                {t(
+                  'admin:importer_management.growi_settings.description_of_import_mode.insert',
+                )}
+              </li>
+              <li>
+                {t(
+                  'admin:importer_management.growi_settings.description_of_import_mode.upsert',
+                )}
+              </li>
+              <li>
+                {t(
+                  'admin:importer_management.growi_settings.description_of_import_mode.flash_and_insert',
+                )}
+              </li>
             </ul>
           </ul>
         </div>
@@ -479,10 +560,19 @@ class ImportForm extends React.Component {
         {this.renderOthers()}
 
         <div className="mt-4 text-center">
-          <button type="button" className="btn btn-outline-secondary mx-1" onClick={this.props.onDiscard}>
+          <button
+            type="button"
+            className="btn btn-outline-secondary mx-1"
+            onClick={this.props.onDiscard}
+          >
             {t('admin:importer_management.growi_settings.discard')}
           </button>
-          <button type="button" className="btn btn-primary mx-1" onClick={this.import} disabled={!canImport || isImporting}>
+          <button
+            type="button"
+            className="btn btn-primary mx-1"
+            onClick={this.import}
+            disabled={!canImport || isImporting}
+          >
             {t('admin:importer_management.import')}
           </button>
         </div>
@@ -492,7 +582,6 @@ class ImportForm extends React.Component {
       </>
     );
   }
-
 }
 
 ImportForm.propTypes = {
@@ -516,5 +605,4 @@ const ImportFormWrapperFc = (props) => {
   return <ImportForm t={t} socket={socket} {...props} />;
 };
 
-
 export default ImportFormWrapperFc;

+ 21 - 15
apps/app/src/client/components/Admin/ImportData/GrowiArchive/UploadForm.jsx

@@ -1,5 +1,4 @@
 import React from 'react';
-
 import { useTranslation } from 'next-i18next';
 import PropTypes from 'prop-types';
 
@@ -7,7 +6,6 @@ import { apiv3PostForm } from '~/client/util/apiv3-client';
 import { toastError } from '~/client/util/toastr';
 
 class UploadForm extends React.Component {
-
   constructor(props) {
     super(props);
 
@@ -33,14 +31,12 @@ class UploadForm extends React.Component {
     try {
       const { data } = await apiv3PostForm('/import/upload', formData);
       this.props.onUpload(data);
-    }
-    catch (err) {
+    } catch (err) {
       if (err[0].code === 'versions-are-not-met') {
         if (this.props.onVersionMismatch !== null) {
           this.props.onVersionMismatch(err[0].code);
         }
-      }
-      else {
+      } else {
         toastError(err);
       }
     }
@@ -48,9 +44,9 @@ class UploadForm extends React.Component {
 
   validateForm() {
     return (
-      this.inputRef.current // null check
-      && this.inputRef.current.files[0] // null check
-      && /\.zip$/.test(this.inputRef.current.files[0].name) // validate extension
+      this.inputRef.current && // null check
+      this.inputRef.current.files[0] && // null check
+      /\.zip$/.test(this.inputRef.current.files[0].name) // validate extension
     );
   }
 
@@ -61,7 +57,10 @@ class UploadForm extends React.Component {
       <form onSubmit={this.uploadZipFile}>
         <fieldset>
           <div className="row">
-            <label htmlFor="file" className="col-md-3 col-form-label col-form-label-sm">
+            <label
+              htmlFor="file"
+              className="col-md-3 col-form-label col-form-label-sm"
+            >
               {t('admin:importer_management.growi_settings.growi_archive_file')}
             </label>
             <div className="col-md-6">
@@ -76,12 +75,20 @@ class UploadForm extends React.Component {
           </div>
           <div className="row">
             <div className="mt-4 text-center">
-              { this.props.onDiscard && (
-                <button type="button" className="btn btn-outline-secondary mx-1" onClick={this.props.onDiscard}>
+              {this.props.onDiscard && (
+                <button
+                  type="button"
+                  className="btn btn-outline-secondary mx-1"
+                  onClick={this.props.onDiscard}
+                >
                   {t('admin:importer_management.growi_settings.discard')}
                 </button>
-              ) }
-              <button type="submit" className="btn btn-primary" disabled={!this.validateForm()}>
+              )}
+              <button
+                type="submit"
+                className="btn btn-primary"
+                disabled={!this.validateForm()}
+              >
                 {t('admin:importer_management.growi_settings.upload')}
               </button>
             </div>
@@ -90,7 +97,6 @@ class UploadForm extends React.Component {
       </form>
     );
   }
-
 }
 
 UploadForm.propTypes = {

+ 25 - 24
apps/app/src/client/components/Admin/ImportData/GrowiArchiveSection.jsx

@@ -1,17 +1,14 @@
 import React, { Fragment } from 'react';
-
 import { useTranslation } from 'next-i18next';
 import PropTypes from 'prop-types';
 
 import { apiv3Delete, apiv3Get } from '~/client/util/apiv3-client';
 import { toastError, toastSuccess } from '~/client/util/toastr';
 
-
 import ImportForm from './GrowiArchive/ImportForm';
 import UploadForm from './GrowiArchive/UploadForm';
 
 class GrowiArchiveSection extends React.Component {
-
   constructor(props) {
     super(props);
 
@@ -27,9 +24,11 @@ class GrowiArchiveSection extends React.Component {
     this.discardData = this.discardData.bind(this);
     this.resetState = this.resetState.bind(this);
     this.handleMismatchedVersions = this.handleMismatchedVersions.bind(this);
-    this.renderDefferentVersionAlert = this.renderDefferentVersionAlert.bind(this);
+    this.renderDefferentVersionAlert =
+      this.renderDefferentVersionAlert.bind(this);
   }
 
+  // biome-ignore lint/correctness/noNestedComponentDefinitions: lifecycle method on a class component
   async UNSAFE_componentWillMount() {
     // get uploaded file status
     const res = await apiv3Get('/import/status');
@@ -42,9 +41,7 @@ class GrowiArchiveSection extends React.Component {
     }
   }
 
-  handleUpload({
-    meta, fileName, innerFileStats,
-  }) {
+  handleUpload({ meta, fileName, innerFileStats }) {
     this.setState({
       fileName,
       innerFileStats,
@@ -59,18 +56,15 @@ class GrowiArchiveSection extends React.Component {
       this.resetState();
 
       toastSuccess(`Deleted ${fileName}`);
-    }
-    catch (err) {
+    } catch (err) {
       toastError(err);
     }
   }
 
-
   handleMismatchedVersions(err) {
     this.setState({
       isTheSameVersion: false,
     });
-
   }
 
   renderDefferentVersionAlert() {
@@ -92,17 +86,24 @@ class GrowiArchiveSection extends React.Component {
 
     return (
       <Fragment>
-        <h2 className="mb-3">{t('importer_management.import_growi_archive')}</h2>
+        <h2 className="mb-3">
+          {t('importer_management.import_growi_archive')}
+        </h2>
         <div className="card custom-card bg-body-tertiary mb-4 small">
           <ul>
-            <li>{t('importer_management.skip_username_and_email_when_overlapped')}</li>
-            <li>{t('importer_management.prepare_new_account_for_migration')}</li>
+            <li>
+              {t('importer_management.skip_username_and_email_when_overlapped')}
+            </li>
+            <li>
+              {t('importer_management.prepare_new_account_for_migration')}
+            </li>
             <li>
               <a
                 href={`${t('importer_management.admin_archive_data_import_guide_url')}`}
                 target="_blank"
                 rel="noopener noreferrer"
-              >{t('importer_management.archive_data_import_detail')}
+              >
+                {t('importer_management.archive_data_import_detail')}
               </a>
             </li>
           </ul>
@@ -117,18 +118,18 @@ class GrowiArchiveSection extends React.Component {
               onDiscard={this.discardData}
             />
           </div>
-        )
-          : (
-            <UploadForm
-              onUpload={this.handleUpload}
-              onDiscard={this.state.fileName != null ? this.discardData : undefined}
-              onVersionMismatch={this.handleMismatchedVersions}
-            />
-          )}
+        ) : (
+          <UploadForm
+            onUpload={this.handleUpload}
+            onDiscard={
+              this.state.fileName != null ? this.discardData : undefined
+            }
+            onVersionMismatch={this.handleMismatchedVersions}
+          />
+        )}
       </Fragment>
     );
   }
-
 }
 
 GrowiArchiveSection.propTypes = {

+ 4 - 2
apps/app/src/client/components/Admin/ImportData/ImportDataPageContents.jsx

@@ -1,5 +1,4 @@
 import { withUnstatedContainers } from '../../UnstatedUtils';
-
 import GrowiArchiveSection from './GrowiArchiveSection';
 
 const ImportDataPageContents = () => {
@@ -13,6 +12,9 @@ const ImportDataPageContents = () => {
 /**
  * Wrapper component for using unstated
  */
-const ImportDataPageContentsWrapper = withUnstatedContainers(ImportDataPageContents, []);
+const ImportDataPageContentsWrapper = withUnstatedContainers(
+  ImportDataPageContents,
+  [],
+);
 
 export default ImportDataPageContentsWrapper;

+ 25 - 15
apps/app/src/client/components/Admin/LegacySlackIntegration/LegacySlackIntegration.jsx

@@ -1,5 +1,4 @@
 import React, { useEffect } from 'react';
-
 import { useTranslation } from 'next-i18next';
 import PropTypes from 'prop-types';
 
@@ -9,8 +8,6 @@ import { toArrayIfNot } from '~/utils/array-utils';
 import loggerFactory from '~/utils/logger';
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
-
-
 import SlackConfiguration from './SlackConfiguration';
 
 const logger = loggerFactory('growi:NotificationSetting');
@@ -19,39 +16,47 @@ const LegacySlackIntegration = (props) => {
   const { t } = useTranslation();
   const { adminSlackIntegrationLegacyContainer } = props;
 
-
   useEffect(() => {
-    const fetchLegacySlackIntegrationData = async() => {
+    const fetchLegacySlackIntegrationData = async () => {
       await adminSlackIntegrationLegacyContainer.retrieveData();
     };
 
     try {
       fetchLegacySlackIntegrationData();
-    }
-    catch (err) {
+    } catch (err) {
       const errs = toArrayIfNot(err);
       toastError(errs);
       logger.error(errs);
     }
   }, [adminSlackIntegrationLegacyContainer]);
 
-
-  const isDisabled = adminSlackIntegrationLegacyContainer.state.isSlackbotConfigured;
+  const isDisabled =
+    adminSlackIntegrationLegacyContainer.state.isSlackbotConfigured;
 
   return (
     <div data-testid="admin-slack-integration-legacy">
-      { isDisabled && (
+      {isDisabled && (
         <div className="alert alert-danger">
           <span className="material-symbols-outlined">remove</span>
           {/* eslint-disable-next-line react/no-danger */}
-          <span dangerouslySetInnerHTML={{ __html: t('admin:slack_integration_legacy.alert_disabled') }}></span>
+          <span
+            // biome-ignore lint/security/noDangerouslySetInnerHtml: translation contains HTML markup
+            dangerouslySetInnerHTML={{
+              __html: t('admin:slack_integration_legacy.alert_disabled'),
+            }}
+          ></span>
         </div>
-      ) }
+      )}
 
       <div className="alert alert-warning">
         <span className="material-symbols-outlined">info</span>
         {/* eslint-disable-next-line react/no-danger */}
-        <span dangerouslySetInnerHTML={{ __html: t('admin:slack_integration_legacy.alert_deplicated') }}></span>
+        <span
+          // biome-ignore lint/security/noDangerouslySetInnerHtml: translation contains HTML markup
+          dangerouslySetInnerHTML={{
+            __html: t('admin:slack_integration_legacy.alert_deplicated'),
+          }}
+        ></span>
       </div>
 
       <SlackConfiguration />
@@ -59,10 +64,15 @@ const LegacySlackIntegration = (props) => {
   );
 };
 
-const LegacySlackIntegrationWithUnstatedContainer = withUnstatedContainers(LegacySlackIntegration, [AdminSlackIntegrationLegacyContainer]);
+const LegacySlackIntegrationWithUnstatedContainer = withUnstatedContainers(
+  LegacySlackIntegration,
+  [AdminSlackIntegrationLegacyContainer],
+);
 
 LegacySlackIntegration.propTypes = {
-  adminSlackIntegrationLegacyContainer: PropTypes.instanceOf(AdminSlackIntegrationLegacyContainer).isRequired,
+  adminSlackIntegrationLegacyContainer: PropTypes.instanceOf(
+    AdminSlackIntegrationLegacyContainer,
+  ).isRequired,
 };
 
 export default LegacySlackIntegrationWithUnstatedContainer;

+ 151 - 64
apps/app/src/client/components/Admin/LegacySlackIntegration/SlackConfiguration.jsx

@@ -1,11 +1,10 @@
 import React, { useCallback, useEffect } from 'react';
-
 import { useTranslation } from 'next-i18next';
 import PropTypes from 'prop-types';
 import { useForm } from 'react-hook-form';
 
 import AdminSlackIntegrationLegacyContainer from '~/client/services/AdminSlackIntegrationLegacyContainer';
-import { toastSuccess, toastError } from '~/client/util/toastr';
+import { toastError, toastSuccess } from '~/client/util/toastr';
 import loggerFactory from '~/utils/logger';
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
@@ -15,7 +14,8 @@ const logger = loggerFactory('growi:slackAppConfiguration');
 
 const SlackConfiguration = (props) => {
   const { t, adminSlackIntegrationLegacyContainer } = props;
-  const { webhookUrl, slackToken, retrieveError } = adminSlackIntegrationLegacyContainer.state;
+  const { webhookUrl, slackToken, retrieveError } =
+    adminSlackIntegrationLegacyContainer.state;
 
   const { register, handleSubmit, reset } = useForm();
 
@@ -27,18 +27,24 @@ const SlackConfiguration = (props) => {
     });
   }, [reset, webhookUrl, slackToken]);
 
-  const onClickSubmit = useCallback(async(data) => {
-    try {
-      await adminSlackIntegrationLegacyContainer.changeWebhookUrl(data.webhookUrl ?? '');
-      await adminSlackIntegrationLegacyContainer.changeSlackToken(data.slackToken ?? '');
-      await adminSlackIntegrationLegacyContainer.updateSlackAppConfiguration();
-      toastSuccess(t('notification_settings.updated_slackApp'));
-    }
-    catch (err) {
-      toastError(err);
-      logger.error(err);
-    }
-  }, [adminSlackIntegrationLegacyContainer, t]);
+  const onClickSubmit = useCallback(
+    async (data) => {
+      try {
+        await adminSlackIntegrationLegacyContainer.changeWebhookUrl(
+          data.webhookUrl ?? '',
+        );
+        await adminSlackIntegrationLegacyContainer.changeSlackToken(
+          data.slackToken ?? '',
+        );
+        await adminSlackIntegrationLegacyContainer.updateSlackAppConfiguration();
+        toastSuccess(t('notification_settings.updated_slackApp'));
+      } catch (err) {
+        toastError(err);
+        logger.error(err);
+      }
+    },
+    [adminSlackIntegrationLegacyContainer, t],
+  );
 
   return (
     <form onSubmit={handleSubmit(onClickSubmit)}>
@@ -56,23 +62,54 @@ const SlackConfiguration = (props) => {
               >
                 {`Slack ${adminSlackIntegrationLegacyContainer.state.selectSlackOption}`}
               </button>
-              <div className="dropdown-menu" aria-labelledby="dropdownMenuButton">
-                <button className="dropdown-item" type="button" onClick={() => adminSlackIntegrationLegacyContainer.switchSlackOption('Incoming Webhooks')}>
+              <div
+                className="dropdown-menu"
+                role="menu"
+                aria-labelledby="dropdownMenuButton"
+              >
+                <button
+                  className="dropdown-item"
+                  type="button"
+                  onClick={() =>
+                    adminSlackIntegrationLegacyContainer.switchSlackOption(
+                      'Incoming Webhooks',
+                    )
+                  }
+                >
                   Slack Incoming Webhooks
                 </button>
-                <button className="dropdown-item" type="button" onClick={() => adminSlackIntegrationLegacyContainer.switchSlackOption('App')}>Slack App</button>
+                <button
+                  className="dropdown-item"
+                  type="button"
+                  onClick={() =>
+                    adminSlackIntegrationLegacyContainer.switchSlackOption(
+                      'App',
+                    )
+                  }
+                >
+                  Slack App
+                </button>
               </div>
             </div>
           </div>
         </div>
-        {adminSlackIntegrationLegacyContainer.state.selectSlackOption === 'Incoming Webhooks' ? (
+        {adminSlackIntegrationLegacyContainer.state.selectSlackOption ===
+        'Incoming Webhooks' ? (
           <React.Fragment>
-            <h2 className="border-bottom mb-5">{t('notification_settings.slack_incoming_configuration')}</h2>
+            <h2 className="border-bottom mb-5">
+              {t('notification_settings.slack_incoming_configuration')}
+            </h2>
 
             <div className="row mb-3">
-              <label className="form-label col-md-3 text-start text-md-end">Webhook URL</label>
+              <label
+                className="form-label col-md-3 text-start text-md-end"
+                htmlFor="webhookUrl"
+              >
+                Webhook URL
+              </label>
               <div className="col-md-6">
                 <input
+                  id="webhookUrl"
                   className="form-control"
                   type="text"
                   {...register('webhookUrl')}
@@ -87,10 +124,18 @@ const SlackConfiguration = (props) => {
                     type="checkbox"
                     className="form-check-input"
                     id="cbPrioritizeIWH"
-                    checked={adminSlackIntegrationLegacyContainer.state.isIncomingWebhookPrioritized || false}
-                    onChange={() => { adminSlackIntegrationLegacyContainer.switchIsIncomingWebhookPrioritized() }}
+                    checked={
+                      adminSlackIntegrationLegacyContainer.state
+                        .isIncomingWebhookPrioritized || false
+                    }
+                    onChange={() => {
+                      adminSlackIntegrationLegacyContainer.switchIsIncomingWebhookPrioritized();
+                    }}
                   />
-                  <label className="form-label form-check-label" htmlFor="cbPrioritizeIWH">
+                  <label
+                    className="form-label form-check-label"
+                    htmlFor="cbPrioritizeIWH"
+                  >
                     {t('notification_settings.prioritize_webhook')}
                   </label>
                 </div>
@@ -100,40 +145,61 @@ const SlackConfiguration = (props) => {
               </div>
             </div>
           </React.Fragment>
-        )
-          : (
-            <React.Fragment>
-              <h2 className="border-bottom mb-3">{t('notification_settings.slack_app_configuration')}</h2>
-
-              <div className="card custom-card bg-danger-subtle">
-                <span className="text-danger"><span className="material-symbols-outlined">error</span>NOT RECOMMENDED</span>
-                <br />
-                {/* eslint-disable-next-line react/no-danger */}
-                <span dangerouslySetInnerHTML={{ __html: t('notification_settings.slack_app_configuration_desc') }} />
-                <br />
-                <a
-                  href="#slack-incoming-webhooks"
-                  data-bs-toggle="tab"
-                  onClick={() => adminSlackIntegrationLegacyContainer.switchSlackOption('Incoming Webhooks')}
-                >
-                  {t('notification_settings.use_instead')}
-                </a>
-              </div>
+        ) : (
+          <React.Fragment>
+            <h2 className="border-bottom mb-3">
+              {t('notification_settings.slack_app_configuration')}
+            </h2>
 
-              <div className="row mb-5 mt-4">
-                <label className="form-label col-md-3 text-start text-md-end">OAuth access token</label>
-                <div className="col-md-6">
-                  <input
-                    className="form-control"
-                    type="text"
-                    {...register('slackToken')}
-                  />
-                </div>
-              </div>
+            <div className="card custom-card bg-danger-subtle">
+              <span className="text-danger">
+                <span className="material-symbols-outlined">error</span>NOT
+                RECOMMENDED
+              </span>
+              <br />
+              {/* eslint-disable-next-line react/no-danger */}
+              <span
+                // biome-ignore lint/security/noDangerouslySetInnerHtml: translation contains HTML markup
+                dangerouslySetInnerHTML={{
+                  __html: t(
+                    'notification_settings.slack_app_configuration_desc',
+                  ),
+                }}
+              />
+              <br />
+              <button
+                type="button"
+                className="btn btn-link p-0"
+                data-bs-toggle="tab"
+                data-bs-target="#slack-incoming-webhooks"
+                onClick={() =>
+                  adminSlackIntegrationLegacyContainer.switchSlackOption(
+                    'Incoming Webhooks',
+                  )
+                }
+              >
+                {t('notification_settings.use_instead')}
+              </button>
+            </div>
 
-            </React.Fragment>
-          )
-        }
+            <div className="row mb-5 mt-4">
+              <label
+                className="form-label col-md-3 text-start text-md-end"
+                htmlFor="slackToken"
+              >
+                OAuth access token
+              </label>
+              <div className="col-md-6">
+                <input
+                  id="slackToken"
+                  className="form-control"
+                  type="text"
+                  {...register('slackToken')}
+                />
+              </div>
+            </div>
+          </React.Fragment>
+        )}
 
         <AdminUpdateButtonRow
           disabled={retrieveError != null}
@@ -143,16 +209,28 @@ const SlackConfiguration = (props) => {
         <hr />
 
         <h3>
-          <span className="material-symbols-outlined" aria-hidden="true">help</span>{' '}
-          <a href="#collapseHelpForIwh" data-bs-toggle="collapse">{t('notification_settings.how_to.header')}</a>
+          <span className="material-symbols-outlined" aria-hidden="true">
+            help
+          </span>{' '}
+          <a href="#collapseHelpForIwh" data-bs-toggle="collapse">
+            {t('notification_settings.how_to.header')}
+          </a>
         </h3>
 
-        <ol id="collapseHelpForIwh" className="collapse card custom-card bg-body-tertiary">
+        <ol
+          id="collapseHelpForIwh"
+          className="collapse card custom-card bg-body-tertiary"
+        >
           <li className="ms-3">
             {t('notification_settings.how_to.workspace')}
             <ol>
               {/* eslint-disable-next-line react/no-danger */}
-              <li dangerouslySetInnerHTML={{ __html: t('notification_settings.how_to.workspace_desc1') }} />
+              <li
+                // biome-ignore lint/security/noDangerouslySetInnerHtml: translation contains HTML markup
+                dangerouslySetInnerHTML={{
+                  __html: t('notification_settings.how_to.workspace_desc1'),
+                }}
+              />
               <li>{t('notification_settings.how_to.workspace_desc2')}</li>
               <li>{t('notification_settings.how_to.workspace_desc3')}</li>
             </ol>
@@ -161,11 +239,15 @@ const SlackConfiguration = (props) => {
             {t('notification_settings.how_to.at_growi')}
             <ol>
               {/* eslint-disable-next-line react/no-danger */}
-              <li dangerouslySetInnerHTML={{ __html: t('notification_settings.how_to.at_growi_desc') }} />
+              <li
+                // biome-ignore lint/security/noDangerouslySetInnerHtml: translation contains HTML markup
+                dangerouslySetInnerHTML={{
+                  __html: t('notification_settings.how_to.at_growi_desc'),
+                }}
+              />
             </ol>
           </li>
         </ol>
-
       </React.Fragment>
     </form>
   );
@@ -173,7 +255,9 @@ const SlackConfiguration = (props) => {
 
 SlackConfiguration.propTypes = {
   t: PropTypes.func.isRequired, // i18next
-  adminSlackIntegrationLegacyContainer: PropTypes.instanceOf(AdminSlackIntegrationLegacyContainer).isRequired,
+  adminSlackIntegrationLegacyContainer: PropTypes.instanceOf(
+    AdminSlackIntegrationLegacyContainer,
+  ).isRequired,
 };
 
 const SlackConfigurationWrapperFc = (props) => {
@@ -182,6 +266,9 @@ const SlackConfigurationWrapperFc = (props) => {
   return <SlackConfiguration t={t} {...props} />;
 };
 
-const SlackConfigurationWrapper = withUnstatedContainers(SlackConfigurationWrapperFc, [AdminSlackIntegrationLegacyContainer]);
+const SlackConfigurationWrapper = withUnstatedContainers(
+  SlackConfigurationWrapperFc,
+  [AdminSlackIntegrationLegacyContainer],
+);
 
 export default SlackConfigurationWrapper;

+ 32 - 27
apps/app/src/client/components/Admin/ManageExternalAccount.tsx

@@ -1,34 +1,38 @@
-import React, { useCallback, useEffect, type JSX } from 'react';
-
-import { useTranslation } from 'next-i18next';
+import React, { type JSX, useCallback, useEffect } from 'react';
 import Link from 'next/link';
+import { useTranslation } from 'next-i18next';
 
 import AdminExternalAccountsContainer from '~/client/services/AdminExternalAccountsContainer';
 import { toastError } from '~/client/util/toastr';
 
 import PaginationWrapper from '../PaginationWrapper';
 import { withUnstatedContainers } from '../UnstatedUtils';
-
 import ExternalAccountTable from './Users/ExternalAccountTable';
 
 type ManageExternalAccountProps = {
-  adminExternalAccountsContainer: AdminExternalAccountsContainer,
-}
-
-const ManageExternalAccount = (props: ManageExternalAccountProps): JSX.Element => {
+  adminExternalAccountsContainer: AdminExternalAccountsContainer;
+};
 
+const ManageExternalAccount = (
+  props: ManageExternalAccountProps,
+): JSX.Element => {
   const { t } = useTranslation();
   const { adminExternalAccountsContainer } = props;
-  const { activePage, totalAccounts, pagingLimit } = adminExternalAccountsContainer.state;
+  const { activePage, totalAccounts, pagingLimit } =
+    adminExternalAccountsContainer.state;
 
-  const externalAccountPageHandler = useCallback(async(selectedPage) => {
-    try {
-      await adminExternalAccountsContainer.retrieveExternalAccountsByPagingNum(selectedPage);
-    }
-    catch (err) {
-      toastError(err);
-    }
-  }, [adminExternalAccountsContainer]);
+  const externalAccountPageHandler = useCallback(
+    async (selectedPage) => {
+      try {
+        await adminExternalAccountsContainer.retrieveExternalAccountsByPagingNum(
+          selectedPage,
+        );
+      } catch (err) {
+        toastError(err);
+      }
+    },
+    [adminExternalAccountsContainer],
+  );
 
   // for Next routing
   useEffect(() => {
@@ -54,28 +58,29 @@ const ManageExternalAccount = (props: ManageExternalAccountProps): JSX.Element =
           prefetch={false}
           className="btn btn-outline-secondary"
         >
-          <span className="material-symbols-outlined" aria-hidden="true">arrow_back</span>
+          <span className="material-symbols-outlined" aria-hidden="true">
+            arrow_back
+          </span>
           {t('admin:user_management.back_to_user_management')}
         </Link>
       </p>
       <h2>{t('admin:user_management.external_account_list')}</h2>
-      {(totalAccounts !== 0) ? (
+      {totalAccounts !== 0 ? (
         <>
           {pager}
           <ExternalAccountTable />
           {pager}
         </>
-      )
-        : (
-          <>
-            { t('admin:user_management.external_account_none') }
-          </>
-        )
-      }
+      ) : (
+        <>{t('admin:user_management.external_account_none')}</>
+      )}
     </>
   );
 };
 
-const ManageExternalAccountWrapper = withUnstatedContainers(ManageExternalAccount, [AdminExternalAccountsContainer]);
+const ManageExternalAccountWrapper = withUnstatedContainers(
+  ManageExternalAccount,
+  [AdminExternalAccountsContainer],
+);
 
 export default ManageExternalAccountWrapper;

+ 60 - 25
apps/app/src/client/components/Admin/MarkdownSetting/IndentForm.tsx

@@ -1,13 +1,15 @@
 /* eslint-disable react/no-danger */
 import React, { useCallback } from 'react';
-
 import { useTranslation } from 'next-i18next';
 import {
-  UncontrolledDropdown, DropdownToggle, DropdownMenu, DropdownItem,
+  DropdownItem,
+  DropdownMenu,
+  DropdownToggle,
+  UncontrolledDropdown,
 } from 'reactstrap';
 
 import AdminMarkDownContainer from '~/client/services/AdminMarkDownContainer';
-import { toastSuccess, toastError } from '~/client/util/toastr';
+import { toastError, toastSuccess } from '~/client/util/toastr';
 import loggerFactory from '~/utils/logger';
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
@@ -15,24 +17,30 @@ import AdminUpdateButtonRow from '../Common/AdminUpdateButtonRow';
 
 const logger = loggerFactory('growi:importer');
 
-
 type Props = {
   adminMarkDownContainer: AdminMarkDownContainer;
-}
+};
 
 const IndentForm = (props: Props) => {
   const { t } = useTranslation('admin');
 
-  const onClickSubmit = useCallback(async(props) => {
-    try {
-      await props.adminMarkDownContainer.updateIndentSetting();
-      toastSuccess(t('toaster.update_successed', { target: t('markdown_settings.indent_header'), ns: 'commons' }));
-    }
-    catch (err) {
-      toastError(err);
-      logger.error(err);
-    }
-  }, [t]);
+  const onClickSubmit = useCallback(
+    async (props) => {
+      try {
+        await props.adminMarkDownContainer.updateIndentSetting();
+        toastSuccess(
+          t('toaster.update_successed', {
+            target: t('markdown_settings.indent_header'),
+            ns: 'commons',
+          }),
+        );
+      } catch (err) {
+        toastError(err);
+        logger.error(err);
+      }
+    },
+    [t],
+  );
 
   const renderIndentSizeOption = (props) => {
     const { adminMarkDownContainer } = props;
@@ -41,9 +49,14 @@ const IndentForm = (props: Props) => {
     return (
       <div className="col">
         <div>
-          <label htmlFor="adminPreferredIndentSize" className="form-label">{t('markdown_settings.indent_options.indentSize')}</label>
+          <label htmlFor="adminPreferredIndentSize" className="form-label">
+            {t('markdown_settings.indent_options.indentSize')}
+          </label>
           <UncontrolledDropdown id="adminPreferredIndentSize">
-            <DropdownToggle caret className="col-3 col-sm-2 col-md-5 col-lg-5 col-xl-3 text-end">
+            <DropdownToggle
+              caret
+              className="col-3 col-sm-2 col-md-5 col-lg-5 col-xl-3 text-end"
+            >
               <span className="float-start">
                 {adminPreferredIndentSize || 4}
               </span>
@@ -51,8 +64,14 @@ const IndentForm = (props: Props) => {
             <DropdownMenu className="dropdown-menu" role="menu">
               {[2, 4].map((num) => {
                 return (
-                  <DropdownItem key={num} role="presentation" onClick={() => adminMarkDownContainer.setAdminPreferredIndentSize(num)}>
-                    <a role="menuitem">{num}</a>
+                  <DropdownItem
+                    key={num}
+                    role="presentation"
+                    onClick={() =>
+                      adminMarkDownContainer.setAdminPreferredIndentSize(num)
+                    }
+                  >
+                    <span>{num}</span>
                   </DropdownItem>
                 );
               })}
@@ -70,7 +89,9 @@ const IndentForm = (props: Props) => {
     const { adminMarkDownContainer } = props;
     const { isIndentSizeForced } = adminMarkDownContainer.state;
 
-    const helpIndentInComment = { __html: t('markdown_settings.indent_options.disallow_indent_change_desc') };
+    const helpIndentInComment = {
+      __html: t('markdown_settings.indent_options.disallow_indent_change_desc'),
+    };
 
     return (
       <div className="col">
@@ -81,14 +102,23 @@ const IndentForm = (props: Props) => {
             id="isIndentSizeForced"
             checked={isIndentSizeForced || false}
             onChange={() => {
-              adminMarkDownContainer.setState({ isIndentSizeForced: !isIndentSizeForced });
+              adminMarkDownContainer.setState({
+                isIndentSizeForced: !isIndentSizeForced,
+              });
             }}
           />
-          <label className="form-label form-check-label" htmlFor="isIndentSizeForced">
+          <label
+            className="form-label form-check-label"
+            htmlFor="isIndentSizeForced"
+          >
             {t('markdown_settings.indent_options.disallow_indent_change')}
           </label>
         </div>
-        <p className="form-text text-muted" dangerouslySetInnerHTML={helpIndentInComment} />
+        <p
+          className="form-text text-muted"
+          // biome-ignore lint/security/noDangerouslySetInnerHtml: translation contains HTML markup
+          dangerouslySetInnerHTML={helpIndentInComment}
+        />
       </div>
     );
   };
@@ -101,7 +131,10 @@ const IndentForm = (props: Props) => {
         {renderIndentSizeOption(props)}
         {renderIndentForceOption(props)}
       </fieldset>
-      <AdminUpdateButtonRow onClick={() => onClickSubmit(props)} disabled={adminMarkDownContainer.state.retrieveError != null} />
+      <AdminUpdateButtonRow
+        onClick={() => onClickSubmit(props)}
+        disabled={adminMarkDownContainer.state.retrieveError != null}
+      />
     </React.Fragment>
   );
 };
@@ -109,6 +142,8 @@ const IndentForm = (props: Props) => {
 /**
  * Wrapper component for using unstated
  */
-const IndentFormWrapper = withUnstatedContainers(IndentForm, [AdminMarkDownContainer]);
+const IndentFormWrapper = withUnstatedContainers(IndentForm, [
+  AdminMarkDownContainer,
+]);
 
 export default IndentFormWrapper;

+ 57 - 21
apps/app/src/client/components/Admin/MarkdownSetting/LineBreakForm.jsx

@@ -1,11 +1,10 @@
 /* eslint-disable react/no-danger */
 import React from 'react';
-
 import { useTranslation } from 'next-i18next';
 import PropTypes from 'prop-types';
 
 import AdminMarkDownContainer from '~/client/services/AdminMarkDownContainer';
-import { toastSuccess, toastError } from '~/client/util/toastr';
+import { toastError, toastSuccess } from '~/client/util/toastr';
 import loggerFactory from '~/utils/logger';
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
@@ -14,22 +13,24 @@ import AdminUpdateButtonRow from '../Common/AdminUpdateButtonRow';
 const logger = loggerFactory('growi:importer');
 
 class LineBreakForm extends React.Component {
-
   constructor(props) {
     super(props);
 
     this.onClickSubmit = this.onClickSubmit.bind(this);
   }
 
-
   async onClickSubmit() {
     const { t } = this.props;
 
     try {
       await this.props.adminMarkDownContainer.updateLineBreakSetting();
-      toastSuccess(t('toaster.update_successed', { target: t('markdown_settings.lineBreak_header'), ns: 'commons' }));
-    }
-    catch (err) {
+      toastSuccess(
+        t('toaster.update_successed', {
+          target: t('markdown_settings.lineBreak_header'),
+          ns: 'commons',
+        }),
+      );
+    } catch (err) {
       toastError(err);
       logger.error(err);
     }
@@ -39,7 +40,9 @@ class LineBreakForm extends React.Component {
     const { t, adminMarkDownContainer } = this.props;
     const { isEnabledLinebreaks } = adminMarkDownContainer.state;
 
-    const helpLineBreak = { __html: t('markdown_settings.lineBreak_options.enable_lineBreak_desc') };
+    const helpLineBreak = {
+      __html: t('markdown_settings.lineBreak_options.enable_lineBreak_desc'),
+    };
 
     return (
       <div className="col">
@@ -49,13 +52,24 @@ class LineBreakForm extends React.Component {
             className="form-check-input"
             id="isEnabledLinebreaks"
             checked={isEnabledLinebreaks}
-            onChange={() => { adminMarkDownContainer.setState({ isEnabledLinebreaks: !isEnabledLinebreaks }) }}
+            onChange={() => {
+              adminMarkDownContainer.setState({
+                isEnabledLinebreaks: !isEnabledLinebreaks,
+              });
+            }}
           />
-          <label className="form-label form-check-label" htmlFor="isEnabledLinebreaks">
-            {t('markdown_settings.lineBreak_options.enable_lineBreak') }
+          <label
+            className="form-label form-check-label"
+            htmlFor="isEnabledLinebreaks"
+          >
+            {t('markdown_settings.lineBreak_options.enable_lineBreak')}
           </label>
         </div>
-        <p className="form-text text-muted" dangerouslySetInnerHTML={helpLineBreak} />
+        <p
+          className="form-text text-muted"
+          // biome-ignore lint/security/noDangerouslySetInnerHtml: translation contains HTML markup
+          dangerouslySetInnerHTML={helpLineBreak}
+        />
       </div>
     );
   }
@@ -64,7 +78,11 @@ class LineBreakForm extends React.Component {
     const { t, adminMarkDownContainer } = this.props;
     const { isEnabledLinebreaksInComments } = adminMarkDownContainer.state;
 
-    const helpLineBreakInComment = { __html: t('markdown_settings.lineBreak_options.enable_lineBreak_for_comment_desc') };
+    const helpLineBreakInComment = {
+      __html: t(
+        'markdown_settings.lineBreak_options.enable_lineBreak_for_comment_desc',
+      ),
+    };
 
     return (
       <div className="col">
@@ -74,13 +92,26 @@ class LineBreakForm extends React.Component {
             className="form-check-input"
             id="isEnabledLinebreaksInComments"
             checked={isEnabledLinebreaksInComments}
-            onChange={() => { adminMarkDownContainer.setState({ isEnabledLinebreaksInComments: !isEnabledLinebreaksInComments }) }}
+            onChange={() => {
+              adminMarkDownContainer.setState({
+                isEnabledLinebreaksInComments: !isEnabledLinebreaksInComments,
+              });
+            }}
           />
-          <label className="form-label form-check-label" htmlFor="isEnabledLinebreaksInComments">
-            {t('markdown_settings.lineBreak_options.enable_lineBreak_for_comment') }
+          <label
+            className="form-label form-check-label"
+            htmlFor="isEnabledLinebreaksInComments"
+          >
+            {t(
+              'markdown_settings.lineBreak_options.enable_lineBreak_for_comment',
+            )}
           </label>
         </div>
-        <p className="form-text text-muted" dangerouslySetInnerHTML={helpLineBreakInComment} />
+        <p
+          className="form-text text-muted"
+          // biome-ignore lint/security/noDangerouslySetInnerHtml: translation contains HTML markup
+          dangerouslySetInnerHTML={helpLineBreakInComment}
+        />
       </div>
     );
   }
@@ -94,11 +125,13 @@ class LineBreakForm extends React.Component {
           {this.renderLineBreakOption()}
           {this.renderLineBreakInCommentOption()}
         </fieldset>
-        <AdminUpdateButtonRow onClick={this.onClickSubmit} disabled={adminMarkDownContainer.state.retrieveError != null} />
+        <AdminUpdateButtonRow
+          onClick={this.onClickSubmit}
+          disabled={adminMarkDownContainer.state.retrieveError != null}
+        />
       </React.Fragment>
     );
   }
-
 }
 
 const LineBreakFormFC = (props) => {
@@ -109,11 +142,14 @@ const LineBreakFormFC = (props) => {
 /**
  * Wrapper component for using unstated
  */
-const LineBreakFormWrapper = withUnstatedContainers(LineBreakFormFC, [AdminMarkDownContainer]);
+const LineBreakFormWrapper = withUnstatedContainers(LineBreakFormFC, [
+  AdminMarkDownContainer,
+]);
 
 LineBreakForm.propTypes = {
   t: PropTypes.func.isRequired,
-  adminMarkDownContainer: PropTypes.instanceOf(AdminMarkDownContainer).isRequired,
+  adminMarkDownContainer: PropTypes.instanceOf(AdminMarkDownContainer)
+    .isRequired,
 };
 
 export default LineBreakFormWrapper;

+ 28 - 18
apps/app/src/client/components/Admin/MarkdownSetting/MarkDownSettingContents.tsx

@@ -1,5 +1,4 @@
-import React, { useEffect, type JSX } from 'react';
-
+import React, { type JSX, useEffect } from 'react';
 import { useTranslation } from 'next-i18next';
 import { Card, CardBody } from 'reactstrap';
 
@@ -9,30 +8,28 @@ import { toArrayIfNot } from '~/utils/array-utils';
 import loggerFactory from '~/utils/logger';
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
-
 import IndentForm from './IndentForm';
 import LineBreakForm from './LineBreakForm';
 import XssForm from './XssForm';
 
 const logger = loggerFactory('growi:MarkDown');
 
-type Props ={
-  adminMarkDownContainer: AdminMarkDownContainer
-}
+type Props = {
+  adminMarkDownContainer: AdminMarkDownContainer;
+};
 
 const MarkDownSettingContents = React.memo((props: Props): JSX.Element => {
   const { t } = useTranslation('admin');
   const { adminMarkDownContainer } = props;
 
   useEffect(() => {
-    const fetchMarkdownData = async() => {
+    const fetchMarkdownData = async () => {
       await adminMarkDownContainer.retrieveMarkdownData();
     };
 
     try {
       fetchMarkdownData();
-    }
-    catch (err) {
+    } catch (err) {
       const errs = toArrayIfNot(err);
       toastError(errs);
       logger.error(errs);
@@ -42,23 +39,35 @@ const MarkDownSettingContents = React.memo((props: Props): JSX.Element => {
   return (
     <div data-testid="admin-markdown" className="mb-5">
       {/* Line Break Setting */}
-      <h2 className="admin-setting-header">{t('markdown_settings.lineBreak_header')}</h2>
+      <h2 className="admin-setting-header">
+        {t('markdown_settings.lineBreak_header')}
+      </h2>
       <Card className="card custom-card bg-body-tertiary my-3">
-        <CardBody className="px-0 py-2">{ t('markdown_settings.lineBreak_desc') }</CardBody>
+        <CardBody className="px-0 py-2">
+          {t('markdown_settings.lineBreak_desc')}
+        </CardBody>
       </Card>
       <LineBreakForm />
 
       {/* Indent Setting */}
-      <h2 className="admin-setting-header mt-5">{t('markdown_settings.indent_header')}</h2>
+      <h2 className="admin-setting-header mt-5">
+        {t('markdown_settings.indent_header')}
+      </h2>
       <Card className="card custom-card bg-body-tertiary my-3">
-        <CardBody className="px-0 py-2">{t('markdown_settings.indent_desc') }</CardBody>
+        <CardBody className="px-0 py-2">
+          {t('markdown_settings.indent_desc')}
+        </CardBody>
       </Card>
       <IndentForm />
 
       {/* XSS Setting */}
-      <h2 className="admin-setting-header mt-5">{ t('markdown_settings.xss_header') }</h2>
+      <h2 className="admin-setting-header mt-5">
+        {t('markdown_settings.xss_header')}
+      </h2>
       <Card className="card custom-card bg-body-tertiary my-3">
-        <CardBody className="px-0 py-2">{ t('markdown_settings.xss_desc') }</CardBody>
+        <CardBody className="px-0 py-2">
+          {t('markdown_settings.xss_desc')}
+        </CardBody>
       </Card>
       <XssForm />
     </div>
@@ -66,8 +75,9 @@ const MarkDownSettingContents = React.memo((props: Props): JSX.Element => {
 });
 MarkDownSettingContents.displayName = 'MarkDownSettingContents';
 
-
-const MarkdownSettingWithUnstatedContainer = withUnstatedContainers(MarkDownSettingContents, [AdminMarkDownContainer]);
-
+const MarkdownSettingWithUnstatedContainer = withUnstatedContainers(
+  MarkDownSettingContents,
+  [AdminMarkDownContainer],
+);
 
 export default MarkdownSettingWithUnstatedContainer;

+ 33 - 19
apps/app/src/client/components/Admin/MarkdownSetting/WhitelistInput.tsx

@@ -1,24 +1,25 @@
-import { useCallback, type JSX } from 'react';
-
+import { type JSX, useCallback } from 'react';
 import { useTranslation } from 'next-i18next';
 import type { UseFormRegister, UseFormSetValue } from 'react-hook-form';
 
 import type AdminMarkDownContainer from '~/client/services/AdminMarkDownContainer';
-import { tagNames as recommendedTagNames, attributes as recommendedAttributes } from '~/services/renderer/recommended-whitelist';
+import {
+  attributes as recommendedAttributes,
+  tagNames as recommendedTagNames,
+} from '~/services/renderer/recommended-whitelist';
 
 type FormValues = {
-  tagWhitelist: string,
-  attrWhitelist: string,
-}
+  tagWhitelist: string;
+  attrWhitelist: string;
+};
 
-type Props ={
-  adminMarkDownContainer: AdminMarkDownContainer,
-  register: UseFormRegister<FormValues>,
-  setValue: UseFormSetValue<FormValues>,
-}
+type Props = {
+  adminMarkDownContainer: AdminMarkDownContainer;
+  register: UseFormRegister<FormValues>;
+  setValue: UseFormSetValue<FormValues>;
+};
 
 export const WhitelistInput = (props: Props): JSX.Element => {
-
   const { t } = useTranslation('admin');
   const { adminMarkDownContainer, register, setValue } = props;
 
@@ -39,9 +40,16 @@ export const WhitelistInput = (props: Props): JSX.Element => {
       <div className="mt-4">
         <div className="d-flex justify-content-between">
           {t('markdown_settings.xss_options.tag_names')}
-          <p id="btn-import-tags" className="btn btn-sm btn-primary" onClick={clickRecommendTagButtonHandler}>
-            {t('markdown_settings.xss_options.import_recommended', { target: 'Tags' })}
-          </p>
+          <button
+            type="button"
+            id="btn-import-tags"
+            className="btn btn-sm btn-primary"
+            onClick={clickRecommendTagButtonHandler}
+          >
+            {t('markdown_settings.xss_options.import_recommended', {
+              target: 'Tags',
+            })}
+          </button>
         </div>
         <textarea
           className="form-control xss-list"
@@ -53,9 +61,16 @@ export const WhitelistInput = (props: Props): JSX.Element => {
       <div className="mt-4">
         <div className="d-flex justify-content-between">
           {t('markdown_settings.xss_options.tag_attributes')}
-          <p id="btn-import-tags" className="btn btn-sm btn-primary" onClick={clickRecommendAttrButtonHandler}>
-            {t('markdown_settings.xss_options.import_recommended', { target: 'Attrs' })}
-          </p>
+          <button
+            type="button"
+            id="btn-import-attrs"
+            className="btn btn-sm btn-primary"
+            onClick={clickRecommendAttrButtonHandler}
+          >
+            {t('markdown_settings.xss_options.import_recommended', {
+              target: 'Attrs',
+            })}
+          </button>
         </div>
         <textarea
           className="form-control xss-list"
@@ -66,5 +81,4 @@ export const WhitelistInput = (props: Props): JSX.Element => {
       </div>
     </>
   );
-
 };

+ 70 - 37
apps/app/src/client/components/Admin/MarkdownSetting/XssForm.jsx

@@ -1,31 +1,29 @@
 import React, { useCallback, useEffect } from 'react';
-
 import { useTranslation } from 'next-i18next';
 import PropTypes from 'prop-types';
 import { useForm } from 'react-hook-form';
 
 import AdminMarkDownContainer from '~/client/services/AdminMarkDownContainer';
-import { toastSuccess, toastError } from '~/client/util/toastr';
+import { toastError, toastSuccess } from '~/client/util/toastr';
 import { RehypeSanitizeType } from '~/interfaces/services/rehype-sanitize';
-import { tagNames as recommendedTagNames, attributes as recommendedAttributes } from '~/services/renderer/recommended-whitelist';
+import {
+  attributes as recommendedAttributes,
+  tagNames as recommendedTagNames,
+} from '~/services/renderer/recommended-whitelist';
 import loggerFactory from '~/utils/logger';
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
 import AdminUpdateButtonRow from '../Common/AdminUpdateButtonRow';
-
 import { WhitelistInput } from './WhitelistInput';
 
 const logger = loggerFactory('growi:importer');
 
 const XssForm = (props) => {
   const { t, adminMarkDownContainer } = props;
-  const {
-    xssOption, tagWhitelist, attrWhitelist, retrieveError,
-  } = adminMarkDownContainer.state;
+  const { xssOption, tagWhitelist, attrWhitelist, retrieveError } =
+    adminMarkDownContainer.state;
 
-  const {
-    register, handleSubmit, reset, setValue,
-  } = useForm();
+  const { register, handleSubmit, reset, setValue } = useForm();
 
   // Sync form with container state
   useEffect(() => {
@@ -35,28 +33,37 @@ const XssForm = (props) => {
     });
   }, [reset, tagWhitelist, attrWhitelist]);
 
-  const onClickSubmit = useCallback(async(data) => {
-    try {
-      await adminMarkDownContainer.setState({ tagWhitelist: data.tagWhitelist ?? '' });
-      await adminMarkDownContainer.setState({ attrWhitelist: data.attrWhitelist ?? '' });
-      await adminMarkDownContainer.updateXssSetting();
-      toastSuccess(t('toaster.update_successed', { target: t('markdown_settings.xss_header'), ns: 'commons' }));
-    }
-    catch (err) {
-      toastError(err);
-      logger.error(err);
-    }
-  }, [adminMarkDownContainer, t]);
+  const onClickSubmit = useCallback(
+    async (data) => {
+      try {
+        await adminMarkDownContainer.setState({
+          tagWhitelist: data.tagWhitelist ?? '',
+        });
+        await adminMarkDownContainer.setState({
+          attrWhitelist: data.attrWhitelist ?? '',
+        });
+        await adminMarkDownContainer.updateXssSetting();
+        toastSuccess(
+          t('toaster.update_successed', {
+            target: t('markdown_settings.xss_header'),
+            ns: 'commons',
+          }),
+        );
+      } catch (err) {
+        toastError(err);
+        logger.error(err);
+      }
+    },
+    [adminMarkDownContainer, t],
+  );
 
   const xssOptions = useCallback(() => {
-
     const rehypeRecommendedTags = recommendedTagNames.join(',');
     const rehypeRecommendedAttributes = JSON.stringify(recommendedAttributes);
 
     return (
       <div className="col-12 mt-3">
         <div className="row">
-
           <div className="col-md-6 col-sm-12 align-self-start">
             <div className="form-check">
               <input
@@ -65,10 +72,19 @@ const XssForm = (props) => {
                 id="xssOption1"
                 name="XssOption"
                 checked={xssOption === RehypeSanitizeType.RECOMMENDED}
-                onChange={() => { adminMarkDownContainer.setState({ xssOption: RehypeSanitizeType.RECOMMENDED }) }}
+                onChange={() => {
+                  adminMarkDownContainer.setState({
+                    xssOption: RehypeSanitizeType.RECOMMENDED,
+                  });
+                }}
               />
-              <label className="form-label form-check-label w-100" htmlFor="xssOption1">
-                <p className="fw-bold">{t('markdown_settings.xss_options.recommended_setting')}</p>
+              <label
+                className="form-label form-check-label w-100"
+                htmlFor="xssOption1"
+              >
+                <p className="fw-bold">
+                  {t('markdown_settings.xss_options.recommended_setting')}
+                </p>
                 <div className="mt-4">
                   <div className="d-flex justify-content-between">
                     {t('markdown_settings.xss_options.tag_names')}
@@ -107,11 +123,24 @@ const XssForm = (props) => {
                 id="xssOption2"
                 name="XssOption"
                 checked={xssOption === RehypeSanitizeType.CUSTOM}
-                onChange={() => { adminMarkDownContainer.setState({ xssOption: RehypeSanitizeType.CUSTOM }) }}
+                onChange={() => {
+                  adminMarkDownContainer.setState({
+                    xssOption: RehypeSanitizeType.CUSTOM,
+                  });
+                }}
               />
-              <label className="form-label form-check-label w-100" htmlFor="xssOption2">
-                <p className="fw-bold">{t('markdown_settings.xss_options.custom_whitelist')}</p>
-                <WhitelistInput adminMarkDownContainer={adminMarkDownContainer} register={register} setValue={setValue} />
+              <label
+                className="form-label form-check-label w-100"
+                htmlFor="xssOption2"
+              >
+                <p className="fw-bold">
+                  {t('markdown_settings.xss_options.custom_whitelist')}
+                </p>
+                <WhitelistInput
+                  adminMarkDownContainer={adminMarkDownContainer}
+                  register={register}
+                  setValue={setValue}
+                />
               </label>
             </div>
           </div>
@@ -137,16 +166,17 @@ const XssForm = (props) => {
                   checked={isEnabledXss}
                   onChange={adminMarkDownContainer.switchEnableXss}
                 />
-                <label className="form-label form-check-label w-100" htmlFor="XssEnable">
+                <label
+                  className="form-label form-check-label w-100"
+                  htmlFor="XssEnable"
+                >
                   {t('markdown_settings.xss_options.enable_xss_prevention')}
                 </label>
               </div>
             </div>
           </div>
 
-          <div className="col-12">
-            {isEnabledXss && xssOptions()}
-          </div>
+          <div className="col-12">{isEnabledXss && xssOptions()}</div>
         </fieldset>
         <AdminUpdateButtonRow
           disabled={retrieveError != null}
@@ -159,7 +189,8 @@ const XssForm = (props) => {
 
 XssForm.propTypes = {
   t: PropTypes.func.isRequired, // i18next
-  adminMarkDownContainer: PropTypes.instanceOf(AdminMarkDownContainer).isRequired,
+  adminMarkDownContainer: PropTypes.instanceOf(AdminMarkDownContainer)
+    .isRequired,
 };
 
 const XssFormWrapperFC = (props) => {
@@ -168,6 +199,8 @@ const XssFormWrapperFC = (props) => {
   return <XssForm t={t} {...props} />;
 };
 
-const XssFormWrapper = withUnstatedContainers(XssFormWrapperFC, [AdminMarkDownContainer]);
+const XssFormWrapper = withUnstatedContainers(XssFormWrapperFC, [
+  AdminMarkDownContainer,
+]);
 
 export default XssFormWrapper;

+ 1 - 4
apps/app/src/client/components/Admin/NotFoundPage.tsx

@@ -1,11 +1,8 @@
 import React, { type JSX } from 'react';
-
 import { useTranslation } from 'next-i18next';
 
 export const AdminNotFoundPage = (): JSX.Element => {
   const { t } = useTranslation('commons');
 
-  return (
-    <h1 className="title">{t('not_found_page.page_not_exist')}</h1>
-  );
+  return <h1 className="title">{t('not_found_page.page_not_exist')}</h1>;
 };

+ 71 - 24
apps/app/src/client/components/Admin/Notification/GlobalNotification.jsx

@@ -1,30 +1,31 @@
 import { React, useCallback } from 'react';
-
-import { useTranslation } from 'next-i18next';
 import { useRouter } from 'next/router';
+import { useTranslation } from 'next-i18next';
 import PropTypes from 'prop-types';
 
 import AdminNotificationContainer from '~/client/services/AdminNotificationContainer';
-import { toastSuccess, toastError } from '~/client/util/toastr';
+import { toastError, toastSuccess } from '~/client/util/toastr';
 import loggerFactory from '~/utils/logger';
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
-
 import GlobalNotificationList from './GlobalNotificationList';
 
 const logger = loggerFactory('growi:GlobalNotification');
 
 const GlobalNotification = (props) => {
-
   const { adminNotificationContainer } = props;
   const { t } = useTranslation('admin');
 
-  const onClickSubmit = useCallback(async() => {
+  const onClickSubmit = useCallback(async () => {
     try {
       await adminNotificationContainer.updateGlobalNotificationForPages();
-      toastSuccess(t('toaster.update_successed', { target: t('external_notification.external_notification'), ns: 'commons' }));
-    }
-    catch (err) {
+      toastSuccess(
+        t('toaster.update_successed', {
+          target: t('external_notification.external_notification'),
+          ns: 'commons',
+        }),
+      );
+    } catch (err) {
       toastError(err);
       logger.error(err);
     }
@@ -34,11 +35,18 @@ const GlobalNotification = (props) => {
   const { globalNotifications } = adminNotificationContainer.state;
   return (
     <>
-      <h2 className="border-bottom my-4">{t('notification_settings.valid_page')}</h2>
+      <h2 className="border-bottom my-4">
+        {t('notification_settings.valid_page')}
+      </h2>
 
       <p className="card custom-card bg-body-tertiary">
         {/* eslint-disable-next-line react/no-danger */}
-        <span dangerouslySetInnerHTML={{ __html: t('notification_settings.link_notification_help') }} />
+        <span
+          // biome-ignore lint/security/noDangerouslySetInnerHtml: trusted translation markup
+          dangerouslySetInnerHTML={{
+            __html: t('notification_settings.link_notification_help'),
+          }}
+        />
       </p>
       <div className="row mb-4">
         <div className="col-md-8 offset-md-2">
@@ -47,12 +55,25 @@ const GlobalNotification = (props) => {
               id="isNotificationForOwnerPageEnabled"
               className="form-check-input"
               type="checkbox"
-              checked={adminNotificationContainer.state.isNotificationForOwnerPageEnabled || false}
-              onChange={() => { adminNotificationContainer.switchIsNotificationForOwnerPageEnabled() }}
+              checked={
+                adminNotificationContainer.state
+                  .isNotificationForOwnerPageEnabled || false
+              }
+              onChange={() => {
+                adminNotificationContainer.switchIsNotificationForOwnerPageEnabled();
+              }}
             />
-            <label className="form-label form-check-label" htmlFor="isNotificationForOwnerPageEnabled">
+            <label
+              className="form-label form-check-label"
+              htmlFor="isNotificationForOwnerPageEnabled"
+            >
               {/* eslint-disable-next-line react/no-danger */}
-              <span dangerouslySetInnerHTML={{ __html: t('notification_settings.just_me_notification_help') }} />
+              <span
+                // biome-ignore lint/security/noDangerouslySetInnerHtml: trusted translation markup
+                dangerouslySetInnerHTML={{
+                  __html: t('notification_settings.just_me_notification_help'),
+                }}
+              />
             </label>
           </div>
         </div>
@@ -65,12 +86,25 @@ const GlobalNotification = (props) => {
               id="isNotificationForGroupPageEnabled"
               className="form-check-input"
               type="checkbox"
-              checked={adminNotificationContainer.state.isNotificationForGroupPageEnabled || false}
-              onChange={() => { adminNotificationContainer.switchIsNotificationForGroupPageEnabled() }}
+              checked={
+                adminNotificationContainer.state
+                  .isNotificationForGroupPageEnabled || false
+              }
+              onChange={() => {
+                adminNotificationContainer.switchIsNotificationForGroupPageEnabled();
+              }}
             />
-            <label className="form-label form-check-label" htmlFor="isNotificationForGroupPageEnabled">
+            <label
+              className="form-label form-check-label"
+              htmlFor="isNotificationForGroupPageEnabled"
+            >
               {/* eslint-disable-next-line react/no-danger */}
-              <span dangerouslySetInnerHTML={{ __html: t('notification_settings.group_notification_help') }} />
+              <span
+                // biome-ignore lint/security/noDangerouslySetInnerHtml: trusted translation markup
+                dangerouslySetInnerHTML={{
+                  __html: t('notification_settings.group_notification_help'),
+                }}
+              />
             </label>
           </div>
         </div>
@@ -82,7 +116,8 @@ const GlobalNotification = (props) => {
             className="btn btn-primary"
             onClick={onClickSubmit}
             disabled={adminNotificationContainer.state.retrieveError}
-          >{t('Update')}
+          >
+            {t('Update')}
           </button>
         </div>
       </div>
@@ -94,14 +129,23 @@ const GlobalNotification = (props) => {
         className="btn btn-outline-secondary mb-3"
         type="button"
         onClick={() => router.push('/admin/global-notification/new')}
-      >{t('notification_settings.add_notification')}
+      >
+        {t('notification_settings.add_notification')}
       </button>
       <table className="table table-bordered">
         <thead>
           <tr>
             <th>ON/OFF</th>
             {/* eslint-disable-next-line react/no-danger */}
-            <th>{t('notification_settings.trigger_path')} <span dangerouslySetInnerHTML={{ __html: t('notification_settings.trigger_path_help') }} /></th>
+            <th>
+              {t('notification_settings.trigger_path')}{' '}
+              <span
+                // biome-ignore lint/security/noDangerouslySetInnerHtml: trusted translation markup
+                dangerouslySetInnerHTML={{
+                  __html: t('notification_settings.trigger_path_help'),
+                }}
+              />
+            </th>
             <th>{t('notification_settings.trigger_events')}</th>
             <th>{t('notification_settings.notify_to')}</th>
             <th></th>
@@ -118,9 +162,12 @@ const GlobalNotification = (props) => {
 };
 
 GlobalNotification.propTypes = {
-  adminNotificationContainer: PropTypes.instanceOf(AdminNotificationContainer).isRequired,
+  adminNotificationContainer: PropTypes.instanceOf(AdminNotificationContainer)
+    .isRequired,
 };
 
-const GlobalNotificationWrapper = withUnstatedContainers(GlobalNotification, [AdminNotificationContainer]);
+const GlobalNotificationWrapper = withUnstatedContainers(GlobalNotification, [
+  AdminNotificationContainer,
+]);
 
 export default GlobalNotificationWrapper;

+ 90 - 40
apps/app/src/client/components/Admin/Notification/GlobalNotificationList.jsx

@@ -1,24 +1,20 @@
 import React from 'react';
-
 import { useTranslation } from 'next-i18next';
 import PropTypes from 'prop-types';
 import urljoin from 'url-join';
 
 import AdminNotificationContainer from '~/client/services/AdminNotificationContainer';
 import { apiv3Put } from '~/client/util/apiv3-client';
-import { toastSuccess, toastError } from '~/client/util/toastr';
+import { toastError, toastSuccess } from '~/client/util/toastr';
 import loggerFactory from '~/utils/logger';
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
-
 import NotificationDeleteModal from './NotificationDeleteModal';
 import { NotificationTypeIcon } from './NotificationTypeIcon';
 
-
 const logger = loggerFactory('growi:GolobalNotificationList');
 
 class GlobalNotificationList extends React.Component {
-
   constructor(props) {
     super(props);
 
@@ -36,34 +32,52 @@ class GlobalNotificationList extends React.Component {
     const { t } = this.props;
     const isEnabled = !notification.isEnabled;
     try {
-      await apiv3Put(`/notification-setting/global-notification/${notification._id}/enabled`, {
-        isEnabled,
-      });
-      toastSuccess(t('notification_settings.toggle_notification', { path: notification.triggerPath }));
+      await apiv3Put(
+        `/notification-setting/global-notification/${notification._id}/enabled`,
+        {
+          isEnabled,
+        },
+      );
+      toastSuccess(
+        t('notification_settings.toggle_notification', {
+          path: notification.triggerPath,
+        }),
+      );
       await this.props.adminNotificationContainer.retrieveNotificationData();
-    }
-    catch (err) {
+    } catch (err) {
       toastError(err);
       logger.error(err);
     }
   }
 
   openConfirmationModal(notification) {
-    this.setState({ isConfirmationModalOpen: true, notificationForConfiguration: notification });
+    this.setState({
+      isConfirmationModalOpen: true,
+      notificationForConfiguration: notification,
+    });
   }
 
   closeConfirmationModal() {
-    this.setState({ isConfirmationModalOpen: false, notificationForConfiguration: null });
+    this.setState({
+      isConfirmationModalOpen: false,
+      notificationForConfiguration: null,
+    });
   }
 
   async onClickSubmit() {
     const { t, adminNotificationContainer } = this.props;
 
     try {
-      const deletedNotificaton = await adminNotificationContainer.deleteGlobalNotificationPattern(this.state.notificationForConfiguration._id);
-      toastSuccess(t('notification_settings.delete_notification_pattern', { path: deletedNotificaton.triggerPath }));
-    }
-    catch (err) {
+      const deletedNotificaton =
+        await adminNotificationContainer.deleteGlobalNotificationPattern(
+          this.state.notificationForConfiguration._id,
+        );
+      toastSuccess(
+        t('notification_settings.delete_notification_pattern', {
+          path: deletedNotificaton.triggerPath,
+        }),
+      );
+    } catch (err) {
       toastError(err);
       logger.error(err);
     }
@@ -88,50 +102,67 @@ class GlobalNotificationList extends React.Component {
                     defaultChecked={notification.isEnabled}
                     onClick={() => this.toggleIsEnabled(notification)}
                   />
-                  <label className="form-label form-check-label" htmlFor={notification._id} />
+                  <label
+                    className="form-label form-check-label"
+                    htmlFor={notification._id}
+                  >
+                    <span className="visually-hidden">{t('Enable')}</span>
+                  </label>
                 </div>
               </td>
-              <td>
-                {notification.triggerPath}
-              </td>
+              <td>{notification.triggerPath}</td>
               <td>
                 <ul className="list-inline mb-0">
                   {notification.triggerEvents.includes('pageCreate') && (
                     <li className="list-inline-item badge rounded-pill bg-success">
-                      <span className=" material-symbols-outlined">description</span> CREATE
+                      <span className=" material-symbols-outlined">
+                        description
+                      </span>{' '}
+                      CREATE
                     </li>
                   )}
                   {notification.triggerEvents.includes('pageEdit') && (
                     <li className="list-inline-item badge rounded-pill bg-warning text-dark">
-                      <span className="material-symbols-outlined">edit</span> EDIT
+                      <span className="material-symbols-outlined">edit</span>{' '}
+                      EDIT
                     </li>
                   )}
                   {notification.triggerEvents.includes('pageMove') && (
                     <li className="list-inline-item badge rounded-pill bg-pink">
-                      <span className="material-symbols-outlined">redo</span> MOVE
+                      <span className="material-symbols-outlined">redo</span>{' '}
+                      MOVE
                     </li>
                   )}
                   {notification.triggerEvents.includes('pageDelete') && (
                     <li className="list-inline-item badge rounded-pill bg-danger">
-                      <span className="material-symbols-outlined">delete_forever</span>DELETE
+                      <span className="material-symbols-outlined">
+                        delete_forever
+                      </span>
+                      DELETE
                     </li>
                   )}
                   {notification.triggerEvents.includes('pageLike') && (
                     <li className="list-inline-item badge rounded-pill bg-info">
-                      <span className="material-symbols-outlined">favorite</span> LIKE
+                      <span className="material-symbols-outlined">
+                        favorite
+                      </span>{' '}
+                      LIKE
                     </li>
                   )}
                   {notification.triggerEvents.includes('comment') && (
                     <li className="list-inline-item badge rounded-pill bg-primary">
-                      <span className="material-symbols-outlined">bubble_chart</span> POST
+                      <span className="material-symbols-outlined">
+                        bubble_chart
+                      </span>{' '}
+                      POST
                     </li>
                   )}
                 </ul>
               </td>
               <td>
                 <NotificationTypeIcon notification={notification} />
-                { notification.__t === 'mail' && notification.toEmail }
-                { notification.__t === 'slack' && notification.slackChannels }
+                {notification.__t === 'mail' && notification.toEmail}
+                {notification.__t === 'slack' && notification.slackChannels}
               </td>
               <td className="td-abs-center">
                 <div className="dropdown">
@@ -143,14 +174,29 @@ class GlobalNotificationList extends React.Component {
                     aria-haspopup="true"
                     aria-expanded="false"
                   >
-                    <span className="material-symbols-outlined">settings</span> <span className="caret"></span>
+                    <span className="material-symbols-outlined">settings</span>{' '}
+                    <span className="caret"></span>
                   </button>
-                  <div className="dropdown-menu dropdown-menu-right" aria-labelledby="dropdownMenuButton">
-                    <a className="dropdown-item" href={urljoin('/admin/global-notification/', notification._id)}>
-                      <span className="material-symbols-outlined">note</span> {t('Edit')}
+                  <div className="dropdown-menu dropdown-menu-right">
+                    <a
+                      className="dropdown-item"
+                      href={urljoin(
+                        '/admin/global-notification/',
+                        notification._id,
+                      )}
+                    >
+                      <span className="material-symbols-outlined">note</span>{' '}
+                      {t('Edit')}
                     </a>
-                    <button className="dropdown-item" type="button" onClick={() => this.openConfirmationModal(notification)}>
-                      <span className="material-symbols-outlined text-danger">delete_forever</span> {t('Delete')}
+                    <button
+                      className="dropdown-item"
+                      type="button"
+                      onClick={() => this.openConfirmationModal(notification)}
+                    >
+                      <span className="material-symbols-outlined text-danger">
+                        delete_forever
+                      </span>{' '}
+                      {t('Delete')}
                     </button>
                   </div>
                 </div>
@@ -163,19 +209,20 @@ class GlobalNotificationList extends React.Component {
             isOpen={this.state.isConfirmationModalOpen}
             onClose={this.closeConfirmationModal}
             onClickSubmit={this.onClickSubmit}
-            notificationForConfiguration={this.state.notificationForConfiguration}
+            notificationForConfiguration={
+              this.state.notificationForConfiguration
+            }
           />
         )}
       </React.Fragment>
     );
   }
-
 }
 
 GlobalNotificationList.propTypes = {
   t: PropTypes.func.isRequired, // i18next
-  adminNotificationContainer: PropTypes.instanceOf(AdminNotificationContainer).isRequired,
-
+  adminNotificationContainer: PropTypes.instanceOf(AdminNotificationContainer)
+    .isRequired,
 };
 
 const GlobalNotificationListWrapperFC = (props) => {
@@ -184,6 +231,9 @@ const GlobalNotificationListWrapperFC = (props) => {
   return <GlobalNotificationList t={t} {...props} />;
 };
 
-const GlobalNotificationListWrapper = withUnstatedContainers(GlobalNotificationListWrapperFC, [AdminNotificationContainer]);
+const GlobalNotificationListWrapper = withUnstatedContainers(
+  GlobalNotificationListWrapperFC,
+  [AdminNotificationContainer],
+);
 
 export default GlobalNotificationListWrapper;

+ 160 - 102
apps/app/src/client/components/Admin/Notification/ManageGlobalNotification.tsx

@@ -1,45 +1,49 @@
 import React, {
-  useCallback, useMemo, useEffect, useState, type JSX,
+  type JSX,
+  useCallback,
+  useEffect,
+  useMemo,
+  useState,
 } from 'react';
-
-import { useAtomValue } from 'jotai';
-import { useTranslation } from 'next-i18next';
 import Link from 'next/link';
 import { useRouter } from 'next/router';
+import { useAtomValue } from 'jotai';
+import { useTranslation } from 'next-i18next';
 
-import { NotifyType, TriggerEventType } from '~/client/interfaces/global-notification';
+import {
+  NotifyType,
+  TriggerEventType,
+} from '~/client/interfaces/global-notification';
 import { apiv3Post } from '~/client/util/apiv3-client';
 import { toastError } from '~/client/util/toastr';
 import { isMailerSetupAtom } from '~/states/server-configurations';
 import { useSWRxGlobalNotification } from '~/stores/global-notification';
 import loggerFactory from '~/utils/logger';
 
-
 import AdminUpdateButtonRow from '../Common/AdminUpdateButtonRow';
-
 import TriggerEventCheckBox from './TriggerEventCheckBox';
 
-
 const logger = loggerFactory('growi:manageGlobalNotification');
 
-
 type Props = {
-  globalNotificationId?: string,
-}
+  globalNotificationId?: string;
+};
 
 const ManageGlobalNotification = (props: Props): JSX.Element => {
-
   const [triggerPath, setTriggerPath] = useState('');
   const [notifyType, setNotifyType] = useState<NotifyType>(NotifyType.Email);
   const [emailToSend, setEmailToSend] = useState('');
   const [slackChannelToSend, setSlackChannelToSend] = useState('');
-  const [triggerEvents, setTriggerEvents] = useState(new Set());
-  const { data: globalNotificationData, update: updateGlobalNotification } = useSWRxGlobalNotification(props.globalNotificationId || '');
-  const globalNotification = useMemo(() => globalNotificationData?.globalNotification, [globalNotificationData?.globalNotification]);
+  const [triggerEvents, setTriggerEvents] = useState(new Set<string>());
+  const { data: globalNotificationData, update: updateGlobalNotification } =
+    useSWRxGlobalNotification(props.globalNotificationId || '');
+  const globalNotification = useMemo(
+    () => globalNotificationData?.globalNotification,
+    [globalNotificationData?.globalNotification],
+  );
 
   const router = useRouter();
 
-
   useEffect(() => {
     if (globalNotification != null) {
       const notifyType = globalNotification.__t;
@@ -50,15 +54,15 @@ const ManageGlobalNotification = (props: Props): JSX.Element => {
 
       if (notifyType === NotifyType.Email) {
         setEmailToSend(globalNotification.toEmail);
-      }
-      else {
+      } else {
         setSlackChannelToSend(globalNotification.slackChannels);
       }
     }
   }, [globalNotification]);
 
   const isLoading = globalNotificationData === undefined;
-  const notExistsGlobalNotification = !isLoading && globalNotificationData == null;
+  const notExistsGlobalNotification =
+    !isLoading && globalNotificationData == null;
 
   useEffect(() => {
     if (notExistsGlobalNotification) {
@@ -66,22 +70,24 @@ const ManageGlobalNotification = (props: Props): JSX.Element => {
     }
   }, [notExistsGlobalNotification, router]);
 
+  const onChangeTriggerEvents = useCallback(
+    (triggerEvent: string) => {
+      let newTriggerEvents: string[];
+
+      if (triggerEvents.has(triggerEvent)) {
+        newTriggerEvents = [...triggerEvents].filter(
+          (item) => item !== triggerEvent,
+        );
+        setTriggerEvents(new Set(newTriggerEvents));
+      } else {
+        newTriggerEvents = [...triggerEvents, triggerEvent];
+        setTriggerEvents(new Set(newTriggerEvents));
+      }
+    },
+    [triggerEvents],
+  );
 
-  const onChangeTriggerEvents = useCallback((triggerEvent) => {
-    let newTriggerEvents;
-
-    if (triggerEvents.has(triggerEvent)) {
-      newTriggerEvents = ([...triggerEvents].filter(item => item !== triggerEvent));
-      setTriggerEvents(new Set(newTriggerEvents));
-    }
-    else {
-      newTriggerEvents = [...triggerEvents, triggerEvent];
-      setTriggerEvents(new Set(newTriggerEvents));
-    }
-  }, [triggerEvents]);
-
-
-  const updateButtonClickedHandler = useCallback(async() => {
+  const updateButtonClickedHandler = useCallback(async () => {
     const requestParams = {
       triggerPath,
       notifyType,
@@ -94,18 +100,27 @@ const ManageGlobalNotification = (props: Props): JSX.Element => {
       if (props.globalNotificationId != null) {
         await updateGlobalNotification(requestParams);
         router.push('/admin/notification');
-      }
-      else {
-        await apiv3Post('/notification-setting/global-notification', requestParams);
+      } else {
+        await apiv3Post(
+          '/notification-setting/global-notification',
+          requestParams,
+        );
         router.push('/admin/notification');
       }
-    }
-    catch (err) {
+    } catch (err) {
       toastError(err);
       logger.error(err);
     }
-  }, [emailToSend, notifyType, props.globalNotificationId, router, slackChannelToSend, triggerEvents, triggerPath, updateGlobalNotification]);
-
+  }, [
+    emailToSend,
+    notifyType,
+    props.globalNotificationId,
+    router,
+    slackChannelToSend,
+    triggerEvents,
+    triggerPath,
+    updateGlobalNotification,
+  ]);
 
   // Mailer setup status (unused yet but kept for potential conditional logic)
   const isMailerSetup = useAtomValue(isMailerSetupAtom);
@@ -115,22 +130,34 @@ const ManageGlobalNotification = (props: Props): JSX.Element => {
     <>
       <div className="my-3">
         <Link href="/admin/notification" className="btn btn-outline-secondary">
-          <span className="material-symbols-outlined" aria-hidden="true">arrow_left_alt</span>
+          <span className="material-symbols-outlined" aria-hidden="true">
+            arrow_left_alt
+          </span>
           {t('notification_settings.back_to_list')}
         </Link>
       </div>
 
-
       <div className="row">
         <div className="form-box col-md-12">
-          <h2 className="border-bottom mb-5">{t('notification_settings.notification_detail')}</h2>
+          <h2 className="border-bottom mb-5">
+            {t('notification_settings.notification_detail')}
+          </h2>
         </div>
 
         <div className="col-sm-4">
           <h3>
-            <label htmlFor="triggerPath" className="form-label">{t('notification_settings.trigger_path')}
+            <label htmlFor="triggerPath" className="form-label">
+              {t('notification_settings.trigger_path')}
               {/* eslint-disable-next-line react/no-danger */}
-              <small dangerouslySetInnerHTML={{ __html: t('notification_settings.trigger_path_help', '<code>*</code>') }} />
+              <small
+                // biome-ignore lint/security/noDangerouslySetInnerHtml: trusted translation markup
+                dangerouslySetInnerHTML={{
+                  __html: t(
+                    'notification_settings.trigger_path_help',
+                    '<code>*</code>',
+                  ),
+                }}
+              />
             </label>
           </h3>
           <div>
@@ -139,7 +166,9 @@ const ManageGlobalNotification = (props: Props): JSX.Element => {
               type="text"
               name="triggerPath"
               value={triggerPath}
-              onChange={(e) => { setTriggerPath(e.target.value) }}
+              onChange={(e) => {
+                setTriggerPath(e.target.value);
+              }}
               required
             />
           </div>
@@ -154,7 +183,9 @@ const ManageGlobalNotification = (props: Props): JSX.Element => {
                 name="notifyType"
                 value="mail"
                 checked={notifyType === NotifyType.Email}
-                onChange={() => { setNotifyType(NotifyType.Email) }}
+                onChange={() => {
+                  setNotifyType(NotifyType.Email);
+                }}
               />
               <label className="form-label form-check-label" htmlFor="mail">
                 <p className="fw-bold">Email</p>
@@ -168,7 +199,9 @@ const ManageGlobalNotification = (props: Props): JSX.Element => {
                 name="notifyType"
                 value="slack"
                 checked={notifyType === NotifyType.SLACK}
-                onChange={() => { setNotifyType(NotifyType.SLACK) }}
+                onChange={() => {
+                  setNotifyType(NotifyType.SLACK);
+                }}
               />
               <label className="form-label form-check-label" htmlFor="slack">
                 <p className="fw-bold">Slack</p>
@@ -176,57 +209,77 @@ const ManageGlobalNotification = (props: Props): JSX.Element => {
             </div>
           </div>
 
-          {notifyType === NotifyType.Email
-            ? (
-              <>
-                <div className="input-group notify-to-option" id="mail-input">
-                  <div>
-                    <span className="input-group-text" id="mail-addon"></span><span className="material-symbols-outlined">mail</span>
-                  </div>
-                  <input
-                    className="form-control"
-                    type="text"
-                    aria-describedby="mail-addon"
-                    name="toEmail"
-                    placeholder="Email"
-                    value={emailToSend}
-                    onChange={(e) => { setEmailToSend(e.target.value) }}
-                  />
-
+          {notifyType === NotifyType.Email ? (
+            <>
+              <div className="input-group notify-to-option" id="mail-input">
+                <div>
+                  <span className="input-group-text" id="mail-addon"></span>
+                  <span className="material-symbols-outlined">mail</span>
                 </div>
-
-                <p className="p-2">
-                  {/* eslint-disable-next-line react/no-danger */}
-                  {!isMailerSetup && <span className="form-text text-muted" dangerouslySetInnerHTML={{ __html: t('admin:mailer_setup_required') }} />}
-                  <b>Hint: </b>
-                  <a href="https://ifttt.com/create" target="blank">{t('notification_settings.email.ifttt_link')}
-                    <span className="material-symbols-outlined">share</span>
-                  </a>
-                </p>
-              </>
-            )
-            : (
-              <>
-                <div className="input-group notify-to-option" id="slack-input">
-                  <div>
-                    <span className="input-group-text" id="slack-channel-addon"></span><span className="material-symbols-outlined">tag</span>
-                  </div>
-                  <input
-                    className="form-control"
-                    type="text"
-                    aria-describedby="slack-channel-addon"
-                    name="notificationGlobal[slackChannels]"
-                    placeholder="Slack Channel"
-                    value={slackChannelToSend}
-                    onChange={(e) => { setSlackChannelToSend(e.target.value) }}
+                <input
+                  className="form-control"
+                  type="text"
+                  aria-describedby="mail-addon"
+                  name="toEmail"
+                  placeholder="Email"
+                  value={emailToSend}
+                  onChange={(e) => {
+                    setEmailToSend(e.target.value);
+                  }}
+                />
+              </div>
+
+              <p className="p-2">
+                {/* eslint-disable-next-line react/no-danger */}
+                {!isMailerSetup && (
+                  <span
+                    className="form-text text-muted"
+                    // biome-ignore lint/security/noDangerouslySetInnerHtml: trusted translation markup
+                    dangerouslySetInnerHTML={{
+                      __html: t('admin:mailer_setup_required'),
+                    }}
                   />
+                )}
+                <b>Hint: </b>
+                <a href="https://ifttt.com/create" target="blank">
+                  {t('notification_settings.email.ifttt_link')}
+                  <span className="material-symbols-outlined">share</span>
+                </a>
+              </p>
+            </>
+          ) : (
+            <>
+              <div className="input-group notify-to-option" id="slack-input">
+                <div>
+                  <span
+                    className="input-group-text"
+                    id="slack-channel-addon"
+                  ></span>
+                  <span className="material-symbols-outlined">tag</span>
                 </div>
-                <p className="p-2">
-                  {/* eslint-disable-next-line react/no-danger */}
-                  <span dangerouslySetInnerHTML={{ __html: t('notification_settings.channel_desc') }} />
-                </p>
-              </>
-            )}
+                <input
+                  className="form-control"
+                  type="text"
+                  aria-describedby="slack-channel-addon"
+                  name="notificationGlobal[slackChannels]"
+                  placeholder="Slack Channel"
+                  value={slackChannelToSend}
+                  onChange={(e) => {
+                    setSlackChannelToSend(e.target.value);
+                  }}
+                />
+              </div>
+              <p className="p-2">
+                {/* eslint-disable-next-line react/no-danger */}
+                <span
+                  // biome-ignore lint/security/noDangerouslySetInnerHtml: trusted translation markup
+                  dangerouslySetInnerHTML={{
+                    __html: t('notification_settings.channel_desc'),
+                  }}
+                />
+              </p>
+            </>
+          )}
         </div>
 
         <div className="offset-1 col-sm-5">
@@ -240,7 +293,8 @@ const ManageGlobalNotification = (props: Props): JSX.Element => {
                 onChange={() => onChangeTriggerEvents(TriggerEventType.CREATE)}
               >
                 <span className="badge rounded-pill bg-success">
-                  <span className="material-symbols-outlined">edit_note</span> CREATE
+                  <span className="material-symbols-outlined">edit_note</span>{' '}
+                  CREATE
                 </span>
               </TriggerEventCheckBox>
             </div>
@@ -276,7 +330,10 @@ const ManageGlobalNotification = (props: Props): JSX.Element => {
                 onChange={() => onChangeTriggerEvents(TriggerEventType.DELETE)}
               >
                 <span className="badge rounded-pill bg-danger">
-                  <span className="material-symbols-outlined">delete_forever</span>DELETE
+                  <span className="material-symbols-outlined">
+                    delete_forever
+                  </span>
+                  DELETE
                 </span>
               </TriggerEventCheckBox>
             </div>
@@ -288,7 +345,8 @@ const ManageGlobalNotification = (props: Props): JSX.Element => {
                 onChange={() => onChangeTriggerEvents(TriggerEventType.LIKE)}
               >
                 <span className="badge rounded-pill bg-info">
-                  <span className="material-symbols-outlined">favorite</span>LIKE
+                  <span className="material-symbols-outlined">favorite</span>
+                  LIKE
                 </span>
               </TriggerEventCheckBox>
             </div>
@@ -300,11 +358,11 @@ const ManageGlobalNotification = (props: Props): JSX.Element => {
                 onChange={() => onChangeTriggerEvents(TriggerEventType.POST)}
               >
                 <span className="badge rounded-pill bg-primary">
-                  <span className="material-symbols-outlined">language</span>POST
+                  <span className="material-symbols-outlined">language</span>
+                  POST
                 </span>
               </TriggerEventCheckBox>
             </div>
-
           </div>
         </div>
       </div>

+ 18 - 11
apps/app/src/client/components/Admin/Notification/NotificationDeleteModal.jsx

@@ -1,37 +1,44 @@
 import React from 'react';
-
 import { useTranslation } from 'next-i18next';
 import PropTypes from 'prop-types';
-import {
-  Modal, ModalHeader, ModalBody, ModalFooter,
-} from 'reactstrap';
+import { Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
 
 class NotificationDeleteModal extends React.PureComponent {
-
   render() {
     const { t, notificationForConfiguration } = this.props;
     return (
       <Modal isOpen={this.props.isOpen} toggle={this.props.onClose}>
-        <ModalHeader tag="h4" toggle={this.props.onClose} className="text-danger">
-          <span className="material-symbols-outlined">delete_forever</span>Delete Global Notification Setting
+        <ModalHeader
+          tag="h4"
+          toggle={this.props.onClose}
+          className="text-danger"
+        >
+          <span className="material-symbols-outlined">delete_forever</span>
+          Delete Global Notification Setting
         </ModalHeader>
         <ModalBody>
           <p>
-            {t('notification_settings.delete_notification_pattern_desc1', { path: notificationForConfiguration.triggerPath })}
+            {t('notification_settings.delete_notification_pattern_desc1', {
+              path: notificationForConfiguration.triggerPath,
+            })}
           </p>
           <p className="text-danger">
             {t('notification_settings.delete_notification_pattern_desc2')}
           </p>
         </ModalBody>
         <ModalFooter>
-          <button type="button" className="btn btn-sm btn-danger" onClick={this.props.onClickSubmit}>
-            <span className="material-symbols-outlined">delete_forever</span> {t('Delete')}
+          <button
+            type="button"
+            className="btn btn-sm btn-danger"
+            onClick={this.props.onClickSubmit}
+          >
+            <span className="material-symbols-outlined">delete_forever</span>{' '}
+            {t('Delete')}
           </button>
         </ModalFooter>
       </Modal>
     );
   }
-
 }
 
 NotificationDeleteModal.propTypes = {

+ 93 - 54
apps/app/src/client/components/Admin/Notification/NotificationSetting.jsx

@@ -1,13 +1,8 @@
-import React, {
-  useCallback, useEffect, useMemo, useState,
-} from 'react';
-
+import React, { useCallback, useEffect, useState } from 'react';
 import { SlackbotType } from '@growi/slack';
 import { useTranslation } from 'next-i18next';
 import PropTypes from 'prop-types';
-import {
-  TabContent, TabPane,
-} from 'reactstrap';
+import { TabContent, TabPane } from 'reactstrap';
 
 import AdminNotificationContainer from '~/client/services/AdminNotificationContainer';
 import { toastError } from '~/client/util/toastr';
@@ -16,8 +11,6 @@ import loggerFactory from '~/utils/logger';
 
 import CustomNav from '../../CustomNavigation/CustomNav';
 import { withUnstatedContainers } from '../../UnstatedUtils';
-
-
 import GlobalNotification from './GlobalNotification';
 import UserTriggerNotification from './UserTriggerNotification';
 
@@ -25,14 +18,34 @@ const logger = loggerFactory('growi:NotificationSetting');
 
 let retrieveErrors = null;
 
+const SettingsIcon = () => (
+  <span className="material-symbols-outlined">settings</span>
+);
+
+const navTabMapping = {
+  user_trigger_notification: {
+    Icon: SettingsIcon,
+    i18n: 'User trigger notification',
+  },
+  global_notification: {
+    Icon: SettingsIcon,
+    i18n: 'Global notification',
+  },
+};
 
 // eslint-disable-next-line react/prop-types
 const Badge = ({ isEnabled }) => {
   const { t } = useTranslation('admin');
 
-  return isEnabled
-    ? <span className="badge text-bg-success">{t('external_notification.enabled')}</span>
-    : <span className="badge text-bg-primary">{t('external_notification.disabled')}</span>;
+  return isEnabled ? (
+    <span className="badge text-bg-success">
+      {t('external_notification.enabled')}
+    </span>
+  ) : (
+    <span className="badge text-bg-primary">
+      {t('external_notification.disabled')}
+    </span>
+  );
 };
 
 const SkeletonListItem = () => (
@@ -48,20 +61,32 @@ const SkeletonListItem = () => (
 const SlackIntegrationListItem = ({ isEnabled, currentBotType }) => {
   const { t } = useTranslation('admin');
 
-  const isCautionVisible = currentBotType === SlackbotType.OFFICIAL || currentBotType === SlackbotType.CUSTOM_WITH_PROXY;
+  const isCautionVisible =
+    currentBotType === SlackbotType.OFFICIAL ||
+    currentBotType === SlackbotType.CUSTOM_WITH_PROXY;
 
   return (
-    <li data-testid="slack-integration-list-item" className="list-group-item bg-body-tertiary">
+    <li
+      data-testid="slack-integration-list-item"
+      className="list-group-item bg-body-tertiary"
+    >
       <h4>
         <Badge isEnabled={isEnabled} />
-        <a href="/admin/slack-integration" className="ms-2">{t('slack_integration.slack_integration')}</a>
+        <a href="/admin/slack-integration" className="ms-2">
+          {t('slack_integration.slack_integration')}
+        </a>
       </h4>
-      { isCautionVisible && (
+      {isCautionVisible && (
         <ul className="mt-2 ps-4">
           {/* eslint-disable-next-line react/no-danger */}
-          <li dangerouslySetInnerHTML={{ __html: t('external_notification.caution_enabled') }} />
+          <li
+            // biome-ignore lint/security/noDangerouslySetInnerHtml: trusted translation markup
+            dangerouslySetInnerHTML={{
+              __html: t('external_notification.caution_enabled'),
+            }}
+          />
         </ul>
-      ) }
+      )}
     </li>
   );
 };
@@ -74,16 +99,24 @@ const LegacySlackIntegrationListItem = ({ isEnabled }) => {
     <li className="list-group-item">
       <h4>
         <Badge isEnabled={isEnabled} />
-        <a href="/admin/slack-integration-legacy" className="ms-2">{t('slack_integration_legacy.slack_integration_legacy')}</a>
+        <a href="/admin/slack-integration-legacy" className="ms-2">
+          {t('slack_integration_legacy.slack_integration_legacy')}
+        </a>
       </h4>
-      { isEnabled && (
+      {isEnabled && (
         <ul className="mt-2 ps-4">
           <li>
             {/* eslint-disable-next-line react/no-danger */}
-            <span className="text-danger" dangerouslySetInnerHTML={{ __html: t('slack_integration_legacy.alert_deplicated') }}></span>
+            <span
+              className="text-danger"
+              // biome-ignore lint/security/noDangerouslySetInnerHtml: trusted translation markup
+              dangerouslySetInnerHTML={{
+                __html: t('slack_integration_legacy.alert_deplicated'),
+              }}
+            ></span>
           </li>
         </ul>
-      ) }
+      )}
     </li>
   );
 };
@@ -95,24 +128,24 @@ function NotificationSetting(props) {
 
   const [isMounted, setMounted] = useState(false);
   const [activeTab, setActiveTab] = useState('user_trigger_notification');
-  const [activeComponents, setActiveComponents] = useState(new Set(['user_trigger_notification']));
+  const [activeComponents, setActiveComponents] = useState(
+    new Set(['user_trigger_notification']),
+  );
 
   const switchActiveTab = (selectedTab) => {
     setActiveTab(selectedTab);
     setActiveComponents(activeComponents.add(selectedTab));
   };
 
-  const fetchData = useCallback(async() => {
+  const fetchData = useCallback(async () => {
     try {
       await adminNotificationContainer.retrieveNotificationData();
-    }
-    catch (err) {
+    } catch (err) {
       const errs = toArrayIfNot(err);
       toastError(errs);
       logger.error(errs);
       retrieveErrors = errs;
-    }
-    finally {
+    } finally {
       setMounted(true);
     }
   }, [adminNotificationContainer]);
@@ -121,39 +154,37 @@ function NotificationSetting(props) {
     fetchData();
   }, [fetchData]);
 
-  const navTabMapping = useMemo(() => {
-    return {
-      user_trigger_notification: {
-        Icon: () => <span className="material-symbols-outlined">settings</span>,
-        i18n: 'User trigger notification',
-      },
-      global_notification: {
-        Icon: () => <span className="material-symbols-outlined">settings</span>,
-        i18n: 'Global notification',
-      },
-    };
-  }, []);
-
-  const { isSlackbotConfigured, isSlackLegacyConfigured, currentBotType } = adminNotificationContainer.state;
+  const { isSlackbotConfigured, isSlackLegacyConfigured, currentBotType } =
+    adminNotificationContainer.state;
   const isSlackEnabled = isSlackbotConfigured;
   const isSlackLegacyEnabled = !isSlackbotConfigured && isSlackLegacyConfigured;
 
   return (
     <div data-testid="admin-notification">
-      <h2 className="admin-setting-header">{t('external_notification.header_status')}</h2>
+      <h2 className="admin-setting-header">
+        {t('external_notification.header_status')}
+      </h2>
       <ul className="list-group">
-        { !isMounted && <SkeletonListItem />}
-        { isMounted && (
+        {!isMounted && <SkeletonListItem />}
+        {isMounted && (
           <>
-            <SlackIntegrationListItem isEnabled={isSlackEnabled} currentBotType={currentBotType} />
+            <SlackIntegrationListItem
+              isEnabled={isSlackEnabled}
+              currentBotType={currentBotType}
+            />
             {/* Legacy Slack Integration become visible only when new Slack Integration is disabled */}
-            { !isSlackEnabled && <LegacySlackIntegrationListItem isEnabled={isSlackLegacyEnabled} /> }
+            {!isSlackEnabled && (
+              <LegacySlackIntegrationListItem
+                isEnabled={isSlackLegacyEnabled}
+              />
+            )}
           </>
-        ) }
+        )}
       </ul>
 
-
-      <h2 className="admin-setting-header mt-5">{t('notification_settings.notification_settings')}</h2>
+      <h2 className="admin-setting-header mt-5">
+        {t('notification_settings.notification_settings')}
+      </h2>
 
       <CustomNav
         activeTab={activeTab}
@@ -165,20 +196,28 @@ function NotificationSetting(props) {
 
       <TabContent activeTab={activeTab} className="p-5">
         <TabPane tabId="user_trigger_notification">
-          {activeComponents.has('user_trigger_notification') && <UserTriggerNotification />}
+          {activeComponents.has('user_trigger_notification') && (
+            <UserTriggerNotification />
+          )}
         </TabPane>
         <TabPane tabId="global_notification">
-          {activeComponents.has('global_notification') && <GlobalNotification />}
+          {activeComponents.has('global_notification') && (
+            <GlobalNotification />
+          )}
         </TabPane>
       </TabContent>
     </div>
   );
 }
 
-const NotificationSettingWithUnstatedContainer = withUnstatedContainers(NotificationSetting, [AdminNotificationContainer]);
+const NotificationSettingWithUnstatedContainer = withUnstatedContainers(
+  NotificationSetting,
+  [AdminNotificationContainer],
+);
 
 NotificationSetting.propTypes = {
-  adminNotificationContainer: PropTypes.instanceOf(AdminNotificationContainer).isRequired,
+  adminNotificationContainer: PropTypes.instanceOf(AdminNotificationContainer)
+    .isRequired,
 };
 
 export default NotificationSettingWithUnstatedContainer;

+ 8 - 6
apps/app/src/client/components/Admin/Notification/NotificationTypeIcon.tsx

@@ -1,18 +1,18 @@
 import React, { type JSX } from 'react';
-
 import { UncontrolledTooltip } from 'reactstrap';
 
 import type { INotificationType } from '~/client/interfaces/notification';
 
-
 type NotificationTypeIconProps = {
   // supports 2 types:
   //   User trigger notification -> has 'provider: slack'
   //   Global notification -> has '__t: slack|mail'
-  notification: INotificationType
-}
+  notification: INotificationType;
+};
 
-export const NotificationTypeIcon = (props: NotificationTypeIconProps): JSX.Element => {
+export const NotificationTypeIcon = (
+  props: NotificationTypeIconProps,
+): JSX.Element => {
   const { __t, _id, provider } = props.notification;
 
   const type = __t != null && __t === 'mail' ? 'mail' : 'slack';
@@ -28,7 +28,9 @@ export const NotificationTypeIcon = (props: NotificationTypeIconProps): JSX.Elem
 
   return (
     <>
-      <span id={elemId} className="material-symbols-outlined me-1">{iconName}</span>
+      <span id={elemId} className="material-symbols-outlined me-1">
+        {iconName}
+      </span>
       <UncontrolledTooltip target={elemId}>{toolChip}</UncontrolledTooltip>
     </>
   );

+ 5 - 5
apps/app/src/client/components/Admin/Notification/TriggerEventCheckBox.jsx

@@ -1,5 +1,4 @@
 import React from 'react';
-
 import { useTranslation } from 'next-i18next';
 import PropTypes from 'prop-types';
 
@@ -15,15 +14,16 @@ const TriggerEventCheckBox = (props) => {
         checked={props.checked}
         onChange={props.onChange}
       />
-      <label className="form-label form-check-label" htmlFor={`trigger-event-${props.event}`}>
-        {props.children}{' '}
-        {t(`notification_settings.event_${props.event}`)}
+      <label
+        className="form-label form-check-label"
+        htmlFor={`trigger-event-${props.event}`}
+      >
+        {props.children} {t(`notification_settings.event_${props.event}`)}
       </label>
     </div>
   );
 };
 
-
 TriggerEventCheckBox.propTypes = {
   t: PropTypes.func.isRequired, // i18next
 

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