浏览代码

Merge branch 'master' into fix/disable-logo-update-without-file

hikaruNAKANO 3 月之前
父节点
当前提交
a287d9daf7
共有 100 个文件被更改,包括 2957 次插入2152 次删除
  1. 0 1
      .devcontainer/app/devcontainer.json
  2. 3 0
      .devcontainer/app/postCreateCommand.sh
  3. 0 1
      .devcontainer/pdf-converter/devcontainer.json
  4. 3 0
      .devcontainer/pdf-converter/postCreateCommand.sh
  5. 0 88
      .eslintrc.js
  6. 0 1
      .github/mergify.yml
  7. 2 2
      .github/workflows/ci-app.yml
  8. 1 1
      .github/workflows/ci-pdf-converter.yml
  9. 1 1
      .github/workflows/ci-slackbot-proxy.yml
  10. 5 30
      .github/workflows/release-rc.yml
  11. 11 46
      .github/workflows/release.yml
  12. 0 1
      .serena/memories/apps-app-development-patterns.md
  13. 37 0
      .serena/memories/apps-app-google-workspace-oauth2-mail.md
  14. 105 0
      .serena/memories/apps-app-page-path-nav-and-sub-navigation-layering.md
  15. 3 13
      .serena/memories/coding_conventions.md
  16. 0 1
      .serena/memories/project_structure.md
  17. 1 2
      .serena/memories/task_completion_checklist.md
  18. 8 61
      .vscode/settings.json
  19. 74 0
      AGENTS.md
  20. 40 1
      CHANGELOG.md
  21. 1 97
      CLAUDE.md
  22. 0 136
      apps/app/.eslintrc.js
  23. 84 0
      apps/app/AGENTS.md
  24. 1 0
      apps/app/CLAUDE.md
  25. 1 1
      apps/app/bin/openapi/generate-operation-ids/cli.spec.ts
  26. 1 1
      apps/app/bin/openapi/generate-operation-ids/cli.ts
  27. 1 1
      apps/app/bin/openapi/generate-operation-ids/generate-operation-ids.spec.ts
  28. 2 0
      apps/app/bin/print-memory-consumption.ts
  29. 1 2
      apps/app/config/migrate-mongo-config.js
  30. 0 1
      apps/app/config/next-i18next.config.js
  31. 1 1
      apps/app/docker/README.md
  32. 0 3
      apps/app/next.config.js
  33. 3 5
      apps/app/package.json
  34. 0 16
      apps/app/playwright/.eslintrc.mjs
  35. 6 3
      apps/app/playwright/50-sidebar/access-to-sidebar.spec.ts
  36. 2 1
      apps/app/playwright/60-home/home.spec.ts
  37. 7 4
      apps/app/playwright/utils/Login.ts
  38. 2 2
      apps/app/public/static/locales/en_US/admin.json
  39. 5 0
      apps/app/public/static/locales/en_US/translation.json
  40. 2 2
      apps/app/public/static/locales/fr_FR/admin.json
  41. 5 0
      apps/app/public/static/locales/fr_FR/translation.json
  42. 2 2
      apps/app/public/static/locales/ja_JP/admin.json
  43. 5 0
      apps/app/public/static/locales/ja_JP/translation.json
  44. 3 3
      apps/app/public/static/locales/ko_KR/admin.json
  45. 5 0
      apps/app/public/static/locales/ko_KR/translation.json
  46. 2 2
      apps/app/public/static/locales/zh_CN/admin.json
  47. 5 0
      apps/app/public/static/locales/zh_CN/translation.json
  48. 0 5
      apps/app/src/client/components/.eslintrc.js
  49. 0 134
      apps/app/src/client/components/Admin/AdminHome/AdminHome.jsx
  50. 168 0
      apps/app/src/client/components/Admin/AdminHome/AdminHome.tsx
  51. 8 8
      apps/app/src/client/components/Admin/AdminHome/EnvVarsTable.tsx
  52. 20 28
      apps/app/src/client/components/Admin/AdminHome/SystemInfomationTable.tsx
  53. 1 0
      apps/app/src/client/components/Admin/AdminHome/index.ts
  54. 110 75
      apps/app/src/client/components/Admin/App/AppSetting.jsx
  55. 54 32
      apps/app/src/client/components/Admin/App/AppSettingsPageContents.tsx
  56. 50 19
      apps/app/src/client/components/Admin/App/AwsSetting.tsx
  57. 106 36
      apps/app/src/client/components/Admin/App/AzureSetting.tsx
  58. 22 25
      apps/app/src/client/components/Admin/App/ConfirmModal.tsx
  59. 59 50
      apps/app/src/client/components/Admin/App/FileUploadSetting.tsx
  60. 36 30
      apps/app/src/client/components/Admin/App/FileUploadSetting.types.ts
  61. 77 28
      apps/app/src/client/components/Admin/App/GcsSetting.tsx
  62. 76 50
      apps/app/src/client/components/Admin/App/MailSetting.tsx
  63. 53 22
      apps/app/src/client/components/Admin/App/MaintenanceMode.tsx
  64. 26 22
      apps/app/src/client/components/Admin/App/MaskedInput.tsx
  65. 63 35
      apps/app/src/client/components/Admin/App/PageBulkExportSettings.tsx
  66. 16 11
      apps/app/src/client/components/Admin/App/SesSetting.tsx
  67. 61 37
      apps/app/src/client/components/Admin/App/SiteUrlSetting.tsx
  68. 26 12
      apps/app/src/client/components/Admin/App/SmtpSetting.tsx
  69. 43 31
      apps/app/src/client/components/Admin/App/V5PageMigration.tsx
  70. 42 18
      apps/app/src/client/components/Admin/App/useFileUploadSettings.spec.ts
  71. 54 28
      apps/app/src/client/components/Admin/App/useFileUploadSettings.ts
  72. 32 13
      apps/app/src/client/components/Admin/AuditLog/ActivityTable.tsx
  73. 10 5
      apps/app/src/client/components/Admin/AuditLog/AuditLogDisableMode.tsx
  74. 38 15
      apps/app/src/client/components/Admin/AuditLog/AuditLogSettings.tsx
  75. 49 44
      apps/app/src/client/components/Admin/AuditLog/DateRangePicker.tsx
  76. 54 26
      apps/app/src/client/components/Admin/AuditLog/SearchUsernameTypeahead.tsx
  77. 151 88
      apps/app/src/client/components/Admin/AuditLog/SelectActionDropdown.tsx
  78. 142 73
      apps/app/src/client/components/Admin/AuditLogManagement.tsx
  79. 2 6
      apps/app/src/client/components/Admin/Common/Accordion.jsx
  80. 11 5
      apps/app/src/client/components/Admin/Common/AdminInstallButtonRow.tsx
  81. 5 7
      apps/app/src/client/components/Admin/Common/AdminUpdateButtonRow.tsx
  82. 17 12
      apps/app/src/client/components/Admin/Common/LabeledProgressBar.tsx
  83. 8 10
      apps/app/src/client/components/Admin/Customize/Customize.jsx
  84. 36 28
      apps/app/src/client/components/Admin/Customize/CustomizeCssSetting.tsx
  85. 7 11
      apps/app/src/client/components/Admin/Customize/CustomizeFunctionOption.tsx
  86. 105 44
      apps/app/src/client/components/Admin/Customize/CustomizeFunctionSetting.tsx
  87. 69 56
      apps/app/src/client/components/Admin/Customize/CustomizeLayoutSetting.tsx
  88. 127 60
      apps/app/src/client/components/Admin/Customize/CustomizeLogoSetting.tsx
  89. 85 75
      apps/app/src/client/components/Admin/Customize/CustomizeNoscriptSetting.tsx
  90. 36 18
      apps/app/src/client/components/Admin/Customize/CustomizePresentationSetting.tsx
  91. 52 38
      apps/app/src/client/components/Admin/Customize/CustomizeScriptSetting.tsx
  92. 58 55
      apps/app/src/client/components/Admin/Customize/CustomizeSidebarSetting.tsx
  93. 19 15
      apps/app/src/client/components/Admin/Customize/CustomizeThemeOptions.tsx
  94. 25 23
      apps/app/src/client/components/Admin/Customize/CustomizeThemeSetting.tsx
  95. 90 62
      apps/app/src/client/components/Admin/Customize/CustomizeTitle.tsx
  96. 12 11
      apps/app/src/client/components/Admin/Customize/PagingSizeUncontrolledDropdown.jsx
  97. 53 25
      apps/app/src/client/components/Admin/Customize/ThemeColorBox.tsx
  98. 29 34
      apps/app/src/client/components/Admin/ElasticsearchManagement/ElasticsearchManagement.tsx
  99. 12 9
      apps/app/src/client/components/Admin/ElasticsearchManagement/NormalizeIndicesControls.tsx
  100. 11 15
      apps/app/src/client/components/Admin/ElasticsearchManagement/RebuildIndexControls.jsx

+ 0 - 1
.devcontainer/app/devcontainer.json

@@ -26,7 +26,6 @@
         // AI
         "anthropic.claude-code",
         // linter
-        "dbaeumer.vscode-eslint",
         "biomejs.biome",
         "editorconfig.editorconfig",
         "shinnn.stylelint",

+ 3 - 0
.devcontainer/app/postCreateCommand.sh

@@ -27,3 +27,6 @@ pnpm install @anthropic-ai/claude-code --global
 
 # Install dependencies
 turbo run bootstrap
+
+# Install Lefthook git hooks
+pnpm lefthook install

+ 0 - 1
.devcontainer/pdf-converter/devcontainer.json

@@ -15,7 +15,6 @@
   "customizations": {
     "vscode": {
       "extensions": [
-        "dbaeumer.vscode-eslint",
         "biomejs.biome",
         "mhutchie.git-graph",
         "eamodio.gitlens"

+ 3 - 0
.devcontainer/pdf-converter/postCreateCommand.sh

@@ -22,3 +22,6 @@ pnpm install turbo --global
 
 # Install dependencies
 turbo run bootstrap
+
+# Install Lefthook git hooks
+pnpm lefthook install

+ 0 - 88
.eslintrc.js

@@ -1,88 +0,0 @@
-/**
- * @type {import('eslint').Linter.Config}
- */
-module.exports = {
-  root: true, // https://eslint.org/docs/user-guide/configuring/configuration-files#cascading-and-hierarchy
-  extends: [
-    'weseek',
-    'weseek/typescript',
-  ],
-  plugins: [
-    'regex',
-  ],
-  ignorePatterns: [
-    'node_modules/**',
-  ],
-  rules: {
-    'import/prefer-default-export': 'off',
-    'import/order': [
-      'warn',
-      {
-        pathGroups: [
-          {
-            pattern: 'react',
-            group: 'builtin',
-            position: 'before',
-          },
-          {
-            pattern: '^/**',
-            group: 'parent',
-            position: 'before',
-          },
-          {
-            pattern: '~/**',
-            group: 'parent',
-            position: 'before',
-          },
-          {
-            pattern: '*.css',
-            group: 'type',
-            patternOptions: { matchBase: true },
-            position: 'after',
-          },
-          {
-            pattern: '*.scss',
-            group: 'type',
-            patternOptions: { matchBase: true },
-            position: 'after',
-          },
-        ],
-        alphabetize: {
-          order: 'asc',
-        },
-        pathGroupsExcludedImportTypes: ['react'],
-        'newlines-between': 'always',
-      },
-    ],
-    '@typescript-eslint/consistent-type-imports': 'warn',
-    '@typescript-eslint/explicit-module-boundary-types': 'off',
-    indent: [
-      'error',
-      2,
-      {
-        SwitchCase: 1,
-        ArrayExpression: 'first',
-        FunctionDeclaration: { body: 1, parameters: 2 },
-        FunctionExpression: { body: 1, parameters: 2 },
-      },
-    ],
-    'regex/invalid': ['error', [
-      {
-        regex: '\\?\\<\\!',
-        message: 'Do not use any negative lookbehind',
-      }, {
-        regex: '\\?\\<\\=',
-        message: 'Do not use any Positive lookbehind',
-      },
-    ]],
-  },
-  overrides: [
-    {
-      // enable the rule specifically for TypeScript files
-      files: ['*.ts', '*.mts', '*.tsx'],
-      rules: {
-        '@typescript-eslint/explicit-module-boundary-types': ['error'],
-      },
-    },
-  ],
-};

+ 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

+ 2 - 2
.github/workflows/ci-app.yml

@@ -11,7 +11,7 @@ on:
     paths:
       - .github/mergify.yml
       - .github/workflows/ci-app.yml
-      - .eslint*
+      - biome.json
       - tsconfig.base.json
       - turbo.json
       - pnpm-lock.yaml
@@ -24,7 +24,7 @@ on:
     paths:
       - .github/mergify.yml
       - .github/workflows/ci-app.yml
-      - .eslint*
+      - biome.json
       - tsconfig.base.json
       - turbo.json
       - pnpm-lock.yaml

+ 1 - 1
.github/workflows/ci-pdf-converter.yml

@@ -9,7 +9,7 @@ on:
     paths:
       - .github/mergify.yml
       - .github/workflows/ci-pdf-converter.yml
-      - .eslint*
+      - biome.json
       - tsconfig.base.json
       - turbo.json
       - pnpm-lock.yaml

+ 1 - 1
.github/workflows/ci-slackbot-proxy.yml

@@ -9,7 +9,7 @@ on:
     paths:
       - .github/mergify.yml
       - .github/workflows/ci-slackbot-proxy.yml
-      - .eslint*
+      - biome.json
       - tsconfig.base.json
       - turbo.json
       - pnpm-lock.yaml

+ 5 - 30
.github/workflows/release-rc.yml

@@ -17,8 +17,7 @@ jobs:
     runs-on: ubuntu-latest
 
     outputs:
-      TAGS_WESEEK: ${{ steps.meta-weseek.outputs.tags }}
-      TAGS_GROWILABS: ${{ steps.meta-growilabs.outputs.tags }}
+      TAGS: ${{ steps.meta.outputs.tags }}
 
     steps:
     - uses: actions/checkout@v4
@@ -27,19 +26,9 @@ jobs:
       uses: myrotvorets/info-from-package-json-action@v2.0.2
       id: package-json
 
-    - name: Docker meta for weseek/growi
+    - name: Docker meta for docker.io
       uses: docker/metadata-action@v5
-      id: meta-weseek
-      with:
-        images: docker.io/weseek/growi
-        sep-tags: ','
-        tags: |
-          type=raw,value=${{ steps.package-json.outputs.packageVersion }}
-          type=raw,value=${{ steps.package-json.outputs.packageVersion }}.{{sha}}
-
-    - name: Docker meta for growilabs/growi
-      uses: docker/metadata-action@v5
-      id: meta-growilabs
+      id: meta
       with:
         images: docker.io/growilabs/growi
         sep-tags: ','
@@ -55,29 +44,15 @@ jobs:
     secrets:
       AWS_ROLE_TO_ASSUME_FOR_OIDC: ${{ secrets.AWS_ROLE_TO_ASSUME_FOR_OIDC }}
 
-
-  publish-rc-image-for-growilabs:
+  publish-image-rc:
     needs: [determine-tags, build-image-rc]
 
     uses: growilabs/growi/.github/workflows/reusable-app-create-manifests.yml@master
     with:
-      tags: ${{ needs.determine-tags.outputs.TAGS_GROWILABS }}
+      tags: ${{ needs.determine-tags.outputs.TAGS }}
       registry: docker.io
       image-name: 'growilabs/growi'
       docker-registry-username: 'growimoogle'
       tag-temporary: latest-rc
     secrets:
       DOCKER_REGISTRY_PASSWORD: ${{ secrets.DOCKER_REGISTRY_PASSWORD_GROWIMOOGLE }}
-
-  publish-rc-image-for-weseek:
-    needs: [determine-tags, build-image-rc]
-
-    uses: growilabs/growi/.github/workflows/reusable-app-create-manifests.yml@master
-    with:
-      tags: ${{ needs.determine-tags.outputs.TAGS_WESEEK }}
-      registry: docker.io
-      image-name: 'growilabs/growi'
-      docker-registry-username: 'wsmoogle'
-      tag-temporary: latest-rc
-    secrets:
-      DOCKER_REGISTRY_PASSWORD: ${{ secrets.DOCKER_REGISTRY_PASSWORD }}

+ 11 - 46
.github/workflows/release.yml

@@ -1,4 +1,3 @@
-# TODO: https://redmine.weseek.co.jp/issues/171293
 name: Release
 
 on:
@@ -81,8 +80,7 @@ jobs:
     runs-on: ubuntu-latest
 
     outputs:
-      TAGS_WESEEK: ${{ steps.meta-weseek.outputs.tags }}
-      TAGS_GROWILABS: ${{ steps.meta-growilabs.outputs.tags }}
+      TAGS: ${{ steps.meta.outputs.tags }}
 
     steps:
     - uses: actions/checkout@v4
@@ -91,21 +89,9 @@ jobs:
       uses: myrotvorets/info-from-package-json-action@v2.0.2
       id: package-json
 
-    - name: Docker meta for weseek/growi
+    - name: Docker meta for docker.io
       uses: docker/metadata-action@v5
-      id: meta-weseek
-      with:
-        images: docker.io/weseek/growi
-        sep-tags: ','
-        tags: |
-          type=raw,value=latest
-          type=semver,value=${{ needs.create-github-release.outputs.RELEASED_VERSION }},pattern={{major}}
-          type=semver,value=${{ needs.create-github-release.outputs.RELEASED_VERSION }},pattern={{major}}.{{minor}}
-          type=semver,value=${{ needs.create-github-release.outputs.RELEASED_VERSION }},pattern={{major}}.{{minor}}.{{patch}}
-
-    - name: Docker meta for growilabs/growi
-      uses: docker/metadata-action@v5
-      id: meta-growilabs
+      id: meta
       with:
         images: docker.io/growilabs/growi
         sep-tags: ','
@@ -126,12 +112,12 @@ jobs:
     secrets:
       AWS_ROLE_TO_ASSUME_FOR_OIDC: ${{ secrets.AWS_ROLE_TO_ASSUME_FOR_OIDC }}
 
-  publish-app-image-for-growilabs:
+  publish-app-image:
     needs: [determine-tags, build-app-image]
 
     uses: growilabs/growi/.github/workflows/reusable-app-create-manifests.yml@master
     with:
-      tags: ${{ needs.determine-tags.outputs.TAGS_GROWILABS }}
+      tags: ${{ needs.determine-tags.outputs.TAGS }}
       registry: docker.io
       image-name: 'growilabs/growi'
       docker-registry-username: 'growimoogle'
@@ -139,42 +125,21 @@ jobs:
     secrets:
       DOCKER_REGISTRY_PASSWORD: ${{ secrets.DOCKER_REGISTRY_PASSWORD_GROWIMOOGLE }}
 
-  publish-app-image-for-weseek:
-    needs: [determine-tags, build-app-image]
-
-    uses: growilabs/growi/.github/workflows/reusable-app-create-manifests.yml@master
-    with:
-      tags: ${{ needs.determine-tags.outputs.TAGS_WESEEK }}
-      registry: docker.io
-      image-name: 'growilabs/growi'
-      docker-registry-username: 'wsmoogle'
-      tag-temporary: latest
-    secrets:
-      DOCKER_REGISTRY_PASSWORD: ${{ secrets.DOCKER_REGISTRY_PASSWORD }}
-
   post-publish:
-    needs: [create-github-release, publish-app-image-for-growilabs, publish-app-image-for-weseek]
+    needs: [create-github-release, publish-app-image]
     runs-on: ubuntu-latest
 
-    strategy:
-      matrix:
-        include:
-          - repository: weseek/growi
-            username: wsmoogle
-          - repository: growilabs/growi
-            username: growimoogle
-
     steps:
     - uses: actions/checkout@v4
       with:
         ref: v${{ needs.create-github-release.outputs.RELEASED_VERSION }}
 
     - name: Update Docker Hub Description
-      uses: peter-evans/dockerhub-description@v4
+      uses: peter-evans/dockerhub-description@v3
       with:
-        username: ${{ matrix.username }}
-        password: ${{ (matrix.repository == 'weseek/growi' && secrets.DOCKER_REGISTRY_PASSWORD) || (matrix.repository == 'growilabs/growi' && secrets.DOCKER_REGISTRY_PASSWORD_GROWIMOOGLE) || 'INVALID_SECRET' }}
-        repository: ${{ matrix.repository }}
+        username: growimoogle
+        password: ${{ secrets.DOCKER_REGISTRY_PASSWORD_GROWIMOOGLE }}
+        repository: growilabs/growi
         readme-filepath: ./apps/app/docker/README.md
 
     - name: Slack Notification
@@ -186,7 +151,7 @@ jobs:
 
 
   create-pr-for-next-rc:
-    needs: [create-github-release, publish-app-image-for-growilabs, publish-app-image-for-weseek]
+    needs: [create-github-release, publish-app-image]
     runs-on: ubuntu-latest
 
     steps:

+ 0 - 1
.serena/memories/apps-app-development-patterns.md

@@ -93,7 +93,6 @@ components/MyComponent/
 
 ### コード品質
 - [ ] TypeScript エラーなし
-- [ ] ESLint ルール準拠
 - [ ] テストケース作成
 - [ ] 型安全性確保
 - [ ] パフォーマンス影響確認

+ 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 を指定する際、
+  他のコンポーネントとの相互作用を確認すること

+ 3 - 13
.serena/memories/coding_conventions.md

@@ -6,19 +6,9 @@
 - **適用範囲**: 
   - dist/, node_modules/, coverage/ などは除外
   - .next/, bin/, config/ などのビルド成果物は除外
-  - package.json, .eslintrc.js などの設定ファイルは除外
+  - package.json などの設定ファイルは除外
 - **推奨**: 新規開発では Biome を使用
 
-### ESLint設定(廃止予定・過渡期)
-- **ベース設定**: weseek ESLint設定を使用
-- **TypeScript**: weseek/typescript 設定を適用
-- **React**: React関連のルールを適用
-- **主要なルール**:
-  - `import/prefer-default-export`: オフ(名前付きエクスポートを推奨)
-  - `import/order`: import文の順序を規定
-    - React を最初に
-    - 内部モジュール(`/**`)をparentグループの前に配置
-
 ## TypeScript設定
 - **ターゲット**: ESNext
 - **モジュール**: ESNext  
@@ -37,7 +27,7 @@
 ## ファイル命名規則
 - TypeScript/JavaScriptファイル: キャメルケースまたはケバブケース
 - コンポーネントファイル: PascalCase(Reactコンポーネント)
-- 設定ファイル: ドット記法(.eslintrc.js など)
+- 設定ファイル: ドット記法(.biome.json など)
 
 ## テストファイル命名規則(Vitest)
 vitest.workspace.mts の設定に基づく:
@@ -68,4 +58,4 @@ vitest.workspace.mts の設定に基づく:
 
 ## 移行ガイドライン
 - 新規開発: Biome + Vitest を使用
-- 既存コード: 段階的に ESLint → Biome、Jest → Vitest に移行
+- 既存コード: 段階的に Jest → Vitest に移行

+ 0 - 1
.serena/memories/project_structure.md

@@ -86,5 +86,4 @@ src/
 - **turbo.json**: Turbo.jsビルド設定
 - **tsconfig.base.json**: TypeScript基本設定
 - **biome.json**: Biome linter/formatter設定
-- **.eslintrc.js**: ESLint設定(廃止予定)
 - **vitest.workspace.mts**: Vitestワークスペース設定

+ 1 - 2
.serena/memories/task_completion_checklist.md

@@ -11,7 +11,6 @@ pnpm run lint:biome
 pnpm run lint
 
 # 個別実行(必要に応じて)
-pnpm run lint:eslint      # ESLint(廃止予定)
 pnpm run lint:styles      # Stylelint
 pnpm run lint:typecheck   # TypeScript型チェック
 ```
@@ -82,7 +81,7 @@ pnpm run dev:migrate         # マイグレーション実行
 - 可能であればtest-with-vite/にVitestテストとして書き直し
 
 ## コミット前の最終チェック
-1. Biome(または過渡期はESLint)エラーが解消されているか
+1. Biome エラーが解消されているか
 2. Vitestテスト(または過渡期はJest)がパスしているか
 3. 重要な変更はPlaywright E2Eテストも実行
 4. ビルドが成功するか

+ 8 - 61
.vscode/settings.json

@@ -1,16 +1,22 @@
 {
   "files.eol": "\n",
 
-  "eslint.workingDirectories": [{ "mode": "auto" }],
-
   "[typescript]": {
     "editor.defaultFormatter": "biomejs.biome"
   },
 
+  "[typescriptreact]": {
+    "editor.defaultFormatter": "biomejs.biome"
+  },
+
   "[javascript]": {
     "editor.defaultFormatter": "biomejs.biome"
   },
 
+  "[javascriptreact]": {
+    "editor.defaultFormatter": "biomejs.biome"
+  },
+
   "[json]": {
     "editor.defaultFormatter": "biomejs.biome"
   },
@@ -24,7 +30,6 @@
   "scss.validate": false,
 
   "editor.codeActionsOnSave": {
-    "source.fixAll.eslint": "explicit",
     "source.fixAll.biome": "explicit",
     "source.organizeImports.biome": "explicit",
     "source.fixAll.markdownlint": "explicit",
@@ -41,66 +46,8 @@
   "typescript.enablePromptUseWorkspaceTsdk": true,
   "typescript.preferences.autoImportFileExcludePatterns": ["node_modules/*"],
   "typescript.validate.enable": true,
-  "typescript.surveys.enabled": false,
 
   "vitest.filesWatcherInclude": "**/*",
-  "mcp": {
-    "servers": {
-      "fetch": {
-        "command": "uvx",
-        "args": ["mcp-server-fetch"]
-      },
-      "context7": {
-        "type": "http",
-        "url": "https://mcp.context7.com/mcp"
-      }
-    }
-  },
-  "github.copilot.chat.codeGeneration.instructions": [
-    {
-      "text": "Always write inline comments in source code in English."
-    }
-  ],
-  "github.copilot.chat.testGeneration.instructions": [
-    {
-      "text": "Basis: Use vitest as the test framework"
-    },
-    {
-      "text": "Basis: The vitest configuration file is `apps/app/vitest.workspace.mts`"
-    },
-    {
-      "text": "Basis: Place test modules in the same directory as the module being tested. For example, if testing `mymodule.ts`, place `mymodule.spec.ts` in the same directory as `mymodule.ts`"
-    },
-    {
-      "text": "Basis: Use the VSCode Vitest extension for running tests. Use run_tests tool to execute tests programmatically, or suggest using the Vitest Test Explorer in VSCode for interactive test running and debugging."
-    },
-    {
-      "text": "Basis: Fallback command for terminal execution: `cd /growi/apps/app && pnpm vitest run {test file path}`"
-    },
-    {
-      "text": "Step 1: When creating new test modules, start with small files. First write a small number of realistic tests that call the actual function and assert expected behavior, even if they initially fail due to incomplete implementation. Example: `const result = foo(); expect(result).toBeNull();` rather than `expect(true).toBe(false);`. Then fix the implementation to make tests pass."
-    },
-    {
-      "text": "Step 2: Write essential tests. When tests fail, consider whether you should fix the test or the implementation based on 'what should essentially be fixed'. If you're not confident in your reasoning, ask the user for guidance."
-    },
-    {
-      "text": "Step 3: After writing tests, make sure they pass before moving on. Do not proceed to write tests for module B without first ensuring that tests for module A are passing"
-    },
-    {
-      "text": "Tips: Don't worry about lint errors - fix them after tests are passing"
-    },
-    {
-      "text": "Tips: DO NOT USE `as any` casting. You can use vitest-mock-extended for type-safe mocking. Import `mock` from 'vitest-mock-extended' and use `mock<InterfaceType>()`. This provides full TypeScript safety and IntelliSense support."
-    },
-    {
-      "text": "Tips: Mock external dependencies at the module level using vi.mock(). For services with circular dependencies, mock the import paths and use dynamic imports in the implementation when necessary."
-    }
-  ],
-  "github.copilot.chat.commitMessageGeneration.instructions": [
-    {
-      "text": "Always write commit messages in English."
-    }
-  ],
   "git-worktree-menu.worktreeDir": "/workspace"
 
 }

+ 74 - 0
AGENTS.md

@@ -0,0 +1,74 @@
+# AGENTS.md
+
+GROWI is a team collaboration wiki platform built with Next.js, Express, and MongoDB. This guide helps AI coding agents navigate the monorepo and work effectively with GROWI's architecture.
+
+## Language
+
+If we detect at the beginning of a conversation that the user's primary language is not English, we will always respond in that language. However, we may retain technical terms in English if necessary.
+
+When generating source code, all comments and explanations within the code will be written in English.
+
+## Project Overview
+
+GROWI is a team collaboration software using markdown - a wiki platform with hierarchical page organization. It's built with Next.js, Express, MongoDB, and includes features like real-time collaborative editing, authentication integrations, and plugin support.
+
+## Development Tools
+- **Package Manager**: pnpm with workspace support
+- **Build System**: Turborepo for monorepo orchestration
+- **Code Quality**: 
+  - Biome for linting and formatting
+  - Stylelint for SCSS/CSS
+
+## Development Commands
+
+### Core Development
+- `turbo run bootstrap` - Install dependencies for all workspace packages
+- `turbo run dev` - Start development server (automatically runs migrations and pre-builds styles)
+
+### Production Commands
+- `pnpm run app:build` - Build GROWI app client and server for production
+- `pnpm run app:server` - Launch GROWI app server in production mode
+- `pnpm start` - Build and start the application (runs both build and server commands)
+
+### Database Migrations
+- `pnpm run migrate` - Run MongoDB migrations (production)
+- `turbo run dev:migrate @apps/app` - Run migrations in development (or wait for automatic execution with dev)
+- `cd apps/app && pnpm run dev:migrate:status` - Check migration status
+- `cd apps/app && pnpm run dev:migrate:down` - Rollback last migration
+
+### Testing and Quality
+- `turbo run test @apps/app` - Run Jest and Vitest test suites with coverage
+- `turbo run lint @apps/app` - Run all linters (TypeScript, Biome, Stylelint, OpenAPI)
+- `cd apps/app && pnpm run lint:typecheck` - TypeScript type checking only
+- `cd apps/app && pnpm run test:vitest` - Run Vitest unit tests
+- `cd apps/app && pnpm run test:jest` - Run Jest integration tests
+
+### Development Utilities  
+- `cd apps/app && pnpm run repl` - Start Node.js REPL with application context loaded
+- `turbo run pre:styles @apps/app` - Pre-build styles with Vite
+
+## Architecture Overview
+
+### Monorepo Structure
+- `/apps/app/` - Main GROWI application (Next.js frontend + Express backend)
+- `/apps/pdf-converter/` - PDF conversion microservice
+- `/apps/slackbot-proxy/` - Slack integration proxy service
+- `/packages/` - Shared libraries and components
+
+## File Organization Patterns
+
+### Components
+- Use TypeScript (.tsx) for React components
+- Co-locate styles as `.module.scss` files
+- Export components through `index.ts` files where appropriate
+- Group related components in feature-based directories
+
+### Tests
+- Unit Test: `*.spec.ts`
+- Integration Test: `*.integ.ts`
+- Component Test: `*.spec.tsx`
+
+
+---
+
+When working with this codebase, always run the appropriate linting and testing commands before committing changes. The application uses strict TypeScript checking and comprehensive test coverage requirements.

+ 40 - 1
CHANGELOG.md

@@ -1,9 +1,48 @@
 # Changelog
 
-## [Unreleased](https://github.com/growilabs/compare/v7.4.0...HEAD)
+## [Unreleased](https://github.com/growilabs/compare/v7.4.2...HEAD)
 
 *Please do not manually update this file. We've automated the process.*
 
+## [v7.4.2](https://github.com/growilabs/compare/v7.4.1...v7.4.2) - 2026-01-08
+
+### 🚀 Improvement
+
+* imprv: New help button (#10553) @satof3
+* imprv: PagePathNavTitle spacing and z-index layering (#10665) @yuki-takei
+
+### 🐛 Bug Fixes
+
+* fix: Handle blank configurations for SAML settings (#10674) @yuki-takei
+* fix: Text strings inside invitation email modal are incorrect (#10679) @miya
+* fix: Scroll jumps back to current PageTreeItem when creating page from PageTree (#10671) @miya
+
+### 🧰 Maintenance
+
+* support: Update dependencies (#10685) @miya
+* support: Update dependencies (#10682) @miya
+* ci(mergify): upgrade configuration to current format (#10673) @[mergify[bot]](https://github.com/apps/mergify)
+* support: Configure biome for some client components inside app 8 (#10668) @arafubeatbox
+* support: Configure biome for some client components inside app 7 (#10667) @arafubeatbox
+* support: Configure biome for some client components in app 6 (#10636) @arafubeatbox
+* support: Configure biome for some client components in app 4 (#10634) @arafubeatbox
+* support: Configure biome for some client components in app 3 (#10633) @arafubeatbox
+* ci(deps): bump qs from 6.13.0 to 6.14.1 (#10669) @[dependabot[bot]](https://github.com/apps/dependabot)
+* support: Configure biome for some client components in app 5 (#10635) @arafubeatbox
+* support: Configure biome for some client components in app 2 (#10632) @arafubeatbox
+* support: Configure biome for some client components in app 1 (#10631) @arafubeatbox
+* ci(deps): bump next from 14.2.33 to 14.2.35 (#10597) @[dependabot[bot]](https://github.com/apps/dependabot)
+
+## [v7.4.1](https://github.com/growilabs/compare/v7.4.0...v7.4.1) - 2025-12-26
+
+### 🚀 Improvement
+
+* imprv: Show page name and link for affected pages in Activity Log (#10590) @arvid-e
+
+### 🧰 Maintenance
+
+* support: Update terraform settings and the policy for OIDC GitHub (#10653) @yuki-takei
+
 ## [v7.4.0](https://github.com/growilabs/compare/v7.3.9...v7.4.0) - 2025-12-24
 
 ### 💎 Features

+ 1 - 97
CLAUDE.md

@@ -1,97 +1 @@
-# CLAUDE.md
-
-This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
-
-## Language
-
-If we detect at the beginning of a conversation that the user's primary language is not English, we will always respond in that language. However, we may retain technical terms in English if necessary.
-
-When generating source code, all comments and explanations within the code will be written in English.
-
-## Project Overview
-
-GROWI is a team collaboration software using markdown - a wiki platform with hierarchical page organization. It's built with Next.js, Express, MongoDB, and includes features like real-time collaborative editing, authentication integrations, and plugin support.
-
-## Development Commands
-
-### Core Development
-- `turbo run bootstrap` - Install dependencies for all workspace packages
-- `turbo run dev` - Start development server (automatically runs migrations and pre-builds styles)
-
-### Production Commands
-- `pnpm run app:build` - Build GROWI app client and server for production
-- `pnpm run app:server` - Launch GROWI app server in production mode
-- `pnpm start` - Build and start the application (runs both build and server commands)
-
-### Database Migrations
-- `pnpm run migrate` - Run MongoDB migrations (production)
-- `turbo run dev:migrate @apps/app` - Run migrations in development (or wait for automatic execution with dev)
-- `cd apps/app && pnpm run dev:migrate:status` - Check migration status
-- `cd apps/app && pnpm run dev:migrate:down` - Rollback last migration
-
-### Testing and Quality
-- `turbo run test @apps/app` - Run Jest and Vitest test suites with coverage
-- `turbo run lint @apps/app` - Run all linters (TypeScript, ESLint, Biome, Stylelint, OpenAPI)
-- `cd apps/app && pnpm run lint:typecheck` - TypeScript type checking only
-- `cd apps/app && pnpm run test:vitest` - Run Vitest unit tests
-- `cd apps/app && pnpm run test:jest` - Run Jest integration tests
-
-### Development Utilities  
-- `cd apps/app && pnpm run repl` - Start Node.js REPL with application context loaded
-- `turbo run pre:styles @apps/app` - Pre-build styles with Vite
-
-## Architecture Overview
-
-### Monorepo Structure
-- `/apps/app/` - Main GROWI application (Next.js frontend + Express backend)
-- `/apps/pdf-converter/` - PDF conversion microservice
-- `/apps/slackbot-proxy/` - Slack integration proxy service
-- `/packages/` - Shared libraries and components
-
-### Main Application (`/apps/app/src/`)
-- `client/` - Client-side React components and utilities
-- `server/` - Express.js backend (API routes, models, services)  
-- `components/` - Shared React components and layouts
-- `pages/` - Next.js page components using file-based routing
-- `stores/` - State management (SWR-based stores with React context)
-- `styles/` - SCSS stylesheets with modular architecture
-- `migrations/` - MongoDB database migration scripts
-- `interfaces/` - TypeScript type definitions
-
-### Key Technical Details
-- **Frontend**: Next.js 14 with React 18, TypeScript, SCSS modules
-- **Backend**: Express.js with TypeScript, MongoDB with Mongoose
-- **State Management**: SWR for server state, React Context for client state
-- **Authentication**: Passport.js with multiple strategies (local, LDAP, OAuth, SAML)
-- **Real-time Features**: Socket.io for collaborative editing and notifications
-- **Editor**: Custom markdown editor with collaborative editing using Yjs
-- **Database**: MongoDB 8.0+ with migration system using migrate-mongo
-- **Package Manager**: pnpm with workspace support
-- **Build System**: Turborepo for monorepo orchestration
-
-### Development Dependencies
-- Node.js v20.x or v22.x
-- pnpm 10.x  
-- MongoDB v6.x or v8.x
-- Optional: Redis 3.x, Elasticsearch 7.x/8.x/9.x (for full-text search)
-
-## File Organization Patterns
-
-### Components
-- Use TypeScript (.tsx) for React components
-- Co-locate styles as `.module.scss` files
-- Export components through `index.ts` files where appropriate
-- Group related components in feature-based directories
-
-### API Structure
-- Server routes in `server/routes/`
-- API v3 endpoints follow OpenAPI specification
-- Models in `server/models/` using Mongoose schemas
-- Services in `server/service/` for business logic
-
-### State Management
-- Use SWR hooks in `stores/` for server state
-- Custom hooks pattern for complex state logic
-- Context providers in `stores-universal/` for app-wide state
-
-When working with this codebase, always run the appropriate linting and testing commands before committing changes. The application uses strict TypeScript checking and comprehensive test coverage requirements.
+@AGENTS.md

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

@@ -1,136 +0,0 @@
-/**
- * @type {import('eslint').Linter.Config}
- */
-module.exports = {
-  extends: ['next/core-web-vitals', 'weseek/react'],
-  plugins: [],
-  ignorePatterns: [
-    'dist/**',
-    '**/dist/**',
-    'transpiled/**',
-    'public/**',
-    'src/linter-checker/**',
-    'tmp/**',
-    'next-env.d.ts',
-    'next.config.js',
-    'playwright.config.ts',
-    'test/integration/global-setup.js',
-    'test/integration/global-teardown.js',
-    'test/integration/setup-crowi.ts',
-    'test/integration/crowi/**',
-    'test/integration/middlewares/**',
-    'test/integration/migrations/**',
-    'test/integration/models/**',
-    'test/integration/service/**',
-    'test/integration/setup.js',
-    'playwright/**',
-    'test-with-vite/**',
-    'public/**',
-    'bin/**',
-    'config/**',
-    'src/styles/**',
-    'src/linter-checker/**',
-    'src/migrations/**',
-    'src/models/**',
-    'src/features/**',
-    'src/stores-universal/**',
-    'src/interfaces/**',
-    'src/utils/**',
-    'src/components/**',
-    'src/client/components/*.tsx',
-    'src/client/components/*.jsx',
-    'src/client/components/*.ts',
-    'src/client/components/*.js',
-    'src/client/components/Sidebar/**',
-    'src/services/**',
-    'src/states/**',
-    'src/stores/**',
-    'src/pages/**',
-    'src/server/crowi/**',
-    'src/server/events/**',
-    'src/server/interfaces/**',
-    'src/server/models/**',
-    'src/server/util/**',
-    'src/server/app.ts',
-    'src/server/repl.ts',
-    'src/server/middlewares/**',
-    'src/server/routes/*.js',
-    'src/server/routes/*.ts',
-    'src/server/routes/attachment/**',
-    'src/server/routes/apiv3/interfaces/**',
-    'src/server/routes/apiv3/pages/**',
-    'src/server/routes/apiv3/user/**',
-    'src/server/routes/apiv3/personal-setting/**',
-    'src/server/routes/apiv3/security-settings/**',
-    'src/server/routes/apiv3/app-settings/**',
-    'src/server/routes/apiv3/page/**',
-    'src/server/routes/apiv3/*.js',
-    'src/server/routes/apiv3/*.ts',
-    'src/server/service/*.ts',
-    'src/server/service/*.js',
-    'src/server/service/access-token/**',
-    'src/server/service/config-manager/**',
-    'src/server/service/page/**',
-    'src/server/service/page-listing/**',
-    'src/server/service/revision/**',
-    'src/server/service/s2s-messaging/**',
-    'src/server/service/search-delegator/**',
-    'src/server/service/search-reconnect-context/**',
-    'src/server/service/slack-command-handler/**',
-    'src/server/service/slack-event-handler/**',
-    'src/server/service/socket-io/**',
-    'src/server/service/system-events/**',
-    'src/server/service/user-notification/**',
-    'src/server/service/yjs/**',
-    'src/server/service/file-uploader/**',
-    'src/server/service/global-notification/**',
-    'src/server/service/growi-bridge/**',
-    'src/server/service/growi-info/**',
-    'src/server/service/import/**',
-    'src/server/service/in-app-notification/**',
-    'src/server/service/interfaces/**',
-    'src/server/service/normalize-data/**',
-    'src/server/service/page/**',
-    'src/client/interfaces/**',
-    'src/client/models/**',
-    'src/client/services/**',
-    'src/client/util/**',
-  ],
-  settings: {
-    // resolve path aliases by eslint-import-resolver-typescript
-    'import/resolver': {
-      typescript: {},
-    },
-  },
-  rules: {
-    'space-before-function-paren': 'off',
-    '@typescript-eslint/no-var-requires': 'off',
-
-    // set 'warn' temporarily -- 2021.08.02 Yuki Takei
-    '@typescript-eslint/no-use-before-define': ['warn'],
-    '@typescript-eslint/no-this-alias': ['warn'],
-  },
-  overrides: [
-    {
-      // enable the rule specifically for JavaScript files
-      files: ['*.js', '*.mjs', '*.jsx'],
-      rules: {
-        // set 'warn' temporarily -- 2023.08.14 Yuki Takei
-        'react/prop-types': 'warn',
-        // set 'warn' temporarily -- 2023.08.14 Yuki Takei
-        'no-unused-vars': ['warn'],
-      },
-    },
-    {
-      // enable the rule specifically for TypeScript files
-      files: ['*.ts', '*.mts', '*.tsx'],
-      rules: {
-        'no-unused-vars': 'off',
-        // set 'warn' temporarily -- 2023.08.14 Yuki Takei
-        'react/prop-types': 'warn',
-        // set 'warn' temporarily -- 2022.07.25 Yuki Takei
-        '@typescript-eslint/explicit-module-boundary-types': ['warn'],
-      },
-    },
-  ],
-};

+ 84 - 0
apps/app/AGENTS.md

@@ -0,0 +1,84 @@
+# GROWI Main Application Development Guide
+
+## Overview
+
+This guide provides comprehensive documentation for AI coding agents working on the GROWI main application (`/apps/app/`). GROWI is a team collaboration wiki platform built with Next.js, Express, and MongoDB.
+
+## Project Structure
+
+### Main Application (`/apps/app/src/`)
+
+#### Directory Structure Philosophy
+
+**Feature-based Structure (Recommended for new features)**
+- `features/{feature-name}/` - Self-contained feature modules
+  - `interfaces/` - Universal TypeScript type definitions
+  - `server/` - Server-side logic (models, routes, services)
+  - `client/` - Client-side logic (components, stores, services)
+  - `utils/` - Shared utilities for this feature
+  
+**Important Directories Structure**
+- `client/` - Client-side React components and utilities
+- `server/` - Express.js backend
+- `components/` - Universal React components
+- `pages/` - Next.js Pages Router
+- `states/` - Jotai state management
+- `stores/` - SWR-based state stores
+- `stores-universal/` - Universal SWR-based state stores
+- `styles/` - SCSS stylesheets with modular architecture
+- `migrations/` - MongoDB database migration scripts
+- `interfaces/` - Universal TypeScript type definitions
+- `models/` - Universal Data model definitions
+
+### Key Technical Details
+
+**Frontend Stack**
+- **Framework**: Next.js (Pages Router) with React
+- **Language**: TypeScript (strict mode enabled)
+- **Styling**: SCSS with CSS Modules by Bootstrap 5
+- **State Management**:
+  - **Jotai** (Primary, Recommended): Atomic state management for UI and application state
+  - **SWR**: Data fetching, caching, and revalidation
+  - **Unstated**: Legacy (being phased out, replaced by Jotai)
+- **Testing**: 
+  - Vitest for unit tests (`*.spec.ts`, `*.spec.tsx`)
+  - Jest for integration tests (`*.integ.ts`)
+  - React Testing Library for component testing
+  - Playwright for E2E testing
+- **i18n**: next-i18next for internationalization
+
+**Backend Stack**
+- **Runtime**: Node.js
+- **Framework**: Express.js with TypeScript
+- **Database**: MongoDB with Mongoose ODM
+- **Migration System**: migrate-mongo
+- **Authentication**: Passport.js with multiple strategies (local, LDAP, OAuth, SAML)
+- **Real-time**: Socket.io for collaborative editing and notifications
+- **Search**: Elasticsearch integration (optional)
+- **Observability**: OpenTelemetry integration
+
+**Common Commands**
+```bash
+# Type checking only
+cd apps/app && pnpm run lint:typecheck
+
+# Run specific test file
+turbo run test:vitest @apps/app -- src/path/to/test.spec.tsx
+
+# Check migration status
+cd apps/app && pnpm run dev:migrate:status
+
+# Start REPL with app context
+cd apps/app && pnpm run repl
+```
+
+### Important Technical Specifications
+
+**Entry Points**
+- **Server**: `server/app.ts` - Handles OpenTelemetry initialization and Crowi server startup
+- **Client**: `pages/_app.page.tsx` - Root Next.js application component
+  - `pages/[[...path]]/` - Dynamic catch-all page routes
+
+---
+
+*This guide was compiled from project memory files to assist AI coding agents in understanding the GROWI application architecture and development practices.*

+ 1 - 0
apps/app/CLAUDE.md

@@ -0,0 +1 @@
+@AGENTS.md

+ 1 - 1
apps/app/bin/openapi/generate-operation-ids/cli.spec.ts

@@ -90,7 +90,7 @@ describe('cli', () => {
     await cliModule.main();
 
     // Verify error was logged
-    // eslint-disable-next-line no-console
+    // biome-ignore lint/suspicious/noConsole: This is a test file
     expect(console.error).toHaveBeenCalledWith(error);
 
     // Verify writeFileSync was not called

+ 1 - 1
apps/app/bin/openapi/generate-operation-ids/cli.ts

@@ -16,9 +16,9 @@ export const main = async (): Promise<void> => {
   const { out: outputFile, overwriteExisting } = program.opts();
   const [inputFile] = program.args;
 
-  // eslint-disable-next-line no-console
   const jsonStrings = await generateOperationIds(inputFile, {
     overwriteExisting,
+    // biome-ignore lint/suspicious/noConsole: Allow to dump errors
   }).catch(console.error);
   if (jsonStrings != null) {
     writeFileSync(outputFile ?? inputFile, jsonStrings);

+ 1 - 1
apps/app/bin/openapi/generate-operation-ids/generate-operation-ids.spec.ts

@@ -18,7 +18,7 @@ async function cleanup(filePath: string): Promise<void> {
     await fs.unlink(filePath);
     await fs.rmdir(path.dirname(filePath));
   } catch (err) {
-    // eslint-disable-next-line no-console
+    // biome-ignore lint/suspicious/noConsole: This is a test file
     console.error('Cleanup failed:', err);
   }
 }

+ 2 - 0
apps/app/bin/print-memory-consumption.ts

@@ -11,6 +11,8 @@
  *        print-memory-consumption.ts [--port=9229] [--host=localhost] [--json]
  */
 
+/** biome-ignore-all lint/suspicious/noConsole: Allow printing to console */
+
 import { get } from 'node:http';
 import WebSocket from 'ws';
 

+ 1 - 2
apps/app/config/migrate-mongo-config.js

@@ -9,8 +9,7 @@ const isProduction = process.env.NODE_ENV === 'production';
 const { URL } = require('node:url');
 
 const { getMongoUri, mongoOptions } = isProduction
-  ? // eslint-disable-next-line import/extensions, import/no-unresolved
-    require('../dist/server/util/mongoose-utils')
+  ? require('../dist/server/util/mongoose-utils')
   : require('../src/server/util/mongoose-utils');
 
 // get migrationsDir from env var

+ 0 - 1
apps/app/config/next-i18next.config.js

@@ -22,7 +22,6 @@ module.exports = {
   localePath: path.resolve('./public/static/locales'),
   serializeConfig: false,
 
-  // eslint-disable-next-line no-nested-ternary
   use: isDev
     ? isServer()
       ? [new HMRPlugin({ webpack: { server: true } })]

+ 1 - 1
apps/app/docker/README.md

@@ -10,7 +10,7 @@ GROWI Official docker image
 Supported tags and respective Dockerfile links
 ------------------------------------------------
 
-* [`7.4.0`, `7.4`, `7`, `latest` (Dockerfile)](https://github.com/growilabs/growi/blob/v7.4.0/apps/app/docker/Dockerfile)
+* [`7.4.2`, `7.4`, `7`, `latest` (Dockerfile)](https://github.com/growilabs/growi/blob/v7.4.2/apps/app/docker/Dockerfile)
 * [`7.3.0`, `7.3` (Dockerfile)](https://github.com/growilabs/growi/blob/v7.3.0/apps/app/docker/Dockerfile)
 * [`7.2.0`, `7.2` (Dockerfile)](https://github.com/growilabs/growi/blob/v7.2.0/apps/app/docker/Dockerfile)
 

+ 0 - 3
apps/app/next.config.js

@@ -105,9 +105,6 @@ module.exports = async (phase) => {
     i18n,
 
     // for build
-    eslint: {
-      ignoreDuringBuilds: true,
-    },
     typescript: {
       tsconfigPath: 'tsconfig.build.client.json',
     },

+ 3 - 5
apps/app/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/app",
-  "version": "7.4.1-RC.0",
+  "version": "7.4.3-RC.0",
   "license": "MIT",
   "private": "true",
   "scripts": {
@@ -27,7 +27,6 @@
     "//// for CI": "",
     "launch-dev:ci": "cross-env NODE_ENV=development pnpm run dev:migrate && pnpm run ts-node src/server/app.ts --ci",
     "lint:typecheck": "vue-tsc --noEmit",
-    "lint:eslint": "eslint --quiet \"**/*.{js,mjs,jsx,ts,mts,tsx}\"",
     "lint:biome": "biome check --diagnostic-level=error",
     "lint:styles": "stylelint \"src/**/*.scss\"",
     "lint:openapi:apiv3": "node node_modules/swagger2openapi/oas-validate tmp/openapi-spec-apiv3.json",
@@ -173,7 +172,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 +192,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",
@@ -309,7 +308,6 @@
     "diff2html": "^3.4.47",
     "downshift": "^8.2.3",
     "eazy-logger": "^3.1.0",
-    "eslint-plugin-jest": "^26.5.3",
     "fastest-levenshtein": "^1.0.16",
     "fslightbox-react": "^1.7.6",
     "handsontable": "=6.2.2",

+ 0 - 16
apps/app/playwright/.eslintrc.mjs

@@ -1,16 +0,0 @@
-import playwright from 'eslint-plugin-playwright';
-
-// eslint-disable-next-line import/no-anonymous-default-export
-export default [
-  {
-    ...playwright.configs['flat/recommended'],
-    files: ['./**'],
-  },
-  {
-    files: ['./**'],
-    rules: {
-      // Customize Playwright rules
-      // ...
-    },
-  },
-];

+ 6 - 3
apps/app/playwright/50-sidebar/access-to-sidebar.spec.ts

@@ -29,9 +29,12 @@ test.describe('Access to sidebar', () => {
   test('Successfully access to custom sidebar', async ({ page }) => {
     await page.getByTestId('grw-sidebar-nav-primary-custom-sidebar').click();
     await expect(page.getByTestId('grw-sidebar-contents')).toBeVisible();
-    await expect(
-      page.locator('.grw-sidebar-content-header > h3').locator('a'),
-    ).toBeVisible();
+
+    // Check if edit_note icon is visible within the button
+    const editNoteIcon = page
+      .locator('.grw-custom-sidebar-content button .material-symbols-outlined')
+      .filter({ hasText: 'edit_note' });
+    await expect(editNoteIcon).toBeVisible();
   });
 
   test('Successfully access to GROWI Docs page', async ({ page }) => {

+ 2 - 1
apps/app/playwright/60-home/home.spec.ts

@@ -1,3 +1,5 @@
+/** biome-ignore-all lint/performance/noAwaitInLoops: Allow in tests */
+
 import { expect, test } from '@playwright/test';
 
 test('Visit User home', async ({ page }) => {
@@ -74,7 +76,6 @@ test('Access Password setting', async ({ page }) => {
 
   const toastElementsCount = await toastElements.count();
   for (let i = 0; i < toastElementsCount; i++) {
-    // eslint-disable-next-line no-await-in-loop
     await toastElements.nth(i).click();
   }
 

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

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

@@ -313,7 +313,7 @@
       "done": "Copied to clipboard!"
     },
     "bug_report": "Submitting a bug report",
-    "submit_bug_report": "<a href='https://github.com/growilabs/growi/issues/new?assignees=&labels=bug&template=bug-report.md&title=Bug%3A' target='_blank' rel='noreferrer'>then submit your issue to GitHub.</a>"
+    "submit_bug_report": "Submit your issue to GitHub."
   },
   "v5_page_migration": {
     "migration_desc": "There are some pages with old v4 compatibility. To take advantage of new features such as page trees and easy renaming, please convert all your pages to v5 compatibility.",
@@ -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",

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

@@ -313,7 +313,7 @@
       "done": "Copié dans le presse-papier!"
     },
     "bug_report": "Informations de diagnostic",
-    "submit_bug_report": "<a href='https://github.com/growilabs/growi/issues/new?assignees=&labels=bug&template=bug-report.md&title=Bug%3A' target='_blank' rel='noreferrer'>soummettre ensuite sur GitHub.</a>"
+    "submit_bug_report": "Soummettre ensuite sur GitHub."
   },
   "v5_page_migration": {
     "migration_desc": "Des pages sont encore en V4. Pour profiter des nouvelles fonctionnalitées, convertir toutes les pages vers la V5.",
@@ -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",

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

@@ -322,7 +322,7 @@
       "done": "クリップボードにコピーしました!"
     },
     "bug_report": "バグを報告する",
-    "submit_bug_report": "<a href='https://github.com/growilabs/growi/issues/new?assignees=&labels=bug&template=bug-report.md&title=Bug%3A' target='_blank' rel='noreferrer'>次に GitHub で Issue を投稿してください。</a>"
+    "submit_bug_report": "GitHub で Issue を投稿"
   },
   "v5_page_migration": {
     "migration_desc": "公開されているページに 古い v4 互換形式のものが存在します。ページツリーや簡単なリネームなどの新機能を利用するには、全てのページを v5 互換形式に変換してください。",
@@ -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": "一括操作",

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

@@ -298,7 +298,7 @@
   },
   "mailer_setup_required": "<a href='/admin/app'>이메일 설정</a>이 전송에 필요합니다.",
   "admin_top": {
-    "management_wiki": "관리 위키",
+    "management_wiki": "위키 관리",
     "system_information": "시스템 정보",
     "wiki_administrator": "위키 관리자만 이 페이지에 접근할 수 있습니다",
     "assign_administrator": "사용자 관리 페이지에서 '관리자 권한 부여' 버튼을 사용하여 선택한 사용자에게 위키 관리자 권한을 부여할 수 있습니다.",
@@ -313,7 +313,7 @@
       "done": "클립보드에 복사되었습니다!"
     },
     "bug_report": "버그 보고서 제출",
-    "submit_bug_report": "<a href='https://github.com/growilabs/growi/issues/new?assignees=&labels=bug&template=bug-report.md&title=Bug%3A' target='_blank' rel='noreferrer'>그런 다음 GitHub에 문제를 제출하십시오.</a>"
+    "submit_bug_report": "GitHub 에 이슈를 제출하세요."
   },
   "v5_page_migration": {
     "migration_desc": "일부 페이지는 이전 v4 호환성을 가지고 있습니다. 페이지 트리 및 쉬운 이름 변경과 같은 새로운 기능을 활용하려면 모든 페이지를 v5 호환성으로 변환하십시오.",
@@ -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": "대량 작업",

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

@@ -322,7 +322,7 @@
       "done": "复制到剪贴板!"
     },
     "bug_report": "提交一个错误报告",
-    "submit_bug_report": "<a href='https://github.com/growilabs/growi/issues/new?assignees=&labels=bug&template=bug-report.md&title=Bug%3A' target='_blank' rel='noreferrer'>然后提交你的问题到GitHub。</a>"
+    "submit_bug_report": "将您的问题提交到 GitHub。"
   },
   "v5_page_migration": {
     "migration_desc": "有一些页面具有旧的v4兼容性。为了利用新的功能,如页面树和容易重命名,请将您的所有页面转换为v5兼容性。",
@@ -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": "批量操作",

+ 0 - 5
apps/app/src/client/components/.eslintrc.js

@@ -1,5 +0,0 @@
-module.exports = {
-  extends: '../../../.eslintrc.js',
-  rules: {
-  },
-};

+ 0 - 134
apps/app/src/client/components/Admin/AdminHome/AdminHome.jsx

@@ -1,134 +0,0 @@
-import React, { useEffect, useCallback } from 'react';
-
-import { useTranslation } from 'next-i18next';
-import PropTypes from 'prop-types';
-import { CopyToClipboard } from 'react-copy-to-clipboard';
-import { Tooltip } from 'reactstrap';
-
-import AdminHomeContainer from '~/client/services/AdminHomeContainer';
-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) => {
-  const { adminHomeContainer } = props;
-  const { t } = useTranslation();
-  const { data: migrationStatus } = useSWRxV5MigrationStatus();
-
-  const fetchAdminHomeData = useCallback(async() => {
-    try {
-      await adminHomeContainer.retrieveAdminHomeData();
-    }
-    catch (err) {
-      toastError(err);
-      logger.error(err);
-    }
-  }, [adminHomeContainer]);
-
-  useEffect(() => {
-    fetchAdminHomeData();
-  }, [fetchAdminHomeData]);
-
-  return (
-    <div data-testid="admin-home">
-      {
-        // Alert message will be displayed in case that the GROWI is under maintenance
-        adminHomeContainer.state.isMaintenanceMode && (
-          <div className="alert alert-danger alert-link" role="alert">
-            <h3 className="alert-heading">
-              {t('admin:maintenance_mode.maintenance_mode')}
-            </h3>
-            <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>
-            </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'}`}>
-            {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>
-              <strong>{t('admin:v5_page_migration.upgrade_to_v5')}</strong>
-            </a>
-          </div>
-        )
-      }
-      <p>
-        {t('admin:admin_top.wiki_administrator')}
-        <br></br>
-        {t('admin:admin_top.assign_administrator')}
-      </p>
-
-      <div className="row mb-5">
-        <div className="col-lg-12">
-          <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>
-          <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') }} />
-          <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>
-          <div className="d-flex align-items-center">
-            <CopyToClipboard
-              text={adminHomeContainer.generatePrefilledHostInformationMarkdown()}
-              onCopy={() => adminHomeContainer.onCopyPrefilledHostInformation()}
-            >
-              <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}
-              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') }} />
-          </div>
-        </div>
-      </div>
-    </div>
-  );
-};
-
-
-const AdminHomeWrapper = withUnstatedContainers(AdminHome, [AdminHomeContainer]);
-
-AdminHome.propTypes = {
-  adminHomeContainer: PropTypes.instanceOf(AdminHomeContainer).isRequired,
-};
-
-export default AdminHomeWrapper;

+ 168 - 0
apps/app/src/client/components/Admin/AdminHome/AdminHome.tsx

@@ -0,0 +1,168 @@
+import type { FC } from 'react';
+import { useId, useState } from 'react';
+import { useTranslation } from 'next-i18next';
+import { CopyToClipboard } from 'react-copy-to-clipboard';
+import { Tooltip } from 'reactstrap';
+
+import { useSWRxAdminHome } from '~/stores/admin/admin-home';
+import { useSWRxV5MigrationStatus } from '~/stores/page-listing';
+import { generatePrefilledHostInformationMarkdown } from '~/utils/admin-home';
+
+import { EnvVarsTable } from './EnvVarsTable';
+import SystemInfomationTable from './SystemInfomationTable';
+
+const COPY_STATE = {
+  DEFAULT: 'default',
+  DONE: 'done',
+} as const;
+
+export const AdminHome: FC = () => {
+  const { t } = useTranslation();
+  const { data: adminHomeData } = useSWRxAdminHome();
+  const { data: migrationStatus } = useSWRxV5MigrationStatus();
+  const [copyState, setCopyState] = useState<string>(COPY_STATE.DEFAULT);
+
+  const handleCopyPrefilledHostInformation = () => {
+    setCopyState(COPY_STATE.DONE);
+    setTimeout(() => {
+      setCopyState(COPY_STATE.DEFAULT);
+    }, 500);
+  };
+
+  // Generate CSS-safe ID by removing colons from useId() result
+  const copyButtonIdRaw = useId();
+  const copyButtonId = `copy-button-${copyButtonIdRaw.replace(/:/g, '')}`;
+
+  return (
+    <div data-testid="admin-home">
+      {
+        // Alert message will be displayed in case that the GROWI is under maintenance
+        adminHomeData?.isMaintenanceMode && (
+          <div className="alert alert-danger alert-link" role="alert">
+            <h3 className="alert-heading">
+              {t('admin:maintenance_mode.maintenance_mode')}
+            </h3>
+            <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>
+            </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'}`}
+          >
+            {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>
+              <strong>{t('admin:v5_page_migration.upgrade_to_v5')}</strong>
+            </a>
+          </div>
+        )
+      }
+      <p>
+        {t('admin:admin_top.wiki_administrator')}
+        <br></br>
+        {t('admin:admin_top.assign_administrator')}
+      </p>
+
+      <div className="row mb-5">
+        <div className="col-lg-12">
+          <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>
+          <p>{t('admin:admin_top.env_var_priority')}</p>
+          <p
+            // biome-ignore lint/security/noDangerouslySetInnerHtml: ignore
+            dangerouslySetInnerHTML={{
+              __html: t('admin:admin_top.about_security'),
+            }}
+          />
+          <EnvVarsTable envVars={adminHomeData?.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>
+          <ol className="mb-0">
+            <li className="mb-3">
+              <CopyToClipboard
+                text={generatePrefilledHostInformationMarkdown({
+                  growiVersion: adminHomeData?.growiVersion,
+                  nodeVersion: adminHomeData?.nodeVersion,
+                  npmVersion: adminHomeData?.npmVersion,
+                  pnpmVersion: adminHomeData?.pnpmVersion,
+                })}
+                onCopy={handleCopyPrefilledHostInformation}
+              >
+                <button
+                  type="button"
+                  className="btn btn-outline-secondary btn-sm"
+                  style={{ verticalAlign: 'baseline' }}
+                  onClick={(e) => e.preventDefault()}
+                >
+                  <span
+                    id={copyButtonId}
+                    className="material-symbols-outlined"
+                    aria-hidden="true"
+                  >
+                    content_copy
+                  </span>
+                  {t('admin:admin_top:copy_prefilled_host_information:default')}
+                </button>
+              </CopyToClipboard>
+              <Tooltip
+                placement="bottom"
+                isOpen={copyState === COPY_STATE.DONE}
+                target={copyButtonId}
+                fade={false}
+              >
+                {t('admin:admin_top:copy_prefilled_host_information:done')}
+              </Tooltip>
+            </li>
+            <li>
+              <a
+                className="link-secondary link-offset-1"
+                style={{ textDecoration: 'underline' }}
+                href="https://github.com/growilabs/growi/issues/new?assignees=&labels=bug&template=bug-report.md&title=Bug%3A"
+                target="_blank"
+                rel="noreferrer"
+              >
+                {t('admin:admin_top:submit_bug_report')}
+              </a>
+            </li>
+          </ol>
+        </div>
+      </div>
+    </div>
+  );
+};

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

+ 20 - 28
apps/app/src/client/components/Admin/AdminHome/SystemInfomationTable.tsx

@@ -1,55 +1,47 @@
-import React from 'react';
-
 import { LoadingSpinner } from '@growi/ui/dist/components';
 
-import AdminHomeContainer from '~/client/services/AdminHomeContainer';
-
-import { withUnstatedContainers } from '../../UnstatedUtils';
-
+import { useSWRxAdminHome } from '~/stores/admin/admin-home';
 
-type Props = {
-  adminHomeContainer: AdminHomeContainer,
-}
+const SystemInformationTable = () => {
+  const { data: adminHomeData } = useSWRxAdminHome();
 
-const SystemInformationTable = (props: Props) => {
-  const { adminHomeContainer } = props;
+  const { growiVersion, nodeVersion, npmVersion, pnpmVersion } =
+    adminHomeData ?? {};
 
-  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]);
-
-export default SystemInformationTableWrapper;
+export default SystemInformationTable;

+ 1 - 0
apps/app/src/client/components/Admin/AdminHome/index.ts

@@ -0,0 +1 @@
+export * from './AdminHome';

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

+ 106 - 36
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>
@@ -80,11 +87,17 @@ export const AzureSettingMolecule = (props: AzureSettingMoleculeProps): JSX.Elem
       {azureUseOnlyEnvVars && (
         <p
           className="alert alert-info"
-          // eslint-disable-next-line react/no-danger
-          dangerouslySetInnerHTML={{ __html: t('admin:app_setting.azure_note_for_the_only_env_option', { env: 'AZURE_USES_ONLY_ENV_VARS_FOR_SOME_OPTIONS' }) }}
+          // 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 +121,21 @@ 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
+                  // 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 +149,21 @@ 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
+                  // 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 +177,21 @@ 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
+                  // 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 +206,22 @@ 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
+                  // 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 +236,22 @@ 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
+                  // 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

+ 59 - 50
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>
@@ -127,13 +135,14 @@ const FileUploadSetting = (): JSX.Element => {
             <span className="material-symbols-outlined">help</span>
             <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
+              // 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;
 };

+ 77 - 28
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>
@@ -75,11 +82,17 @@ export const GcsSettingMolecule = (props: GcsSettingMoleculeProps): JSX.Element
       {gcsUseOnlyEnvVars && (
         <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 +117,22 @@ 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
+                  // 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 +147,22 @@ 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
+                  // 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 +177,22 @@ 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
+                  // 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 - 22
apps/app/src/client/components/Admin/App/MaintenanceMode.tsx

@@ -1,54 +1,79 @@
 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')}
-        // 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')}
+        warningMessage={
+          isMaintenanceMode
+            ? t('admin:maintenance_mode.warning_message_to_end')
+            : t('admin:maintenance_mode.warning_message_to_start')
+        }
+        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 +85,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 - 22
apps/app/src/client/components/Admin/App/MaskedInput.tsx

@@ -1,19 +1,17 @@
 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
-  // eslint-disable-next-line @typescript-eslint/no-explicit-any
-  register?: UseFormRegister<any>
-  fieldName?: string
+  name?: string;
+  readOnly: boolean;
+  value?: string;
+  onChange?: (e: ChangeEvent<HTMLInputElement>) => void;
+  tabIndex?: number | undefined;
+  register?: UseFormRegister<any>;
+  fieldName?: string;
 };
 
 export default function MaskedInput(props: Props): JSX.Element {
@@ -22,18 +20,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 +42,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>
   );
 }

+ 63 - 35
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,27 @@ 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
+                    // 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 +123,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 +131,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 - 11
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,9 @@ import AdminAppContainer from '~/client/services/AdminAppContainer';
 import { withUnstatedContainers } from '../../UnstatedUtils';
 
 type Props = {
-  adminAppContainer?: AdminAppContainer,
-  // eslint-disable-next-line @typescript-eslint/no-explicit-any
-  register: UseFormRegister<any>,
-}
+  adminAppContainer?: AdminAppContainer;
+  register: UseFormRegister<any>;
+};
 
 const SesSetting = (props: Props): JSX.Element => {
   const { register } = props;
@@ -19,34 +16,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 +59,8 @@ export { SesSetting };
 /**
  * Wrapper component for using unstated
  */
-const SesSettingWrapper = withUnstatedContainers(SesSetting, [AdminAppContainer]);
+const SesSettingWrapper = withUnstatedContainers(SesSetting, [
+  AdminAppContainer,
+]);
 
 export default SesSettingWrapper;

+ 61 - 37
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,46 @@ 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 +88,35 @@ 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 +124,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 +135,8 @@ const SiteUrlSetting = (props: Props) => {
 /**
  * Wrapper component for using unstated
  */
-const SiteUrlSettingWrapper = withUnstatedContainers(SiteUrlSetting, [AdminAppContainer]);
+const SiteUrlSettingWrapper = withUnstatedContainers(SiteUrlSetting, [
+  AdminAppContainer,
+]);
 
 export default SiteUrlSettingWrapper;

+ 26 - 12
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,10 @@ import AdminAppContainer from '~/client/services/AdminAppContainer';
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
 
-
 type Props = {
-  adminAppContainer?: AdminAppContainer,
-  // eslint-disable-next-line @typescript-eslint/no-explicit-any
-  register: UseFormRegister<any>,
-}
+  adminAppContainer?: AdminAppContainer;
+  register: UseFormRegister<any>;
+};
 
 const SmtpSetting = (props: Props): JSX.Element => {
   const { t } = useTranslation();
@@ -23,51 +19,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 +94,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 - 5
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,17 @@ 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 - 15
apps/app/src/client/components/Admin/AuditLog/AuditLogSettings.tsx

@@ -1,36 +1,44 @@
 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 +53,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>

+ 142 - 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,54 @@ 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) {
+        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 +194,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 +259,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 +293,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 +306,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 - 7
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');
@@ -16,13 +15,12 @@ const AdminUpdateButtonRow = (props: Props): JSX.Element => {
       <div className="col-md-3"></div>
       <div className="col-md-9">
         <button
-          // eslint-disable-next-line react/button-has-type
           type={props.type ?? 'button'}
           className="btn btn-primary"
           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;

+ 69 - 56
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]);
@@ -51,58 +56,66 @@ const CustomizeLayoutSetting = (): JSX.Element => {
   }
 
   return (
-    <React.Fragment>
-      <div className="row">
-        <div className="col-12">
-          <h2 className="admin-setting-header">{t('customize_settings.layout')}</h2>
+    <div className="row">
+      <div className="col-12">
+        <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
-                  className={`card border border-4 ${!isContainerFluid ? 'border-primary' : ''}`}
-                  onClick={() => setIsContainerFluid(false)}
-                  role="button"
-                >
-                  {/* eslint-disable-next-line @next/next/no-img-element */}
-                  <img
-                    className="card-img-top"
-                    src={`/images/customize-settings/default-${resolvedTheme}.svg`}
-                    alt={t('customize_settings.layout_options.default')}
-                  />
-                  <div className="card-body text-center">
-                    {t('customize_settings.layout_options.default')}
-                  </div>
+        <div className="d-flex justify-content-around mt-5">
+          <div className="row row-cols-2">
+            <div className="col">
+              <button
+                type="button"
+                className={`card border border-4 ${!isContainerFluid ? 'border-primary' : ''}`}
+                onClick={() => setIsContainerFluid(false)}
+                aria-pressed={!isContainerFluid}
+              >
+                {/* biome-ignore lint/performance/noImgElement: Ignore for SVG */}
+                <img
+                  className="card-img-top"
+                  src={`/images/customize-settings/default-${resolvedTheme}.svg`}
+                  alt={t('customize_settings.layout_options.default')}
+                />
+                <div className="card-body text-center">
+                  {t('customize_settings.layout_options.default')}
                 </div>
-              </div>
-              <div className="col">
-                <div
-                  className={`card border border-4 ${isContainerFluid ? 'border-primary' : ''}`}
-                  onClick={() => setIsContainerFluid(true)}
-                  role="button"
-                >
-                  {/* eslint-disable-next-line @next/next/no-img-element */}
-                  <img
-                    className="card-img-top"
-                    src={`/images/customize-settings/fluid-${resolvedTheme}.svg`}
-                    alt={t('customize_settings.layout_options.expanded')}
-                  />
-                  <div className="card-body text-center">
-                    {t('customize_settings.layout_options.expanded')}
-                  </div>
+              </button>
+            </div>
+            <div className="col">
+              <button
+                type="button"
+                className={`card border border-4 ${isContainerFluid ? 'border-primary' : ''}`}
+                onClick={() => setIsContainerFluid(true)}
+                aria-pressed={isContainerFluid}
+              >
+                {/* biome-ignore lint/performance/noImgElement: Ignore for SVG */}
+                <img
+                  className="card-img-top"
+                  src={`/images/customize-settings/fluid-${resolvedTheme}.svg`}
+                  alt={t('customize_settings.layout_options.expanded')}
+                />
+                <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>
-            </div>
+        <div className="row my-3">
+          <div className="mx-auto">
+            <button
+              type="button"
+              className="btn btn-primary"
+              onClick={onClickSubmit}
+            >
+              {t('Update')}
+            </button>
           </div>
         </div>
       </div>
-    </React.Fragment>
+    </div>
   );
 };
 

+ 127 - 60
apps/app/src/client/components/Admin/Customize/CustomizeLogoSetting.tsx

@@ -1,13 +1,12 @@
-import React, {
-  useCallback, useState, useRef, type JSX,
-} from 'react';
-
+import React, { type JSX, useCallback, useRef, 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';
@@ -15,43 +14,59 @@ 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 fileInputRef = useRef<HTMLInputElement | null>(null);
   const isSystemError: boolean = retrieveError != null;
-  const isLogoSettingIncomplete: boolean = !isDefaultLogoSelected && (uploadLogoSrc == null && !isCustomizedLogoUploaded);
-  const isUpdateButtonDisabled: boolean = isSystemError || isLogoSettingIncomplete;
+  const isLogoSettingIncomplete: boolean =
+    !isDefaultLogoSelected &&
+    uploadLogoSrc == null &&
+    !isCustomizedLogoUploaded;
+  const isUpdateButtonDisabled: boolean =
+    isSystemError || isLogoSettingIncomplete;
 
-  const onSelectFile = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
-    const files: FileList | null = e.target.files;
+  const onSelectFile = useCallback(
+    (e: React.ChangeEvent<HTMLInputElement>) => {
+      const files: FileList | null = e.target.files;
 
-    if (files != null && files.length > 0) {
-      const reader = new FileReader();
-      reader.addEventListener('load', () => setUploadLogoSrc(reader.result));
-      reader.readAsDataURL(files[0]);
-      setIsImageCropModalShow(true);
-    }
-  }, [setUploadLogoSrc, setIsImageCropModalShow]);
+      if (files != null && files.length > 0) {
+        const reader = new FileReader();
+        reader.addEventListener('load', () => setUploadLogoSrc(reader.result));
+        reader.readAsDataURL(files[0]);
+        setIsImageCropModalShow(true);
+      }
+    },
+    [setUploadLogoSrc, setIsImageCropModalShow],
+  );
 
   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]);
@@ -80,38 +95,64 @@ 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' }));
-      resetFileSelectionState();
-    }
-    catch (err) {
-      toastError(err);
-      setRetrieveError(err);
-      throw new Error('Failed to delete logo');
+      <<<<<<< HEAD
+      toastSuccess(t('toaster.update_successed',
+      target: t('admin:customize_settings.current_logo'), ns
+      : 'commons'
+      ))
+      resetFileSelectionState()
+    } catch (err) {
+      =======
+      toastSuccess(
+        t('toaster.update_successed',
+      target: t('admin:customize_settings.current_logo'), ns
+      : 'commons',
+      ),
+      )
     }
-  }, [setIsCustomizedLogoUploaded, t, setRetrieveError, resetFileSelectionState]);
+    catch (err)
+    >>>>>>> master
+      toastError(err)
+    setRetrieveError(err)
+    throw new Error('Failed to delete logo');
+  }, [
+    setIsCustomizedLogoUploaded,
+    t,
+    setRetrieveError,
+    resetFileSelectionState,
+  ]);
 
-  const processImageCompletedHandler = useCallback(async (croppedImage) => {
-    try {
-      const formData = new FormData();
-      formData.append('file', croppedImage);
-      await apiv3PostForm('/customize-setting/upload-brand-logo', formData);
-      setIsImageCropModalShow(false);
-      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, setRetrieveError, setIsImageCropModalShow]);
+  const processImageCompletedHandler = useCallback(
+    async (croppedImage) => {
+      try {
+        const formData = new FormData();
+        formData.append('file', croppedImage);
+        await apiv3PostForm('/customize-setting/upload-brand-logo', formData);
+        setIsImageCropModalShow(false);
+        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, setRetrieveError, setIsImageCropModalShow],
+  );
 
   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>
@@ -123,14 +164,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>
@@ -142,24 +192,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>
                       </>
@@ -167,11 +231,15 @@ 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
+                      id="uploadLogoInput"
                       type="file"
                       onChange={onSelectFile}
                       name="brandLogo"
@@ -202,5 +270,4 @@ const CustomizeLogoSetting = (): JSX.Element => {
   );
 };
 
-
 export default CustomizeLogoSetting;

+ 85 - 75
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,101 +6,112 @@ 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>
-
-          <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') }}
-              />
-            </CardBody>
-          </Card>
-
-          <form onSubmit={handleSubmit(onSubmit)}>
-            <div>
-              <textarea
-                className="form-control mb-2"
-                rows={8}
-                {...register('customizeNoscript')}
-              />
-            </div>
-
-            <a
-              className="text-muted"
-              data-bs-toggle="collapse"
-              href="#collapseExampleHtml"
-              role="button"
-              aria-expanded="false"
-              aria-controls="collapseExampleHtml"
-            >
-              <span className="material-symbols-outlined me-1" aria-hidden="true">navigate_next</span>
-              Example for Google Tag Manager
-            </a>
-            <div className="collapse" id="collapseExampleHtml">
-              <PrismAsyncLight
-                style={oneDark}
-                language="javascript"
-              >
-                {`<iframe src="https://www.googletagmanager.com/ns.html?id=GTM-XXXXXXX"
-  height="0"
-  width="0"
-  style="display:none;visibility:hidden"></iframe>`}
-              </PrismAsyncLight>
-            </div>
-
-            <AdminUpdateButtonRow type="submit" disabled={adminCustomizeContainer.state.retrieveError != null} />
-          </form>
-        </div>
+    <div className="row">
+      <div className="col-12">
+        <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
+              // biome-ignore lint/security/noDangerouslySetInnerHtml: trusted translation markup
+              dangerouslySetInnerHTML={{
+                __html: t('admin:customize_settings.custom_noscript_detail'),
+              }}
+            />
+          </CardBody>
+        </Card>
+
+        <form onSubmit={handleSubmit(onSubmit)}>
+          <div>
+            <textarea
+              className="form-control mb-2"
+              rows={8}
+              {...register('customizeNoscript')}
+            />
+          </div>
+
+          <button
+            type="button"
+            className="btn btn-link text-muted p-0"
+            data-bs-toggle="collapse"
+            data-bs-target="#collapseExampleHtml"
+            aria-expanded="false"
+            aria-controls="collapseExampleHtml"
+          >
+            <span className="material-symbols-outlined me-1" aria-hidden="true">
+              navigate_next
+            </span>
+            Example for Google Tag Manager
+          </button>
+          <div className="collapse" id="collapseExampleHtml">
+            <PrismAsyncLight style={oneDark} language="javascript">
+              {`<iframe src="https://www.googletagmanager.com/ns.html?id=GTM-XXXXXXX"
+height="0"
+width="0"
+style="display:none;visibility:hidden"></iframe>`}
+            </PrismAsyncLight>
+          </div>
+
+          <AdminUpdateButtonRow
+            type="submit"
+            disabled={adminCustomizeContainer.state.retrieveError != null}
+          />
+        </form>
       </div>
-    </React.Fragment>
+    </div>
   );
-
 };
 
-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;

+ 58 - 55
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]);
@@ -36,58 +37,60 @@ const CustomizeSidebarsetting = (): JSX.Element => {
   const { isSidebarCollapsedMode } = data;
 
   return (
-    <React.Fragment>
-      <div className="row">
-        <div className="col-12">
-
-          <h2 className="admin-setting-header">{t('customize_settings.default_sidebar_mode.title')}</h2>
+    <div className="row">
+      <div className="col-12">
+        <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">
-              {t('customize_settings.default_sidebar_mode.desc')}
-            </CardBody>
-          </Card>
+        <Card className="card custom-card bg-body-tertiary my-3">
+          <CardBody className="px-0 py-2">
+            {t('customize_settings.default_sidebar_mode.desc')}
+          </CardBody>
+        </Card>
 
-          <div className="d-flex justify-content-around mt-5">
-            <div className="row row-cols-2">
-              <div className="col">
-                <div
-                  className={`card border border-4 ${isSidebarCollapsedMode ? 'border-primary' : ''}`}
-                  onClick={() => setIsSidebarCollapsedMode(true)}
-                  role="button"
-                >
-                  {/* 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>
-              <div className="col">
-                <div
-                  className={`card border border-4 ${!isSidebarCollapsedMode ? 'border-primary' : ''}`}
-                  onClick={() => setIsSidebarCollapsedMode(false)}
-                  role="button"
-                >
-                  {/* 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>
+        <div className="d-flex justify-content-around mt-5">
+          <div className="row row-cols-2">
+            <div className="col">
+              <button
+                type="button"
+                className={`card border border-4 ${isSidebarCollapsedMode ? 'border-primary' : ''}`}
+                onClick={() => setIsSidebarCollapsedMode(true)}
+                aria-pressed={isSidebarCollapsedMode}
+              >
+                {/* biome-ignore lint/performance/noImgElement: Ignore for SVG */}
+                <img src={collapsedIconFileName} alt="Collapsed Mode" />
+                <div className="card-body text-center">Collapsed Mode</div>
+              </button>
             </div>
-          </div>
-
-          <div className="row my-3">
-            <div className="mx-auto">
-              <button type="button" onClick={onClickSubmit} className="btn btn-primary">{ t('Update') }</button>
+            <div className="col">
+              <button
+                type="button"
+                className={`card border border-4 ${!isSidebarCollapsedMode ? 'border-primary' : ''}`}
+                onClick={() => setIsSidebarCollapsedMode(false)}
+                aria-pressed={!isSidebarCollapsedMode}
+              >
+                {/* biome-ignore lint/performance/noImgElement: Ignore for SVG */}
+                <img src={dockIconFileName} alt="Dock Mode" />
+                <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>
+          </div>
         </div>
       </div>
-    </React.Fragment>
+    </div>
   );
 };
 

+ 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 - 23
apps/app/src/client/components/Admin/Customize/CustomizeThemeSetting.tsx

@@ -1,24 +1,14 @@
-import React, {
-  useCallback, useEffect, useState, type JSX,
-} from 'react';
-
+import { 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 = {
-}
-
-// eslint-disable-next-line @typescript-eslint/no-unused-vars
-const CustomizeThemeSetting = (props: Props): JSX.Element => {
+const CustomizeThemeSetting = (): JSX.Element => {
   const { t } = useTranslation();
 
   const { data, error, update } = useSWRxGrowiThemeSetting();
@@ -32,7 +22,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 +33,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>
   );

+ 90 - 62
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 { 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,64 +24,98 @@ 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>
-        </div>
+    <div className="row">
+      <div className="col-12">
+        <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">
+            <p
+              // biome-ignore lint/security/noDangerouslySetInnerHtml: trusted translation markup
+              dangerouslySetInnerHTML={{
+                __html: t('admin:customize_settings.custom_title_detail'),
+              }}
+            />
+            <ul>
+              <li>
+                <span
+                  // biome-ignore lint/security/noDangerouslySetInnerHtml: trusted translation markup
+                  dangerouslySetInnerHTML={{
+                    __html: t(
+                      'admin:customize_settings.custom_title_detail_placeholder1',
+                    ),
+                  }}
+                />
+              </li>
+              <li>
+                <span
+                  // biome-ignore lint/security/noDangerouslySetInnerHtml: trusted translation markup
+                  dangerouslySetInnerHTML={{
+                    __html: t(
+                      'admin:customize_settings.custom_title_detail_placeholder2',
+                    ),
+                  }}
+                />
+              </li>
+              <li>
+                <span
+                  // biome-ignore lint/security/noDangerouslySetInnerHtml: trusted translation markup
+                  dangerouslySetInnerHTML={{
+                    __html: t(
+                      'admin:customize_settings.custom_title_detail_placeholder3',
+                    ),
+                  }}
+                />
+              </li>
+            </ul>
+          </CardBody>
+        </Card>
+      </div>
 
+      {/* 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>
+        <br />
+        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">
-          <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') }} />
-              <ul>
-                <li>
-                  <span dangerouslySetInnerHTML={{ __html: t('admin:customize_settings.custom_title_detail_placeholder1') }} />
-                </li>
-                <li>
-                  <span dangerouslySetInnerHTML={{ __html: t('admin:customize_settings.custom_title_detail_placeholder2') }} />
-                </li>
-                <li>
-                  <span dangerouslySetInnerHTML={{ __html: t('admin:customize_settings.custom_title_detail_placeholder3') }} />
-                </li>
-              </ul>
-              {/* eslint-enable react/no-danger */}
-            </CardBody>
-          </Card>
+          <input className="form-control" {...register('customizeTitle')} />
         </div>
-
-        {/* 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>
-          <br />
-          Default Output Example: <code className="xml">&lt;title&gt;Page name - My GROWI&lt;&#047;title&gt;</code>
+        <div className="col-12">
+          <AdminUpdateButtonRow type="submit" disabled={false} />
         </div>
-        <form onSubmit={handleSubmit(onSubmit)}>
-          <div className="col-12">
-            <input
-              className="form-control"
-              {...register('customizeTitle')}
-            />
-          </div>
-          <div className="col-12">
-            <AdminUpdateButtonRow type="submit" disabled={false} />
-          </div>
-        </form>
-      </div>
-    </React.Fragment>
+      </form>
+    </div>
   );
 };

+ 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
 

部分文件因为文件数量过多而无法显示